返回 DeepSeek-Reasonix
command_normalize.go
根目录 / internal / hook / command_normalize.go
1 package hook
2
3 import (
4 "strings"
5
6 "reasonix/internal/shellparse"
7 )
8
9 // NormalizeCommand repairs narrow classes of copied JSON-escaped hook commands.
10 // It is intentionally conservative: hook commands can be security controls, so
11 // only commands that were already malformed and match a known hook shape are
12 // rewritten.
13 func NormalizeCommand(command string) string {
14 if fixed, ok := normalizeStaticNodeEval(command); ok {
15 return fixed
16 }
17 if fixed, ok := normalizeEscapedNodeEval(command); ok {
18 return fixed
19 }
20 if fixed, ok := normalizeEscapedPowerShellFile(command); ok {
21 return fixed
22 }
23 return command
24 }
25
26 func normalizeStaticNodeEval(command string) (string, bool) {
27 node, flag, script, ok := repairableNodeEvalArgs(command)
28 if !ok {
29 return "", false
30 }
31 return renderNodeEvalCommand(node, flag, script), true
32 }
33
34 func directNodeEvalArgs(command string) (string, string, string, bool) {
35 fields, malformed := shellparse.StaticFields(command)
36 if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) {
37 script, ok := repairQuotedNodeEvalScript(fields[2])
38 if ok {
39 return fields[0], fields[1], script, true
40 }
41 if isHookStdinNodeEval(fields[2]) {
42 return fields[0], fields[1], fields[2], true
43 }
44 }
45 node, flag, script, ok := escapedNodeEvalArgs(command)
46 return node, flag, script, ok
47 }
48
49 func repairableNodeEvalArgs(command string) (string, string, string, bool) {
50 fields, malformed := shellparse.StaticFields(command)
51 if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) {
52 script, ok := repairQuotedNodeEvalScript(fields[2])
53 if ok {
54 return fields[0], fields[1], script, true
55 }
56 }
57 return escapedNodeEvalArgs(command)
58 }
59
60 func normalizeEscapedPowerShellFile(command string) (string, bool) {
61 trimmed := strings.TrimSpace(command)
62 _, spans, ok := repairablePowerShellFileParse(trimmed)
63 if !ok || len(spans) == 0 {
64 return "", false
65 }
66 // Replace only the escaped-quote sequences found during parsing, so
67 // well-formed sibling arguments keep their bytes verbatim.
68 var b strings.Builder
69 prev := 0
70 for _, span := range spans {
71 b.WriteString(trimmed[prev:span[0]])
72 b.WriteByte('"')
73 prev = span[1]
74 }
75 b.WriteString(trimmed[prev:])
76 return b.String(), true
77 }
78
79 func repairablePowerShellFileArgs(command string) (string, []string, bool) {
80 fields, _, ok := repairablePowerShellFileParse(command)
81 if !ok {
82 return "", nil, false
83 }
84 return fields[0], fields[1:], true
85 }
86
87 func repairablePowerShellFileParse(command string) ([]string, [][2]int, bool) {
88 fields, repaired, spans, ok := parseSimpleHookCommandFields(command)
89 if !ok || len(fields) < 3 || !isPowerShellCommand(fields[0]) {
90 return nil, nil, false
91 }
92 fileIdx := powerShellFileFlagIndex(fields)
93 if fileIdx < 0 || fileIdx+1 >= len(fields) {
94 return nil, nil, false
95 }
96 if !powerShellFileRepairApplies(repaired, fileIdx) {
97 return nil, nil, false
98 }
99 return fields, spans, true
100 }
101
102 func powerShellFileRepairApplies(repaired []bool, fileIdx int) bool {
103 for i, ok := range repaired {
104 if !ok {
105 continue
106 }
107 if i == 0 || i > fileIdx {
108 return true
109 }
110 }
111 return false
112 }
113
114 func powerShellFileFlagIndex(fields []string) int {
115 for i := 1; i < len(fields); i++ {
116 if strings.EqualFold(fields[i], "-File") {
117 return i
118 }
119 }
120 return -1
121 }
122
123 func parseSimpleHookCommandFields(command string) ([]string, []bool, [][2]int, bool) {
124 // A newline is a shell command separator, not argument whitespace; leave
125 // multi-command strings alone like other compound commands.
126 if strings.ContainsAny(command, "\n\r") {
127 return nil, nil, nil, false
128 }
129 s := strings.TrimSpace(command)
130 fields := []string{}
131 repaired := []bool{}
132 spans := [][2]int{}
133 for i := 0; i < len(s); {
134 for i < len(s) && isShellWhitespace(s[i]) {
135 i++
136 }
137 if i >= len(s) {
138 break
139 }
140 var b strings.Builder
141 fieldStarted := false
142 fieldRepaired := false
143 var quote byte
144 escapedQuote := false
145 for i < len(s) {
146 c := s[i]
147 if quote == 0 {
148 if isShellWhitespace(c) {
149 break
150 }
151 if isShellControl(c) {
152 return nil, nil, nil, false
153 }
154 if n := escapedShellQuoteLen(s, i); n > 0 {
155 quote = '"'
156 escapedQuote = true
157 fieldStarted = true
158 fieldRepaired = true
159 spans = append(spans, [2]int{i, i + n})
160 i += n
161 continue
162 }
163 if c == '"' || c == '\'' {
164 quote = c
165 escapedQuote = false
166 fieldStarted = true
167 i++
168 continue
169 }
170 b.WriteByte(c)
171 fieldStarted = true
172 i++
173 continue
174 }
175 if escapedQuote {
176 if n := escapedShellQuoteLen(s, i); n > 0 {
177 quote = 0
178 escapedQuote = false
179 fieldRepaired = true
180 spans = append(spans, [2]int{i, i + n})
181 i += n
182 continue
183 }
184 b.WriteByte(c)
185 i++
186 continue
187 }
188 if c == quote {
189 quote = 0
190 i++
191 continue
192 }
193 if quote == '"' && c == '\\' && i+1 < len(s) && isDoubleQuoteEscapedByte(s[i+1]) {
194 b.WriteByte(s[i+1])
195 i += 2
196 continue
197 }
198 b.WriteByte(c)
199 i++
200 }
201 if quote != 0 {
202 return nil, nil, nil, false
203 }
204 if !fieldStarted {
205 return nil, nil, nil, false
206 }
207 fields = append(fields, b.String())
208 repaired = append(repaired, fieldRepaired)
209 for i < len(s) && isShellWhitespace(s[i]) {
210 i++
211 }
212 }
213 return fields, repaired, spans, len(fields) > 0
214 }
215
216 func escapedShellQuoteLen(s string, i int) int {
217 j := i
218 for j < len(s) && s[j] == '\\' {
219 j++
220 }
221 if j == i || j >= len(s) || s[j] != '"' {
222 return 0
223 }
224 return j - i + 1
225 }
226
227 func isShellWhitespace(c byte) bool {
228 return c == ' ' || c == '\t'
229 }
230
231 func isShellControl(c byte) bool {
232 switch c {
233 case '&', '|', ';', '<', '>':
234 return true
235 default:
236 return false
237 }
238 }
239
240 func isDoubleQuoteEscapedByte(c byte) bool {
241 switch c {
242 case '"', '\\', '$', '`', '\n':
243 return true
244 default:
245 return false
246 }
247 }
248
249 func normalizeEscapedNodeEval(command string) (string, bool) {
250 node, flag, script, ok := escapedNodeEvalArgs(command)
251 if !ok {
252 return "", false
253 }
254 return renderNodeEvalCommand(node, flag, script), true
255 }
256
257 func escapedNodeEvalArgs(command string) (string, string, string, bool) {
258 command = strings.TrimSpace(command)
259 for _, prefix := range []struct {
260 raw string
261 node string
262 flag string
263 }{
264 {raw: `node -e `, node: "node", flag: "-e"},
265 {raw: `node --eval `, node: "node", flag: "--eval"},
266 {raw: `node.exe -e `, node: "node.exe", flag: "-e"},
267 {raw: `node.exe --eval `, node: "node.exe", flag: "--eval"},
268 } {
269 rest, ok := strings.CutPrefix(command, prefix.raw)
270 if !ok {
271 continue
272 }
273 script, ok := trimEscapedQuotes(strings.TrimSpace(rest))
274 if !ok || !isHookStdinNodeEval(script) {
275 return "", "", "", false
276 }
277 return prefix.node, prefix.flag, script, true
278 }
279 return "", "", "", false
280 }
281
282 func repairQuotedNodeEvalScript(script string) (string, bool) {
283 for _, trim := range []func(string) (string, bool){
284 trimDoubleQuotes,
285 trimEscapedQuotes,
286 trimBackslashEscapedQuotes,
287 } {
288 if candidate, ok := trim(strings.TrimSpace(script)); ok && isHookStdinNodeEval(candidate) {
289 return candidate, true
290 }
291 }
292 return "", false
293 }
294
295 func trimDoubleQuotes(s string) (string, bool) {
296 if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
297 return "", false
298 }
299 return s[1 : len(s)-1], true
300 }
301
302 func trimEscapedQuotes(s string) (string, bool) {
303 if len(s) < 4 || !strings.HasPrefix(s, `\"`) || !strings.HasSuffix(s, `\"`) {
304 return "", false
305 }
306 return unescapeJSONStyleQuotes(s[2 : len(s)-2]), true
307 }
308
309 func trimBackslashEscapedQuotes(s string) (string, bool) {
310 if len(s) < 6 || !strings.HasPrefix(s, `\\"`) || !strings.HasSuffix(s, `\\"`) {
311 return "", false
312 }
313 return unescapeJSONStyleQuotes(s[3 : len(s)-3]), true
314 }
315
316 func unescapeJSONStyleQuotes(s string) string {
317 return strings.ReplaceAll(s, `\"`, `"`)
318 }
319
320 func isHookStdinNodeEval(script string) bool {
321 script = strings.TrimSpace(script)
322 if script == "" {
323 return false
324 }
325 if !startsLikeJSStatement(script) {
326 return false
327 }
328 return strings.Contains(script, "JSON.parse") &&
329 (strings.Contains(script, "readFileSync(0") || strings.Contains(script, "readFileSync( 0") || strings.Contains(script, "process.stdin"))
330 }
331
332 func startsLikeJSStatement(script string) bool {
333 for _, prefix := range []string{
334 "const ", "const\t", "let ", "let\t", "var ", "var\t",
335 "import ", "require(", "if ", "if(", "try ", "(async ", "async ",
336 } {
337 if strings.HasPrefix(script, prefix) {
338 return true
339 }
340 }
341 return false
342 }
343
344 func isNodeCommand(command string) bool {
345 base := strings.ToLower(shellparse.WordBase(command))
346 return base == "node" || base == "node.exe"
347 }
348
349 func isPowerShellCommand(command string) bool {
350 base := strings.ToLower(command)
351 if i := strings.LastIndexAny(base, `/\`); i >= 0 {
352 base = base[i+1:]
353 }
354 return base == "powershell" || base == "powershell.exe" || base == "pwsh" || base == "pwsh.exe"
355 }
356
357 func isNodeEvalFlag(flag string) bool {
358 return flag == "-e" || flag == "--eval"
359 }
360
361 func renderNodeEvalCommand(node, flag, script string) string {
362 return shellField(node) + " " + shellField(flag) + " " + shellDoubleQuote(script)
363 }
364
365 func shellField(s string) string {
366 if s != "" {
367 safe := true
368 for _, r := range s {
369 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
370 r == '_' || r == '-' || r == '.' || r == '/' || r == ':' || r == '\\' {
371 continue
372 }
373 safe = false
374 break
375 }
376 if safe {
377 return s
378 }
379 }
380 return shellDoubleQuote(s)
381 }
382
383 func shellDoubleQuote(s string) string {
384 var b strings.Builder
385 b.Grow(len(s) + 2)
386 b.WriteByte('"')
387 for _, r := range s {
388 switch r {
389 case '\\', '"', '$', '`':
390 b.WriteByte('\\')
391 }
392 b.WriteRune(r)
393 }
394 b.WriteByte('"')
395 return b.String()
396 }
397
397 lines GO