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:
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).
|
||||
`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),
|
||||
|
||||
Reference in new issue
Block a user