source dump of claude code
at main 1648 lines 68 kB view raw
1/** 2 * PowerShell-specific permission checking, adapted from bashPermissions.ts 3 * for case-insensitive cmdlet matching. 4 */ 5 6import { resolve } from 'path' 7import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' 8import type { 9 PermissionDecisionReason, 10 PermissionResult, 11} from '../../types/permissions.js' 12import { getCwd } from '../../utils/cwd.js' 13import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js' 14import type { PermissionRule } from '../../utils/permissions/PermissionRule.js' 15import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 16import { 17 createPermissionRequestMessage, 18 getRuleByContentsForToolName, 19} from '../../utils/permissions/permissions.js' 20import { 21 matchWildcardPattern, 22 parsePermissionRule, 23 type ShellPermissionRule, 24 suggestionForExactCommand as sharedSuggestionForExactCommand, 25} from '../../utils/permissions/shellRuleMatching.js' 26import { 27 classifyCommandName, 28 deriveSecurityFlags, 29 getAllCommandNames, 30 getFileRedirections, 31 type ParsedCommandElement, 32 type ParsedPowerShellCommand, 33 PS_TOKENIZER_DASH_CHARS, 34 parsePowerShellCommand, 35 stripModulePrefix, 36} from '../../utils/powershell/parser.js' 37import { containsVulnerableUncPath } from '../../utils/shell/readOnlyCommandValidation.js' 38import { isDotGitPathPS, isGitInternalPathPS } from './gitSafety.js' 39import { 40 checkPermissionMode, 41 isSymlinkCreatingCommand, 42} from './modeValidation.js' 43import { 44 checkPathConstraints, 45 dangerousRemovalDeny, 46 isDangerousRemovalRawPath, 47} from './pathValidation.js' 48import { powershellCommandIsSafe } from './powershellSecurity.js' 49import { 50 argLeaksValue, 51 isAllowlistedCommand, 52 isCwdChangingCmdlet, 53 isProvablySafeStatement, 54 isReadOnlyCommand, 55 isSafeOutputCommand, 56 resolveToCanonical, 57} from './readOnlyValidation.js' 58import { POWERSHELL_TOOL_NAME } from './toolName.js' 59 60// Matches `$var = `, `$var += `, `$env:X = `, `$x ??= ` etc. Used to strip 61// nested assignment prefixes in the parse-failed fallback path. 62const PS_ASSIGN_PREFIX_RE = /^\$[\w:]+\s*(?:[+\-*/%]|\?\?)?\s*=\s*/ 63 64/** 65 * Cmdlets that can place a file at a caller-specified path. The 66 * git-internal-paths guard checks whether any arg is a git-internal path 67 * (hooks/, refs/, objects/, HEAD). Non-creating writers (remove-item, 68 * clear-content) are intentionally absent — they can't plant new hooks. 69 */ 70const GIT_SAFETY_WRITE_CMDLETS = new Set([ 71 'new-item', 72 'set-content', 73 'add-content', 74 'out-file', 75 'copy-item', 76 'move-item', 77 'rename-item', 78 'expand-archive', 79 'invoke-webrequest', 80 'invoke-restmethod', 81 'tee-object', 82 'export-csv', 83 'export-clixml', 84]) 85 86/** 87 * External archive-extraction applications that write files to cwd with 88 * archive-controlled paths. `tar -xf payload.tar; git status` defeats 89 * isCurrentDirectoryBareGitRepo (TOCTOU): the check runs at 90 * permission-eval time, tar extracts HEAD/hooks/refs/ AFTER the check and 91 * BEFORE git runs. Unlike GIT_SAFETY_WRITE_CMDLETS (where we can inspect 92 * args for git-internal paths), archive contents are opaque — any 93 * extraction preceding git must ask. Matched by name only (lowercase, 94 * with and without .exe). 95 */ 96const GIT_SAFETY_ARCHIVE_EXTRACTORS = new Set([ 97 'tar', 98 'tar.exe', 99 'bsdtar', 100 'bsdtar.exe', 101 'unzip', 102 'unzip.exe', 103 '7z', 104 '7z.exe', 105 '7za', 106 '7za.exe', 107 'gzip', 108 'gzip.exe', 109 'gunzip', 110 'gunzip.exe', 111 'expand-archive', 112]) 113 114/** 115 * Extract the command name from a PowerShell command string. 116 * Uses the parser to get the first command name from the AST. 117 */ 118async function extractCommandName(command: string): Promise<string> { 119 const trimmed = command.trim() 120 if (!trimmed) { 121 return '' 122 } 123 const parsed = await parsePowerShellCommand(trimmed) 124 const names = getAllCommandNames(parsed) 125 return names[0] ?? '' 126} 127 128/** 129 * Parse a permission rule string into a structured rule object. 130 * Delegates to shared parsePermissionRule. 131 */ 132export function powershellPermissionRule( 133 permissionRule: string, 134): ShellPermissionRule { 135 return parsePermissionRule(permissionRule) 136} 137 138/** 139 * Generate permission update suggestion for exact command match. 140 * 141 * Skip exact-command suggestion for commands that can't round-trip cleanly: 142 * - Multi-line: newlines don't survive normalization, rule would never match 143 * - Literal *: storing `Remove-Item * -Force` verbatim re-parses as a wildcard 144 * rule via hasWildcards() (matches `^Remove-Item .* -Force$`). Escaping to 145 * `\*` creates a dead rule — parsePermissionRule's exact branch returns the 146 * raw string with backslash intact, so `Remove-Item \* -Force` never matches 147 * the incoming `Remove-Item * -Force`. Globs are unsafe to exact-auto-allow 148 * anyway; prefix suggestion still offered. (finding #12) 149 */ 150function suggestionForExactCommand(command: string): PermissionUpdate[] { 151 if (command.includes('\n') || command.includes('*')) { 152 return [] 153 } 154 return sharedSuggestionForExactCommand(POWERSHELL_TOOL_NAME, command) 155} 156 157/** 158 * PowerShell input schema type - simplified for initial implementation 159 */ 160type PowerShellInput = { 161 command: string 162 timeout?: number 163} 164 165/** 166 * Filter rules by contents matching an input command. 167 * PowerShell-specific: uses case-insensitive matching throughout. 168 * Follows the same structure as BashTool's local filterRulesByContentsMatchingInput. 169 */ 170function filterRulesByContentsMatchingInput( 171 input: PowerShellInput, 172 rules: Map<string, PermissionRule>, 173 matchMode: 'exact' | 'prefix', 174 behavior: 'deny' | 'ask' | 'allow', 175): PermissionRule[] { 176 const command = input.command.trim() 177 178 function strEquals(a: string, b: string): boolean { 179 return a.toLowerCase() === b.toLowerCase() 180 } 181 function strStartsWith(str: string, prefix: string): boolean { 182 return str.toLowerCase().startsWith(prefix.toLowerCase()) 183 } 184 // SECURITY: stripModulePrefix on RULE names widens the 185 // secondary-canonical match — a deny rule `Module\Remove-Item:*` blocking 186 // `rm` is the intent (fail-safe over-match), but an allow rule 187 // `ModuleA\Get-Thing:*` also matching `ModuleB\Get-Thing` is fail-OPEN. 188 // Deny/ask over-match is fine; allow must never over-match. 189 function stripModulePrefixForRule(name: string): string { 190 if (behavior === 'allow') { 191 return name 192 } 193 return stripModulePrefix(name) 194 } 195 196 // Extract the first word (command name) from the input for canonical matching. 197 // Keep both raw (for slicing the original `command` string) and stripped 198 // (for canonical resolution) versions. For module-qualified inputs like 199 // `Microsoft.PowerShell.Utility\Invoke-Expression foo`, rawCmdName holds the 200 // full token so `command.slice(rawCmdName.length)` yields the correct rest. 201 const rawCmdName = command.split(/\s+/)[0] ?? '' 202 const inputCmdName = stripModulePrefix(rawCmdName) 203 const inputCanonical = resolveToCanonical(inputCmdName) 204 205 // Build a version of the command with the canonical name substituted 206 // e.g., 'rm foo.txt' -> 'remove-item foo.txt' so deny rules on Remove-Item also block rm. 207 // SECURITY: Normalize the whitespace separator between name and args to a 208 // single space. PowerShell accepts any whitespace (tab, etc.) as separator, 209 // but prefix rule matching uses `prefix + ' '` (literal space). Without this, 210 // `rm\t./x` canonicalizes to `remove-item\t./x` and misses the deny rule 211 // `Remove-Item:*`, while acceptEdits auto-allow (using AST cmd.name) still 212 // matches — a deny-rule bypass. Build unconditionally (not just when the 213 // canonical differs) so non-space-separated raw commands are also normalized. 214 const rest = command.slice(rawCmdName.length).replace(/^\s+/, ' ') 215 const canonicalCommand = inputCanonical + rest 216 217 return Array.from(rules.entries()) 218 .filter(([ruleContent]) => { 219 const rule = powershellPermissionRule(ruleContent) 220 221 // Also resolve the rule's command name to canonical for cross-matching 222 // e.g., a deny rule for 'rm' should also block 'Remove-Item' 223 function matchesCommand(cmd: string): boolean { 224 switch (rule.type) { 225 case 'exact': 226 return strEquals(rule.command, cmd) 227 case 'prefix': 228 switch (matchMode) { 229 case 'exact': 230 return strEquals(rule.prefix, cmd) 231 case 'prefix': { 232 if (strEquals(cmd, rule.prefix)) { 233 return true 234 } 235 return strStartsWith(cmd, rule.prefix + ' ') 236 } 237 } 238 break 239 case 'wildcard': 240 if (matchMode === 'exact') { 241 return false 242 } 243 return matchWildcardPattern(rule.pattern, cmd, true) 244 } 245 } 246 247 // Check against the original command 248 if (matchesCommand(command)) { 249 return true 250 } 251 252 // Also check against the canonical form of the command 253 // This ensures 'deny Remove-Item' also blocks 'rm', 'del', 'ri', etc. 254 if (matchesCommand(canonicalCommand)) { 255 return true 256 } 257 258 // Also resolve the rule's command name to canonical and compare 259 // This ensures 'deny rm' also blocks 'Remove-Item' 260 // SECURITY: stripModulePrefix applied to DENY/ASK rule command 261 // names too, not just input. Otherwise a deny rule written as 262 // `Microsoft.PowerShell.Management\Remove-Item:*` is bypassed by `rm`, 263 // `del`, or plain `Remove-Item` — resolveToCanonical won't match the 264 // module-qualified form against COMMON_ALIASES. 265 if (rule.type === 'exact') { 266 const rawRuleCmdName = rule.command.split(/\s+/)[0] ?? '' 267 const ruleCanonical = resolveToCanonical( 268 stripModulePrefixForRule(rawRuleCmdName), 269 ) 270 if (ruleCanonical === inputCanonical) { 271 // Rule and input resolve to same canonical cmdlet 272 // SECURITY: use normalized `rest` not a raw re-slice 273 // from `command`. The raw slice preserves tab separators so 274 // `Remove-Item\t./secret.txt` vs deny rule `rm ./secret.txt` misses. 275 // Normalize both sides identically. 276 const ruleRest = rule.command 277 .slice(rawRuleCmdName.length) 278 .replace(/^\s+/, ' ') 279 const inputRest = rest 280 if (strEquals(ruleRest, inputRest)) { 281 return true 282 } 283 } 284 } else if (rule.type === 'prefix') { 285 const rawRuleCmdName = rule.prefix.split(/\s+/)[0] ?? '' 286 const ruleCanonical = resolveToCanonical( 287 stripModulePrefixForRule(rawRuleCmdName), 288 ) 289 if (ruleCanonical === inputCanonical) { 290 const ruleRest = rule.prefix 291 .slice(rawRuleCmdName.length) 292 .replace(/^\s+/, ' ') 293 const canonicalPrefix = inputCanonical + ruleRest 294 if (matchMode === 'exact') { 295 if (strEquals(canonicalPrefix, canonicalCommand)) { 296 return true 297 } 298 } else { 299 if ( 300 strEquals(canonicalCommand, canonicalPrefix) || 301 strStartsWith(canonicalCommand, canonicalPrefix + ' ') 302 ) { 303 return true 304 } 305 } 306 } 307 } else if (rule.type === 'wildcard') { 308 // Resolve the wildcard pattern's command name to canonical and re-match 309 // This ensures 'deny rm *' also blocks 'Remove-Item secret.txt' 310 const rawRuleCmdName = rule.pattern.split(/\s+/)[0] ?? '' 311 const ruleCanonical = resolveToCanonical( 312 stripModulePrefixForRule(rawRuleCmdName), 313 ) 314 if (ruleCanonical === inputCanonical && matchMode !== 'exact') { 315 // Rebuild the pattern with the canonical cmdlet name 316 // Normalize separator same as exact and prefix branches. 317 // Without this, a wildcard rule `rm\t*` produces canonicalPattern 318 // with a literal tab that never matches the space-normalized 319 // canonicalCommand. 320 const ruleRest = rule.pattern 321 .slice(rawRuleCmdName.length) 322 .replace(/^\s+/, ' ') 323 const canonicalPattern = inputCanonical + ruleRest 324 if (matchWildcardPattern(canonicalPattern, canonicalCommand, true)) { 325 return true 326 } 327 } 328 } 329 330 return false 331 }) 332 .map(([, rule]) => rule) 333} 334 335/** 336 * Get matching rules for input across all rule types (deny, ask, allow) 337 */ 338function matchingRulesForInput( 339 input: PowerShellInput, 340 toolPermissionContext: ToolPermissionContext, 341 matchMode: 'exact' | 'prefix', 342) { 343 const denyRuleByContents = getRuleByContentsForToolName( 344 toolPermissionContext, 345 POWERSHELL_TOOL_NAME, 346 'deny', 347 ) 348 const matchingDenyRules = filterRulesByContentsMatchingInput( 349 input, 350 denyRuleByContents, 351 matchMode, 352 'deny', 353 ) 354 355 const askRuleByContents = getRuleByContentsForToolName( 356 toolPermissionContext, 357 POWERSHELL_TOOL_NAME, 358 'ask', 359 ) 360 const matchingAskRules = filterRulesByContentsMatchingInput( 361 input, 362 askRuleByContents, 363 matchMode, 364 'ask', 365 ) 366 367 const allowRuleByContents = getRuleByContentsForToolName( 368 toolPermissionContext, 369 POWERSHELL_TOOL_NAME, 370 'allow', 371 ) 372 const matchingAllowRules = filterRulesByContentsMatchingInput( 373 input, 374 allowRuleByContents, 375 matchMode, 376 'allow', 377 ) 378 379 return { matchingDenyRules, matchingAskRules, matchingAllowRules } 380} 381 382/** 383 * Check if the command is an exact match for a permission rule. 384 */ 385export function powershellToolCheckExactMatchPermission( 386 input: PowerShellInput, 387 toolPermissionContext: ToolPermissionContext, 388): PermissionResult { 389 const trimmedCommand = input.command.trim() 390 const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 391 matchingRulesForInput(input, toolPermissionContext, 'exact') 392 393 if (matchingDenyRules[0] !== undefined) { 394 return { 395 behavior: 'deny', 396 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${trimmedCommand} has been denied.`, 397 decisionReason: { type: 'rule', rule: matchingDenyRules[0] }, 398 } 399 } 400 401 if (matchingAskRules[0] !== undefined) { 402 return { 403 behavior: 'ask', 404 message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 405 decisionReason: { type: 'rule', rule: matchingAskRules[0] }, 406 } 407 } 408 409 if (matchingAllowRules[0] !== undefined) { 410 return { 411 behavior: 'allow', 412 updatedInput: input, 413 decisionReason: { type: 'rule', rule: matchingAllowRules[0] }, 414 } 415 } 416 417 const decisionReason: PermissionDecisionReason = { 418 type: 'other' as const, 419 reason: 'This command requires approval', 420 } 421 return { 422 behavior: 'passthrough', 423 message: createPermissionRequestMessage( 424 POWERSHELL_TOOL_NAME, 425 decisionReason, 426 ), 427 decisionReason, 428 suggestions: suggestionForExactCommand(trimmedCommand), 429 } 430} 431 432/** 433 * Check permission for a PowerShell command including prefix matches. 434 */ 435export function powershellToolCheckPermission( 436 input: PowerShellInput, 437 toolPermissionContext: ToolPermissionContext, 438): PermissionResult { 439 const command = input.command.trim() 440 441 // 1. Check exact match first 442 const exactMatchResult = powershellToolCheckExactMatchPermission( 443 input, 444 toolPermissionContext, 445 ) 446 447 // 1a. Deny/ask if exact command has a rule 448 if ( 449 exactMatchResult.behavior === 'deny' || 450 exactMatchResult.behavior === 'ask' 451 ) { 452 return exactMatchResult 453 } 454 455 // 2. Find all matching rules (prefix or exact) 456 const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 457 matchingRulesForInput(input, toolPermissionContext, 'prefix') 458 459 // 2a. Deny if command has a deny rule 460 if (matchingDenyRules[0] !== undefined) { 461 return { 462 behavior: 'deny', 463 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 464 decisionReason: { 465 type: 'rule', 466 rule: matchingDenyRules[0], 467 }, 468 } 469 } 470 471 // 2b. Ask if command has an ask rule 472 if (matchingAskRules[0] !== undefined) { 473 return { 474 behavior: 'ask', 475 message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 476 decisionReason: { 477 type: 'rule', 478 rule: matchingAskRules[0], 479 }, 480 } 481 } 482 483 // 3. Allow if command had an exact match allow 484 if (exactMatchResult.behavior === 'allow') { 485 return exactMatchResult 486 } 487 488 // 4. Allow if command has an allow rule 489 if (matchingAllowRules[0] !== undefined) { 490 return { 491 behavior: 'allow', 492 updatedInput: input, 493 decisionReason: { 494 type: 'rule', 495 rule: matchingAllowRules[0], 496 }, 497 } 498 } 499 500 // 5. Passthrough since no rules match, will trigger permission prompt 501 const decisionReason = { 502 type: 'other' as const, 503 reason: 'This command requires approval', 504 } 505 return { 506 behavior: 'passthrough', 507 message: createPermissionRequestMessage( 508 POWERSHELL_TOOL_NAME, 509 decisionReason, 510 ), 511 decisionReason, 512 suggestions: suggestionForExactCommand(command), 513 } 514} 515 516/** 517 * Information about a sub-command for permission checking. 518 */ 519type SubCommandInfo = { 520 text: string 521 element: ParsedCommandElement 522 statement: ParsedPowerShellCommand['statements'][number] | null 523 isSafeOutput: boolean 524} 525 526/** 527 * Extract sub-commands that need independent permission checking from a parsed command. 528 * Safe output cmdlets (Format-Table, Select-Object, etc.) are flagged but NOT 529 * filtered out — step 4.4 still checks deny rules against them (deny always 530 * wins), step 5 skips them for approval collection (they inherit the permission 531 * of the preceding command). 532 * 533 * Also includes nested commands from control flow statements (if, for, foreach, etc.) 534 * to ensure commands hidden inside control flow are checked. 535 * 536 * Returns sub-command info including both text and the parsed element for accurate 537 * suggestion generation. 538 */ 539async function getSubCommandsForPermissionCheck( 540 parsed: ParsedPowerShellCommand, 541 originalCommand: string, 542): Promise<SubCommandInfo[]> { 543 if (!parsed.valid) { 544 // Return a fallback element for unparsed commands 545 return [ 546 { 547 text: originalCommand, 548 element: { 549 name: await extractCommandName(originalCommand), 550 nameType: 'unknown', 551 elementType: 'CommandAst', 552 args: [], 553 text: originalCommand, 554 }, 555 statement: null, 556 isSafeOutput: false, 557 }, 558 ] 559 } 560 561 const subCommands: SubCommandInfo[] = [] 562 563 // Check direct commands in pipelines 564 for (const statement of parsed.statements) { 565 for (const cmd of statement.commands) { 566 // Only check actual commands (CommandAst), not expressions 567 if (cmd.elementType !== 'CommandAst') { 568 continue 569 } 570 subCommands.push({ 571 text: cmd.text, 572 element: cmd, 573 statement, 574 // SECURITY: nameType gate — scripts\\Out-Null strips to Out-Null and 575 // would match SAFE_OUTPUT_CMDLETS, but PowerShell runs the .ps1 file. 576 // isSafeOutput: true causes step 5 to filter this command out of the 577 // approval list, so it would silently execute. See isAllowlistedCommand. 578 // SECURITY: args.length === 0 gate — Out-Null -InputObject:(1 > /etc/x) 579 // was filtered as safe-output (name-only) → step-5 subCommands empty → 580 // auto-allow → redirection inside paren writes file. Only zero-arg 581 // Out-String/Out-Null/Out-Host invocations are provably safe. 582 isSafeOutput: 583 cmd.nameType !== 'application' && 584 isSafeOutputCommand(cmd.name) && 585 cmd.args.length === 0, 586 }) 587 } 588 589 // Also check nested commands from control flow statements 590 if (statement.nestedCommands) { 591 for (const cmd of statement.nestedCommands) { 592 subCommands.push({ 593 text: cmd.text, 594 element: cmd, 595 statement, 596 isSafeOutput: 597 cmd.nameType !== 'application' && 598 isSafeOutputCommand(cmd.name) && 599 cmd.args.length === 0, 600 }) 601 } 602 } 603 } 604 605 if (subCommands.length > 0) { 606 return subCommands 607 } 608 609 // Fallback for commands with no sub-commands 610 return [ 611 { 612 text: originalCommand, 613 element: { 614 name: await extractCommandName(originalCommand), 615 nameType: 'unknown', 616 elementType: 'CommandAst', 617 args: [], 618 text: originalCommand, 619 }, 620 statement: null, 621 isSafeOutput: false, 622 }, 623 ] 624} 625 626/** 627 * Main permission check function for PowerShell tool. 628 * 629 * This function implements the full permission flow: 630 * 1. Check exact match against deny/ask/allow rules 631 * 2. Check prefix match against rules 632 * 3. Run security check via powershellCommandIsSafe() 633 * 4. Return appropriate PermissionResult 634 * 635 * @param input - The PowerShell tool input 636 * @param context - The tool use context (for abort signal and session info) 637 * @returns Promise resolving to PermissionResult 638 */ 639export async function powershellToolHasPermission( 640 input: PowerShellInput, 641 context: ToolUseContext, 642): Promise<PermissionResult> { 643 const toolPermissionContext = context.getAppState().toolPermissionContext 644 const command = input.command.trim() 645 646 // Empty command check 647 if (!command) { 648 return { 649 behavior: 'allow', 650 updatedInput: input, 651 decisionReason: { 652 type: 'other', 653 reason: 'Empty command is safe', 654 }, 655 } 656 } 657 658 // Parse the command once and thread through all sub-functions 659 const parsed = await parsePowerShellCommand(command) 660 661 // SECURITY: Check deny/ask rules BEFORE parse validity check. 662 // Deny rules operate on the raw command string and don't need the parsed AST. 663 // This ensures explicit deny rules still block commands even when parsing fails. 664 // 1. Check exact match first 665 const exactMatchResult = powershellToolCheckExactMatchPermission( 666 input, 667 toolPermissionContext, 668 ) 669 670 // Exact command was denied 671 if (exactMatchResult.behavior === 'deny') { 672 return exactMatchResult 673 } 674 675 // 2. Check prefix/wildcard rules 676 const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( 677 input, 678 toolPermissionContext, 679 'prefix', 680 ) 681 682 // 2a. Deny if command has a deny rule 683 if (matchingDenyRules[0] !== undefined) { 684 return { 685 behavior: 'deny', 686 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 687 decisionReason: { 688 type: 'rule', 689 rule: matchingDenyRules[0], 690 }, 691 } 692 } 693 694 // 2b. Ask if command has an ask rule — DEFERRED into decisions[]. 695 // Previously this early-returned before sub-command deny checks ran, so 696 // `Get-Process; Invoke-Expression evil` with ask(Get-Process:*) + 697 // deny(Invoke-Expression:*) would show the ask dialog and the deny never 698 // fired. Now: store the ask, push into decisions[] after parse succeeds. 699 // If parse fails, returned before the parse-error ask (preserves the 700 // rule-attributed decisionReason when pwsh is unavailable). 701 let preParseAskDecision: PermissionResult | null = null 702 if (matchingAskRules[0] !== undefined) { 703 preParseAskDecision = { 704 behavior: 'ask', 705 message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 706 decisionReason: { 707 type: 'rule', 708 rule: matchingAskRules[0], 709 }, 710 } 711 } 712 713 // Block UNC paths — reading from UNC paths can trigger network requests 714 // and leak NTLM/Kerberos credentials. DEFERRED into decisions[]. 715 // The raw-string UNC check must not early-return before sub-command deny 716 // (step 4+). Same fix as 2b above. 717 if (preParseAskDecision === null && containsVulnerableUncPath(command)) { 718 preParseAskDecision = { 719 behavior: 'ask', 720 message: 721 'Command contains a UNC path that could trigger network requests', 722 } 723 } 724 725 // 2c. Exact allow rules short-circuit here ONLY when parsing failed AND 726 // no pre-parse ask (2b prefix or UNC) is pending. Converting 2b/UNC from 727 // early-return to deferred-assign meant 2c 728 // fired before L648 consumed preParseAskDecision — silently overriding the 729 // ask with allow. Parse-succeeded path enforces ask > allow via the reduce 730 // (L917); without this guard, parse-failed was inconsistent. 731 // This ensures user-configured exact allow rules work even when pwsh is 732 // unavailable. When parsing succeeds, the exact allow check is deferred to 733 // after step 4.4 (sub-command deny/ask) — matching BashTool's ordering where 734 // the main-flow exact allow at bashPermissions.ts:1520 runs after sub-command 735 // deny checks (1442-1458). Without this, an exact allow on a compound command 736 // would bypass deny rules on sub-commands. 737 // 738 // SECURITY (parse-failed branch): the nameType guard in step 5 lives 739 // inside the sub-command loop, which only runs when parsed.valid. 740 // This is the !parsed.valid escape hatch. Input-side stripModulePrefix 741 // is unconditional — `scripts\build.exe --flag` strips to `build.exe`, 742 // canonicalCommand matches exact allow, and without this guard we'd 743 // return allow here and execute the local script. classifyCommandName 744 // is a pure string function (no AST needed). `scripts\build.exe` → 745 // 'application' (has `\`). Same tradeoff as step 5: `build.exe` alone 746 // also classifies 'application' (has `.`) so legitimate executable 747 // exact-allows downgrade to ask when pwsh is degraded — fail-safe. 748 // Module-qualified cmdlets (Module\Cmdlet) also classify 'application' 749 // (same `\`); same fail-safe over-fire. 750 if ( 751 exactMatchResult.behavior === 'allow' && 752 !parsed.valid && 753 preParseAskDecision === null && 754 classifyCommandName(command.split(/\s+/)[0] ?? '') !== 'application' 755 ) { 756 return exactMatchResult 757 } 758 759 // 0. Check if command can be parsed - if not, require approval but don't suggest persisting 760 // This matches Bash behavior: invalid syntax triggers a permission prompt but we don't 761 // recommend saving invalid commands to settings 762 // NOTE: This check is intentionally AFTER deny/ask rules so explicit rules still work 763 // even when the parser fails (e.g., pwsh unavailable). 764 if (!parsed.valid) { 765 // SECURITY: Fallback sub-command deny scan for parse-failed path. 766 // The sub-command deny loop at L851+ needs the AST; when parsing fails 767 // (command exceeds MAX_COMMAND_LENGTH, pwsh unavailable, timeout, bad 768 // JSON), we'd return 'ask' without ever checking sub-command deny rules. 769 // Attack: `Get-ChildItem # <~2000 chars padding> ; Invoke-Expression evil` 770 // → padding forces valid=false → generic ask prompt, deny(iex:*) never 771 // fires. This fallback splits on PowerShell separators/grouping and runs 772 // each fragment through the SAME rule matcher as step 2a (prefix deny). 773 // Conservative: fragments inside string literals/comments may false-positive 774 // deny — safe here (parse-failed is already a degraded state, and this is 775 // a deny-DOWNGRADE fix). Match against full fragment (not just first token) 776 // so multi-word rules like `Remove-Item foo:*` still fire; the matcher's 777 // canonical resolution handles aliases (`iex` → `Invoke-Expression`). 778 // 779 // SECURITY: backtick is PS escape/line-continuation, NOT a separator. 780 // Splitting on it would fragment `Invoke-Ex`pression` into non-matching 781 // pieces. Instead: collapse backtick-newline (line continuation) so 782 // `Invoke-Ex`<nl>pression` rejoins, strip remaining backticks (escape 783 // chars — ``x → x), then split on actual statement/grouping separators. 784 const backtickStripped = command 785 .replace(/`[\r\n]+\s*/g, '') 786 .replace(/`/g, '') 787 for (const fragment of backtickStripped.split(/[;|\n\r{}()&]+/)) { 788 const trimmedFrag = fragment.trim() 789 if (!trimmedFrag) continue // skip empty fragments 790 // Skip the full command ONLY if it starts with a cmdlet name (no 791 // assignment prefix). The full command was already checked at 2a, but 792 // 2a uses the raw text — $x %= iex as first token `$x` misses the 793 // deny(iex:*) rule. If normalization would change the fragment 794 // (assignment prefix, dot-source), don't skip — let it be re-checked 795 // after normalization. (bug #10/#24) 796 if ( 797 trimmedFrag === command && 798 !/^\$[\w:]/.test(trimmedFrag) && 799 !/^[&.]\s/.test(trimmedFrag) 800 ) { 801 continue 802 } 803 // SECURITY: Normalize invocation-operator and assignment prefixes before 804 // rule matching (findings #5/#22). The splitter gives us the raw fragment 805 // text; matchingRulesForInput extracts the first token as the cmdlet name. 806 // Without normalization: 807 // `$x = Invoke-Expression 'p'` → first token `$x` → deny(iex:*) misses 808 // `. Invoke-Expression 'p'` → first token `.` → deny(iex:*) misses 809 // `& 'Invoke-Expression' 'p'` → first token `&` removed by split but 810 // `'Invoke-Expression'` retains quotes 811 // → deny(iex:*) misses 812 // The parse-succeeded path handles these via AST (parser.ts:839 strips 813 // quotes from rawNameUnstripped; invocation operators are separate AST 814 // nodes). This fallback mirrors that normalization. 815 // Loop strips nested assignments: $x = $y = iex → $y = iex → iex 816 let normalized = trimmedFrag 817 let m: RegExpMatchArray | null 818 while ((m = normalized.match(PS_ASSIGN_PREFIX_RE))) { 819 normalized = normalized.slice(m[0].length) 820 } 821 normalized = normalized.replace(/^[&.]\s+/, '') // & cmd, . cmd (dot-source) 822 const rawFirst = normalized.split(/\s+/)[0] ?? '' 823 const firstTok = rawFirst.replace(/^['"]|['"]$/g, '') 824 const normalizedFrag = firstTok + normalized.slice(rawFirst.length) 825 // SECURITY: parse-independent dangerous-removal hard-deny. The 826 // isDangerousRemovalPath check in checkPathConstraintsForStatement 827 // requires a valid AST; when pwsh times out or is unavailable, 828 // `Remove-Item /` degrades from hard-deny to generic ask. Check 829 // raw positional args here so root/home/system deletion is denied 830 // regardless of parser availability. Conservative: only positional 831 // args (skip -Param tokens); over-deny in degraded state is safe 832 // (same deny-downgrade rationale as the sub-command scan above). 833 if (resolveToCanonical(firstTok) === 'remove-item') { 834 for (const arg of normalized.split(/\s+/).slice(1)) { 835 if (PS_TOKENIZER_DASH_CHARS.has(arg[0] ?? '')) continue 836 if (isDangerousRemovalRawPath(arg)) { 837 return dangerousRemovalDeny(arg) 838 } 839 } 840 } 841 const { matchingDenyRules: fragDenyRules } = matchingRulesForInput( 842 { command: normalizedFrag }, 843 toolPermissionContext, 844 'prefix', 845 ) 846 if (fragDenyRules[0] !== undefined) { 847 return { 848 behavior: 'deny', 849 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 850 decisionReason: { type: 'rule', rule: fragDenyRules[0] }, 851 } 852 } 853 } 854 // Preserve pre-parse ask messaging when parse fails. The deferred ask 855 // (2b prefix rule or UNC) carries a better decisionReason than the 856 // generic parse-error ask. Sub-command deny can't run the AST loop 857 // without a parse, so the fallback scan above is best-effort. 858 if (preParseAskDecision !== null) { 859 return preParseAskDecision 860 } 861 const decisionReason = { 862 type: 'other' as const, 863 reason: `Command contains malformed syntax that cannot be parsed: ${parsed.errors[0]?.message ?? 'unknown error'}`, 864 } 865 return { 866 behavior: 'ask', 867 decisionReason, 868 message: createPermissionRequestMessage( 869 POWERSHELL_TOOL_NAME, 870 decisionReason, 871 ), 872 // No suggestions - don't recommend persisting invalid syntax 873 } 874 } 875 876 // ======================================================================== 877 // COLLECT-THEN-REDUCE: post-parse decisions (deny > ask > allow > passthrough) 878 // ======================================================================== 879 // Ported from bashPermissions.ts:1446-1472. Every post-parse check pushes 880 // its decision into a single array; a single reduce applies precedence. 881 // This structurally closes the ask-before-deny bug class: an 'ask' from an 882 // earlier check (security flags, provider paths, cd+git) can no longer mask 883 // a 'deny' from a later check (sub-command deny, checkPathConstraints). 884 // 885 // Supersedes the firstSubCommandAskRule stash from commit 8f5ae6c56b — that 886 // fix only patched step 4; steps 3, 3.5, 4.42 had the same flaw. The stash 887 // pattern is also fragile: the next author who writes `return ask` is back 888 // where we started. Collect-then-reduce makes the bypass impossible to write. 889 // 890 // First-of-each-behavior wins (array order = step order), so single-check 891 // ask messages are unchanged vs. sequential-early-return. 892 // 893 // Pre-parse deny checks above (exact/prefix deny) stay sequential: they 894 // fire even when pwsh is unavailable. Pre-parse asks (prefix ask, raw UNC) 895 // are now deferred here so sub-command deny (step 4) beats them. 896 897 // Gather sub-commands once (used by decisions 3, 4, and fallthrough step 5). 898 const allSubCommands = await getSubCommandsForPermissionCheck(parsed, command) 899 900 const decisions: PermissionResult[] = [] 901 902 // Decision: deferred pre-parse ask (2b prefix ask or UNC path). 903 // Pushed first so its message wins over later asks (first-of-behavior wins), 904 // but the reduce ensures any deny in decisions[] still beats it. 905 if (preParseAskDecision !== null) { 906 decisions.push(preParseAskDecision) 907 } 908 909 // Decision: security check — was step 3 (:630-650). 910 // powershellCommandIsSafe returns 'ask' for subexpressions, script blocks, 911 // encoded commands, download cradles, etc. Only 'ask' | 'passthrough'. 912 const safetyResult = powershellCommandIsSafe(command, parsed) 913 if (safetyResult.behavior !== 'passthrough') { 914 const decisionReason: PermissionDecisionReason = { 915 type: 'other' as const, 916 reason: 917 safetyResult.behavior === 'ask' && safetyResult.message 918 ? safetyResult.message 919 : 'This command contains patterns that could pose security risks and requires approval', 920 } 921 decisions.push({ 922 behavior: 'ask', 923 message: createPermissionRequestMessage( 924 POWERSHELL_TOOL_NAME, 925 decisionReason, 926 ), 927 decisionReason, 928 suggestions: suggestionForExactCommand(command), 929 }) 930 } 931 932 // Decision: using statements / script requirements — invisible to AST block walk. 933 // `using module ./evil.psm1` loads and executes a module's top-level script body; 934 // `using assembly ./evil.dll` loads a .NET assembly (module initializers run). 935 // `#Requires -Modules <name>` triggers module loading from PSModulePath. 936 // These are siblings of the named blocks on ScriptBlockAst, not children, so 937 // Process-BlockStatements and all downstream command walkers never see them. 938 // Without this check, a decoy cmdlet like Get-Process fills subCommands, 939 // bypassing the empty-statement fallback, and isReadOnlyCommand auto-allows. 940 if (parsed.hasUsingStatements) { 941 const decisionReason: PermissionDecisionReason = { 942 type: 'other' as const, 943 reason: 944 'Command contains a `using` statement that may load external code (module or assembly)', 945 } 946 decisions.push({ 947 behavior: 'ask', 948 message: createPermissionRequestMessage( 949 POWERSHELL_TOOL_NAME, 950 decisionReason, 951 ), 952 decisionReason, 953 suggestions: suggestionForExactCommand(command), 954 }) 955 } 956 if (parsed.hasScriptRequirements) { 957 const decisionReason: PermissionDecisionReason = { 958 type: 'other' as const, 959 reason: 960 'Command contains a `#Requires` directive that may trigger module loading', 961 } 962 decisions.push({ 963 behavior: 'ask', 964 message: createPermissionRequestMessage( 965 POWERSHELL_TOOL_NAME, 966 decisionReason, 967 ), 968 decisionReason, 969 suggestions: suggestionForExactCommand(command), 970 }) 971 } 972 973 // Decision: resolved-arg provider/UNC scan — was step 3.5 (:652-709). 974 // Provider paths (env:, HKLM:, function:) access non-filesystem resources. 975 // UNC paths can leak NTLM/Kerberos credentials on Windows. The raw-string 976 // UNC check above (pre-parse) misses backtick-escaped forms; cmd.args has 977 // backtick escapes resolved by the parser. Labeled loop breaks on FIRST 978 // match (same as the previous early-return). 979 // Provider prefix matches both the short form (`env:`, `HKLM:`) and the 980 // fully-qualified form (`Microsoft.PowerShell.Core\Registry::HKLM\...`). 981 // The optional `(?:[\w.]+\\)?` handles the module-qualified prefix; `::?` 982 // matches either single-colon drive syntax or double-colon provider syntax. 983 const NON_FS_PROVIDER_PATTERN = 984 /^(?:[\w.]+\\)?(env|hklm|hkcu|function|alias|variable|cert|wsman|registry)::?/i 985 function extractProviderPathFromArg(arg: string): string { 986 // Handle colon parameter syntax: -Path:env:HOME → extract 'env:HOME'. 987 // SECURITY: PowerShell's tokenizer accepts en-dash/em-dash/horizontal-bar 988 // (U+2013/2014/2015) as parameter prefixes. `–Path:env:HOME` (en-dash) 989 // must also strip the `–Path:` prefix or NON_FS_PROVIDER_PATTERN won't 990 // match (pattern is `^(env|...):` which fails on `–Path:env:...`). 991 let s = arg 992 if (s.length > 0 && PS_TOKENIZER_DASH_CHARS.has(s[0]!)) { 993 const colonIdx = s.indexOf(':', 1) // skip the leading dash 994 if (colonIdx > 0) { 995 s = s.substring(colonIdx + 1) 996 } 997 } 998 // Strip backtick escapes before matching: `Registry`::HKLM\...` has a 999 // backtick before `::` that the PS tokenizer removes at runtime but that 1000 // would otherwise prevent the ^-anchored pattern from matching. 1001 return s.replace(/`/g, '') 1002 } 1003 function providerOrUncDecisionForArg(arg: string): PermissionResult | null { 1004 const value = extractProviderPathFromArg(arg) 1005 if (NON_FS_PROVIDER_PATTERN.test(value)) { 1006 return { 1007 behavior: 'ask', 1008 message: `Command argument '${arg}' uses a non-filesystem provider path and requires approval`, 1009 } 1010 } 1011 if (containsVulnerableUncPath(value)) { 1012 return { 1013 behavior: 'ask', 1014 message: `Command argument '${arg}' contains a UNC path that could trigger network requests`, 1015 } 1016 } 1017 return null 1018 } 1019 providerScan: for (const statement of parsed.statements) { 1020 for (const cmd of statement.commands) { 1021 if (cmd.elementType !== 'CommandAst') continue 1022 for (const arg of cmd.args) { 1023 const decision = providerOrUncDecisionForArg(arg) 1024 if (decision !== null) { 1025 decisions.push(decision) 1026 break providerScan 1027 } 1028 } 1029 } 1030 if (statement.nestedCommands) { 1031 for (const cmd of statement.nestedCommands) { 1032 for (const arg of cmd.args) { 1033 const decision = providerOrUncDecisionForArg(arg) 1034 if (decision !== null) { 1035 decisions.push(decision) 1036 break providerScan 1037 } 1038 } 1039 } 1040 } 1041 } 1042 1043 // Decision: per-sub-command deny/ask rules — was step 4 (:711-803). 1044 // Each sub-command produces at most one decision (deny or ask). Deny rules 1045 // on LATER sub-commands still beat ask rules on EARLIER ones via the reduce. 1046 // No stash needed — the reduce structurally enforces deny > ask. 1047 // 1048 // SECURITY: Always build a canonical command string from AST-derived data 1049 // (element.name + space-joined args) and check rules against it too. Deny 1050 // and allow must use the same normalized form to close asymmetries: 1051 // - Invocation operators (`& 'Remove-Item' ./x`): raw text starts with `&`, 1052 // splitting on whitespace yields the operator, not the cmdlet name. 1053 // - Non-space whitespace (`rm\t./x`): raw prefix match uses `prefix + ' '` 1054 // (literal space), but PowerShell accepts any whitespace separator. 1055 // checkPermissionMode auto-allow (using AST cmd.name) WOULD match while 1056 // deny-rule match on raw text would miss — a deny-rule bypass. 1057 // - Module prefixes (`Microsoft.PowerShell.Management\Remove-Item`): 1058 // element.name has the module prefix stripped. 1059 for (const { text: subCmd, element } of allSubCommands) { 1060 // element.name is quote-stripped at the parser (transformCommandAst) so 1061 // `& 'Invoke-Expression' 'x'` yields name='Invoke-Expression', not 1062 // "'Invoke-Expression'". canonicalSubCmd is built from the same stripped 1063 // name, so deny-rule prefix matching on `Invoke-Expression:*` hits. 1064 const canonicalSubCmd = 1065 element.name !== '' ? [element.name, ...element.args].join(' ') : null 1066 1067 const subInput = { command: subCmd } 1068 const { matchingDenyRules: subDenyRules, matchingAskRules: subAskRules } = 1069 matchingRulesForInput(subInput, toolPermissionContext, 'prefix') 1070 let matchedDenyRule = subDenyRules[0] 1071 let matchedAskRule = subAskRules[0] 1072 1073 if (matchedDenyRule === undefined && canonicalSubCmd !== null) { 1074 const { 1075 matchingDenyRules: canonicalDenyRules, 1076 matchingAskRules: canonicalAskRules, 1077 } = matchingRulesForInput( 1078 { command: canonicalSubCmd }, 1079 toolPermissionContext, 1080 'prefix', 1081 ) 1082 matchedDenyRule = canonicalDenyRules[0] 1083 if (matchedAskRule === undefined) { 1084 matchedAskRule = canonicalAskRules[0] 1085 } 1086 } 1087 1088 if (matchedDenyRule !== undefined) { 1089 decisions.push({ 1090 behavior: 'deny', 1091 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 1092 decisionReason: { 1093 type: 'rule', 1094 rule: matchedDenyRule, 1095 }, 1096 }) 1097 } else if (matchedAskRule !== undefined) { 1098 decisions.push({ 1099 behavior: 'ask', 1100 message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 1101 decisionReason: { 1102 type: 'rule', 1103 rule: matchedAskRule, 1104 }, 1105 }) 1106 } 1107 } 1108 1109 // Decision: cd+git compound guard — was step 4.42 (:805-833). 1110 // When cd/Set-Location is paired with git, don't allow without prompting — 1111 // cd to a malicious directory makes git dangerous (fake hooks, bare repo 1112 // attacks). Collect-then-reduce keeps the improvement over BashTool: in 1113 // bash, cd+git (B9, line 1416) runs BEFORE sub-command deny (B11), so cd+git 1114 // ask masks deny. Here, both are in the same decision array; deny wins. 1115 // 1116 // SECURITY: NO cd-to-CWD no-op exclusion. A previous iteration excluded 1117 // `Set-Location .` as a no-op, but the "first non-dash arg" heuristic used 1118 // to extract the target is fooled by colon-bound params: 1119 // `Set-Location -Path:/etc .` — real target is /etc, heuristic sees `.`, 1120 // exclusion fires, bypass. The UX case (model emitting `Set-Location .; foo`) 1121 // is rare; the attack surface isn't worth the special-case. Any cd-family 1122 // cmdlet in the compound sets this flag, period. 1123 // Only flag compound cd when there are multiple sub-commands. A standalone 1124 // `Set-Location ./subdir` is not a TOCTOU risk (no later statement resolves 1125 // relative paths against stale cwd). Without this, standalone cd forces the 1126 // compound guard, suppressing the per-subcommand auto-allow path. (bug #25) 1127 const hasCdSubCommand = 1128 allSubCommands.length > 1 && 1129 allSubCommands.some(({ element }) => isCwdChangingCmdlet(element.name)) 1130 // Symlink-create compound guard (finding #18 / bug 001+004): when the 1131 // compound creates a filesystem link, subsequent writes through that link 1132 // land outside the validator's view. Same TOCTOU shape as cwd desync. 1133 const hasSymlinkCreate = 1134 allSubCommands.length > 1 && 1135 allSubCommands.some(({ element }) => isSymlinkCreatingCommand(element)) 1136 const hasGitSubCommand = allSubCommands.some( 1137 ({ element }) => resolveToCanonical(element.name) === 'git', 1138 ) 1139 if (hasCdSubCommand && hasGitSubCommand) { 1140 decisions.push({ 1141 behavior: 'ask', 1142 message: 1143 'Compound commands with cd/Set-Location and git require approval to prevent bare repository attacks', 1144 }) 1145 } 1146 1147 // cd+write compound guard — SUBSUMED by checkPathConstraints(compoundCommandHasCd). 1148 // Previously this block pushed 'ask' when hasCdSubCommand && hasAcceptEditsWrite, 1149 // but checkPathConstraints now receives hasCdSubCommand and pushes 'ask' for ANY 1150 // path operation (read or write) in a cd-compound — broader coverage at the path 1151 // layer (BashTool parity). The step-5 !hasCdSubCommand gates and modeValidation's 1152 // compound-cd guard remain as defense-in-depth for paths that don't reach 1153 // checkPathConstraints (e.g., cmdlets not in CMDLET_PATH_CONFIG). 1154 1155 // Decision: bare-git-repo guard — bash parity. 1156 // If cwd has HEAD/objects/refs/ without a valid .git/HEAD, Git treats 1157 // cwd as a bare repository and runs hooks from cwd. Attacker creates 1158 // hooks/pre-commit, deletes .git/HEAD, then any git subcommand runs it. 1159 // Port of BashTool readOnlyValidation.ts isCurrentDirectoryBareGitRepo. 1160 if (hasGitSubCommand && isCurrentDirectoryBareGitRepo()) { 1161 decisions.push({ 1162 behavior: 'ask', 1163 message: 1164 'Git command in a directory with bare-repository indicators (HEAD, objects/, refs/ in cwd without .git/HEAD). Git may execute hooks from cwd.', 1165 }) 1166 } 1167 1168 // Decision: git-internal-paths write guard — bash parity. 1169 // Compound command creates HEAD/objects/refs/hooks/ then runs git → the 1170 // git subcommand executes freshly-created malicious hooks. Check all 1171 // extracted write paths + redirection targets against git-internal patterns. 1172 // Port of BashTool commandWritesToGitInternalPaths, adapted for AST. 1173 if (hasGitSubCommand) { 1174 const writesToGitInternal = allSubCommands.some( 1175 ({ element, statement }) => { 1176 // Redirection targets on this sub-command (raw Extent.Text — quotes 1177 // and ./ intact; normalizer handles both) 1178 for (const r of element.redirections ?? []) { 1179 if (isGitInternalPathPS(r.target)) return true 1180 } 1181 // Write cmdlet args (new-item HEAD; mkdir hooks; set-content hooks/pre-commit) 1182 const canonical = resolveToCanonical(element.name) 1183 if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false 1184 // Raw arg text — normalizer strips colon-bound params, quotes, ./, case. 1185 // PS ArrayLiteralAst (`New-Item a,hooks/pre-commit`) surfaces as a single 1186 // comma-joined arg — split before checking. 1187 if ( 1188 element.args 1189 .flatMap(a => a.split(',')) 1190 .some(a => isGitInternalPathPS(a)) 1191 ) { 1192 return true 1193 } 1194 // Pipeline input: `"hooks/pre-commit" | New-Item -ItemType File` binds the 1195 // string to -Path at runtime. The path is in a non-CommandAst pipeline 1196 // element, not in element.args. The hasExpressionSource guard at step 5 1197 // already forces approval here; this check just adds the git-internal 1198 // warning text. 1199 if (statement !== null) { 1200 for (const c of statement.commands) { 1201 if (c.elementType === 'CommandAst') continue 1202 if (isGitInternalPathPS(c.text)) return true 1203 } 1204 } 1205 return false 1206 }, 1207 ) 1208 // Also check top-level file redirections (> hooks/pre-commit) 1209 const redirWritesToGitInternal = getFileRedirections(parsed).some(r => 1210 isGitInternalPathPS(r.target), 1211 ) 1212 if (writesToGitInternal || redirWritesToGitInternal) { 1213 decisions.push({ 1214 behavior: 'ask', 1215 message: 1216 '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.', 1217 }) 1218 } 1219 // SECURITY: Archive-extraction TOCTOU. isCurrentDirectoryBareGitRepo 1220 // checks at permission-eval time; `tar -xf x.tar; git status` extracts 1221 // bare-repo indicators AFTER the check, BEFORE git runs. Unlike write 1222 // cmdlets (where we inspect args for git-internal paths), archive 1223 // contents are opaque — any extraction in a compound with git must ask. 1224 const hasArchiveExtractor = allSubCommands.some(({ element }) => 1225 GIT_SAFETY_ARCHIVE_EXTRACTORS.has(element.name.toLowerCase()), 1226 ) 1227 if (hasArchiveExtractor) { 1228 decisions.push({ 1229 behavior: 'ask', 1230 message: 1231 '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.', 1232 }) 1233 } 1234 } 1235 1236 // .git/ writes are dangerous even WITHOUT a git subcommand — a planted 1237 // .git/hooks/pre-commit fires on the user's next commit. Unlike the 1238 // bare-repo check above (which gates on hasGitSubCommand because `hooks/` 1239 // is a common project dirname), `.git/` is unambiguous. 1240 { 1241 const found = 1242 allSubCommands.some(({ element }) => { 1243 for (const r of element.redirections ?? []) { 1244 if (isDotGitPathPS(r.target)) return true 1245 } 1246 const canonical = resolveToCanonical(element.name) 1247 if (!GIT_SAFETY_WRITE_CMDLETS.has(canonical)) return false 1248 return element.args.flatMap(a => a.split(',')).some(isDotGitPathPS) 1249 }) || getFileRedirections(parsed).some(r => isDotGitPathPS(r.target)) 1250 if (found) { 1251 decisions.push({ 1252 behavior: 'ask', 1253 message: 1254 'Command writes to .git/ — hooks or config planted there execute on the next git operation.', 1255 }) 1256 } 1257 } 1258 1259 // Decision: path constraints — was step 4.44 (:835-845). 1260 // The deny-capable check that was being masked by earlier asks. Returns 1261 // 'deny' when an Edit(...) deny rule matches an extracted path (pathValidation 1262 // lines ~994, 1088, 1160, 1210), 'ask' for paths outside working dirs, or 1263 // 'passthrough'. 1264 // 1265 // Thread hasCdSubCommand (BashTool compoundCommandHasCd parity): when the 1266 // compound contains a cwd-changing cmdlet, checkPathConstraints forces 'ask' 1267 // for any statement with path operations — relative paths resolve against the 1268 // stale validator cwd, not PowerShell's runtime cwd. This is the architectural 1269 // fix for the CWD-desync cluster (findings #3/#21/#27/#28), replacing the 1270 // per-auto-allow-site guards with a single gate at the path-resolution layer. 1271 const pathResult = checkPathConstraints( 1272 input, 1273 parsed, 1274 toolPermissionContext, 1275 hasCdSubCommand, 1276 ) 1277 if (pathResult.behavior !== 'passthrough') { 1278 decisions.push(pathResult) 1279 } 1280 1281 // Decision: exact allow (parse-succeeded case) — was step 4.45 (:861-867). 1282 // Matches BashTool ordering: sub-command deny → path constraints → exact 1283 // allow. Reduce enforces deny > ask > allow, so the exact allow only 1284 // surfaces when no deny or ask fired — same as sequential. 1285 // 1286 // SECURITY: nameType gate — mirrors the parse-failed guard at L696-700. 1287 // Input-side stripModulePrefix is unconditional: `scripts\Get-Content` 1288 // strips to `Get-Content`, canonicalCommand matches exact allow. Without 1289 // this gate, allow enters decisions[] and reduce returns it before step 5 1290 // can inspect nameType — PowerShell runs the local .ps1 file. The AST's 1291 // nameType for the first command element is authoritative when parse 1292 // succeeded; 'application' means a script/executable path, not a cmdlet. 1293 // SECURITY: Same argLeaksValue gate as the per-subcommand loop below 1294 // (finding #32). Without it, `PowerShell(Write-Output:*)` exact-matches 1295 // `Write-Output $env:ANTHROPIC_API_KEY`, pushes allow to decisions[], and 1296 // reduce returns it before the per-subcommand gate ever runs. The 1297 // allSubCommands.every check ensures NO command in the statement leaks 1298 // (a single-command exact-allow has one element; a pipeline has several). 1299 // 1300 // SECURITY: nameType gate must check ALL subcommands, not just [0] 1301 // (finding #10). canonicalCommand at L171 collapses `\n` → space, so 1302 // `code\n.\build.ps1` (two statements) matches exact rule 1303 // `PowerShell(code .\build.ps1)`. Checking only allSubCommands[0] lets the 1304 // second statement (nameType=application, a script path) through. Require 1305 // EVERY subcommand to have nameType !== 'application'. 1306 if ( 1307 exactMatchResult.behavior === 'allow' && 1308 allSubCommands[0] !== undefined && 1309 allSubCommands.every( 1310 sc => 1311 sc.element.nameType !== 'application' && 1312 !argLeaksValue(sc.text, sc.element), 1313 ) 1314 ) { 1315 decisions.push(exactMatchResult) 1316 } 1317 1318 // Decision: read-only allowlist — was step 4.5 (:869-885). 1319 // Mirrors Bash auto-allow for ls, cat, git status, etc. PowerShell 1320 // equivalents: Get-Process, Get-ChildItem, Get-Content, git log, etc. 1321 // Reduce places this below sub-command ask rules (ask > allow). 1322 if (isReadOnlyCommand(command, parsed)) { 1323 decisions.push({ 1324 behavior: 'allow', 1325 updatedInput: input, 1326 decisionReason: { 1327 type: 'other', 1328 reason: 'Command is read-only and safe to execute', 1329 }, 1330 }) 1331 } 1332 1333 // Decision: file redirections — was :887-900. 1334 // Redirections (>, >>, 2>) write to arbitrary paths. isReadOnlyCommand 1335 // already rejects redirections internally so this can't conflict with the 1336 // read-only allow above. Reduce places it above checkPermissionMode allow. 1337 const fileRedirections = getFileRedirections(parsed) 1338 if (fileRedirections.length > 0) { 1339 decisions.push({ 1340 behavior: 'ask', 1341 message: 1342 'Command contains file redirections that could write to arbitrary paths', 1343 suggestions: suggestionForExactCommand(command), 1344 }) 1345 } 1346 1347 // Decision: mode-specific handling (acceptEdits) — was step 4.7 (:902-906). 1348 // checkPermissionMode only returns 'allow' | 'passthrough'. 1349 const modeResult = checkPermissionMode(input, parsed, toolPermissionContext) 1350 if (modeResult.behavior !== 'passthrough') { 1351 decisions.push(modeResult) 1352 } 1353 1354 // REDUCE: deny > ask > allow > passthrough. First of each behavior type 1355 // wins (preserves step-order messaging for single-check cases). If nothing 1356 // decided, fall through to step 5 per-sub-command approval collection. 1357 const deniedDecision = decisions.find(d => d.behavior === 'deny') 1358 if (deniedDecision !== undefined) { 1359 return deniedDecision 1360 } 1361 const askDecision = decisions.find(d => d.behavior === 'ask') 1362 if (askDecision !== undefined) { 1363 return askDecision 1364 } 1365 const allowDecision = decisions.find(d => d.behavior === 'allow') 1366 if (allowDecision !== undefined) { 1367 return allowDecision 1368 } 1369 1370 // 5. Pipeline/statement splitting: check each sub-command independently. 1371 // This prevents a prefix rule like "Get-Process:*" from silently allowing 1372 // piped commands like "Get-Process | Stop-Process -Force". 1373 // Note: deny rules are already checked above (4.4), so this loop handles 1374 // ask rules, explicit allow rules, and read-only allowlist fallback. 1375 1376 // Filter out safe output cmdlets (Format-Table, etc.) — they were checked 1377 // for deny rules in step 4.4 but shouldn't need independent approval here. 1378 // Also filter out cd/Set-Location to CWD (model habit, Bash parity). 1379 const subCommands = allSubCommands.filter(({ element, isSafeOutput }) => { 1380 if (isSafeOutput) { 1381 return false 1382 } 1383 // SECURITY: nameType gate — sixth location. Filtering out of the approval 1384 // list is a form of auto-allow. scripts\\Set-Location . would match below 1385 // (stripped name 'Set-Location', arg '.' → CWD) and be silently dropped, 1386 // then scripts\\Set-Location.ps1 executes with no prompt. Keep 'application' 1387 // commands in the list so they reach isAllowlistedCommand (which rejects them). 1388 if (element.nameType === 'application') { 1389 return true 1390 } 1391 const canonical = resolveToCanonical(element.name) 1392 if (canonical === 'set-location' && element.args.length > 0) { 1393 // SECURITY: use PS_TOKENIZER_DASH_CHARS, not ASCII-only startsWith('-'). 1394 // `Set-Location –Path .` (en-dash) would otherwise treat `–Path` as the 1395 // target, resolve it against cwd (mismatch), and keep the command in the 1396 // approval list — correct. But `Set-Location –LiteralPath evil` with 1397 // en-dash would find `–LiteralPath` as "target", mismatch cwd, stay in 1398 // list — also correct. The risk is the inverse: a Unicode-dash parameter 1399 // being treated as the positional target. Use the tokenizer dash set. 1400 const target = element.args.find( 1401 a => a.length === 0 || !PS_TOKENIZER_DASH_CHARS.has(a[0]!), 1402 ) 1403 if (target && resolve(getCwd(), target) === getCwd()) { 1404 return false 1405 } 1406 } 1407 return true 1408 }) 1409 1410 // Note: cd+git compound guard already ran at step 4.42. If we reach here, 1411 // either there's no cd or no git in the compound. 1412 1413 const subCommandsNeedingApproval: string[] = [] 1414 // Statements whose sub-commands were PUSHED to subCommandsNeedingApproval 1415 // in the step-5 loop below. The fail-closed gate (after the loop) only 1416 // pushes statements NOT tracked here — prevents duplicate suggestions where 1417 // both "Get-Process" (sub-command) AND "$x = Get-Process" (full statement) 1418 // appear. 1419 // 1420 // SECURITY: track on PUSH only, not on loop entry. 1421 // If a statement's only sub-commands `continue` via user allow rules 1422 // (L1113), marking it seen at loop-entry would make the fail-closed gate 1423 // skip it — auto-allowing invisible non-CommandAst content like bare 1424 // `$env:SECRET` inside control flow. Example attack: user approves 1425 // Get-Process, then `if ($true) { Get-Process; $env:SECRET }` — Get-Process 1426 // is allow-ruled (continue, no push), $env:SECRET is VariableExpressionAst 1427 // (not a sub-command), statement marked seen → gate skips → auto-allow → 1428 // secret leaks. Tracking on push only: statement stays unseen → gate fires 1429 // → ask. 1430 const statementsSeenInLoop = new Set< 1431 ParsedPowerShellCommand['statements'][number] 1432 >() 1433 1434 for (const { text: subCmd, element, statement } of subCommands) { 1435 // Check deny rules FIRST - user explicit rules take precedence over allowlist 1436 const subInput = { command: subCmd } 1437 const subResult = powershellToolCheckPermission( 1438 subInput, 1439 toolPermissionContext, 1440 ) 1441 1442 if (subResult.behavior === 'deny') { 1443 return { 1444 behavior: 'deny', 1445 message: `Permission to use ${POWERSHELL_TOOL_NAME} with command ${command} has been denied.`, 1446 decisionReason: subResult.decisionReason, 1447 } 1448 } 1449 1450 if (subResult.behavior === 'ask') { 1451 if (statement !== null) { 1452 statementsSeenInLoop.add(statement) 1453 } 1454 subCommandsNeedingApproval.push(subCmd) 1455 continue 1456 } 1457 1458 // Explicitly allowed by a user rule — BUT NOT for applications/scripts. 1459 // SECURITY: INPUT-side stripModulePrefix is unconditional, so 1460 // `scripts\Get-Content /etc/shadow` strips to 'Get-Content' and matches 1461 // an allow rule `Get-Content:*`. Without the nameType guard, continue 1462 // skips all checks and the local script runs. nameType is classified from 1463 // the RAW name pre-strip — `scripts\Get-Content` → 'application' (has `\`). 1464 // Module-qualified cmdlets also classify 'application' — fail-safe over-fire. 1465 // An application should NEVER be auto-allowed by a cmdlet allow rule. 1466 if ( 1467 subResult.behavior === 'allow' && 1468 element.nameType !== 'application' && 1469 !hasSymlinkCreate 1470 ) { 1471 // SECURITY: User allow rule asserts the cmdlet is safe, NOT that 1472 // arbitrary variable expansion through it is safe. A user who allows 1473 // PowerShell(Write-Output:*) did not intend to auto-allow 1474 // `Write-Output $env:ANTHROPIC_API_KEY`. Apply the same argLeaksValue 1475 // gate that protects the built-in allowlist path below — rejects 1476 // Variable/Other/ScriptBlock/SubExpression elementTypes and colon-bound 1477 // expression children. (security finding #32) 1478 // 1479 // SECURITY: Also skip when the compound contains a symlink-creating 1480 // command (finding — symlink+read gap). New-Item -ItemType SymbolicLink 1481 // can redirect subsequent reads to arbitrary paths. The built-in 1482 // allowlist path (below) and acceptEdits path both gate on 1483 // !hasSymlinkCreate; the user-rule path must too. 1484 if (argLeaksValue(subCmd, element)) { 1485 if (statement !== null) { 1486 statementsSeenInLoop.add(statement) 1487 } 1488 subCommandsNeedingApproval.push(subCmd) 1489 continue 1490 } 1491 continue 1492 } 1493 if (subResult.behavior === 'allow') { 1494 // nameType === 'application' with a matching allow rule: the rule was 1495 // written for a cmdlet, but this is a script/executable masquerading. 1496 // Don't continue; fall through to approval (NOT deny — the user may 1497 // actually want to run `scripts\Get-Content` and will see a prompt). 1498 if (statement !== null) { 1499 statementsSeenInLoop.add(statement) 1500 } 1501 subCommandsNeedingApproval.push(subCmd) 1502 continue 1503 } 1504 1505 // SECURITY: fail-closed gate. Do NOT take the allowlist shortcut unless 1506 // the parent statement is a PipelineAst where every element is a 1507 // CommandAst. This subsumes the previous hasExpressionSource check 1508 // (expression sources are one way a statement fails the gate) and also 1509 // rejects assignments, chain operators, control flow, and any future 1510 // AST type by construction. Examples this blocks: 1511 // 'env:SECRET_API_KEY' | Get-Content — CommandExpressionAst element 1512 // $x = Get-Process — AssignmentStatementAst 1513 // Get-Process && Get-Service — PipelineChainAst 1514 // Explicit user allow rules (above) run before this gate but apply their 1515 // own argLeaksValue check; both paths now gate argument elementTypes. 1516 // 1517 // SECURITY: Also skip when the compound contains a cwd-changing cmdlet 1518 // (finding #27 — cd+read gap). isAllowlistedCommand validates Get-Content 1519 // in isolation, but `Set-Location ~; Get-Content ./.ssh/id_rsa` runs 1520 // Get-Content from ~, not from the validator's cwd. Path validation saw 1521 // /project/.ssh/id_rsa; runtime reads ~/.ssh/id_rsa. Same gate as the 1522 // checkPermissionMode call below and the checkPathConstraints threading. 1523 if ( 1524 statement !== null && 1525 !hasCdSubCommand && 1526 !hasSymlinkCreate && 1527 isProvablySafeStatement(statement) && 1528 isAllowlistedCommand(element, subCmd) 1529 ) { 1530 continue 1531 } 1532 1533 // Check per-sub-command acceptEdits mode (BashTool parity). 1534 // Delegate to checkPermissionMode on a single-statement AST so that ALL 1535 // of its guards apply: expression pipeline sources (non-CommandAst elements), 1536 // security flags (subexpressions, script blocks, assignments, splatting, etc.), 1537 // and the ACCEPT_EDITS_ALLOWED_CMDLETS allowlist. This keeps one source of 1538 // truth for what makes a statement safe in acceptEdits mode — any future 1539 // hardening of checkPermissionMode automatically applies here. 1540 // 1541 // Pass parsed.variables (not []) so splatting from any statement in the 1542 // compound command is visible. Conservative: if we can't tell which statement 1543 // a splatted variable affects, assume it affects all of them. 1544 // 1545 // SECURITY: Skip this auto-allow path when the compound contains a 1546 // cwd-changing command (Set-Location/Push-Location/Pop-Location). The 1547 // synthetic single-statement AST strips compound context, so 1548 // checkPermissionMode cannot see the cd in other statements. Without this 1549 // gate, `Set-Location ./.claude; Set-Content ./settings.json '...'` would 1550 // pass: Set-Content is checked in isolation, matches ACCEPT_EDITS_ALLOWED_CMDLETS, 1551 // and auto-allows — but PowerShell runs it from the changed cwd, writing to 1552 // .claude/settings.json (a Claude config file the path validator didn't check). 1553 // This matches BashTool's compoundCommandHasCd guard. 1554 if (statement !== null && !hasCdSubCommand && !hasSymlinkCreate) { 1555 const subModeResult = checkPermissionMode( 1556 { command: subCmd }, 1557 { 1558 valid: true, 1559 errors: [], 1560 variables: parsed.variables, 1561 hasStopParsing: parsed.hasStopParsing, 1562 originalCommand: subCmd, 1563 statements: [statement], 1564 }, 1565 toolPermissionContext, 1566 ) 1567 if (subModeResult.behavior === 'allow') { 1568 continue 1569 } 1570 } 1571 1572 // Not allowlisted, no mode auto-allow, and no explicit rule — needs approval 1573 if (statement !== null) { 1574 statementsSeenInLoop.add(statement) 1575 } 1576 subCommandsNeedingApproval.push(subCmd) 1577 } 1578 1579 // SECURITY: fail-closed gate (second half). The step-5 loop above only 1580 // iterates sub-commands that getSubCommandsForPermissionCheck surfaced 1581 // AND survived the safe-output filter. Statements that produce zero 1582 // CommandAst sub-commands (bare $env:SECRET) or whose only sub-commands 1583 // were filtered as safe-output ($env:X | Out-String) never enter the loop. 1584 // Without this, they silently auto-allow on empty subCommandsNeedingApproval. 1585 // 1586 // Only push statements NOT tracked above: if the loop PUSHED any 1587 // sub-command from a statement, the user will see a prompt. Pushing the 1588 // statement text too creates a duplicate suggestion where accepting the 1589 // sub-command rule does not prevent re-prompting. 1590 // If all sub-commands `continue`d (allow-ruled / allowlisted / mode-allowed) 1591 // the statement is NOT tracked and the gate re-checks it below — this is 1592 // the fail-closed property. 1593 for (const stmt of parsed.statements) { 1594 if (!isProvablySafeStatement(stmt) && !statementsSeenInLoop.has(stmt)) { 1595 subCommandsNeedingApproval.push(stmt.text) 1596 } 1597 } 1598 1599 if (subCommandsNeedingApproval.length === 0) { 1600 // SECURITY: empty-list auto-allow is only safe when there's nothing 1601 // unverifiable. If the pipeline has script blocks, every safe-output 1602 // cmdlet was filtered at :1032, but the block content wasn't verified — 1603 // non-command AST nodes (AssignmentStatementAst etc.) are invisible to 1604 // getAllCommands. `Where-Object {$true} | Sort-Object {$env:PATH='evil'}` 1605 // would auto-allow here. hasAssignments is top-level-only (parser.ts:1385) 1606 // so it doesn't catch nested assignments either. Prompt instead. 1607 if (deriveSecurityFlags(parsed).hasScriptBlocks) { 1608 return { 1609 behavior: 'ask', 1610 message: createPermissionRequestMessage(POWERSHELL_TOOL_NAME), 1611 decisionReason: { 1612 type: 'other', 1613 reason: 1614 'Pipeline consists of output-formatting cmdlets with script blocks — block content cannot be verified', 1615 }, 1616 } 1617 } 1618 return { 1619 behavior: 'allow', 1620 updatedInput: input, 1621 decisionReason: { 1622 type: 'other', 1623 reason: 'All pipeline commands are individually allowed', 1624 }, 1625 } 1626 } 1627 1628 // 6. Some sub-commands need approval — build suggestions 1629 const decisionReason = { 1630 type: 'other' as const, 1631 reason: 'This command requires approval', 1632 } 1633 1634 const pendingSuggestions: PermissionUpdate[] = [] 1635 for (const subCmd of subCommandsNeedingApproval) { 1636 pendingSuggestions.push(...suggestionForExactCommand(subCmd)) 1637 } 1638 1639 return { 1640 behavior: 'passthrough', 1641 message: createPermissionRequestMessage( 1642 POWERSHELL_TOOL_NAME, 1643 decisionReason, 1644 ), 1645 decisionReason, 1646 suggestions: pendingSuggestions, 1647 } 1648}