/** * PowerShell permission mode validation. * * Checks if commands should be auto-allowed based on the current permission mode. * In acceptEdits mode, filesystem-modifying PowerShell cmdlets are auto-allowed. * Follows the same patterns as BashTool/modeValidation.ts. */ import type { ToolPermissionContext } from '../../Tool.js' import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' import type { ParsedPowerShellCommand } from '../../utils/powershell/parser.js' import { deriveSecurityFlags, getPipelineSegments, PS_TOKENIZER_DASH_CHARS, } from '../../utils/powershell/parser.js' import { argLeaksValue, isAllowlistedPipelineTail, isCwdChangingCmdlet, isSafeOutputCommand, resolveToCanonical, } from './readOnlyValidation.js' /** * Filesystem-modifying cmdlets that are auto-allowed in acceptEdits mode. * Stored as canonical (lowercase) cmdlet names. * * Tier 3 cmdlets with complex parameter binding removed — they fall through to * 'ask'. Only simple write cmdlets (first positional = -Path) are auto-allowed * here, and they get path validation via CMDLET_PATH_CONFIG in pathValidation.ts. */ const ACCEPT_EDITS_ALLOWED_CMDLETS = new Set([ 'set-content', 'add-content', 'remove-item', 'clear-content', ]) function isAcceptEditsAllowedCmdlet(name: string): boolean { // resolveToCanonical handles aliases via COMMON_ALIASES, so e.g. 'rm' → 'remove-item', // 'ac' → 'add-content'. Any alias that resolves to an allowed cmdlet is automatically // allowed. Tier 3 cmdlets (new-item, copy-item, move-item, etc.) and their aliases // (mkdir, ni, cp, mv, etc.) resolve to cmdlets NOT in the set and fall through to 'ask'. const canonical = resolveToCanonical(name) return ACCEPT_EDITS_ALLOWED_CMDLETS.has(canonical) } /** * New-Item -ItemType values that create filesystem links (reparse points or * hard links). All three redirect path resolution at runtime — symbolic links * and junctions are directory/file reparse points; hard links alias a file's * inode. Any of these let a later relative-path write land outside the * validator's view. */ const LINK_ITEM_TYPES = new Set(['symboliclink', 'junction', 'hardlink']) /** * Check if a lowered, dash-normalized arg (colon-value stripped) is an * unambiguous PowerShell abbreviation of New-Item's -ItemType or -Type param. * Min prefixes: `-it` (avoids ambiguity with other New-Item params), `-ty` * (avoids `-t` colliding with `-Target`). */ function isItemTypeParamAbbrev(p: string): boolean { return ( (p.length >= 3 && '-itemtype'.startsWith(p)) || (p.length >= 3 && '-type'.startsWith(p)) ) } /** * Detects New-Item creating a filesystem link (-ItemType SymbolicLink / * Junction / HardLink, or the -Type alias). Links poison subsequent path * resolution the same way Set-Location/New-PSDrive do: a relative path * through the link resolves to the link target, not the validator's view. * Finding #18. * * Handles PS parameter abbreviation (`-it`, `-ite`, ... `-itemtype`; `-ty`, * `-typ`, `-type`), unicode dash prefixes (en-dash/em-dash/horizontal-bar), * and colon-bound values (`-it:Junction`). */ export function isSymlinkCreatingCommand(cmd: { name: string args: string[] }): boolean { const canonical = resolveToCanonical(cmd.name) if (canonical !== 'new-item') return false for (let i = 0; i < cmd.args.length; i++) { const raw = cmd.args[i] ?? '' if (raw.length === 0) continue // Normalize unicode dash prefixes (–, —, ―) and forward-slash (PS 5.1 // parameter prefix) → ASCII `-` so prefix comparison works. PS tokenizer // treats all four dash chars plus `/` as parameter markers. (bug #26) const normalized = PS_TOKENIZER_DASH_CHARS.has(raw[0]!) || raw[0] === '/' ? '-' + raw.slice(1) : raw const lower = normalized.toLowerCase() // Split colon-bound value: -it:SymbolicLink → param='-it', val='symboliclink' const colonIdx = lower.indexOf(':', 1) const paramRaw = colonIdx > 0 ? lower.slice(0, colonIdx) : lower // Strip backtick escapes: -Item`Type → -ItemType (bug #22) const param = paramRaw.replace(/`/g, '') if (!isItemTypeParamAbbrev(param)) continue const rawVal = colonIdx > 0 ? lower.slice(colonIdx + 1) : (cmd.args[i + 1]?.toLowerCase() ?? '') // Strip backtick escapes from colon-bound value: -it:Sym`bolicLink → symboliclink // Mirrors the param-name strip at L103. Space-separated args use .value // (backtick-resolved by .NET parser), but colon-bound uses .text (raw source). // Strip surrounding quotes: -it:'SymbolicLink' or -it:"Junction" (bug #6) const val = rawVal.replace(/`/g, '').replace(/^['"]|['"]$/g, '') if (LINK_ITEM_TYPES.has(val)) return true } return false } /** * Checks if commands should be handled differently based on the current permission mode. * * In acceptEdits mode, auto-allows filesystem-modifying PowerShell cmdlets. * Uses the AST to resolve aliases before checking the allowlist. * * @param input - The PowerShell command input * @param parsed - The parsed AST of the command * @param toolPermissionContext - Context containing mode and permissions * @returns * - 'allow' if the current mode permits auto-approval * - 'passthrough' if no mode-specific handling applies */ export function checkPermissionMode( input: { command: string }, parsed: ParsedPowerShellCommand, toolPermissionContext: ToolPermissionContext, ): PermissionResult { // Skip bypass and dontAsk modes (handled elsewhere) if ( toolPermissionContext.mode === 'bypassPermissions' || toolPermissionContext.mode === 'dontAsk' ) { return { behavior: 'passthrough', message: 'Mode is handled in main permission flow', } } if (toolPermissionContext.mode !== 'acceptEdits') { return { behavior: 'passthrough', message: 'No mode-specific validation required', } } // acceptEdits mode: check if all commands are filesystem-modifying cmdlets if (!parsed.valid) { return { behavior: 'passthrough', message: 'Cannot validate mode for unparsed command', } } // SECURITY: Check for subexpressions, script blocks, or member invocations // that could be used to smuggle arbitrary code through acceptEdits mode. const securityFlags = deriveSecurityFlags(parsed) if ( securityFlags.hasSubExpressions || securityFlags.hasScriptBlocks || securityFlags.hasMemberInvocations || securityFlags.hasSplatting || securityFlags.hasAssignments || securityFlags.hasStopParsing || securityFlags.hasExpandableStrings ) { return { behavior: 'passthrough', message: 'Command contains subexpressions, script blocks, or member invocations that require approval', } } const segments = getPipelineSegments(parsed) // SECURITY: Empty segments with valid parse = no commands to check, don't auto-allow if (segments.length === 0) { return { behavior: 'passthrough', message: 'No commands found to validate for acceptEdits mode', } } // SECURITY: Compound cwd desync guard — BashTool parity. // When any statement in a compound contains Set-Location/Push-Location/Pop-Location // (or aliases like cd, sl, chdir, pushd, popd), the cwd changes between statements. // Path validation resolves relative paths against the stale process cwd, so a write // cmdlet in a later statement targets a different directory than the validator checked. // Example: `Set-Location ./.claude; Set-Content ./settings.json '...'` — the validator // sees ./settings.json as /project/settings.json, but PowerShell writes to // /project/.claude/settings.json. Refuse to auto-allow any write operation in a // compound that contains a cwd-changing command. This matches BashTool's // compoundCommandHasCd guard (BashTool/pathValidation.ts:630-655). const totalCommands = segments.reduce( (sum, seg) => sum + seg.commands.length, 0, ) if (totalCommands > 1) { let hasCdCommand = false let hasSymlinkCreate = false let hasWriteCommand = false for (const seg of segments) { for (const cmd of seg.commands) { if (cmd.elementType !== 'CommandAst') continue if (isCwdChangingCmdlet(cmd.name)) hasCdCommand = true if (isSymlinkCreatingCommand(cmd)) hasSymlinkCreate = true if (isAcceptEditsAllowedCmdlet(cmd.name)) hasWriteCommand = true } } if (hasCdCommand && hasWriteCommand) { return { behavior: 'passthrough', message: 'Compound command contains a directory-changing command (Set-Location/Push-Location/Pop-Location) with a write operation — cannot auto-allow because path validation uses stale cwd', } } // SECURITY: Link-create compound guard (finding #18). Mirrors the cd // guard above. `New-Item -ItemType SymbolicLink -Path ./link -Value /etc; // Get-Content ./link/passwd` — path validation resolves ./link/passwd // against cwd (no link there at validation time), but runtime follows // the just-created link to /etc/passwd. Same TOCTOU shape as cwd desync. // Applies to SymbolicLink, Junction, and HardLink — all three redirect // path resolution at runtime. // No `hasWriteCommand` requirement: read-through-symlink is equally // dangerous (exfil via Get-Content ./link/etc/shadow), and any other // command using paths after a just-created link is unvalidatable. if (hasSymlinkCreate) { return { behavior: 'passthrough', message: 'Compound command creates a filesystem link (New-Item -ItemType SymbolicLink/Junction/HardLink) — cannot auto-allow because path validation cannot follow just-created links', } } } for (const segment of segments) { for (const cmd of segment.commands) { if (cmd.elementType !== 'CommandAst') { // SECURITY: This guard is load-bearing for THREE cases. Do not narrow it. // // 1. Expression pipeline sources (designed): '/etc/passwd' | Remove-Item // — the string literal is CommandExpressionAst, piped value binds to // -Path. We cannot statically know what path it represents. // // 2. Control-flow statements (accidental but relied upon): // foreach ($x in ...) { Remove-Item $x }. Non-PipelineAst statements // produce a synthetic CommandExpressionAst entry in segment.commands // (parser.ts transformStatement). Without this guard, Remove-Item $x // in nestedCommands would be checked below and auto-allowed — but $x // is a loop-bound variable we cannot validate. // // 3. Non-PipelineAst redirection coverage (accidental): cmd && cmd2 > /tmp // also produces a synthetic element here. isReadOnlyCommand relies on // the same accident (its allowlist rejects the synthetic element's // full-text name), so both paths fail safe together. return { behavior: 'passthrough', message: `Pipeline contains expression source (${cmd.elementType}) that cannot be statically validated`, } } // SECURITY: nameType is computed from the raw name before stripModulePrefix. // 'application' = raw name had path chars (. \\ /). scripts\\Remove-Item // strips to Remove-Item and would match ACCEPT_EDITS_ALLOWED_CMDLETS below, // but PowerShell runs scripts\\Remove-Item.ps1. Same gate as isAllowlistedCommand. if (cmd.nameType === 'application') { return { behavior: 'passthrough', message: `Command '${cmd.name}' resolved from a path-like name and requires approval`, } } // SECURITY: elementTypes whitelist — same as isAllowlistedCommand. // deriveSecurityFlags above checks hasSubExpressions/etc. but does NOT // flag bare Variable/Other elementTypes. `Remove-Item $env:PATH`: // elementTypes = ['StringConstant', 'Variable'] // deriveSecurityFlags: no subexpression → passes // checkPathConstraints: resolves literal text '$env:PATH' as relative // path → cwd/$env:PATH → inside cwd → allow // RUNTIME: PowerShell expands $env:PATH → deletes actual env value path // isAllowlistedCommand rejects non-StringConstant/Parameter; this is the // acceptEdits parity gate. // // Also check colon-bound expression metachars (same as isAllowlistedCommand's // colon-bound check). `Remove-Item -Path:(1 > /tmp/x)`: // elementTypes = ['StringConstant', 'Parameter'] — passes whitelist above // deriveSecurityFlags: ParenExpressionAst in .Argument not detected by // Get-SecurityPatterns (ParenExpressionAst not in FindAll filter) // checkPathConstraints: literal text '-Path:(1 > /tmp/x)' not a path // RUNTIME: paren evaluates, redirection writes /tmp/x → arbitrary write if (cmd.elementTypes) { for (let i = 1; i < cmd.elementTypes.length; i++) { const t = cmd.elementTypes[i] if (t !== 'StringConstant' && t !== 'Parameter') { return { behavior: 'passthrough', message: `Command argument has unvalidatable type (${t}) — variable paths cannot be statically resolved`, } } if (t === 'Parameter') { // elementTypes[i] ↔ args[i-1] (elementTypes[0] is the command name). const arg = cmd.args[i - 1] ?? '' const colonIdx = arg.indexOf(':') if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { return { behavior: 'passthrough', message: 'Colon-bound parameter contains an expression that cannot be statically validated', } } } } } // Safe output cmdlets (Out-Null, etc.) and allowlisted pipeline-tail // transformers (Format-*, Measure-Object, Select-Object, etc.) don't // affect the semantics of the preceding command. Skip them so // `Remove-Item ./foo | Out-Null` or `Set-Content ./foo hi | Format-Table` // auto-allows the same as the bare write cmdlet. isAllowlistedPipelineTail // is the narrow fallback for cmdlets moved from SAFE_OUTPUT_CMDLETS to // CMDLET_ALLOWLIST (argLeaksValue validates their args). if ( isSafeOutputCommand(cmd.name) || isAllowlistedPipelineTail(cmd, input.command) ) { continue } if (!isAcceptEditsAllowedCmdlet(cmd.name)) { return { behavior: 'passthrough', message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, } } // SECURITY: Reject commands with unclassifiable argument types. 'Other' // covers HashtableAst, ConvertExpressionAst, BinaryExpressionAst — all // can contain nested redirections or code that the parser cannot fully // decompose. isAllowlistedCommand (readOnlyValidation.ts) already // enforces this whitelist via argLeaksValue; this closes the same gap // in acceptEdits mode. Without this, @{k='payload' > ~/.bashrc} as a // -Value argument passes because HashtableAst maps to 'Other'. // argLeaksValue also catches colon-bound variables (-Flag:$env:SECRET). if (argLeaksValue(cmd.name, cmd)) { return { behavior: 'passthrough', message: `Arguments in '${cmd.name}' cannot be statically validated in acceptEdits mode`, } } } // Also check nested commands from control flow statements if (segment.nestedCommands) { for (const cmd of segment.nestedCommands) { if (cmd.elementType !== 'CommandAst') { // SECURITY: Same as above — non-CommandAst element in nested commands // (control flow bodies) cannot be statically validated as a path source. return { behavior: 'passthrough', message: `Nested expression element (${cmd.elementType}) cannot be statically validated`, } } if (cmd.nameType === 'application') { return { behavior: 'passthrough', message: `Nested command '${cmd.name}' resolved from a path-like name and requires approval`, } } if ( isSafeOutputCommand(cmd.name) || isAllowlistedPipelineTail(cmd, input.command) ) { continue } if (!isAcceptEditsAllowedCmdlet(cmd.name)) { return { behavior: 'passthrough', message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, } } // SECURITY: Same argLeaksValue check as the main command loop above. if (argLeaksValue(cmd.name, cmd)) { return { behavior: 'passthrough', message: `Arguments in nested '${cmd.name}' cannot be statically validated in acceptEdits mode`, } } } } } // All commands are filesystem-modifying cmdlets -- auto-allow return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'mode', mode: 'acceptEdits', }, } }