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

@ -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,