返回 DeepSeek-Reasonix
bash_readonly.go
根目录 / internal / permission / bash_readonly.go
1 package permission
2
3 import (
4 "encoding/json"
5 "strings"
6
7 "reasonix/internal/shellsafe"
8 )
9
10 // BashCommandIsReadOnly reports whether a bash tool call is a known foreground
11 // read-only command. Capability-restricted runners use this directly instead of
12 // depending on Plan mode: Plan is a collaboration workflow, while this check is
13 // an execution permission boundary.
14 func BashCommandIsReadOnly(args json.RawMessage) bool {
15 var p struct {
16 Command string `json:"command"`
17 RunInBackground bool `json:"run_in_background"`
18 PreserveBackgroundProcesses bool `json:"preserve_background_processes"`
19 }
20 if err := json.Unmarshal(args, &p); err != nil || strings.TrimSpace(p.Command) == "" {
21 return false
22 }
23 if p.RunInBackground || p.PreserveBackgroundProcesses {
24 return false
25 }
26 return isReadOnlyBashSubject(p.Command)
27 }
28
29 // isReadOnlyBashSubject returns true when a bash command is a known read-only
30 // operation. The subject is the JSON arg value extracted by Subject() — for bash
31 // it is the raw command string. Command membership comes from the shared
32 // shellsafe tables (the shared command-classification source, #5341); the
33 // argument rigor below is permission-specific.
34 func isReadOnlyBashSubject(subject string) bool {
35 if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
36 subject = normalized
37 }
38 base, sub, fields, ok := shellsafe.ClassifyReadOnlyCommand(subject)
39 if !ok {
40 return false
41 }
42 if sub == "" {
43 return !hasUnsafeReadOnlyArgs(base, fields[1:])
44 }
45 return !hasUnsafePrefixArgs(base, sub, fields[2:])
46 }
47
48 // containsShellSyntax delegates to the shared classifier; retained for the other
49 // permission call sites (permission.go).
50 func containsShellSyntax(cmd string) bool {
51 return shellsafe.ContainsShellSyntax(cmd)
52 }
53
54 func hasUnsafeReadOnlyArgs(base string, args []string) bool {
55 switch base {
56 case "find":
57 return hasAnyArg(args, "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf")
58 case "sed":
59 for _, arg := range args {
60 if strings.HasPrefix(arg, "-i") || strings.HasPrefix(arg, "--in-place") {
61 return true
62 }
63 }
64 case "sort":
65 return hasArgWithPrefix(args, "-o") || hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=")
66 }
67 return false
68 }
69
70 func hasUnsafePrefixArgs(base, subcmd string, args []string) bool {
71 switch base {
72 case "git":
73 switch subcmd {
74 case "diff", "show", "log":
75 return hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=")
76 case "tag":
77 // Bare `git tag` lists; with a name it creates one, and -d deletes.
78 return !gitTagIsListing(args)
79 }
80 case "go":
81 if subcmd == "env" {
82 return hasAnyArg(args, "-w", "-u")
83 }
84 }
85 return false
86 }
87
88 // gitTagIsListing reports whether a `git tag` invocation only lists tags. A
89 // bare `git tag` lists; a name creates one and -d deletes, so anything that
90 // isn't an explicit listing form writes the ref namespace.
91 func gitTagIsListing(args []string) bool {
92 listing := false
93 var operands []string
94 for _, arg := range args {
95 switch {
96 case arg == "-l" || arg == "--list":
97 listing = true
98 case arg == "-d" || arg == "--delete" || arg == "-a" || arg == "-s" || arg == "-f" || arg == "--force" || arg == "-m" || arg == "-F":
99 return false
100 case strings.HasPrefix(arg, "-"):
101 // Remaining flags (-n, --sort=, --format=, --contains, …) are output
102 // shaping; unknown ones fail closed below only when paired with an
103 // operand, which is the create/delete form.
104 continue
105 default:
106 operands = append(operands, arg)
107 }
108 }
109 if listing {
110 return true // operands are shell patterns for the listing filter
111 }
112 return len(operands) == 0
113 }
114
115 func hasArgWithPrefix(args []string, prefix string) bool {
116 for _, arg := range args {
117 if strings.HasPrefix(arg, prefix) {
118 return true
119 }
120 }
121 return false
122 }
123
124 func hasAnyArg(args []string, unsafe ...string) bool {
125 for _, arg := range args {
126 for _, candidate := range unsafe {
127 if arg == candidate {
128 return true
129 }
130 }
131 }
132 return false
133 }
134
135 // dangerousBashPatterns are glob-like patterns that match destructive
136 // commands. Used only for a UI warning — the deny list is the actual
137 // enforcement mechanism.
138 var dangerousBashPatterns = []struct {
139 pattern string
140 label string
141 }{
142 {"rm -rf*", "recursive delete"},
143 {"rm -r *", "recursive delete"},
144 {"rm -fr*", "recursive delete"},
145 {"git push*--force*", "force push"},
146 {"git push*-f*", "force push"},
147 {"git reset --hard*", "hard reset"},
148 {"git clean -f*", "force clean"},
149 {"git restore*", "discards uncommitted changes"},
150 {"git checkout -- *", "discards uncommitted changes"},
151 {"git checkout .*", "discards uncommitted changes"},
152 {"git stash drop*", "drops stashed changes"},
153 {"git stash clear*", "drops stashed changes"},
154 {"chmod 777*", "world-writable"},
155 {"chmod -R 777*", "world-writable recursive"},
156 {"chown *", "ownership change"},
157 {"sudo *", "superuser"},
158 {"mkfs*", "filesystem format"},
159 {"dd if=*", "raw device write"},
160 {"fdisk*", "partition table"},
161 {"> /dev/*", "device overwrite"},
162 }
163
164 // BashDangerWarning returns a short label if subject matches a known
165 // dangerous pattern, or "" when the command looks safe. This is a visual
166 // hint only — the Policy rules are the authority.
167 func BashDangerWarning(subject string) string {
168 s := strings.TrimSpace(subject)
169 for _, d := range dangerousBashPatterns {
170 if matchGlob(d.pattern, s) {
171 return d.label
172 }
173 }
174 return ""
175 }
176
176 lines GO