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