返回 DeepSeek-Reasonix
bash_approval.go
根目录 / internal / permission / bash_approval.go
1 package permission
2
3 import (
4 "strings"
5
6 "reasonix/internal/shellparse"
7 )
8
9 type bashApprovalClass uint8
10
11 const (
12 bashApprovalReusable bashApprovalClass = iota
13 bashApprovalExactOnly
14 bashApprovalRequireHuman
15 )
16
17 // BashSubjectRequiresExplicitApproval reports whether subject can execute a
18 // nested or indirect command and therefore needs a human in Ask/Auto. Exact
19 // command rules are handled separately by Policy before this classification.
20 func BashSubjectRequiresExplicitApproval(subject string) bool {
21 return classifyBashApproval(subject) == bashApprovalRequireHuman
22 }
23
24 func bashSubjectRequiresExactRule(subject string) bool {
25 return classifyBashApproval(subject) != bashApprovalReusable
26 }
27
28 func classifyBashApproval(subject string) bashApprovalClass {
29 if strings.TrimSpace(subject) == "" {
30 return bashApprovalReusable
31 }
32 segments, _, ok := shellparse.SplitTopLevel(subject)
33 if !ok {
34 return classifyBashSegmentApproval(subject)
35 }
36 if len(segments) == 0 {
37 return bashApprovalRequireHuman
38 }
39 class := bashApprovalReusable
40 for _, segment := range segments {
41 segmentClass := classifyBashSegmentApproval(segment)
42 if segmentClass > class {
43 class = segmentClass
44 }
45 if class == bashApprovalRequireHuman {
46 break
47 }
48 }
49 return class
50 }
51
52 func classifyBashSegmentApproval(subject string) bashApprovalClass {
53 if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
54 subject = normalized
55 }
56 features, ok := shellparse.AnalyzeApprovalFeatures(subject)
57 if !ok || features.NestedExecution || features.DynamicCommandName {
58 return bashApprovalRequireHuman
59 }
60 if len(features.CommandPrefix) > 0 && isIndirectExecution(features.CommandPrefix) {
61 return bashApprovalRequireHuman
62 }
63 if features.Expansion || features.Assignment || features.Redirection ||
64 shellparse.ContainsUnquotedGlob(subject) || hasEnvWrapperAssignment(features.CommandPrefix) {
65 return bashApprovalExactOnly
66 }
67 return bashApprovalReusable
68 }
69
70 func isIndirectExecution(fields []string) bool {
71 if len(fields) == 0 {
72 return true
73 }
74 base := executableBase(fields[0])
75 args := fields[1:]
76
77 switch base {
78 case "eval", "source", ".", "xargs":
79 return true
80 case "env":
81 for len(args) > 0 && isEnvironmentAssignment(args[0]) {
82 args = args[1:]
83 }
84 if len(args) == 0 || strings.HasPrefix(args[0], "-") {
85 return true
86 }
87 return isIndirectExecution(args)
88 case "builtin", "command", "exec", "nohup", "sudo":
89 if len(args) == 0 || strings.HasPrefix(args[0], "-") {
90 return true
91 }
92 return isIndirectExecution(args)
93 case "bash", "dash", "fish", "ksh", "sh", "zsh":
94 return hasShellCommandFlag(args)
95 case "powershell", "pwsh":
96 return hasAnyFoldedArg(args, "-c", "-command", "-e", "-enc", "-encodedcommand")
97 case "cmd":
98 return hasAnyFoldedArg(args, "/c", "/k")
99 case "node", "bun":
100 return hasAnyFoldedArg(args, "-e", "--eval", "-p", "--print")
101 case "deno":
102 return hasAnyFoldedArg(args, "eval")
103 case "python", "python3", "py", "pypy", "pypy3":
104 return hasAnyFoldedArg(args, "-c")
105 case "perl", "ruby", "lua", "luajit", "r", "rscript", "osascript":
106 return hasAnyFoldedArg(args, "-e")
107 case "php":
108 return hasAnyFoldedArg(args, "-r")
109 case "find":
110 return hasAnyFoldedArg(args, "-exec", "-execdir", "-ok", "-okdir")
111 default:
112 return false
113 }
114 }
115
116 func hasEnvWrapperAssignment(fields []string) bool {
117 if len(fields) < 2 || executableBase(fields[0]) != "env" {
118 return false
119 }
120 for _, arg := range fields[1:] {
121 if isEnvironmentAssignment(arg) {
122 return true
123 }
124 if !strings.HasPrefix(arg, "-") {
125 return false
126 }
127 }
128 return false
129 }
130
131 func executableBase(command string) string {
132 if i := strings.LastIndexAny(command, `/\\`); i >= 0 {
133 command = command[i+1:]
134 }
135 command = strings.ToLower(command)
136 return strings.TrimSuffix(command, ".exe")
137 }
138
139 func hasShellCommandFlag(args []string) bool {
140 for _, arg := range args {
141 lower := strings.ToLower(arg)
142 if lower == "--" {
143 return false
144 }
145 if lower == "--command" {
146 return true
147 }
148 if strings.HasPrefix(lower, "-") && !strings.HasPrefix(lower, "--") && strings.Contains(lower[1:], "c") {
149 return true
150 }
151 }
152 return false
153 }
154
155 func hasAnyFoldedArg(args []string, candidates ...string) bool {
156 for _, arg := range args {
157 lower := strings.ToLower(arg)
158 for _, candidate := range candidates {
159 candidate = strings.ToLower(candidate)
160 if lower == candidate {
161 return true
162 }
163 if strings.HasPrefix(candidate, "--") && (strings.HasPrefix(lower, candidate+"=") || strings.HasPrefix(lower, candidate+":")) {
164 return true
165 }
166 if strings.HasPrefix(candidate, "-") && !strings.HasPrefix(candidate, "--") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) && !strings.HasPrefix(lower, "--") {
167 return true
168 }
169 if strings.HasPrefix(candidate, "/") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) {
170 return true
171 }
172 if len(candidate) > 2 && strings.HasPrefix(candidate, "-") && strings.HasPrefix(lower, candidate+":") {
173 return true
174 }
175 }
176 }
177 return false
178 }
179
180 func isEnvironmentAssignment(arg string) bool {
181 name, _, ok := strings.Cut(arg, "=")
182 if !ok || name == "" {
183 return false
184 }
185 for i, r := range name {
186 letter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
187 digit := i > 0 && r >= '0' && r <= '9'
188 if !letter && !digit && r != '_' {
189 return false
190 }
191 }
192 return true
193 }
194
194 lines GO