I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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