| @@ -0,0 +1,865 @@ |
| 1 | + package approval |
| 2 | + |
| 3 | + // Permission rules in Claude Code's syntax. |
| 4 | + // |
| 5 | + // A script written for `claude -p` says what the agent may do with |
| 6 | + // --allowedTools and --disallowedTools, and a repository says the same thing in |
| 7 | + // .claude/settings.json under permissions.allow and permissions.deny. Both are |
| 8 | + // lists of rules in one grammar, and this file is the one reading of it, so |
| 9 | + // that a rule means the same thing wherever Codey found it. |
| 10 | + // |
| 11 | + // The API, for anything that has a list of rule strings in hand: |
| 12 | + // |
| 13 | + // rule, err := approval.ParseRule("Bash(npm run test:*)") |
| 14 | + // rule.Matches("bash", input) // the rule covers the whole call |
| 15 | + // rule.Touches("bash", input) // the rule covers some part of the call |
| 16 | + // |
| 17 | + // set := approval.Ruleset{Allow: allowRules, Deny: denyRules} |
| 18 | + // verdict, rule := set.Check("bash", input) // Allowed, Denied or Unmatched |
| 19 | + // policy.Rules = set // and the policy consults it |
| 20 | + // |
| 21 | + // SplitRules cuts a flag value or a settings entry into rule strings, and |
| 22 | + // ParseRules parses a list of them, keeping the good ones and reporting the |
| 23 | + // rest. An error from ParseRule is a reason to warn, not to stop: a rule |
| 24 | + // naming a tool Codey does not have is a rule about nothing here. |
| 25 | + // |
| 26 | + // Two readings of "matches" exist because allowing and denying need opposite |
| 27 | + // kinds of caution. A compound shell command is allowed only when every |
| 28 | + // simple command in it is covered by some allow rule, so `git status; rm -rf |
| 29 | + // ~` does not ride in on Bash(git status:*). It is denied when any one of them |
| 30 | + // hits a deny rule. A command the shell analysis cannot see through — command |
| 31 | + // substitution, an inline interpreter script, a parse error — is never allowed |
| 32 | + // by a rule, and is denied if anything that can be read out of it matches. |
| 33 | + // |
| 34 | + // Deny rules are a guard rail, not a sandbox, for the reason shell.go gives: |
| 35 | + // a determined command can always be spelled so that no string match sees it. |
| 36 | + // They catch the command the model would ordinarily write. |
| 37 | + |
| 38 | + import ( |
| 39 | + "encoding/json" |
| 40 | + "errors" |
| 41 | + "fmt" |
| 42 | + "net/url" |
| 43 | + "os" |
| 44 | + "path" |
| 45 | + "path/filepath" |
| 46 | + "strings" |
| 47 | + "unicode" |
| 48 | + |
| 49 | + "mvdan.cc/sh/v3/syntax" |
| 50 | + ) |
| 51 | + |
| 52 | + // ErrUnknownTool is wrapped by ParseRule when a rule names a tool Codey has no |
| 53 | + // counterpart for. Callers warn and carry on. |
| 54 | + var ErrUnknownTool = errors.New("unknown tool") |
| 55 | + |
| 56 | + // specKind is what the text inside a rule's parentheses is about. |
| 57 | + type specKind int |
| 58 | + |
| 59 | + const ( |
| 60 | + specNone specKind = iota // the rule takes no argument |
| 61 | + specBash // a command, or a command prefix |
| 62 | + specPath // a gitignore-style path pattern |
| 63 | + specDomain // domain:host, for web_fetch |
| 64 | + ) |
| 65 | + |
| 66 | + // ccTool is how one Claude Code tool name lands in Codey. |
| 67 | + type ccTool struct { |
| 68 | + // whole is what the bare name covers; scoped is what a rule with an |
| 69 | + // argument covers. They differ for Read, whose path rules Claude Code |
| 70 | + // applies to its search tools as well, and they are the same elsewhere. |
| 71 | + whole, scoped []string |
| 72 | + spec specKind |
| 73 | + } |
| 74 | + |
| 75 | + // ccTools maps Claude Code's tool names onto Codey's. |
| 76 | + // |
| 77 | + // Edit covers write_file as well as edit_file because Claude Code says its |
| 78 | + // Edit rules apply to every built-in tool that edits files, and Write is one; |
| 79 | + // a script that disallows Edit wants no edits, whichever tool would make them. |
| 80 | + // MultiEdit is Claude Code's several-replacements edit, which edit_file is. |
| 81 | + var ccTools = map[string]ccTool{ |
| 82 | + "Bash": {whole: []string{"bash"}, spec: specBash}, |
| 83 | + "Read": {whole: []string{"read_file"}, scoped: []string{"read_file", "glob", "grep"}, spec: specPath}, |
| 84 | + "Edit": {whole: []string{"edit_file", "write_file"}, spec: specPath}, |
| 85 | + "MultiEdit": {whole: []string{"edit_file"}, spec: specPath}, |
| 86 | + "Write": {whole: []string{"write_file"}, spec: specPath}, |
| 87 | + "Glob": {whole: []string{"glob"}}, |
| 88 | + "Grep": {whole: []string{"grep"}}, |
| 89 | + "WebFetch": {whole: []string{"web_fetch"}, spec: specDomain}, |
| 90 | + "WebSearch": {whole: []string{"web_search"}}, |
| 91 | + "TodoWrite": {whole: []string{"todo_write"}}, |
| 92 | + "Task": {whole: []string{"spawn_agent"}}, |
| 93 | + "Agent": {whole: []string{"spawn_agent"}}, |
| 94 | + "BashOutput": {whole: []string{"process_output"}}, |
| 95 | + "KillShell": {whole: []string{"process_kill"}}, |
| 96 | + "KillBash": {whole: []string{"process_kill"}}, |
| 97 | + "Skill": {whole: []string{"skill"}}, |
| 98 | + "AskUserQuestion": {whole: []string{"ask_user"}}, |
| 99 | + } |
| 100 | + |
| 101 | + // codeyTools are Codey's own names, accepted as they are, so a rule can name a |
| 102 | + // tool Claude Code has no word for. The ones that take an argument take the |
| 103 | + // same argument as their Claude Code counterpart. |
| 104 | + var codeyTools = map[string]specKind{ |
| 105 | + "bash": specBash, "read_file": specPath, "edit_file": specPath, "write_file": specPath, |
| 106 | + "glob": specNone, "grep": specNone, "web_fetch": specDomain, "web_search": specNone, |
| 107 | + "todo_write": specNone, "process_output": specNone, "process_kill": specNone, |
| 108 | + "skill": specNone, "ask_user": specNone, "context_status": specNone, |
| 109 | + "spawn_agent": specNone, "agent_wait": specNone, "lsp": specNone, |
| 110 | + } |
| 111 | + |
| 112 | + // mcpPrefix is how an MCP tool's registered name begins, here as in Claude Code. |
| 113 | + const mcpPrefix = "mcp__" |
| 114 | + |
| 115 | + // Rule is one parsed permission rule. |
| 116 | + type Rule struct { |
| 117 | + // Raw is the rule as it was written, for messages. |
| 118 | + Raw string |
| 119 | + // Tools are the Codey tool names the rule is about. For an MCP rule it |
| 120 | + // holds the one name, which is a whole server when it has no tool part. |
| 121 | + Tools []string |
| 122 | + // Specifier is the text inside the parentheses, empty for a rule about the |
| 123 | + // whole tool. |
| 124 | + Specifier string |
| 125 | + // Root is the directory relative path patterns are read against — the |
| 126 | + // working root for a flag, the project for a settings file. Empty means |
| 127 | + // paths are compared as the tool call gave them. |
| 128 | + Root string |
| 129 | + // Home is what a leading ~/ in a path pattern stands for. Empty means the |
| 130 | + // current user's home directory. |
| 131 | + Home string |
| 132 | + |
| 133 | + spec specKind |
| 134 | + mcp bool |
| 135 | + } |
| 136 | + |
| 137 | + // SplitRules cuts a list of rules into its items. Claude Code accepts them |
| 138 | + // separated by commas or by spaces, and a rule's argument may itself contain |
| 139 | + // either — Bash(git log --oneline, -n 5) is one rule — so separators count only |
| 140 | + // outside parentheses. |
| 141 | + func SplitRules(list string) []string { |
| 142 | + var ( |
| 143 | + out []string |
| 144 | + cur strings.Builder |
| 145 | + depth int |
| 146 | + ) |
| 147 | + flush := func() { |
| 148 | + if s := strings.TrimSpace(cur.String()); s != "" { |
| 149 | + out = append(out, s) |
| 150 | + } |
| 151 | + cur.Reset() |
| 152 | + } |
| 153 | + for _, r := range list { |
| 154 | + switch { |
| 155 | + case r == '(': |
| 156 | + depth++ |
| 157 | + case r == ')' && depth > 0: |
| 158 | + depth-- |
| 159 | + case depth == 0 && (r == ',' || unicode.IsSpace(r)): |
| 160 | + flush() |
| 161 | + continue |
| 162 | + } |
| 163 | + cur.WriteRune(r) |
| 164 | + } |
| 165 | + flush() |
| 166 | + return out |
| 167 | + } |
| 168 | + |
| 169 | + // ParseRules parses every rule in the list, keeping the good ones. The errors |
| 170 | + // are for a warning: one rule that names a tool Codey does not have is no |
| 171 | + // reason to drop the others. |
| 172 | + func ParseRules(list []string) ([]Rule, []error) { |
| 173 | + var ( |
| 174 | + rules []Rule |
| 175 | + errs []error |
| 176 | + ) |
| 177 | + for _, s := range list { |
| 178 | + r, err := ParseRule(s) |
| 179 | + if err != nil { |
| 180 | + errs = append(errs, err) |
| 181 | + continue |
| 182 | + } |
| 183 | + rules = append(rules, r) |
| 184 | + } |
| 185 | + return rules, errs |
| 186 | + } |
| 187 | + |
| 188 | + // ParseRule reads one rule: a tool name, optionally followed by an argument in |
| 189 | + // parentheses — Bash, Bash(git status), Bash(npm run test:*), Read(./src/**), |
| 190 | + // WebFetch(domain:example.com), mcp__github, mcp__github__create_issue. |
| 191 | + func ParseRule(s string) (Rule, error) { |
| 192 | + raw := strings.TrimSpace(s) |
| 193 | + name, spec := raw, "" |
| 194 | + if i := strings.IndexByte(raw, '('); i >= 0 { |
| 195 | + if !strings.HasSuffix(raw, ")") { |
| 196 | + return Rule{}, fmt.Errorf("rule %q: no closing parenthesis", raw) |
| 197 | + } |
| 198 | + name, spec = raw[:i], strings.TrimSpace(raw[i+1:len(raw)-1]) |
| 199 | + } |
| 200 | + if name == "" { |
| 201 | + return Rule{}, fmt.Errorf("rule %q: no tool name", raw) |
| 202 | + } |
| 203 | + // An argument of * is the whole tool written longhand. |
| 204 | + if spec == "*" { |
| 205 | + spec = "" |
| 206 | + } |
| 207 | + r := Rule{Raw: raw, Specifier: spec} |
| 208 | + |
| 209 | + switch { |
| 210 | + case strings.HasPrefix(name, mcpPrefix): |
| 211 | + if spec != "" { |
| 212 | + return Rule{}, fmt.Errorf("rule %q: an MCP rule takes no argument", raw) |
| 213 | + } |
| 214 | + // mcp__server__* is mcp__server: every tool the server has. |
| 215 | + name = strings.TrimSuffix(name, "__*") |
| 216 | + if name == strings.TrimSuffix(mcpPrefix, "__") || name == mcpPrefix { |
| 217 | + return Rule{}, fmt.Errorf("rule %q: no server named", raw) |
| 218 | + } |
| 219 | + r.Tools, r.mcp = []string{name}, true |
| 220 | + return r, nil |
| 221 | + } |
| 222 | + |
| 223 | + if t, ok := ccTools[name]; ok { |
| 224 | + r.Tools, r.spec = t.whole, t.spec |
| 225 | + if spec != "" && t.scoped != nil { |
| 226 | + r.Tools = t.scoped |
| 227 | + } |
| 228 | + } else if kind, ok := codeyTools[name]; ok { |
| 229 | + r.Tools, r.spec = []string{name}, kind |
| 230 | + } else { |
| 231 | + return Rule{}, fmt.Errorf("rule %q: %w %q", raw, ErrUnknownTool, name) |
| 232 | + } |
| 233 | + |
| 234 | + if spec == "" { |
| 235 | + return r, nil |
| 236 | + } |
| 237 | + switch r.spec { |
| 238 | + case specNone: |
| 239 | + return Rule{}, fmt.Errorf("rule %q: %s takes no argument", raw, name) |
| 240 | + case specDomain: |
| 241 | + host, ok := strings.CutPrefix(spec, "domain:") |
| 242 | + if !ok || host == "" { |
| 243 | + return Rule{}, fmt.Errorf("rule %q: expected %s(domain:example.com)", raw, name) |
| 244 | + } |
| 245 | + r.Specifier = strings.ToLower(host) |
| 246 | + } |
| 247 | + return r, nil |
| 248 | + } |
| 249 | + |
| 250 | + // String is the rule as it was written. |
| 251 | + func (r Rule) String() string { return r.Raw } |
| 252 | + |
| 253 | + // WholeTool reports whether the rule is about every call to its tools, which |
| 254 | + // for a deny rule means the tools need not be offered at all. |
| 255 | + func (r Rule) WholeTool() bool { return r.Specifier == "" } |
| 256 | + |
| 257 | + // Covers reports whether the rule is about this tool at all, whatever the |
| 258 | + // arguments. |
| 259 | + func (r Rule) Covers(tool string) bool { |
| 260 | + for _, t := range r.Tools { |
| 261 | + if tool == t || (r.mcp && strings.HasPrefix(tool, t+"__")) { |
| 262 | + return true |
| 263 | + } |
| 264 | + } |
| 265 | + return false |
| 266 | + } |
| 267 | + |
| 268 | + // Matches reports whether the rule covers the whole of this call. A shell |
| 269 | + // command matches only when every simple command in it does, and never when |
| 270 | + // the analysis cannot see what it runs. This is the reading an allow rule |
| 271 | + // wants. |
| 272 | + func (r Rule) Matches(tool string, input json.RawMessage) bool { |
| 273 | + if !r.Covers(tool) { |
| 274 | + return false |
| 275 | + } |
| 276 | + if r.WholeTool() { |
| 277 | + return true |
| 278 | + } |
| 279 | + switch r.spec { |
| 280 | + case specBash: |
| 281 | + cmds, exact := shellCommands(bashCommand(input)) |
| 282 | + if !exact || len(cmds) == 0 { |
| 283 | + return false |
| 284 | + } |
| 285 | + for _, c := range cmds { |
| 286 | + if !r.bashAllows(c) { |
| 287 | + return false |
| 288 | + } |
| 289 | + } |
| 290 | + return true |
| 291 | + default: |
| 292 | + return r.matchesArg(tool, input) |
| 293 | + } |
| 294 | + } |
| 295 | + |
| 296 | + // Touches reports whether any part of this call falls under the rule: one |
| 297 | + // simple command in a compound shell command is enough, as is anything that |
| 298 | + // can be read out of a command the analysis cannot otherwise see through. This |
| 299 | + // is the reading a deny rule wants. |
| 300 | + func (r Rule) Touches(tool string, input json.RawMessage) bool { |
| 301 | + if !r.Covers(tool) { |
| 302 | + return false |
| 303 | + } |
| 304 | + if r.WholeTool() { |
| 305 | + return true |
| 306 | + } |
| 307 | + if r.spec == specBash { |
| 308 | + for _, words := range denyCandidates(bashCommand(input), 0) { |
| 309 | + if r.bashWordsMatch(words) { |
| 310 | + return true |
| 311 | + } |
| 312 | + } |
| 313 | + return false |
| 314 | + } |
| 315 | + return r.matchesArg(tool, input) |
| 316 | + } |
| 317 | + |
| 318 | + // matchesArg is the non-shell half of both readings: a path or a URL is one |
| 319 | + // thing, so there is nothing to be strict or lenient about. |
| 320 | + func (r Rule) matchesArg(tool string, input json.RawMessage) bool { |
| 321 | + var args struct { |
| 322 | + Path string `json:"path"` |
| 323 | + URL string `json:"url"` |
| 324 | + } |
| 325 | + json.Unmarshal(input, &args) |
| 326 | + switch r.spec { |
| 327 | + case specPath: |
| 328 | + p := args.Path |
| 329 | + if p == "" && (tool == "glob" || tool == "grep") { |
| 330 | + // The search tools default to the working root. |
| 331 | + p = "." |
| 332 | + } |
| 333 | + return p != "" && r.pathMatches(p) |
| 334 | + case specDomain: |
| 335 | + u, err := url.Parse(args.URL) |
| 336 | + if err != nil || u.Hostname() == "" { |
| 337 | + return false |
| 338 | + } |
| 339 | + host := strings.ToLower(u.Hostname()) |
| 340 | + if suffix, ok := strings.CutPrefix(r.Specifier, "*."); ok { |
| 341 | + return strings.HasSuffix(host, "."+suffix) |
| 342 | + } |
| 343 | + return host == r.Specifier |
| 344 | + } |
| 345 | + return false |
| 346 | + } |
| 347 | + |
| 348 | + // Verdict is what a Ruleset says about one call. |
| 349 | + type Verdict int |
| 350 | + |
| 351 | + const ( |
| 352 | + // Unmatched leaves the call to whatever decides otherwise — a prompt, or |
| 353 | + // a refusal where there is nobody to prompt. |
| 354 | + Unmatched Verdict = iota |
| 355 | + Allowed |
| 356 | + Denied |
| 357 | + ) |
| 358 | + |
| 359 | + // Ruleset is a list of allow rules and a list of deny rules, decided together. |
| 360 | + type Ruleset struct { |
| 361 | + Allow []Rule |
| 362 | + Deny []Rule |
| 363 | + } |
| 364 | + |
| 365 | + // Empty reports whether there is nothing to decide with. |
| 366 | + func (s Ruleset) Empty() bool { return len(s.Allow) == 0 && len(s.Deny) == 0 } |
| 367 | + |
| 368 | + // Check decides a call. Deny wins over allow, as in Claude Code: a rule saying |
| 369 | + // no is a statement about this repository, and no allow rule written for |
| 370 | + // convenience overrides it. The rule returned is the one that decided, for a |
| 371 | + // message. |
| 372 | + // |
| 373 | + // A compound shell command is allowed when each of its simple commands is |
| 374 | + // covered by some allow rule — not necessarily the same one — so |
| 375 | + // `git status && npm test` passes under Bash(git status) and Bash(npm test:*). |
| 376 | + // The rule returned then is the first that matched. |
| 377 | + func (s Ruleset) Check(tool string, input json.RawMessage) (Verdict, Rule) { |
| 378 | + for _, r := range s.Deny { |
| 379 | + if r.Touches(tool, input) { |
| 380 | + return Denied, r |
| 381 | + } |
| 382 | + } |
| 383 | + if tool != "bash" { |
| 384 | + for _, r := range s.Allow { |
| 385 | + if r.Matches(tool, input) { |
| 386 | + return Allowed, r |
| 387 | + } |
| 388 | + } |
| 389 | + return Unmatched, Rule{} |
| 390 | + } |
| 391 | + |
| 392 | + cmds, exact := shellCommands(bashCommand(input)) |
| 393 | + if !exact || len(cmds) == 0 { |
| 394 | + // What cannot be seen through is allowed only by a rule that allows |
| 395 | + // every command there is. |
| 396 | + for _, r := range s.Allow { |
| 397 | + if r.Covers(tool) && r.WholeTool() { |
| 398 | + return Allowed, r |
| 399 | + } |
| 400 | + } |
| 401 | + return Unmatched, Rule{} |
| 402 | + } |
| 403 | + var first Rule |
| 404 | + for i, c := range cmds { |
| 405 | + covered := false |
| 406 | + for _, r := range s.Allow { |
| 407 | + if r.Covers(tool) && (r.WholeTool() || r.bashAllows(c)) { |
| 408 | + if i == 0 { |
| 409 | + first = r |
| 410 | + } |
| 411 | + covered = true |
| 412 | + break |
| 413 | + } |
| 414 | + } |
| 415 | + if !covered { |
| 416 | + return Unmatched, Rule{} |
| 417 | + } |
| 418 | + } |
| 419 | + return Allowed, first |
| 420 | + } |
| 421 | + |
| 422 | + // bashCommand pulls the command out of a bash call. |
| 423 | + func bashCommand(input json.RawMessage) string { |
| 424 | + var args struct { |
| 425 | + Command string `json:"command"` |
| 426 | + } |
| 427 | + json.Unmarshal(input, &args) |
| 428 | + return args.Command |
| 429 | + } |
| 430 | + |
| 431 | + // shellCmd is one simple command, as words. |
| 432 | + type shellCmd struct { |
| 433 | + words []string |
| 434 | + // fixed[i] says words[i] is exactly what the shell will pass: no |
| 435 | + // expansion, no unquoted glob. Only fixed words can match a rule's own |
| 436 | + // words; anything else can only fall under its trailing wildcard. |
| 437 | + fixed []bool |
| 438 | + } |
| 439 | + |
| 440 | + // bashAllows is the strict reading for one simple command. |
| 441 | + func (r Rule) bashAllows(c shellCmd) bool { |
| 442 | + want, prefix := r.bashPattern() |
| 443 | + if want == nil { |
| 444 | + // A rule with a wildcard in the middle is matched against the command |
| 445 | + // as one string, which only means anything when every word is fixed. |
| 446 | + for _, f := range c.fixed { |
| 447 | + if !f { |
| 448 | + return false |
| 449 | + } |
| 450 | + } |
| 451 | + return wildcardMatch(r.Specifier, strings.Join(c.words, " ")) |
| 452 | + } |
| 453 | + if len(c.words) < len(want) || (!prefix && len(c.words) != len(want)) { |
| 454 | + return false |
| 455 | + } |
| 456 | + for i, w := range want { |
| 457 | + if !c.fixed[i] || c.words[i] != w { |
| 458 | + return false |
| 459 | + } |
| 460 | + } |
| 461 | + return true |
| 462 | + } |
| 463 | + |
| 464 | + // bashWordsMatch is the lenient reading for one candidate: the words as |
| 465 | + // written, expansions and all, against the rule. |
| 466 | + func (r Rule) bashWordsMatch(words []string) bool { |
| 467 | + want, prefix := r.bashPattern() |
| 468 | + if want == nil { |
| 469 | + return wildcardMatch(r.Specifier, strings.Join(words, " ")) |
| 470 | + } |
| 471 | + if len(words) < len(want) || (!prefix && len(words) != len(want)) { |
| 472 | + return false |
| 473 | + } |
| 474 | + for i, w := range want { |
| 475 | + if words[i] != w { |
| 476 | + return false |
| 477 | + } |
| 478 | + } |
| 479 | + return true |
| 480 | + } |
| 481 | + |
| 482 | + // bashPattern reads a Bash rule's argument. `git status:*` and `git status *` |
| 483 | + // are a prefix, matched word by word, so Bash(ls:*) covers `ls -la` and not |
| 484 | + // `lsof`; anything without a wildcard is an exact command. A wildcard anywhere |
| 485 | + // else returns nil, and the argument is matched as a pattern over the whole |
| 486 | + // command line. |
| 487 | + func (r Rule) bashPattern() (words []string, prefix bool) { |
| 488 | + spec := r.Specifier |
| 489 | + switch { |
| 490 | + case strings.HasSuffix(spec, ":*"): |
| 491 | + spec, prefix = strings.TrimSuffix(spec, ":*"), true |
| 492 | + case strings.HasSuffix(spec, " *"): |
| 493 | + spec, prefix = strings.TrimSuffix(spec, " *"), true |
| 494 | + } |
| 495 | + if strings.Contains(spec, "*") { |
| 496 | + return nil, false |
| 497 | + } |
| 498 | + return strings.Fields(spec), prefix |
| 499 | + } |
| 500 | + |
| 501 | + // wildcardMatch matches s against a pattern in which * stands for any run of |
| 502 | + // characters, and nothing else is special. |
| 503 | + func wildcardMatch(pattern, s string) bool { |
| 504 | + parts := strings.Split(pattern, "*") |
| 505 | + if len(parts) == 1 { |
| 506 | + return pattern == s |
| 507 | + } |
| 508 | + if !strings.HasPrefix(s, parts[0]) { |
| 509 | + return false |
| 510 | + } |
| 511 | + s = s[len(parts[0]):] |
| 512 | + for _, p := range parts[1 : len(parts)-1] { |
| 513 | + i := strings.Index(s, p) |
| 514 | + if i < 0 { |
| 515 | + return false |
| 516 | + } |
| 517 | + s = s[i+len(p):] |
| 518 | + } |
| 519 | + return strings.HasSuffix(s, parts[len(parts)-1]) |
| 520 | + } |
| 521 | + |
| 522 | + // shellCommands lists the simple commands in a script, and reports whether |
| 523 | + // that list is the whole truth about what it runs. It is not when the script |
| 524 | + // does not parse, substitutes a command, runs an inline interpreter script, |
| 525 | + // assigns a variable — PATH=. changes what every later word means — or writes |
| 526 | + // output to a file, which no command prefix says anything about. |
| 527 | + func shellCommands(script string) ([]shellCmd, bool) { |
| 528 | + f, err := syntax.NewParser().Parse(strings.NewReader(script), "") |
| 529 | + if err != nil { |
| 530 | + return nil, false |
| 531 | + } |
| 532 | + exact := true |
| 533 | + var cmds []shellCmd |
| 534 | + syntax.Walk(f, func(node syntax.Node) bool { |
| 535 | + switch n := node.(type) { |
| 536 | + case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.DeclClause, *syntax.CoprocClause: |
| 537 | + exact = false |
| 538 | + case *syntax.Stmt: |
| 539 | + for _, rd := range n.Redirs { |
| 540 | + if writesFile(rd) { |
| 541 | + exact = false |
| 542 | + } |
| 543 | + } |
| 544 | + case *syntax.CallExpr: |
| 545 | + if len(n.Assigns) > 0 { |
| 546 | + exact = false |
| 547 | + } |
| 548 | + if len(n.Args) == 0 { |
| 549 | + return true |
| 550 | + } |
| 551 | + c := shellCmd{} |
| 552 | + for _, w := range n.Args { |
| 553 | + text, fixed := wordText(w) |
| 554 | + c.words = append(c.words, text) |
| 555 | + c.fixed = append(c.fixed, fixed) |
| 556 | + } |
| 557 | + if !c.fixed[0] { |
| 558 | + // A command name the shell computes is a command nobody |
| 559 | + // can name in a rule. |
| 560 | + exact = false |
| 561 | + } |
| 562 | + if interpreters[c.words[0]] && inlineScript(c.words[1:]) { |
| 563 | + exact = false |
| 564 | + } |
| 565 | + cmds = append(cmds, c) |
| 566 | + } |
| 567 | + return true |
| 568 | + }) |
| 569 | + return cmds, exact |
| 570 | + } |
| 571 | + |
| 572 | + // writesFile reports whether a redirection sends output somewhere other than |
| 573 | + // another descriptor or /dev/null. |
| 574 | + func writesFile(rd *syntax.Redirect) bool { |
| 575 | + target, fixed := wordText(rd.Word) |
| 576 | + switch rd.Op { |
| 577 | + case syntax.RdrOut, syntax.AppOut, syntax.RdrAll, syntax.AppAll, syntax.ClbOut, syntax.RdrInOut: |
| 578 | + case syntax.DplOut: |
| 579 | + // >&2 duplicates a descriptor; >&name, in bash, writes a file. |
| 580 | + if fixed && (target == "-" || strings.Trim(target, "0123456789") == "") { |
| 581 | + return false |
| 582 | + } |
| 583 | + default: |
| 584 | + return false |
| 585 | + } |
| 586 | + return !fixed || target != "/dev/null" |
| 587 | + } |
| 588 | + |
| 589 | + // inlineScript is hasInlineScript for words already read out. |
| 590 | + func inlineScript(args []string) bool { |
| 591 | + for _, a := range args { |
| 592 | + if a == "-c" || a == "-e" || a == "--command" { |
| 593 | + return true |
| 594 | + } |
| 595 | + } |
| 596 | + return false |
| 597 | + } |
| 598 | + |
| 599 | + // wordText renders a word the way the shell will see it after quote removal, |
| 600 | + // and reports whether that rendering is certain. A word with an expansion in |
| 601 | + // it is rendered as written, which is good enough for a deny rule to look at |
| 602 | + // and never good enough for an allow rule to trust. |
| 603 | + func wordText(w *syntax.Word) (string, bool) { |
| 604 | + var b strings.Builder |
| 605 | + fixed := true |
| 606 | + for i, part := range w.Parts { |
| 607 | + switch p := part.(type) { |
| 608 | + case *syntax.Lit: |
| 609 | + if strings.ContainsAny(p.Value, "*?[") || (i == 0 && strings.HasPrefix(p.Value, "~")) || |
| 610 | + (strings.Contains(p.Value, "{") && strings.Contains(p.Value, "}") && |
| 611 | + (strings.Contains(p.Value, ",") || strings.Contains(p.Value, ".."))) { |
| 612 | + // A glob, a home directory or a brace expansion: the shell |
| 613 | + // passes something other than what is written. |
| 614 | + fixed = false |
| 615 | + } |
| 616 | + b.WriteString(unescape(p.Value, false)) |
| 617 | + case *syntax.SglQuoted: |
| 618 | + b.WriteString(p.Value) |
| 619 | + case *syntax.DblQuoted: |
| 620 | + for _, inner := range p.Parts { |
| 621 | + if lit, ok := inner.(*syntax.Lit); ok { |
| 622 | + b.WriteString(unescape(lit.Value, true)) |
| 623 | + continue |
| 624 | + } |
| 625 | + fixed = false |
| 626 | + b.WriteString(source(inner)) |
| 627 | + } |
| 628 | + default: |
| 629 | + fixed = false |
| 630 | + b.WriteString(source(part)) |
| 631 | + } |
| 632 | + } |
| 633 | + return b.String(), fixed |
| 634 | + } |
| 635 | + |
| 636 | + // unescape removes the backslashes the shell would. Outside quotes every |
| 637 | + // backslash escapes the next character; inside double quotes only a few |
| 638 | + // characters can be escaped and the backslash stays before anything else. |
| 639 | + func unescape(s string, quoted bool) string { |
| 640 | + if !strings.Contains(s, `\`) { |
| 641 | + return s |
| 642 | + } |
| 643 | + var b strings.Builder |
| 644 | + for i := 0; i < len(s); i++ { |
| 645 | + if s[i] != '\\' || i+1 == len(s) { |
| 646 | + b.WriteByte(s[i]) |
| 647 | + continue |
| 648 | + } |
| 649 | + next := s[i+1] |
| 650 | + switch { |
| 651 | + case next == '\n': |
| 652 | + i++ |
| 653 | + case !quoted || strings.IndexByte("$`\"\\", next) >= 0: |
| 654 | + b.WriteByte(next) |
| 655 | + i++ |
| 656 | + default: |
| 657 | + b.WriteByte(s[i]) |
| 658 | + } |
| 659 | + } |
| 660 | + return b.String() |
| 661 | + } |
| 662 | + |
| 663 | + // source prints a node back as shell text. |
| 664 | + func source(n syntax.Node) string { |
| 665 | + var b strings.Builder |
| 666 | + syntax.NewPrinter().Print(&b, n) |
| 667 | + return b.String() |
| 668 | + } |
| 669 | + |
| 670 | + // maxShellDepth bounds how far denyCandidates follows `bash -c` into `bash -c`. |
| 671 | + const maxShellDepth = 4 |
| 672 | + |
| 673 | + // denyCandidates is every reading of a script a deny rule should be tried |
| 674 | + // against: each simple command, each again with a wrapper like sudo or env |
| 675 | + // taken off the front and its path stripped to the program name, and the |
| 676 | + // script inside a `bash -c` or an eval. A script that does not parse is split |
| 677 | + // on the obvious separators instead, because being unparseable is no reason to |
| 678 | + // escape a rule that would have caught it. |
| 679 | + func denyCandidates(script string, depth int) [][]string { |
| 680 | + f, err := syntax.NewParser().Parse(strings.NewReader(script), "") |
| 681 | + if err != nil { |
| 682 | + var out [][]string |
| 683 | + for _, piece := range strings.FieldsFunc(script, func(r rune) bool { |
| 684 | + return strings.ContainsRune(";&|\n()`", r) |
| 685 | + }) { |
| 686 | + if words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(piece, `"`, ""), "'", "")); len(words) > 0 { |
| 687 | + out = append(out, unwrapped(words)...) |
| 688 | + } |
| 689 | + } |
| 690 | + return out |
| 691 | + } |
| 692 | + |
| 693 | + var out [][]string |
| 694 | + syntax.Walk(f, func(node syntax.Node) bool { |
| 695 | + call, ok := node.(*syntax.CallExpr) |
| 696 | + if !ok || len(call.Args) == 0 { |
| 697 | + return true |
| 698 | + } |
| 699 | + words := make([]string, 0, len(call.Args)) |
| 700 | + for _, w := range call.Args { |
| 701 | + text, _ := wordText(w) |
| 702 | + words = append(words, text) |
| 703 | + } |
| 704 | + for _, cand := range unwrapped(words) { |
| 705 | + out = append(out, cand) |
| 706 | + if depth >= maxShellDepth { |
| 707 | + continue |
| 708 | + } |
| 709 | + if inner, ok := innerScript(cand); ok { |
| 710 | + out = append(out, denyCandidates(inner, depth+1)...) |
| 711 | + } |
| 712 | + } |
| 713 | + return true |
| 714 | + }) |
| 715 | + return out |
| 716 | + } |
| 717 | + |
| 718 | + // innerScript is the script a command runs as a string: the argument of |
| 719 | + // `sh -c`, or everything handed to eval. |
| 720 | + func innerScript(words []string) (string, bool) { |
| 721 | + switch words[0] { |
| 722 | + case "eval": |
| 723 | + return strings.Join(words[1:], " "), len(words) > 1 |
| 724 | + case "bash", "sh", "zsh", "dash", "ksh": |
| 725 | + for i, w := range words[1:] { |
| 726 | + if w == "-c" && i+2 < len(words) { |
| 727 | + return words[i+2], true |
| 728 | + } |
| 729 | + } |
| 730 | + } |
| 731 | + return "", false |
| 732 | + } |
| 733 | + |
| 734 | + // wrappers run the command that follows them, so that `sudo rm -rf x` and |
| 735 | + // `timeout 5 rm -rf x` are both read as rm. |
| 736 | + var wrappers = map[string]bool{ |
| 737 | + "sudo": true, "doas": true, "command": true, "builtin": true, "exec": true, |
| 738 | + "nohup": true, "nice": true, "time": true, "env": true, "timeout": true, |
| 739 | + "xargs": true, "stdbuf": true, "ionice": true, |
| 740 | + } |
| 741 | + |
| 742 | + // unwrapped is a command, then the same command each time a wrapper comes off |
| 743 | + // the front, each with a path on the program name reduced to the name itself: |
| 744 | + // /bin/rm is rm as far as a rule is concerned. |
| 745 | + func unwrapped(words []string) [][]string { |
| 746 | + var out [][]string |
| 747 | + for len(words) > 0 { |
| 748 | + if strings.Contains(words[0], "/") { |
| 749 | + words = append([]string{path.Base(words[0])}, words[1:]...) |
| 750 | + } |
| 751 | + out = append(out, words) |
| 752 | + if !wrappers[words[0]] { |
| 753 | + break |
| 754 | + } |
| 755 | + wrapper := words[0] |
| 756 | + words = words[1:] |
| 757 | + // Skip the wrapper's own options and assignments. The value of an |
| 758 | + // option given as a separate word, and timeout's duration, are taken |
| 759 | + // off too; a guess that takes off one word too many only means a rule |
| 760 | + // sees a shorter command, never that it sees nothing. |
| 761 | + for len(words) > 0 { |
| 762 | + w := words[0] |
| 763 | + switch { |
| 764 | + case strings.HasPrefix(w, "-"): |
| 765 | + words = words[1:] |
| 766 | + if (wrapper == "sudo" || wrapper == "doas") && (w == "-u" || w == "-g") && len(words) > 0 { |
| 767 | + words = words[1:] |
| 768 | + } |
| 769 | + if wrapper == "nice" && w == "-n" && len(words) > 0 { |
| 770 | + words = words[1:] |
| 771 | + } |
| 772 | + continue |
| 773 | + case wrapper == "env" && strings.Contains(w, "="): |
| 774 | + words = words[1:] |
| 775 | + continue |
| 776 | + case wrapper == "timeout" && len(w) > 0 && unicode.IsDigit(rune(w[0])): |
| 777 | + words = words[1:] |
| 778 | + } |
| 779 | + break |
| 780 | + } |
| 781 | + } |
| 782 | + return out |
| 783 | + } |
| 784 | + |
| 785 | + // pathMatches reads a path pattern the way Claude Code does, which is the way |
| 786 | + // gitignore does: // starts an absolute path, ~/ the home directory, / and ./ |
| 787 | + // the root, and a pattern with no slash in it matches a name at any depth. ** |
| 788 | + // crosses directories and * does not. A pattern ending in a slash is a |
| 789 | + // directory and everything under it. |
| 790 | + func (r Rule) pathMatches(p string) bool { |
| 791 | + pattern := r.Specifier |
| 792 | + // gitignore anchors a pattern with a slash anywhere but at its end; one |
| 793 | + // without is a name, matched at any depth. |
| 794 | + anchored := strings.Contains(strings.TrimSuffix(pattern, "/"), "/") |
| 795 | + if strings.HasSuffix(pattern, "/") { |
| 796 | + pattern += "**" |
| 797 | + } |
| 798 | + |
| 799 | + target := filepath.ToSlash(filepath.Clean(p)) |
| 800 | + if !filepath.IsAbs(p) && r.Root != "" { |
| 801 | + target = filepath.ToSlash(filepath.Join(r.Root, p)) |
| 802 | + } |
| 803 | + under := func(rel string) string { |
| 804 | + if r.Root == "" { |
| 805 | + return rel |
| 806 | + } |
| 807 | + return filepath.ToSlash(filepath.Clean(r.Root)) + "/" + rel |
| 808 | + } |
| 809 | + |
| 810 | + var full string |
| 811 | + switch { |
| 812 | + case strings.HasPrefix(pattern, "//"): |
| 813 | + full = pattern[1:] |
| 814 | + case strings.HasPrefix(pattern, "~/"): |
| 815 | + home := r.Home |
| 816 | + if home == "" { |
| 817 | + home, _ = os.UserHomeDir() |
| 818 | + } |
| 819 | + full = filepath.ToSlash(filepath.Clean(home)) + "/" + pattern[2:] |
| 820 | + case strings.HasPrefix(pattern, "./"): |
| 821 | + full = under(pattern[2:]) |
| 822 | + case strings.HasPrefix(pattern, "/"): |
| 823 | + full = under(pattern[1:]) |
| 824 | + case !anchored: |
| 825 | + full = under("**/" + pattern) |
| 826 | + default: |
| 827 | + full = under(pattern) |
| 828 | + } |
| 829 | + return globMatch(segments(full), segments(target)) |
| 830 | + } |
| 831 | + |
| 832 | + // segments splits a slash path into its parts, dropping the empty and "." |
| 833 | + // ones a join or a leading slash leaves behind. |
| 834 | + func segments(p string) []string { |
| 835 | + var out []string |
| 836 | + for _, s := range strings.Split(p, "/") { |
| 837 | + if s != "" && s != "." { |
| 838 | + out = append(out, s) |
| 839 | + } |
| 840 | + } |
| 841 | + return out |
| 842 | + } |
| 843 | + |
| 844 | + // globMatch matches path segments against pattern segments, with ** standing |
| 845 | + // for any number of segments, none included. |
| 846 | + func globMatch(pat, segs []string) bool { |
| 847 | + for len(pat) > 0 { |
| 848 | + if pat[0] == "**" { |
| 849 | + for i := 0; i <= len(segs); i++ { |
| 850 | + if globMatch(pat[1:], segs[i:]) { |
| 851 | + return true |
| 852 | + } |
| 853 | + } |
| 854 | + return false |
| 855 | + } |
| 856 | + if len(segs) == 0 { |
| 857 | + return false |
| 858 | + } |
| 859 | + if ok, err := path.Match(pat[0], segs[0]); err != nil || !ok { |
| 860 | + return false |
| 861 | + } |
| 862 | + pat, segs = pat[1:], segs[1:] |
| 863 | + } |
| 864 | + return len(segs) == 0 |
| 865 | + } |