source dump of claude code
at main 404 lines 17 kB view raw
1/** 2 * PowerShell permission mode validation. 3 * 4 * Checks if commands should be auto-allowed based on the current permission mode. 5 * In acceptEdits mode, filesystem-modifying PowerShell cmdlets are auto-allowed. 6 * Follows the same patterns as BashTool/modeValidation.ts. 7 */ 8 9import type { ToolPermissionContext } from '../../Tool.js' 10import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 11import type { ParsedPowerShellCommand } from '../../utils/powershell/parser.js' 12import { 13 deriveSecurityFlags, 14 getPipelineSegments, 15 PS_TOKENIZER_DASH_CHARS, 16} from '../../utils/powershell/parser.js' 17import { 18 argLeaksValue, 19 isAllowlistedPipelineTail, 20 isCwdChangingCmdlet, 21 isSafeOutputCommand, 22 resolveToCanonical, 23} from './readOnlyValidation.js' 24 25/** 26 * Filesystem-modifying cmdlets that are auto-allowed in acceptEdits mode. 27 * Stored as canonical (lowercase) cmdlet names. 28 * 29 * Tier 3 cmdlets with complex parameter binding removed — they fall through to 30 * 'ask'. Only simple write cmdlets (first positional = -Path) are auto-allowed 31 * here, and they get path validation via CMDLET_PATH_CONFIG in pathValidation.ts. 32 */ 33const ACCEPT_EDITS_ALLOWED_CMDLETS = new Set([ 34 'set-content', 35 'add-content', 36 'remove-item', 37 'clear-content', 38]) 39 40function isAcceptEditsAllowedCmdlet(name: string): boolean { 41 // resolveToCanonical handles aliases via COMMON_ALIASES, so e.g. 'rm' → 'remove-item', 42 // 'ac' → 'add-content'. Any alias that resolves to an allowed cmdlet is automatically 43 // allowed. Tier 3 cmdlets (new-item, copy-item, move-item, etc.) and their aliases 44 // (mkdir, ni, cp, mv, etc.) resolve to cmdlets NOT in the set and fall through to 'ask'. 45 const canonical = resolveToCanonical(name) 46 return ACCEPT_EDITS_ALLOWED_CMDLETS.has(canonical) 47} 48 49/** 50 * New-Item -ItemType values that create filesystem links (reparse points or 51 * hard links). All three redirect path resolution at runtime — symbolic links 52 * and junctions are directory/file reparse points; hard links alias a file's 53 * inode. Any of these let a later relative-path write land outside the 54 * validator's view. 55 */ 56const LINK_ITEM_TYPES = new Set(['symboliclink', 'junction', 'hardlink']) 57 58/** 59 * Check if a lowered, dash-normalized arg (colon-value stripped) is an 60 * unambiguous PowerShell abbreviation of New-Item's -ItemType or -Type param. 61 * Min prefixes: `-it` (avoids ambiguity with other New-Item params), `-ty` 62 * (avoids `-t` colliding with `-Target`). 63 */ 64function isItemTypeParamAbbrev(p: string): boolean { 65 return ( 66 (p.length >= 3 && '-itemtype'.startsWith(p)) || 67 (p.length >= 3 && '-type'.startsWith(p)) 68 ) 69} 70 71/** 72 * Detects New-Item creating a filesystem link (-ItemType SymbolicLink / 73 * Junction / HardLink, or the -Type alias). Links poison subsequent path 74 * resolution the same way Set-Location/New-PSDrive do: a relative path 75 * through the link resolves to the link target, not the validator's view. 76 * Finding #18. 77 * 78 * Handles PS parameter abbreviation (`-it`, `-ite`, ... `-itemtype`; `-ty`, 79 * `-typ`, `-type`), unicode dash prefixes (en-dash/em-dash/horizontal-bar), 80 * and colon-bound values (`-it:Junction`). 81 */ 82export function isSymlinkCreatingCommand(cmd: { 83 name: string 84 args: string[] 85}): boolean { 86 const canonical = resolveToCanonical(cmd.name) 87 if (canonical !== 'new-item') return false 88 for (let i = 0; i < cmd.args.length; i++) { 89 const raw = cmd.args[i] ?? '' 90 if (raw.length === 0) continue 91 // Normalize unicode dash prefixes (–, —, ―) and forward-slash (PS 5.1 92 // parameter prefix) → ASCII `-` so prefix comparison works. PS tokenizer 93 // treats all four dash chars plus `/` as parameter markers. (bug #26) 94 const normalized = 95 PS_TOKENIZER_DASH_CHARS.has(raw[0]!) || raw[0] === '/' 96 ? '-' + raw.slice(1) 97 : raw 98 const lower = normalized.toLowerCase() 99 // Split colon-bound value: -it:SymbolicLink → param='-it', val='symboliclink' 100 const colonIdx = lower.indexOf(':', 1) 101 const paramRaw = colonIdx > 0 ? lower.slice(0, colonIdx) : lower 102 // Strip backtick escapes: -Item`Type → -ItemType (bug #22) 103 const param = paramRaw.replace(/`/g, '') 104 if (!isItemTypeParamAbbrev(param)) continue 105 const rawVal = 106 colonIdx > 0 107 ? lower.slice(colonIdx + 1) 108 : (cmd.args[i + 1]?.toLowerCase() ?? '') 109 // Strip backtick escapes from colon-bound value: -it:Sym`bolicLink → symboliclink 110 // Mirrors the param-name strip at L103. Space-separated args use .value 111 // (backtick-resolved by .NET parser), but colon-bound uses .text (raw source). 112 // Strip surrounding quotes: -it:'SymbolicLink' or -it:"Junction" (bug #6) 113 const val = rawVal.replace(/`/g, '').replace(/^['"]|['"]$/g, '') 114 if (LINK_ITEM_TYPES.has(val)) return true 115 } 116 return false 117} 118 119/** 120 * Checks if commands should be handled differently based on the current permission mode. 121 * 122 * In acceptEdits mode, auto-allows filesystem-modifying PowerShell cmdlets. 123 * Uses the AST to resolve aliases before checking the allowlist. 124 * 125 * @param input - The PowerShell command input 126 * @param parsed - The parsed AST of the command 127 * @param toolPermissionContext - Context containing mode and permissions 128 * @returns 129 * - 'allow' if the current mode permits auto-approval 130 * - 'passthrough' if no mode-specific handling applies 131 */ 132export function checkPermissionMode( 133 input: { command: string }, 134 parsed: ParsedPowerShellCommand, 135 toolPermissionContext: ToolPermissionContext, 136): PermissionResult { 137 // Skip bypass and dontAsk modes (handled elsewhere) 138 if ( 139 toolPermissionContext.mode === 'bypassPermissions' || 140 toolPermissionContext.mode === 'dontAsk' 141 ) { 142 return { 143 behavior: 'passthrough', 144 message: 'Mode is handled in main permission flow', 145 } 146 } 147 148 if (toolPermissionContext.mode !== 'acceptEdits') { 149 return { 150 behavior: 'passthrough', 151 message: 'No mode-specific validation required', 152 } 153 } 154 155 // acceptEdits mode: check if all commands are filesystem-modifying cmdlets 156 if (!parsed.valid) { 157 return { 158 behavior: 'passthrough', 159 message: 'Cannot validate mode for unparsed command', 160 } 161 } 162 163 // SECURITY: Check for subexpressions, script blocks, or member invocations 164 // that could be used to smuggle arbitrary code through acceptEdits mode. 165 const securityFlags = deriveSecurityFlags(parsed) 166 if ( 167 securityFlags.hasSubExpressions || 168 securityFlags.hasScriptBlocks || 169 securityFlags.hasMemberInvocations || 170 securityFlags.hasSplatting || 171 securityFlags.hasAssignments || 172 securityFlags.hasStopParsing || 173 securityFlags.hasExpandableStrings 174 ) { 175 return { 176 behavior: 'passthrough', 177 message: 178 'Command contains subexpressions, script blocks, or member invocations that require approval', 179 } 180 } 181 182 const segments = getPipelineSegments(parsed) 183 184 // SECURITY: Empty segments with valid parse = no commands to check, don't auto-allow 185 if (segments.length === 0) { 186 return { 187 behavior: 'passthrough', 188 message: 'No commands found to validate for acceptEdits mode', 189 } 190 } 191 192 // SECURITY: Compound cwd desync guard — BashTool parity. 193 // When any statement in a compound contains Set-Location/Push-Location/Pop-Location 194 // (or aliases like cd, sl, chdir, pushd, popd), the cwd changes between statements. 195 // Path validation resolves relative paths against the stale process cwd, so a write 196 // cmdlet in a later statement targets a different directory than the validator checked. 197 // Example: `Set-Location ./.claude; Set-Content ./settings.json '...'` — the validator 198 // sees ./settings.json as /project/settings.json, but PowerShell writes to 199 // /project/.claude/settings.json. Refuse to auto-allow any write operation in a 200 // compound that contains a cwd-changing command. This matches BashTool's 201 // compoundCommandHasCd guard (BashTool/pathValidation.ts:630-655). 202 const totalCommands = segments.reduce( 203 (sum, seg) => sum + seg.commands.length, 204 0, 205 ) 206 if (totalCommands > 1) { 207 let hasCdCommand = false 208 let hasSymlinkCreate = false 209 let hasWriteCommand = false 210 for (const seg of segments) { 211 for (const cmd of seg.commands) { 212 if (cmd.elementType !== 'CommandAst') continue 213 if (isCwdChangingCmdlet(cmd.name)) hasCdCommand = true 214 if (isSymlinkCreatingCommand(cmd)) hasSymlinkCreate = true 215 if (isAcceptEditsAllowedCmdlet(cmd.name)) hasWriteCommand = true 216 } 217 } 218 if (hasCdCommand && hasWriteCommand) { 219 return { 220 behavior: 'passthrough', 221 message: 222 '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', 223 } 224 } 225 // SECURITY: Link-create compound guard (finding #18). Mirrors the cd 226 // guard above. `New-Item -ItemType SymbolicLink -Path ./link -Value /etc; 227 // Get-Content ./link/passwd` — path validation resolves ./link/passwd 228 // against cwd (no link there at validation time), but runtime follows 229 // the just-created link to /etc/passwd. Same TOCTOU shape as cwd desync. 230 // Applies to SymbolicLink, Junction, and HardLink — all three redirect 231 // path resolution at runtime. 232 // No `hasWriteCommand` requirement: read-through-symlink is equally 233 // dangerous (exfil via Get-Content ./link/etc/shadow), and any other 234 // command using paths after a just-created link is unvalidatable. 235 if (hasSymlinkCreate) { 236 return { 237 behavior: 'passthrough', 238 message: 239 'Compound command creates a filesystem link (New-Item -ItemType SymbolicLink/Junction/HardLink) — cannot auto-allow because path validation cannot follow just-created links', 240 } 241 } 242 } 243 244 for (const segment of segments) { 245 for (const cmd of segment.commands) { 246 if (cmd.elementType !== 'CommandAst') { 247 // SECURITY: This guard is load-bearing for THREE cases. Do not narrow it. 248 // 249 // 1. Expression pipeline sources (designed): '/etc/passwd' | Remove-Item 250 // — the string literal is CommandExpressionAst, piped value binds to 251 // -Path. We cannot statically know what path it represents. 252 // 253 // 2. Control-flow statements (accidental but relied upon): 254 // foreach ($x in ...) { Remove-Item $x }. Non-PipelineAst statements 255 // produce a synthetic CommandExpressionAst entry in segment.commands 256 // (parser.ts transformStatement). Without this guard, Remove-Item $x 257 // in nestedCommands would be checked below and auto-allowed — but $x 258 // is a loop-bound variable we cannot validate. 259 // 260 // 3. Non-PipelineAst redirection coverage (accidental): cmd && cmd2 > /tmp 261 // also produces a synthetic element here. isReadOnlyCommand relies on 262 // the same accident (its allowlist rejects the synthetic element's 263 // full-text name), so both paths fail safe together. 264 return { 265 behavior: 'passthrough', 266 message: `Pipeline contains expression source (${cmd.elementType}) that cannot be statically validated`, 267 } 268 } 269 // SECURITY: nameType is computed from the raw name before stripModulePrefix. 270 // 'application' = raw name had path chars (. \\ /). scripts\\Remove-Item 271 // strips to Remove-Item and would match ACCEPT_EDITS_ALLOWED_CMDLETS below, 272 // but PowerShell runs scripts\\Remove-Item.ps1. Same gate as isAllowlistedCommand. 273 if (cmd.nameType === 'application') { 274 return { 275 behavior: 'passthrough', 276 message: `Command '${cmd.name}' resolved from a path-like name and requires approval`, 277 } 278 } 279 // SECURITY: elementTypes whitelist — same as isAllowlistedCommand. 280 // deriveSecurityFlags above checks hasSubExpressions/etc. but does NOT 281 // flag bare Variable/Other elementTypes. `Remove-Item $env:PATH`: 282 // elementTypes = ['StringConstant', 'Variable'] 283 // deriveSecurityFlags: no subexpression → passes 284 // checkPathConstraints: resolves literal text '$env:PATH' as relative 285 // path → cwd/$env:PATH → inside cwd → allow 286 // RUNTIME: PowerShell expands $env:PATH → deletes actual env value path 287 // isAllowlistedCommand rejects non-StringConstant/Parameter; this is the 288 // acceptEdits parity gate. 289 // 290 // Also check colon-bound expression metachars (same as isAllowlistedCommand's 291 // colon-bound check). `Remove-Item -Path:(1 > /tmp/x)`: 292 // elementTypes = ['StringConstant', 'Parameter'] — passes whitelist above 293 // deriveSecurityFlags: ParenExpressionAst in .Argument not detected by 294 // Get-SecurityPatterns (ParenExpressionAst not in FindAll filter) 295 // checkPathConstraints: literal text '-Path:(1 > /tmp/x)' not a path 296 // RUNTIME: paren evaluates, redirection writes /tmp/x → arbitrary write 297 if (cmd.elementTypes) { 298 for (let i = 1; i < cmd.elementTypes.length; i++) { 299 const t = cmd.elementTypes[i] 300 if (t !== 'StringConstant' && t !== 'Parameter') { 301 return { 302 behavior: 'passthrough', 303 message: `Command argument has unvalidatable type (${t}) — variable paths cannot be statically resolved`, 304 } 305 } 306 if (t === 'Parameter') { 307 // elementTypes[i] ↔ args[i-1] (elementTypes[0] is the command name). 308 const arg = cmd.args[i - 1] ?? '' 309 const colonIdx = arg.indexOf(':') 310 if (colonIdx > 0 && /[$(@{[]/.test(arg.slice(colonIdx + 1))) { 311 return { 312 behavior: 'passthrough', 313 message: 314 'Colon-bound parameter contains an expression that cannot be statically validated', 315 } 316 } 317 } 318 } 319 } 320 // Safe output cmdlets (Out-Null, etc.) and allowlisted pipeline-tail 321 // transformers (Format-*, Measure-Object, Select-Object, etc.) don't 322 // affect the semantics of the preceding command. Skip them so 323 // `Remove-Item ./foo | Out-Null` or `Set-Content ./foo hi | Format-Table` 324 // auto-allows the same as the bare write cmdlet. isAllowlistedPipelineTail 325 // is the narrow fallback for cmdlets moved from SAFE_OUTPUT_CMDLETS to 326 // CMDLET_ALLOWLIST (argLeaksValue validates their args). 327 if ( 328 isSafeOutputCommand(cmd.name) || 329 isAllowlistedPipelineTail(cmd, input.command) 330 ) { 331 continue 332 } 333 if (!isAcceptEditsAllowedCmdlet(cmd.name)) { 334 return { 335 behavior: 'passthrough', 336 message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, 337 } 338 } 339 // SECURITY: Reject commands with unclassifiable argument types. 'Other' 340 // covers HashtableAst, ConvertExpressionAst, BinaryExpressionAst — all 341 // can contain nested redirections or code that the parser cannot fully 342 // decompose. isAllowlistedCommand (readOnlyValidation.ts) already 343 // enforces this whitelist via argLeaksValue; this closes the same gap 344 // in acceptEdits mode. Without this, @{k='payload' > ~/.bashrc} as a 345 // -Value argument passes because HashtableAst maps to 'Other'. 346 // argLeaksValue also catches colon-bound variables (-Flag:$env:SECRET). 347 if (argLeaksValue(cmd.name, cmd)) { 348 return { 349 behavior: 'passthrough', 350 message: `Arguments in '${cmd.name}' cannot be statically validated in acceptEdits mode`, 351 } 352 } 353 } 354 355 // Also check nested commands from control flow statements 356 if (segment.nestedCommands) { 357 for (const cmd of segment.nestedCommands) { 358 if (cmd.elementType !== 'CommandAst') { 359 // SECURITY: Same as above — non-CommandAst element in nested commands 360 // (control flow bodies) cannot be statically validated as a path source. 361 return { 362 behavior: 'passthrough', 363 message: `Nested expression element (${cmd.elementType}) cannot be statically validated`, 364 } 365 } 366 if (cmd.nameType === 'application') { 367 return { 368 behavior: 'passthrough', 369 message: `Nested command '${cmd.name}' resolved from a path-like name and requires approval`, 370 } 371 } 372 if ( 373 isSafeOutputCommand(cmd.name) || 374 isAllowlistedPipelineTail(cmd, input.command) 375 ) { 376 continue 377 } 378 if (!isAcceptEditsAllowedCmdlet(cmd.name)) { 379 return { 380 behavior: 'passthrough', 381 message: `No mode-specific handling for '${cmd.name}' in acceptEdits mode`, 382 } 383 } 384 // SECURITY: Same argLeaksValue check as the main command loop above. 385 if (argLeaksValue(cmd.name, cmd)) { 386 return { 387 behavior: 'passthrough', 388 message: `Arguments in nested '${cmd.name}' cannot be statically validated in acceptEdits mode`, 389 } 390 } 391 } 392 } 393 } 394 395 // All commands are filesystem-modifying cmdlets -- auto-allow 396 return { 397 behavior: 'allow', 398 updatedInput: input, 399 decisionReason: { 400 type: 'mode', 401 mode: 'acceptEdits', 402 }, 403 } 404}