@setoelkahfi / sigit / commits / 9615286

Add permission rule patterns: per-argument allow and deny lists

The permission system knew three modes and per-tool overrides, which made autonomy all-or-nothing: allowing run_command waved through rm -rf along with cargo test. Rules close that gap. [permissions.rules] in settings.toml holds ordered allow and deny lists of tool_name(pattern) entries, e.g. run_command(git *) or edit_file(src/*). Patterns match the command string for run_command and the path for file tools; a trailing * matches as a pure prefix so commands containing paths still match, while interior wildcards keep glob semantics. Evaluation slots into the existing layering: plan mode, session grants, deny rules, allow rules, per-tool override, default. Deny always beats allow and a matched deny names its rule. A pattern rule never matches a tool without a matchable argument, and unparseable patterns fail closed, so a bad rule can only narrow access, never widen it. Approval prompts gain the same granularity: always-allow on run_command now grants the command family (its first two tokens), not the whole shell. /permissions prints the rule lists and the granular grants.

paydii committed Jul 5, 2026 at 08:49 UTC 96152868c99b9e36933267d365c5f6cc1233b545
5 files changed +409 -28
src/chat.rs
+6 -2
@@ -1793,7 +1793,7 @@ mod tui {
1793 // mutating tool consults policy and may pause on the user's
1794 // y/a/n answer (delivered over a oneshot from the event loop).
1795 use crate::permissions::{self, Decision, TUI_SESSION};
1796 - let output = match permissions::decision_for(TUI_SESSION, &tc.name) {
1796 + let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
1797 Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
1798 Decision::Deny(reason) => {
1799 log::info!(" ✗ {} denied by policy", tc.name);
@@ -1813,7 +1813,11 @@ mod tui {
1813 crate::tools::execute_tool(&tc.name, &tc.arguments).await
1814 }
1815 Ok(ApprovalChoice::Session) => {
1816 - permissions::grant_for_session(TUI_SESSION, &tc.name);
1816 + permissions::grant_for_session(
1817 + TUI_SESSION,
1818 + &tc.name,
1819 + &tc.arguments,
1820 + );
1821 crate::tools::execute_tool(&tc.name, &tc.arguments).await
1822 }
1823 Ok(ApprovalChoice::Deny) => {
src/main.rs
+10 -2
@@ -1372,7 +1372,11 @@ impl SiGitAgent {
1372
1373 // Permission gate: read-only tools pass straight through; a
1374 // mutating tool consults policy and may ask the client.
1375 - let output = match permissions::decision_for(&session_id.to_string(), &tc.name) {
1375 + let output = match permissions::decision_for(
1376 + &session_id.to_string(),
1377 + &tc.name,
1378 + &tc.arguments,
1379 + ) {
1380 permissions::Decision::Allow => {
1381 tools::execute_tool(&tc.name, &tc.arguments).await
1382 }
@@ -1541,7 +1545,11 @@ impl SiGitAgent {
1545 match selected.option_id.0.as_ref() {
1546 "allow_once" => PermissionVerdict::Approved,
1547 "allow_session" => {
1544 - permissions::grant_for_session(&session_id.to_string(), tool_name);
1548 + permissions::grant_for_session(
1549 + &session_id.to_string(),
1550 + tool_name,
1551 + arguments,
1552 + );
1553 PermissionVerdict::Approved
1554 }
1555 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
src/permissions.rs
+335 -20
@@ -10,12 +10,30 @@
10 //! a message telling the model to present a plan instead. Toggled via
11 //! `/plan on|off` (TUI and ACP).
12 //! 2. **Session grants** — "always allow this session", recorded when the user
13 -//! picks that option in an approval prompt.
14 -//! 3. **Per-tool override** — `[permissions.tools]` in `settings.toml`, e.g.
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"`.
16 -//! 4. **Default mode** — `[permissions] default = "ask"|"allow"|"deny"` in
27 +//! 5. **Default mode** — `[permissions] default = "ask"|"allow"|"deny"` in
28 //! `settings.toml`; `ask` on a fresh install.
29 //!
30 +//! Pattern matching (rules and session grants share it): `*` is a glob-style
31 +//! wildcard (the `glob` tool's translator). A pattern ending in `*` matches
32 +//! everything from the wildcard on — `run_command(cargo *)` covers
33 +//! `cargo build src/main.rs`. A pattern without a trailing `*` must be the
34 +//! whole argument or end at a whitespace boundary: `run_command(git status)`
35 +//! matches `git status` and `git status --short` but not `git status-x`.
36 +//!
37 //! The `SIGIT_PERMISSIONS` env var (`allow`/`ask`/`deny`) overrides the stored
38 //! default without writing the file — the escape hatch for ACP clients that
39 //! cannot answer `session/request_permission` and for CI/headless runs.
@@ -31,7 +49,10 @@
49 use std::collections::{HashMap, HashSet};
50 use std::sync::{Mutex, OnceLock};
51
52 +use regex::Regex;
53 +
54 use crate::settings::{self, PermissionMode};
55 +use crate::tools::glob_to_regex;
56
57 /// Session key used by the interactive TUI, which only ever has one session.
58 /// The TUI (`chat.rs`) is `#[cfg(unix)]`, so this is its only consumer and is
@@ -130,14 +151,89 @@ pub fn approval_preview(arguments: &str) -> String {
151 format!("{shown}… [+{} more chars]", total - MAX_CHARS)
152 }
153
133 -/// Policy check for one tool call. See the module docs for the layering.
134 -pub fn decision_for(session: &str, tool_name: &str) -> Decision {
154 +// ── Rule patterns ────────────────────────────────────────────────────────────
155 +// A rule is `tool_name` or `tool_name(argument_pattern)`; rules from
156 +// `[permissions.rules]` and session grants share this matcher.
157 +
158 +/// Split a rule into tool name and optional argument pattern:
159 +/// `run_command(git *)` → `("run_command", Some("git *"))`;
160 +/// `edit_file` → `("edit_file", None)`.
161 +fn parse_rule(rule: &str) -> (&str, Option<&str>) {
162 + let rule = rule.trim();
163 + if let Some(open) = rule.find('(')
164 + && let Some(inner) = rule[open + 1..].strip_suffix(')')
165 + {
166 + return (rule[..open].trim(), Some(inner));
167 + }
168 + (rule, None)
169 +}
170 +
171 +/// Compile a rule's argument pattern. Reuses the `glob` tool's translator
172 +/// (`*`, `**`, `?`, `{a,b}`), then swaps its end anchor for rule semantics:
173 +/// a pattern ending in `*` matches everything from the wildcard on (prefix
174 +/// semantics — `cargo *` covers `cargo build src/main.rs`), while any other
175 +/// pattern must be the whole argument or end at a whitespace boundary
176 +/// (`git status` matches `git status --short` but not `git status-x`).
177 +fn pattern_regex(pattern: &str) -> Option<Regex> {
178 + let anchored = glob_to_regex(pattern);
179 + let body = anchored.strip_suffix('$').unwrap_or(&anchored);
180 + let source = if pattern.ends_with('*') {
181 + body.to_string()
182 + } else {
183 + format!("{body}(?:$|\\s)")
184 + };
185 + Regex::new(&source).ok()
186 +}
187 +
188 +/// Whether one rule covers one tool call. A bare `tool_name` rule matches any
189 +/// call of that tool; a pattern rule additionally needs the call's matchable
190 +/// argument to fit the pattern — so a pattern rule never matches a tool that
191 +/// has no matchable argument (an unreadable rule must not widen access).
192 +fn rule_matches(rule: &str, tool_name: &str, argument: Option<&str>) -> bool {
193 + let (rule_tool, pattern) = parse_rule(rule);
194 + if rule_tool != tool_name {
195 + return false;
196 + }
197 + match (pattern, argument) {
198 + (None, _) => true,
199 + (Some(pattern), Some(argument)) => {
200 + pattern_regex(pattern).is_some_and(|re| re.is_match(argument))
201 + }
202 + (Some(_), None) => false,
203 + }
204 +}
205 +
206 +/// The argument a rule pattern is matched against, extracted from the tool
207 +/// call's raw JSON arguments: the command string for `run_command`, the path
208 +/// for the file-mutating tools. Read-only tools never reach the matcher, and
209 +/// other tools (MCP, unknown) have no obvious single argument, so they return
210 +/// `None` and are governed only by bare `tool_name` rules.
211 +fn matchable_argument(tool_name: &str, arguments: &str) -> Option<String> {
212 + let key = match tool_name {
213 + "run_command" => "command",
214 + "edit_file" | "create_file" | "multi_edit" | "delete_file" | "create_directory" => "path",
215 + _ => return None,
216 + };
217 + let value: serde_json::Value = serde_json::from_str(arguments).ok()?;
218 + Some(value.get(key)?.as_str()?.to_string())
219 +}
220 +
221 +/// Policy check for one tool call. `arguments` is the call's raw JSON argument
222 +/// string, consulted by rule patterns and granular session grants. See the
223 +/// module docs for the layering.
224 +pub fn decision_for(session: &str, tool_name: &str, arguments: &str) -> Decision {
225 if classify(tool_name) == ToolRisk::ReadOnly {
226 return Decision::Allow;
227 }
228
229 + let argument = matchable_argument(tool_name, arguments);
230 let (plan_mode, granted) = with_session(session, |s| {
140 - (s.plan_mode, s.always_allow.contains(tool_name))
231 + (
232 + s.plan_mode,
233 + s.always_allow
234 + .iter()
235 + .any(|grant| rule_matches(grant, tool_name, argument.as_deref())),
236 + )
237 });
238
239 if plan_mode {
@@ -147,6 +243,26 @@ pub fn decision_for(session: &str, tool_name: &str) -> Decision {
243 return Decision::Allow;
244 }
245
246 + let rules = settings::permission_rules();
247 + if let Some(rule) = rules
248 + .deny
249 + .iter()
250 + .find(|rule| rule_matches(rule, tool_name, argument.as_deref()))
251 + {
252 + return Decision::Deny(format!(
253 + "`{tool_name}` is denied by the permission rule `{rule}` in settings.toml. \
254 + Do not retry it; work without this tool or ask the user to change \
255 + the policy."
256 + ));
257 + }
258 + if rules
259 + .allow
260 + .iter()
261 + .any(|rule| rule_matches(rule, tool_name, argument.as_deref()))
262 + {
263 + return Decision::Allow;
264 + }
265 +
266 match settings::permission_mode_for(tool_name) {
267 PermissionMode::Allow => Decision::Allow,
268 PermissionMode::Ask => Decision::Ask,
@@ -158,13 +274,33 @@ pub fn decision_for(session: &str, tool_name: &str) -> Decision {
274 }
275 }
276
161 -/// Record an "always allow this session" grant for a tool.
162 -pub fn grant_for_session(session: &str, tool_name: &str) {
277 +/// Record an "always allow this session" grant for a tool call. For
278 +/// `run_command` the grant is scoped to the command's first two
279 +/// whitespace-separated tokens (approving `git push origin main` records
280 +/// `run_command(git push)`, covering the `git push …` family only); other
281 +/// tools record the bare tool name, matching any call.
282 +pub fn grant_for_session(session: &str, tool_name: &str, arguments: &str) {
283 + let grant = session_grant_rule(tool_name, arguments);
284 with_session(session, |s| {
164 - s.always_allow.insert(tool_name.to_string());
285 + s.always_allow.insert(grant);
286 });
287 }
288
289 +/// The rule string recorded for one approved call (see [`grant_for_session`]).
290 +/// Falls back to the bare tool name when the command is absent or empty, which
291 +/// grants the whole tool — exactly what the pre-granular behavior was.
292 +fn session_grant_rule(tool_name: &str, arguments: &str) -> String {
293 + if tool_name == "run_command"
294 + && let Some(command) = matchable_argument(tool_name, arguments)
295 + {
296 + let prefix: Vec<&str> = command.split_whitespace().take(2).collect();
297 + if !prefix.is_empty() {
298 + return format!("{tool_name}({})", prefix.join(" "));
299 + }
300 + }
301 + tool_name.to_string()
302 +}
303 +
304 /// Toggle plan mode for a session. Returns the new state.
305 pub fn set_plan_mode(session: &str, enabled: bool) -> bool {
306 with_session(session, |s| {
@@ -199,7 +335,8 @@ pub fn reset_all() {
335 map.clear();
336 }
337
202 -/// One-line status summary for `/permissions` and `/status`.
338 +/// Status summary for `/permissions` and `/status`: the default mode, plan
339 +/// mode, the granular session grants, and the active rule lists.
340 pub fn describe(session: &str) -> String {
341 let plan = if plan_mode(session) { "on" } else { "off" };
342 let default = settings::permission_default();
@@ -213,9 +350,20 @@ pub fn describe(session: &str) -> String {
350 } else {
351 granted
352 };
353 + let rules = settings::permission_rules();
354 + let render = |list: &[String]| {
355 + if list.is_empty() {
356 + "none".to_string()
357 + } else {
358 + list.join(", ")
359 + }
360 + };
361 format!(
362 "permissions: default={default} | plan mode: {plan} | session grants: {granted}\n\
218 - read-only tools always run; configure [permissions] in settings.toml"
363 + rules: deny: {} | allow: {}\n\
364 + read-only tools always run; configure [permissions] in settings.toml",
365 + render(&rules.deny),
366 + render(&rules.allow),
367 )
368 }
369
@@ -232,6 +380,10 @@ mod tests {
380 .lock()
381 .unwrap_or_else(|poisoned| poisoned.into_inner());
382 let dir = std::env::temp_dir().join(format!("sigit_perm_tests_{}", std::process::id()));
383 + // Start from an empty sandbox: a settings.toml written by an earlier
384 + // test in this process (e.g. one storing rule lists) must not leak
385 + // into the next.
386 + let _ = std::fs::remove_dir_all(&dir);
387 // SAFETY: process-global env mutation, serialized by ENV_TEST_LOCK; the
388 // other env-touching tests re-set these before reading.
389 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
@@ -239,6 +391,20 @@ mod tests {
391 guard
392 }
393
394 + /// Persist rule lists into the sandboxed settings.toml.
395 + fn store_rules(allow: &[&str], deny: &[&str]) {
396 + let mut settings = settings::load();
397 + settings.permissions.rules.allow = allow.iter().map(|s| s.to_string()).collect();
398 + settings.permissions.rules.deny = deny.iter().map(|s| s.to_string()).collect();
399 + settings::store(&settings).unwrap();
400 + }
401 +
402 + /// `decision_for` on a `run_command` call with the given command string.
403 + fn run_command_decision(session: &str, command: &str) -> Decision {
404 + let args = serde_json::json!({ "command": command }).to_string();
405 + decision_for(session, "run_command", &args)
406 + }
407 +
408 #[test]
409 fn read_only_tools_always_allowed() {
410 let _guard = env_guard();
@@ -254,7 +420,7 @@ mod tests {
420 "command_output",
421 ] {
422 assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
257 - assert_eq!(decision_for("t-ro", tool), Decision::Allow, "{tool}");
423 + assert_eq!(decision_for("t-ro", tool, "{}"), Decision::Allow, "{tool}");
424 }
425 }
426
@@ -283,10 +449,10 @@ mod tests {
449 reset_session(session);
450 set_plan_mode(session, true);
451 assert!(matches!(
286 - decision_for(session, "run_command"),
452 + run_command_decision(session, "ls"),
453 Decision::Deny(_)
454 ));
289 - assert_eq!(decision_for(session, "read_file"), Decision::Allow);
455 + assert_eq!(decision_for(session, "read_file", "{}"), Decision::Allow);
456 set_plan_mode(session, false);
457 reset_session(session);
458 }
@@ -296,12 +462,21 @@ mod tests {
462 let _guard = env_guard();
463 let session = "t-grant";
464 reset_session(session);
299 - grant_for_session(session, "edit_file");
300 - assert_eq!(decision_for(session, "edit_file"), Decision::Allow);
465 + let args = r#"{"path":"src/a.rs","old_text":"a","new_text":"b"}"#;
466 + grant_for_session(session, "edit_file", args);
467 + assert_eq!(decision_for(session, "edit_file", args), Decision::Allow);
468 + // Non-run_command grants record the bare tool name: any path is covered.
469 + assert_eq!(
470 + decision_for(session, "edit_file", r#"{"path":"docs/other.md"}"#),
471 + Decision::Allow
472 + );
473 // Other tools are unaffected by the grant.
302 - assert_ne!(decision_for(session, "delete_file"), Decision::Allow);
474 + assert_ne!(
475 + decision_for(session, "delete_file", r#"{"path":"src/a.rs"}"#),
476 + Decision::Allow
477 + );
478 reset_session(session);
304 - assert_ne!(decision_for(session, "edit_file"), Decision::Allow);
479 + assert_ne!(decision_for(session, "edit_file", args), Decision::Allow);
480 }
481
482 #[test]
@@ -326,12 +501,152 @@ mod tests {
501 let _guard = env_guard();
502 let session = "t-rank";
503 reset_session(session);
329 - grant_for_session(session, "edit_file");
504 + let args = r#"{"path":"src/a.rs"}"#;
505 + grant_for_session(session, "edit_file", args);
506 set_plan_mode(session, true);
507 assert!(matches!(
332 - decision_for(session, "edit_file"),
508 + decision_for(session, "edit_file", args),
509 Decision::Deny(_)
510 ));
511 reset_session(session);
512 }
513 +
514 + #[test]
515 + fn rules_gate_run_command_by_argument() {
516 + let _guard = env_guard();
517 + let session = "t-rules";
518 + reset_session(session);
519 + store_rules(&["run_command(git *)"], &["run_command(git push*)"]);
520 +
521 + assert_eq!(run_command_decision(session, "git status"), Decision::Allow);
522 + assert!(matches!(
523 + run_command_decision(session, "git push"),
524 + Decision::Deny(_)
525 + ));
526 + assert!(matches!(
527 + run_command_decision(session, "git push --force"),
528 + Decision::Deny(_)
529 + ));
530 + assert_eq!(
531 + run_command_decision(session, "cargo test"),
532 + Decision::Ask,
533 + "an unmatched command falls through to the default mode"
534 + );
535 + reset_session(session);
536 + }
537 +
538 + #[test]
539 + fn deny_rule_beats_matching_allow_rule() {
540 + let _guard = env_guard();
541 + let session = "t-deny-wins";
542 + reset_session(session);
543 + store_rules(&["run_command(git *)"], &["run_command(git *)"]);
544 + match run_command_decision(session, "git status") {
545 + Decision::Deny(reason) => assert!(
546 + reason.contains("run_command(git *)"),
547 + "the denial must name the rule, got: {reason}"
548 + ),
549 + other => panic!("expected a deny, got {other:?}"),
550 + }
551 + reset_session(session);
552 + }
553 +
554 + #[test]
555 + fn rule_pattern_wildcard_and_prefix_edges() {
556 + // Whole-token prefix: a pattern without a trailing `*` matches at a
557 + // whitespace boundary or the end, never mid-token.
558 + let rule = "run_command(git status)";
559 + assert!(rule_matches(rule, "run_command", Some("git status")));
560 + assert!(rule_matches(
561 + rule,
562 + "run_command",
563 + Some("git status --short")
564 + ));
565 + assert!(!rule_matches(rule, "run_command", Some("git status-x")));
566 + assert!(!rule_matches(rule, "run_command", Some("git statu")));
567 + assert!(!rule_matches(rule, "run_command", Some("xgit status")));
568 +
569 + // Trailing `*`: everything from the wildcard on matches.
570 + let rule = "run_command(git push*)";
571 + assert!(rule_matches(rule, "run_command", Some("git push")));
572 + assert!(rule_matches(rule, "run_command", Some("git pushx")));
573 + assert!(rule_matches(rule, "run_command", Some("git push --force")));
574 + assert!(!rule_matches(rule, "run_command", Some("git pus")));
575 + let rule = "run_command(cargo *)";
576 + assert!(rule_matches(
577 + rule,
578 + "run_command",
579 + Some("cargo test --locked")
580 + ));
581 + assert!(
582 + rule_matches(rule, "run_command", Some("cargo build --bin src/x")),
583 + "a trailing `*` also covers arguments containing `/`"
584 + );
585 + assert!(!rule_matches(rule, "run_command", Some("cargo")));
586 +
587 + // A rule only applies to its own tool.
588 + assert!(!rule_matches(rule, "delete_file", Some("cargo test")));
589 + // Bare tool rules match any call, including argument-less tools.
590 + assert!(rule_matches("run_command", "run_command", Some("anything")));
591 + assert!(rule_matches("mcp__srv__tool", "mcp__srv__tool", None));
592 + // A pattern rule never matches a tool without a matchable argument.
593 + assert!(!rule_matches("mcp__srv__tool(x)", "mcp__srv__tool", None));
594 + }
595 +
596 + #[test]
597 + fn run_command_session_grant_is_scoped_to_command_prefix() {
598 + let _guard = env_guard();
599 + let session = "t-grant-scope";
600 + reset_session(session);
601 + grant_for_session(
602 + session,
603 + "run_command",
604 + r#"{"command":"git push origin main"}"#,
605 + );
606 + // The grant is `run_command(git push)`: the `git push …` family only.
607 + assert_eq!(run_command_decision(session, "git push"), Decision::Allow);
608 + assert_eq!(
609 + run_command_decision(session, "git push --force-with-lease"),
610 + Decision::Allow
611 + );
612 + assert_eq!(run_command_decision(session, "git pull"), Decision::Ask);
613 + assert_eq!(run_command_decision(session, "git pushx"), Decision::Ask);
614 + assert_eq!(run_command_decision(session, "rm -rf /"), Decision::Ask);
615 +
616 + // A single-token command grants that token's family.
617 + grant_for_session(session, "run_command", r#"{"command":"ls"}"#);
618 + assert_eq!(run_command_decision(session, "ls -la"), Decision::Allow);
619 + assert_eq!(run_command_decision(session, "lsof"), Decision::Ask);
620 + reset_session(session);
621 + }
622 +
623 + #[test]
624 + fn file_tool_rules_match_on_path() {
625 + let _guard = env_guard();
626 + let session = "t-file-rules";
627 + reset_session(session);
628 + store_rules(&["edit_file(src/*)"], &["delete_file(src/*)"]);
629 + assert_eq!(
630 + decision_for(
631 + session,
632 + "edit_file",
633 + r#"{"path":"src/main.rs","old_text":"a","new_text":"b"}"#
634 + ),
635 + Decision::Allow
636 + );
637 + assert_eq!(
638 + decision_for(session, "edit_file", r#"{"path":"docs/readme.md"}"#),
639 + Decision::Ask,
640 + "a path outside the rule falls through to the default mode"
641 + );
642 + assert!(matches!(
643 + decision_for(session, "delete_file", r#"{"path":"src/main.rs"}"#),
644 + Decision::Deny(_)
645 + ));
646 + assert_eq!(
647 + decision_for(session, "delete_file", r#"{"path":"docs/readme.md"}"#),
648 + Decision::Ask
649 + );
650 + reset_session(session);
651 + }
652 }
src/settings.rs
+54 -1
@@ -66,8 +66,29 @@ impl std::fmt::Display for PermissionMode {
66 }
67 }
68
69 +/// The `[permissions.rules]` table: ordered allow/deny lists of rule strings.
70 +/// A rule is `tool_name` or `tool_name(argument_pattern)`; the pattern is
71 +/// matched against the command string for `run_command` and the path for the
72 +/// file-mutating tools (see `crate::permissions` for the matching semantics).
73 +///
74 +/// ```toml
75 +/// [permissions.rules]
76 +/// allow = ["run_command(git *)", "edit_file(src/*)"]
77 +/// deny = ["run_command(git push*)"]
78 +/// ```
79 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80 +pub struct PermissionRules {
81 + /// Rules that let a matching call run without asking.
82 + #[serde(default)]
83 + pub allow: Vec<String>,
84 + /// Rules that block a matching call. Checked before `allow`, so a deny
85 + /// always beats an allow that matches the same call.
86 + #[serde(default)]
87 + pub deny: Vec<String>,
88 +}
89 +
90 /// The `[permissions]` table: a default mode for mutating tools plus per-tool
70 -/// overrides, e.g.
91 +/// overrides and argument-level rule lists, e.g.
92 ///
93 /// ```toml
94 /// [permissions]
@@ -76,6 +97,10 @@ impl std::fmt::Display for PermissionMode {
97 /// [permissions.tools]
98 /// edit_file = "allow"
99 /// delete_file = "deny"
100 +///
101 +/// [permissions.rules]
102 +/// allow = ["run_command(cargo *)"]
103 +/// deny = ["run_command(git push*)"]
104 /// ```
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
106 pub struct PermissionSettings {
@@ -87,6 +112,10 @@ pub struct PermissionSettings {
112 /// `mcp__<server>__<tool>` name).
113 #[serde(default)]
114 pub tools: BTreeMap<String, PermissionMode>,
115 + /// Argument-level allow/deny rules, consulted after session grants and
116 + /// before the per-tool overrides.
117 + #[serde(default)]
118 + pub rules: PermissionRules,
119 }
120
121 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
@@ -188,6 +217,11 @@ pub fn permission_default() -> PermissionMode {
217 load().permissions.default
218 }
219
220 +/// The stored `[permissions.rules]` allow/deny lists.
221 +pub fn permission_rules() -> PermissionRules {
222 + load().permissions.rules
223 +}
224 +
225 /// The effective permission mode for one tool: its `[permissions.tools]`
226 /// override when present, else the default (see [`permission_default`]).
227 pub fn permission_mode_for(tool_name: &str) -> PermissionMode {
@@ -274,6 +308,25 @@ mod tests {
308 "unrecognized env value falls back to stored setting"
309 );
310
311 + // Permission rules: absent on a fresh file, and they survive a
312 + // store/load round trip without disturbing the other settings.
313 + assert_eq!(permission_rules(), PermissionRules::default());
314 + let mut settings = load();
315 + settings.permissions.rules.allow = vec![
316 + "run_command(git *)".to_string(),
317 + "edit_file(src/*)".to_string(),
318 + ];
319 + settings.permissions.rules.deny = vec!["run_command(git push*)".to_string()];
320 + store(&settings).unwrap();
321 + let reloaded = load();
322 + assert_eq!(reloaded.permissions.rules, settings.permissions.rules);
323 + assert_eq!(permission_rules(), settings.permissions.rules);
324 + assert_eq!(
325 + reloaded.permissions.tools.get("delete_file"),
326 + Some(&PermissionMode::Deny),
327 + "storing rules preserves the per-tool overrides"
328 + );
329 +
330 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
331 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
332 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
src/tools.rs
+4 -3
@@ -1346,9 +1346,10 @@ fn exec_multi_edit(arguments: &str) -> String {
1346 /// Translate a shell-style glob into an anchored regex. Supports `*`
1347 /// (non-separator run), `**` (any number of directories), `?` (one
1348 /// non-separator), and `{a,b}` alternation; everything else is matched
1349 -/// literally. Used both by the `glob` tool (against relative paths) and by
1350 -/// `search_files`' `file_glob` filter (against bare file names).
1351 -fn glob_to_regex(glob: &str) -> String {
1349 +/// literally. Used by the `glob` tool (against relative paths), by
1350 +/// `search_files`' `file_glob` filter (against bare file names), and by
1351 +/// `crate::permissions` rule patterns (which re-anchor the result).
1352 +pub(crate) fn glob_to_regex(glob: &str) -> String {
1353 let chars: Vec<char> = glob.chars().collect();
1354 let mut re = String::from("^");
1355 let mut brace_depth = 0usize;