mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
744 lines
25 KiB
TypeScript
744 lines
25 KiB
TypeScript
import { parse } from "shell-quote"
|
|
|
|
type ShellToken = string | { op: string } | { command: string }
|
|
|
|
/**
|
|
* # Command Denylist Feature - Longest Prefix Match Strategy
|
|
*
|
|
* This module implements a sophisticated command validation system that uses the
|
|
* "longest prefix match" strategy to resolve conflicts between allowlist and denylist patterns.
|
|
*
|
|
* ## Core Concept: Longest Prefix Match
|
|
*
|
|
* When a command matches patterns in both the allowlist and denylist, the system uses
|
|
* the longest (most specific) match to determine the final decision. This approach
|
|
* provides fine-grained control over command execution permissions.
|
|
*
|
|
* ### Examples:
|
|
*
|
|
* **Example 1: Specific denial overrides general allowance**
|
|
* - Allowlist: ["git"]
|
|
* - Denylist: ["git push"]
|
|
* - Command: "git push origin main"
|
|
* - Result: DENIED (denylist match "git push" is longer than allowlist match "git")
|
|
*
|
|
* **Example 2: Specific allowance overrides general denial**
|
|
* - Allowlist: ["git push --dry-run"]
|
|
* - Denylist: ["git push"]
|
|
* - Command: "git push --dry-run origin main"
|
|
* - Result: APPROVED (allowlist match is longer and more specific)
|
|
*
|
|
* **Example 3: Wildcard handling**
|
|
* - Allowlist: ["*"]
|
|
* - Denylist: ["rm", "sudo"]
|
|
* - Command: "rm -rf /"
|
|
* - Result: DENIED (specific denylist match overrides wildcard allowlist)
|
|
*
|
|
* ## Command Processing Pipeline:
|
|
*
|
|
* 1. **Dangerous Substitution Detection**: Commands containing dangerous patterns like ${var@P} are never auto-approved
|
|
* 2. **Command Parsing**: Split chained commands (&&, ||, ;, |, &) into individual commands for separate validation
|
|
* 3. **Pattern Matching**: For each individual command, find the longest matching prefix in both allowlist and denylist
|
|
* 4. **Decision Logic**: Apply longest prefix match rule - more specific (longer) matches take precedence
|
|
* 5. **Aggregation**: Combine individual decisions - if any command is denied, the entire chain is denied
|
|
*
|
|
* ## Security Considerations:
|
|
*
|
|
* - **Dangerous Substitution Protection**: Detects dangerous parameter expansions and escape sequences that could execute commands
|
|
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately to prevent bypassing via chaining
|
|
* - **Case Insensitive**: All pattern matching is case-insensitive for consistent behavior across different input styles
|
|
* - **Whitespace Handling**: Commands are trimmed and normalized before matching to prevent whitespace-based bypasses
|
|
*
|
|
* ## Configuration Merging:
|
|
*
|
|
* The system merges command lists from two sources with global state taking precedence:
|
|
* 1. Global state (user preferences)
|
|
* 2. Workspace configuration (project-specific settings)
|
|
*
|
|
* This allows users to have personal defaults while projects can define specific restrictions.
|
|
*/
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @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)
|
|
|
|
// Return true if any dangerous pattern is detected
|
|
return (
|
|
dangerousParameterExpansion || parameterAssignmentWithEscapes || indirectExpansion || hereStringWithSubstitution
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Split a command string into individual sub-commands by
|
|
* chaining operators (&&, ||, ;, |, or &) and newlines.
|
|
*
|
|
* Uses shell-quote to properly handle:
|
|
* - Quoted strings (preserves quotes)
|
|
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
|
|
* - PowerShell redirections (2>&1)
|
|
* - Chain operators (&&, ||, ;, |, &)
|
|
* - Newlines as command separators
|
|
*/
|
|
export function parseCommand(command: string): string[] {
|
|
if (!command?.trim()) return []
|
|
|
|
// Split by newlines first (handle different line ending formats)
|
|
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
|
|
const lines = command.split(/\r\n|\r|\n/)
|
|
const allCommands: string[] = []
|
|
|
|
for (const line of lines) {
|
|
// Skip empty lines
|
|
if (!line.trim()) continue
|
|
|
|
// Process each line through the existing parsing logic
|
|
const lineCommands = parseCommandLine(line)
|
|
allCommands.push(...lineCommands)
|
|
}
|
|
|
|
return allCommands
|
|
}
|
|
|
|
/**
|
|
* Helper function to restore placeholders in a command string
|
|
*/
|
|
function restorePlaceholders(
|
|
command: string,
|
|
quotes: string[],
|
|
redirections: string[],
|
|
arrayIndexing: string[],
|
|
arithmeticExpressions: string[],
|
|
parameterExpansions: string[],
|
|
variables: string[],
|
|
subshells: string[],
|
|
): string {
|
|
let result = command
|
|
// Restore quotes
|
|
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
|
|
// Restore redirections
|
|
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
|
|
// Restore array indexing expressions
|
|
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
|
|
// Restore arithmetic expressions
|
|
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
|
|
// Restore parameter expansions
|
|
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
|
|
// Restore variable references
|
|
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
|
|
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Parse a single line of commands (internal helper function)
|
|
*/
|
|
function parseCommandLine(command: string): string[] {
|
|
if (!command?.trim()) return []
|
|
|
|
// Storage for replaced content
|
|
const redirections: string[] = []
|
|
const subshells: string[] = []
|
|
const quotes: string[] = []
|
|
const arrayIndexing: string[] = []
|
|
const arithmeticExpressions: string[] = []
|
|
const variables: string[] = []
|
|
const parameterExpansions: string[] = []
|
|
|
|
// First handle PowerShell redirections by temporarily replacing them
|
|
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
|
|
redirections.push(match)
|
|
return `__REDIR_${redirections.length - 1}__`
|
|
})
|
|
|
|
// Handle arithmetic expressions: $((...)) pattern
|
|
// Match the entire arithmetic expression including nested parentheses
|
|
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
|
|
arithmeticExpressions.push(match)
|
|
return `__ARITH_${arithmeticExpressions.length - 1}__`
|
|
})
|
|
|
|
// Handle $[...] arithmetic expressions (alternative syntax)
|
|
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
|
|
arithmeticExpressions.push(match)
|
|
return `__ARITH_${arithmeticExpressions.length - 1}__`
|
|
})
|
|
|
|
// Handle parameter expansions: ${...} patterns (including array indexing)
|
|
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
|
|
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
|
|
parameterExpansions.push(match)
|
|
return `__PARAM_${parameterExpansions.length - 1}__`
|
|
})
|
|
|
|
// Handle process substitutions: <(...) and >(...)
|
|
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
|
|
subshells.push(inner.trim())
|
|
return `__SUBSH_${subshells.length - 1}__`
|
|
})
|
|
|
|
// Handle simple variable references: $varname pattern
|
|
// This prevents shell-quote from splitting $count into separate tokens
|
|
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
|
|
variables.push(match)
|
|
return `__VAR_${variables.length - 1}__`
|
|
})
|
|
|
|
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
|
|
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
|
|
variables.push(match)
|
|
return `__VAR_${variables.length - 1}__`
|
|
})
|
|
|
|
// Then handle subshell commands $() and back-ticks
|
|
processedCommand = processedCommand
|
|
.replace(/\$\((.*?)\)/g, (_, inner) => {
|
|
subshells.push(inner.trim())
|
|
return `__SUBSH_${subshells.length - 1}__`
|
|
})
|
|
.replace(/`(.*?)`/g, (_, inner) => {
|
|
subshells.push(inner.trim())
|
|
return `__SUBSH_${subshells.length - 1}__`
|
|
})
|
|
|
|
// Then handle quoted strings
|
|
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
|
|
quotes.push(match)
|
|
return `__QUOTE_${quotes.length - 1}__`
|
|
})
|
|
|
|
let tokens: ShellToken[]
|
|
try {
|
|
tokens = parse(processedCommand) as ShellToken[]
|
|
} catch (error: any) {
|
|
// If shell-quote fails to parse, fall back to simple splitting
|
|
console.warn("shell-quote parse error:", error.message, "for command:", processedCommand)
|
|
|
|
// Simple fallback: split by common operators
|
|
const fallbackCommands = processedCommand
|
|
.split(/(?:&&|\|\||;|\||&)/)
|
|
.map((cmd) => cmd.trim())
|
|
.filter((cmd) => cmd.length > 0)
|
|
|
|
// Restore all placeholders for each command
|
|
return fallbackCommands.map((cmd) =>
|
|
restorePlaceholders(
|
|
cmd,
|
|
quotes,
|
|
redirections,
|
|
arrayIndexing,
|
|
arithmeticExpressions,
|
|
parameterExpansions,
|
|
variables,
|
|
subshells,
|
|
),
|
|
)
|
|
}
|
|
|
|
const commands: string[] = []
|
|
let currentCommand: string[] = []
|
|
|
|
for (const token of tokens) {
|
|
if (typeof token === "object" && "op" in token) {
|
|
// Chain operator - split command
|
|
if (["&&", "||", ";", "|", "&"].includes(token.op)) {
|
|
if (currentCommand.length > 0) {
|
|
commands.push(currentCommand.join(" "))
|
|
currentCommand = []
|
|
}
|
|
} else {
|
|
// Other operators (>) are part of the command
|
|
currentCommand.push(token.op)
|
|
}
|
|
} else if (typeof token === "string") {
|
|
// Check if it's a subshell placeholder
|
|
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
|
|
if (subshellMatch) {
|
|
if (currentCommand.length > 0) {
|
|
commands.push(currentCommand.join(" "))
|
|
currentCommand = []
|
|
}
|
|
commands.push(subshells[parseInt(subshellMatch[1])])
|
|
} else {
|
|
currentCommand.push(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add any remaining command
|
|
if (currentCommand.length > 0) {
|
|
commands.push(currentCommand.join(" "))
|
|
}
|
|
|
|
// Restore quotes and redirections
|
|
return commands.map((cmd) =>
|
|
restorePlaceholders(
|
|
cmd,
|
|
quotes,
|
|
redirections,
|
|
arrayIndexing,
|
|
arithmeticExpressions,
|
|
parameterExpansions,
|
|
variables,
|
|
subshells,
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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"
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* **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 &&, ||, ;, |)
|
|
const subCommands = parseCommand(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"
|
|
}
|
|
|
|
/**
|
|
* Centralized Command Validation Service
|
|
*
|
|
* This class provides a unified interface for all command validation operations
|
|
* in the Command Denylist feature. It encapsulates the validation logic and
|
|
* provides convenient methods for different validation scenarios.
|
|
*/
|
|
export class CommandValidator {
|
|
constructor(
|
|
private allowedCommands: string[],
|
|
private deniedCommands?: string[],
|
|
) {}
|
|
|
|
/**
|
|
* Update the command lists used for validation
|
|
*/
|
|
updateCommandLists(allowedCommands: string[], deniedCommands?: string[]) {
|
|
this.allowedCommands = allowedCommands
|
|
this.deniedCommands = deniedCommands
|
|
}
|
|
|
|
/**
|
|
* Get the current command lists
|
|
*/
|
|
getCommandLists() {
|
|
return {
|
|
allowedCommands: [...this.allowedCommands],
|
|
deniedCommands: this.deniedCommands ? [...this.deniedCommands] : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a command and return a decision
|
|
* This is the main validation method that should be used for all command validation
|
|
*/
|
|
validateCommand(command: string): CommandDecision {
|
|
return getCommandDecision(command, this.allowedCommands, this.deniedCommands)
|
|
}
|
|
|
|
/**
|
|
* Check if a command would be auto-approved
|
|
*/
|
|
isAutoApproved(command: string): boolean {
|
|
return this.validateCommand(command) === "auto_approve"
|
|
}
|
|
|
|
/**
|
|
* Check if a command would be auto-denied
|
|
*/
|
|
isAutoDenied(command: string): boolean {
|
|
return this.validateCommand(command) === "auto_deny"
|
|
}
|
|
|
|
/**
|
|
* Check if a command requires user input
|
|
*/
|
|
requiresUserInput(command: string): boolean {
|
|
return this.validateCommand(command) === "ask_user"
|
|
}
|
|
|
|
/**
|
|
* Get detailed validation information for a command
|
|
* Useful for debugging and providing user feedback
|
|
*/
|
|
getValidationDetails(command: string): {
|
|
decision: CommandDecision
|
|
subCommands: string[]
|
|
allowedMatches: Array<{ command: string; match: string | null }>
|
|
deniedMatches: Array<{ command: string; match: string | null }>
|
|
hasDangerousSubstitution: boolean
|
|
} {
|
|
const subCommands = parseCommand(command)
|
|
const hasDangerousSubstitution = containsDangerousSubstitution(command)
|
|
|
|
const allowedMatches = subCommands.map((cmd) => ({
|
|
command: cmd,
|
|
match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.allowedCommands),
|
|
}))
|
|
|
|
const deniedMatches = subCommands.map((cmd) => ({
|
|
command: cmd,
|
|
match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.deniedCommands || []),
|
|
}))
|
|
|
|
return {
|
|
decision: this.validateCommand(command),
|
|
subCommands,
|
|
allowedMatches,
|
|
deniedMatches,
|
|
hasDangerousSubstitution,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate multiple commands at once
|
|
* Returns a map of command to decision
|
|
*/
|
|
validateCommands(commands: string[]): Map<string, CommandDecision> {
|
|
const results = new Map<string, CommandDecision>()
|
|
for (const command of commands) {
|
|
results.set(command, this.validateCommand(command))
|
|
}
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Check if the validator has any rules configured
|
|
*/
|
|
hasRules(): boolean {
|
|
return this.allowedCommands.length > 0 || (this.deniedCommands?.length ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Get statistics about the current configuration
|
|
*/
|
|
getStats() {
|
|
return {
|
|
allowedCount: this.allowedCommands.length,
|
|
deniedCount: this.deniedCommands?.length ?? 0,
|
|
hasWildcard: this.allowedCommands.some((cmd) => cmd.toLowerCase() === "*"),
|
|
hasRules: this.hasRules(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function to create a CommandValidator instance
|
|
* This is the recommended way to create validators in the application
|
|
*/
|
|
export function createCommandValidator(allowedCommands: string[], deniedCommands?: string[]): CommandValidator {
|
|
return new CommandValidator(allowedCommands, deniedCommands)
|
|
}
|