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:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue