1 //! Tool permission policy: which agent tools may run, and when to ask.
2 //!
3 //! Every tool call funnels through one decision point before execution
4 //! (`decision_for`). Tools are classified by risk: *read-only* tools (reading
5 //! files, searching, listing, fetching a web page) always run, while *mutating*
6 //! tools (writing files, deleting, shell commands, MCP tools) are governed by
7 //! policy. The policy layers, first match wins:
8 //!
9 //! 1. **Plan mode** — a per-session switch that denies every mutating tool with
10 //! a message telling the model to present a plan instead. Toggled via
11 //! `/plan on|off` (TUI and ACP).
12 //! 2. **Session grants** — "always allow this session", recorded when the user
13 //! picks that option in an approval prompt. For `run_command` the grant is
14 //! scoped to the command's first two whitespace-separated tokens (approving
15 //! `git push origin main` records `run_command(git push)`); other tools
16 //! record the bare tool name.
17 //! 3. **Rule lists** — `[permissions.rules]` in `settings.toml`: ordered
18 //! `deny` and `allow` lists of rules shaped `tool_name` or
19 //! `tool_name(argument_pattern)`, e.g. `run_command(git status)`,
20 //! `run_command(cargo *)`, `edit_file(src/*)`. The pattern matches the
21 //! command string for `run_command` and the path argument for the
22 //! file-mutating tools; for tools with no obvious argument (MCP tools,
23 //! unknown tools) only a bare `tool_name` rule matches. `deny` is checked
24 //! before `allow`, so a deny always beats an allow matching the same call.
25 //! 4. **Per-tool override** — `[permissions.tools]` in `settings.toml`, e.g.
26 //! `run_command = "ask"`, `edit_file = "allow"`, `delete_file = "deny"`.
27 //! 5. **Default mode** — `[permissions] default = "ask"|"allow"|"deny"` in
28 //! `settings.toml`; `ask` on a fresh install.
29 //!
30 //! Pattern matching (rules and session grants share it): `*` is a glob-style
31 //! wildcard (the `glob` tool's translator). A pattern ending in `*` matches
32 //! everything from the wildcard on — `run_command(cargo *)` covers
33 //! `cargo build src/main.rs`. A pattern without a trailing `*` must be the
34 //! whole argument or end at a whitespace boundary: `run_command(git status)`
35 //! matches `git status` and `git status --short` but not `git status-x`.
36 //!
37 //! The `SIGIT_PERMISSIONS` env var (`allow`/`ask`/`deny`) overrides the stored
38 //! default without writing the file — the escape hatch for ACP clients that
39 //! cannot answer `session/request_permission` and for CI/headless runs.
40 //!
41 //! Tools discovered from MCP servers (`mcp__*`) and any unknown tool name are
42 //! treated as mutating: external tools can have arbitrary side effects, so the
43 //! safe assumption is to gate them.
44 //!
45 //! Session state (grants + plan mode) lives in a process-global keyed by
46 //! session id — the same pattern as `mcp.rs`'s server cache — so the ACP
47 //! multi-session surface and the single-session TUI share one implementation.
48
49 use std::collections::{HashMap, HashSet};
50 use std::sync::{Mutex, OnceLock};
51
52 use regex::Regex;
53
54 use crate::settings::{self, PermissionMode};
55 use crate::tools::glob_to_regex;
56
57 /// Session key used by the interactive TUI, which only ever has one session.
58 /// The TUI (`chat.rs`) is `#[cfg(unix)]`, so this is its only consumer and is
59 /// dead on non-Unix targets — the rest of the module is used on all platforms.
60 #[cfg_attr(not(unix), allow(dead_code))]
61 pub const TUI_SESSION: &str = "tui";
62
63 /// How risky a tool is to run without the user's sign-off.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 pub enum ToolRisk {
66 /// Observes state without changing it; always allowed to run.
67 ReadOnly,
68 /// Changes files, runs commands, or has unknown side effects; governed by
69 /// the permission policy.
70 Mutating,
71 }
72
73 /// The outcome of the policy check for one tool call.
74 #[derive(Debug, Clone, PartialEq, Eq)]
75 pub enum Decision {
76 /// Run the tool without asking.
77 Allow,
78 /// Ask the user before running (surface-specific: ACP permission request
79 /// or TUI approval prompt).
80 Ask,
81 /// Do not run the tool; the string is returned to the model as the tool
82 /// result so it can adapt instead of retrying blindly.
83 Deny(String),
84 }
85
86 /// Classify a tool by name. Unknown names and MCP tools are mutating: the
87 /// conservative default for anything whose side effects we can't see.
88 /// `task` is read-only because the subagent it launches is restricted to the
89 /// read-only toolset (see `SUBAGENT_TOOL_NAMES` in `tools.rs`), so delegated
90 /// research stays available in plan mode.
91 pub fn classify(tool_name: &str) -> ToolRisk {
92 match tool_name {
93 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
94 | "write_todos" | "skill" | "task" | "command_output" => ToolRisk::ReadOnly,
95 _ => ToolRisk::Mutating,
96 }
97 }
98
99 /// Per-session permission state.
100 #[derive(Default)]
101 struct SessionPerms {
102 /// Tools the user chose "always allow this session" for.
103 always_allow: HashSet<String>,
104 /// When set, every mutating tool is denied with a plan-mode message.
105 plan_mode: bool,
106 }
107
108 fn sessions() -> &'static Mutex<HashMap<String, SessionPerms>> {
109 static SESSIONS: OnceLock<Mutex<HashMap<String, SessionPerms>>> = OnceLock::new();
110 SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
111 }
112
113 fn with_session<T>(session: &str, f: impl FnOnce(&mut SessionPerms) -> T) -> T {
114 let mut map = sessions()
115 .lock()
116 .unwrap_or_else(|poisoned| poisoned.into_inner());
117 f(map.entry(session.to_string()).or_default())
118 }
119
120 /// The message returned to the model when a mutating tool is blocked by plan
121 /// mode. Instructive rather than terse so the model changes course in one turn.
122 fn plan_mode_denial(tool_name: &str) -> String {
123 format!(
124 "Plan mode is active: `{tool_name}` was not executed because it modifies state. \
125 Present a concise plan of the changes you intend to make and ask the user to \
126 approve it (they can run /plan off to enable execution). Read-only tools \
127 (read_file, search_files, glob, list_directory) remain available for research."
128 )
129 }
130
131 /// The message returned to the model when the user (or policy) denies a tool.
132 pub fn user_denial(tool_name: &str) -> String {
133 format!(
134 "The user denied permission to run `{tool_name}`. Do not retry the same call. \
135 Explain what you wanted to do and ask the user how to proceed, or continue \
136 with an approach that does not need this tool."
137 )
138 }
139
140 /// Render a tool call's arguments for an approval prompt. The person deciding
141 /// must be able to see what they are approving, so the cap is generous and any
142 /// cut is marked with how much is hidden — silently truncating could hide the
143 /// tail of a command from the user who is about to allow it.
144 pub fn approval_preview(arguments: &str) -> String {
145 const MAX_CHARS: usize = 500;
146 let total = arguments.chars().count();
147 if total <= MAX_CHARS {
148 return arguments.to_string();
149 }
150 let shown: String = arguments.chars().take(MAX_CHARS).collect();
151 format!("{shown}… [+{} more chars]", total - MAX_CHARS)
152 }
153
154 // ── Rule patterns ────────────────────────────────────────────────────────────
155 // A rule is `tool_name` or `tool_name(argument_pattern)`; rules from
156 // `[permissions.rules]` and session grants share this matcher.
157
158 /// Split a rule into tool name and optional argument pattern:
159 /// `run_command(git *)` → `("run_command", Some("git *"))`;
160 /// `edit_file` → `("edit_file", None)`.
161 fn parse_rule(rule: &str) -> (&str, Option<&str>) {
162 let rule = rule.trim();
163 if let Some(open) = rule.find('(')
164 && let Some(inner) = rule[open + 1..].strip_suffix(')')
165 {
166 return (rule[..open].trim(), Some(inner));
167 }
168 (rule, None)
169 }
170
171 /// Compile a rule's argument pattern. Reuses the `glob` tool's translator
172 /// (`*`, `**`, `?`, `{a,b}`), then swaps its end anchor for rule semantics:
173 /// a pattern ending in `*` matches everything from the wildcard on (prefix
174 /// semantics — `cargo *` covers `cargo build src/main.rs`), while any other
175 /// pattern must be the whole argument or end at a whitespace boundary
176 /// (`git status` matches `git status --short` but not `git status-x`).
177 fn pattern_regex(pattern: &str) -> Option<Regex> {
178 let anchored = glob_to_regex(pattern);
179 let body = anchored.strip_suffix('$').unwrap_or(&anchored);
180 let source = if pattern.ends_with('*') {
181 body.to_string()
182 } else {
183 format!("{body}(?:$|\\s)")
184 };
185 Regex::new(&source).ok()
186 }
187
188 /// Whether one rule covers one tool call. A bare `tool_name` rule matches any
189 /// call of that tool; a pattern rule additionally needs the call's matchable
190 /// argument to fit the pattern — so a pattern rule never matches a tool that
191 /// has no matchable argument (an unreadable rule must not widen access).
192 fn rule_matches(rule: &str, tool_name: &str, argument: Option<&str>) -> bool {
193 let (rule_tool, pattern) = parse_rule(rule);
194 if rule_tool != tool_name {
195 return false;
196 }
197 match (pattern, argument) {
198 (None, _) => true,
199 (Some(pattern), Some(argument)) => {
200 pattern_regex(pattern).is_some_and(|re| re.is_match(argument))
201 }
202 (Some(_), None) => false,
203 }
204 }
205
206 /// The argument a rule pattern is matched against, extracted from the tool
207 /// call's raw JSON arguments: the command string for `run_command`, the path
208 /// for the file-mutating tools. Read-only tools never reach the matcher, and
209 /// other tools (MCP, unknown) have no obvious single argument, so they return
210 /// `None` and are governed only by bare `tool_name` rules.
211 fn matchable_argument(tool_name: &str, arguments: &str) -> Option<String> {
212 let key = match tool_name {
213 "run_command" => "command",
214 "edit_file" | "create_file" | "multi_edit" | "delete_file" | "create_directory" => "path",
215 _ => return None,
216 };
217 let value: serde_json::Value = serde_json::from_str(arguments).ok()?;
218 Some(value.get(key)?.as_str()?.to_string())
219 }
220
221 /// Policy check for one tool call. `arguments` is the call's raw JSON argument
222 /// string, consulted by rule patterns and granular session grants. See the
223 /// module docs for the layering.
224 pub fn decision_for(session: &str, tool_name: &str, arguments: &str) -> Decision {
225 if classify(tool_name) == ToolRisk::ReadOnly {
226 return Decision::Allow;
227 }
228
229 let argument = matchable_argument(tool_name, arguments);
230 let (plan_mode, granted) = with_session(session, |s| {
231 (
232 s.plan_mode,
233 s.always_allow
234 .iter()
235 .any(|grant| rule_matches(grant, tool_name, argument.as_deref())),
236 )
237 });
238
239 if plan_mode {
240 return Decision::Deny(plan_mode_denial(tool_name));
241 }
242 if granted {
243 return Decision::Allow;
244 }
245
246 let rules = settings::permission_rules();
247 if let Some(rule) = rules
248 .deny
249 .iter()
250 .find(|rule| rule_matches(rule, tool_name, argument.as_deref()))
251 {
252 return Decision::Deny(format!(
253 "`{tool_name}` is denied by the permission rule `{rule}` in settings.toml. \
254 Do not retry it; work without this tool or ask the user to change \
255 the policy."
256 ));
257 }
258 if rules
259 .allow
260 .iter()
261 .any(|rule| rule_matches(rule, tool_name, argument.as_deref()))
262 {
263 return Decision::Allow;
264 }
265
266 match settings::permission_mode_for(tool_name) {
267 PermissionMode::Allow => Decision::Allow,
268 PermissionMode::Ask => Decision::Ask,
269 PermissionMode::Deny => Decision::Deny(format!(
270 "`{tool_name}` is denied by the permission policy in settings.toml. \
271 Do not retry it; work without this tool or ask the user to change \
272 the policy."
273 )),
274 }
275 }
276
277 /// Record an "always allow this session" grant for a tool call. For
278 /// `run_command` the grant is scoped to the command's first two
279 /// whitespace-separated tokens (approving `git push origin main` records
280 /// `run_command(git push)`, covering the `git push …` family only); other
281 /// tools record the bare tool name, matching any call.
282 pub fn grant_for_session(session: &str, tool_name: &str, arguments: &str) {
283 let grant = session_grant_rule(tool_name, arguments);
284 with_session(session, |s| {
285 s.always_allow.insert(grant);
286 });
287 }
288
289 /// The rule string recorded for one approved call (see [`grant_for_session`]).
290 /// Falls back to the bare tool name when the command is absent or empty, which
291 /// grants the whole tool — exactly what the pre-granular behavior was.
292 fn session_grant_rule(tool_name: &str, arguments: &str) -> String {
293 if tool_name == "run_command"
294 && let Some(command) = matchable_argument(tool_name, arguments)
295 {
296 let prefix: Vec<&str> = command.split_whitespace().take(2).collect();
297 if !prefix.is_empty() {
298 return format!("{tool_name}({})", prefix.join(" "));
299 }
300 }
301 tool_name.to_string()
302 }
303
304 /// Toggle plan mode for a session. Returns the new state.
305 pub fn set_plan_mode(session: &str, enabled: bool) -> bool {
306 with_session(session, |s| {
307 s.plan_mode = enabled;
308 s.plan_mode
309 })
310 }
311
312 /// Whether plan mode is active for a session.
313 pub fn plan_mode(session: &str) -> bool {
314 with_session(session, |s| s.plan_mode)
315 }
316
317 /// Drop all recorded state for a session (fresh session, /clear, or session
318 /// teardown) so grants never outlive the conversation they were given in.
319 pub fn reset_session(session: &str) {
320 let mut map = sessions()
321 .lock()
322 .unwrap_or_else(|poisoned| poisoned.into_inner());
323 map.remove(session);
324 }
325
326 /// Drop the recorded state for *every* session. Called at ACP session
327 /// boundaries (new/load/fork): the agent drives one shared engine, so only one
328 /// conversation is live at a time and grants must never cross a boundary. This
329 /// also keeps the map from accumulating entries for session ids that will
330 /// never be used again.
331 pub fn reset_all() {
332 let mut map = sessions()
333 .lock()
334 .unwrap_or_else(|poisoned| poisoned.into_inner());
335 map.clear();
336 }
337
338 /// Status summary for `/permissions` and `/status`: the default mode, plan
339 /// mode, the granular session grants, and the active rule lists.
340 pub fn describe(session: &str) -> String {
341 let plan = if plan_mode(session) { "on" } else { "off" };
342 let default = settings::permission_default();
343 let granted = with_session(session, |s| {
344 let mut names: Vec<&str> = s.always_allow.iter().map(String::as_str).collect();
345 names.sort_unstable();
346 names.join(", ")
347 });
348 let granted = if granted.is_empty() {
349 "none".to_string()
350 } else {
351 granted
352 };
353 let rules = settings::permission_rules();
354 let render = |list: &[String]| {
355 if list.is_empty() {
356 "none".to_string()
357 } else {
358 list.join(", ")
359 }
360 };
361 format!(
362 "permissions: default={default} | plan mode: {plan} | session grants: {granted}\n\
363 rules: deny: {} | allow: {}\n\
364 read-only tools always run; configure [permissions] in settings.toml",
365 render(&rules.deny),
366 render(&rules.allow),
367 )
368 }
369
370 #[cfg(test)]
371 mod tests {
372 use super::*;
373
374 /// `decision_for` reads settings (env + file), and the settings test
375 /// mutates `SIGIT_CONFIG_DIR`/`SIGIT_PERMISSIONS` under this lock — hold it
376 /// here too so parallel test runs don't race, and point the config dir at
377 /// an empty sandbox so a developer's real settings.toml can't skew results.
378 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
379 let guard = crate::ENV_TEST_LOCK
380 .lock()
381 .unwrap_or_else(|poisoned| poisoned.into_inner());
382 let dir = std::env::temp_dir().join(format!("sigit_perm_tests_{}", std::process::id()));
383 // Start from an empty sandbox: a settings.toml written by an earlier
384 // test in this process (e.g. one storing rule lists) must not leak
385 // into the next.
386 let _ = std::fs::remove_dir_all(&dir);
387 // SAFETY: process-global env mutation, serialized by ENV_TEST_LOCK; the
388 // other env-touching tests re-set these before reading.
389 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
390 unsafe { std::env::remove_var("SIGIT_PERMISSIONS") };
391 guard
392 }
393
394 /// Persist rule lists into the sandboxed settings.toml.
395 fn store_rules(allow: &[&str], deny: &[&str]) {
396 let mut settings = settings::load();
397 settings.permissions.rules.allow = allow.iter().map(|s| s.to_string()).collect();
398 settings.permissions.rules.deny = deny.iter().map(|s| s.to_string()).collect();
399 settings::store(&settings).unwrap();
400 }
401
402 /// `decision_for` on a `run_command` call with the given command string.
403 fn run_command_decision(session: &str, command: &str) -> Decision {
404 let args = serde_json::json!({ "command": command }).to_string();
405 decision_for(session, "run_command", &args)
406 }
407
408 #[test]
409 fn read_only_tools_always_allowed() {
410 let _guard = env_guard();
411 for tool in [
412 "read_file",
413 "list_directory",
414 "search_files",
415 "glob",
416 "read_website",
417 "write_todos",
418 "skill",
419 "task",
420 "command_output",
421 ] {
422 assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
423 assert_eq!(decision_for("t-ro", tool, "{}"), Decision::Allow, "{tool}");
424 }
425 }
426
427 #[test]
428 fn mutating_and_unknown_tools_are_gated() {
429 for tool in [
430 "edit_file",
431 "multi_edit",
432 "create_file",
433 "create_directory",
434 "delete_file",
435 "run_command",
436 "kill_command",
437 "remember",
438 "mcp__server__anything",
439 "totally_unknown_tool",
440 ] {
441 assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
442 }
443 }
444
445 #[test]
446 fn plan_mode_denies_mutating_and_spares_read_only() {
447 let _guard = env_guard();
448 let session = "t-plan";
449 reset_session(session);
450 set_plan_mode(session, true);
451 assert!(matches!(
452 run_command_decision(session, "ls"),
453 Decision::Deny(_)
454 ));
455 assert_eq!(decision_for(session, "read_file", "{}"), Decision::Allow);
456 set_plan_mode(session, false);
457 reset_session(session);
458 }
459
460 #[test]
461 fn session_grant_short_circuits_ask() {
462 let _guard = env_guard();
463 let session = "t-grant";
464 reset_session(session);
465 let args = r#"{"path":"src/a.rs","old_text":"a","new_text":"b"}"#;
466 grant_for_session(session, "edit_file", args);
467 assert_eq!(decision_for(session, "edit_file", args), Decision::Allow);
468 // Non-run_command grants record the bare tool name: any path is covered.
469 assert_eq!(
470 decision_for(session, "edit_file", r#"{"path":"docs/other.md"}"#),
471 Decision::Allow
472 );
473 // Other tools are unaffected by the grant.
474 assert_ne!(
475 decision_for(session, "delete_file", r#"{"path":"src/a.rs"}"#),
476 Decision::Allow
477 );
478 reset_session(session);
479 assert_ne!(decision_for(session, "edit_file", args), Decision::Allow);
480 }
481
482 #[test]
483 fn approval_preview_shows_short_arguments_in_full() {
484 let args = r#"{"command":"cargo test"}"#;
485 assert_eq!(approval_preview(args), args);
486 }
487
488 #[test]
489 fn approval_preview_marks_truncation_explicitly() {
490 let args = format!(r#"{{"command":"echo {}; rm -rf /"}}"#, "x".repeat(600));
491 let preview = approval_preview(&args);
492 assert!(preview.chars().count() < args.chars().count());
493 assert!(
494 preview.contains("more chars]"),
495 "hidden content must be flagged, got: {preview}"
496 );
497 }
498
499 #[test]
500 fn plan_mode_outranks_session_grant() {
501 let _guard = env_guard();
502 let session = "t-rank";
503 reset_session(session);
504 let args = r#"{"path":"src/a.rs"}"#;
505 grant_for_session(session, "edit_file", args);
506 set_plan_mode(session, true);
507 assert!(matches!(
508 decision_for(session, "edit_file", args),
509 Decision::Deny(_)
510 ));
511 reset_session(session);
512 }
513
514 #[test]
515 fn rules_gate_run_command_by_argument() {
516 let _guard = env_guard();
517 let session = "t-rules";
518 reset_session(session);
519 store_rules(&["run_command(git *)"], &["run_command(git push*)"]);
520
521 assert_eq!(run_command_decision(session, "git status"), Decision::Allow);
522 assert!(matches!(
523 run_command_decision(session, "git push"),
524 Decision::Deny(_)
525 ));
526 assert!(matches!(
527 run_command_decision(session, "git push --force"),
528 Decision::Deny(_)
529 ));
530 assert_eq!(
531 run_command_decision(session, "cargo test"),
532 Decision::Ask,
533 "an unmatched command falls through to the default mode"
534 );
535 reset_session(session);
536 }
537
538 #[test]
539 fn deny_rule_beats_matching_allow_rule() {
540 let _guard = env_guard();
541 let session = "t-deny-wins";
542 reset_session(session);
543 store_rules(&["run_command(git *)"], &["run_command(git *)"]);
544 match run_command_decision(session, "git status") {
545 Decision::Deny(reason) => assert!(
546 reason.contains("run_command(git *)"),
547 "the denial must name the rule, got: {reason}"
548 ),
549 other => panic!("expected a deny, got {other:?}"),
550 }
551 reset_session(session);
552 }
553
554 #[test]
555 fn rule_pattern_wildcard_and_prefix_edges() {
556 // Whole-token prefix: a pattern without a trailing `*` matches at a
557 // whitespace boundary or the end, never mid-token.
558 let rule = "run_command(git status)";
559 assert!(rule_matches(rule, "run_command", Some("git status")));
560 assert!(rule_matches(
561 rule,
562 "run_command",
563 Some("git status --short")
564 ));
565 assert!(!rule_matches(rule, "run_command", Some("git status-x")));
566 assert!(!rule_matches(rule, "run_command", Some("git statu")));
567 assert!(!rule_matches(rule, "run_command", Some("xgit status")));
568
569 // Trailing `*`: everything from the wildcard on matches.
570 let rule = "run_command(git push*)";
571 assert!(rule_matches(rule, "run_command", Some("git push")));
572 assert!(rule_matches(rule, "run_command", Some("git pushx")));
573 assert!(rule_matches(rule, "run_command", Some("git push --force")));
574 assert!(!rule_matches(rule, "run_command", Some("git pus")));
575 let rule = "run_command(cargo *)";
576 assert!(rule_matches(
577 rule,
578 "run_command",
579 Some("cargo test --locked")
580 ));
581 assert!(
582 rule_matches(rule, "run_command", Some("cargo build --bin src/x")),
583 "a trailing `*` also covers arguments containing `/`"
584 );
585 assert!(!rule_matches(rule, "run_command", Some("cargo")));
586
587 // A rule only applies to its own tool.
588 assert!(!rule_matches(rule, "delete_file", Some("cargo test")));
589 // Bare tool rules match any call, including argument-less tools.
590 assert!(rule_matches("run_command", "run_command", Some("anything")));
591 assert!(rule_matches("mcp__srv__tool", "mcp__srv__tool", None));
592 // A pattern rule never matches a tool without a matchable argument.
593 assert!(!rule_matches("mcp__srv__tool(x)", "mcp__srv__tool", None));
594 }
595
596 #[test]
597 fn run_command_session_grant_is_scoped_to_command_prefix() {
598 let _guard = env_guard();
599 let session = "t-grant-scope";
600 reset_session(session);
601 grant_for_session(
602 session,
603 "run_command",
604 r#"{"command":"git push origin main"}"#,
605 );
606 // The grant is `run_command(git push)`: the `git push …` family only.
607 assert_eq!(run_command_decision(session, "git push"), Decision::Allow);
608 assert_eq!(
609 run_command_decision(session, "git push --force-with-lease"),
610 Decision::Allow
611 );
612 assert_eq!(run_command_decision(session, "git pull"), Decision::Ask);
613 assert_eq!(run_command_decision(session, "git pushx"), Decision::Ask);
614 assert_eq!(run_command_decision(session, "rm -rf /"), Decision::Ask);
615
616 // A single-token command grants that token's family.
617 grant_for_session(session, "run_command", r#"{"command":"ls"}"#);
618 assert_eq!(run_command_decision(session, "ls -la"), Decision::Allow);
619 assert_eq!(run_command_decision(session, "lsof"), Decision::Ask);
620 reset_session(session);
621 }
622
623 #[test]
624 fn file_tool_rules_match_on_path() {
625 let _guard = env_guard();
626 let session = "t-file-rules";
627 reset_session(session);
628 store_rules(&["edit_file(src/*)"], &["delete_file(src/*)"]);
629 assert_eq!(
630 decision_for(
631 session,
632 "edit_file",
633 r#"{"path":"src/main.rs","old_text":"a","new_text":"b"}"#
634 ),
635 Decision::Allow
636 );
637 assert_eq!(
638 decision_for(session, "edit_file", r#"{"path":"docs/readme.md"}"#),
639 Decision::Ask,
640 "a path outside the rule falls through to the default mode"
641 );
642 assert!(matches!(
643 decision_for(session, "delete_file", r#"{"path":"src/main.rs"}"#),
644 Decision::Deny(_)
645 ));
646 assert_eq!(
647 decision_for(session, "delete_file", r#"{"path":"docs/readme.md"}"#),
648 Decision::Ask
649 );
650 reset_session(session);
651 }
652 }