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.
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
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 {
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,
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| {
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();
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
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) };
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();
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
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
}
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]
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
}