diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/tests/subagent_spawn_context_tests.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/tests/subagent_spawn_context_tests.rs index 170b2aa..4f52a38 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/tests/subagent_spawn_context_tests.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/tests/subagent_spawn_context_tests.rs @@ -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!( diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/tool_calls.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/tool_calls.rs index b291d8e..ef534cc 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/tool_calls.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/tool_calls.rs @@ -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 }; diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/permission_auto_mode_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/permission_auto_mode_tests.rs index 7d87ce3..f3d8894 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/permission_auto_mode_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/permission_auto_mode_tests.rs @@ -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!( diff --git a/crates/codegen/kigi-shell/tests/test_mcp_permission_persistence.rs b/crates/codegen/kigi-shell/tests/test_mcp_permission_persistence.rs index 98adad6..892ec31 100644 --- a/crates/codegen/kigi-shell/tests/test_mcp_permission_persistence.rs +++ b/crates/codegen/kigi-shell/tests/test_mcp_permission_persistence.rs @@ -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"); diff --git a/crates/codegen/kigi-tui/docs/user-guide/22-permissions-and-safety.md b/crates/codegen/kigi-tui/docs/user-guide/22-permissions-and-safety.md index af24c38..3d39763 100644 --- a/crates/codegen/kigi-tui/docs/user-guide/22-permissions-and-safety.md +++ b/crates/codegen/kigi-tui/docs/user-guide/22-permissions-and-safety.md @@ -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 diff --git a/crates/codegen/kigi-workspace/src/permission/manager.rs b/crates/codegen/kigi-workspace/src/permission/manager.rs index 54d88ec..e047a52 100644 --- a/crates/codegen/kigi-workspace/src/permission/manager.rs +++ b/crates/codegen/kigi-workspace/src/permission/manager.rs @@ -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, subagent_type: Option, subagent_description: Option, + tool_cwd: AbsPathBuf, + display_cwd: Option, ) -> 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, + } + #[async_trait::async_trait(?Send)] + impl acp::Client for SessionEditsThenRejectClient { + async fn request_permission( + &self, + args: acp::RequestPermissionRequest, + ) -> acp::Result { + 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 diff --git a/crates/codegen/kigi-workspace/src/permission/policy.rs b/crates/codegen/kigi-workspace/src/permission/policy.rs index 70a0987..84ea15b 100644 --- a/crates/codegen/kigi-workspace/src/permission/policy.rs +++ b/crates/codegen/kigi-workspace/src/permission/policy.rs @@ -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, 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 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 { + 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 { + fn walk(path: &Path, depth: usize) -> Option { + 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) -> CompiledPolicy { + CompiledPolicy::new(PermissionConfig::new(rules)) + } + + /// Workspace with `link -> /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:?}" + ); + } + } } diff --git a/crates/codegen/kigi-workspace/src/permission/types.rs b/crates/codegen/kigi-workspace/src/permission/types.rs index 25b25f7..1fce8ba 100644 --- a/crates/codegen/kigi-workspace/src/permission/types.rs +++ b/crates/codegen/kigi-workspace/src/permission/types.rs @@ -228,6 +228,11 @@ pub enum PermissionCommand { subagent_type: Option, /// Subagent description if this request is from a child. subagent_description: Option, + /// 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, }, /// Set the YOLO mode (auto-approve all permissions) SetYoloMode(bool),