Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -81,6 +81,15 @@ struct Args {
/// instead of widening to the built-in default catalog.
#[arg(long)]
require_explicit_toolset: bool,
/// Trust project-scoped LSP servers from `<repo>/.grok/lsp.json`.
/// Defaults off; sandbox opts in only after workspace trust is established.
#[arg(
long,
env = "GROK_WORKSPACE_PROJECT_LSP_TRUSTED",
default_value_t = false,
action = clap::ArgAction::Set,
)]
project_lsp_trusted: bool,
/// Confine `x.ai/fs/*` resolution to the workspace root (reject `..`,
/// absolute-outside-root, symlink escapes). On by default: the standalone
/// server always backs a remote-sandbox workspace, a real tenant boundary.
@ -338,7 +347,6 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
} else {
None
};
let project_lsp_trusted = true;
let preview_scrape_interval = status_config.preview_activity_scrape_interval;
xai_grok_workspace::init_metrics();
let ws_handle = xai_grok_workspace::handle::connect_local_workspace(
@ -351,7 +359,7 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
args.allow_insecure_ws,
status_config,
args.upload_queue_enabled,
project_lsp_trusted,
args.project_lsp_trusted,
Some(diag_handle.clone()),
args.require_explicit_toolset,
args.confine_fs_to_workspace_root,
@ -448,6 +456,15 @@ mod tests {
assert!(args.capabilities);
}
#[test]
fn project_lsp_trust_defaults_off_and_is_opt_in() {
unsafe { std::env::remove_var("GROK_WORKSPACE_PROJECT_LSP_TRUSTED") };
let args = Args::try_parse_from(["xai-workspace-server"]).unwrap();
assert!(!args.project_lsp_trusted);
let args = Args::try_parse_from(["xai-workspace-server", "--project-lsp-trusted", "true"])
.unwrap();
assert!(args.project_lsp_trusted);
}
#[test]
fn capabilities_manifest_shape() {
let value = serde_json::to_value(CAPABILITIES).unwrap();
assert_eq!(value, serde_json::json!({ "diag" : true }));

View file

@ -77,17 +77,6 @@ pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
files
.into_iter()
.map(|mut file| {
// Strip rules-file YAML frontmatter so it does not leak as raw YAML (matches grok-build render).
if file.file_path.contains("/.grok/rules/")
|| file.file_path.contains("/.claude/rules/")
{
file.content = xai_grok_tools::implementations::skills::skill::extract_skill_body(
&file.content,
);
}
file
})
.filter_map(|file| match serde_json::to_value(&file) {
Ok(v) => Some(v),
Err(e) => {
@ -362,9 +351,9 @@ mod tests {
// Discovery also scans the real `~/.grok`, so fixtures use test-unique names.
#[tokio::test]
async fn discover_agents_md_strips_rules_frontmatter() {
async fn discover_agents_md_receives_normalized_rule_content() {
let tmp = tempfile::tempdir().unwrap();
let rules_dir = tmp.path().join(".grok").join("rules");
let rules_dir = tmp.path().join(".cursor").join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(
rules_dir.join("xyzzy-discover-agents-md-test.md"),
@ -378,7 +367,7 @@ mod tests {
.find(|f| {
f["file_path"]
.as_str()
.is_some_and(|p| p.ends_with("/.grok/rules/xyzzy-discover-agents-md-test.md"))
.is_some_and(|p| p.ends_with("/.cursor/rules/xyzzy-discover-agents-md-test.md"))
})
.expect("should discover the rules file");
let content = rule["content"].as_str().unwrap();

View file

@ -233,8 +233,8 @@ pub fn persist_trust(store: &mut TrustStore, key: &Path) {
}
}
/// Whether any repo-local code-exec config is present for `cwd`. When none are
/// present there is nothing to gate, so we skip the prompt entirely.
/// Whether any repo-local trust-sensitive config is present for `cwd`. When none
/// are present there is nothing to gate, so we skip the prompt entirely.
///
/// Thin wrapper over [`collect_repo_config_kinds`] with `first_only = true`, so
/// the gate and the display-only [`repo_config_kinds`] enumerate the EXACT same
@ -244,12 +244,13 @@ pub fn repo_configs_present(cwd: &Path) -> bool {
!collect_repo_config_kinds(cwd, true).is_empty()
}
/// Display-only: which repo-local code-exec config KINDS are present for `cwd`
/// (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`), deduped in
/// cheap→expensive marker order. Single source with [`repo_configs_present`]
/// (which is `!repo_config_kinds(cwd).is_empty()`), so a folder that the gate
/// fired on always has a non-empty, accurate kind list — no `[plugins].paths` /
/// `.claude` / `.grok/agents` / subdir-launch gaps. NOT itself the trust gate.
/// Display-only: which repo-local trust-sensitive config KINDS are present for
/// `cwd` (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`, `roles`,
/// `personas`), deduped in cheap→expensive marker order. Single source with
/// [`repo_configs_present`] (which is `!repo_config_kinds(cwd).is_empty()`), so a
/// folder that the gate fired on always has a non-empty, accurate kind list — no
/// `[plugins].paths` / `.claude` / `.grok/agents` / subdir-launch gaps. NOT
/// itself the trust gate.
pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
collect_repo_config_kinds(cwd, false)
}
@ -262,6 +263,14 @@ fn path_present_or_uncertain(path: &Path) -> bool {
}
}
fn directory_present_or_uncertain(path: &Path) -> bool {
match std::fs::metadata(path) {
Ok(metadata) => metadata.is_dir(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => true,
}
}
/// Shared scanner behind [`repo_configs_present`] and [`repo_config_kinds`]. With
/// `first_only` it returns immediately after the first marker (the gate's
/// historical short-circuit); otherwise it collects every distinct kind.
@ -376,6 +385,14 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str>
if !xai_grok_agent::discovery::project_agent_dirs_in(&chain.dirs).is_empty() {
hit!("agents");
}
// Presence matches exact-cwd discovery without parsing repository content.
let grok = cwd.join(".grok");
if directory_present_or_uncertain(&grok.join("roles")) {
hit!("roles");
}
if directory_present_or_uncertain(&grok.join("personas")) {
hit!("personas");
}
// `~/.claude.json` `projects.<cwd>.mcpServers`.
if claude_project_mcp_present(cwd) {
hit!("mcp");
@ -595,6 +612,64 @@ mod tests {
assert!(repo_configs_present(&subdir));
}
#[test]
fn repo_configs_present_detects_project_roles() {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".grok").join("roles")).unwrap();
assert!(repo_configs_present(tmp.path()));
assert!(repo_config_kinds(tmp.path()).contains(&"roles"));
}
#[test]
fn repo_configs_present_detects_project_personas() {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".grok").join("personas")).unwrap();
assert!(repo_configs_present(tmp.path()));
assert!(repo_config_kinds(tmp.path()).contains(&"personas"));
}
#[test]
fn project_subagent_marker_regular_file_is_absent() {
let tmp = repo_tmp();
let grok = tmp.path().join(".grok");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("roles"), "not a directory").unwrap();
assert!(!repo_configs_present(tmp.path()));
}
#[test]
fn project_subagent_marker_at_repo_root_is_absent_from_subdir() {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".grok/roles")).unwrap();
let subdir = tmp.path().join("nested");
std::fs::create_dir_all(&subdir).unwrap();
assert!(!repo_configs_present(&subdir));
}
#[cfg(unix)]
#[test]
fn project_subagent_marker_symlink_to_directory_is_present() {
let tmp = repo_tmp();
let target = tmp.path().join("target-roles");
let grok = tmp.path().join(".grok");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&grok).unwrap();
std::os::unix::fs::symlink(&target, grok.join("roles")).unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[cfg(unix)]
#[test]
fn dangling_project_subagent_marker_is_absent() {
let tmp = repo_tmp();
let grok = tmp.path().join(".grok");
std::fs::create_dir_all(&grok).unwrap();
std::os::unix::fs::symlink("missing", grok.join("personas")).unwrap();
assert!(!repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_claude_settings_from_subdir() {
// A `.claude/settings.json` `env` in a SUBDIR (no other repo config),

View file

@ -2697,6 +2697,9 @@ impl WorkspaceHandle {
pub fn session_ids(&self) -> Vec<String> {
self.shared.sessions.read().keys().cloned().collect()
}
pub fn session_count(&self) -> usize {
self.shared.sessions.read().len()
}
/// Fork a new subagent session. Clones (not references) the parent's
/// tool config and env. Enforces capability subset and fork budget.
///

View file

@ -13,7 +13,7 @@ use xai_computer_hub_mcp_adapter::{
};
use xai_computer_hub_sdk::ToolServerHandler;
use xai_grok_mcp::rmcp;
use xai_grok_mcp::servers::McpClient;
use xai_grok_mcp::servers::{McpClient, parse_mcp_qualified_name};
use xai_tool_protocol::ToolId;
use xai_tool_runtime::{ToolCallContext, ToolStream, TypedToolOutput};
use xai_tool_types::ToolDescription;
@ -143,15 +143,14 @@ pub(crate) struct QualifiedMcpToolHandler {
}
impl QualifiedMcpToolHandler {
/// Returns `None` if the qualified name is not a valid `ToolId`.
/// Returns `None` if the qualified name is invalid or ambiguous.
pub fn try_new(qualified_name: String, inner: Arc<McpToolHandler>) -> Option<Self> {
let qualified_id = match ToolId::new(&qualified_name) {
Ok(id) => id,
Err(err) => {
let qualified_id = match parse_mcp_qualified_name(&qualified_name) {
Some((id, _, _)) => id,
None => {
tracing::warn!(
qualified_name = %qualified_name,
error = %err,
"skipping MCP tool: qualified name is not a valid ToolId"
qualified_name,
"skipping MCP tool: qualified name is invalid or ambiguous"
);
return None;
}
@ -218,3 +217,60 @@ pub(crate) fn make_bridge_config(
namespace: Some(server_name.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use xai_computer_hub_mcp_adapter::{McpBridge, McpError};
use xai_tool_protocol::SessionId;
struct TestTransport;
#[async_trait]
impl McpTransport for TestTransport {
async fn initialize(&self) -> Result<McpServerInfo, McpError> {
Ok(McpServerInfo {
name: "test".to_owned(),
version: "1".to_owned(),
capabilities: Value::Null,
})
}
async fn list_tools(&self) -> Result<Vec<McpToolDefinition>, McpError> {
Ok(vec![McpToolDefinition {
name: "tool".to_owned(),
description: None,
input_schema: None,
}])
}
async fn call_tool(
&self,
_name: &str,
_arguments: Value,
) -> Result<McpCallResult, McpError> {
unreachable!("constructor test does not call the tool")
}
async fn close(&self) -> Result<(), McpError> {
Ok(())
}
}
#[tokio::test]
async fn qualified_handler_rejects_ambiguous_name() {
let bridge = McpBridge::connect(
Arc::new(TestTransport),
&make_bridge_config(SessionId::new("session").unwrap(), "test"),
)
.await
.unwrap()
.bridge;
let inner = bridge.handlers()[0].clone();
let valid = QualifiedMcpToolHandler::try_new("123__lookup".to_owned(), inner.clone())
.expect("valid qualified ToolId");
assert_eq!(valid.tool_id().as_str(), "123__lookup");
assert!(QualifiedMcpToolHandler::try_new("foo___bar".to_owned(), inner).is_none());
}
}

View file

@ -14,16 +14,19 @@ use crate::permission::bash_command_splitting::{
use crate::permission::policy::CompiledPolicy;
use crate::permission::prompter::{AcpPrompter, PromptOutcome};
use crate::permission::shell_access::{
combine_decisions, command_write_paths_in_tree, is_safe_write_sink,
combine_decisions, command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink,
};
use crate::permission::state::{PermissionState, load_state_from_disk, persist_state};
use crate::permission::types::{
AccessKind, ClientType, Decision, EditPolicy, PermissionCommand, PermissionEvent, PromptPolicy,
AccessKind, ClientType, Decision, EditPathContext, EditPolicy, PermissionCommand,
PermissionEvent, PromptPolicy,
};
use xai_grok_mcp::servers::parse_mcp_qualified_name;
use xai_grok_paths::AbsPathBuf;
use xai_grok_tools::implementations::grok_build::web_fetch::{
DomainMatcher, domain::normalize_domain,
};
use xai_grok_tools::types::resources::resolve_model_path;
/// Canonical `decision_reason` triggers for the uploaded artifact. Single source
/// so the emit sites can't drift or misspell (the field doc lists these values).
@ -97,17 +100,11 @@ pub enum PermissionHandle {
AllowAll,
}
/// True iff `name` is an MCP tool whose server prefix (everything before the
/// first `__`) is in `servers`. The empty-prefix guard rejects corrupt entries
/// such as `{""}` or names like `"__tool"`.
/// True iff `name` is a valid qualified MCP ID whose server is in `servers`.
/// Malformed names fail closed, including `{""}` or names like `"__tool"`.
fn mcp_server_prefix_allowed(name: &str, servers: &HashSet<String>) -> bool {
if servers.is_empty() {
return false;
}
let Some((server, _)) = name.split_once("__") else {
return false;
};
!server.is_empty() && servers.contains(server)
!servers.is_empty()
&& parse_mcp_qualified_name(name).is_some_and(|(_, server, _)| servers.contains(server))
}
/// Pre-decision lookup for an MCP tool. Returns `Some(Decision::Allow)`
@ -669,6 +666,28 @@ impl PermissionHandle {
session_id: Option<String>,
subagent_type: Option<String>,
subagent_description: Option<String>,
) -> Decision {
self.request_with_edit_path_context(
access,
tool_call_update,
None,
session_id,
subagent_type,
subagent_description,
)
.await
}
/// Request permission with the edit tool's per-session execution cwd.
/// Shared parent/subagent managers must use this for `AccessKind::Edit`.
pub async fn request_with_edit_path_context(
&self,
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
edit_path_context: Option<EditPathContext>,
session_id: Option<String>,
subagent_type: Option<String>,
subagent_description: Option<String>,
) -> Decision {
match self {
PermissionHandle::AllowAll => Decision::Allow,
@ -682,6 +701,7 @@ impl PermissionHandle {
let msg = PermissionCommand::Request {
access,
tool_call_update,
edit_path_context,
respond_to: tx,
session_id,
subagent_type,
@ -1112,6 +1132,7 @@ fn spawn_permission_manager_with_pin(
PermissionCommand::Request {
access,
tool_call_update,
edit_path_context,
mut respond_to,
session_id: request_session_id,
subagent_type: request_subagent_type,
@ -1213,6 +1234,23 @@ fn spawn_permission_manager_with_pin(
AccessKind::Bash(cmd) => Some(evaluate_bash(cmd, &state, true)),
_ => None,
};
let protected_edit = match (&access, edit_path_context.as_ref()) {
(AccessKind::Edit(path), Some(context)) => {
let resolved = resolve_model_path(
&context.real_cwd,
context.display_cwd.as_deref(),
path,
);
edit_target_requires_prompt(&resolved)
}
// Direct workspace callers predate per-request context and execute
// against the manager cwd; the shell always supplies context.
(AccessKind::Edit(path), None) => {
let resolved = resolve_model_path(cwd.as_path(), None, path);
edit_target_requires_prompt(&resolved)
}
_ => false,
};
// Evaluate managed policy (direct access + per-segment Bash command
// rules + Bash shell-file args) up front so the YOLO/sandbox fast
@ -1274,6 +1312,7 @@ fn spawn_permission_manager_with_pin(
// Ask floors fall through so managed Ask / shell-file Ask stay binding.
if !policy_forced_prompt
&& !shell_forced_prompt
&& !protected_edit
&& let Some((decision, reason)) = session_grant_pre_decision(
&access,
bash_evaluation.as_ref(),
@ -1307,7 +1346,8 @@ fn spawn_permission_manager_with_pin(
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
auto_mode_fast_path,
};
let needs_user = access_requires_user_interaction(&tool_name, &access);
let needs_user =
protected_edit || access_requires_user_interaction(&tool_name, &access);
let fast = auto_mode_fast_path(&access, &tool_name, needs_user);
match fast {
AutoFastPath::Allow => {
@ -1421,7 +1461,7 @@ fn spawn_permission_manager_with_pin(
// pre-decision match: a policy `Ask` rule on an MCP tool
// overrides the session allowlist and forces a re-prompt.
// Other access kinds keep their legacy fall-through behavior,
// subject to Bash request floors.
// subject to Bash request and protected-edit floors.
match policy_decision {
Some(Decision::Ask) => {
tracing::info!(
@ -1431,12 +1471,13 @@ fn spawn_permission_manager_with_pin(
);
}
Some(Decision::Allow)
if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
if protected_edit
|| bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
{
tracing::info!(
tool = ?tool_name,
source = "policy",
"permission policy allow deferred to Bash prompt floor"
"permission policy allow deferred to confirmation floor"
);
}
Some(decision) => {
@ -1490,7 +1531,7 @@ fn spawn_permission_manager_with_pin(
)
.map(|d| (d, reasons::PERSISTED_GRANT)),
AccessKind::Edit(_) => {
if allow_edits_for_session {
if allow_edits_for_session && !protected_edit {
Some((Decision::Allow, reasons::PERSISTED_GRANT))
} else {
match state.edit_policy {
@ -1757,20 +1798,15 @@ fn spawn_permission_manager_with_pin(
}
PromptOutcome::AllowAlwaysMcpServer(server_prefix) => {
// Derive the canonical server prefix from the current
// AccessKind via `split_once("__")`. Validate the
// client-supplied prefix against it; on mismatch (or
// empty / no separator), downgrade to tool-scope using
// the access-kind name. This prevents a buggy or
// malicious client from whitelisting an unrelated
// server.
// AccessKind and validate the client-supplied prefix
// against it. On mismatch or malformed input, downgrade
// to tool-scope using the access-kind name.
if let AccessKind::MCPTool {
name: access_name, ..
} = &access
{
let canonical = access_name
.split_once("__")
.map(|(s, _)| s)
.filter(|s| !s.is_empty());
let canonical = parse_mcp_qualified_name(access_name)
.map(|(_, server, _)| server);
match canonical {
Some(canonical) if canonical == server_prefix => {
state
@ -1784,10 +1820,9 @@ fn spawn_permission_manager_with_pin(
persist_state(&cwd, &state, client_id_ref).await;
}
_ => {
// Mismatch, empty prefix, or no `__` separator
// in the access name. Defensively downgrade to
// tool-scope on the access-kind name so the
// user is not re-prompted, but the blast
// Mismatch or malformed access name. Defensively
// downgrade to tool-scope on the access-kind name
// so the user is not re-prompted, but the blast
// radius is the smaller scope they actually
// saw.
tracing::warn!(
@ -2115,6 +2150,74 @@ mod tests {
.await;
}
#[tokio::test]
async fn session_edit_grant_excludes_protected_target() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let tmp = tempfile::tempdir().unwrap();
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
let transport = fake_hub(serde_json::json!({ "outcome": "always_approve" }));
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
for path in ["src/first.rs", "src/second.rs", "~/.zshrc"] {
assert_eq!(
mgr.request(AccessKind::Edit(path.into()), tool_call(), None, None, None)
.await,
Decision::Allow
);
}
assert_eq!(transport.seen.lock().unwrap().len(), 2);
})
.await;
}
#[tokio::test]
#[cfg(unix)]
async fn shared_manager_uses_request_edit_path_context() {
use std::os::unix::fs::symlink;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let parent = tempfile::tempdir().unwrap();
let child = tempfile::tempdir().unwrap();
let display = tempfile::tempdir().unwrap();
symlink("/etc", child.path().join("link")).unwrap();
let parent_cwd = AbsPathBuf::new(parent.path().to_path_buf()).unwrap();
let transport = fake_hub(serde_json::json!({ "outcome": "approve" }));
let (mgr, _events) = test_manager_with_hub(&parent_cwd, transport.clone());
mgr.set_auto_mode(true);
let context = EditPathContext {
real_cwd: child.path().to_path_buf(),
display_cwd: Some(display.path().to_path_buf()),
};
for displayed in [
display.path().join("link/hosts"),
display.path().join("src.rs"),
] {
assert_eq!(
mgr.request_with_edit_path_context(
AccessKind::Edit(displayed.to_string_lossy().into_owned()),
tool_call(),
Some(context.clone()),
None,
None,
None,
)
.await,
Decision::Allow
);
}
assert_eq!(
transport.seen.lock().unwrap().len(),
1,
"child protected target prompts; ordinary displayed child path stays auto"
);
})
.await;
}
#[tokio::test]
async fn hub_permission_reject_aborts() {
let local = tokio::task::LocalSet::new();
@ -2216,6 +2319,64 @@ mod tests {
.await;
}
#[tokio::test]
async fn ambiguous_mcp_server_scope_downgrades_to_exact_persisted_grant() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
for (name, forged_server) in [("a__b__c", "a"), ("foo___bar", "foo")] {
let tmp = tempfile::tempdir().unwrap();
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
let transport = fake_hub(serde_json::json!({
"outcome": "always_approve",
"scope": { "kind": "server_prefix", "value": forged_server },
}));
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
let decision = mgr
.request(
AccessKind::MCPTool {
name: name.into(),
input: serde_json::Value::Null,
},
tool_call(),
None,
None,
None,
)
.await;
assert_eq!(decision, Decision::Allow);
let persisted = load_state_from_disk(&cwd, None).await;
assert!(persisted.allowed_mcp_servers.is_empty(), "{name}");
assert!(persisted.allowed_mcp_tools.contains(name), "{name}");
assert!(matches!(
mcp_pre_decision(name, &persisted, false, false),
Some(Decision::Allow)
));
let replay_transport = fake_hub(serde_json::json!({ "outcome": "reject" }));
let (reloaded, _e) = test_manager_with_hub(&cwd, replay_transport.clone());
assert_eq!(
reloaded
.request(
AccessKind::MCPTool {
name: name.into(),
input: serde_json::Value::Null,
},
tool_call(),
None,
None,
None,
)
.await,
Decision::Allow
);
assert!(replay_transport.seen.lock().unwrap().is_empty());
}
})
.await;
}
/// A managed `Ask` rule on a direct `Read`/`Grep` must reach the prompt, not
/// the unconditional auto-allow. With no responder wired, that surfaces as a
/// non-`Allow` decision; a non-ask read still auto-allows.
@ -3482,6 +3643,59 @@ mod tests {
.await;
}
#[tokio::test]
async fn protected_edit_floor_covers_auto_config_allow_and_dont_ask() {
use crate::permission::types::{PermissionRule, RuleAction, ToolFilter};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let mut auto = crate::permission::types::PermissionConfig::new(vec![]);
auto.prompt_policy = PromptPolicy::Auto;
let allow = crate::permission::types::PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Edit,
pattern: None,
pattern_mode: Default::default(),
}]);
let mut deny = crate::permission::types::PermissionConfig::new(vec![]);
deny.prompt_policy = PromptPolicy::Deny;
for (name, config, expected_prompts, policy_deny) in [
("auto", auto, 1, false),
("configured allow", allow, 1, false),
("dontAsk", deny, 0, true),
] {
let tmp = tempfile::tempdir().unwrap();
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
let client = RecordingClient::default();
let prompts = client.prompts.clone();
let (mgr, _events) = manager_with_recording_client(
&cwd,
Some(config),
client,
ClientType::Generic,
);
let decision = mgr
.request(
AccessKind::Edit("/etc/hosts".into()),
tool_call(),
None,
None,
None,
)
.await;
assert_eq!(prompts.borrow().len(), expected_prompts, "{name}");
if policy_deny {
assert!(matches!(decision, Decision::PolicyDeny(_)), "{name}");
} else {
assert!(matches!(decision, Decision::Reject(_)), "{name}");
}
}
})
.await;
}
#[test]
fn sandbox_auto_allow_respects_real_file_write_floor() {
let state = PermissionState::default();
@ -3558,6 +3772,7 @@ mod tests {
.send(PermissionCommand::Request {
access: AccessKind::Bash("curl http://example.com".into()),
tool_call_update: tool_call(),
edit_path_context: None,
respond_to: tx,
session_id: None,
subagent_type: None,
@ -3667,6 +3882,7 @@ mod tests {
.send(PermissionCommand::Request {
access: AccessKind::Bash("curl http://example.com".into()),
tool_call_update: tool_call(),
edit_path_context: None,
respond_to: tx,
session_id: None,
subagent_type: None,
@ -5139,10 +5355,13 @@ mod tests {
#[test]
fn server_prefix_match_allows() {
assert!(mcp_server_prefix_allowed(
"linear__list",
&servers(&["linear"])
));
for (name, server) in [
("linear__list", "linear"),
("123__lookup", "123"),
("server:scope__tool", "server:scope"),
] {
assert!(mcp_server_prefix_allowed(name, &servers(&[server])));
}
}
#[test]
@ -5151,8 +5370,24 @@ mod tests {
}
#[test]
fn no_separator_rejects() {
assert!(!mcp_server_prefix_allowed("linear", &servers(&["linear"])));
fn malformed_names_do_not_consume_server_grants() {
for (name, server) in [
("server__part__tool", "server"),
("server__tool__part", "server"),
("foo___bar", "foo"),
("foo___bar", "foo_"),
("foo____bar", "foo"),
("server__", "server"),
("server", "server"),
("__tool", ""),
("", ""),
("server__bad.tool", "server"),
] {
assert!(
!mcp_server_prefix_allowed(name, &servers(&[server])),
"unexpectedly allowed {name:?}"
);
}
}
#[test]
@ -5172,9 +5407,8 @@ mod tests {
}
#[test]
fn first_double_underscore_anchors_split() {
// "a__b__c" splits into ("a", "b__c"); server "a" matches.
assert!(mcp_server_prefix_allowed("a__b__c", &servers(&["a"])));
fn multiple_delimiters_do_not_inherit_first_segment_grant() {
assert!(!mcp_server_prefix_allowed("a__b__c", &servers(&["a"])));
}
#[test]
@ -5187,21 +5421,17 @@ mod tests {
));
}
#[test]
fn empty_tool_name_after_prefix_still_allowed() {
// The MCP server is responsible for rejecting empty tool names;
// the prefix match is what gates access.
assert!(mcp_server_prefix_allowed("foo__", &servers(&["foo"])));
}
#[test]
fn pre_decision_tool_grant_allows() {
let mut state = PermissionState::default();
state.allowed_mcp_tools.insert("linear__list".to_string());
assert!(matches!(
mcp_pre_decision("linear__list", &state, false, false),
Some(Decision::Allow)
));
state.allowed_mcp_tools.insert("a__b__c".to_string());
for name in ["linear__list", "a__b__c"] {
assert!(matches!(
mcp_pre_decision(name, &state, false, false),
Some(Decision::Allow)
));
}
}
#[test]
@ -5360,10 +5590,8 @@ mod tests {
.await;
}
/// Auto mode accepts ALL file edits via the fast path regardless of location
/// (the accept-all-edits product decision, no workspace restriction): both an
/// in-cwd edit and an absolute path clearly OUTSIDE cwd fast-path Allow. The
/// fast path is path-independent, so the target file need not exist.
/// Auto mode accepts ordinary file edits via the fast path regardless of
/// location (the accept-all-edits product decision, no workspace restriction).
#[tokio::test]
async fn auto_mode_edit_fast_path_allows() {
let local = tokio::task::LocalSet::new();
@ -5380,7 +5608,6 @@ mod tests {
)
};
// In-cwd edit → Allow (file need not exist).
let in_cwd = tmp.path().join("f.rs").to_string_lossy().into_owned();
let d = mgr
.request(AccessKind::Edit(in_cwd), mk("tc-edit-in"), None, None, None)
@ -5390,10 +5617,9 @@ mod tests {
"in-cwd edit under auto must fast-path allow, got {d:?}"
);
// Out-of-workspace absolute edit → Allow too (no workspace restriction).
let d = mgr
.request(
AccessKind::Edit("/etc/hosts".into()),
AccessKind::Edit("/tmp/out-of-ws.rs".into()),
mk("tc-edit-out"),
None,
None,

View file

@ -9,6 +9,7 @@ use crate::permission::{
use agent_client_protocol::{self as acp, Client as _};
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_file_utils::events::{Event, EventWriter, PermissionDecision};
use xai_grok_mcp::servers::parse_mcp_qualified_name;
use xai_grok_tools::implementations::grok_build::web_fetch::domain_from_url;
const REJECT_ONCE_LABEL: &str = "No, and tell Grok what to do differently";
@ -162,9 +163,8 @@ pub struct BashCommandSelectedTerms {
/// depend on it without dragging the full workspace or rmcp into each
/// other). Re-exported here for backward-compat with callers that historically
/// reached `xai_grok_workspace::permission::MCP_TOOL_NAME_DELIMITER`.
/// Validation in `into_registration` rejects MCP tools whose qualified name
/// contains more than one occurrence of this delimiter, so stripping it given
/// a trusted `server_prefix` is always unambiguous.
/// Model-callable MCP registration validates this delimiter before permission
/// handling, so stripping it given a trusted `server_prefix` is unambiguous.
pub use xai_grok_workspace_types::MCP_TOOL_NAME_DELIMITER;
/// Extract the action segment of a qualified MCP tool name using a
@ -222,15 +222,13 @@ pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> St
/// Display variant for callers that have only a qualified-or-raw tool
/// name string (e.g. activity titles from ACP `tool_call.fields.title`
/// or scrollback blocks that store the wire name verbatim). Splits on
/// the (validated-at-construction) `MCP_TOOL_NAME_DELIMITER`: if the
/// split succeeds the name is formatted as `"(Server) Action"` with
/// each segment title-cased; otherwise the input is returned unchanged
/// (no title-casing — the input may be a bash command, file path, or
/// other non-MCP text that the caller mustn't mangle).
/// or scrollback blocks that store the wire name verbatim). Valid qualified
/// names are formatted as `"(Server) Action"` with each segment title-cased;
/// otherwise the input is returned unchanged (no title-casing — the input may
/// be a bash command, file path, or other non-MCP text).
pub fn mcp_pretty_name_if_qualified(name: &str) -> String {
match name.split_once(MCP_TOOL_NAME_DELIMITER) {
Some((server, action)) => format!(
match parse_mcp_qualified_name(name) {
Some((_, server, action)) => format!(
"({}) {}",
mcp_titleize_segment(server),
mcp_titleize_segment(action)
@ -250,10 +248,9 @@ pub struct McpToolPermission {
/// Full tool name as the agent called it
/// (e.g. `"grok_com_notion__notion-fetch"`).
pub tool_name: String,
/// Server segment (everything before the single `__` separator,
/// e.g. `"grok_com_notion"`). `None` if the tool name has no `__`,
/// in which case the view hides the scope toggle and only offers
/// tool-scope.
/// Server component of a valid qualified MCP ID (e.g. `"grok_com_notion"`).
/// `None` for malformed or unqualified names, in which case the view hides
/// the scope toggle and only offers tool-scope.
pub server_prefix: Option<String>,
}
@ -277,7 +274,7 @@ impl McpToolPermission {
pub enum McpScopeSelection {
/// Whitelist exactly this tool name.
Tool { tool_name: String },
/// Whitelist every tool whose name starts with `<server>__`.
/// Whitelist the server component of the current valid qualified MCP ID.
Server { server: String },
}
@ -293,8 +290,8 @@ pub enum PromptOutcome {
AllowAlwaysDomain(String),
/// Persist this exact MCP tool name in `allowed_mcp_tools`.
AllowAlwaysMcpTool(String),
/// Persist this MCP server prefix (no trailing `__`) in
/// `allowed_mcp_servers`. An empty string is rejected by the manager.
/// Persist the current valid qualified MCP ID's server component in
/// `allowed_mcp_servers`; the manager rejects mismatched or malformed input.
AllowAlwaysMcpServer(String),
RejectOnce,
RejectAlwaysBashCommand(String),
@ -671,7 +668,8 @@ impl AcpPrompter {
ClientType::GrokTUI | ClientType::GrokPager | ClientType::Desktop => {
let mut options: IndexMap<acp::PermissionOptionId, acp::PermissionOption> =
IndexMap::new();
let server_prefix = tool_name.split_once("__").map(|(s, _)| s.to_owned());
let server_prefix = parse_mcp_qualified_name(tool_name)
.map(|(_, server, _)| server.to_owned());
options.insert(
acp::PermissionOptionId::new("allow-always-mcp"),
acp::PermissionOption::new(
@ -1169,37 +1167,46 @@ mod tests {
#[test]
fn mcp_prompt_includes_allow_always_with_meta() {
let p = prompter(ClientType::GrokTUI);
let access = AccessKind::MCPTool {
name: "linear__list".to_owned(),
input: serde_json::Value::Null,
};
let opts = p.build_options(&access);
let opt = opts
.get(&acp::PermissionOptionId::new("allow-always-mcp"))
.expect("allow-always-mcp option missing");
let meta = opt.meta.clone().expect("meta missing");
let perm: McpToolPermission =
serde_json::from_value(serde_json::Value::Object(meta)).unwrap();
assert_eq!(perm.tool_name, "linear__list");
assert_eq!(perm.server_prefix.as_deref(), Some("linear"));
assert_eq!(perm.prompt_prefix, "Always allow:");
for (name, server) in [
("linear__list", "linear"),
("123__lookup", "123"),
("server:scope__tool", "server:scope"),
] {
let access = AccessKind::MCPTool {
name: name.to_owned(),
input: serde_json::Value::Null,
};
let opts = p.build_options(&access);
let opt = opts
.get(&acp::PermissionOptionId::new("allow-always-mcp"))
.expect("allow-always-mcp option missing");
let meta = opt.meta.clone().expect("meta missing");
let perm: McpToolPermission =
serde_json::from_value(serde_json::Value::Object(meta)).unwrap();
assert_eq!(perm.tool_name, name);
assert_eq!(perm.server_prefix.as_deref(), Some(server));
assert_eq!(perm.prompt_prefix, "Always allow:");
}
}
#[test]
fn mcp_prompt_no_separator_hides_server_scope() {
fn mcp_prompt_malformed_name_hides_server_scope() {
let p = prompter(ClientType::GrokPager);
let access = AccessKind::MCPTool {
name: "standalone".to_owned(),
input: serde_json::Value::Null,
};
let opts = p.build_options(&access);
let opt = opts
.get(&acp::PermissionOptionId::new("allow-always-mcp"))
.unwrap();
let perm: McpToolPermission =
serde_json::from_value(serde_json::Value::Object(opt.meta.clone().unwrap())).unwrap();
assert_eq!(perm.tool_name, "standalone");
assert_eq!(perm.server_prefix, None);
for name in ["standalone", "linear__shadow__exfil", "linear__"] {
let access = AccessKind::MCPTool {
name: name.to_owned(),
input: serde_json::Value::Null,
};
let opts = p.build_options(&access);
let opt = opts
.get(&acp::PermissionOptionId::new("allow-always-mcp"))
.unwrap();
let perm: McpToolPermission =
serde_json::from_value(serde_json::Value::Object(opt.meta.clone().unwrap()))
.unwrap();
assert_eq!(perm.tool_name, name);
assert_eq!(perm.server_prefix, None);
}
}
#[test]
@ -1317,11 +1324,20 @@ mod tests {
mcp_pretty_name_if_qualified("linear__list_issues"),
"(Linear) List Issues"
);
assert_eq!(mcp_pretty_name_if_qualified("123__lookup"), "(123) Lookup");
assert_eq!(
mcp_pretty_name_if_qualified("server:scope__tool"),
"(Server:scope) Tool"
);
// Non-qualified input (e.g. a bash command, file path, or any
// string without `__`) is returned UNCHANGED — must not
// title-case or mangle non-MCP strings.
assert_eq!(mcp_pretty_name_if_qualified("read_file"), "read_file");
assert_eq!(mcp_pretty_name_if_qualified("cargo test"), "cargo test");
assert_eq!(
mcp_pretty_name_if_qualified("linear__shadow__exfil"),
"linear__shadow__exfil"
);
assert_eq!(mcp_pretty_name_if_qualified(""), "");
}

View file

@ -230,6 +230,84 @@ pub(crate) fn is_safe_write_sink(path: &str) -> bool {
matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr")
}
/// Whether an already-resolved direct edit target needs explicit confirmation.
///
/// The caller uses the edit tools' shared model-path resolver first. This helper
/// preserves its uncollapsed components for physical symlink + `..` resolution,
/// while checking a separate lexical normalization for traversal aliases.
pub(crate) fn edit_target_requires_prompt(path: &Path) -> bool {
if !path.is_absolute() {
return true;
}
let lexical = xai_grok_paths::normalize_lexically(path);
if protected_edit_path(&lexical) {
return true;
}
let Some(resolved) = resolve_following_symlinks(path, 0) else {
return true;
};
protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc"))
}
fn protected_edit_path(path: &Path) -> bool {
let components: Vec<String> = path
.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_string_lossy().to_ascii_lowercase()),
_ => None,
})
.collect();
let string_components: Vec<&str> = components.iter().map(String::as_str).collect();
let file = string_components.last().copied().unwrap_or("");
const STARTUP_FILES: &[&str] = &[
".bashrc",
".bash_profile",
".bash_login",
".bash_logout",
".profile",
".zshrc",
".zshenv",
".zprofile",
".zlogin",
".zlogout",
".kshrc",
".cshrc",
".tcshrc",
".login",
".logout",
".inputrc",
".xprofile",
];
STARTUP_FILES.contains(&file)
|| protected_git_hooks_path(&string_components)
|| string_components.contains(&".ssh")
|| string_components.ends_with(&[".grok", "config.toml"])
|| path == Path::new("/etc")
|| path.starts_with(Path::new("/etc"))
}
fn protected_git_hooks_path(components: &[&str]) -> bool {
components.windows(2).any(|pair| pair == [".git", "hooks"])
|| components.iter().enumerate().any(|(git, component)| {
*component == ".git"
&& components.get(git + 1) == Some(&"modules")
&& components[git + 2..]
.iter()
.skip(1)
.any(|component| *component == "hooks")
})
}
/// `resolved_path` is already physical; resolve `root` so platform aliases such
/// as macOS `/etc -> /private/etc` compare in the same namespace. Resolution
/// failure is conservative: the caller then requires confirmation.
fn resolved_path_is_within_root(resolved_path: &Path, root: &Path) -> bool {
resolve_following_symlinks(root, 0)
.map(|resolved_root| resolved_path.starts_with(resolved_root))
.unwrap_or(true)
}
#[derive(Clone, Copy)]
pub(crate) enum ShellFileMode {
Read,
@ -738,8 +816,8 @@ fn resolve_symlink_target(absolute: &str) -> Option<String> {
/// Resolve `path` following every symlink, including a *dangling* final link
/// (which `canonicalize` alone rejects) and not-yet-existing trailing
/// components. Depth-bounded against cycles; any fs error yields `None`.
/// Blocking fs syscalls; runs per operand when file rules exist.
/// components. Depth-bounded against cycles; unexpected fs errors yield `None`.
/// Blocking fs syscalls; runs for shell operands under file rules and direct edits.
fn resolve_following_symlinks(path: &Path, depth: usize) -> Option<PathBuf> {
const MAX_SYMLINK_DEPTH: usize = 40;
if depth > MAX_SYMLINK_DEPTH {
@ -750,13 +828,17 @@ fn resolve_following_symlinks(path: &Path, depth: usize) -> Option<PathBuf> {
return Some(canonical);
}
// Resolve the parent, then the final component, so a dangling/new leaf still follows.
// Missing components are valid new paths; other metadata errors fail closed.
let parent = path.parent()?;
let file_name = path.file_name()?;
let resolved_parent = resolve_following_symlinks(parent, depth + 1)?;
let candidate = resolved_parent.join(file_name);
if let Ok(meta) = std::fs::symlink_metadata(&candidate)
&& meta.file_type().is_symlink()
{
let metadata = match std::fs::symlink_metadata(&candidate) {
Ok(metadata) => Some(metadata),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => return None,
};
if metadata.is_some_and(|metadata| metadata.file_type().is_symlink()) {
// A symlink must be followed; if it can't be read, treat the whole path
// as unresolved (`None`) rather than returning the link's own path.
let target = std::fs::read_link(&candidate).ok()?;
@ -818,6 +900,111 @@ mod tests {
std::path::Path::new("/work")
}
#[test]
fn sensitive_edit_targets_and_lexical_aliases_prompt() {
for path in [
"/home/user/.zshrc",
"/etc",
"/etc/grok-test",
"/work/subdir/../.git/hooks/pre-commit",
] {
assert!(
edit_target_requires_prompt(Path::new(path)),
"protected edit target must prompt: {path}"
);
}
for path in [
"/work/src/main.rs",
"/work/project/.grok/config.toml/backup",
] {
assert!(
!edit_target_requires_prompt(Path::new(path)),
"ordinary edit target should not prompt: {path}"
);
}
}
#[test]
fn sensitive_edit_targets_include_submodule_hooks() {
for path in [
"/work/.git/modules/foo/hooks/pre-commit",
"/work/.git/modules/submodules/sglang-private/hooks/pre-commit",
"/work/.git/modules/outer/modules/inner/hooks/pre-commit",
"/work/subdir/../.git/modules/foo/hooks/pre-commit",
] {
assert!(
edit_target_requires_prompt(Path::new(path)),
"submodule hook target must prompt: {path}"
);
}
for path in [
"/work/.git/modules/hooks/pre-commit",
"/work/.git/module/foo/hooks/pre-commit",
"/work/.git/modules/foo/hook/pre-commit",
"/work/.git/modules/foo/hooks-disabled/pre-commit",
"/work/src/modules/foo/hooks/pre-commit",
] {
assert!(
!edit_target_requires_prompt(Path::new(path)),
"non-hook control must not prompt: {path}"
);
}
}
#[test]
#[cfg(unix)]
fn sensitive_edit_targets_follow_symlinks() {
use std::os::unix::fs::symlink;
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let startup = outside.path().join(".zshrc");
std::fs::write(&startup, b"").unwrap();
symlink(&startup, ws.path().join("file-link")).unwrap();
std::fs::create_dir_all(outside.path().join(".git/hooks")).unwrap();
symlink(
outside.path().join(".git/hooks"),
ws.path().join("hooks-link"),
)
.unwrap();
std::fs::create_dir_all(outside.path().join(".git/modules/foo/hooks")).unwrap();
symlink(
outside.path().join(".git/modules/foo/hooks"),
ws.path().join("module-hooks-link"),
)
.unwrap();
for path in [
ws.path().join("file-link"),
ws.path().join("hooks-link/new-hook"),
ws.path().join("module-hooks-link/new-hook"),
] {
assert!(
edit_target_requires_prompt(&path),
"symlinked protected edit target must prompt: {}",
path.display()
);
}
}
#[test]
fn resolved_root_alias_matches_physical_destination() {
let resolved_root = resolve_following_symlinks(Path::new("/etc"), 0).unwrap();
assert!(resolved_path_is_within_root(
&resolved_root.join("grok-test"),
Path::new("/etc")
));
assert!(!resolved_path_is_within_root(
Path::new("/tmp/grok-test"),
Path::new("/etc")
));
}
#[test]
#[cfg(target_os = "macos")]
fn private_etc_alias_requires_prompt() {
assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts")));
}
#[test]
#[cfg(unix)]
fn resolved_symlink_target_hits_read_deny() {

View file

@ -6,7 +6,9 @@ use std::collections::HashSet;
use xai_grok_paths::AbsPathBuf;
use xai_grok_tools::util::grok_home::grok_home;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
const VALIDATED_MCP_SERVER_GRANTS_VERSION: i64 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PermissionState {
pub edit_policy: EditPolicy,
@ -19,11 +21,47 @@ pub struct PermissionState {
/// Exact MCP tool names (e.g. `"grok_com_notion__notion-fetch"`)
/// the user has granted "always allow" for. Lookup is exact.
pub allowed_mcp_tools: HashSet<String>,
/// MCP server prefixes (everything before the first `__`,
/// e.g. `"grok_com_notion"`) for which the user has granted
/// "always allow" to every tool. Lookup is "tool name starts with
/// `<prefix>__`".
/// Server components of valid qualified MCP IDs (e.g. `"grok_com_notion"`)
/// for which the user has granted "always allow" to every tool. Lookup
/// validates and parses the complete qualified ID before matching.
pub allowed_mcp_servers: HashSet<String>,
/// Version proving server-wide grants were minted from validated qualified IDs.
/// Missing or malformed markers are legacy; future integer versions are preserved.
#[serde(
default = "legacy_mcp_server_grants_version",
deserialize_with = "deserialize_mcp_server_grants_version"
)]
pub(crate) validated_mcp_server_grants_version: i64,
}
fn legacy_mcp_server_grants_version() -> i64 {
0
}
fn deserialize_mcp_server_grants_version<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
Ok(match value.as_integer() {
Some(version) if version >= 0 => version,
_ => 0,
})
}
impl Default for PermissionState {
fn default() -> Self {
Self {
edit_policy: EditPolicy::default(),
allow_bash_execute: false,
allowed_bash_commands: HashSet::new(),
disallowed_bash_commands: HashSet::new(),
allowed_web_fetch_domains: HashSet::new(),
allowed_mcp_tools: HashSet::new(),
allowed_mcp_servers: HashSet::new(),
validated_mcp_server_grants_version: VALIDATED_MCP_SERVER_GRANTS_VERSION,
}
}
}
fn state_dir_for_cwd(cwd: &AbsPathBuf) -> std::path::PathBuf {
@ -49,9 +87,23 @@ fn state_file_path(dir: &std::path::Path, client_identifier: Option<&str>) -> st
}
}
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
async fn try_load_state_with_writer<F>(path: &std::path::Path, writer: F) -> Option<PermissionState>
where
F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static,
{
match tokio::fs::read_to_string(path).await {
Ok(s) => Some(toml::from_str(&s).unwrap_or_default()),
Ok(s) => {
let mut state: PermissionState = toml::from_str(&s).unwrap_or_default();
if state.validated_mcp_server_grants_version < VALIDATED_MCP_SERVER_GRANTS_VERSION {
state.allowed_mcp_servers.clear();
state.validated_mcp_server_grants_version = VALIDATED_MCP_SERVER_GRANTS_VERSION;
tracing::info!(path = %path.display(), "invalidated legacy MCP server grants");
if let Err(e) = persist_state_to_path_with_writer(path, &state, writer).await {
tracing::warn!(?e, path = %path.display(), "failed writing permission state");
}
}
Some(state)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(?e, "failed reading permission state");
@ -60,6 +112,13 @@ async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
}
}
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
try_load_state_with_writer(path, |path, contents| {
xai_grok_config::fs_atomic::write_atomically(path, contents, None)
})
.await
}
async fn load_state_from_dir(
dir: &std::path::Path,
client_identifier: Option<&str>,
@ -69,12 +128,10 @@ async fn load_state_from_dir(
if let Some(state) = try_load_state(&per_client).await {
return state;
}
let shared = state_file_path(dir, None);
try_load_state(&shared).await.unwrap_or_default()
} else {
let path = state_file_path(dir, None);
try_load_state(&path).await.unwrap_or_default()
}
try_load_state(&state_file_path(dir, None))
.await
.unwrap_or_default()
}
pub(crate) async fn load_state_from_disk(
@ -84,6 +141,32 @@ pub(crate) async fn load_state_from_disk(
load_state_from_dir(&state_dir_for_cwd(cwd), client_identifier).await
}
async fn persist_state_to_path_with_writer<F>(
path: &std::path::Path,
state: &PermissionState,
writer: F,
) -> std::io::Result<()>
where
F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static,
{
let contents = toml::to_string_pretty(state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || writer(&path, &contents))
.await
.map_err(std::io::Error::other)?
}
async fn persist_state_to_path(
path: &std::path::Path,
state: &PermissionState,
) -> std::io::Result<()> {
persist_state_to_path_with_writer(path, state, |path, contents| {
xai_grok_config::fs_atomic::write_atomically(path, contents, None)
})
.await
}
async fn persist_state_to_dir(
dir: &std::path::Path,
state: &PermissionState,
@ -94,13 +177,8 @@ async fn persist_state_to_dir(
return;
}
let path = state_file_path(dir, client_identifier);
match toml::to_string_pretty(state) {
Ok(s) => {
if let Err(e) = tokio::fs::write(&path, s).await {
tracing::warn!(?e, "failed writing permission state");
}
}
Err(e) => tracing::warn!(?e, "failed serializing permission state"),
if let Err(e) = persist_state_to_path(&path, state).await {
tracing::warn!(?e, path = %path.display(), "failed writing permission state");
}
}
@ -161,6 +239,10 @@ mod tests {
assert!(!restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.is_empty());
assert!(restored.disallowed_bash_commands.is_empty());
assert_eq!(
restored.validated_mcp_server_grants_version,
VALIDATED_MCP_SERVER_GRANTS_VERSION
);
}
#[test]
@ -251,11 +333,12 @@ mod tests {
}
#[test]
fn deserialize_from_empty_toml() {
fn deserialize_from_empty_toml_is_legacy() {
let state: PermissionState = toml::from_str("").unwrap();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
assert!(state.disallowed_bash_commands.is_empty());
assert_eq!(state.validated_mcp_server_grants_version, 0);
}
#[test]
@ -364,6 +447,16 @@ allowed_web_fetch_domains = ["github.com"]
assert!(state.allowed_web_fetch_domains.contains("github.com"));
assert!(state.allowed_mcp_tools.is_empty());
assert!(state.allowed_mcp_servers.is_empty());
assert_eq!(state.validated_mcp_server_grants_version, 0);
}
#[test]
fn malformed_mcp_server_grants_version_is_legacy() {
for marker in ["-1", "\"invalid\""] {
let state: PermissionState =
toml::from_str(&format!("validated_mcp_server_grants_version = {marker}")).unwrap();
assert_eq!(state.validated_mcp_server_grants_version, 0);
}
}
#[test]
@ -387,13 +480,139 @@ allowed_bash_commands = ["ls"]
// ── Disk persistence roundtrip tests ─────────────────────────
async fn write_legacy_mcp_state(path: &std::path::Path) {
tokio::fs::write(
path,
r#"
edit_policy = "reject"
allow_bash_execute = true
allowed_bash_commands = ["cargo test"]
disallowed_bash_commands = ["rm"]
allowed_web_fetch_domains = ["example.com"]
allowed_mcp_tools = ["a__b__c"]
allowed_mcp_servers = ["a"]
"#,
)
.await
.unwrap();
}
fn assert_legacy_mcp_state_migrated(state: &PermissionState) {
assert!(state.allowed_mcp_servers.is_empty());
assert!(state.allowed_mcp_tools.contains("a__b__c"));
assert!(state.allow_bash_execute);
assert!(state.allowed_bash_commands.contains("cargo test"));
assert!(state.disallowed_bash_commands.contains("rm"));
assert!(state.allowed_web_fetch_domains.contains("example.com"));
assert_eq!(state.edit_policy, EditPolicy::Reject);
assert_eq!(
state.validated_mcp_server_grants_version,
VALIDATED_MCP_SERVER_GRANTS_VERSION
);
}
#[tokio::test]
async fn legacy_shared_mcp_server_grants_migrate_and_rewrite() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), None);
write_legacy_mcp_state(&path).await;
assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), None).await);
let rewritten: PermissionState =
toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_legacy_mcp_state_migrated(&rewritten);
}
#[tokio::test]
async fn failed_migration_rewrite_preserves_legacy_file_for_retry() {
fn fail_write(_: &std::path::Path, _: &str) -> std::io::Result<()> {
Err(std::io::Error::other("injected write failure"))
}
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), None);
write_legacy_mcp_state(&path).await;
let legacy_contents = tokio::fs::read_to_string(&path).await.unwrap();
let in_memory = try_load_state_with_writer(&path, fail_write).await.unwrap();
assert_legacy_mcp_state_migrated(&in_memory);
assert_eq!(
tokio::fs::read_to_string(&path).await.unwrap(),
legacy_contents
);
let still_legacy: PermissionState = toml::from_str(&legacy_contents).unwrap();
assert_eq!(still_legacy.validated_mcp_server_grants_version, 0);
assert!(still_legacy.allowed_mcp_servers.contains("a"));
assert_legacy_mcp_state_migrated(&try_load_state(&path).await.unwrap());
let rewritten: PermissionState =
toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
assert_legacy_mcp_state_migrated(&rewritten);
}
#[tokio::test]
async fn current_and_future_mcp_server_grants_are_retained_exactly() {
for version in [
VALIDATED_MCP_SERVER_GRANTS_VERSION,
VALIDATED_MCP_SERVER_GRANTS_VERSION + 1,
4_294_967_296,
] {
let tmp = tempfile::tempdir().unwrap();
let mut state = PermissionState::default();
state.validated_mcp_server_grants_version = version;
state.allowed_mcp_servers.insert("linear".to_owned());
persist_state_to_dir(tmp.path(), &state, None).await;
let loaded = load_state_from_dir(tmp.path(), None).await;
assert!(loaded.allowed_mcp_servers.contains("linear"));
assert_eq!(loaded.validated_mcp_server_grants_version, version);
let persisted: PermissionState = toml::from_str(
&tokio::fs::read_to_string(state_file_path(tmp.path(), None))
.await
.unwrap(),
)
.unwrap();
assert_eq!(persisted.validated_mcp_server_grants_version, version);
}
}
#[tokio::test]
async fn per_client_legacy_migration_rewrites_only_loaded_file() {
let tmp = tempfile::tempdir().unwrap();
let shared = state_file_path(tmp.path(), None);
let per_client = state_file_path(tmp.path(), Some("desktop"));
let mut shared_state = PermissionState::default();
shared_state.allowed_mcp_servers.insert("shared".to_owned());
persist_state_to_dir(tmp.path(), &shared_state, None).await;
write_legacy_mcp_state(&per_client).await;
assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), Some("desktop")).await);
let shared_after: PermissionState =
toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap();
assert!(shared_after.allowed_mcp_servers.contains("shared"));
let client_after: PermissionState =
toml::from_str(&tokio::fs::read_to_string(per_client).await.unwrap()).unwrap();
assert_legacy_mcp_state_migrated(&client_after);
}
#[tokio::test]
async fn per_client_fallback_migrates_shared_file() {
let tmp = tempfile::tempdir().unwrap();
let shared = state_file_path(tmp.path(), None);
write_legacy_mcp_state(&shared).await;
assert_legacy_mcp_state_migrated(
&load_state_from_dir(tmp.path(), Some("missing-client")).await,
);
let shared_after: PermissionState =
toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap();
assert_legacy_mcp_state_migrated(&shared_after);
assert!(!state_file_path(tmp.path(), Some("missing-client")).exists());
}
#[tokio::test]
async fn persist_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let cwd_path = tmp.path().join("my-project");
std::fs::create_dir_all(&cwd_path).unwrap();
let _cwd = AbsPathBuf::new(cwd_path).unwrap();
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state
@ -401,18 +620,8 @@ allowed_bash_commands = ["ls"]
.insert("cargo build".to_string());
state.disallowed_bash_commands.insert("rm -rf".to_string());
// Override the state dir to use our temp dir.
// We can't easily override grok_home(), so instead test
// the serialize/deserialize path directly with TOML.
let toml_str = toml::to_string_pretty(&state).unwrap();
let dir = tmp.path().join("sessions").join("test");
tokio::fs::create_dir_all(&dir).await.unwrap();
let path = dir.join("permission.toml");
tokio::fs::write(&path, &toml_str).await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let restored: PermissionState = toml::from_str(&content).unwrap();
persist_state_to_dir(tmp.path(), &state, None).await;
let restored = load_state_from_dir(tmp.path(), None).await;
assert!(restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.contains("cargo build"));
assert!(restored.disallowed_bash_commands.contains("rm -rf"));
@ -492,11 +701,17 @@ allowed_bash_commands = ["ls"]
async fn try_load_state_valid_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("permission.toml");
tokio::fs::write(&path, "allow_bash_execute = true")
let mut expected = PermissionState::default();
expected.allow_bash_execute = true;
tokio::fs::write(&path, toml::to_string_pretty(&expected).unwrap())
.await
.unwrap();
let state = try_load_state(&path).await.unwrap();
assert!(state.allow_bash_execute);
assert_eq!(
state.validated_mcp_server_grants_version,
VALIDATED_MCP_SERVER_GRANTS_VERSION
);
}
#[tokio::test]

View file

@ -211,11 +211,17 @@ impl<'de> Deserialize<'de> for EditPolicy {
deserializer.deserialize_str(V)
}
}
#[derive(Debug, Clone)]
pub struct EditPathContext {
pub real_cwd: std::path::PathBuf,
pub display_cwd: Option<std::path::PathBuf>,
}
#[allow(clippy::large_enum_variant)]
pub enum PermissionCommand {
Request {
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
edit_path_context: Option<EditPathContext>,
respond_to: oneshot::Sender<Decision>,
/// Session ID originating this request. Used to attribute
/// permission events to child subagents.

View file

@ -475,7 +475,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
}
fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend {
crate::config::SessionTerminalBackend::local(
xai_grok_tools::computer::local::LocalTerminalBackend::with_persistent_shell(),
xai_grok_tools::computer::local::LocalTerminalBackend::new(),
)
}
fn registry_builder(&self) -> ToolRegistryBuilder {
@ -497,6 +497,10 @@ fn build_proxy_headers(base_url: &str) -> indexmap::IndexMap<String, String> {
format!("xai-grok-workspace/{version}"),
);
headers.insert("x-grok-client-version".to_string(), version.to_string());
headers.insert(
"x-grok-client-identifier".to_string(),
std::env::var("GROK_CLIENT_NAME").unwrap_or_else(|_| "grok-shell".to_string()),
);
if base_url.contains("cli-chat-proxy") || base_url.contains("chat-proxy") {
headers.insert("X-XAI-Token-Auth".to_string(), "xai-grok-cli".to_string());
headers.insert(
@ -518,6 +522,9 @@ fn build_web_fetch_config() -> xai_grok_tools::implementations::grok_build::web_
if let Ok(proxy) = std::env::var("GROK_WEB_FETCH_PROXY") {
params.proxy_endpoint = Some(proxy);
}
if xai_grok_config::env_bool("GROK_WEB_FETCH_ALLOW_LOCAL") == Some(true) {
params.allow_local = Some(true);
}
WebFetchConfig::Enabled { params }
}
fn default_web_search_model() -> String {