fix(permissions): native path deny/ask rules apply to the file the tool opens

Edit/Read/Grep rules matched only the raw argument string, so a workspace
symlink (or a quoted, `~`, `./`, `..`, or absolute spelling) reached a
denied file. The policy now resolves the path exactly as the tools do
(sanitize, expand `~`, display-cwd remap, join to the requesting session's
cwd), follows symlinks parent-first, and re-checks deny/ask rules on the
opened path and its target as absolute, `./`-relative and relative
spellings. Escalation only: no allow is granted through a link. A link
that cannot be followed forces a prompt that survives YOLO, auto mode and
the session edit grant, with event reason `unresolved_link`.

Permission requests now carry the requesting tool's cwd and display cwd
so a child worktree session sharing its parent's handle is checked where
it runs.

Ported from upstream 1.0.14 (grok-build permission/policy.rs symlink
re-check), reshaped for Kigi's lexical rule matcher.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 05:48:06 -04:00
1 parent fb3fe89d33
commit 5f7416f3ef
8 files changed
+995 -34

No files matched your search

@@ -27,7 +27,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
kigi_workspace::permission::spawn_permission_manager(
sid.clone(),
gateway,
cwd,
cwd.clone(),
kigi_workspace::permission::types::ClientType::Generic,
Some(PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
Some("child-session".to_owned()),
Some("general-purpose".to_owned()),
Some("permission inheritance regression".to_owned()),
cwd.clone(),
None,
)
.await;
assert!(
@@ -957,6 +957,8 @@ impl SessionActor {
Some(self.session_info.id.0.to_string()),
None,
None,
self.tool_context.cwd.clone(),
self.display_cwd.get().map(std::path::PathBuf::from),
)
.await
};
@@ -82,6 +82,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
@@ -99,6 +101,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
assert!(
@@ -161,6 +161,9 @@ async fn request(handle: &PermissionHandle, access: AccessKind, id: &str) -> Dec
session_id: None,
subagent_type: None,
subagent_description: None,
// The accesses here carry no file path; the process cwd is a real cwd.
tool_cwd: AbsPathBuf::new(std::env::current_dir().unwrap()).unwrap(),
display_cwd: None,
};
let PermissionHandle::Actor { cmd_tx, .. } = handle else {
panic!("expected actor handle");
@@ -270,10 +270,10 @@ Path patterns are globs matched against the path string the tool was called with
- `*` and `?` do not cross `/`; `**` does. `Read(src/*)` matches `src/main.rs` but not `src/nested/mod.rs`; use `Read(src/**)` for the whole tree.
- A bare filename matches only that exact string. Use `**/.env` to match `.env` at any depth.
- There are no anchor prefixes: a leading `//` or `~/` in a pattern is treated as literal glob text. Write absolute-path patterns or `**/` patterns instead.
- Paths are matched as given, without canonicalization. Whether a path is absolute or relative depends on how the tool was invoked, so patterns intended as boundaries should cover both forms (for example both `/repo/secrets/**` and `secrets/**`).
- Allow rules match the path as the tool was called with it. Deny and ask rules are also checked against the file the tool will open: the path is resolved against the session directory, symlinks are followed, and the absolute and the workspace-relative spelling (with or without a leading `./`) are matched. `secrets/**` therefore catches a read of `/repo/secrets/key` and a read through a symlink that points into `secrets/`. A symlink that cannot be resolved (a loop) prompts when any deny or ask rule covers the tool.
- `Read` rules also govern `grep` searches; `Grep(...)` rules match only grep.
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks. The direct `read_file`/`search_replace` tool checks do not resolve symlinks. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks too. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
### MCP Rules
@@ -28,6 +28,8 @@ mod reasons {
pub const POLICY_ALLOW: &str = "policy_allow";
pub const POLICY_DENY: &str = "policy_deny";
pub const POLICY_ASK: &str = "policy_ask";
/// A native path link could not be followed; the prompt is forced.
pub const UNRESOLVED_LINK: &str = "unresolved_link";
pub const AUTO_FAST_PATH: &str = "auto_fast_path";
pub const AUTO_CLASSIFIER_ALLOW: &str = "auto_classifier_allow";
pub const AUTO_CLASSIFIER_BLOCK: &str = "auto_classifier_block";
@@ -648,6 +650,8 @@ impl PermissionHandle {
session_id: Option<String>,
subagent_type: Option<String>,
subagent_description: Option<String>,
tool_cwd: AbsPathBuf,
display_cwd: Option<std::path::PathBuf>,
) -> Decision {
match self {
PermissionHandle::AllowAll => Decision::Allow,
@@ -665,6 +669,8 @@ impl PermissionHandle {
session_id,
subagent_type,
subagent_description,
tool_cwd,
display_cwd,
};
if let Err(e) = cmd_tx.send(msg) {
tracing::error!(?e, "failed to send permission request");
@@ -1047,6 +1053,8 @@ fn spawn_permission_manager_with_pin(
session_id: request_session_id,
subagent_type: request_subagent_type,
subagent_description: request_subagent_description,
tool_cwd,
display_cwd,
} => {
// wait_ms timer; starts at dequeue so it excludes time queued behind others.
let request_received = std::time::Instant::now();
@@ -1143,9 +1151,14 @@ fn spawn_permission_manager_with_pin(
// Evaluate managed policy (direct access + per-segment Bash command
// rules + Bash shell-file args) up front so the YOLO/sandbox fast
// paths below honor a deny or forced prompt.
let direct_decision = compiled_policy
.as_ref()
.and_then(|policy| policy.evaluate(&access));
let (direct_decision, unresolved_link_prompt) =
compiled_policy.as_ref().map_or((None, false), |policy| {
policy.evaluate_with_cwd(
&access,
tool_cwd.as_path(),
display_cwd.as_deref(),
)
});
let shell_command_decision = match (&compiled_policy, &access) {
(Some(policy), AccessKind::Bash(cmd)) => {
policy.evaluate_bash_command_policy(cmd)
@@ -1154,15 +1167,17 @@ fn spawn_permission_manager_with_pin(
};
let shell_file_decision = match (&compiled_policy, &access) {
(Some(policy), AccessKind::Bash(cmd)) => {
policy.evaluate_shell_file_access(cmd, cwd.as_path())
policy.evaluate_shell_file_access(cmd, tool_cwd.as_path())
}
_ => None,
};
let shell_file_forced_prompt =
matches!(shell_file_decision, Some(Decision::Ask));
// An `Ask` from either bash gate must block the YOLO/auto fast paths.
let shell_forced_prompt = shell_file_forced_prompt
|| matches!(shell_command_decision, Some(Decision::Ask));
// An `Ask` from either bash gate, or from a native path whose link
// cannot be followed, must block the YOLO/auto fast paths.
let forced_prompt = shell_file_forced_prompt
|| matches!(shell_command_decision, Some(Decision::Ask))
|| unresolved_link_prompt;
let policy_decision = combine_decisions(
combine_decisions(direct_decision, shell_command_decision),
shell_file_decision,
@@ -1188,7 +1203,7 @@ fn spawn_permission_manager_with_pin(
continue;
}
if yolo_mode && !shell_forced_prompt {
if yolo_mode && !forced_prompt {
tracing::debug!("YOLO mode: auto-approving permission request");
let decision = Decision::Allow;
emit_event(&decision, true, false, None, Some(reasons::YOLO));
@@ -1199,7 +1214,7 @@ fn spawn_permission_manager_with_pin(
// Session always-allow grants win before the auto classifier.
// Ask floors fall through so managed Ask / shell-file Ask stay binding.
if !policy_forced_prompt
&& !shell_forced_prompt
&& !forced_prompt
&& let Some((decision, reason)) = session_grant_pre_decision(
&access,
&state,
@@ -1223,7 +1238,7 @@ fn spawn_permission_manager_with_pin(
// fast-path/classifier allows. Policy Ask still prompts below
// unless auto fast-path/classifier decides first for non-forced
// paths — we skip auto entirely when policy_forced_prompt.
if auto_mode && !policy_forced_prompt && !shell_forced_prompt {
if auto_mode && !policy_forced_prompt && !forced_prompt {
use crate::permission::auto_mode::{
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
auto_mode_fast_path,
@@ -1399,7 +1414,9 @@ fn spawn_permission_manager_with_pin(
)
.map(|d| (d, reasons::PERSISTED_GRANT)),
AccessKind::Edit(_) => {
if allow_edits_for_session {
// A link the policy could not follow keeps the prompt: the
// session grant cannot vouch for its target.
if allow_edits_for_session && !forced_prompt {
Some((Decision::Allow, reasons::PERSISTED_GRANT))
} else {
match state.edit_policy {
@@ -1516,7 +1533,9 @@ fn spawn_permission_manager_with_pin(
// Why this reached the prompt — otherwise lost once user_prompted=true.
// A policy/shell `ask` wins; else the auto-mode reason; else unapproved.
let prompt_trigger = if policy_forced_prompt || shell_forced_prompt {
let prompt_trigger = if unresolved_link_prompt {
reasons::UNRESOLVED_LINK
} else if policy_forced_prompt || forced_prompt {
reasons::POLICY_ASK
} else {
auto_prompt_reason.unwrap_or(reasons::NEEDS_USER)
@@ -1932,6 +1951,208 @@ mod tests {
.await;
}
/// The follow uses the requesting tool's cwd, not the manager root: a child
/// worktree link to a denied file is rejected through the shared handle.
#[tokio::test]
#[cfg(unix)]
async fn direct_read_follows_the_requesting_tools_cwd() {
use crate::permission::types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
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 worktree = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("deny-secret.txt");
std::fs::write(&secret, b"secret").unwrap();
std::os::unix::fs::symlink(&secret, worktree.path().join("link")).unwrap();
std::fs::write(tmp.path().join("link"), b"plain").unwrap();
let config = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Read,
pattern: Some("**/deny-secret.txt".to_owned()),
pattern_mode: PatternMode::Glob,
}]);
let tc = || {
acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc")),
acp::ToolCallUpdateFields::default(),
)
};
let (mgr, _e) = test_manager_with_config(&cwd, config, true);
let child_cwd = AbsPathBuf::new(worktree.path().to_path_buf()).unwrap();
let read = || AccessKind::Read(Some("link".into()));
let d = mgr
.request(read(), tc(), None, None, None, child_cwd, None)
.await;
assert!(
matches!(d, Decision::PolicyDeny(_)),
"child worktree link must hit the deny, got {d:?}"
);
let d = mgr
.request(read(), tc(), None, None, None, cwd.clone(), None)
.await;
assert!(
matches!(d, Decision::Allow),
"plain root file must auto-allow, got {d:?}"
);
})
.await;
}
/// A link that cannot be followed forces the prompt even under YOLO; with
/// no responder wired that surfaces as a non-`Allow` decision.
#[tokio::test]
#[cfg(unix)]
async fn unresolved_link_prompts_under_yolo() {
use crate::permission::types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
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();
std::os::unix::fs::symlink(tmp.path().join("b"), tmp.path().join("a")).unwrap();
std::os::unix::fs::symlink(tmp.path().join("a"), tmp.path().join("b")).unwrap();
let config = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Edit,
pattern: Some("**/prohibited-zone/**".to_owned()),
pattern_mode: PatternMode::Glob,
}]);
let (mgr, _e) = test_manager_with_config(&cwd, config, true);
let tc = acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc")),
acp::ToolCallUpdateFields::default(),
);
let d = mgr
.request(
AccessKind::Edit("a".into()),
tc,
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
!matches!(d, Decision::Allow),
"YOLO must not approve a write through an unresolvable link, got {d:?}"
);
})
.await;
}
/// "Allow edits for this session" cannot vouch for a link the policy could
/// not follow: the next such edit prompts again.
#[tokio::test]
#[cfg(unix)]
async fn session_edit_grant_does_not_cover_an_unresolved_link() {
use crate::permission::types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
struct SessionEditsThenRejectClient {
prompts: Arc<AtomicUsize>,
}
#[async_trait::async_trait(?Send)]
impl acp::Client for SessionEditsThenRejectClient {
async fn request_permission(
&self,
args: acp::RequestPermissionRequest,
) -> acp::Result<acp::RequestPermissionResponse> {
let wanted = if self.prompts.fetch_add(1, Ordering::Relaxed) == 0 {
crate::permission::prompter::ALLOW_EDITS_SESSION_OPTION_ID
} else {
"reject-once"
};
let option_id = args
.options
.iter()
.find(|o| o.option_id.0.as_ref() == wanted)
.map(|o| o.option_id.clone())
.expect("prompt must offer the option");
Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
option_id,
)),
))
}
async fn session_notification(&self, _: acp::SessionNotification) -> acp::Result<()> {
Ok(())
}
}
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();
std::fs::write(tmp.path().join("plain.txt"), b"x").unwrap();
std::os::unix::fs::symlink(tmp.path().join("b"), tmp.path().join("a")).unwrap();
std::os::unix::fs::symlink(tmp.path().join("a"), tmp.path().join("b")).unwrap();
let prompts = Arc::new(AtomicUsize::new(0));
let config = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Edit,
pattern: Some("**/prohibited-zone/**".to_owned()),
pattern_mode: PatternMode::Glob,
}]);
let (mgr, _e) = manager_with_recording_client_remember(
&cwd,
Some(config),
SessionEditsThenRejectClient {
prompts: prompts.clone(),
},
ClientType::Generic,
false,
);
let edit = |path: &str| AccessKind::Edit(path.into());
let d = mgr
.request(
edit("plain.txt"),
tool_call(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow, "first edit is granted for the session");
let d = mgr
.request(
edit("plain.txt"),
tool_call(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(
d,
Decision::Allow,
"the grant covers a plain edit without a prompt"
);
assert_eq!(prompts.load(Ordering::Relaxed), 1);
let d = mgr
.request(edit("a"), tool_call(), None, None, None, cwd.clone(), None)
.await;
assert!(
matches!(d, Decision::Reject(_)),
"an unresolved link must prompt again, got {d:?}"
);
assert_eq!(prompts.load(Ordering::Relaxed), 2);
})
.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.
@@ -1965,6 +2186,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -1978,6 +2201,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2022,7 +2247,15 @@ mod tests {
let (mgr, _e) = test_manager_with_config(&cwd, config(), false);
let d = mgr
.request(AccessKind::Bash("cat .env".into()), tc(), None, None, None)
.request(
AccessKind::Bash("cat .env".into()),
tc(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::PolicyDeny(_)),
@@ -2035,6 +2268,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2048,6 +2283,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2061,6 +2298,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2074,6 +2313,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2088,6 +2329,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2106,6 +2349,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2116,7 +2361,15 @@ mod tests {
let (yolo_mgr, _e2) = test_manager_with_config(&cwd, config(), true);
assert!(yolo_mgr.is_yolo_mode(), "precondition: yolo on");
let d = yolo_mgr
.request(AccessKind::Bash("cat .env".into()), tc(), None, None, None)
.request(
AccessKind::Bash("cat .env".into()),
tc(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::PolicyDeny(_)),
@@ -2131,7 +2384,15 @@ mod tests {
persist_state(&cwd, &state, None).await;
let (persisted_mgr, _e3) = test_manager_with_config(&cwd, config(), false);
let d = persisted_mgr
.request(AccessKind::Bash("cat .env".into()), tc(), None, None, None)
.request(
AccessKind::Bash("cat .env".into()),
tc(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::PolicyDeny(_)),
@@ -2180,7 +2441,15 @@ mod tests {
"cd /tmp && grep -n x f; sed -n '1,5p' f",
] {
let d = mgr
.request(AccessKind::Bash(cmd.into()), tc(), None, None, None)
.request(
AccessKind::Bash(cmd.into()),
tc(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::PolicyDeny(_)),
@@ -2197,6 +2466,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
if yolo {
@@ -2220,6 +2491,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2241,7 +2514,15 @@ mod tests {
"echo hi && ls",
] {
let d = mgr
.request(AccessKind::Bash(cmd.into()), tc(), None, None, None)
.request(
AccessKind::Bash(cmd.into()),
tc(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::Allow),
@@ -2363,7 +2644,15 @@ mod tests {
let (unpinned, _e1) = test_manager(&cwd, false, None);
let allow = unpinned
.request(AccessKind::Bash(benign.into()), bash(), None, None, None)
.request(
AccessKind::Bash(benign.into()),
bash(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(
allow,
@@ -2373,7 +2662,15 @@ mod tests {
let (pinned, _e2) = test_manager(&cwd, false, Some(PIN));
let neutralized = pinned
.request(AccessKind::Bash(benign.into()), bash(), None, None, None)
.request(
AccessKind::Bash(benign.into()),
bash(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
// Gateway receiver is dropped in test_manager — a prompt attempt
// surfaces as non-Allow (same pattern as neighboring Ask tests).
@@ -2612,6 +2909,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow, "prompted allow-once must allow");
@@ -2627,6 +2926,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2667,6 +2968,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -2684,6 +2987,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2732,6 +3037,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(matches!(d, Decision::PolicyDeny(_)), "got {d:?}");
@@ -2741,7 +3048,15 @@ mod tests {
mgr.set_classifier(Some(clf));
for cmd in ["my-custom-build --release", "second-custom-tool"] {
let d = mgr
.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None)
.request(
AccessKind::Bash(cmd.into()),
tool_call(),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
}
@@ -2778,6 +3093,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Cancelled);
@@ -2791,6 +3108,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2809,6 +3128,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(matches!(d, Decision::Reject(_)), "got {d:?}");
@@ -2822,6 +3143,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2855,8 +3178,7 @@ mod tests {
tool_call(),
None,
None,
None,
)
None, cwd.clone(), None,)
.await;
assert_eq!(d, Decision::Allow);
}
@@ -2869,8 +3191,7 @@ mod tests {
tool_call(),
None,
None,
None,
)
None, cwd.clone(), None,)
.await;
assert_eq!(d, Decision::Allow);
@@ -2922,6 +3243,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2937,6 +3260,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -2986,7 +3311,7 @@ mod tests {
let d = tokio::time::timeout(
std::time::Duration::from_secs(5),
mgr.request(AccessKind::Bash("ls".into()), tool_call(), None, None, None),
mgr.request(AccessKind::Bash("ls".into()), tool_call(), None, None, None, cwd.clone(), None,),
)
.await
.expect("permission request must resolve, not hang");
@@ -3023,7 +3348,7 @@ mod tests {
let cmd = "curl http://example.com && sh -c 'echo hi'";
let d = tokio::time::timeout(
std::time::Duration::from_secs(5),
mgr.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None),
mgr.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None, cwd.clone(), None,),
)
.await
.expect("permission request must resolve, not hang");
@@ -3058,7 +3383,15 @@ mod tests {
let d = tokio::time::timeout(
std::time::Duration::from_secs(5),
mgr.request(AccessKind::Bash("ls".into()), tool_call(), None, None, None),
mgr.request(
AccessKind::Bash("ls".into()),
tool_call(),
None,
None,
None,
cwd.clone(),
None,
),
)
.await
.expect("permission request must resolve, not hang");
@@ -3101,6 +3434,8 @@ mod tests {
session_id: None,
subagent_type: None,
subagent_description: None,
tool_cwd: cwd.clone(),
display_cwd: None,
})
.expect("actor alive");
@@ -3112,6 +3447,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -3209,6 +3546,8 @@ mod tests {
session_id: None,
subagent_type: None,
subagent_description: None,
tool_cwd: cwd.clone(),
display_cwd: None,
})
.expect("actor alive");
tokio::time::timeout(std::time::Duration::from_secs(5), async {
@@ -3228,6 +3567,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -3264,6 +3605,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert_eq!(d, Decision::Allow);
@@ -3302,6 +3645,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -3389,6 +3734,7 @@ mod tests {
// Request A parks in the gated prompt; B then arrives and overlaps it.
let mgr_a = mgr.clone();
let cwd_a = cwd.clone();
let a = tokio::task::spawn_local(async move {
mgr_a
.request(
@@ -3397,6 +3743,8 @@ mod tests {
None,
None,
None,
cwd_a,
None,
)
.await
});
@@ -3413,6 +3761,7 @@ mod tests {
"request A must reach its prompt before B is sent"
);
let mgr_b = mgr.clone();
let cwd_b = cwd.clone();
let b = tokio::task::spawn_local(async move {
mgr_b
.request(
@@ -3421,6 +3770,8 @@ mod tests {
None,
None,
None,
cwd_b,
None,
)
.await
});
@@ -3501,7 +3852,15 @@ mod tests {
);
let d = tokio::time::timeout(
std::time::Duration::from_secs(5),
mgr.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None),
mgr.request(
AccessKind::Bash(cmd.into()),
tool_call(),
None,
None,
None,
cwd.clone(),
None,
),
)
.await
.expect("permission request must resolve, not hang");
@@ -3603,6 +3962,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -4805,6 +5166,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4821,6 +5184,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4837,6 +5202,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4855,6 +5222,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4888,7 +5257,15 @@ 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)
.request(
AccessKind::Edit(in_cwd),
mk("tc-edit-in"),
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
matches!(d, Decision::Allow),
@@ -4903,6 +5280,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4937,6 +5316,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4950,6 +5331,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -4992,6 +5375,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -5070,6 +5455,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -5123,6 +5510,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5185,6 +5574,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5235,6 +5626,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5280,6 +5673,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5326,6 +5721,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5375,6 +5772,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5420,8 +5819,7 @@ mod tests {
tool_call(),
None,
None,
None,
),
None, cwd.clone(), None,),
)
.await
.expect("must resolve, not hang");
@@ -5461,6 +5859,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
)
.await;
assert!(
@@ -5506,6 +5906,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -5555,6 +5957,8 @@ mod tests {
None,
None,
None,
cwd.clone(),
None,
),
)
.await
@@ -3,7 +3,10 @@ use crate::permission::shell_access::combine_decisions;
use crate::permission::types::{
AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
use kigi_paths::normalize_lexically;
use kigi_tools::implementations::kigi::web_fetch::domain::normalize_domain;
use kigi_tools::types::resources::resolve_model_path;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy)]
enum MatchContext {
@@ -158,6 +161,65 @@ impl CompiledPolicy {
}
}
impl CompiledPolicy {
/// Like [`Self::evaluate`], and for a native Edit/Read/Grep path also
/// checks deny/ask rules against the path the tool opens (sanitized,
/// `~`-expanded, display-remapped, joined to `cwd`) and against its
/// symlink-followed target, each as the absolute, `./`-relative and
/// relative spelling. Escalation only: no allow is granted through a link
/// or an alternate spelling. The flag is set when a link cannot be
/// followed (cycle, unreadable) while a deny/ask rule covers the tool:
/// the `Ask` it forces must survive the YOLO and auto fast paths.
pub fn evaluate_with_cwd(
&self,
access: &AccessKind,
cwd: &Path,
display_cwd: Option<&Path>,
) -> (Option<Decision>, bool) {
let lexical = self.evaluate(access);
let Some(path) = native_file_path(access) else {
return (lexical, false);
};
let opened = resolve_model_path(cwd, display_cwd, path);
let mut spellings = path_spellings(
&normalize_lexically(&opened),
Some(&normalize_lexically(cwd)),
);
let mut unresolved_link = false;
match resolve_following_symlinks(&opened) {
Some(on_disk) => {
spellings.extend(path_spellings(
&on_disk,
dunce::canonicalize(cwd).ok().as_deref(),
));
}
None => {
unresolved_link =
path_has_symlink(&opened) && self.native_path_restrictions_apply(access);
}
}
let mut escalation = spellings.into_iter().fold(None, |acc, spelling| {
let decision = match self.evaluate(&access_with_path(access, spelling)) {
Some(decision @ (Decision::Reject(_) | Decision::Ask)) => Some(decision),
_ => None,
};
combine_decisions(acc, decision)
});
if unresolved_link {
escalation = combine_decisions(escalation, Some(Decision::Ask));
}
(combine_decisions(lexical, escalation), unresolved_link)
}
/// Any deny/ask rule whose tool filter covers this access, Grep included.
fn native_path_restrictions_apply(&self, access: &AccessKind) -> bool {
self.config.rules.iter().any(|rule| {
matches!(rule.action, RuleAction::Deny | RuleAction::Ask)
&& tool_filter_matches(access, &rule.tool)
})
}
}
impl From<PermissionConfig> for CompiledPolicy {
fn from(config: PermissionConfig) -> Self {
Self::new(config)
@@ -192,6 +254,102 @@ fn shell_dash_c_script(words: &[String]) -> Option<&str> {
None
}
fn native_file_path(access: &AccessKind) -> Option<&str> {
match access {
AccessKind::Edit(path) => Some(path.as_str()),
AccessKind::Read(Some(path)) => Some(path.as_str()),
AccessKind::Grep {
path: Some(path), ..
} => Some(path.as_str()),
_ => None,
}
}
fn access_with_path(access: &AccessKind, path: String) -> AccessKind {
match access {
AccessKind::Edit(_) => AccessKind::Edit(path),
AccessKind::Read(_) => AccessKind::Read(Some(path)),
AccessKind::Grep { glob, .. } => AccessKind::Grep {
path: Some(path),
glob: glob.clone(),
},
_ => unreachable!("caller filters via native_file_path"),
}
}
/// The absolute spelling plus, under `root`, the `./`-relative and relative
/// spellings rules are written in.
fn path_spellings(path: &Path, root: Option<&Path>) -> Vec<String> {
let mut forms = vec![path_match_string(path)];
if let Some(relative) = root.and_then(|root| path.strip_prefix(root).ok()) {
if relative.as_os_str().is_empty() {
forms.extend([".".to_owned(), "./".to_owned()]);
} else {
let relative = path_match_string(relative);
forms.push(format!("./{relative}"));
forms.push(relative);
}
}
forms
}
/// Windows separators become `/` so `/`-written rules match; a Unix
/// backslash is a filename character and stays.
fn path_match_string(path: &Path) -> String {
let text = path.to_string_lossy();
if cfg!(windows) {
text.replace('\\', "/")
} else {
text.into_owned()
}
}
/// True if any existing component of `absolute` is a symlink.
fn path_has_symlink(absolute: &Path) -> bool {
let mut prefix = PathBuf::new();
absolute.components().any(|component| {
prefix.push(component);
std::fs::symlink_metadata(&prefix).is_ok_and(|meta| meta.file_type().is_symlink())
})
}
/// Follow every symlink, including dangling leaves and missing trailing
/// components; `None` on cycles, depth limits or unreadable links.
fn resolve_following_symlinks(path: &Path) -> Option<PathBuf> {
fn walk(path: &Path, depth: usize) -> Option<PathBuf> {
const MAX_SYMLINK_DEPTH: usize = 40;
if depth > MAX_SYMLINK_DEPTH {
return None;
}
// `dunce` avoids Windows `\\?\` verbatim paths (repo convention).
if let Ok(canonical) = dunce::canonicalize(path) {
return Some(canonical);
}
// Parent-first so a dangling or not-yet-created leaf still follows links.
let parent = path.parent()?;
let file_name = path.file_name()?;
let resolved_parent = walk(parent, depth + 1)?;
let candidate = resolved_parent.join(file_name);
// NotFound is a new path; any other metadata error fails closed.
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()) {
let target = std::fs::read_link(&candidate).ok()?;
let target = if target.is_absolute() {
target
} else {
resolved_parent.join(target)
};
return walk(&target, depth + 1);
}
Some(candidate)
}
walk(path, 0)
}
fn tool_filter_matches(access: &AccessKind, filter: &ToolFilter) -> bool {
match filter {
ToolFilter::Any => true,
@@ -875,4 +1033,387 @@ mod tests {
.is_none()
);
}
#[cfg(unix)]
fn file_rule(action: RuleAction, tool: ToolFilter, pattern: &str) -> PermissionRule {
PermissionRule {
action,
tool,
pattern: Some(pattern.to_owned()),
pattern_mode: PatternMode::Glob,
}
}
#[cfg(unix)]
fn leaf_access(tool: &ToolFilter, arg: &str) -> AccessKind {
match tool {
ToolFilter::Edit => AccessKind::Edit(arg.into()),
ToolFilter::Read => AccessKind::Read(Some(arg.into())),
ToolFilter::Grep => AccessKind::Grep {
path: Some(arg.into()),
glob: None,
},
_ => panic!("unexpected tool"),
}
}
#[cfg(unix)]
fn policy_of(rules: Vec<PermissionRule>) -> CompiledPolicy {
CompiledPolicy::new(PermissionConfig::new(rules))
}
/// Workspace with `link -> <outside>/deny-secret.txt`.
#[cfg(unix)]
fn linked_secret() -> (tempfile::TempDir, tempfile::TempDir) {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("deny-secret.txt");
std::fs::write(&secret, b"secret").unwrap();
std::os::unix::fs::symlink(&secret, ws.path().join("link")).unwrap();
(ws, outside)
}
#[test]
#[cfg(unix)]
fn deny_matches_workspace_symlink_target_for_edit_read_grep() {
let (ws, _outside) = linked_secret();
for tool in [ToolFilter::Edit, ToolFilter::Read, ToolFilter::Grep] {
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
tool.clone(),
"**/deny-secret.txt",
)]);
let decision = policy
.evaluate_with_cwd(&leaf_access(&tool, "link"), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for {tool:?} via symlink, got {decision:?}"
);
}
}
#[test]
#[cfg(unix)]
fn sanitized_spellings_still_follow_the_link() {
let (ws, _outside) = linked_secret();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"**/deny-secret.txt",
)]);
for spelling in [" link ", "\"link\"", "'link'", "./link"] {
let decision = policy
.evaluate_with_cwd(&AccessKind::Read(Some(spelling.into())), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for {spelling:?}, got {decision:?}"
);
}
}
#[test]
#[cfg(unix)]
fn display_cwd_paths_follow_the_worktree_link() {
let (worktree, _outside) = linked_secret();
let project = tempfile::tempdir().unwrap();
std::fs::write(project.path().join("link"), b"plain").unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Edit,
"**/deny-secret.txt",
)]);
let access = AccessKind::Edit(project.path().join("link").to_string_lossy().into_owned());
let remapped = policy
.evaluate_with_cwd(&access, worktree.path(), Some(project.path()))
.0;
assert!(
matches!(remapped, Some(Decision::Reject(_))),
"got {remapped:?}"
);
assert_eq!(
policy.evaluate_with_cwd(&access, worktree.path(), None).0,
None
);
}
#[test]
#[cfg(unix)]
fn relative_and_absolute_rules_match_the_opened_file() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join("private")).unwrap();
std::fs::write(ws.path().join("private/secret.txt"), b"secret").unwrap();
std::os::unix::fs::symlink(ws.path().join("private/secret.txt"), ws.path().join("link"))
.unwrap();
let root = dunce::canonicalize(ws.path()).unwrap();
let absolute_rule = format!("{}/private/**", root.to_string_lossy());
for (pattern, access) in [
("private/**", AccessKind::Edit("link".into())),
("private/secret.txt", AccessKind::Edit("link".into())),
(
absolute_rule.as_str(),
AccessKind::Read(Some("private/secret.txt".into())),
),
(
"private/**",
AccessKind::Read(Some(
root.join("private/secret.txt")
.to_string_lossy()
.into_owned(),
)),
),
] {
let policy = policy_of(vec![file_rule(RuleAction::Deny, ToolFilter::Any, pattern)]);
let decision = policy.evaluate_with_cwd(&access, ws.path(), None).0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for rule {pattern:?} on {access:?}, got {decision:?}"
);
}
}
#[test]
#[cfg(unix)]
fn midpath_symlink_and_physical_dotdot_hit_the_deny() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let zone = outside.path().join("prohibited-zone");
std::fs::create_dir_all(zone.join("dir")).unwrap();
std::fs::create_dir_all(zone.join("dir2")).unwrap();
std::fs::write(zone.join("data.txt"), b"secret").unwrap();
std::fs::write(zone.join("dir2/x"), b"secret").unwrap();
std::os::unix::fs::symlink(&zone, ws.path().join("linked")).unwrap();
std::os::unix::fs::symlink(zone.join("dir"), ws.path().join("link")).unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Any,
"**/prohibited-zone/**",
)]);
for access in [
AccessKind::Edit("linked/data.txt".into()),
AccessKind::Read(Some("link/../dir2/x".into())),
] {
let decision = policy.evaluate_with_cwd(&access, ws.path(), None).0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for {access:?}, got {decision:?}"
);
}
}
#[test]
#[cfg(unix)]
fn dangling_leaf_symlink_deny_on_target_rejects() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret_dir = outside.path().join("prohibited-zone");
std::fs::create_dir(&secret_dir).unwrap();
std::os::unix::fs::symlink(secret_dir.join("new.txt"), ws.path().join("out")).unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Edit,
"**/prohibited-zone/**",
)]);
let decision = policy
.evaluate_with_cwd(&AccessKind::Edit("out".into()), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"got {decision:?}"
);
}
#[test]
#[cfg(unix)]
fn target_rules_escalate_only() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("target.txt");
std::fs::write(&target, b"x").unwrap();
std::fs::create_dir(ws.path().join("config")).unwrap();
std::os::unix::fs::symlink(&target, ws.path().join("config/notes.txt")).unwrap();
let access = AccessKind::Edit("config/notes.txt".into());
// Allow on the target is not granted via the link.
let allow = policy_of(vec![file_rule(
RuleAction::Allow,
ToolFilter::Edit,
"**/target.txt",
)]);
assert_eq!(allow.evaluate_with_cwd(&access, ws.path(), None).0, None);
// Ask on the target does apply.
let ask = policy_of(vec![file_rule(
RuleAction::Ask,
ToolFilter::Edit,
"**/target.txt",
)]);
assert_eq!(
ask.evaluate_with_cwd(&access, ws.path(), None).0,
Some(Decision::Ask)
);
// Deny on the target beats allow on the link.
let both = policy_of(vec![
file_rule(RuleAction::Allow, ToolFilter::Edit, "**/notes.txt"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/target.txt"),
]);
assert!(matches!(
both.evaluate_with_cwd(&access, ws.path(), None).0,
Some(Decision::Reject(_))
));
// A plain file keeps the lexical decision.
std::fs::write(ws.path().join("config/plain.txt"), b"x").unwrap();
let plain = policy_of(vec![file_rule(
RuleAction::Allow,
ToolFilter::Edit,
"**/plain.txt",
)]);
assert_eq!(
plain
.evaluate_with_cwd(
&AccessKind::Edit("config/plain.txt".into()),
ws.path(),
None
)
.0,
Some(Decision::Allow)
);
}
#[test]
#[cfg(unix)]
fn unresolvable_symlink_asks_only_when_a_rule_covers_the_tool() {
let ws = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(ws.path().join("b"), ws.path().join("a")).unwrap();
std::os::unix::fs::symlink(ws.path().join("a"), ws.path().join("b")).unwrap();
let grep = AccessKind::Grep {
path: Some("a".into()),
glob: None,
};
let grep_only = policy_of(vec![file_rule(RuleAction::Deny, ToolFilter::Grep, "**/x")]);
assert_eq!(
grep_only.evaluate_with_cwd(&grep, ws.path(), None).0,
Some(Decision::Ask)
);
assert_eq!(
grep_only
.evaluate_with_cwd(&AccessKind::Read(Some("a".into())), ws.path(), None)
.0,
None
);
assert_eq!(
policy_of(vec![])
.evaluate_with_cwd(&grep, ws.path(), None)
.0,
None
);
}
#[test]
#[cfg(unix)]
fn unresolved_link_reports_the_forced_prompt() {
let ws = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(ws.path().join("b"), ws.path().join("a")).unwrap();
std::os::unix::fs::symlink(ws.path().join("a"), ws.path().join("b")).unwrap();
std::fs::write(ws.path().join("plain.txt"), b"x").unwrap();
let policy = policy_of(vec![file_rule(RuleAction::Deny, ToolFilter::Edit, "**/x")]);
assert_eq!(
policy.evaluate_with_cwd(&AccessKind::Edit("a".into()), ws.path(), None),
(Some(Decision::Ask), true)
);
assert_eq!(
policy.evaluate_with_cwd(&AccessKind::Edit("plain.txt".into()), ws.path(), None),
(None, false)
);
}
#[test]
#[cfg(unix)]
fn dot_slash_rules_match_the_followed_target() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join("private")).unwrap();
std::fs::write(ws.path().join("private/data.txt"), b"secret").unwrap();
std::os::unix::fs::symlink(ws.path().join("private/data.txt"), ws.path().join("link"))
.unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"./private/**",
)]);
let decision = policy
.evaluate_with_cwd(&AccessKind::Read(Some("link".into())), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"got {decision:?}"
);
}
#[test]
#[cfg(unix)]
fn sanitized_spelling_of_a_denied_link_name_rejects() {
let ws = tempfile::tempdir().unwrap();
std::fs::write(ws.path().join("allowed.txt"), b"x").unwrap();
std::os::unix::fs::symlink(ws.path().join("allowed.txt"), ws.path().join("secret.txt"))
.unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"**/secret.txt",
)]);
for spelling in [" secret.txt ", "\"secret.txt\"", "./secret.txt"] {
let decision = policy
.evaluate_with_cwd(&AccessKind::Read(Some(spelling.into())), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for {spelling:?}, got {decision:?}"
);
}
}
#[test]
#[cfg(unix)]
fn unix_backslash_in_a_file_name_is_not_a_separator() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join("private")).unwrap();
let odd = ws.path().join("private/secret\\name.txt");
std::fs::write(&odd, b"secret").unwrap();
std::os::unix::fs::symlink(&odd, ws.path().join("odd-link")).unwrap();
let policy = policy_of(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"**/secret*.txt",
)]);
let decision = policy
.evaluate_with_cwd(&AccessKind::Read(Some("odd-link".into())), ws.path(), None)
.0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"got {decision:?}"
);
}
#[test]
#[cfg(unix)]
fn root_alias_matches_the_dot_slash_rule() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(ws.path().join("fixture.txt"), b"x").unwrap();
std::os::unix::fs::symlink(ws.path(), outside.path().join("alias")).unwrap();
let policy = policy_of(vec![file_rule(RuleAction::Deny, ToolFilter::Grep, "./**")]);
for path in [
"./".to_owned(),
outside.path().join("alias").to_string_lossy().into_owned(),
] {
let access = AccessKind::Grep {
path: Some(path.clone()),
glob: None,
};
let decision = policy.evaluate_with_cwd(&access, ws.path(), None).0;
assert!(
matches!(decision, Some(Decision::Reject(_))),
"expected Reject for {path:?}, got {decision:?}"
);
}
}
}
@@ -228,6 +228,11 @@ pub enum PermissionCommand {
subagent_type: Option<String>,
/// Subagent description if this request is from a child.
subagent_description: Option<String>,
/// Where the requesting tool resolves relative paths: a child
/// session's worktree, not the manager's root.
tool_cwd: kigi_paths::AbsPathBuf,
/// Project path the tool remaps onto `tool_cwd` for forked sessions.
display_cwd: Option<std::path::PathBuf>,
},
/// Set the YOLO mode (auto-approve all permissions)
SetYoloMode(bool),