| 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 |