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