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