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