返回 DeepSeek-Reasonix
taskpolicy_external.go
根目录 / internal / agent / taskpolicy_external.go
1 package agent
2
3 import (
4 "encoding/json"
5 "path/filepath"
6 "strings"
7 "unicode"
8
9 "reasonix/internal/shellparse"
10 )
11
12 const maxExternalCommandDepth = 4
13
14 // isExternalActionTool recognizes push/publish/deploy-style actions after a
15 // proxy has resolved to its concrete target. Tool names are host metadata; the
16 // shell path uses static argv parsing so legal global options cannot hide the
17 // executable's real subcommand from a user constraint.
18 func isExternalActionTool(evidenceName, permName string, args json.RawMessage) bool {
19 if externalActionToolName(evidenceName) || externalActionToolName(permName) {
20 return true
21 }
22 return shellCommandHasExternalAction(bashCommandFromArgs(args))
23 }
24
25 func externalActionToolName(name string) bool {
26 tokens := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(name)), func(r rune) bool {
27 return !unicode.IsLetter(r) && !unicode.IsDigit(r)
28 })
29 for _, token := range tokens {
30 switch token {
31 case "push", "publish", "deploy":
32 return true
33 }
34 }
35 if !containsField(tokens, "release") {
36 return false
37 }
38 if len(tokens) == 1 {
39 return true
40 }
41 for _, token := range tokens {
42 switch token {
43 case "create", "delete", "edit", "publish", "upload", "update":
44 return true
45 }
46 }
47 return false
48 }
49
50 func shellCommandHasExternalAction(command string) bool {
51 return shellCommandHasExternalActionAtDepth(command, 0)
52 }
53
54 func shellCommandHasExternalActionAtDepth(command string, depth int) bool {
55 command = strings.TrimSpace(command)
56 if command == "" {
57 return false
58 }
59 if depth >= maxExternalCommandDepth {
60 // Deep wrapper chains are statically opaque at this policy boundary.
61 return true
62 }
63 segments, _, ok := shellparse.SplitTopLevel(command)
64 if !ok {
65 return opaqueCommandMayRunExternalAction(command)
66 }
67 for _, segment := range segments {
68 parsed, err := shellparse.ParseStaticCommand(strings.TrimSpace(segment), shellparse.StaticCommandPolicy{
69 AllowEnvAssignments: true,
70 AllowStderrToStdout: true,
71 })
72 if err == nil {
73 if commandFieldsRunExternalAction(parsed.Argv, depth) {
74 return true
75 }
76 continue
77 }
78 if opaqueCommandMayRunExternalAction(segment) {
79 return true
80 }
81 }
82 return false
83 }
84
85 func opaqueCommandMayRunExternalAction(command string) bool {
86 features, ok := shellparse.AnalyzeApprovalFeatures(command)
87 if ok && len(features.CommandPrefix) > 0 {
88 if commandFieldsRunExternalAction(features.CommandPrefix, 0) {
89 return true
90 }
91 if (features.DynamicCommandName || features.Expansion) && externalActionCapableExecutable(features.CommandPrefix[0]) {
92 // A dynamic subcommand such as `git "$verb"` cannot prove that the
93 // explicit no-external constraint is preserved, so fail closed.
94 return true
95 }
96 }
97 lower := strings.ToLower(command)
98 for _, action := range []string{"git push", "git publish", "npm publish", "gh release", "docker push", "kubectl apply"} {
99 if strings.Contains(lower, action) {
100 return true
101 }
102 }
103 return false
104 }
105
106 func commandFieldsRunExternalAction(fields []string, depth int) bool {
107 if len(fields) == 0 {
108 return false
109 }
110 if depth >= maxExternalCommandDepth {
111 return true
112 }
113 base := commandBase(fields[0])
114 args := fields[1:]
115 if handled, external := wrappedCommandRunsExternalAction(base, args, depth); handled {
116 return external
117 }
118 return commandArgsRunExternalAction(base, lowerFields(args))
119 }
120
121 func wrappedCommandRunsExternalAction(base string, args []string, depth int) (bool, bool) {
122 switch base {
123 case "env":
124 return true, commandFieldsRunExternalAction(wrappedCommandPayload(args), depth+1)
125 case "command":
126 if len(args) > 0 && (args[0] == "-v" || args[0] == "-V") {
127 return true, false
128 }
129 return true, commandFieldsRunExternalAction(wrappedCommandPayload(args), depth+1)
130 case "nohup":
131 return true, commandFieldsRunExternalAction(wrappedCommandPayload(args), depth+1)
132 case "sudo", "doas":
133 return true, commandFieldsRunExternalAction(wrappedCommandPayload(args), depth+1)
134 case "bash", "sh", "zsh":
135 if nested := shellCommandString(args); nested != "" {
136 return true, shellCommandHasExternalActionAtDepth(nested, depth+1)
137 }
138 return true, false
139 case "npx", "pnpx", "bunx":
140 return true, commandFieldsRunExternalAction(wrappedCommandPayload(args), depth+1)
141 default:
142 return false, false
143 }
144 }
145
146 func commandArgsRunExternalAction(base string, args []string) bool {
147 // Prefer conservative action-token matching over a table of every global option.
148 // The gate is active only for an explicit no-external constraint, so rejecting
149 // an ambiguous token is safer than letting a real action bypass it.
150 switch base {
151 case "git":
152 return containsAnyField(args, "push", "publish")
153 case "npm", "pnpm", "yarn", "cargo", "poetry", "uv":
154 return containsAnyField(args, "push", "publish", "unpublish", "deploy", "release")
155 case "gem":
156 return containsAnyField(args, "push", "publish")
157 case "dotnet":
158 return containsField(args, "publish") || (containsField(args, "nuget") && containsField(args, "push"))
159 case "docker":
160 return containsAnyField(args, "push", "--push")
161 case "kubectl":
162 return containsField(args, "apply")
163 case "gh":
164 return containsField(args, "release") && containsAnyField(args, "create", "delete", "edit", "publish", "upload")
165 case "vercel", "netlify", "flyctl", "railway", "firebase", "wrangler":
166 return containsAnyField(args, "deploy", "publish")
167 case "make", "just", "task":
168 return containsAnyField(args, "push", "publish", "deploy", "release")
169 }
170 return false
171 }
172
173 func wrappedCommandPayload(args []string) []string {
174 for i, arg := range args {
175 if externalActionCapableExecutable(arg) {
176 return args[i:]
177 }
178 }
179 return nil
180 }
181
182 func externalActionCapableExecutable(field string) bool {
183 switch commandBase(field) {
184 case "git", "npm", "pnpm", "yarn", "cargo", "poetry", "uv", "gem", "dotnet",
185 "docker", "kubectl", "gh", "vercel", "netlify", "flyctl", "railway", "firebase", "wrangler",
186 "make", "just", "task", "env", "command", "nohup", "sudo", "doas", "bash", "sh", "zsh", "npx", "pnpx", "bunx":
187 return true
188 default:
189 return false
190 }
191 }
192
193 func shellCommandString(args []string) string {
194 for i, arg := range args {
195 lower := strings.ToLower(arg)
196 if lower == "-c" || (strings.HasPrefix(lower, "-") && !strings.HasPrefix(lower, "--") && strings.Contains(lower[1:], "c")) {
197 if i+1 < len(args) {
198 return args[i+1]
199 }
200 return ""
201 }
202 }
203 return ""
204 }
205
206 func commandBase(field string) string {
207 base := strings.ToLower(filepath.Base(strings.TrimSpace(field)))
208 return strings.TrimSuffix(base, ".exe")
209 }
210
211 func lowerFields(fields []string) []string {
212 out := make([]string, len(fields))
213 for i, field := range fields {
214 out[i] = strings.ToLower(field)
215 }
216 return out
217 }
218
219 func containsField(fields []string, want string) bool {
220 for _, field := range fields {
221 if strings.EqualFold(field, want) {
222 return true
223 }
224 }
225 return false
226 }
227
228 func containsAnyField(fields []string, wants ...string) bool {
229 for _, want := range wants {
230 if containsField(fields, want) {
231 return true
232 }
233 }
234 return false
235 }
236
236 lines GO