返回 DeepSeek-Reasonix
rules.go
根目录 / internal / recovery / rules.go
1 package recovery
2
3 import (
4 "encoding/json"
5 "path/filepath"
6 "strings"
7
8 "reasonix/internal/evidence"
9 "reasonix/internal/shellparse"
10 "reasonix/internal/shellsafe"
11 )
12
13 // QualifyingFailure reports whether an observation should arm the checkpoint.
14 // User rejections, host policy blocks, cancels, provider errors, and empty
15 // search results never qualify.
16 func QualifyingFailure(obs Observation) bool {
17 if obs.Success || obs.Blocked || obs.UserRejected || obs.ProviderError || obs.Cancelled || obs.EmptySearch {
18 return false
19 }
20 // Mutating tool failure always qualifies.
21 if obs.Mutates {
22 return true
23 }
24 // Host-recognized verification command non-zero exit.
25 if obs.Verification {
26 return true
27 }
28 // File/shell/MCP tools that can change state but reported non-readonly.
29 if !obs.ReadOnly && strings.TrimSpace(obs.Tool) != "" {
30 return true
31 }
32 return false
33 }
34
35 // ClassifyFailure identifies the owning recovery policy without treating an
36 // execution reliability problem as a permission or user-decision boundary.
37 // The classifier is deliberately narrow: permission/sandbox/user blocks are
38 // filtered by QualifyingFailure before this is called.
39 func ClassifyFailure(obs Observation) FailureClass {
40 if transientFailureText(obs.ErrSummary) || transientFailureText(obs.Output) {
41 return FailureClassTransient
42 }
43 if obs.Verification {
44 return FailureClassVerification
45 }
46 if obs.Mutates {
47 return FailureClassMutation
48 }
49 return FailureClassExecution
50 }
51
52 func transientFailureText(text string) bool {
53 text = strings.ToLower(strings.TrimSpace(text))
54 if text == "" {
55 return false
56 }
57 for _, marker := range []string{
58 "command timed out",
59 "timed out after",
60 "timed out (>",
61 "context deadline exceeded",
62 "deadline exceeded",
63 "execution timeout",
64 } {
65 if strings.Contains(text, marker) {
66 return true
67 }
68 }
69 return false
70 }
71
72 // IsVerificationCall reports whether the host recognizes the call as a
73 // verification command (test/lint/build/typecheck/compile).
74 func IsVerificationCall(tool string, args json.RawMessage, readOnly bool) bool {
75 tool = strings.TrimSpace(tool)
76 if tool == "bash" {
77 return evidence.IsDeliveryVerificationCommand(commandFromArgs(args))
78 }
79 // Project-check style tools are verification even when not bash.
80 switch tool {
81 case "complete_step":
82 return false
83 }
84 _ = readOnly
85 return false
86 }
87
88 // IsSafeVerificationRetry reports whether proposal is a first safe retry of the
89 // same host-proven verification command that failed.
90 // Callers must also consult the runtime safe-retry budget (safeRetryUsed /
91 // SafeRetryLeft); a spent budget never qualifies.
92 func IsSafeVerificationRetry(failure *FailureEvent, proposal Proposal) bool {
93 if failure == nil || !failure.Verification {
94 return false
95 }
96 if failure.SafeRetryLeft <= 0 {
97 // evidenceCopy sets SafeRetryLeft from runtime truth; 0 means spent.
98 return false
99 }
100 if !proposal.Verification || proposal.HighRisk || proposal.ExpandedScope || proposal.StrategyChanged {
101 return false
102 }
103 if strings.TrimSpace(proposal.Tool) != strings.TrimSpace(failure.Tool) {
104 return false
105 }
106 // Same normalized command / subject for verification retries.
107 if normalizeCommand(proposal.Subject) != "" && normalizeCommand(failure.Subject) != "" {
108 return normalizeCommand(proposal.Subject) == normalizeCommand(failure.Subject)
109 }
110 return CallFingerprint(proposal.Tool, proposal.Subject, "", proposal.Args) ==
111 CallFingerprint(failure.Tool, failure.Subject, "", failure.Args)
112 }
113
114 // IsHighRiskMutation preserves the legacy execution-risk classifier for event
115 // compatibility and focused policy tests. Auto no longer turns this result into
116 // a human confirmation; permission, sandbox, and tool policy own that boundary.
117 func IsHighRiskMutation(proposal Proposal) bool {
118 return riskBoundaryForProposal(proposal).highRisk
119 }
120
121 // TaskGrantKey returns the legacy semantic key used by persisted recovery cards.
122 // New Auto decisions do not create execution-risk grants. Keys remain narrower
123 // than a command name but broader than raw command bytes:
124 // for example, ordinary pushes to the same Git remote destination share a key,
125 // while a different ref, force push, or arbitrary HTTP/API mutation never does.
126 func TaskGrantKey(proposal Proposal) string {
127 return riskBoundaryForProposal(proposal).taskGrantKey
128 }
129
130 type riskBoundary struct {
131 highRisk bool
132 taskGrantKey string
133 taskGrantDisplay string
134 }
135
136 func riskBoundaryForProposal(proposal Proposal) riskBoundary {
137 if proposal.HighRisk {
138 // Caller-supplied risk has no host-proven semantic scope, so it is never
139 // eligible for a reusable task grant.
140 return riskBoundary{highRisk: true}
141 }
142 tool := strings.TrimSpace(proposal.Tool)
143 if strings.HasPrefix(tool, "mcp__") || strings.Contains(tool, "mcp") {
144 // MCP already has a richer policy/destructive-hint gate. Duplicating that
145 // prompt here would create two human decisions for one call.
146 return riskBoundary{}
147 }
148 if tool == "bash" {
149 cmd := commandFromArgs(proposal.Args)
150 // Host-recognized test/build commands may create project-local artifacts,
151 // but are already bounded by the verification classifier. Deterministic
152 // destructive forms still trip commandFieldsHighRisk below.
153 return bashRiskBoundary(cmd, proposal.Mutates && !proposal.Verification)
154 }
155 // Workspace file tools remain on Auto's fast path, including dependency,
156 // configuration, and workflow files. Sandbox and explicit approval policy
157 // still own writes outside the workspace; this layer only adds hard-boundary
158 // confirmation for commands the host can classify deterministically.
159 return riskBoundary{}
160 }
161
162 // ClassifyEmptySearch reports whether a successful read-only search produced
163 // no matches. Callers set Observation.EmptySearch from this.
164 func ClassifyEmptySearch(tool string, success bool, readOnly bool, output string) bool {
165 if !success || !readOnly {
166 return false
167 }
168 switch strings.TrimSpace(tool) {
169 case "grep", "glob", "ls", "code_index", "codeindex":
170 // fall through
171 default:
172 return false
173 }
174 out := strings.TrimSpace(output)
175 if out == "" {
176 return true
177 }
178 lower := strings.ToLower(out)
179 for _, marker := range []string{
180 "no matches",
181 "no files found",
182 "0 matches",
183 "not found",
184 "no results",
185 } {
186 if strings.Contains(lower, marker) {
187 return true
188 }
189 }
190 return false
191 }
192
193 // IsDiagnosticSuccess reports a successful read-only diagnostic that must not
194 // clear the active failure event (ls/rg/grep/read_file, etc.).
195 func IsDiagnosticSuccess(obs Observation) bool {
196 if !obs.Success || obs.Mutates || obs.Verification {
197 return false
198 }
199 switch strings.TrimSpace(obs.Tool) {
200 case "bash":
201 cmd := commandFromArgs(obs.Args)
202 base, _, readOnly := shellsafe.CommandIsReadOnly(cmd)
203 if !readOnly {
204 return false
205 }
206 switch strings.ToLower(filepath.Base(base)) {
207 case "ls", "rg", "grep", "find", "cat", "head", "tail", "wc", "file", "stat", "pwd", "which", "type":
208 return true
209 }
210 return true // other host-proven read-only bash diagnostics
211 case "read_file", "grep", "glob", "ls", "code_index", "codeindex":
212 return obs.ReadOnly
213 default:
214 return false
215 }
216 }
217
218 func commandFromArgs(args json.RawMessage) string {
219 if len(args) == 0 {
220 return ""
221 }
222 var fields map[string]json.RawMessage
223 if err := json.Unmarshal(args, &fields); err != nil {
224 return ""
225 }
226 raw, ok := fields["command"]
227 if !ok {
228 return ""
229 }
230 var cmd string
231 if err := json.Unmarshal(raw, &cmd); err != nil {
232 return ""
233 }
234 return strings.TrimSpace(cmd)
235 }
236
237 func pathsFromArgs(args json.RawMessage) []string {
238 if len(args) == 0 {
239 return nil
240 }
241 var fields map[string]any
242 if err := json.Unmarshal(args, &fields); err != nil {
243 return nil
244 }
245 var paths []string
246 for _, key := range []string{
247 "path", "file_path", "file", "target", "destination",
248 "source_path", "destination_path", "old_path", "new_path",
249 } {
250 if v, ok := fields[key].(string); ok && strings.TrimSpace(v) != "" {
251 paths = append(paths, strings.TrimSpace(v))
252 }
253 }
254 return uniqueStrings(paths)
255 }
256
257 func normalizeCommand(s string) string {
258 return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
259 }
260
261 func bashRiskBoundary(command string, enforceMutationAllowlist bool) riskBoundary {
262 command = strings.TrimSpace(command)
263 if command == "" {
264 return riskBoundary{highRisk: true}
265 }
266 lower := strings.ToLower(command)
267 // Fast markers cover destructive redirection and commands whose static
268 // tokenization may be obscured by shell punctuation. Project-local installs
269 // and version-controlled configuration edits intentionally stay automatic.
270 riskMarkers := []string{
271 "rm -", "rmdir", "unlink ", "shred ",
272 "git reset --hard", "git clean",
273 "chmod ", "chown ", "mkfs", "dd if=",
274 "> /", ">> /",
275 }
276 for _, m := range riskMarkers {
277 if strings.Contains(lower, m) {
278 return riskBoundary{highRisk: true}
279 }
280 }
281 segments, _, ok := shellparse.SplitTopLevel(command)
282 if !ok {
283 return riskBoundary{highRisk: true}
284 }
285 var grant taskGrantBoundary
286 for _, segment := range segments {
287 fields, malformed := shellparse.StaticFields(segment)
288 if malformed != "" || len(fields) == 0 {
289 return riskBoundary{highRisk: true}
290 }
291 if commandFieldsHighRisk(fields) {
292 if len(segments) == 1 {
293 grant = commandFieldsTaskGrantBoundary(fields)
294 }
295 return riskBoundary{
296 highRisk: true,
297 taskGrantKey: grant.key,
298 taskGrantDisplay: grant.display,
299 }
300 }
301 if enforceMutationAllowlist && !commandFieldsKnownSafeMutation(fields) {
302 // The host knows this call can mutate, but this policy cannot prove it is
303 // a reversible workspace operation. Fail closed instead of letting an
304 // unlisted shell or PowerShell command silently widen Auto.
305 return riskBoundary{highRisk: true}
306 }
307 }
308 return riskBoundary{}
309 }
310
311 func commandFieldsHighRisk(fields []string) bool {
312 if len(fields) == 0 {
313 return true
314 }
315 base := strings.ToLower(filepath.Base(fields[0]))
316 rawArgs := fields[1:]
317 args := lowerFields(rawArgs)
318 switch base {
319 case "sudo", "doas", "pkexec", "xargs":
320 // Privilege escalation and dynamic command dispatch are high risk even
321 // when the wrapped command itself is not statically recoverable here.
322 return true
323 case "env":
324 wrapped, ok := unwrapEnvCommand(rawArgs)
325 return !ok || commandFieldsHighRisk(wrapped)
326 case "command":
327 wrapped, ok := unwrapCommandBuiltin(rawArgs)
328 return !ok || (len(wrapped) > 0 && commandFieldsHighRisk(wrapped))
329 case "nohup":
330 return commandFieldsHighRisk(trimLeadingOptions(rawArgs))
331 case "rm", "rmdir", "unlink", "shred", "dd", "mkfs", "chmod", "chown",
332 "docker", "kubectl", "terraform":
333 return true
334 case "remove-item", "clear-content", "set-content", "add-content", "move-item", "copy-item",
335 "new-item", "rename-item", "invoke-restmethod", "invoke-webrequest", "start-process",
336 "stop-process", "restart-computer", "stop-computer", "format-volume", "clear-disk",
337 "initialize-disk", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "cmd", "cmd.exe",
338 "del", "erase", "rd", "format", "diskpart":
339 // Reasonix runs the bash tool through PowerShell on Windows. Bash AST still
340 // gives us useful static words for simple native commands, but these verbs
341 // are not reversible workspace operations and must never fall through.
342 return true
343 case "find":
344 return containsAny(args, "-delete", "-exec", "-execdir", "-ok", "-okdir")
345 case "git":
346 return gitCommandHighRisk(args)
347 case "curl":
348 return curlCommandHighRisk(rawArgs)
349 case "wget":
350 return wgetCommandHighRisk(args)
351 case "gh":
352 return ghCommandHighRisk(args)
353 case "http", "https", "xh":
354 return httpCommandHighRisk(args)
355 case "aws", "gcloud", "az", "oci", "doctl", "heroku", "vercel", "netlify",
356 "flyctl", "railway", "firebase", "wrangler", "cloudflared", "ssh", "scp",
357 "sftp", "rsync", "psql", "mysql", "redis-cli", "mongosh":
358 // These tools can mutate remote services or hosts, and their command
359 // languages are too broad for this layer to prove a call read-only. Keep
360 // them behind Auto's explicit external-action boundary.
361 return true
362 case "npm":
363 return containsAny(args, "publish", "unpublish", "link", "unlink", "config") || hasGlobalFlag(args)
364 case "pnpm":
365 return containsAny(args, "publish", "deploy", "link", "unlink", "setup") || hasGlobalFlag(args) ||
366 (containsAny(args, "env") && containsAny(args, "use", "remove") && containsAny(args, "--global"))
367 case "yarn":
368 return containsAny(args, "publish", "link", "unlink") || hasGlobalFlag(args) ||
369 (containsAny(args, "global") && containsAny(args, "add", "remove", "upgrade"))
370 case "pip", "pip3", "pipx":
371 // Python installers mutate the active interpreter environment unless the
372 // host can prove a project-local target, which this command layer cannot.
373 return containsAny(args, "install", "uninstall", "inject", "upgrade")
374 case "brew", "apt", "apt-get", "dnf", "yum", "apk", "pacman":
375 return containsAny(args, "install", "add", "remove", "uninstall", "upgrade", "update")
376 case "go":
377 if containsAny(args, "install", "clean") {
378 return true
379 }
380 if containsAny(args, "env") && containsAny(args, "-w", "-u") {
381 return true
382 }
383 return false
384 case "cargo":
385 return containsAny(args, "install", "uninstall", "publish", "yank", "login", "logout")
386 case "composer":
387 return (containsAny(args, "config") && hasGlobalFlag(args)) ||
388 (containsAny(args, "global") && containsAny(args, "require", "remove", "update", "install", "config", "exec"))
389 case "poetry":
390 return containsAny(args, "publish", "config", "self")
391 case "uv":
392 return containsAny(args, "publish", "tool")
393 case "dotnet":
394 return containsAny(args, "push", "delete") || hasGlobalFlag(args)
395 case "gem", "bundle", "bundler":
396 return containsAny(args, "install", "uninstall", "update", "add", "remove", "push", "yank", "publish")
397 }
398 return false
399 }
400
401 func gitCommandHighRisk(args []string) bool {
402 if containsAny(args, "push", "clean", "prune", "filter-branch", "filter-repo") {
403 return true
404 }
405 if containsAny(args, "gc") {
406 return true
407 }
408 if containsAny(args, "reset") && containsAny(args, "--hard", "--merge", "--keep") {
409 return true
410 }
411 if containsAny(args, "checkout") {
412 // `git checkout .` and `git checkout path` discard worktree contents even
413 // without -f/--. Prefer the unambiguous switch command for safe branch
414 // changes; keep all checkout forms behind confirmation.
415 return true
416 }
417 if containsAny(args, "switch") && containsAny(args, "--discard-changes") {
418 return true
419 }
420 if containsAny(args, "restore") && (!containsAny(args, "--staged") || containsAny(args, "--worktree")) {
421 // Restoring only the index is reversible from the worktree; restoring the
422 // worktree can discard the user's uncommitted contents.
423 return true
424 }
425 if containsAny(args, "branch") && containsAny(args, "-d", "--delete", "-f", "--force") {
426 return true
427 }
428 if containsAny(args, "tag") && containsAny(args, "-d", "--delete", "-f", "--force") {
429 return true
430 }
431 if containsAny(args, "stash") && containsAny(args, "clear", "drop") {
432 return true
433 }
434 if containsAny(args, "reflog") && containsAny(args, "expire", "delete") {
435 return true
436 }
437 if containsAny(args, "worktree") && containsAny(args, "remove", "prune") {
438 return true
439 }
440 if containsAny(args, "update-ref") && containsAny(args, "-d", "--delete", "--stdin") {
441 return true
442 }
443 if containsAny(args, "remote") && containsAny(args, "add", "remove", "rm", "rename", "set-url", "set-head", "set-branches", "prune", "update") {
444 return true
445 }
446 // Repository-local git config is not version-controlled workspace config and
447 // can redirect hooks, credentials, or future pushes. Read-only config probes
448 // are the only fast path.
449 if containsAny(args, "config") {
450 if containsAny(args, "--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section", "--edit", "-e") {
451 return true
452 }
453 return !containsAny(args, "--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l", "--name-only")
454 }
455 return false
456 }
457
458 func curlCommandHighRisk(args []string) bool {
459 method := ""
460 for i, arg := range args {
461 lower := strings.ToLower(arg)
462 switch {
463 case arg == "-X" || lower == "--request":
464 if i+1 >= len(args) {
465 return true
466 }
467 method = strings.ToUpper(args[i+1])
468 case strings.HasPrefix(arg, "-X") && len(arg) > 2:
469 method = strings.ToUpper(arg[2:])
470 case strings.HasPrefix(lower, "--request="):
471 method = strings.ToUpper(arg[len("--request="):])
472 case arg == "-d" || lower == "--data" || lower == "--data-ascii" || lower == "--data-binary" ||
473 lower == "--data-raw" || lower == "--data-urlencode" || lower == "--json" ||
474 arg == "-F" || lower == "--form" || lower == "--form-string" ||
475 arg == "-T" || lower == "--upload-file":
476 return true
477 case strings.HasPrefix(arg, "-d") && len(arg) > 2,
478 strings.HasPrefix(arg, "-F") && len(arg) > 2,
479 strings.HasPrefix(arg, "-T") && len(arg) > 2,
480 strings.HasPrefix(lower, "--data="), strings.HasPrefix(lower, "--data-ascii="),
481 strings.HasPrefix(lower, "--data-binary="), strings.HasPrefix(lower, "--data-raw="),
482 strings.HasPrefix(lower, "--data-urlencode="), strings.HasPrefix(lower, "--json="),
483 strings.HasPrefix(lower, "--form="), strings.HasPrefix(lower, "--form-string="),
484 strings.HasPrefix(lower, "--upload-file="):
485 return true
486 }
487 }
488 return method != "" && method != "GET" && method != "HEAD" && method != "OPTIONS"
489 }
490
491 func wgetCommandHighRisk(args []string) bool {
492 for i, arg := range args {
493 switch {
494 case arg == "--post-data" || arg == "--post-file" || strings.HasPrefix(arg, "--post-data=") || strings.HasPrefix(arg, "--post-file="):
495 return true
496 case arg == "--method":
497 if i+1 >= len(args) {
498 return true
499 }
500 method := strings.ToUpper(args[i+1])
501 return method != "GET" && method != "HEAD" && method != "OPTIONS"
502 case strings.HasPrefix(arg, "--method="):
503 method := strings.ToUpper(strings.TrimPrefix(arg, "--method="))
504 return method != "GET" && method != "HEAD" && method != "OPTIONS"
505 }
506 }
507 return false
508 }
509
510 func ghCommandHighRisk(args []string) bool {
511 group, rest := ghCommandGroup(args)
512 switch group {
513 case "api":
514 return ghAPICommandHighRisk(rest)
515 case "pr":
516 return containsAny(rest, "create", "close", "comment", "edit", "merge", "ready", "reopen", "review")
517 case "issue":
518 return containsAny(rest, "create", "close", "comment", "delete", "edit", "reopen", "transfer", "pin", "unpin", "lock", "unlock")
519 case "repo":
520 return containsAny(rest, "create", "delete", "archive", "edit", "fork", "rename", "sync")
521 case "release":
522 return containsAny(rest, "create", "delete", "edit", "upload")
523 case "workflow":
524 return containsAny(rest, "run", "enable", "disable")
525 case "run":
526 return containsAny(rest, "cancel", "delete", "rerun")
527 case "secret", "variable":
528 return containsAny(rest, "set", "delete")
529 case "label":
530 return containsAny(rest, "create", "delete", "edit", "clone")
531 case "gist":
532 return containsAny(rest, "create", "delete", "edit")
533 case "ssh-key", "gpg-key":
534 return containsAny(rest, "add", "delete")
535 case "cache":
536 return containsAny(rest, "delete")
537 case "auth":
538 return containsAny(rest, "login", "logout", "refresh", "setup-git", "switch")
539 case "alias":
540 return containsAny(rest, "set", "delete")
541 case "config":
542 return containsAny(rest, "set", "clear")
543 case "extension":
544 return containsAny(rest, "install", "remove", "upgrade", "create")
545 case "project", "codespace":
546 return !containsAny(rest, "list", "view", "status", "logs")
547 }
548 return false
549 }
550
551 func ghCommandGroup(args []string) (string, []string) {
552 groups := map[string]struct{}{
553 "api": {}, "pr": {}, "issue": {}, "repo": {}, "release": {}, "workflow": {}, "run": {},
554 "secret": {}, "variable": {}, "label": {}, "gist": {}, "ssh-key": {}, "gpg-key": {},
555 "cache": {}, "auth": {}, "alias": {}, "config": {}, "extension": {}, "project": {}, "codespace": {},
556 }
557 for i, arg := range args {
558 if _, ok := groups[arg]; ok {
559 return arg, args[i+1:]
560 }
561 }
562 return "", nil
563 }
564
565 func ghAPICommandHighRisk(args []string) bool {
566 method := ""
567 hasBody := false
568 for i, arg := range args {
569 switch {
570 case arg == "-x" || arg == "--method":
571 if i+1 >= len(args) {
572 return true
573 }
574 method = strings.ToUpper(args[i+1])
575 case strings.HasPrefix(arg, "-x") && len(arg) > 2:
576 method = strings.ToUpper(arg[2:])
577 case strings.HasPrefix(arg, "--method="):
578 method = strings.ToUpper(strings.TrimPrefix(arg, "--method="))
579 case arg == "-f" || arg == "--raw-field" || arg == "--field" || arg == "--input":
580 hasBody = true
581 case strings.HasPrefix(arg, "-f") && len(arg) > 2:
582 hasBody = true
583 case strings.HasPrefix(arg, "--raw-field=") || strings.HasPrefix(arg, "--field=") || strings.HasPrefix(arg, "--input="):
584 hasBody = true
585 }
586 }
587 if method == "" {
588 return hasBody // gh api switches its default from GET to POST when fields/input are supplied.
589 }
590 return method != "GET" && method != "HEAD" && method != "OPTIONS"
591 }
592
593 func commandFieldsKnownSafeMutation(fields []string) bool {
594 if len(fields) == 0 || commandFieldsHighRisk(fields) {
595 return false
596 }
597 base := strings.ToLower(filepath.Base(fields[0]))
598 rawArgs := fields[1:]
599 args := lowerFields(rawArgs)
600 switch base {
601 case "env":
602 wrapped, ok := unwrapEnvCommand(rawArgs)
603 return ok && commandFieldsKnownSafeMutation(wrapped)
604 case "command":
605 wrapped, ok := unwrapCommandBuiltin(rawArgs)
606 return ok && (len(wrapped) == 0 || commandFieldsKnownSafeMutation(wrapped))
607 case "nohup":
608 wrapped := trimLeadingOptions(rawArgs)
609 return len(wrapped) > 0 && commandFieldsKnownSafeMutation(wrapped)
610 case "git":
611 return gitCommandKnownSafe(args)
612 case "curl":
613 return !curlCommandHighRisk(rawArgs)
614 case "wget":
615 return !wgetCommandHighRisk(args)
616 case "gh":
617 return !ghCommandHighRisk(args)
618 case "http", "https", "xh":
619 return !httpCommandHighRisk(args)
620 case "sed", "gofmt", "goimports", "rustfmt", "prettier", "biome", "eslint", "black", "ruff",
621 "cp", "mv", "mkdir", "touch", "ln":
622 // These are deterministic workspace-editing families. The ordinary
623 // permission/sandbox layer still owns path confinement.
624 return true
625 case "npm":
626 return containsAny(args, "install", "add", "remove", "uninstall", "update", "dedupe") && !hasGlobalFlag(args)
627 case "pnpm":
628 return containsAny(args, "install", "add", "remove", "update", "dedupe", "import") && !hasGlobalFlag(args)
629 case "yarn":
630 return containsAny(args, "install", "add", "remove", "up", "upgrade", "dedupe") && !hasGlobalFlag(args) && !containsAny(args, "global")
631 case "go":
632 return containsAny(args, "get", "mod", "work", "fmt", "build", "test") && !containsAny(args, "install", "clean")
633 case "cargo":
634 return containsAny(args, "add", "remove", "update", "build", "check", "test", "fmt", "fix", "clippy")
635 case "composer":
636 return containsAny(args, "require", "remove", "update", "install", "dump-autoload") && !hasGlobalFlag(args) && !containsAny(args, "global")
637 case "poetry":
638 return containsAny(args, "add", "remove", "install", "update", "lock", "sync")
639 case "uv":
640 return containsAny(args, "add", "remove", "sync", "lock")
641 case "dotnet":
642 return containsAny(args, "add", "remove", "restore", "build", "test", "format") && !hasGlobalFlag(args)
643 }
644 // A coarse host mutation bit must not turn a statically proven read-only
645 // diagnostic into a confirmation. Destructive argument forms were rejected
646 // before reaching this point.
647 if _, _, readOnly := shellsafe.CommandIsReadOnly(strings.Join(fields, " ")); readOnly {
648 return true
649 }
650 return false
651 }
652
653 func gitCommandKnownSafe(args []string) bool {
654 sub := gitSubcommand(args)
655 switch sub {
656 case "add", "commit", "status", "diff", "log", "show", "rev-parse", "rev-list", "describe",
657 "blame", "grep", "ls-files", "ls-tree", "cat-file", "for-each-ref", "name-rev", "shortlog",
658 "whatchanged", "cherry", "fetch", "pull", "clone", "init", "merge", "rebase", "cherry-pick",
659 "revert", "apply", "am", "switch", "reset", "branch", "tag", "stash", "restore", "worktree",
660 "remote", "config", "reflog":
661 return true
662 default:
663 return false
664 }
665 }
666
667 func gitSubcommand(args []string) string {
668 for i := 0; i < len(args); i++ {
669 arg := args[i]
670 switch {
671 case arg == "-c" || arg == "--git-dir" || arg == "--work-tree" || arg == "--namespace":
672 i++
673 case strings.HasPrefix(arg, "-"):
674 continue
675 default:
676 return strings.ToLower(arg)
677 }
678 }
679 return ""
680 }
681
682 type taskGrantBoundary struct {
683 key string
684 display string
685 }
686
687 func commandFieldsTaskGrantBoundary(fields []string) taskGrantBoundary {
688 if len(fields) == 0 {
689 return taskGrantBoundary{}
690 }
691 base := strings.ToLower(filepath.Base(fields[0]))
692 rawArgs := fields[1:]
693 switch base {
694 case "env":
695 wrapped, ok := unwrapEnvCommand(rawArgs)
696 if ok {
697 return commandFieldsTaskGrantBoundary(wrapped)
698 }
699 case "command":
700 wrapped, ok := unwrapCommandBuiltin(rawArgs)
701 if ok {
702 return commandFieldsTaskGrantBoundary(wrapped)
703 }
704 case "git":
705 return gitPushTaskGrantBoundary(rawArgs)
706 case "gh":
707 return ghTaskGrantBoundary(rawArgs)
708 }
709 return taskGrantBoundary{}
710 }
711
712 func gitPushTaskGrantBoundary(args []string) taskGrantBoundary {
713 lower := lowerFields(args)
714 if gitSubcommand(lower) != "push" || containsAny(lower,
715 "-f", "--force", "--mirror", "--delete", "--prune", "--all", "--tags", "--follow-tags",
716 ) {
717 return taskGrantBoundary{}
718 }
719 for _, arg := range lower {
720 if strings.HasPrefix(arg, "--force") || strings.HasPrefix(arg, ":") || strings.HasPrefix(arg, "+") {
721 return taskGrantBoundary{}
722 }
723 }
724 pushAt := -1
725 for i, arg := range lower {
726 if arg == "push" {
727 pushAt = i
728 break
729 }
730 }
731 if pushAt != 0 {
732 // Global options such as -C/--git-dir can redirect an otherwise identical
733 // command to another repository. Keep those forms one-shot because the
734 // displayed remote alias would no longer identify the same target context.
735 return taskGrantBoundary{}
736 }
737 var positionals []string
738 for i := pushAt + 1; i < len(args); i++ {
739 arg := lower[i]
740 switch arg {
741 case "-u", "--set-upstream", "-q", "--quiet", "-v", "--verbose", "--progress", "--no-progress":
742 continue
743 }
744 if strings.HasPrefix(arg, "-") {
745 // Behavior-changing and unknown push options are deliberately one-shot.
746 // In particular, push-option/receive-pack/no-verify must not inherit a
747 // grant issued for an ordinary push to the same ref.
748 return taskGrantBoundary{}
749 }
750 positionals = append(positionals, strings.TrimSpace(args[i]))
751 }
752 // A reusable grant needs both an explicit remote and exactly one explicit
753 // refspec. Bare `git push` depends on mutable branch/upstream configuration.
754 if len(positionals) != 2 {
755 return taskGrantBoundary{}
756 }
757 remote, refspec := positionals[0], positionals[1]
758 if remote == "" || refspec == "" || strings.Contains(refspec, "*") {
759 return taskGrantBoundary{}
760 }
761 target := refspec
762 if before, after, ok := strings.Cut(refspec, ":"); ok {
763 if strings.TrimSpace(before) == "" || strings.TrimSpace(after) == "" {
764 return taskGrantBoundary{}
765 }
766 target = strings.TrimSpace(after)
767 }
768 if target == "HEAD" || target == "@" {
769 return taskGrantBoundary{}
770 }
771 return taskGrantBoundary{
772 key: "bash:git.push:" + CallFingerprint("git.push", remote, target, nil),
773 display: "git push " + remote + " → " + target,
774 }
775 }
776
777 func ghTaskGrantBoundary(args []string) taskGrantBoundary {
778 lower := lowerFields(args)
779 group, rest := ghCommandGroup(lower)
780 if len(rest) == 0 {
781 return taskGrantBoundary{}
782 }
783 verb := rest[0]
784 if (group != "pr" && group != "issue") || verb != "comment" {
785 return taskGrantBoundary{}
786 }
787 if containsAny(lower, "--edit-last", "--delete-last") {
788 return taskGrantBoundary{}
789 }
790 repo := "current"
791 for i, arg := range lower {
792 switch {
793 case (arg == "--repo" || arg == "-r") && i+1 < len(args):
794 repo = args[i+1]
795 case strings.HasPrefix(arg, "--repo="):
796 repo = strings.TrimSpace(args[i][len("--repo="):])
797 case strings.HasPrefix(arg, "-r") && len(arg) > 2:
798 repo = strings.TrimSpace(args[i][2:])
799 }
800 }
801 target := "current"
802 if len(rest) > 1 && !strings.HasPrefix(rest[1], "-") {
803 target = rest[1]
804 } else if len(rest) > 1 {
805 // Options before a positional target are legal in gh. Avoid guessing
806 // through their values; a form the host cannot scope exactly stays
807 // one-shot rather than sharing an accidentally broad "current" grant.
808 return taskGrantBoundary{}
809 }
810 // "current" can change after a checkout or branch switch. Require an
811 // explicit PR/issue target before offering a reusable external-write grant.
812 if target == "current" {
813 return taskGrantBoundary{}
814 }
815 repo = strings.TrimSpace(repo)
816 target = strings.TrimSpace(target)
817 display := "gh " + group + " comment " + target
818 if repo != "current" {
819 display += " --repo " + repo
820 }
821 return taskGrantBoundary{
822 key: "bash:gh." + group + ".comment:" + CallFingerprint("gh."+group+".comment", repo, target, nil),
823 display: display,
824 }
825 }
826
827 func httpCommandHighRisk(args []string) bool {
828 for _, arg := range args {
829 upper := strings.ToUpper(arg)
830 switch upper {
831 case "POST", "PUT", "PATCH", "DELETE", "CONNECT", "PURGE", "LOCK", "UNLOCK":
832 return true
833 }
834 lower := strings.ToLower(arg)
835 if lower == "--raw" || lower == "--form" || strings.HasPrefix(lower, "--raw=") {
836 return true
837 }
838 if strings.HasPrefix(arg, "-") || strings.Contains(arg, "://") {
839 continue
840 }
841 if strings.Contains(arg, "==") && !strings.Contains(arg, ":=") && !strings.Contains(arg, "@") {
842 continue // HTTPie query-string item; remains a GET by default.
843 }
844 // HTTPie-style request items with a value or file body implicitly switch
845 // the default method from GET to a mutating request.
846 if strings.Contains(arg, "=") || strings.Contains(arg, "@") {
847 return true
848 }
849 }
850 return false
851 }
852
853 func hasGlobalFlag(fields []string) bool {
854 return containsAny(fields, "-g", "--global", "--system", "--user")
855 }
856
857 func unwrapEnvCommand(args []string) ([]string, bool) {
858 for len(args) > 0 {
859 arg := args[0]
860 lower := strings.ToLower(arg)
861 switch {
862 case lower == "-i" || lower == "--ignore-environment" || lower == "-0" || lower == "--null":
863 args = args[1:]
864 case lower == "-u" || lower == "--unset" || lower == "-c" || lower == "--chdir":
865 if len(args) < 2 {
866 return nil, false
867 }
868 args = args[2:]
869 case strings.HasPrefix(lower, "--unset=") || strings.HasPrefix(lower, "--chdir="):
870 args = args[1:]
871 case strings.HasPrefix(arg, "-"):
872 // Split-string and unknown options can change the command shape.
873 return nil, false
874 case strings.Contains(arg, "="):
875 args = args[1:]
876 default:
877 return args, true
878 }
879 }
880 return nil, false
881 }
882
883 func unwrapCommandBuiltin(args []string) ([]string, bool) {
884 for len(args) > 0 {
885 switch strings.ToLower(args[0]) {
886 case "-p":
887 args = args[1:]
888 case "-v":
889 // Inspection-only command lookup; there is no wrapped execution.
890 return nil, true
891 default:
892 if strings.HasPrefix(args[0], "-") {
893 return nil, false
894 }
895 return args, true
896 }
897 }
898 return nil, false
899 }
900
901 func trimLeadingOptions(args []string) []string {
902 for len(args) > 0 && strings.HasPrefix(args[0], "-") {
903 args = args[1:]
904 }
905 return args
906 }
907
908 func lowerFields(fields []string) []string {
909 out := make([]string, len(fields))
910 for i, field := range fields {
911 out[i] = strings.ToLower(strings.TrimSpace(field))
912 }
913 return out
914 }
915
916 func containsAny(fields []string, values ...string) bool {
917 wanted := make(map[string]struct{}, len(values))
918 for _, value := range values {
919 wanted[value] = struct{}{}
920 }
921 for _, field := range fields {
922 if _, ok := wanted[field]; ok {
923 return true
924 }
925 }
926 return false
927 }
928
929 // WriteScopePaths extracts path-like targets from mutation args for scope compare.
930 func WriteScopePaths(tool string, args json.RawMessage) []string {
931 tool = strings.TrimSpace(tool)
932 paths := pathsFromArgs(args)
933 for i := range paths {
934 paths[i] = filepath.Clean(paths[i])
935 }
936 if tool == "multi_edit" || tool == "multi-edit" {
937 var payload struct {
938 Edits []struct {
939 Path string `json:"path"`
940 } `json:"edits"`
941 }
942 if err := json.Unmarshal(args, &payload); err == nil {
943 for _, e := range payload.Edits {
944 if strings.TrimSpace(e.Path) != "" {
945 paths = append(paths, filepath.Clean(e.Path))
946 }
947 }
948 }
949 }
950 if tool == "bash" {
951 // Best-effort: do not invent paths from free-form shell.
952 return paths
953 }
954 return uniqueStrings(paths)
955 }
956
957 // ScopeExpanded reports whether the proposal writes outside the failure's
958 // recorded path set (when both sides have path info).
959 func ScopeExpanded(failure *FailureEvent, proposal Proposal) bool {
960 if proposal.ExpandedScope {
961 return true
962 }
963 if failure == nil {
964 return false
965 }
966 failedPaths := WriteScopePaths(failure.Tool, failure.Args)
967 nextPaths := WriteScopePaths(proposal.Tool, proposal.Args)
968 if len(failedPaths) == 0 || len(nextPaths) == 0 {
969 return false
970 }
971 allowed := map[string]struct{}{}
972 for _, p := range failedPaths {
973 allowed[filepath.Clean(p)] = struct{}{}
974 // Allow writes under the same directory as a failed file target.
975 allowed[filepath.Clean(filepath.Dir(p))] = struct{}{}
976 }
977 for _, p := range nextPaths {
978 p = filepath.Clean(p)
979 if _, ok := allowed[p]; ok {
980 continue
981 }
982 parent := filepath.Clean(filepath.Dir(p))
983 if _, ok := allowed[parent]; ok {
984 continue
985 }
986 // Outside all known failed paths.
987 return true
988 }
989 return false
990 }
991
992 // StrategyChanged reports an explicit semantic method change. A tool-name
993 // transition is not enough: the normal recovery flow after a failing verifier
994 // is to inspect the evidence and edit the diagnosed code. Risk and scope have
995 // deterministic classifiers; ambiguous method changes are left to the reviewer.
996 func StrategyChanged(failure *FailureEvent, proposal Proposal) bool {
997 _ = failure
998 return proposal.StrategyChanged
999 }
1000
1001 func uniqueStrings(in []string) []string {
1002 seen := map[string]struct{}{}
1003 out := make([]string, 0, len(in))
1004 for _, s := range in {
1005 s = strings.TrimSpace(s)
1006 if s == "" {
1007 continue
1008 }
1009 if _, ok := seen[s]; ok {
1010 continue
1011 }
1012 seen[s] = struct{}{}
1013 out = append(out, s)
1014 }
1015 return out
1016 }
1017
1017 lines GO