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