/** * PowerShell-specific permission checking, adapted from bashPermissions.ts * for case-insensitive cmdlet matching. */ import { resolve } from 'path' import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' import type { PermissionDecisionReason, PermissionResult, } from '../../types/permissions.js' import { getCwd } from '../../utils/cwd.js' import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js' import type { PermissionRule } from '../../utils/permissions/PermissionRule.js' import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' import { createPermissionRequestMessage, getRuleByContentsForToolName, } from '../../utils/permissions/permissions.js' import { matchWildcardPattern, parsePermissionRule, type ShellPermissionRule, suggestionForExactCommand as sharedSuggestionForExactCommand, } from '../../utils/permissions/shellRuleMatching.js' import { classifyCommandName, deriveSecurityFlags, getAllCommandNames, getFileRedirections, type ParsedCommandElement, type ParsedPowerShellCommand, PS_TOKENIZER_DASH_CHARS, parsePowerShellCommand, stripModulePrefix, } from '../../utils/powershell/parser.js' import { containsVulnerableUncPath } from '../../utils/shell/readOnlyCommandValidation.js' import { isDotGitPathPS, isGitInternalPathPS } from './gitSafety.js' import { checkPermissionMode, isSymlinkCreatingCommand, } from './modeValidation.js' import { checkPathConstraints, dangerousRemovalDeny, isDangerousRemovalRawPath, } from './pathValidation.js' import { powershellCommandIsSafe } from './powershellSecurity.js' import { argLeaksValue, isAllowlistedCommand, isCwdChangingCmdlet, isProvablySafeStatement, isReadOnlyCommand, isSafeOutputCommand, resolveToCanonical, } from './readOnlyValidation.js' import { POWERSHELL_TOOL_NAME } from './toolName.js' // Matches `$var = `, `$var += `, `$env:X = `, `$x ??= ` etc. Used to strip // nested assignment prefixes in the parse-failed fallback path. const PS_ASSIGN_PREFIX_RE = /^\$[\w:]+\s*(?:[+\-*/%]|\?\?)?\s*=\s*/ /** * Cmdlets that can place a file at a caller-specified path. The * git-internal-paths guard checks whether any arg is a git-internal path * (hooks/, refs/, objects/, HEAD). Non-creating writers (remove-item, * clear-content) are intentionally absent — they can't plant new hooks. */ const GIT_SAFETY_WRITE_CMDLETS = new Set([ 'new-item', 'set-content', 'add-content', 'out-file', 'copy-item', 'move-item', 'rename-item', 'expand-archive', 'invoke-webrequest', 'invoke-restmethod', 'tee-object', 'export-csv', 'export-clixml', ]) /** * External archive-extraction applications that write files to cwd with * archive-controlled paths. `tar -xf payload.tar; git status` defeats * isCurrentDirectoryBareGitRepo (TOCTOU): the check runs at * permission-eval time, tar extracts HEAD/hooks/refs/ AFTER the check and * BEFORE git runs. Unlike GIT_SAFETY_WRITE_CMDLETS (where we can inspect * args for git-internal paths), archive contents are opaque — any * extraction preceding git must ask. Matched by name only (lowercase, * with and without .exe). */ const GIT_SAFETY_ARCHIVE_EXTRACTORS = new Set([ 'tar', 'tar.exe', 'bsdtar', 'bsdtar.exe', 'unzip', 'unzip.exe', '7z', '7z.exe', '7za', '7za.exe', 'gzip', 'gzip.exe', 'gunzip', 'gunzip.exe', 'expand-archive', ]) /** * Extract the command name from a PowerShell command string. * Uses the parser to get the first command name from the AST. */ async function extractCommandName(command: string): Promise { const trimmed = command.trim() if (!trimmed) { return '' } const parsed = await parsePowerShellCommand(trimmed) const names = getAllCommandNames(parsed) return names[0] ?? '' } /** * Parse a permission rule string into a structured rule object. * Delegates to shared parsePermissionRule. */ export function powershellPermissionRule( permissionRule: string, ): ShellPermissionRule { return parsePermissionRule(permissionRule) } /** * Generate permission update suggestion for exact command match. * * Skip exact-command suggestion for commands that can't round-trip cleanly: * - Multi-line: newlines don't survive normalization, rule would never match * - Literal *: storing `Remove-Item * -Force` verbatim re-parses as a wildcard * rule via hasWildcards() (matches `^Remove-Item .* -Force$`). Escaping to * `\*` creates a dead rule — parsePermissionRule's exact branch returns the * raw string with backslash intact, so `Remove-Item \* -Force` never matches * the incoming `Remove-Item * -Force`. Globs are unsafe to exact-auto-allow * anyway; prefix suggestion still offered. (finding #12) */ function suggestionForExactCommand(command: string): PermissionUpdate[] { if (command.includes('\n') || command.includes('*')) { return [] } return sharedSuggestionForExactCommand(POWERSHELL_TOOL_NAME, command) } /** * PowerShell input schema type - simplified for initial implementation */ type PowerShellInput = { command: string timeout?: number } /** * Filter rules by contents matching an input command. * PowerShell-specific: uses case-insensitive matching throughout. * Follows the same structure as BashTool's local filterRulesByContentsMatchingInput. */ function filterRulesByContentsMatchingInput( input: PowerShellInput, rules: Map, matchMode: 'exact' | 'prefix', behavior: 'deny' | 'ask' | 'allow', ): PermissionRule[] { const command = input.command.trim() function strEquals(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase() } function strStartsWith(str: string, prefix: string): boolean { return str.toLowerCase().startsWith(prefix.toLowerCase()) } // SECURITY: stripModulePrefix on RULE names widens the // secondary-canonical match — a deny rule `Module\Remove-Item:*` blocking // `rm` is the intent (fail-safe over-match), but an allow rule // `ModuleA\Get-Thing:*` also matching `ModuleB\Get-Thing` is fail-OPEN. // Deny/ask over-match is fine; allow must never over-match. function stripModulePrefixForRule(name: string): string { if (behavior === 'allow') { return name } return stripModulePrefix(name) } // Extract the first word (command name) from the input for canonical matching. // Keep both raw (for slicing the original `command` string) and stripped // (for canonical resolution) versions. For module-qualified inputs like // `Microsoft.PowerShell.Utility\Invoke-Expression foo`, rawCmdName holds the // full token so `command.slice(rawCmdName.length)` yields the correct rest. const rawCmdName = command.split(/\s+/)[0] ?? '' const inputCmdName = stripModulePrefix(rawCmdName) const inputCanonical = resolveToCanonical(inputCmdName) // Build a version of the command with the canonical name substituted // e.g., 'rm foo.txt' -> 'remove-item foo.txt' so deny rules on Remove-Item also block rm. // SECURITY: Normalize the whitespace separator between name and args to a // single space. PowerShell accepts any whitespace (tab, etc.) as separator, // but prefix rule matching uses `prefix + ' '` (literal space). Without this, // `rm\t./x` canonicalizes to `remove-item\t./x` and misses the deny rule // `Remove-Item:*`, while acceptEdits auto-allow (using AST cmd.name) still // matches — a deny-rule bypass. Build unconditionally (not just when the // canonical differs) so non-space-separated raw commands are also normalized. const rest = command.slice(rawCmdName.length).replace(/^\s+/, ' ') const canonicalCommand = inputCanonical + rest return Array.from(rules.entries()) .filter(([ruleContent]) => { const rule = powershellPermissionRule(ruleContent) // Also resolve the rule's command name to canonical for cross-matching // e.g., a deny rule for 'rm' should also block 'Remove-Item' function matchesCommand(cmd: string): boolean { switch (rule.type) { case 'exact': return strEquals(rule.command, cmd) case 'prefix': switch (matchMode) { case 'exact': return strEquals(rule.prefix, cmd) case 'prefix': { if (strEquals(cmd, rule.prefix)) { return true } return strStartsWith(cmd, rule.prefix + ' ') } } break case 'wildcard': if (matchMode === 'exact') { return false } return matchWildcardPattern(rule.pattern, cmd, true) } } // Check against the original command if (matchesCommand(command)) { return true } // Also check against the canonical form of the command // This ensures 'deny Remove-Item' also blocks 'rm', 'del', 'ri', etc. if (matchesCommand(canonicalCommand)) { return true } // Also resolve the rule's command name to canonical and compare // This ensures 'deny rm' also blocks 'Remove-Item' // SECURITY: stripModulePrefix applied to DENY/ASK rule command // names too, not just input. Otherwise a deny rule written as // `Microsoft.PowerShell.Management\Remove-Item:*` is bypassed by `rm`, // `del`, or plain `Remove-Item` — resolveToCanonical won't match the // module-qualified form against COMMON_ALIASES. if (rule.type === 'exact') { const rawRuleCmdName = rule.command.split(/\s+/)[0] ?? '' const ruleCanonical = resolveToCanonical( stripModulePrefixForRule(rawRuleCmdName), ) if (ruleCanonical === inputCanonical) { // Rule and input resolve to same canonical cmdlet // SECURITY: use normalized `rest` not a raw re-slice // from `command`. The raw slice preserves tab separators so // `Remove-Item\t./secret.txt` vs deny rule `rm ./secret.txt` misses. // Normalize both sides identically. const ruleRest = rule.command .slice(rawRuleCmdName.length) .replace(/^\s+/, ' ') const inputRest = rest if (strEquals(ruleRest, inputRest)) { return true } } } else if (rule.type === 'prefix') { const rawRuleCmdName = rule.prefix.split(/\s+/)[0] ?? '' const ruleCanonical = resolveToCanonical( stripModulePrefixForRule(rawRuleCmdName), ) if (ruleCanonical === inputCanonical) { const ruleRest = rule.prefix .slice(rawRuleCmdName.length) .replace(/^\s+/, ' ') const canonicalPrefix = inputCanonical + ruleRest if (matchMode === 'exact') { if (strEquals(canonicalPrefix, canonicalCommand)) { return true } } else { if ( strEquals(canonicalCommand, canonicalPrefix) || strStartsWith(canonicalCommand, canonicalPrefix + ' ') ) { return true } } } } else if (rule.type === 'wildcard') { // Resolve the wildcard pattern's command name to canonical and re-match // This ensures 'deny rm *' also blocks 'Remove-Item secret.txt' const rawRuleCmdName = rule.pattern.split(/\s+/)[0] ?? '' const ruleCanonical = resolveToCanonical( stripModulePrefixForRule(rawRuleCmdName), ) if (ruleCanonical === inputCanonical && matchMode !== 'exact') { // Rebuild the pattern with the canonical cmdlet name // Normalize separator same as exact and prefix branches. // Without this, a wildcard rule `rm\t*` produces canonicalPattern // with a literal tab that never matches the space-normalized // canonicalCommand. const ruleRest = rule.pattern .slice(rawRuleCmdName.length) .replace(/^\s+/, ' ') const canonicalPattern = inputCanonical + ruleRest if (matchWildcardPattern(canonicalPattern, canonicalCommand, true)) { return true } } } return false }) .map(([, rule]) => rule) } /** * Get matching rules for input across all rule types (deny, ask, allow) */ function matchingRulesForInput( input: PowerShellInput, toolPermissionContext: ToolPermissionContext, matchMode: 'exact' | 'prefix', ) { const denyRuleByContents = getRuleByContentsForToolName( toolPermissionContext, POWERSHELL_TOOL_NAME, 'deny', ) const matchingDenyRules = filterRulesByContentsMatchingInput( input, denyRuleByContents, matchMode, 'deny', ) const askRuleByContents = getRuleByContentsForToolName( toolPermissionContext, POWERSHELL_TOOL_NAME, 'ask', ) const matchingAskRules = filterRulesByContentsMatchingInput( input, askRuleByContents, matchMode, 'ask', ) const allowRuleByContents = getRuleByContentsForToolName( toolPermissionContext, POWERSHELL_TOOL_NAME, 'allow', ) const matchingAllowRules = filterRulesByContentsMatchingInput( input, allowRuleByContents, matchMode, 'allow', ) return { matchingDenyRules, matchingAskRules, matchingAllowRules } } /** * Check if the command is an exact match for a permission rule. */ export function powershellToolCheckExactMatchPermission( input: PowerShellInput, toolPermissionContext: ToolPermissionContext, ): PermissionResult { const trimmedCommand = input.command.trim() const { matchingDenyRules, matchingAskRules, matchingAllowRules } = matchingRulesForInput(input, toolPermissionContext, 'exact') if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${trimmedCommand} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0] }, } } if (matchingAskRules[0] !== undefined) { return { behavior: 'ask', message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), decisionReason: { type: 'rule', rule: matchingAskRules[0] }, } } if (matchingAllowRules[0] !== undefined) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'rule', rule: matchingAllowRules[0] }, } } const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: 'This command requires approval', } return { behavior: 'passthrough', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: suggestionForExactCommand(trimmedCommand), } } /** * Check permission for a PowerShell command including prefix matches. */ export function powershellToolCheckPermission( input: PowerShellInput, toolPermissionContext: ToolPermissionContext, ): PermissionResult { const command = input.command.trim() // 1. Check exact match first const exactMatchResult = powershellToolCheckExactMatchPermission( input, toolPermissionContext, ) // 1a. Deny/ask if exact command has a rule if ( exactMatchResult.behavior === 'deny' || exactMatchResult.behavior === 'ask' ) { return exactMatchResult } // 2. Find all matching rules (prefix or exact) const { matchingDenyRules, matchingAskRules, matchingAllowRules } = matchingRulesForInput(input, toolPermissionContext, 'prefix') // 2a. Deny if command has a deny rule if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0], }, } } // 2b. Ask if command has an ask rule if (matchingAskRules[0] !== undefined) { return { behavior: 'ask', message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), decisionReason: { type: 'rule', rule: matchingAskRules[0], }, } } // 3. Allow if command had an exact match allow if (exactMatchResult.behavior === 'allow') { return exactMatchResult } // 4. Allow if command has an allow rule if (matchingAllowRules[0] !== undefined) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'rule', rule: matchingAllowRules[0], }, } } // 5. Passthrough since no rules match, will trigger permission prompt const decisionReason = { type: 'other' as const, reason: 'This command requires approval', } return { behavior: 'passthrough', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: suggestionForExactCommand(command), } } /** * Information about a sub-command for permission checking. */ type SubCommandInfo = { text: string element: ParsedCommandElement statement: ParsedPowerShellCommand['statements'][number] | null isSafeOutput: boolean } /** * Extract sub-commands that need independent permission checking from a parsed command. * Safe output cmdlets (Format-Table, Select-Object, etc.) are flagged but NOT * filtered out — step 4.4 still checks deny rules against them (deny always * wins), step 5 skips them for approval collection (they inherit the permission * of the preceding command). * * Also includes nested commands from control flow statements (if, for, foreach, etc.) * to ensure commands hidden inside control flow are checked. * * Returns sub-command info including both text and the parsed element for accurate * suggestion generation. */ async function getSubCommandsForPermissionCheck( parsed: ParsedPowerShellCommand, originalCommand: string, ): Promise { if (!parsed.valid) { // Return a fallback element for unparsed commands return [ { text: originalCommand, element: { name: await extractCommandName(originalCommand), nameType: 'unknown', elementType: 'CommandAst', args: [], text: originalCommand, }, statement: null, isSafeOutput: false, }, ] } const subCommands: SubCommandInfo[] = [] // Check direct commands in pipelines for (const statement of parsed.statements) { for (const cmd of statement.commands) { // Only check actual commands (CommandAst), not expressions if (cmd.elementType !== 'CommandAst') { continue } subCommands.push({ text: cmd.text, element: cmd, statement, // SECURITY: nameType gate — scripts\\Out-Null strips to Out-Null and // would match SAFE_OUTPUT_CMDLETS, but PowerShell runs the .ps1 file. // isSafeOutput: true causes step 5 to filter this command out of the // approval list, so it would silently execute. See isAllowlistedCommand. // SECURITY: args.length === 0 gate — Out-Null -InputObject:(1 > /etc/x) // was filtered as safe-output (name-only) → step-5 subCommands empty → // auto-allow → redirection inside paren writes file. Only zero-arg // Out-String/Out-Null/Out-Host invocations are provably safe. isSafeOutput: cmd.nameType !== 'application' && isSafeOutputCommand(cmd.name) && cmd.args.length === 0, }) } // Also check nested commands from control flow statements if (statement.nestedCommands) { for (const cmd of statement.nestedCommands) { subCommands.push({ text: cmd.text, element: cmd, statement, isSafeOutput: cmd.nameType !== 'application' && isSafeOutputCommand(cmd.name) && cmd.args.length === 0, }) } } } if (subCommands.length > 0) { return subCommands } // Fallback for commands with no sub-commands return [ { text: originalCommand, element: { name: await extractCommandName(originalCommand), nameType: 'unknown', elementType: 'CommandAst', args: [], text: originalCommand, }, statement: null, isSafeOutput: false, }, ] } /** * Main permission check function for PowerShell tool. * * This function implements the full permission flow: * 1. Check exact match against deny/ask/allow rules * 2. Check prefix match against rules * 3. Run security check via powershellCommandIsSafe() * 4. Return appropriate PermissionResult * * @param input - The PowerShell tool input * @param context - The tool use context (for abort signal and session info) * @returns Promise resolving to PermissionResult */ export async function powershellToolHasPermission( input: PowerShellInput, context: ToolUseContext, ): Promise { const toolPermissionContext = context.getAppState().toolPermissionContext const command = input.command.trim() // Empty command check if (!command) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'other', reason: 'Empty command is safe', }, } } // Parse the command once and thread through all sub-functions const parsed = await parsePowerShellCommand(command) // SECURITY: Check deny/ask rules BEFORE parse validity check. // Deny rules operate on the raw command string and don't need the parsed AST. // This ensures explicit deny rules still block commands even when parsing fails. // 1. Check exact match first const exactMatchResult = powershellToolCheckExactMatchPermission( input, toolPermissionContext, ) // Exact command was denied if (exactMatchResult.behavior === 'deny') { return exactMatchResult } // 2. Check prefix/wildcard rules const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( input, toolPermissionContext, 'prefix', ) // 2a. Deny if command has a deny rule if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0], }, } } // 2b. Ask if command has an ask rule — DEFERRED into decisions[]. // Previously this early-returned before sub-command deny checks ran, so // `Get-Process; Invoke-Expression evil` with ask(Get-Process:*) + // deny(Invoke-Expression:*) would show the ask dialog and the deny never // fired. Now: store the ask, push into decisions[] after parse succeeds. // If parse fails, returned before the parse-error ask (preserves the // rule-attributed decisionReason when pwsh is unavailable). let preParseAskDecision: PermissionResult | null = null if (matchingAskRules[0] !== undefined) { preParseAskDecision = { behavior: 'ask', message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), decisionReason: { type: 'rule', rule: matchingAskRules[0], }, } } // Block UNC paths — reading from UNC paths can trigger network requests // and leak NTLM/Kerberos credentials. DEFERRED into decisions[]. // The raw-string UNC check must not early-return before sub-command deny // (step 4+). Same fix as 2b above. if (preParseAskDecision === null && containsVulnerableUncPath(command)) { preParseAskDecision = { behavior: 'ask', message: 'Command contains a UNC path that could trigger network requests', } } // 2c. Exact allow rules short-circuit here ONLY when parsing failed AND // no pre-parse ask (2b prefix or UNC) is pending. Converting 2b/UNC from // early-return to deferred-assign meant 2c // fired before L648 consumed preParseAskDecision — silently overriding the // ask with allow. Parse-succeeded path enforces ask > allow via the reduce // (L917); without this guard, parse-failed was inconsistent. // This ensures user-configured exact allow rules work even when pwsh is // unavailable. When parsing succeeds, the exact allow check is deferred to // after step 4.4 (sub-command deny/ask) — matching BashTool's ordering where // the main-flow exact allow at bashPermissions.ts:1520 runs after sub-command // deny checks (1442-1458). Without this, an exact allow on a compound command // would bypass deny rules on sub-commands. // // SECURITY (parse-failed branch): the nameType guard in step 5 lives // inside the sub-command loop, which only runs when parsed.valid. // This is the !parsed.valid escape hatch. Input-side stripModulePrefix // is unconditional — `scripts\build.exe --flag` strips to `build.exe`, // canonicalCommand matches exact allow, and without this guard we'd // return allow here and execute the local script. classifyCommandName // is a pure string function (no AST needed). `scripts\build.exe` → // 'application' (has `\`). Same tradeoff as step 5: `build.exe` alone // also classifies 'application' (has `.`) so legitimate executable // exact-allows downgrade to ask when pwsh is degraded — fail-safe. // Module-qualified cmdlets (Module\Cmdlet) also classify 'application' // (same `\`); same fail-safe over-fire. if ( exactMatchResult.behavior === 'allow' && !parsed.valid && preParseAskDecision === null && classifyCommandName(command.split(/\s+/)[0] ?? '') !== 'application' ) { return exactMatchResult } // 0. Check if command can be parsed - if not, require approval but don't suggest persisting // This matches Bash behavior: invalid syntax triggers a permission prompt but we don't // recommend saving invalid commands to settings // NOTE: This check is intentionally AFTER deny/ask rules so explicit rules still work // even when the parser fails (e.g., pwsh unavailable). if (!parsed.valid) { // SECURITY: Fallback sub-command deny scan for parse-failed path. // The sub-command deny loop at L851+ needs the AST; when parsing fails // (command exceeds MAX_COMMAND_LENGTH, pwsh unavailable, timeout, bad // JSON), we'd return 'ask' without ever checking sub-command deny rules. // Attack: `Get-ChildItem # <~2000 chars padding> ; Invoke-Expression evil` // → padding forces valid=false → generic ask prompt, deny(iex:*) never // fires. This fallback splits on PowerShell separators/grouping and runs // each fragment through the SAME rule matcher as step 2a (prefix deny). // Conservative: fragments inside string literals/comments may false-positive // deny — safe here (parse-failed is already a degraded state, and this is // a deny-DOWNGRADE fix). Match against full fragment (not just first token) // so multi-word rules like `Remove-Item foo:*` still fire; the matcher's // canonical resolution handles aliases (`iex` → `Invoke-Expression`). // // SECURITY: backtick is PS escape/line-continuation, NOT a separator. // Splitting on it would fragment `Invoke-Ex`pression` into non-matching // pieces. Instead: collapse backtick-newline (line continuation) so // `Invoke-Ex`pression` rejoins, strip remaining backticks (escape // chars — ``x → x), then split on actual statement/grouping separators. const backtickStripped = command .replace(/`[\r\n]+\s*/g, '') .replace(/`/g, '') for (const fragment of backtickStripped.split(/[;|\n\r{}()&]+/)) { const trimmedFrag = fragment.trim() if (!trimmedFrag) continue // skip empty fragments // Skip the full command ONLY if it starts with a cmdlet name (no // assignment prefix). The full command was already checked at 2a, but // 2a uses the raw text — $x %= iex as first token `$x` misses the // deny(iex:*) rule. If normalization would change the fragment // (assignment prefix, dot-source), don't skip — let it be re-checked // after normalization. (bug #10/#24) if ( trimmedFrag === command && !/^\$[\w:]/.test(trimmedFrag) && !/^[&.]\s/.test(trimmedFrag) ) { continue } // SECURITY: Normalize invocation-operator and assignment prefixes before // rule matching (findings #5/#22). The splitter gives us the raw fragment // text; matchingRulesForInput extracts the first token as the cmdlet name. // Without normalization: // `$x = Invoke-Expression 'p'` → first token `$x` → deny(iex:*) misses // `. Invoke-Expression 'p'` → first token `.` → deny(iex:*) misses // `& 'Invoke-Expression' 'p'` → first token `&` removed by split but // `'Invoke-Expression'` retains quotes // → deny(iex:*) misses // The parse-succeeded path handles these via AST (parser.ts:839 strips // quotes from rawNameUnstripped; invocation operators are separate AST // nodes). This fallback mirrors that normalization. // Loop strips nested assignments: $x = $y = iex → $y = iex → iex let normalized = trimmedFrag let m: RegExpMatchArray | null while ((m = normalized.match(PS_ASSIGN_PREFIX_RE))) { normalized = normalized.slice(m[0].length) } normalized = normalized.replace(/^[&.]\s+/, '') // & cmd, . cmd (dot-source) const rawFirst = normalized.split(/\s+/)[0] ?? '' const firstTok = rawFirst.replace(/^['"]|['"]$/g, '') const normalizedFrag = firstTok + normalized.slice(rawFirst.length) // SECURITY: parse-independent dangerous-removal hard-deny. The // isDangerousRemovalPath check in checkPathConstraintsForStatement // requires a valid AST; when pwsh times out or is unavailable, // `Remove-Item /` degrades from hard-deny to generic ask. Check // raw positional args here so root/home/system deletion is denied // regardless of parser availability. Conservative: only positional // args (skip -Param tokens); over-deny in degraded state is safe // (same deny-downgrade rationale as the sub-command scan above). if (resolveToCanonical(firstTok) === 'remove-item') { for (const arg of normalized.split(/\s+/).slice(1)) { if (PS_TOKENIZER_DASH_CHARS.has(arg[0] ?? '')) continue if (isDangerousRemovalRawPath(arg)) { return dangerousRemovalDeny(arg) } } } const { matchingDenyRules: fragDenyRules } = matchingRulesForInput( { command: normalizedFrag }, toolPermissionContext, 'prefix', ) if (fragDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: fragDenyRules[0] }, } } } // Preserve pre-parse ask messaging when parse fails. The deferred ask // (2b prefix rule or UNC) carries a better decisionReason than the // generic parse-error ask. Sub-command deny can't run the AST loop // without a parse, so the fallback scan above is best-effort. if (preParseAskDecision !== null) { return preParseAskDecision } const decisionReason = { type: 'other' as const, reason: `Command contains malformed syntax that cannot be parsed: ${parsed.errors[0]?.message ?? 'unknown error'}`, } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), // No suggestions - don't recommend persisting invalid syntax } } // ======================================================================== // COLLECT-THEN-REDUCE: post-parse decisions (deny > ask > allow > passthrough) // ======================================================================== // Ported from bashPermissions.ts:1446-1472. Every post-parse check pushes // its decision into a single array; a single reduce applies precedence. // This structurally closes the ask-before-deny bug class: an 'ask' from an // earlier check (security flags, provider paths, cd+git) can no longer mask // a 'deny' from a later check (sub-command deny, checkPathConstraints). // // Supersedes the firstSubCommandAskRule stash from commit 8f5ae6c56b — that // fix only patched step 4; steps 3, 3.5, 4.42 had the same flaw. The stash // pattern is also fragile: the next author who writes `return ask` is back // where we started. Collect-then-reduce makes the bypass impossible to write. // // First-of-each-behavior wins (array order = step order), so single-check // ask messages are unchanged vs. sequential-early-return. // // Pre-parse deny checks above (exact/prefix deny) stay sequential: they // fire even when pwsh is unavailable. Pre-parse asks (prefix ask, raw UNC) // are now deferred here so sub-command deny (step 4) beats them. // Gather sub-commands once (used by decisions 3, 4, and fallthrough step 5). const allSubCommands = await getSubCommandsForPermissionCheck(parsed, command) const decisions: PermissionResult[] = [] // Decision: deferred pre-parse ask (2b prefix ask or UNC path). // Pushed first so its message wins over later asks (first-of-behavior wins), // but the reduce ensures any deny in decisions[] still beats it. if (preParseAskDecision !== null) { decisions.push(preParseAskDecision) } // Decision: security check — was step 3 (:630-650). // powershellCommandIsSafe returns 'ask' for subexpressions, script blocks, // encoded commands, download cradles, etc. Only 'ask' | 'passthrough'. const safetyResult = powershellCommandIsSafe(command, parsed) if (safetyResult.behavior !== 'passthrough') { const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: safetyResult.behavior === 'ask' && safetyResult.message ? safetyResult.message : 'This command contains patterns that could pose security risks and requires approval', } decisions.push({ behavior: 'ask', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: suggestionForExactCommand(command), }) } // Decision: using statements / script requirements — invisible to AST block walk. // `using module ./evil.psm1` loads and executes a module's top-level script body; // `using assembly ./evil.dll` loads a .NET assembly (module initializers run). // `#Requires -Modules ` triggers module loading from PSModulePath. // These are siblings of the named blocks on ScriptBlockAst, not children, so // Process-BlockStatements and all downstream command walkers never see them. // Without this check, a decoy cmdlet like Get-Process fills subCommands, // bypassing the empty-statement fallback, and isReadOnlyCommand auto-allows. if (parsed.hasUsingStatements) { const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: 'Command contains a `using` statement that may load external code (module or assembly)', } decisions.push({ behavior: 'ask', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: suggestionForExactCommand(command), }) } if (parsed.hasScriptRequirements) { const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: 'Command contains a `#Requires` directive that may trigger module loading', } decisions.push({ behavior: 'ask', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: suggestionForExactCommand(command), }) } // Decision: resolved-arg provider/UNC scan — was step 3.5 (:652-709). // Provider paths (env:, HKLM:, function:) access non-filesystem resources. // UNC paths can leak NTLM/Kerberos credentials on Windows. The raw-string // UNC check above (pre-parse) misses backtick-escaped forms; cmd.args has // backtick escapes resolved by the parser. Labeled loop breaks on FIRST // match (same as the previous early-return). // Provider prefix matches both the short form (`env:`, `HKLM:`) and the // fully-qualified form (`Microsoft.PowerShell.Core\Registry::HKLM\...`). // The optional `(?:[\w.]+\\)?` handles the module-qualified prefix; `::?` // matches either single-colon drive syntax or double-colon provider syntax. const NON_FS_PROVIDER_PATTERN = /^(?:[\w.]+\\)?(env|hklm|hkcu|function|alias|variable|cert|wsman|registry)::?/i function extractProviderPathFromArg(arg: string): string { // Handle colon parameter syntax: -Path:env:HOME → extract 'env:HOME'. // SECURITY: PowerShell's tokenizer accepts en-dash/em-dash/horizontal-bar // (U+2013/2014/2015) as parameter prefixes. `–Path:env:HOME` (en-dash) // must also strip the `–Path:` prefix or NON_FS_PROVIDER_PATTERN won't // match (pattern is `^(env|...):` which fails on `–Path:env:...`). let s = arg if (s.length > 0 && PS_TOKENIZER_DASH_CHARS.has(s[0]!)) { const colonIdx = s.indexOf(':', 1) // skip the leading dash if (colonIdx > 0) { s = s.substring(colonIdx + 1) } } // Strip backtick escapes before matching: `Registry`::HKLM\...` has a // backtick before `::` that the PS tokenizer removes at runtime but that // would otherwise prevent the ^-anchored pattern from matching. return s.replace(/`/g, '') } function providerOrUncDecisionForArg(arg: string): PermissionResult | null { const value = extractProviderPathFromArg(arg) if (NON_FS_PROVIDER_PATTERN.test(value)) { return { behavior: 'ask', message: `Command argument '${arg}' uses a non-filesystem provider path and requires approval`, } } if (containsVulnerableUncPath(value)) { return { behavior: 'ask', message: `Command argument '${arg}' contains a UNC path that could trigger network requests`, } } return null } providerScan: for (const statement of parsed.statements) { for (const cmd of statement.commands) { if (cmd.elementType !== 'CommandAst') continue for (const arg of cmd.args) { const decision = providerOrUncDecisionForArg(arg) if (decision !== null) { decisions.push(decision) break providerScan } } } if (statement.nestedCommands) { for (const cmd of statement.nestedCommands) { for (const arg of cmd.args) { const decision = providerOrUncDecisionForArg(arg) if (decision !== null) { decisions.push(decision) break providerScan } } } } } // Decision: per-sub-command deny/ask rules — was step 4 (:711-803). // Each sub-command produces at most one decision (deny or ask). Deny rules // on LATER sub-commands still beat ask rules on EARLIER ones via the reduce. // No stash needed — the reduce structurally enforces deny > ask. // // SECURITY: Always build a canonical command string from AST-derived data // (element.name + space-joined args) and check rules against it too. Deny // and allow must use the same normalized form to close asymmetries: // - Invocation operators (`& 'Remove-Item' ./x`): raw text starts with `&`, // splitting on whitespace yields the operator, not the cmdlet name. // - Non-space whitespace (`rm\t./x`): raw prefix match uses `prefix + ' '` // (literal space), but PowerShell accepts any whitespace separator. // checkPermissionMode auto-allow (using AST cmd.name) WOULD match while // deny-rule match on raw text would miss — a deny-rule bypass. // - Module prefixes (`Microsoft.PowerShell.Management\Remove-Item`): // element.name has the module prefix stripped. for (const { text: subCmd, element } of allSubCommands) { // element.name is quote-stripped at the parser (transformCommandAst) so // `& 'Invoke-Expression' 'x'` yields name='Invoke-Expression', not // "'Invoke-Expression'". canonicalSubCmd is built from the same stripped // name, so deny-rule prefix matching on `Invoke-Expression:*` hits. const canonicalSubCmd = element.name !== '' ? [element.name, ...element.args].join(' ') : null const subInput = { command: subCmd } const { matchingDenyRules: subDenyRules, matchingAskRules: subAskRules } = matchingRulesForInput(subInput, toolPermissionContext, 'prefix') let matchedDenyRule = subDenyRules[0] let matchedAskRule = subAskRules[0] if (matchedDenyRule === undefined && canonicalSubCmd !== null) { const { matchingDenyRules: canonicalDenyRules, matchingAskRules: canonicalAskRules, } = matchingRulesForInput( { command: canonicalSubCmd }, toolPermissionContext, 'prefix', ) matchedDenyRule = canonicalDenyRules[0] if (matchedAskRule === undefined) { matchedAskRule = canonicalAskRules[0] } } if (matchedDenyRule !== undefined) { decisions.push({ behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchedDenyRule, }, }) } else if (matchedAskRule !== undefined) { decisions.push({ behavior: 'ask', message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), decisionReason: { type: 'rule', rule: matchedAskRule, }, }) } } // Decision: cd+git compound guard — was step 4.42 (:805-833). // When cd/Set-Location is paired with git, don't allow without prompting — // cd to a malicious directory makes git dangerous (fake hooks, bare repo // attacks). Collect-then-reduce keeps the improvement over BashTool: in // bash, cd+git (B9, line 1416) runs BEFORE sub-command deny (B11), so cd+git // ask masks deny. Here, both are in the same decision array; deny wins. // // SECURITY: NO cd-to-CWD no-op exclusion. A previous iteration excluded // `Set-Location .` as a no-op, but the "first non-dash arg" heuristic used // to extract the target is fooled by colon-bound params: // `Set-Location -Path:/etc .` — real target is /etc, heuristic sees `.`, // exclusion fires, bypass. The UX case (model emitting `Set-Location .; foo`) // is rare; the attack surface isn't worth the special-case. Any cd-family // cmdlet in the compound sets this flag, period. // Only flag compound cd when there are multiple sub-commands. A standalone // `Set-Location ./subdir` is not a TOCTOU risk (no later statement resolves // relative paths against stale cwd). Without this, standalone cd forces the // compound guard, suppressing the per-subcommand auto-allow path. (bug #25) const hasCdSubCommand = allSubCommands.length > 1 && allSubCommands.some(({ element }) => isCwdChangingCmdlet(element.name)) // Symlink-create compound guard (finding #18 / bug 001+004): when the // compound creates a filesystem link, subsequent writes through that link // land outside the validator's view. Same TOCTOU shape as cwd desync. const hasSymlinkCreate = allSubCommands.length > 1 && allSubCommands.some(({ element }) => isSymlinkCreatingCommand(element)) const hasGitSubCommand = allSubCommands.some( ({ element }) => resolveToCanonical(element.name) === 'git', ) if (hasCdSubCommand && hasGitSubCommand) { decisions.push({ behavior: 'ask', message: 'Compound commands with cd/Set-Location and git require approval to prevent bare repository attacks', }) } // cd+write compound guard — SUBSUMED by checkPathConstraints(compoundCommandHasCd). // Previously this block pushed 'ask' when hasCdSubCommand && hasAcceptEditsWrite, // but checkPathConstraints now receives hasCdSubCommand and pushes 'ask' for ANY // path operation (read or write) in a cd-compound — broader coverage at the path // layer (BashTool parity). The step-5 !hasCdSubCommand gates and modeValidation's // compound-cd guard remain as defense-in-depth for paths that don't reach // checkPathConstraints (e.g., cmdlets not in CMDLET_PATH_CONFIG). // Decision: bare-git-repo guard — bash parity. // If cwd has HEAD/objects/refs/ without a valid .git/HEAD, Git treats // cwd as a bare repository and runs hooks from cwd. Attacker creates // hooks/pre-commit, deletes .git/HEAD, then any git subcommand runs it. // Port of BashTool readOnlyValidation.ts isCurrentDirectoryBareGitRepo. if (hasGitSubCommand && isCurrentDirectoryBareGitRepo()) { decisions.push({ behavior: 'ask', message: 'Git command in a directory with bare-repository indicators (HEAD, objects/, refs/ in cwd without .git/HEAD). Git may execute hooks from cwd.', }) } // Decision: git-internal-paths write guard — bash parity. // Compound command creates HEAD/objects/refs/hooks/ then runs git → the // git subcommand executes freshly-created malicious hooks. Check all // extracted write paths + redirection targets against git-internal patterns. // Port of BashTool commandWritesToGitInternalPaths, adapted for AST. if (hasGitSubCommand) { const writesToGitInternal = allSubCommands.some( ({ element, statement }) => { // Redirection targets on this sub-command (raw Extent.Text — quotes // and ./ intact; normalizer handles both) for (const r of element.redirections ?? []) { if (isGitInternalPathPS(r.target)) return true } // Write cmdlet args (new-item HEAD; mkdir hooks; set-content hooks/pre-commit) const canonical = resolveToCanonical(element.name) if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false // Raw arg text — normalizer strips colon-bound params, quotes, ./, case. // PS ArrayLiteralAst (`New-Item a,hooks/pre-commit`) surfaces as a single // comma-joined arg — split before checking. if ( element.args .flatMap(a => a.split(',')) .some(a => isGitInternalPathPS(a)) ) { return true } // Pipeline input: `"hooks/pre-commit" | New-Item -ItemType File` binds the // string to -Path at runtime. The path is in a non-CommandAst pipeline // element, not in element.args. The hasExpressionSource guard at step 5 // already forces approval here; this check just adds the git-internal // warning text. if (statement !== null) { for (const c of statement.commands) { if (c.elementType === 'CommandAst') continue if (isGitInternalPathPS(c.text)) return true } } return false }, ) // Also check top-level file redirections (> hooks/pre-commit) const redirWritesToGitInternal = getFileRedirections(parsed).some(r => isGitInternalPathPS(r.target), ) if (writesToGitInternal || redirWritesToGitInternal) { decisions.push({ behavior: 'ask', message: 'Command writes to a git-internal path (HEAD, objects/, refs/, hooks/, .git/) and runs git. This could plant a malicious hook that git then executes.', }) } // SECURITY: Archive-extraction TOCTOU. isCurrentDirectoryBareGitRepo // checks at permission-eval time; `tar -xf x.tar; git status` extracts // bare-repo indicators AFTER the check, BEFORE git runs. Unlike write // cmdlets (where we inspect args for git-internal paths), archive // contents are opaque — any extraction in a compound with git must ask. const hasArchiveExtractor = allSubCommands.some(({ element }) => GIT_SAFETY_ARCHIVE_EXTRACTORS.has(element.name.toLowerCase()), ) if (hasArchiveExtractor) { decisions.push({ behavior: 'ask', message: 'Compound command extracts an archive and runs git. Archive contents may plant bare-repository indicators (HEAD, hooks/, refs/) that git then treats as the repository root.', }) } } // .git/ writes are dangerous even WITHOUT a git subcommand — a planted // .git/hooks/pre-commit fires on the user's next commit. Unlike the // bare-repo check above (which gates on hasGitSubCommand because `hooks/` // is a common project dirname), `.git/` is unambiguous. { const found = allSubCommands.some(({ element }) => { for (const r of element.redirections ?? []) { if (isDotGitPathPS(r.target)) return true } const canonical = resolveToCanonical(element.name) if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false return element.args.flatMap(a => a.split(',')).some(isDotGitPathPS) }) || getFileRedirections(parsed).some(r => isDotGitPathPS(r.target)) if (found) { decisions.push({ behavior: 'ask', message: 'Command writes to .git/ — hooks or config planted there execute on the next git operation.', }) } } // Decision: path constraints — was step 4.44 (:835-845). // The deny-capable check that was being masked by earlier asks. Returns // 'deny' when an Edit(...) deny rule matches an extracted path (pathValidation // lines ~994, 1088, 1160, 1210), 'ask' for paths outside working dirs, or // 'passthrough'. // // Thread hasCdSubCommand (BashTool compoundCommandHasCd parity): when the // compound contains a cwd-changing cmdlet, checkPathConstraints forces 'ask' // for any statement with path operations — relative paths resolve against the // stale validator cwd, not PowerShell's runtime cwd. This is the architectural // fix for the CWD-desync cluster (findings #3/#21/#27/#28), replacing the // per-auto-allow-site guards with a single gate at the path-resolution layer. const pathResult = checkPathConstraints( input, parsed, toolPermissionContext, hasCdSubCommand, ) if (pathResult.behavior !== 'passthrough') { decisions.push(pathResult) } // Decision: exact allow (parse-succeeded case) — was step 4.45 (:861-867). // Matches BashTool ordering: sub-command deny → path constraints → exact // allow. Reduce enforces deny > ask > allow, so the exact allow only // surfaces when no deny or ask fired — same as sequential. // // SECURITY: nameType gate — mirrors the parse-failed guard at L696-700. // Input-side stripModulePrefix is unconditional: `scripts\Get-Content` // strips to `Get-Content`, canonicalCommand matches exact allow. Without // this gate, allow enters decisions[] and reduce returns it before step 5 // can inspect nameType — PowerShell runs the local .ps1 file. The AST's // nameType for the first command element is authoritative when parse // succeeded; 'application' means a script/executable path, not a cmdlet. // SECURITY: Same argLeaksValue gate as the per-subcommand loop below // (finding #32). Without it, `PowerShell(Write-Output:*)` exact-matches // `Write-Output $env:ANTHROPIC_API_KEY`, pushes allow to decisions[], and // reduce returns it before the per-subcommand gate ever runs. The // allSubCommands.every check ensures NO command in the statement leaks // (a single-command exact-allow has one element; a pipeline has several). // // SECURITY: nameType gate must check ALL subcommands, not just [0] // (finding #10). canonicalCommand at L171 collapses `\n` → space, so // `code\n.\build.ps1` (two statements) matches exact rule // `PowerShell(code .\build.ps1)`. Checking only allSubCommands[0] lets the // second statement (nameType=application, a script path) through. Require // EVERY subcommand to have nameType !== 'application'. if ( exactMatchResult.behavior === 'allow' && allSubCommands[0] !== undefined && allSubCommands.every( sc => sc.element.nameType !== 'application' && !argLeaksValue(sc.text, sc.element), ) ) { decisions.push(exactMatchResult) } // Decision: read-only allowlist — was step 4.5 (:869-885). // Mirrors Bash auto-allow for ls, cat, git status, etc. PowerShell // equivalents: Get-Process, Get-ChildItem, Get-Content, git log, etc. // Reduce places this below sub-command ask rules (ask > allow). if (isReadOnlyCommand(command, parsed)) { decisions.push({ behavior: 'allow', updatedInput: input, decisionReason: { type: 'other', reason: 'Command is read-only and safe to execute', }, }) } // Decision: file redirections — was :887-900. // Redirections (>, >>, 2>) write to arbitrary paths. isReadOnlyCommand // already rejects redirections internally so this can't conflict with the // read-only allow above. Reduce places it above checkPermissionMode allow. const fileRedirections = getFileRedirections(parsed) if (fileRedirections.length > 0) { decisions.push({ behavior: 'ask', message: 'Command contains file redirections that could write to arbitrary paths', suggestions: suggestionForExactCommand(command), }) } // Decision: mode-specific handling (acceptEdits) — was step 4.7 (:902-906). // checkPermissionMode only returns 'allow' | 'passthrough'. const modeResult = checkPermissionMode(input, parsed, toolPermissionContext) if (modeResult.behavior !== 'passthrough') { decisions.push(modeResult) } // REDUCE: deny > ask > allow > passthrough. First of each behavior type // wins (preserves step-order messaging for single-check cases). If nothing // decided, fall through to step 5 per-sub-command approval collection. const deniedDecision = decisions.find(d => d.behavior === 'deny') if (deniedDecision !== undefined) { return deniedDecision } const askDecision = decisions.find(d => d.behavior === 'ask') if (askDecision !== undefined) { return askDecision } const allowDecision = decisions.find(d => d.behavior === 'allow') if (allowDecision !== undefined) { return allowDecision } // 5. Pipeline/statement splitting: check each sub-command independently. // This prevents a prefix rule like "Get-Process:*" from silently allowing // piped commands like "Get-Process | Stop-Process -Force". // Note: deny rules are already checked above (4.4), so this loop handles // ask rules, explicit allow rules, and read-only allowlist fallback. // Filter out safe output cmdlets (Format-Table, etc.) — they were checked // for deny rules in step 4.4 but shouldn't need independent approval here. // Also filter out cd/Set-Location to CWD (model habit, Bash parity). const subCommands = allSubCommands.filter(({ element, isSafeOutput }) => { if (isSafeOutput) { return false } // SECURITY: nameType gate — sixth location. Filtering out of the approval // list is a form of auto-allow. scripts\\Set-Location . would match below // (stripped name 'Set-Location', arg '.' → CWD) and be silently dropped, // then scripts\\Set-Location.ps1 executes with no prompt. Keep 'application' // commands in the list so they reach isAllowlistedCommand (which rejects them). if (element.nameType === 'application') { return true } const canonical = resolveToCanonical(element.name) if (canonical === 'set-location' && element.args.length > 0) { // SECURITY: use PS_TOKENIZER_DASH_CHARS, not ASCII-only startsWith('-'). // `Set-Location –Path .` (en-dash) would otherwise treat `–Path` as the // target, resolve it against cwd (mismatch), and keep the command in the // approval list — correct. But `Set-Location –LiteralPath evil` with // en-dash would find `–LiteralPath` as "target", mismatch cwd, stay in // list — also correct. The risk is the inverse: a Unicode-dash parameter // being treated as the positional target. Use the tokenizer dash set. const target = element.args.find( a => a.length === 0 || !PS_TOKENIZER_DASH_CHARS.has(a[0]!), ) if (target && resolve(getCwd(), target) === getCwd()) { return false } } return true }) // Note: cd+git compound guard already ran at step 4.42. If we reach here, // either there's no cd or no git in the compound. const subCommandsNeedingApproval: string[] = [] // Statements whose sub-commands were PUSHED to subCommandsNeedingApproval // in the step-5 loop below. The fail-closed gate (after the loop) only // pushes statements NOT tracked here — prevents duplicate suggestions where // both "Get-Process" (sub-command) AND "$x = Get-Process" (full statement) // appear. // // SECURITY: track on PUSH only, not on loop entry. // If a statement's only sub-commands `continue` via user allow rules // (L1113), marking it seen at loop-entry would make the fail-closed gate // skip it — auto-allowing invisible non-CommandAst content like bare // `$env:SECRET` inside control flow. Example attack: user approves // Get-Process, then `if ($true) { Get-Process; $env:SECRET }` — Get-Process // is allow-ruled (continue, no push), $env:SECRET is VariableExpressionAst // (not a sub-command), statement marked seen → gate skips → auto-allow → // secret leaks. Tracking on push only: statement stays unseen → gate fires // → ask. const statementsSeenInLoop = new Set< ParsedPowerShellCommand['statements'][number] >() for (const { text: subCmd, element, statement } of subCommands) { // Check deny rules FIRST - user explicit rules take precedence over allowlist const subInput = { command: subCmd } const subResult = powershellToolCheckPermission( subInput, toolPermissionContext, ) if (subResult.behavior === 'deny') { return { behavior: 'deny', message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, decisionReason: subResult.decisionReason, } } if (subResult.behavior === 'ask') { if (statement !== null) { statementsSeenInLoop.add(statement) } subCommandsNeedingApproval.push(subCmd) continue } // Explicitly allowed by a user rule — BUT NOT for applications/scripts. // SECURITY: INPUT-side stripModulePrefix is unconditional, so // `scripts\Get-Content /etc/shadow` strips to 'Get-Content' and matches // an allow rule `Get-Content:*`. Without the nameType guard, continue // skips all checks and the local script runs. nameType is classified from // the RAW name pre-strip — `scripts\Get-Content` → 'application' (has `\`). // Module-qualified cmdlets also classify 'application' — fail-safe over-fire. // An application should NEVER be auto-allowed by a cmdlet allow rule. if ( subResult.behavior === 'allow' && element.nameType !== 'application' && !hasSymlinkCreate ) { // SECURITY: User allow rule asserts the cmdlet is safe, NOT that // arbitrary variable expansion through it is safe. A user who allows // PowerShell(Write-Output:*) did not intend to auto-allow // `Write-Output $env:ANTHROPIC_API_KEY`. Apply the same argLeaksValue // gate that protects the built-in allowlist path below — rejects // Variable/Other/ScriptBlock/SubExpression elementTypes and colon-bound // expression children. (security finding #32) // // SECURITY: Also skip when the compound contains a symlink-creating // command (finding — symlink+read gap). New-Item -ItemType SymbolicLink // can redirect subsequent reads to arbitrary paths. The built-in // allowlist path (below) and acceptEdits path both gate on // !hasSymlinkCreate; the user-rule path must too. if (argLeaksValue(subCmd, element)) { if (statement !== null) { statementsSeenInLoop.add(statement) } subCommandsNeedingApproval.push(subCmd) continue } continue } if (subResult.behavior === 'allow') { // nameType === 'application' with a matching allow rule: the rule was // written for a cmdlet, but this is a script/executable masquerading. // Don't continue; fall through to approval (NOT deny — the user may // actually want to run `scripts\Get-Content` and will see a prompt). if (statement !== null) { statementsSeenInLoop.add(statement) } subCommandsNeedingApproval.push(subCmd) continue } // SECURITY: fail-closed gate. Do NOT take the allowlist shortcut unless // the parent statement is a PipelineAst where every element is a // CommandAst. This subsumes the previous hasExpressionSource check // (expression sources are one way a statement fails the gate) and also // rejects assignments, chain operators, control flow, and any future // AST type by construction. Examples this blocks: // 'env:SECRET_API_KEY' | Get-Content — CommandExpressionAst element // $x = Get-Process — AssignmentStatementAst // Get-Process && Get-Service — PipelineChainAst // Explicit user allow rules (above) run before this gate but apply their // own argLeaksValue check; both paths now gate argument elementTypes. // // SECURITY: Also skip when the compound contains a cwd-changing cmdlet // (finding #27 — cd+read gap). isAllowlistedCommand validates Get-Content // in isolation, but `Set-Location ~; Get-Content ./.ssh/id_rsa` runs // Get-Content from ~, not from the validator's cwd. Path validation saw // /project/.ssh/id_rsa; runtime reads ~/.ssh/id_rsa. Same gate as the // checkPermissionMode call below and the checkPathConstraints threading. if ( statement !== null && !hasCdSubCommand && !hasSymlinkCreate && isProvablySafeStatement(statement) && isAllowlistedCommand(element, subCmd) ) { continue } // Check per-sub-command acceptEdits mode (BashTool parity). // Delegate to checkPermissionMode on a single-statement AST so that ALL // of its guards apply: expression pipeline sources (non-CommandAst elements), // security flags (subexpressions, script blocks, assignments, splatting, etc.), // and the ACCEPT_EDITS_ALLOWED_CMDLETS allowlist. This keeps one source of // truth for what makes a statement safe in acceptEdits mode — any future // hardening of checkPermissionMode automatically applies here. // // Pass parsed.variables (not []) so splatting from any statement in the // compound command is visible. Conservative: if we can't tell which statement // a splatted variable affects, assume it affects all of them. // // SECURITY: Skip this auto-allow path when the compound contains a // cwd-changing command (Set-Location/Push-Location/Pop-Location). The // synthetic single-statement AST strips compound context, so // checkPermissionMode cannot see the cd in other statements. Without this // gate, `Set-Location ./.claude; Set-Content ./settings.json '...'` would // pass: Set-Content is checked in isolation, matches ACCEPT_EDITS_ALLOWED_CMDLETS, // and auto-allows — but PowerShell runs it from the changed cwd, writing to // .claude/settings.json (a Claude config file the path validator didn't check). // This matches BashTool's compoundCommandHasCd guard. if (statement !== null && !hasCdSubCommand && !hasSymlinkCreate) { const subModeResult = checkPermissionMode( { command: subCmd }, { valid: true, errors: [], variables: parsed.variables, hasStopParsing: parsed.hasStopParsing, originalCommand: subCmd, statements: [statement], }, toolPermissionContext, ) if (subModeResult.behavior === 'allow') { continue } } // Not allowlisted, no mode auto-allow, and no explicit rule — needs approval if (statement !== null) { statementsSeenInLoop.add(statement) } subCommandsNeedingApproval.push(subCmd) } // SECURITY: fail-closed gate (second half). The step-5 loop above only // iterates sub-commands that getSubCommandsForPermissionCheck surfaced // AND survived the safe-output filter. Statements that produce zero // CommandAst sub-commands (bare $env:SECRET) or whose only sub-commands // were filtered as safe-output ($env:X | Out-String) never enter the loop. // Without this, they silently auto-allow on empty subCommandsNeedingApproval. // // Only push statements NOT tracked above: if the loop PUSHED any // sub-command from a statement, the user will see a prompt. Pushing the // statement text too creates a duplicate suggestion where accepting the // sub-command rule does not prevent re-prompting. // If all sub-commands `continue`d (allow-ruled / allowlisted / mode-allowed) // the statement is NOT tracked and the gate re-checks it below — this is // the fail-closed property. for (const stmt of parsed.statements) { if (!isProvablySafeStatement(stmt) && !statementsSeenInLoop.has(stmt)) { subCommandsNeedingApproval.push(stmt.text) } } if (subCommandsNeedingApproval.length === 0) { // SECURITY: empty-list auto-allow is only safe when there's nothing // unverifiable. If the pipeline has script blocks, every safe-output // cmdlet was filtered at :1032, but the block content wasn't verified — // non-command AST nodes (AssignmentStatementAst etc.) are invisible to // getAllCommands. `Where-Object {$true} | Sort-Object {$env:PATH='evil'}` // would auto-allow here. hasAssignments is top-level-only (parser.ts:1385) // so it doesn't catch nested assignments either. Prompt instead. if (deriveSecurityFlags(parsed).hasScriptBlocks) { return { behavior: 'ask', message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), decisionReason: { type: 'other', reason: 'Pipeline consists of output-formatting cmdlets with script blocks — block content cannot be verified', }, } } return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'other', reason: 'All pipeline commands are individually allowed', }, } } // 6. Some sub-commands need approval — build suggestions const decisionReason = { type: 'other' as const, reason: 'This command requires approval', } const pendingSuggestions: PermissionUpdate[] = [] for (const subCmd of subCommandsNeedingApproval) { pendingSuggestions.push(...suggestionForExactCommand(subCmd)) } return { behavior: 'passthrough', message: createPermissionRequestMessage( POWERSHELL_TOOL_NAME, decisionReason, ), decisionReason, suggestions: pendingSuggestions, } }