Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -19,8 +19,8 @@ use crate::permission::gate_preflight::GatePreflight;
use crate::permission::policy::{CompiledPolicy, ShellWord};
use crate::permission::prompter::{AcpPrompter, PromptOutcome};
use crate::permission::shell_access::{
command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink,
tree_has_opaque_shell, words_are_opaque_shell,
command_write_paths_in_tree, edit_target_protection, is_safe_write_sink, tree_has_opaque_shell,
words_are_opaque_shell,
};
use crate::permission::state::{PermissionState, load_state_from_disk, persist_state};
use crate::permission::types::{
@ -1569,15 +1569,15 @@ fn spawn_permission_manager_with_pin(
context.display_cwd.as_deref(),
path,
);
edit_target_requires_prompt(&resolved)
edit_target_protection(&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)
edit_target_protection(&resolved)
}
_ => false,
_ => None,
};
// Evaluate managed policy (direct access + per-segment Bash command
@ -1627,7 +1627,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
&& protected_edit.is_none()
&& let Some((decision, reason)) = session_grant_pre_decision(
&access,
bash_evaluation.as_ref(),
@ -1651,7 +1651,7 @@ fn spawn_permission_manager_with_pin(
if auto_mode
&& !policy_forced_prompt
&& !shell_forced_prompt
&& !protected_edit
&& protected_edit.is_none()
&& !bash_request_floor_requires_prompt(bash_evaluation.as_ref())
&& matches!(policy_decision, Some(Decision::Allow))
{
@ -1681,8 +1681,8 @@ fn spawn_permission_manager_with_pin(
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
auto_mode_fast_path,
};
let needs_user =
protected_edit || access_requires_user_interaction(&tool_name, &access);
let needs_user = protected_edit.is_some()
|| access_requires_user_interaction(&tool_name, &access);
let fast = auto_mode_fast_path(&access, &tool_name, needs_user);
match fast {
AutoFastPath::Allow => {
@ -1901,7 +1901,7 @@ fn spawn_permission_manager_with_pin(
);
}
Some(Decision::Allow)
if protected_edit
if protected_edit.is_some()
|| bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
{
tracing::info!(
@ -1961,7 +1961,7 @@ fn spawn_permission_manager_with_pin(
)
.map(|d| (d, reasons::PERSISTED_GRANT)),
AccessKind::Edit(_) => {
if allow_edits_for_session && !protected_edit {
if allow_edits_for_session && protected_edit.is_none() {
Some((Decision::Allow, reasons::PERSISTED_GRANT))
} else {
match state.edit_policy {
@ -2117,7 +2117,7 @@ fn spawn_permission_manager_with_pin(
// (e.g. `curl … && sh` must not become two separate
// prompts for `curl …` then `sh`).
let prompt_outcome = tokio::select! {
outcome = prompter.request(&access, &tool_call_update) => outcome,
outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome,
_ = respond_to.closed() => PromptOutcome::Cancelled,
};
@ -2173,7 +2173,7 @@ fn spawn_permission_manager_with_pin(
_ => {
// Non-bash access kinds keep the single-prompt flow.
let prompt_outcome = tokio::select! {
outcome = prompter.request(&access, &tool_call_update) => outcome,
outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome,
_ = respond_to.closed() => PromptOutcome::Cancelled,
};
let (decision, outcome_str) = match &prompt_outcome {
@ -5123,46 +5123,43 @@ mod tests {
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 path in ["/etc/hosts", "/home/user/.grok/hooks/evil.json"] {
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}");
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(path.into()), tool_call(), None, None, None)
.await;
assert_eq!(prompts.borrow().len(), expected_prompts, "{name} {path}");
if policy_deny {
assert!(matches!(decision, Decision::PolicyDeny(_)), "{name} {path}");
} else {
assert!(matches!(decision, Decision::Reject(_)), "{name} {path}");
}
}
}
})

View file

@ -43,6 +43,7 @@ pub use prompter::{
PromptOutcome, is_enable_always_approve_option, mcp_pretty_name_if_qualified,
mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name,
};
pub use shell_access::{ProtectedEditPermission, ProtectedEditReason};
pub use state::PermissionState;
pub use state::cleanup_stale_permission_state;
pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent};

View file

@ -565,6 +565,22 @@ impl AcpPrompter {
}
}
/// Request `_meta`: bash selection scope, or protected-edit description for Edit.
fn permission_request_meta(
&self,
access: &AccessKind,
protected_edit: Option<crate::permission::ProtectedEditReason>,
) -> Option<acp::Meta> {
if let Some(bash) = self.bash_selection_meta(access) {
return Some(bash);
}
let reason = protected_edit?;
let payload = crate::permission::ProtectedEditPermission::from_reason(reason);
serde_json::to_value(payload)
.ok()
.and_then(|v| v.as_object().cloned())
}
/// Build the per-access-kind option map WITHOUT the
/// "enable always-approve mode" prepend. Kept as a separate inner
/// fn so `build_options` can wrap the result with one prepend call
@ -719,6 +735,7 @@ impl AcpPrompter {
&self,
access: &AccessKind,
tool_call_update: &acp::ToolCallUpdate,
protected_edit: Option<crate::permission::ProtectedEditReason>,
) -> PromptOutcome {
let tool_name = tool_name_for_access(access);
// events.jsonl: `PermissionRequested` at prompt-start. The `Instant`
@ -753,7 +770,7 @@ impl AcpPrompter {
tool_call_update.clone(),
permission_options.values().cloned().collect(),
)
.meta(self.bash_selection_meta(access));
.meta(self.permission_request_meta(access, protected_edit));
match self.gateway.request_permission(req).await {
Ok(resp) => match resp.outcome {
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
@ -1625,7 +1642,7 @@ mod tests {
acp::ToolCallUpdateFields::default(),
);
let outcome = prompter.request(&access, &tool_call_update).await;
let outcome = prompter.request(&access, &tool_call_update, None).await;
assert!(
matches!(outcome, PromptOutcome::Error(_)),
"dropped gateway receiver should yield PromptOutcome::Error"
@ -1675,7 +1692,7 @@ mod tests {
acp::ToolCallId::new(Arc::from("tc-2")),
acp::ToolCallUpdateFields::default(),
);
let outcome = prompter.request(&access, &tool_call_update).await;
let outcome = prompter.request(&access, &tool_call_update, None).await;
assert!(matches!(outcome, PromptOutcome::Error(_)));
}
}

View file

@ -319,26 +319,109 @@ 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.
/// Why acceptEdits must still prompt for this edit target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtectedEditReason {
HookRoot,
GitHooks,
Ssh,
StartupFile,
Etc,
GrokConfig,
ClaudeSettings,
CursorHooks,
/// Fail-closed / unclassified sensitive path; no user copy yet.
Sensitive,
}
impl ProtectedEditReason {
pub fn kind(self) -> &'static str {
match self {
Self::HookRoot => "hook_root",
Self::GitHooks => "git_hooks",
Self::Ssh => "ssh",
Self::StartupFile => "startup_file",
Self::Etc => "etc",
Self::GrokConfig => "grok_config",
Self::ClaudeSettings => "claude_settings",
Self::CursorHooks => "cursor_hooks",
Self::Sensitive => "sensitive",
}
}
pub fn description(self) -> Option<&'static str> {
match self {
Self::HookRoot => Some(
"Note: This edit contains changes to hooks, which can be executed as code on later sessions without a separate execution approval.",
),
Self::GitHooks => Some(
"Note: This edit contains changes to Git hooks, which can run automatically on commit, push, or other Git actions without a separate execution approval.",
),
Self::Ssh => Some(
"Note: This edit contains changes under `.ssh`, which can affect credentials and authentication for future sessions.",
),
Self::StartupFile => Some(
"Note: This edit contains changes to a shell startup file, which can run automatically in future terminals without a separate execution approval.",
),
Self::Etc => Some(
"Note: This edit contains changes under `/etc`, which is system configuration and can affect this machine beyond the current project.",
),
Self::GrokConfig => Some(
"Note: This edit contains changes to Grok config, which can alter permissions, tools, and other behavior in later sessions.",
),
Self::ClaudeSettings => Some(
"Note: This edit contains changes to Claude-compatible settings, which can install hooks or change permission mode without a separate execution approval.",
),
Self::CursorHooks => Some(
"Note: This edit contains changes to Cursor hooks, which can run automatically in later sessions without a separate execution approval.",
),
Self::Sensitive => None,
}
}
}
/// ACP `_meta` payload for protected-edit prompts (pager reads this for description).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProtectedEditPermission {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl ProtectedEditPermission {
pub fn from_reason(reason: ProtectedEditReason) -> Self {
Self {
kind: reason.kind().to_owned(),
description: reason.description().map(str::to_owned),
}
}
}
/// Whether an already-resolved direct edit target needs confirmation, and why.
///
/// 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 {
pub(crate) fn edit_target_protection(path: &Path) -> Option<ProtectedEditReason> {
if !path.is_absolute() {
return true;
return Some(ProtectedEditReason::Sensitive);
}
let lexical = xai_grok_paths::normalize_lexically(path);
if protected_edit_path(&lexical) {
return true;
if let Some(reason) = protected_edit_reason(&lexical) {
return Some(reason);
}
let Some(resolved) = resolve_following_symlinks(path, 0) else {
return true;
return Some(ProtectedEditReason::Sensitive);
};
protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc"))
if let Some(reason) = protected_edit_reason(&resolved) {
return Some(reason);
}
resolved_path_is_within_root(&resolved, Path::new("/etc"))
.then_some(ProtectedEditReason::Sensitive)
}
fn protected_edit_path(path: &Path) -> bool {
fn protected_edit_reason(path: &Path) -> Option<ProtectedEditReason> {
let components: Vec<String> = path
.components()
.filter_map(|component| match component {
@ -368,12 +451,49 @@ fn protected_edit_path(path: &Path) -> bool {
".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"))
if protected_grok_hook_root(path, &string_components) {
return Some(ProtectedEditReason::HookRoot);
}
if string_components.ends_with(&[".claude", "settings.json"])
|| string_components.ends_with(&[".claude", "settings.local.json"])
{
return Some(ProtectedEditReason::ClaudeSettings);
}
if string_components.ends_with(&[".cursor", "hooks.json"]) {
return Some(ProtectedEditReason::CursorHooks);
}
if protected_git_hooks_path(&string_components) {
return Some(ProtectedEditReason::GitHooks);
}
if string_components.contains(&".ssh") {
return Some(ProtectedEditReason::Ssh);
}
if STARTUP_FILES.contains(&file) {
return Some(ProtectedEditReason::StartupFile);
}
if string_components.ends_with(&[".grok", "config.toml"]) {
return Some(ProtectedEditReason::GrokConfig);
}
if path == Path::new("/etc") || path.starts_with(Path::new("/etc")) {
return Some(ProtectedEditReason::Etc);
}
None
}
fn path_is_under_user_grok_hook_root(path: &Path, grok_home: &Path) -> bool {
path.starts_with(grok_home.join("hooks")) || path == grok_home.join("hooks-paths")
}
fn protected_grok_hook_root(path: &Path, components: &[&str]) -> bool {
components.windows(2).any(|pair| pair == [".grok", "hooks"])
|| components.ends_with(&[".grok", "hooks-paths"])
|| xai_grok_config::user_grok_home().is_some_and(|grok_home| {
let lexical_home = xai_grok_paths::normalize_lexically(&grok_home);
path_is_under_user_grok_hook_root(path, &lexical_home)
|| resolve_following_symlinks(&lexical_home, 0).is_some_and(|resolved_home| {
path_is_under_user_grok_hook_root(path, &resolved_home)
})
})
}
fn protected_git_hooks_path(components: &[&str]) -> bool {
@ -1251,7 +1371,7 @@ mod tests {
"/work/subdir/../.git/hooks/pre-commit",
] {
assert!(
edit_target_requires_prompt(Path::new(path)),
edit_target_protection(Path::new(path)).is_some(),
"protected edit target must prompt: {path}"
);
}
@ -1260,7 +1380,7 @@ mod tests {
"/work/project/.grok/config.toml/backup",
] {
assert!(
!edit_target_requires_prompt(Path::new(path)),
edit_target_protection(Path::new(path)).is_none(),
"ordinary edit target should not prompt: {path}"
);
}
@ -1275,7 +1395,7 @@ mod tests {
"/work/subdir/../.git/modules/foo/hooks/pre-commit",
] {
assert!(
edit_target_requires_prompt(Path::new(path)),
edit_target_protection(Path::new(path)).is_some(),
"submodule hook target must prompt: {path}"
);
}
@ -1287,12 +1407,110 @@ mod tests {
"/work/src/modules/foo/hooks/pre-commit",
] {
assert!(
!edit_target_requires_prompt(Path::new(path)),
edit_target_protection(Path::new(path)).is_none(),
"non-hook control must not prompt: {path}"
);
}
}
#[test]
fn edit_target_protection_classifies_reasons() {
let cases = [
(
"/home/user/.grok/hooks/evil.json",
ProtectedEditReason::HookRoot,
),
("/work/.git/hooks/pre-commit", ProtectedEditReason::GitHooks),
("/home/user/.ssh/id_rsa", ProtectedEditReason::Ssh),
("/home/user/.zshrc", ProtectedEditReason::StartupFile),
("/etc/hosts", ProtectedEditReason::Etc),
(
"/home/user/.grok/config.toml",
ProtectedEditReason::GrokConfig,
),
(
"/home/user/.claude/settings.json",
ProtectedEditReason::ClaudeSettings,
),
(
"/home/user/.cursor/hooks.json",
ProtectedEditReason::CursorHooks,
),
];
for (path, reason) in cases {
assert_eq!(
edit_target_protection(Path::new(path)),
Some(reason),
"{path}"
);
assert!(reason.description().is_some(), "{path}");
}
assert_eq!(
edit_target_protection(Path::new("/home/user/project/src/main.rs")),
None
);
assert!(ProtectedEditReason::Sensitive.description().is_none());
}
#[test]
fn sensitive_edit_targets_include_hook_roots() {
for path in [
"/home/user/.grok/hooks/evil.json",
"/home/user/.grok/hooks/nested/deep.json",
"/home/user/.grok/hooks-paths",
"/home/user/.claude/settings.json",
"/home/user/.claude/settings.local.json",
"/home/user/.cursor/hooks.json",
"/work/project/.grok/hooks/local.json",
"/work/project/.grok/hooks-paths",
] {
assert!(
edit_target_protection(Path::new(path)).is_some(),
"hook root edit target must prompt: {path}"
);
}
for path in [
"/home/user/.grok/hooks-disabled/note.json",
"/home/user/.grok/hooks-evil/note.json",
"/home/user/project/src/hooks.json",
"/home/user/.claude/other.json",
"/home/user/.cursor/settings.json",
] {
assert!(
edit_target_protection(Path::new(path)).is_none(),
"ordinary edit target should not prompt: {path}"
);
}
}
#[test]
fn path_is_under_user_grok_hook_root_matches_relocated_home() {
let home = Path::new("/custom/grok-home");
for path in [
"/custom/grok-home/hooks/x.json",
"/custom/grok-home/hooks/nested/deep.json",
"/custom/grok-home/hooks",
"/custom/grok-home/hooks-paths",
] {
assert!(
path_is_under_user_grok_hook_root(Path::new(path), home),
"must match under custom grok home: {path}"
);
}
for path in [
"/custom/grok-home/hooks-disabled/note.json",
"/custom/grok-home/hooks-evil/note.json",
"/custom/grok-home/config.toml",
"/custom/other/hooks/x.json",
"/custom/grok-home-extra/hooks/x.json",
] {
assert!(
!path_is_under_user_grok_hook_root(Path::new(path), home),
"must not match outside hook roots: {path}"
);
}
}
#[test]
#[cfg(unix)]
fn sensitive_edit_targets_follow_symlinks() {
@ -1314,14 +1532,19 @@ mod tests {
ws.path().join("module-hooks-link"),
)
.unwrap();
let grok_hook = outside.path().join(".grok/hooks/evil.json");
std::fs::create_dir_all(grok_hook.parent().unwrap()).unwrap();
std::fs::write(&grok_hook, b"{}").unwrap();
symlink(&grok_hook, ws.path().join("grok-hook-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"),
ws.path().join("grok-hook-link"),
] {
assert!(
edit_target_requires_prompt(&path),
edit_target_protection(&path).is_some(),
"symlinked protected edit target must prompt: {}",
path.display()
);
@ -1344,7 +1567,7 @@ mod tests {
#[test]
#[cfg(target_os = "macos")]
fn private_etc_alias_requires_prompt() {
assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts")));
assert!(edit_target_protection(Path::new("/private/etc/hosts")).is_some());
}
#[test]