返回 DeepSeek-Reasonix
effect.go
根目录 / internal / shellsafe / effect.go
1 package shellsafe
2
3 import (
4 "path/filepath"
5 "slices"
6 "strings"
7
8 "mvdan.cc/sh/v3/syntax"
9
10 "reasonix/internal/shellparse"
11 )
12
13 // Certainty reports whether the host could statically prove a command's
14 // effects. Unknown commands fail closed at mutation and permission boundaries.
15 type Certainty uint8
16
17 const (
18 EffectUnknown Certainty = iota
19 EffectKnown
20 )
21
22 // WriteDomain identifies durable state a command can change.
23 type WriteDomain uint8
24
25 const (
26 WriteWorkspaceContent WriteDomain = 1 << iota
27 WriteRepositoryMetadata
28 WriteHostState
29 WriteExternalState
30 )
31
32 // CommandEffect is the shared, host-derived effect classification for one Bash
33 // invocation. CommandFamily and Reason are deliberately argument-free so they
34 // are safe to surface in policy diagnostics.
35 type CommandEffect struct {
36 Certainty Certainty
37 Writes WriteDomain
38 PermissionSafe bool
39 ExecutesCode bool
40 UsesNetwork bool
41 CommandFamily string
42 Reason string
43 }
44
45 func (e CommandEffect) AnyMutation() bool { return e.Certainty != EffectKnown || e.Writes != 0 }
46 func (e CommandEffect) WorkspaceMutation() bool {
47 return e.Certainty != EffectKnown || e.Writes&(WriteWorkspaceContent|WriteRepositoryMetadata) != 0
48 }
49 func (e CommandEffect) ContentMutation() bool {
50 return e.Certainty != EffectKnown || e.Writes&WriteWorkspaceContent != 0
51 }
52 func (e CommandEffect) RepositoryMutation() bool { return e.Writes&WriteRepositoryMetadata != 0 }
53 func (e CommandEffect) IsPermissionReader() bool {
54 return e.Certainty == EffectKnown && e.Writes == 0 && e.PermissionSafe
55 }
56
57 // ClassifyBash statically classifies every top-level command segment and
58 // combines their effects. It never executes expansions or helper programs.
59 func ClassifyBash(command string) CommandEffect {
60 command = strings.TrimSpace(command)
61 if command == "" {
62 return unknownEffect("shell", "empty command")
63 }
64 if bashHasUnsafeLifecycle(command) {
65 return unknownEffect(commandFamilyFromSource(command), "background or process lifecycle syntax")
66 }
67 segments, _, ok := shellparse.SplitTopLevel(command)
68 if !ok || len(segments) == 0 {
69 return unknownEffect(commandFamilyFromSource(command), "unsupported shell syntax")
70 }
71
72 var aggregate CommandEffect
73 aggregate.Certainty = EffectKnown
74 aggregate.PermissionSafe = true
75 for _, segment := range segments {
76 effect := classifyBashSegment(segment)
77 aggregate.Writes |= effect.Writes
78 aggregate.ExecutesCode = aggregate.ExecutesCode || effect.ExecutesCode
79 aggregate.UsesNetwork = aggregate.UsesNetwork || effect.UsesNetwork
80 aggregate.PermissionSafe = aggregate.PermissionSafe && effect.IsPermissionReader()
81 if aggregate.CommandFamily == "" || effect.AnyMutation() || !effect.PermissionSafe {
82 aggregate.CommandFamily = effect.CommandFamily
83 }
84 if aggregate.Reason == "" && effect.Reason != "" {
85 aggregate.Reason = effect.Reason
86 }
87 if effect.Certainty != EffectKnown {
88 aggregate.Certainty = EffectUnknown
89 }
90 }
91 if aggregate.CommandFamily == "" {
92 aggregate.CommandFamily = "shell"
93 }
94 return aggregate
95 }
96
97 func classifyBashSegment(segment string) CommandEffect {
98 if normalized, ok := NormalizeBashSafeRedirectsForMatch(segment); ok {
99 if fields, envPrefixed, ok := staticArgv(normalized); ok {
100 return applyEnvPrefix(classifyStaticFields(fields), envPrefixed)
101 }
102 // Preserve the existing narrow, recursively proven command-substitution
103 // reader path. Its returned fields contain only opaque placeholders.
104 if base, sub, fields, ok := ClassifyReadOnlyCommand(normalized); ok {
105 effect := classifyStaticFields(fields)
106 if effect.Certainty == EffectKnown && effect.Writes == 0 {
107 effect.CommandFamily = joinFamily(base, sub)
108 return effect
109 }
110 }
111 return unknownEffect(commandFamilyFromSource(normalized), "dynamic command shape")
112 }
113
114 fields, writesFile, ok := staticFieldsWithOutputRedirect(segment)
115 if !ok {
116 return unknownEffect(commandFamilyFromSource(segment), "unsafe or unsupported redirection")
117 }
118 effect := classifyStaticFields(fields)
119 if writesFile {
120 effect.Writes |= WriteWorkspaceContent
121 effect.PermissionSafe = false
122 if effect.Reason == "" {
123 effect.Reason = "writes command output"
124 }
125 }
126 return effect
127 }
128
129 // staticArgv reduces one statically proven command to argv after stripping a
130 // leading env wrapper. Env flags and expansions fail closed.
131 func staticArgv(command string) ([]string, bool, bool) {
132 cmd, err := shellparse.ParseStaticCommand(command, shellparse.StaticCommandPolicy{
133 AllowEnvAssignments: true,
134 AllowStderrToStdout: true,
135 })
136 if err != nil || len(cmd.Argv) == 0 {
137 return nil, false, false
138 }
139 fields, envPrefixed, ok := unwrapEnvArgv(cmd.Argv)
140 if !ok {
141 return nil, envPrefixed, false
142 }
143 return fields, envPrefixed || len(cmd.Env) > 0, true
144 }
145
146 func unwrapEnvArgv(argv []string) ([]string, bool, bool) {
147 if len(argv) == 0 {
148 return nil, false, false
149 }
150 base := strings.ToLower(strings.TrimSuffix(filepath.Base(argv[0]), ".exe"))
151 if base != "env" {
152 return argv, false, true
153 }
154 rest := argv[1:]
155 for len(rest) > 0 {
156 tok := rest[0]
157 if strings.HasPrefix(tok, "-") {
158 return nil, true, false
159 }
160 if !strings.Contains(tok, "=") {
161 break
162 }
163 rest = rest[1:]
164 }
165 if len(rest) == 0 {
166 return nil, true, false
167 }
168 return rest, true, true
169 }
170
171 func applyEnvPrefix(effect CommandEffect, envPrefixed bool) CommandEffect {
172 if !envPrefixed {
173 return effect
174 }
175 effect.PermissionSafe = false
176 if effect.Reason == "" {
177 effect.Reason = "environment prefix requires permission"
178 }
179 return effect
180 }
181
182 // CommandArgv returns statically proven argv after stripping a leading env
183 // prefix. ok is false for expansions, env flags, or unsupported syntax.
184 func CommandArgv(command string) (argv []string, envPrefixed bool, ok bool) {
185 return staticArgv(strings.TrimSpace(command))
186 }
187
188 func classifyStaticFields(fields []string) CommandEffect {
189 if len(fields) == 0 {
190 return unknownEffect("shell", "empty command")
191 }
192 base := strings.ToLower(strings.TrimSuffix(filepath.Base(fields[0]), ".exe"))
193 args := fields[1:]
194 switch base {
195 case "git":
196 return classifyGit(args)
197 case "date":
198 return classifyDate(args)
199 case "go":
200 return classifyGo(args)
201 case "npm":
202 return classifyNPM(args)
203 case "cargo":
204 return classifyCargo(args)
205 case "test-netconnection":
206 return CommandEffect{Certainty: EffectKnown, UsesNetwork: true, CommandFamily: base, Reason: "network probe requires permission"}
207 }
208
209 if readOnlyCommands[base] {
210 effect := knownReader(base)
211 switch base {
212 case "find":
213 if hasEffectArg(args, "-exec", "-execdir", "-ok", "-okdir") {
214 return unknownCodeEffect(base, "executes a nested command")
215 }
216 if hasEffectArg(args, "-delete", "-fls", "-fprint", "-fprint0", "-fprintf") {
217 return knownWriter(base, WriteWorkspaceContent, "writes filesystem state")
218 }
219 case "sort":
220 if hasOutputArg(args) {
221 return knownWriter(base, WriteWorkspaceContent, "writes command output")
222 }
223 }
224 return effect
225 }
226 if len(args) > 0 && readOnlyPrefixes[base][strings.ToLower(args[0])] {
227 effect := knownReader(joinFamily(base, strings.ToLower(args[0])))
228 if base == "docker" || base == "kubectl" {
229 effect.UsesNetwork = true
230 }
231 return effect
232 }
233 return unknownEffect(base, "command effects are not statically known")
234 }
235
236 func classifyGit(args []string) CommandEffect {
237 args = skipGitGlobalArgs(args)
238 if len(args) == 0 {
239 return unknownEffect("git", "missing git subcommand")
240 }
241 sub := strings.ToLower(args[0])
242 rest := args[1:]
243 switch sub {
244 case "branch":
245 return classifyGitBranch(rest)
246 case "tag":
247 return classifyGitTag(rest)
248 case "remote":
249 return classifyGitRemote(rest)
250 case "config":
251 return classifyGitConfig(rest)
252 case "worktree":
253 return classifyGitWorktree(rest)
254 case "stash":
255 return classifyGitStash(rest)
256 case "clean":
257 return classifyGitClean(rest)
258 case "submodule":
259 return classifyGitSubmodule(rest)
260 case "commit":
261 return classifyGitCommit(rest)
262 case "push":
263 effect := knownWriter("git push", WriteExternalState, "updates remote refs")
264 effect.UsesNetwork = true
265 return effect
266 case "fetch":
267 effect := knownWriter("git fetch", WriteRepositoryMetadata, "updates remote-tracking refs")
268 effect.UsesNetwork = true
269 return effect
270 case "pull":
271 effect := knownWriter("git pull", WriteWorkspaceContent|WriteRepositoryMetadata, "updates workspace content and refs")
272 effect.UsesNetwork = true
273 return effect
274 case "reflog":
275 if len(rest) > 0 && slices.Contains([]string{"expire", "delete", "drop"}, strings.ToLower(rest[0])) {
276 return knownWriter("git reflog", WriteRepositoryMetadata, "updates reflog metadata")
277 }
278 return knownReader("git reflog")
279 case "diff", "show", "log":
280 if hasOutputArg(rest) {
281 return knownWriter("git "+sub, WriteWorkspaceContent, "writes command output")
282 }
283 if hasEffectArg(rest, "--ext-diff", "--textconv") {
284 return unknownCodeEffect("git "+sub, "may execute an external diff helper")
285 }
286 return knownReader("git " + sub)
287 case "grep":
288 if hasArgPrefix(rest, "--open-files-in-pager") {
289 return unknownCodeEffect("git grep", "may execute an external pager")
290 }
291 return knownReader("git grep")
292 case "cat-file":
293 if hasEffectArg(rest, "--filters", "--textconv") {
294 return unknownCodeEffect("git cat-file", "may execute an external content filter")
295 }
296 return knownReader("git cat-file")
297 }
298 if readOnlyPrefixes["git"][sub] {
299 return knownReader("git " + sub)
300 }
301 return unknownEffect("git "+sub, "git subcommand effects are not statically known")
302 }
303
304 func skipGitGlobalArgs(args []string) []string {
305 for len(args) > 0 {
306 if args[0] == "--no-pager" {
307 args = args[1:]
308 continue
309 }
310 if len(args) >= 2 && (args[0] == "-C" || args[0] == "--git-dir" || args[0] == "--work-tree") {
311 args = args[2:]
312 continue
313 }
314 break
315 }
316 return args
317 }
318
319 func classifyGitBranch(args []string) CommandEffect {
320 family := "git branch"
321 if slices.ContainsFunc(args, branchMutationArg) {
322 return knownWriter(family, WriteRepositoryMetadata, "updates branch refs or configuration")
323 }
324 listing := len(args) == 0
325 operands := 0
326 for i := 0; i < len(args); i++ {
327 arg := args[i]
328 switch {
329 case arg == "-l" || arg == "--list" || arg == "--all" || arg == "-a" || arg == "--remotes" || arg == "-r" || arg == "--show-current":
330 listing = true
331 case arg == "-v" || arg == "-vv" || arg == "--verbose" || arg == "-q" || arg == "--quiet" || arg == "-i" || arg == "--ignore-case" || arg == "--omit-empty" || arg == "--no-omit-empty":
332 listing = true
333 case branchValueOption(arg):
334 listing = true
335 if !strings.Contains(arg, "=") {
336 i++
337 if i >= len(args) {
338 return unknownEffect(family, "missing branch filter value")
339 }
340 }
341 case strings.HasPrefix(arg, "-"):
342 return unknownEffect(family, "unknown branch option")
343 default:
344 operands++
345 }
346 }
347 if operands > 0 && !hasEffectArg(args, "-l", "--list") {
348 return knownWriter(family, WriteRepositoryMetadata, "creates or resets a branch ref")
349 }
350 if listing {
351 return knownReader(family)
352 }
353 return unknownEffect(family, "ambiguous branch operation")
354 }
355
356 func classifyGitTag(args []string) CommandEffect {
357 family := "git tag"
358 listing := len(args) == 0
359 verify := false
360 operands := 0
361 for i := 0; i < len(args); i++ {
362 arg := args[i]
363 if tagMutationArg(arg) {
364 return knownWriter(family, WriteRepositoryMetadata, "updates tag refs")
365 }
366 switch {
367 case arg == "-l" || arg == "--list":
368 listing = true
369 case arg == "-v" || arg == "--verify":
370 verify = true
371 case arg == "-i" || arg == "--ignore-case" || strings.HasPrefix(arg, "-n") || tagFlagOption(arg):
372 listing = true
373 if tagOptionConsumesNext(arg) {
374 i++
375 if i >= len(args) {
376 return unknownEffect(family, "missing tag filter value")
377 }
378 }
379 case strings.HasPrefix(arg, "-"):
380 return unknownEffect(family, "unknown tag option")
381 default:
382 operands++
383 }
384 }
385 if verify {
386 if operands == 0 {
387 return unknownEffect(family, "missing tag verification operand")
388 }
389 effect := CommandEffect{Certainty: EffectKnown, ExecutesCode: true, CommandFamily: family, Reason: "tag verification may execute a signature helper"}
390 return effect
391 }
392 if operands > 0 && !hasEffectArg(args, "-l", "--list") {
393 return knownWriter(family, WriteRepositoryMetadata, "creates a tag ref")
394 }
395 if listing {
396 return knownReader(family)
397 }
398 return unknownEffect(family, "ambiguous tag operation")
399 }
400
401 func classifyGitRemote(args []string) CommandEffect {
402 family := "git remote"
403 if len(args) == 0 || allEffectArgs(args, "-v", "--verbose") {
404 return knownReader(family)
405 }
406 action := strings.ToLower(args[0])
407 if action == "get-url" {
408 return knownReader(family)
409 }
410 if slices.Contains([]string{"add", "remove", "rm", "rename", "set-url", "prune", "update"}, action) {
411 return knownWriter(family, WriteRepositoryMetadata, "updates remote configuration or refs")
412 }
413 return unknownEffect(family, "remote operation is not a proven local reader")
414 }
415
416 func classifyGitConfig(args []string) CommandEffect {
417 family := "git config"
418 hostScope := hasEffectArg(args, "--global", "--system")
419 for _, arg := range args {
420 name := strings.ToLower(strings.SplitN(arg, "=", 2)[0])
421 if slices.Contains([]string{"--edit", "-e"}, name) {
422 domain := WriteRepositoryMetadata
423 if hostScope {
424 domain = WriteHostState
425 }
426 return knownWriter(family, domain, "opens an editor that can write git configuration")
427 }
428 if slices.Contains([]string{"set", "unset", "unset-all", "add", "replace-all", "rename-section", "remove-section", "--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section"}, name) {
429 domain := WriteRepositoryMetadata
430 if hostScope {
431 domain = WriteHostState
432 }
433 return knownWriter(family, domain, "updates git configuration")
434 }
435 }
436 positional := 0
437 for i := 0; i < len(args); i++ {
438 arg := args[i]
439 if configOptionConsumesNext(arg) {
440 i++
441 continue
442 }
443 if strings.HasPrefix(arg, "-") || configReadAction(arg) {
444 continue
445 }
446 positional++
447 }
448 if positional <= 1 || containsConfigReadAction(args) {
449 return knownReader(family)
450 }
451 domain := WriteRepositoryMetadata
452 if hostScope {
453 domain = WriteHostState
454 }
455 return knownWriter(family, domain, "updates git configuration")
456 }
457
458 func classifyGitWorktree(args []string) CommandEffect {
459 family := "git worktree"
460 if len(args) > 0 && strings.EqualFold(args[0], "list") {
461 return knownReader(family)
462 }
463 if len(args) > 0 && slices.Contains([]string{"add", "remove", "move", "prune"}, strings.ToLower(args[0])) {
464 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates worktrees")
465 }
466 if len(args) > 0 && slices.Contains([]string{"lock", "unlock", "repair"}, strings.ToLower(args[0])) {
467 return knownWriter(family, WriteRepositoryMetadata, "updates worktree metadata")
468 }
469 return unknownEffect(family, "worktree operation is not a proven reader")
470 }
471
472 func classifyGitStash(args []string) CommandEffect {
473 family := "git stash"
474 if len(args) > 0 && slices.Contains([]string{"list", "show"}, strings.ToLower(args[0])) {
475 return knownReader(family)
476 }
477 if len(args) > 0 && slices.Contains([]string{"push", "save", "pop", "apply", "drop", "clear", "branch", "store", "create"}, strings.ToLower(args[0])) {
478 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates stash or working tree state")
479 }
480 return unknownEffect(family, "stash operation is not a proven reader")
481 }
482
483 func classifyGitClean(args []string) CommandEffect {
484 family := "git clean"
485 dryRun := false
486 force := false
487 for _, arg := range args {
488 dryRun = dryRun || arg == "--dry-run" || shortOptionContains(arg, 'n')
489 force = force || arg == "--force" || shortOptionContains(arg, 'f')
490 }
491 if dryRun && !force {
492 return knownReader(family)
493 }
494 return knownWriter(family, WriteWorkspaceContent, "removes untracked workspace content")
495 }
496
497 func classifyGitSubmodule(args []string) CommandEffect {
498 family := "git submodule"
499 if len(args) > 0 && slices.Contains([]string{"status", "summary"}, strings.ToLower(args[0])) {
500 return knownReader(family)
501 }
502 if len(args) > 0 && slices.Contains([]string{"add", "init", "update", "deinit", "sync", "set-url", "set-branch", "foreach", "absorbgitdirs"}, strings.ToLower(args[0])) {
503 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "updates submodule state")
504 }
505 return unknownEffect(family, "submodule operation is not a proven reader")
506 }
507
508 func classifyGitCommit(args []string) CommandEffect {
509 family := "git commit"
510 pure := false
511 for i := 0; i < len(args); i++ {
512 arg := args[i]
513 switch {
514 case arg == "-q" || arg == "--quiet":
515 case arg == "-m" || arg == "--message":
516 if i+1 >= len(args) {
517 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "ambiguous commit form")
518 }
519 pure = true
520 i++
521 case strings.HasPrefix(arg, "--message="):
522 pure = true
523 default:
524 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "commit form may include workspace content")
525 }
526 }
527 if pure {
528 return knownWriter(family, WriteRepositoryMetadata, "creates a commit and moves refs")
529 }
530 return knownWriter(family, WriteWorkspaceContent|WriteRepositoryMetadata, "ambiguous commit form")
531 }
532
533 func classifyDate(args []string) CommandEffect {
534 family := "date"
535 for i := 0; i < len(args); i++ {
536 arg := args[i]
537 switch {
538 case arg == "-s" || arg == "--set" || strings.HasPrefix(arg, "--set="):
539 return knownWriter(family, WriteHostState, "sets the host clock")
540 case strings.HasPrefix(arg, "+"):
541 case slices.Contains([]string{"-u", "--utc", "--universal", "-R", "--rfc-email", "--resolution", "--debug", "-j"}, arg):
542 case strings.HasPrefix(arg, "--iso-8601") || strings.HasPrefix(arg, "--rfc-3339"):
543 case slices.Contains([]string{"-d", "--date", "-r", "--reference", "-f", "--file"}, arg):
544 i++
545 if i >= len(args) {
546 return unknownEffect(family, "missing date reader value")
547 }
548 case strings.HasPrefix(arg, "--date=") || strings.HasPrefix(arg, "--reference=") || strings.HasPrefix(arg, "--file="):
549 case strings.HasPrefix(arg, "-"):
550 return unknownEffect(family, "unknown date option")
551 default:
552 return knownWriter(family, WriteHostState, "may set the host clock")
553 }
554 }
555 return knownReader(family)
556 }
557
558 func classifyGo(args []string) CommandEffect {
559 if len(args) == 0 {
560 return unknownEffect("go", "missing go subcommand")
561 }
562 sub := strings.ToLower(args[0])
563 rest := args[1:]
564 family := "go " + sub
565 if sub == "env" && hasEffectArg(rest, "-w", "-u") {
566 return knownWriter(family, WriteHostState, "updates Go environment configuration")
567 }
568 if sub == "list" {
569 for i, arg := range rest {
570 if arg == "-modfile" || strings.HasPrefix(arg, "-modfile=") || arg == "-mod=mod" || (arg == "-mod" && i+1 < len(rest) && rest[i+1] == "mod") {
571 return knownWriter(family, WriteWorkspaceContent, "may update module files")
572 }
573 }
574 }
575 if readOnlyPrefixes["go"][sub] {
576 return knownReader(family)
577 }
578 return unknownEffect(family, "go subcommand effects are not statically known")
579 }
580
581 func classifyNPM(args []string) CommandEffect {
582 if len(args) == 0 {
583 return unknownEffect("npm", "missing npm subcommand")
584 }
585 sub := strings.ToLower(args[0])
586 family := "npm " + sub
587 if sub == "audit" {
588 effect := knownReader(family)
589 effect.UsesNetwork = true
590 if hasEffectArg(args[1:], "fix", "--fix") {
591 effect.Writes = WriteWorkspaceContent
592 effect.PermissionSafe = false
593 effect.Reason = "updates dependency manifests or lockfiles"
594 }
595 return effect
596 }
597 if readOnlyPrefixes["npm"][sub] {
598 effect := knownReader(family)
599 effect.UsesNetwork = sub == "view" || sub == "info" || sub == "outdated"
600 return effect
601 }
602 return unknownEffect(family, "npm subcommand effects are not statically known")
603 }
604
605 func classifyCargo(args []string) CommandEffect {
606 if len(args) == 0 {
607 return unknownEffect("cargo", "missing cargo subcommand")
608 }
609 sub := strings.ToLower(args[0])
610 family := "cargo " + sub
611 if sub == "check" || sub == "doc" {
612 effect := knownWriter(family, WriteWorkspaceContent, "writes build artifacts and may run build scripts")
613 effect.ExecutesCode = true
614 return effect
615 }
616 if sub == "search" {
617 effect := knownReader(family)
618 effect.UsesNetwork = true
619 return effect
620 }
621 return unknownEffect(family, "cargo subcommand effects are not statically known")
622 }
623
624 func knownReader(family string) CommandEffect {
625 return CommandEffect{Certainty: EffectKnown, PermissionSafe: true, CommandFamily: family}
626 }
627 func knownWriter(family string, writes WriteDomain, reason string) CommandEffect {
628 return CommandEffect{Certainty: EffectKnown, Writes: writes, CommandFamily: family, Reason: reason}
629 }
630 func unknownEffect(family, reason string) CommandEffect {
631 return CommandEffect{Certainty: EffectUnknown, CommandFamily: strings.TrimSpace(family), Reason: reason}
632 }
633
634 func unknownCodeEffect(family, reason string) CommandEffect {
635 effect := unknownEffect(family, reason)
636 effect.ExecutesCode = true
637 return effect
638 }
639
640 func joinFamily(base, sub string) string {
641 if sub == "" {
642 return base
643 }
644 return base + " " + sub
645 }
646
647 func commandFamilyFromSource(command string) string {
648 features, ok := shellparse.AnalyzeApprovalFeatures(command)
649 if !ok || len(features.CommandPrefix) == 0 {
650 return "shell"
651 }
652 base := strings.ToLower(strings.TrimSuffix(filepath.Base(features.CommandPrefix[0]), ".exe"))
653 if len(features.CommandPrefix) > 1 && slices.Contains([]string{"git", "go", "npm", "cargo", "docker", "kubectl"}, base) {
654 return joinFamily(base, strings.ToLower(features.CommandPrefix[1]))
655 }
656 return base
657 }
658
659 func bashHasUnsafeLifecycle(command string) bool {
660 file, err := shellparse.ParseBash(command)
661 if err != nil {
662 return false
663 }
664 unsafe := false
665 syntax.Walk(file, func(node syntax.Node) bool {
666 stmt, ok := node.(*syntax.Stmt)
667 if ok && (stmt.Background || stmt.Coprocess || stmt.Disown) {
668 unsafe = true
669 return false
670 }
671 return !unsafe
672 })
673 return unsafe
674 }
675
676 func staticFieldsWithOutputRedirect(command string) ([]string, bool, bool) {
677 file, err := shellparse.ParseBash(command)
678 if err != nil || shellparse.HasHereDoc(file) || len(file.Stmts) != 1 {
679 return nil, false, false
680 }
681 stmt := file.Stmts[0]
682 call, ok := stmt.Cmd.(*syntax.CallExpr)
683 if !ok || len(call.Assigns) > 0 || stmt.Background || stmt.Negated || stmt.Coprocess || stmt.Disown {
684 return nil, false, false
685 }
686 fields := make([]string, 0, len(call.Args))
687 for _, word := range call.Args {
688 field, static := shellparse.StaticWord(word)
689 if !static {
690 return nil, false, false
691 }
692 fields = append(fields, field)
693 }
694 writes := false
695 for _, redirect := range stmt.Redirs {
696 switch redirect.Op {
697 case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob, syntax.RdrAll, syntax.AppAll, syntax.RdrAllClob, syntax.AppAllClob:
698 writes = true
699 default:
700 return nil, false, false
701 }
702 }
703 return fields, writes, len(fields) > 0
704 }
705
706 func branchMutationArg(arg string) bool {
707 return slices.Contains([]string{"-d", "-D", "--delete", "--no-delete", "-m", "-M", "--move", "--no-move", "-c", "-C", "--copy", "--no-copy", "-f", "--force", "--no-force", "-t", "--track", "--no-track", "-u", "--set-upstream-to", "--unset-upstream", "--edit-description", "--create-reflog", "--recurse-submodules"}, strings.SplitN(arg, "=", 2)[0])
708 }
709 func branchValueOption(arg string) bool {
710 return slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--color", "--column", "--abbrev"}, strings.SplitN(arg, "=", 2)[0])
711 }
712 func tagMutationArg(arg string) bool {
713 return slices.Contains([]string{"-d", "--delete", "-a", "--annotate", "-s", "--sign", "-u", "--local-user", "-f", "--force", "-m", "--message", "-F", "--file", "-e", "--edit", "--trailer", "--cleanup", "--create-reflog"}, strings.SplitN(arg, "=", 2)[0])
714 }
715 func tagFlagOption(arg string) bool {
716 return slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--column", "--no-column", "--color", "--no-color", "--omit-empty", "--no-omit-empty"}, strings.SplitN(arg, "=", 2)[0])
717 }
718
719 func tagOptionConsumesNext(arg string) bool {
720 return !strings.Contains(arg, "=") && slices.Contains([]string{"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}, arg)
721 }
722
723 func configReadAction(arg string) bool {
724 return slices.Contains([]string{"get", "get-all", "get-regexp", "get-urlmatch", "list", "--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "--get-color", "--get-colorbool"}, strings.ToLower(arg))
725 }
726
727 func containsConfigReadAction(args []string) bool { return slices.ContainsFunc(args, configReadAction) }
728 func configOptionConsumesNext(arg string) bool {
729 return slices.Contains([]string{"--file", "-f", "--blob", "--type", "--default", "--fixed-value"}, strings.ToLower(arg))
730 }
731
732 func hasEffectArg(args []string, candidates ...string) bool {
733 return slices.ContainsFunc(args, func(arg string) bool {
734 return slices.Contains(candidates, strings.SplitN(arg, "=", 2)[0])
735 })
736 }
737
738 func hasArgPrefix(args []string, prefix string) bool {
739 return slices.ContainsFunc(args, func(arg string) bool { return strings.HasPrefix(arg, prefix) })
740 }
741 func allEffectArgs(args []string, allowed ...string) bool {
742 return len(args) > 0 && !slices.ContainsFunc(args, func(arg string) bool { return !slices.Contains(allowed, arg) })
743 }
744 func hasOutputArg(args []string) bool {
745 return hasEffectArg(args, "-o", "--output") || hasArgPrefix(args, "-o")
746 }
747 func shortOptionContains(arg string, flag byte) bool {
748 return len(arg) > 1 && arg[0] == '-' && arg[1] != '-' && strings.ContainsRune(arg[1:], rune(flag))
749 }
750
750 lines GO