返回 DeepSeek-Reasonix
permission.go
根目录 / internal / permission / permission.go
1 // Package permission decides, per tool call, whether to allow it, deny it, or
2 // ask the user first. The core is a pure Policy (rule evaluation, no I/O); a
3 // Gate wraps a Policy with an optional interactive Approver and is what the
4 // agent consults at execute time. Keeping rule evaluation pure makes it
5 // trivially testable and keeps the agent independent of how "ask" is resolved.
6 package permission
7
8 import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "strings"
13
14 "reasonix/internal/shellparse"
15 )
16
17 // Decision is the outcome of evaluating a tool call against a Policy.
18 type Decision int
19
20 const (
21 // Allow runs the tool without prompting.
22 Allow Decision = iota
23 // Ask defers to an interactive Approver (or, with none, resolves to Allow).
24 Ask
25 // Deny blocks the tool in every mode.
26 Deny
27 )
28
29 func (d Decision) String() string {
30 switch d {
31 case Allow:
32 return "allow"
33 case Ask:
34 return "ask"
35 case Deny:
36 return "deny"
37 default:
38 return "unknown"
39 }
40 }
41
42 // ParseDecision maps a config string to a Decision. Unknown / empty input
43 // defaults to Ask — the conservative posture for a writer fallback.
44 func ParseDecision(s string) Decision {
45 switch strings.ToLower(strings.TrimSpace(s)) {
46 case "allow":
47 return Allow
48 case "deny":
49 return Deny
50 default:
51 return Ask
52 }
53 }
54
55 // Rule matches tool calls. Tool is the tool name; Subject, when non-empty,
56 // constrains the call's subject. A glob Subject (see matchGlob) matches by
57 // wildcard; a Literal Subject matches by exact string equality. An empty Subject
58 // matches every call to Tool.
59 type Rule struct {
60 Tool string
61 Subject string
62 // Literal matches Subject by exact equality rather than as a glob, so a
63 // remembered concrete command keeps any '*'/'?' as ordinary characters
64 // instead of turning them into wildcards.
65 Literal bool
66 }
67
68 // ParseRule parses "ToolName", "ToolName(glob)", or the legacy
69 // "ToolName=literal" form. Surrounding whitespace is trimmed. The "=literal"
70 // form (taken when the '=' precedes any '(') matches the rest of the string
71 // verbatim — no globbing — and is kept for existing configs that were written
72 // before the Claude Code-style Tool(specifier) approval rules. ok is false for
73 // a malformed entry (empty tool name) so the caller can warn rather than
74 // silently install a rule that matches nothing.
75 func ParseRule(s string) (Rule, bool) {
76 s = strings.TrimSpace(s)
77 if s == "" {
78 return Rule{}, false
79 }
80 if eq := strings.IndexByte(s, '='); eq > 0 {
81 if paren := strings.IndexByte(s, '('); paren < 0 || eq < paren {
82 tool := strings.TrimSpace(s[:eq])
83 if tool == "" {
84 return Rule{}, false
85 }
86 return Rule{Tool: tool, Subject: s[eq+1:], Literal: true}, true
87 }
88 }
89 if i := strings.IndexByte(s, '('); i >= 0 && strings.HasSuffix(s, ")") {
90 tool := strings.TrimSpace(s[:i])
91 if tool == "" {
92 return Rule{}, false
93 }
94 return Rule{Tool: tool, Subject: s[i+1 : len(s)-1]}, true
95 }
96 return Rule{Tool: s}, true
97 }
98
99 func legacyBarePowerShellDenyCmdlet(s string) (string, bool) {
100 switch strings.ToLower(strings.TrimSpace(s)) {
101 case "set-content":
102 return "Set-Content", true
103 case "add-content":
104 return "Add-Content", true
105 case "out-file":
106 return "Out-File", true
107 default:
108 return "", false
109 }
110 }
111 func parseRules(ss []string) []Rule {
112 var out []Rule
113 for _, s := range ss {
114 if r, ok := ParseRule(s); ok {
115 out = append(out, r)
116 }
117 }
118 return out
119 }
120
121 func parseDenyRules(ss []string) []Rule {
122 var out []Rule
123 for _, s := range ss {
124 r, ok := ParseRule(s)
125 if !ok {
126 continue
127 }
128 // Preserve the generic ToolName meaning while also recognizing the three
129 // bare PowerShell write cmdlets accepted by older Desktop settings as
130 // command prefixes. The compatibility expansion is deny-only and
131 // additive, so it cannot broaden an allow or weaken an exact tool deny.
132 out = append(out, r)
133 if r.Subject == "" {
134 if cmdlet, ok := legacyBarePowerShellDenyCmdlet(r.Tool); ok {
135 out = append(out, Rule{Tool: "Bash", Subject: cmdlet + ":*"})
136 }
137 }
138 }
139 return out
140 }
141
142 // Policy is a set of rules plus the writer fallback mode. It is the pure,
143 // I/O-free heart of the permission layer.
144 type Policy struct {
145 // Mode is the fallback decision for writer tools when no rule matches.
146 // Read-only tools always fall back to Allow.
147 Mode Decision
148 Allow []Rule
149 Ask []Rule
150 Deny []Rule
151 // SessionAllow is an explicit frontend/session override such as Claude
152 // Code's --allowed-tools. Deny rules still win, while these rules override
153 // configured Ask entries for the current process only.
154 SessionAllow []Rule
155 // AllowDynamicBash lets the writer fallback Mode cover command
156 // substitution and interpreter -c/-e forms. It is deliberately opt-in:
157 // broad Bash allow rules alone must not re-open nested-command bypasses.
158 AllowDynamicBash bool
159 }
160
161 // WithSessionAllow returns a copy of p with additional ephemeral allow rules.
162 // Malformed entries are ignored consistently with New.
163 func (p Policy) WithSessionAllow(rules []string) Policy {
164 p.SessionAllow = append(append([]Rule(nil), p.SessionAllow...), parseRules(rules)...)
165 return p
166 }
167
168 // WithAllowDynamicBashFallback enables the explicit advanced override for
169 // dynamic shell shapes. Deny, ask, and exact allow rules retain precedence.
170 func (p Policy) WithAllowDynamicBashFallback(enabled bool) Policy {
171 p.AllowDynamicBash = enabled
172 return p
173 }
174
175 // New builds a Policy from config string slices and a mode string ("ask" by
176 // default). Malformed rule strings are dropped.
177 func New(mode string, allow, ask, deny []string) Policy {
178 return Policy{
179 Mode: ParseDecision(mode),
180 Allow: parseRules(allow),
181 Ask: parseRules(ask),
182 Deny: parseDenyRules(deny),
183 }
184 }
185
186 // Decide evaluates a tool call. readOnly is the tool's own classification; args
187 // is the raw JSON the model sent, from which the call's subject is extracted
188 // for glob matching. Calls with multiple subjects, such as move_file's source
189 // and destination paths, must be safe for every subject before the call is
190 // allowed. Precedence: deny > ask > allow > fallback (Allow for readers, Mode
191 // for writers). SessionAllow sits between deny and configured ask rules.
192 func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision {
193 return p.DecideSubjects(toolName, readOnly, Subjects(args))
194 }
195
196 // ExplicitlyDenies reports only configured deny-rule matches. It deliberately
197 // excludes the fallback Mode so installing or explicitly authorizing an MCP
198 // server remains the final allow decision.
199 func (p Policy) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
200 subjects := Subjects(args)
201 if len(subjects) == 0 {
202 subjects = []string{""}
203 }
204 for _, subject := range subjects {
205 if matchAnyRaw(p.Deny, toolName, subject) {
206 return true
207 }
208 }
209 return false
210 }
211
212 // DecideSubject evaluates a tool call when the caller already extracted the
213 // stable approval subject from args.
214 func (p Policy) DecideSubject(toolName string, readOnly bool, subject string) Decision {
215 if canonicalRuleTool(toolName) == "bash" {
216 approvalClass := classifyBashApproval(subject)
217 requiresExact := approvalClass != bashApprovalReusable
218 requiresHuman := approvalClass == bashApprovalRequireHuman
219 parts := DecomposeBashCommand(subject)
220 switch {
221 case matchAnyRaw(p.Deny, toolName, subject):
222 return Deny
223 case matchAnyExact(p.SessionAllow, toolName, subject):
224 return Allow
225 case !requiresExact && parts == nil && matchAnyAllow(p.SessionAllow, toolName, subject):
226 return Allow
227 case matchAnyRaw(p.Ask, toolName, subject):
228 return Ask
229 case matchAnyExact(p.Allow, toolName, subject):
230 return Allow
231 }
232 if parts != nil {
233 return p.decideBashSegments(readOnly, parts)
234 }
235 switch {
236 case requiresHuman && p.Mode == Deny:
237 return Deny
238 case requiresHuman && p.AllowDynamicBash && p.Mode == Allow:
239 return Allow
240 case requiresHuman:
241 return Ask
242 case requiresExact && readOnly:
243 return Allow
244 case requiresExact:
245 return p.Mode
246 }
247 switch {
248 case matchAnyAllow(p.Allow, toolName, subject):
249 return Allow
250 case readOnly:
251 return Allow
252 default:
253 return p.Mode
254 }
255 }
256 switch {
257 case matchAny(p.Deny, toolName, subject):
258 return Deny
259 case matchAny(p.SessionAllow, toolName, subject):
260 return Allow
261 case matchAny(p.Ask, toolName, subject):
262 return Ask
263 case matchAny(p.Allow, toolName, subject):
264 return Allow
265 case readOnly:
266 return Allow
267 default:
268 return p.Mode
269 }
270 }
271
272 // decideBashSegments evaluates each simple-command segment of a compound bash
273 // invocation against the rule table independently. This lets prefix rules like
274 // `Bash(git push:*)` — created by the existing auto-save path for atomic
275 // commands — cover common compound flows (`git add . && git commit && git
276 // push`) without ever synthesizing a new prefix from a compound command.
277 //
278 // Precedence stays deny > ask > allow > fallback. Any single segment hitting
279 // deny denies the whole call; any segment needing approval turns the whole
280 // call into Ask; the whole call is Allow only if every segment is covered or
281 // writer fallback allows uncovered segments.
282 // A segment recognized as read-only by shellsafe (echo/ls/git status/...) is
283 // allowed on its own without a rule, matching the behavior of an atomic
284 // read-only bash call.
285 func (p Policy) decideBashSegments(readOnly bool, parts []string) Decision {
286 out := Allow
287 for _, sub := range parts {
288 segReadOnly := readOnly
289 if !segReadOnly {
290 segReadOnly = isReadOnlyBashSubject(sub)
291 }
292 switch p.DecideSubject("bash", segReadOnly, sub) {
293 case Deny:
294 return Deny
295 case Ask:
296 out = Ask
297 }
298 }
299 return out
300 }
301
302 // DecideSubjects evaluates a tool call against every subject the call touches.
303 // This keeps two-path operations honest: a move is denied if either endpoint is
304 // denied, asks if either endpoint requires approval, and is allowed only when
305 // every endpoint is allowed under the same policy.
306 func (p Policy) DecideSubjects(toolName string, readOnly bool, subjects []string) Decision {
307 if len(subjects) == 0 {
308 return p.DecideSubject(toolName, readOnly, "")
309 }
310 out := Allow
311 for _, subject := range subjects {
312 switch p.DecideSubject(toolName, readOnly, subject) {
313 case Deny:
314 return Deny
315 case Ask:
316 out = Ask
317 }
318 }
319 return out
320 }
321
322 // matchAny reports whether any rule matches the (toolName, subject) pair. A
323 // subject-specific rule cannot match a call that exposes no subject.
324 func matchAny(rules []Rule, toolName, subject string) bool {
325 for _, r := range rules {
326 if !ruleToolMatches(r.Tool, toolName) {
327 continue
328 }
329 if r.Subject == "" {
330 return true
331 }
332 if subject == "" {
333 continue
334 }
335 if ruleSubjectMatches(r, subject) {
336 return true
337 }
338 }
339 return false
340 }
341
342 func matchAnyRaw(rules []Rule, toolName, subject string) bool {
343 for _, r := range rules {
344 if !ruleToolMatches(r.Tool, toolName) {
345 continue
346 }
347 if r.Subject == "" {
348 return true
349 }
350 if subject == "" {
351 continue
352 }
353 if rawRuleSubjectMatches(r, subject) {
354 return true
355 }
356 }
357 return false
358 }
359
360 func firstMatchingRule(rules []Rule, toolName, subject string, raw bool) (Rule, bool) {
361 for _, rule := range rules {
362 if !ruleToolMatches(rule.Tool, toolName) {
363 continue
364 }
365 if rule.Subject == "" {
366 return rule, true
367 }
368 if subject == "" {
369 continue
370 }
371 matches := ruleSubjectMatches(rule, subject)
372 if raw {
373 matches = rawRuleSubjectMatches(rule, subject)
374 }
375 if matches {
376 return rule, true
377 }
378 }
379 return Rule{}, false
380 }
381
382 func ruleConfigString(rule Rule) string {
383 if rule.Subject == "" {
384 return rule.Tool
385 }
386 if rule.Literal {
387 return rule.Tool + "=" + rule.Subject
388 }
389 return rule.Tool + "(" + rule.Subject + ")"
390 }
391
392 // MatchedRule reports the configured rule responsible for an explicit Ask or
393 // Deny decision. Fallback-mode and dynamic-safety decisions intentionally have
394 // no rule provenance. Compound Bash commands are inspected segment by segment
395 // using the same raw-prefix semantics as DecideSubject.
396 func (p Policy) MatchedRule(toolName string, decision Decision, args json.RawMessage) (string, bool) {
397 var rules []Rule
398 switch decision {
399 case Ask:
400 rules = p.Ask
401 case Deny:
402 rules = p.Deny
403 default:
404 return "", false
405 }
406 subjects := Subjects(args)
407 if len(subjects) == 0 {
408 subjects = []string{""}
409 }
410 raw := canonicalRuleTool(toolName) == "bash"
411 for _, subject := range subjects {
412 candidates := []string{subject}
413 if raw {
414 if parts := DecomposeBashCommand(subject); parts != nil {
415 candidates = append(candidates, parts...)
416 }
417 }
418 for _, candidate := range candidates {
419 // A matching configured rule is provenance only when that candidate's
420 // actual decision has the same outcome. SessionAllow may override an
421 // Ask rule on one endpoint while a different endpoint falls back to
422 // Ask; reporting the overridden rule would misstate why the call was
423 // stopped.
424 if p.DecideSubject(toolName, false, candidate) != decision {
425 continue
426 }
427 if rule, ok := firstMatchingRule(rules, toolName, candidate, raw); ok {
428 return ruleConfigString(rule), true
429 }
430 }
431 }
432 return "", false
433 }
434
435 func rawRuleSubjectMatches(rule Rule, subject string) bool {
436 if rule.Literal {
437 return rule.Subject == subject
438 }
439 if canonicalRuleTool(rule.Tool) == "bash" {
440 if base, ok := bashPrefixBase(rule.Subject); ok {
441 return rawBashPrefixMatches(base, subject)
442 }
443 }
444 return matchGlob(rule.Subject, subject)
445 }
446
447 func rawBashPrefixMatches(base, subject string) bool {
448 baseFields, malformed := shellparse.StaticFields(base)
449 if malformed == "" && len(baseFields) > 0 {
450 if features, ok := shellparse.AnalyzeApprovalFeatures(subject); ok && len(features.CommandPrefix) >= len(baseFields) {
451 matched := true
452 for i, want := range baseFields {
453 got := features.CommandPrefix[i]
454 if got != want && !(i == 0 && isCaseInsensitivePowerShellCmdlet(want) && strings.EqualFold(got, want)) {
455 matched = false
456 break
457 }
458 }
459 if matched {
460 return true
461 }
462 }
463 }
464 base = strings.TrimSpace(base)
465 subject = strings.TrimSpace(subject)
466 if subject == base || (isCaseInsensitivePowerShellCmdlet(base) && strings.EqualFold(subject, base)) {
467 return true
468 }
469 if len(subject) <= len(base) {
470 return false
471 }
472 prefixMatches := strings.HasPrefix(subject, base)
473 if isCaseInsensitivePowerShellCmdlet(base) {
474 prefixMatches = strings.EqualFold(subject[:len(base)], base)
475 }
476 if !prefixMatches {
477 return false
478 }
479 switch subject[len(base)] {
480 case ' ', '\t', '\r', '\n':
481 return true
482 default:
483 return false
484 }
485 }
486
487 func isCaseInsensitivePowerShellCmdlet(s string) bool {
488 _, ok := legacyBarePowerShellDenyCmdlet(s)
489 return ok
490 }
491
492 func matchAnyExact(rules []Rule, toolName, subject string) bool {
493 if subject == "" {
494 return false
495 }
496 for _, r := range rules {
497 if !ruleToolMatches(r.Tool, toolName) || r.Subject == "" {
498 continue
499 }
500 if r.Subject == subject && (r.Literal || !hasGlobMeta(r.Subject)) {
501 return true
502 }
503 }
504 return false
505 }
506
507 func matchAnyAllow(rules []Rule, toolName, subject string) bool {
508 if matchAnyExact(rules, toolName, subject) {
509 return true
510 }
511 if canonicalRuleTool(toolName) == "bash" && bashSubjectRequiresExactRule(subject) {
512 return false
513 }
514 return matchAny(rules, toolName, subject)
515 }
516
517 // RuleMatchesString reports whether one config-style rule string matches the
518 // given tool subject. It is used for session grants as well as persisted config
519 // rules so both paths share identical matching semantics.
520 func RuleMatchesString(rule, toolName, subject string) bool {
521 r, ok := ParseRule(rule)
522 return ok && matchAnyAllow([]Rule{r}, toolName, subject)
523 }
524
525 // RuleCoversString reports whether every call represented by candidate is
526 // already covered by existing. It intentionally proves only the cases Reasonix
527 // creates automatically: exact rules covered by broader globs or bare tool
528 // rules, exact duplicate globs, and bare tool rules covering subject rules.
529 func RuleCoversString(existing, candidate string) bool {
530 a, ok := ParseRule(existing)
531 if !ok {
532 return false
533 }
534 b, ok := ParseRule(candidate)
535 if !ok {
536 return false
537 }
538 if !ruleToolCompatible(a.Tool, b.Tool) {
539 return false
540 }
541 if b.Subject == "" {
542 return a.Subject == ""
543 }
544 if canonicalRuleTool(b.Tool) == "bash" && (b.Literal || !hasGlobMeta(b.Subject)) && bashSubjectRequiresExactRule(b.Subject) {
545 return matchAnyExact([]Rule{a}, canonicalRuleTool(b.Tool), b.Subject)
546 }
547 if a.Subject == "" {
548 return true
549 }
550 if bashRulePrefixBaseMatches(a, b) {
551 return true
552 }
553 if b.Literal || !hasGlobMeta(b.Subject) {
554 return ruleSubjectMatches(a, b.Subject)
555 }
556 return !a.Literal && a.Subject == b.Subject
557 }
558
559 func hasGlobMeta(s string) bool {
560 return strings.ContainsAny(s, "*?")
561 }
562
563 func bashRulePrefixBaseMatches(existing, candidate Rule) bool {
564 if canonicalRuleTool(existing.Tool) != "bash" || canonicalRuleTool(candidate.Tool) != "bash" {
565 return false
566 }
567 existingBase, ok := bashPrefixBase(existing.Subject)
568 if !ok {
569 return false
570 }
571 candidateBase, ok := bashPrefixBase(candidate.Subject)
572 return ok && existingBase == candidateBase
573 }
574
575 // subjectKeys are the JSON argument keys, in priority order, that carry a tool
576 // call's "subject" — the thing a Subject glob matches against. Generic so tools
577 // need not implement a permission-specific method: bash exposes command, the
578 // file tools expose path / file_path, grep & glob expose pattern.
579 var subjectKeys = []string{"command", "file_path", "path", "source_path", "destination_path", "pattern"}
580
581 // Subject extracts the primary matchable subject string from a call's raw JSON
582 // args, returning "" when none of the known keys is present (such a call only
583 // matches bare "ToolName" rules). Use Subjects for permission decisions that
584 // must account for every touched endpoint.
585 func Subject(args json.RawMessage) string {
586 subjects := Subjects(args)
587 if len(subjects) > 0 {
588 return subjects[0]
589 }
590 return ""
591 }
592
593 // Subjects extracts every matchable subject from a call's raw JSON args. Most
594 // tools expose one subject; move_file exposes both source_path and
595 // destination_path so path-scoped permission rules can protect either endpoint.
596 func Subjects(args json.RawMessage) []string {
597 if len(args) == 0 {
598 return nil
599 }
600 var m map[string]any
601 if err := json.Unmarshal(args, &m); err != nil {
602 return nil
603 }
604 src := stringArg(m, "source_path")
605 dst := stringArg(m, "destination_path")
606 if src != "" && dst != "" {
607 out := []string{src}
608 if dst != src {
609 out = append(out, dst)
610 }
611 return out
612 }
613 for _, k := range subjectKeys {
614 if s := stringArg(m, k); s != "" {
615 return []string{s}
616 }
617 }
618 return nil
619 }
620
621 func stringArg(m map[string]any, key string) string {
622 if v, ok := m[key]; ok {
623 if s, ok := v.(string); ok && s != "" {
624 return s
625 }
626 }
627 return ""
628 }
629
630 // matchGlob reports whether name matches pattern, where '*' matches any run of
631 // characters (including separators) and '?' matches exactly one. Unlike
632 // path.Match, '*' is not stopped by '/', which is what command-line and path
633 // prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
634 // backtracking, byte-oriented.
635 func matchGlob(pattern, name string) bool {
636 var px, nx, starPx, starNx int
637 starPx = -1
638 for nx < len(name) {
639 switch {
640 case px < len(pattern) && pattern[px] == '*':
641 starPx = px
642 starNx = nx
643 px++
644 case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
645 px++
646 nx++
647 case starPx != -1:
648 px = starPx + 1
649 starNx++
650 nx = starNx
651 default:
652 return false
653 }
654 }
655 for px < len(pattern) && pattern[px] == '*' {
656 px++
657 }
658 return px == len(pattern)
659 }
660
661 // Approver resolves an Ask decision interactively. Implementations live in the
662 // front-end (the chat TUI); a non-interactive run passes a nil Approver, which
663 // the Gate treats as "allow" to preserve autonomous behaviour.
664 type Approver interface {
665 // Approve asks the user about a pending call. It returns whether to allow
666 // it and whether to remember that choice as a new rule. A non-nil err (e.g.
667 // the context was cancelled while waiting) aborts the turn.
668 Approve(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, err error)
669 }
670
671 // ReasonedApprover is the optional extension used by frontends that can return
672 // a denial reason to feed back to the model.
673 type ReasonedApprover interface {
674 ApproveWithReason(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, reason string, err error)
675 }
676
677 // PolicyReasonedApprover receives the explicit permission-rule provenance that
678 // caused an Ask decision. Frontends can display it without duplicating Policy
679 // matching logic; older Approver implementations remain source-compatible.
680 type PolicyReasonedApprover interface {
681 ApproveWithPolicyReason(ctx context.Context, toolName, subject string, args json.RawMessage, policyReason string) (allow, remember bool, reason string, err error)
682 }
683
684 // Gate is what the agent consults at execute time: a Policy plus an optional
685 // Approver. It satisfies the agent's Gate interface structurally.
686 type Gate struct {
687 Policy Policy
688 Approver Approver
689
690 // OnRemember, when set, is invoked with a new allow rule the user chose to
691 // remember (e.g. "Bash(go build)"), so the front-end can persist it.
692 OnRemember func(rule string)
693 }
694
695 // NewGate wires a Policy to an Approver (nil for non-interactive use).
696 func NewGate(p Policy, a Approver) *Gate { return &Gate{Policy: p, Approver: a} }
697
698 // Check decides whether a tool call may run. It is the method the agent's Gate
699 // interface expects. A denied or refused call returns allow=false with a short
700 // reason the agent feeds back to the model.
701 func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
702 if toolName == "bash" && !readOnly {
703 if BashCommandIsReadOnly(args) {
704 readOnly = true
705 }
706 }
707 decision := g.Policy.Decide(toolName, readOnly, args)
708 ruleReason := ""
709 if rule, ok := g.Policy.MatchedRule(toolName, decision, args); ok {
710 ruleReason = fmt.Sprintf("Matched permission rule: %s %s", decision, rule)
711 }
712 switch decision {
713 case Deny:
714 reason := "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain."
715 if ruleReason != "" {
716 reason = ruleReason + "\n" + reason
717 }
718 return false, reason, nil
719 case Ask:
720 if g.Approver == nil {
721 return true, "", nil // non-interactive: preserve autonomy
722 }
723 subject := Subject(args)
724 allow, remember, approverReason, err := g.approve(ctx, toolName, subject, args, ruleReason)
725 if err != nil {
726 return false, "approval aborted", err
727 }
728 if !allow {
729 reason := "the user declined this tool call — do not retry it; ask how they would like to proceed or choose another approach."
730 if approverReason != "" {
731 reason = approverReason
732 }
733 return false, reason, nil
734 }
735 if remember && g.OnRemember != nil {
736 // "Always allow" is tool-wide: persist the bare tool name so any
737 // later subject (a different file / command) is allowed without
738 // re-prompting. Deny rules still take precedence on every call.
739 g.OnRemember(toolName)
740 // Also add the rule to the in-memory Policy immediately so it
741 // takes effect in the current session without requiring a restart.
742 // The session-level grant (controller.granted) already covers the
743 // Approver path, but any code path that consults Policy.Decide()
744 // directly would miss the rule until the next controller build.
745 if rule, ok := ParseRule(toolName); ok {
746 g.Policy.Allow = append(g.Policy.Allow, rule)
747 }
748 }
749 return true, "", nil
750 default:
751 return true, "", nil
752 }
753 }
754
755 // ExplicitlyDenies reports whether an explicit deny rule matches. Authorized
756 // MCP servers use this narrow view so install-time authorization is not
757 // followed by redundant per-call approval prompts.
758 func (g *Gate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
759 return g.Policy.ExplicitlyDenies(toolName, args)
760 }
761
762 func (g *Gate) approve(ctx context.Context, toolName, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
763 if a, ok := g.Approver.(PolicyReasonedApprover); ok {
764 return a.ApproveWithPolicyReason(ctx, toolName, subject, args, policyReason)
765 }
766 if a, ok := g.Approver.(ReasonedApprover); ok {
767 return a.ApproveWithReason(ctx, toolName, subject, args)
768 }
769 allow, remember, err := g.Approver.Approve(ctx, toolName, subject, args)
770 return allow, remember, "", err
771 }
772
773 // rememberRule builds the rule string persisted when the user picks "always
774 // allow". Bash commands prefer a safe command prefix (e.g. go test:*) so
775 // "always allow" covers similar invocations with different arguments. File
776 // mutation tools are remembered tool-wide ("Edit") so approving one file edit
777 // covers all files. Other tools are remembered by tool name. Deny and ask rules keep their higher precedence.
778 func rememberRule(toolName, subject string) string {
779 return RememberRuleForScope(toolName, subject)
780 }
781
782 // RememberRuleForScope builds the rule string persisted when the user chooses
783 // an always-allow option. Bash commands prefer a safe prefix (go test:*) so
784 // similar invocations (different search terms, different test packages) match;
785 // when no safe prefix can be extracted the exact command is used. File
786 // mutation tools are always remembered tool-wide (Edit). Other tools use their
787 // bare tool name. Deny rules still take precedence on every call.
788 func RememberRuleForScope(toolName, subject string) string {
789 subject = strings.TrimSpace(subject)
790 if subject != "" && toolName == "bash" {
791 if pattern := BashCommandPrefix(subject); pattern != "" {
792 return "Bash(" + pattern + ")"
793 }
794 return "Bash=" + subject
795 }
796 if IsFileMutationTool(toolName) {
797 return "Edit"
798 }
799 return toolName
800 }
801
802 // SessionGrantKey returns the in-memory rule for "allow this session". Bash
803 // prefers a command prefix when one is available, falling back to the exact
804 // command when unsafe. File mutation tools share a single Edit grant.
805 func SessionGrantKey(toolName, subject string) string {
806 return SessionGrantRuleForScope(toolName, subject)
807 }
808
809 // SessionGrantRuleForScope returns the in-memory rule for a session grant.
810 // Bash prefers a command prefix when one is available; file mutation tools
811 // share a single Edit grant; all other tools return the bare tool name.
812 func SessionGrantRuleForScope(toolName, subject string) string {
813 subject = strings.TrimSpace(subject)
814 if toolName == "bash" && subject != "" {
815 if pattern := BashCommandPrefix(subject); pattern != "" {
816 return "Bash(" + pattern + ")"
817 }
818 return "Bash=" + subject
819 }
820 if IsFileMutationTool(toolName) {
821 return "Edit"
822 }
823 return toolName
824 }
825
826 // BashCommandPrefix returns a conservative prefix rule for "similar command"
827 // approvals. It avoids shell syntax and keeps the prefix at command-word
828 // boundaries, so approving "go test ./..." grants "go test:*" rather than a
829 // broader "go *".
830 func BashCommandPrefix(subject string) string {
831 cmd := strings.TrimSpace(subject)
832 if cmd == "" || containsShellSyntax(cmd) || bashSubjectRequiresExactRule(cmd) {
833 return ""
834 }
835 if BashDangerWarning(cmd) != "" {
836 return ""
837 }
838 fields, malformed := shellparse.StaticFields(cmd)
839 if malformed != "" {
840 return ""
841 }
842 if len(fields) < 2 {
843 return ""
844 }
845 base := strings.ToLower(fields[0])
846 if isPackageManagerRun(base) && len(fields) >= 3 && strings.ToLower(fields[1]) == "run" {
847 return fields[0] + " " + fields[1] + " " + fields[2] + ":*"
848 }
849 return fields[0] + " " + fields[1] + ":*"
850 }
851
852 func isPackageManagerRun(base string) bool {
853 switch base {
854 case "npm", "pnpm", "yarn", "bun":
855 return true
856 default:
857 return false
858 }
859 }
860
861 // IsFileMutationTool reports whether a built-in tool mutates workspace files.
862 func IsFileMutationTool(toolName string) bool {
863 switch toolName {
864 case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol":
865 return true
866 default:
867 return false
868 }
869 }
870
871 func ruleToolMatches(ruleTool, toolName string) bool {
872 ruleTool = canonicalRuleTool(ruleTool)
873 return ruleTool == toolName || (ruleTool == "file_mutation" && IsFileMutationTool(toolName))
874 }
875
876 func ruleToolCompatible(existingTool, candidateTool string) bool {
877 existingTool = canonicalRuleTool(existingTool)
878 candidateTool = canonicalRuleTool(candidateTool)
879 return existingTool == candidateTool ||
880 (existingTool == "file_mutation" && (candidateTool == "file_mutation" || IsFileMutationTool(candidateTool)))
881 }
882
883 func canonicalRuleTool(toolName string) string {
884 switch strings.TrimSpace(toolName) {
885 case "Bash", "bash":
886 return "bash"
887 case "Edit", "edit", "file_mutation":
888 return "file_mutation"
889 default:
890 return toolName
891 }
892 }
893
894 func ruleSubjectMatches(rule Rule, subject string) bool {
895 if rule.Subject == "" {
896 return true
897 }
898 if subject == "" {
899 return false
900 }
901 if rule.Literal {
902 return rule.Subject == subject
903 }
904 if canonicalRuleTool(rule.Tool) == "bash" {
905 if base, ok := bashColonPrefixBase(rule.Subject); ok {
906 return bashPrefixMatches(base, subject)
907 }
908 if base, ok := legacyBashSpaceStarPrefixBase(rule.Subject); ok {
909 return bashPrefixMatches(base, subject)
910 }
911 }
912 return matchGlob(rule.Subject, subject)
913 }
914
915 func bashColonPrefixBase(pattern string) (string, bool) {
916 if !strings.HasSuffix(pattern, ":*") {
917 return "", false
918 }
919 base := strings.TrimSuffix(pattern, ":*")
920 return base, base != ""
921 }
922
923 func legacyBashSpaceStarPrefixBase(pattern string) (string, bool) {
924 if !strings.HasSuffix(pattern, " *") {
925 return "", false
926 }
927 base := strings.TrimSuffix(pattern, " *")
928 return base, base != ""
929 }
930
931 func bashPrefixBase(pattern string) (string, bool) {
932 if base, ok := bashColonPrefixBase(pattern); ok {
933 return base, true
934 }
935 return legacyBashSpaceStarPrefixBase(pattern)
936 }
937
938 func bashPrefixMatches(base, subject string) bool {
939 if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
940 subject = normalized
941 }
942 fields, malformed := shellparse.StaticFields(subject)
943 if malformed != "" {
944 return false
945 }
946 baseFields, malformed := shellparse.StaticFields(base)
947 if malformed != "" || len(baseFields) == 0 || len(fields) < len(baseFields) {
948 return false
949 }
950 for i, want := range baseFields {
951 if fields[i] != want {
952 return false
953 }
954 }
955 return true
956 }
957
957 lines GO