-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathcommands.ts
More file actions
380 lines (337 loc) · 13.9 KB
/
Copy pathcommands.ts
File metadata and controls
380 lines (337 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import { parseCommand } from "../../shared/parse-command"
/**
* Detect dangerous parameter substitutions that could lead to command execution.
* These patterns are never auto-approved and always require explicit user approval.
*
* Detected patterns:
* - ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
* - ${var@Q} - Quote removal
* - ${var@E} - Escape sequence expansion
* - ${var@A} - Assignment statement
* - ${var@a} - Attribute flags
* - ${var=value} with escape sequences - Can embed commands via \140 (backtick), \x60, or \u0060
* - ${!var} - Indirect variable references
* - <<<$(...) or <<<`...` - Here-strings with command substitution
* - =(...) - Zsh process substitution that executes commands (array assignments like `var=(...)` are excluded)
* - *(e:...:) or similar - Zsh glob qualifiers with code execution
*
* @param source - The command string to analyze
* @returns true if dangerous substitution patterns are detected, false otherwise
*/
export function containsDangerousSubstitution(source: string): boolean {
// Check for dangerous parameter expansion operators that can execute commands
// ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
// ${var@Q} - Quote removal
// ${var@E} - Escape sequence expansion
// ${var@A} - Assignment statement
// ${var@a} - Attribute flags
const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
// Check for parameter expansions with assignments that could contain escape sequences
// ${var=value} or ${var:=value} can embed commands via escape sequences like \140 (backtick)
// Also check for ${var+value}, ${var:-value}, ${var:+value}, ${var:?value}
const parameterAssignmentWithEscapes =
/\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) || // octal escapes
/\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) || // hex escapes
/\$\{[^}]*[=+\-?][^}]*\\u[0-9a-fA-F]{4}[^}]*\}/.test(source) // unicode escapes
// Check for indirect variable references that could execute commands
// ${!var} performs indirect expansion which can be dangerous with crafted variable names
const indirectExpansion = /\$\{![^}]+\}/.test(source)
// Check for here-strings with command substitution
// <<<$(...) or <<<`...` can execute commands
const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
// Check for zsh process substitution =(...) which executes commands
// =(...) creates a temporary file containing the output of the command, but executes it
const zshProcessSubstitution = /(?:(?<=^)|(?<=[\s;|&(<]))=\([^)]+\)/.test(source)
// Check for zsh glob qualifiers with code execution (e:...:)
// Patterns like *(e:whoami:) or ?(e:rm -rf /:) execute commands during glob expansion
// This regex matches patterns like *(e:...:), ?(e:...:), +(e:...:), @(e:...:), !(e:...:)
const zshGlobQualifier = /[*?+@!]\(e:[^:]+:\)/.test(source)
// Return true if any dangerous pattern is detected
return (
dangerousParameterExpansion ||
parameterAssignmentWithEscapes ||
indirectExpansion ||
hereStringWithSubstitution ||
zshProcessSubstitution ||
zshGlobQualifier
)
}
/**
* Find the longest matching prefix from a list of prefixes for a given command.
*
* This is the core function that implements the "longest prefix match" strategy.
* It searches through all provided prefixes and returns the longest one that
* matches the beginning of the command (case-insensitive).
*
* **Special Cases:**
* - Wildcard "*" matches any command but is treated as length 1 for comparison
* - Empty command or empty prefixes list returns null
* - Matching is case-insensitive and uses startsWith logic
*
* **Examples:**
* ```typescript
* findLongestPrefixMatch("git push origin", ["git", "git push"])
* // Returns "git push" (longer match)
*
* findLongestPrefixMatch("npm install", ["*", "npm"])
* // Returns "npm" (specific match preferred over wildcard)
*
* findLongestPrefixMatch("unknown command", ["git", "npm"])
* // Returns null (no match found)
* ```
*
* @param command - The command to match against
* @param prefixes - List of prefix patterns to search through
* @returns The longest matching prefix, or null if no match found
*/
export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
if (!command || !prefixes?.length) {
return null
}
const trimmedCommand = command.trim().toLowerCase()
let longestMatch: string | null = null
for (const prefix of prefixes) {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
longestMatch = lowerPrefix
}
}
}
return longestMatch
}
/**
* Check if a single command should be auto-approved.
* Returns true only for commands that explicitly match the allowlist
* and either don't match the denylist or have a longer allowlist match.
*
* Special handling for wildcards: "*" in allowlist allows any command,
* but denylist can still block specific commands.
*/
export function isAutoApprovedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) {
return true
}
// If no allowlist configured, nothing can be auto-approved
if (!allowedCommands?.length) {
return false
}
// Check if wildcard is present in allowlist
const hasWildcard = allowedCommands.some((cmd) => cmd.toLowerCase() === "*")
// If no denylist provided (undefined), use simple allowlist logic
if (deniedCommands === undefined) {
const trimmedCommand = command.trim().toLowerCase()
return allowedCommands.some((prefix) => {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
return lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)
})
}
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands)
// Special case: if wildcard is present and no denylist match, auto-approve
if (hasWildcard && !longestDeniedMatch) {
return true
}
// Must have an allowlist match to be auto-approved
if (!longestAllowedMatch) {
return false
}
// If no denylist match, auto-approve
if (!longestDeniedMatch) {
return true
}
// Both have matches - allowlist must be longer to auto-approve
return longestAllowedMatch.length > longestDeniedMatch.length
}
/**
* Check if a single command should be auto-denied.
* Returns true only for commands that explicitly match the denylist
* and either don't match the allowlist or have a longer denylist match.
*/
export function isAutoDeniedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) return false
// If no denylist configured, nothing can be auto-denied
if (!deniedCommands?.length) return false
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
// Must have a denylist match to be auto-denied
if (!longestDeniedMatch) return false
// If no allowlist match, auto-deny
if (!longestAllowedMatch) return true
// Both have matches - denylist must be longer or equal to auto-deny
return longestDeniedMatch.length >= longestAllowedMatch.length
}
/**
* Command approval decision types
*/
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malformed_command"
/**
* Unified command validation that implements the longest prefix match rule.
* Returns a definitive decision for a command based on allowlist and denylist.
*
* This is the main entry point for command validation in the Command Denylist feature.
* It handles complex command chains and applies the longest prefix match strategy
* to resolve conflicts between allowlist and denylist patterns.
*
* **Decision Logic:**
* 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands
* 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
* 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
*
* **Return Values:**
* - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected
* - `"auto_deny"`: At least one sub-command is explicitly denied
* - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns
* - `"malformed_command"`: Command contains an unterminated quote -- a shell syntax error that must not be auto-approved
*
* **Examples:**
* ```typescript
* // Simple approval
* getCommandDecision("git status", ["git"], [])
* // Returns "auto_approve"
*
* // Dangerous pattern - never auto-approved
* getCommandDecision('echo "${var@P}"', ["echo"], [])
* // Returns "ask_user"
*
* // Longest prefix match - denial wins
* getCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny"
*
* // Command chain - any denial blocks all
* getCommandDecision("git status && rm file", ["git"], ["rm"])
* // Returns "auto_deny"
*
* // No matches - ask user
* getCommandDecision("unknown command", ["git"], ["rm"])
* // Returns "ask_user"
* ```
*
* @param command - The full command string to validate
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision indicating whether to approve, deny, or ask user
*/
export function getCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command?.trim()) {
return "auto_approve"
}
// Parse into sub-commands (split by &&, ||, ;, |). parseCommand also
// detects shell syntax errors (unterminated quotes, unclosed heredocs) and
// returns a non-null parseError in that case.
const { commands: subCommands, parseError } = parseCommand(command)
// Reject commands with a shell syntax error. An unterminated quote means
// the shell would report a parse error; in a compound command it may
// partially execute the well-formed prefix before aborting. Returning a
// distinct decision lets callers surface a useful message to the agent
// rather than silently presenting the command for user approval.
if (parseError !== null) {
return "malformed_command"
}
// Check each sub-command and collect decisions
const decisions: CommandDecision[] = subCommands.map((cmd) => {
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
})
// If any sub-command is denied, deny the whole command
if (decisions.includes("auto_deny")) {
return "auto_deny"
}
// Require explicit user approval for dangerous patterns
if (containsDangerousSubstitution(command)) {
return "ask_user"
}
// If all sub-commands are approved, approve the whole command
if (decisions.every((decision) => decision === "auto_approve")) {
return "auto_approve"
}
// Otherwise, ask user
return "ask_user"
}
/**
* Get the decision for a single command using longest prefix match rule.
*
* This is the core logic that implements the conflict resolution between
* allowlist and denylist using the "longest prefix match" strategy.
*
* **Longest Prefix Match Algorithm:**
* 1. Find the longest matching prefix in the allowlist
* 2. Find the longest matching prefix in the denylist
* 3. Compare lengths to determine which rule takes precedence
* 4. Longer (more specific) match wins the conflict
*
* **Decision Matrix:**
* | Allowlist Match | Denylist Match | Result | Reason |
* |----------------|----------------|---------|---------|
* | Yes | No | auto_approve | Only allowlist matches |
* | No | Yes | auto_deny | Only denylist matches |
* | Yes | Yes (shorter) | auto_approve | Allowlist is more specific |
* | Yes | Yes (longer/equal) | auto_deny | Denylist is more specific |
* | No | No | ask_user | No rules apply |
*
* **Examples:**
* ```typescript
* // Only allowlist matches
* getSingleCommandDecision("git status", ["git"], ["npm"])
* // Returns "auto_approve"
*
* // Denylist is more specific
* getSingleCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny" (denylist "git push" > allowlist "git")
*
* // Allowlist is more specific
* getSingleCommandDecision("git push --dry-run", ["git push --dry-run"], ["git push"])
* // Returns "auto_approve" (allowlist is longer)
*
* // No matches
* getSingleCommandDecision("unknown", ["git"], ["npm"])
* // Returns "ask_user"
* ```
*
* @param command - Single command to validate (no chaining)
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision for this specific command
*/
export function getSingleCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command) return "auto_approve"
// Find longest matching prefixes in both lists
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
// If only allowlist has a match, auto-approve
if (longestAllowedMatch && !longestDeniedMatch) {
return "auto_approve"
}
// If only denylist has a match, auto-deny
if (!longestAllowedMatch && longestDeniedMatch) {
return "auto_deny"
}
// Both lists have matches - apply longest prefix match rule
if (longestAllowedMatch && longestDeniedMatch) {
return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
}
// If neither list has a match, ask user
return "ask_user"
}