one-code-extension 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. package/README.md +219 -219
  2. package/agents/general-purpose.md +2 -2
  3. package/docs/guide/README.md +2 -1
  4. package/docs/guide/bring-your-claude-code-setup.md +60 -9
  5. package/docs/guide/configuration.md +48 -14
  6. package/docs/guide/differences-from-claude-code.md +32 -23
  7. package/docs/guide/doctor.md +20 -13
  8. package/docs/guide/hooks.md +18 -3
  9. package/docs/guide/installation.md +27 -4
  10. package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
  11. package/docs/guide/providers-and-models.md +137 -54
  12. package/docs/guide/reference.md +27 -15
  13. package/docs/guide/sessions-and-context.md +69 -16
  14. package/docs/guide/skills-plugins-and-mcp.md +57 -20
  15. package/docs/guide/subagents-and-workflows.md +95 -29
  16. package/docs/guide/tasks-and-background-work.md +101 -26
  17. package/docs/guide/terminal-interface.md +24 -12
  18. package/docs/guide/tools.md +46 -5
  19. package/docs/guide/troubleshooting.md +13 -9
  20. package/docs/guide/windows.md +66 -34
  21. package/extensions/artifacts/gallery.ts +227 -0
  22. package/extensions/artifacts/index.ts +288 -0
  23. package/extensions/artifacts/store.ts +570 -0
  24. package/extensions/artifacts/viewer.ts +199 -0
  25. package/extensions/ask-user/description.ts +67 -0
  26. package/extensions/ask-user/dialogs.ts +80 -0
  27. package/extensions/ask-user/index.ts +37 -33
  28. package/extensions/ask-user/questions.ts +32 -7
  29. package/extensions/ask-user/widget.ts +45 -25
  30. package/extensions/auto-mode/actions.ts +6 -0
  31. package/extensions/auto-mode/cache.ts +88 -0
  32. package/extensions/auto-mode/classifier-prompt.ts +5 -4
  33. package/extensions/auto-mode/classifier.ts +290 -65
  34. package/extensions/auto-mode/config.ts +72 -20
  35. package/extensions/auto-mode/decision-log.ts +2 -0
  36. package/extensions/auto-mode/defaults.ts +1 -1
  37. package/extensions/auto-mode/denials.ts +123 -0
  38. package/extensions/auto-mode/git-checkout-programs.ts +187 -0
  39. package/extensions/auto-mode/git-status-meta.ts +159 -0
  40. package/extensions/auto-mode/history.ts +189 -0
  41. package/extensions/auto-mode/instructions.ts +58 -52
  42. package/extensions/auto-mode/model-select.ts +159 -134
  43. package/extensions/auto-mode/paths.ts +57 -5
  44. package/extensions/auto-mode/pause.ts +57 -2
  45. package/extensions/auto-mode/prompt.ts +37 -21
  46. package/extensions/auto-mode/read-only-options.ts +724 -0
  47. package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
  48. package/extensions/auto-mode/rules.ts +18 -1
  49. package/extensions/auto-mode/safety-floor.ts +361 -18
  50. package/extensions/auto-mode/setup-run.ts +20 -14
  51. package/extensions/auto-mode/shell-analysis.ts +764 -490
  52. package/extensions/auto-mode/shell-parse.ts +885 -0
  53. package/extensions/auto-mode/system-note.ts +33 -0
  54. package/extensions/auto-mode/transcript.ts +80 -51
  55. package/extensions/background/cron.ts +515 -0
  56. package/extensions/background/index.ts +514 -236
  57. package/extensions/background/loop-fire.ts +136 -0
  58. package/extensions/background/loop-preamble.ts +59 -0
  59. package/extensions/background/loop-skill.ts +163 -0
  60. package/extensions/background/monitor-batch.ts +53 -0
  61. package/extensions/background/noop-fold.ts +128 -0
  62. package/extensions/background/switch-guard.ts +52 -0
  63. package/extensions/background/wakeup.ts +159 -55
  64. package/extensions/bash/background.ts +53 -17
  65. package/extensions/bash/child.ts +7 -0
  66. package/extensions/bash/description.ts +188 -0
  67. package/extensions/bash/guards.ts +15 -67
  68. package/extensions/bash/index.ts +88 -35
  69. package/extensions/branding/compaction-keep.ts +63 -0
  70. package/extensions/branding/declined.ts +22 -0
  71. package/extensions/branding/index.ts +133 -9
  72. package/extensions/branding/prompt-editor.ts +53 -6
  73. package/extensions/branding/queued-edit.ts +31 -0
  74. package/extensions/branding/replaced-builtins.ts +68 -0
  75. package/extensions/branding/startup.ts +33 -71
  76. package/extensions/btw/index.ts +229 -71
  77. package/extensions/btw/panel.ts +228 -100
  78. package/extensions/btw/prompt.ts +41 -4
  79. package/extensions/claude-compat/index.ts +7 -11
  80. package/extensions/claude-context/index.ts +446 -58
  81. package/extensions/compaction/cut.ts +157 -0
  82. package/extensions/compaction/index.ts +216 -75
  83. package/extensions/compaction/kept-thinking.ts +62 -0
  84. package/extensions/compaction/prompt.ts +15 -12
  85. package/extensions/context-budget/index.ts +24 -5
  86. package/extensions/context-management/index.ts +25 -0
  87. package/extensions/doctor/build.ts +59 -8
  88. package/extensions/doctor/builtins.ts +84 -0
  89. package/extensions/doctor/cli.ts +7 -1
  90. package/extensions/doctor/compat.ts +88 -23
  91. package/extensions/doctor/dependencies.ts +41 -5
  92. package/extensions/doctor/fix-prompt.ts +9 -2
  93. package/extensions/doctor/index.ts +102 -58
  94. package/extensions/doctor/models.ts +102 -63
  95. package/extensions/doctor/presets.ts +42 -27
  96. package/extensions/doctor/report.ts +19 -2
  97. package/extensions/doctor/viewer.ts +49 -27
  98. package/extensions/effort/index.ts +72 -19
  99. package/extensions/effort/settings.ts +21 -0
  100. package/extensions/effort/slider.ts +24 -20
  101. package/extensions/exit/index.ts +32 -1
  102. package/extensions/file-tracker/index.ts +337 -26
  103. package/extensions/file-tracker/replay.ts +96 -10
  104. package/extensions/file-tracker/restore.ts +63 -0
  105. package/extensions/file-tracker/results.ts +66 -0
  106. package/extensions/file-tracker/shell-reads.ts +211 -0
  107. package/extensions/file-tracker/tracker.ts +32 -15
  108. package/extensions/footer/footer-line.ts +45 -24
  109. package/extensions/footer/index.ts +47 -2
  110. package/extensions/footer/pr.ts +34 -5
  111. package/extensions/hooks/compaction.ts +7 -0
  112. package/extensions/hooks/executor.ts +32 -30
  113. package/extensions/hooks/index.ts +198 -31
  114. package/extensions/hooks/matcher.ts +3 -0
  115. package/extensions/hooks/protocol.ts +41 -3
  116. package/extensions/hooks/settings.ts +50 -19
  117. package/extensions/hooks/subagent-bridge.ts +2 -0
  118. package/extensions/hooks/trust.ts +13 -5
  119. package/extensions/idle-compact/index.ts +119 -0
  120. package/extensions/idle-compact/policy.ts +143 -0
  121. package/extensions/init/index.ts +11 -10
  122. package/extensions/init/prompt.ts +25 -32
  123. package/extensions/interrupted/index.ts +13 -1
  124. package/extensions/kept-thinking/index.ts +27 -0
  125. package/extensions/lib/agent-cron.ts +130 -0
  126. package/extensions/lib/agent-loader.ts +15 -1
  127. package/extensions/lib/agent-view.ts +85 -0
  128. package/extensions/lib/anthropic-payload.ts +82 -1
  129. package/extensions/lib/anthropic-server-call.ts +230 -0
  130. package/extensions/lib/anthropic-server-tools.ts +485 -0
  131. package/extensions/lib/app-launch.mjs +94 -0
  132. package/extensions/lib/argument-hints.ts +190 -0
  133. package/extensions/lib/atomic-write.ts +6 -2
  134. package/extensions/lib/auto-memory.ts +75 -0
  135. package/extensions/lib/bash-parser.ts +84 -0
  136. package/extensions/lib/btw-fork.ts +93 -0
  137. package/extensions/lib/child-extensions.ts +42 -0
  138. package/extensions/lib/child-writes.ts +45 -0
  139. package/extensions/lib/claude-context.ts +515 -100
  140. package/extensions/lib/claude-external-includes.ts +68 -0
  141. package/extensions/lib/claude-id.ts +22 -0
  142. package/extensions/lib/claude-rules.ts +323 -0
  143. package/extensions/lib/claude-settings.ts +76 -4
  144. package/extensions/lib/compaction-boundary.ts +36 -0
  145. package/extensions/lib/compaction-keep.mjs +83 -0
  146. package/extensions/lib/config-mode.ts +112 -0
  147. package/extensions/lib/consent-dialogs.ts +63 -0
  148. package/extensions/lib/consent-preview.ts +5 -1
  149. package/extensions/lib/consent-stores.ts +48 -0
  150. package/extensions/lib/context-facts.ts +78 -0
  151. package/extensions/lib/context-stack.ts +81 -0
  152. package/extensions/lib/deferred.ts +219 -25
  153. package/extensions/lib/dock-rows.ts +60 -0
  154. package/extensions/lib/empty-tool-result.ts +27 -0
  155. package/extensions/lib/environment-block.ts +76 -0
  156. package/extensions/lib/fetch-timeout.ts +24 -6
  157. package/extensions/{system-prompt → lib}/git-status.ts +51 -13
  158. package/extensions/lib/git.ts +75 -13
  159. package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
  160. package/extensions/lib/grammars/README.md +2 -0
  161. package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
  162. package/extensions/lib/guide-docs.ts +45 -0
  163. package/extensions/lib/headless-output.ts +46 -0
  164. package/extensions/lib/hook-gate.ts +7 -7
  165. package/extensions/lib/interrupt.ts +20 -4
  166. package/extensions/lib/key-input.ts +455 -0
  167. package/extensions/lib/keys.ts +14 -1
  168. package/extensions/lib/local-command.ts +47 -13
  169. package/extensions/lib/mcp-overrides.ts +1 -1
  170. package/extensions/lib/mcp-share.ts +46 -0
  171. package/extensions/lib/memory-content.ts +146 -0
  172. package/extensions/lib/memory-index.ts +18 -0
  173. package/extensions/lib/memory.ts +134 -123
  174. package/extensions/lib/model-catalog/huggingface.json +174 -0
  175. package/extensions/lib/model-catalog/models-dev.json +18297 -0
  176. package/extensions/lib/model-catalog/openrouter.json +5252 -0
  177. package/extensions/lib/model-catalog-data.ts +513 -0
  178. package/extensions/lib/model-catalog.ts +462 -0
  179. package/extensions/lib/model-choice-warnings.ts +137 -0
  180. package/extensions/lib/model-pairings.ts +152 -0
  181. package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
  182. package/extensions/lib/model-policy.ts +112 -15
  183. package/extensions/lib/model-tier.ts +283 -355
  184. package/extensions/lib/model-unusable.ts +1 -1
  185. package/extensions/lib/newer-model.ts +100 -0
  186. package/extensions/lib/notices.ts +87 -0
  187. package/extensions/lib/notifications.ts +308 -64
  188. package/extensions/lib/one-code-settings.ts +74 -8
  189. package/extensions/lib/open-browser.ts +63 -0
  190. package/extensions/lib/openrouter-generation.ts +94 -0
  191. package/extensions/lib/operation-deadline.ts +30 -0
  192. package/extensions/lib/original-command.ts +4 -4
  193. package/extensions/lib/package-version.ts +1 -1
  194. package/extensions/lib/panel-focus.ts +134 -0
  195. package/extensions/lib/paths.ts +17 -2
  196. package/extensions/lib/permission-gate.ts +72 -6
  197. package/extensions/lib/persisted-output.ts +60 -8
  198. package/extensions/lib/pi-ai-estimate.ts +19 -3
  199. package/extensions/lib/pi-version.ts +21 -2
  200. package/extensions/lib/plan-mode-channels.ts +20 -5
  201. package/extensions/lib/plugins.ts +26 -17
  202. package/extensions/lib/powershell-parse-script.ts +208 -0
  203. package/extensions/lib/powershell-parser.ts +344 -0
  204. package/extensions/lib/process-tree.ts +131 -8
  205. package/extensions/lib/prompt-options.ts +82 -0
  206. package/extensions/lib/queued-delivery.ts +44 -0
  207. package/extensions/lib/reminders.ts +364 -58
  208. package/extensions/lib/replaced-builtins.mjs +199 -0
  209. package/extensions/lib/replay-call.ts +70 -0
  210. package/extensions/lib/request-replay.ts +439 -0
  211. package/extensions/lib/scratchpad.ts +81 -14
  212. package/extensions/lib/send-now-channel.ts +7 -0
  213. package/extensions/lib/session-model-tier.ts +51 -0
  214. package/extensions/lib/session-work.ts +64 -0
  215. package/extensions/lib/settings-channels.ts +4 -4
  216. package/extensions/lib/shell-exit-semantics.ts +170 -0
  217. package/extensions/lib/shell-quote.ts +11 -0
  218. package/extensions/lib/shell-result.ts +70 -0
  219. package/extensions/lib/shell-spawn.ts +70 -6
  220. package/extensions/lib/shell-tool.ts +75 -25
  221. package/extensions/lib/side-call-cache.ts +124 -0
  222. package/extensions/lib/side-call-run.ts +96 -0
  223. package/extensions/lib/side-call-usage.ts +38 -0
  224. package/extensions/lib/side-call.ts +13 -1
  225. package/extensions/lib/single-flight.ts +18 -0
  226. package/extensions/lib/skill-body.ts +42 -0
  227. package/extensions/lib/skill-scan.ts +63 -24
  228. package/extensions/lib/spill-file.ts +140 -0
  229. package/extensions/lib/subagent-handback.ts +113 -0
  230. package/extensions/lib/system-role.ts +156 -0
  231. package/extensions/lib/terminal-text.ts +114 -0
  232. package/extensions/lib/text-width.ts +47 -16
  233. package/extensions/lib/timer-ops.ts +20 -0
  234. package/extensions/lib/tool-additions.ts +149 -0
  235. package/extensions/lib/tool-call-corruption.ts +134 -0
  236. package/extensions/lib/tool-path.ts +66 -0
  237. package/extensions/lib/tool-variants.ts +69 -0
  238. package/extensions/lib/tui-render.ts +255 -27
  239. package/extensions/lib/turn-budget-layout.ts +143 -0
  240. package/extensions/lib/update-check.mjs +15 -2
  241. package/extensions/lib/usage-bus.ts +1 -1
  242. package/extensions/lib/user-denial.ts +38 -0
  243. package/extensions/lib/workspace-channel.ts +16 -0
  244. package/extensions/lib/worktree-channel.ts +31 -3
  245. package/extensions/lib/worktree-isolation.ts +40 -10
  246. package/extensions/lsp/client.ts +8 -9
  247. package/extensions/lsp/index.ts +70 -4
  248. package/extensions/lsp/keep-alive.ts +1 -1
  249. package/extensions/lsp/trust.ts +119 -0
  250. package/extensions/lsp/watcher.ts +1 -1
  251. package/extensions/mcp/announce.ts +87 -5
  252. package/extensions/mcp/client.ts +161 -48
  253. package/extensions/mcp/config.ts +132 -8
  254. package/extensions/mcp/discovery.ts +19 -0
  255. package/extensions/mcp/index.ts +209 -71
  256. package/extensions/mcp/oauth/browser.ts +38 -17
  257. package/extensions/mcp/oauth/flow.ts +25 -17
  258. package/extensions/mcp/oauth/provider.ts +14 -8
  259. package/extensions/mcp/oauth/store.ts +34 -16
  260. package/extensions/mcp/panel/keys.ts +12 -11
  261. package/extensions/mcp/panel/render.ts +9 -6
  262. package/extensions/mcp/schema.ts +104 -5
  263. package/extensions/mcp/trust.ts +151 -67
  264. package/extensions/memory/entries.ts +23 -14
  265. package/extensions/memory/index.ts +134 -28
  266. package/extensions/memory/open-external.ts +33 -18
  267. package/extensions/memory/panel.ts +135 -26
  268. package/extensions/newer-model/index.ts +31 -0
  269. package/extensions/notebook/description.ts +18 -0
  270. package/extensions/notebook/index.ts +6 -5
  271. package/extensions/notebook/notebook.ts +1 -1
  272. package/extensions/permissions/index.ts +1300 -381
  273. package/extensions/permissions/matcher.ts +425 -74
  274. package/extensions/permissions/modes.ts +6 -4
  275. package/extensions/permissions/panel/host.ts +130 -0
  276. package/extensions/permissions/panel/keys.ts +48 -0
  277. package/extensions/permissions/panel/render.ts +450 -0
  278. package/extensions/permissions/panel/state.ts +550 -0
  279. package/extensions/permissions/powershell-cmdlets.ts +198 -0
  280. package/extensions/permissions/powershell-paths.ts +197 -0
  281. package/extensions/permissions/powershell-rules.ts +207 -280
  282. package/extensions/permissions/powershell-tree.ts +557 -0
  283. package/extensions/permissions/project-trust.ts +27 -11
  284. package/extensions/permissions/protected-paths.ts +14 -2
  285. package/extensions/permissions/session-grant.ts +6 -0
  286. package/extensions/permissions/settings.ts +166 -31
  287. package/extensions/permissions/subagent-gate.ts +25 -0
  288. package/extensions/permissions/workspace.ts +78 -0
  289. package/extensions/plan-mode/index.ts +156 -69
  290. package/extensions/plan-mode/reminder.ts +89 -21
  291. package/extensions/plan-mode/texts.ts +177 -0
  292. package/extensions/plan-mode/viewer.ts +92 -27
  293. package/extensions/plugins/counts.ts +3 -3
  294. package/extensions/plugins/index.ts +61 -46
  295. package/extensions/plugins/install/install.ts +16 -0
  296. package/extensions/plugins/marketplace/sync.ts +9 -3
  297. package/extensions/plugins/marketplace/types.ts +3 -1
  298. package/extensions/plugins/panel/keys.ts +21 -24
  299. package/extensions/plugins/panel/render.ts +12 -6
  300. package/extensions/plugins/shell-expand.ts +107 -0
  301. package/extensions/plugins/template.ts +32 -15
  302. package/extensions/powershell/child.ts +7 -0
  303. package/extensions/powershell/description.ts +38 -6
  304. package/extensions/powershell/guards.ts +1 -1
  305. package/extensions/powershell/index.ts +12 -7
  306. package/extensions/recap/index.ts +65 -60
  307. package/extensions/recap/prompt.ts +2 -2
  308. package/extensions/recap/scheduler.ts +3 -5
  309. package/extensions/search-tools/grep-merge.ts +39 -0
  310. package/extensions/search-tools/index.ts +0 -0
  311. package/extensions/send-now/chord.ts +62 -0
  312. package/extensions/send-now/index.ts +133 -0
  313. package/extensions/session-title/index.ts +3 -0
  314. package/extensions/session-title/title.ts +4 -1
  315. package/extensions/skill/code-review.ts +97 -0
  316. package/extensions/skill/index.ts +421 -101
  317. package/extensions/skill/invoke.ts +128 -18
  318. package/extensions/skill/listing.ts +133 -9
  319. package/extensions/skill/panel/keys.ts +15 -16
  320. package/extensions/skill/panel/render.ts +5 -2
  321. package/extensions/skill/panel/state.ts +12 -1
  322. package/extensions/subagents/agent-args.ts +39 -0
  323. package/extensions/subagents/agent-description.ts +132 -0
  324. package/extensions/subagents/agents.ts +116 -23
  325. package/extensions/subagents/announce.ts +69 -0
  326. package/extensions/subagents/default-model.ts +2 -3
  327. package/extensions/subagents/delegation-steer.ts +4 -4
  328. package/extensions/subagents/fork-cache.ts +76 -0
  329. package/extensions/subagents/guide-agent.ts +189 -0
  330. package/extensions/subagents/hand-back-review.ts +3 -0
  331. package/extensions/subagents/index.ts +1493 -824
  332. package/extensions/subagents/live-runs.ts +26 -6
  333. package/extensions/subagents/model-select.ts +38 -25
  334. package/extensions/subagents/outcome.ts +2 -2
  335. package/extensions/subagents/panel-keys.ts +73 -31
  336. package/extensions/subagents/panel-render.ts +120 -96
  337. package/extensions/subagents/panel-widget.ts +60 -89
  338. package/extensions/subagents/runner.ts +108 -80
  339. package/extensions/subagents/runs.ts +39 -5
  340. package/extensions/subagents/send-to-main-tool.ts +1 -1
  341. package/extensions/subagents/session-turns.ts +32 -8
  342. package/extensions/subagents/shell-panel.ts +36 -178
  343. package/extensions/subagents/tasks-dialog.ts +170 -0
  344. package/extensions/subagents/viewport-mirror.ts +95 -0
  345. package/extensions/subagents/worktree.ts +57 -13
  346. package/extensions/system-prompt/environment.ts +19 -19
  347. package/extensions/system-prompt/idle-turn.ts +65 -0
  348. package/extensions/system-prompt/index.ts +131 -47
  349. package/extensions/system-prompt/template.ts +24 -50
  350. package/extensions/system-prompt/tiers/common.ts +23 -64
  351. package/extensions/system-prompt/tiers/frontier.ts +32 -11
  352. package/extensions/system-prompt/tiers/low.ts +67 -45
  353. package/extensions/system-prompt/tiers/mid.ts +96 -38
  354. package/extensions/system-reminder/index.ts +175 -13
  355. package/extensions/tasks/descriptions.ts +190 -0
  356. package/extensions/tasks/index.ts +120 -41
  357. package/extensions/tasks/store.ts +83 -16
  358. package/extensions/tool-call-corruption/child.ts +7 -0
  359. package/extensions/tool-call-corruption/index.ts +237 -0
  360. package/extensions/tool-search/announce.ts +16 -0
  361. package/extensions/tool-search/index.ts +189 -41
  362. package/extensions/tool-style/index.ts +68 -10
  363. package/extensions/tool-style/write-description.ts +40 -0
  364. package/extensions/tool-style/write-render.ts +237 -0
  365. package/extensions/turn-duration/index.ts +7 -3
  366. package/extensions/turn-duration/line.ts +47 -4
  367. package/extensions/web/backends.ts +6 -9
  368. package/extensions/web/description.ts +50 -0
  369. package/extensions/web/index.ts +118 -53
  370. package/extensions/web/native-call.ts +15 -0
  371. package/extensions/web/native-results.ts +41 -0
  372. package/extensions/web-fetch/description.ts +42 -0
  373. package/extensions/web-fetch/extract.ts +53 -6
  374. package/extensions/web-fetch/index.ts +125 -76
  375. package/extensions/web-fetch/page-content.ts +19 -0
  376. package/extensions/web-fetch/response.ts +49 -0
  377. package/extensions/web-fetch/summarize.ts +9 -10
  378. package/extensions/workflow/agent-session.ts +105 -18
  379. package/extensions/workflow/args.ts +24 -0
  380. package/extensions/workflow/description.ts +67 -0
  381. package/extensions/workflow/globals.ts +17 -3
  382. package/extensions/workflow/index.ts +145 -162
  383. package/extensions/workflow/records.ts +1 -0
  384. package/extensions/workflow/run-manager.ts +29 -6
  385. package/extensions/workflow/saved-workflows.ts +3 -2
  386. package/extensions/workflow/types.ts +22 -1
  387. package/extensions/workflow/viewer.ts +54 -24
  388. package/extensions/workflow/widget.ts +20 -0
  389. package/extensions/worktree/descriptions.ts +91 -0
  390. package/extensions/worktree/guards.ts +316 -54
  391. package/extensions/worktree/index.ts +121 -39
  392. package/extensions/worktree/powershell-guards.ts +167 -0
  393. package/extensions/worktree/rewrite.ts +14 -16
  394. package/package.json +19 -7
  395. package/skills/artifact-design/SKILL.md +85 -0
  396. package/skills/code-review/SKILL.md +20 -20
  397. package/skills/code-review/cells/arguments-and-flags.md +19 -0
  398. package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
  399. package/skills/code-review/cells/high-8-inline.md +122 -0
  400. package/skills/code-review/cells/low-1-pass-max4.md +27 -0
  401. package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
  402. package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
  403. package/skills/code-review/cells/medium-3x5-verify.md +132 -0
  404. package/skills/code-review/cells/minimal-prompt.md +32 -0
  405. package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
  406. package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
  407. package/skills/fewer-permission-prompts/SKILL.md +1 -1
  408. package/skills/loop/SKILL.md +11 -0
  409. package/skills/security-review/SKILL.md +2 -5
  410. package/skills/simplify/SKILL.md +7 -5
  411. package/skills/workflow-authoring/SKILL.md +143 -0
  412. package/themes/onecode-light.json +1 -1
  413. package/themes/onecode.json +1 -1
  414. package/types/app.d.ts +58 -0
  415. package/types/community-packages.d.ts +26 -1
  416. package/extensions/auto-mode/recoverability.ts +0 -229
  417. package/extensions/lib/capability-index.ts +0 -466
  418. package/extensions/lib/model-facts.generated.json +0 -2675
  419. package/extensions/lib/model-facts.ts +0 -162
@@ -32,7 +32,24 @@
32
32
 
33
33
  import { isProtectedPath } from "../permissions/protected-paths.ts";
34
34
  import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
35
- import { isWithin, resolveForContainment, toAbsoluteBash } from "./paths.ts";
35
+ import { isWithin, resolveForContainment, toAbsoluteBash, touchesGuardedPath } from "./paths.ts";
36
+ import { checkoutGitRunsProgram, READ_HOOKS } from "./git-checkout-programs.ts";
37
+ import { lstatSync, readdirSync, statSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import {
40
+ GIT_GLOBAL_SAFE,
41
+ GIT_SUBCOMMAND_SPECS,
42
+ PATTERN_OPTIONS,
43
+ READ_ONLY_SPECS,
44
+ checkFind,
45
+ checkOptions,
46
+ gitOperandsAllowed,
47
+ table,
48
+ type OptionSpec,
49
+ } from "./read-only-options.ts";
50
+ import { parseCommand, scopedTracker, type Segment, type Token } from "./shell-parse.ts";
51
+
52
+ export { decodeAnsiC, LOOPS, parseCommand, scopedTracker, type ParseResult, type Segment, type Token } from "./shell-parse.ts";
36
53
 
37
54
  export type ShellVerdict = "safe" | "escalate";
38
55
 
@@ -79,74 +96,33 @@ export interface ShellEvidence {
79
96
  */
80
97
  readOnlyOutside: boolean;
81
98
  /**
82
- * True when the ONLY reason this escalated is an in-project filesystem
83
- * mutation/deletion — every path is inside the working directory and resolved,
84
- * nothing touches the network, no credential/execution-primitive path, no
85
- * unknown command, interpreter, glob, xargs, `cd`, or unmodelled syntax. The
86
- * containment gate uses this to decide whether a git-recoverability check may
87
- * clear the command (see auto-mode/recoverability.ts). Never true when the
88
- * verdict is "safe" (nothing escalated) — it only qualifies an escalation.
99
+ * True when every reason this escalated is a read the classifier should
100
+ * see: an outside-cwd read, or a read that names a credential or an
101
+ * execution-primitive path (`find -name '*.pem'`, `cat Makefile`). No write,
102
+ * mutation, network, unknown command or unmodelled syntax, so nothing here
103
+ * can write a file. Never true for a "safe" verdict.
89
104
  */
90
- containedNonNetwork: boolean;
105
+ readsOnly: boolean;
91
106
  /**
92
- * True for a whole-working-tree destructive git op (`git reset --hard`), whose
93
- * recoverability is judged against the whole tree's cleanliness rather than
94
- * named paths.
107
+ * True when the ONLY reason this escalated is Claude Code's acceptEdits file
108
+ * commands (`ACCEPT_EDITS_OPTIONS`) on the working space — every path is
109
+ * inside the working directory (or a `writableRoots` entry) and resolved,
110
+ * nothing touches the network, no credential/execution-primitive path, no
111
+ * unknown command, interpreter, glob, xargs, `cd`, or unmodelled syntax.
112
+ * Never true when the verdict is "safe" (nothing escalated) — it only
113
+ * qualifies an escalation.
95
114
  */
96
- wholeTree: boolean;
115
+ containedNonNetwork: boolean;
97
116
  }
98
117
 
99
- /** Commands that read and do not write, and are safe to fast-path. */
100
- const READ_ONLY_COMMANDS = new Set([
101
- "basename",
102
- "cat",
103
- "cksum",
104
- "column",
105
- "comm",
106
- "cut",
107
- "date",
108
- "diff",
109
- "dirname",
110
- "du",
111
- "echo",
112
- "false",
113
- "file",
114
- "fold",
115
- "head",
116
- "hostname",
117
- "id",
118
- "join",
119
- "jq",
120
- "ls",
121
- "md5sum",
122
- "nl",
123
- "od",
124
- "paste",
125
- // `printenv` and a bare `env` are NOT read-only for this purpose: they dump
126
- // the process environment, provider keys included (Claude Code removed both
127
- // from its read-only list for the same reason).
128
- "printf",
129
- "pwd",
130
- "readlink",
131
- "realpath",
132
- "rev",
133
- "rg",
134
- "sha1sum",
135
- "sha256sum",
136
- "shasum",
137
- "sort",
138
- "stat",
139
- "tail",
140
- "tr",
141
- "tree",
142
- "true",
143
- "uname",
144
- "uniq",
145
- "wc",
146
- "which",
147
- "whoami",
148
- "yes",
149
- ]);
118
+ /**
119
+ * Commands that read and do not write, and are safe to fast-path — each only
120
+ * with the options its table in read-only-options.ts names. `printenv` and a
121
+ * bare `env` are not here: they dump the process environment, provider keys
122
+ * included (Claude Code removed both from its read-only list for the same
123
+ * reason).
124
+ */
125
+ const READ_ONLY_COMMANDS = new Set(Object.keys(READ_ONLY_SPECS));
150
126
 
151
127
  /**
152
128
  * Read-only commands that take no file operands, so a path-looking positional
@@ -174,32 +150,83 @@ const NO_FILE_OPERANDS = new Set([
174
150
  /**
175
151
  * `grep` and friends take a pattern before their paths, so the first positional
176
152
  * is not a path token. Tracked separately to avoid classifying a regex as a file.
153
+ * `ag` and `ack` are not fast-pathed at all: both take a pager program, and ack
154
+ * reads a project `.ackrc` this check never sees.
177
155
  */
178
- const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]);
156
+ const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]);
157
+
158
+ interface WrapperSpec {
159
+ /** Options whose next word is a harmless value, skipped in both readings. */
160
+ values?: readonly string[];
161
+ /**
162
+ * Options whose value changes where or how the payload runs (`env -C`,
163
+ * `time -o`, `script -T`). The strict reading leaves the value in command
164
+ * position, so the command escalates; the wide reading skips it.
165
+ */
166
+ unsafeValues?: readonly string[];
167
+ /** Options whose value is a command line of its own (`flock -c`, `env -S`): the wide reading returns it. */
168
+ scripts?: readonly string[];
169
+ /** The first operand is a file, not the command (`flock LOCK cmd`, `script LOG cmd`). */
170
+ fileFirst?: boolean;
171
+ /** The first operand is a duration (`timeout 30 cmd`). */
172
+ durationFirst?: boolean;
173
+ /**
174
+ * Whether an in-project payload stays contained (`containedNonNetwork`).
175
+ * xargs takes its targets from stdin, and flock and script write the
176
+ * file they are given, which nothing write-checks.
177
+ */
178
+ contained: boolean;
179
+ /** Peeled only for deny/ask forms; the pre-gate treats it as an unknown command. */
180
+ denyFormsOnly?: boolean;
181
+ }
179
182
 
180
183
  /**
181
- * Commands that run whatever follows them. The original only knew three of
182
- * these and never looked past them, so `env rm -rf ~/Desktop` classified as a
183
- * harmless `env` (review finding N5). Every one of these is peeled and the
184
- * payload command is classified instead.
184
+ * Commands that run whatever follows them, and how each one takes its own
185
+ * options. The original only knew three and never looked past them, so `env
186
+ * rm -rf ~/Desktop` classified as a harmless `env` (review finding N5); one
187
+ * shared value-option list later read `setsid -c` (a flag) and `stdbuf -o 1M`
188
+ * wrong, so the real command slipped past deny rules
189
+ * (PREGATE-REVIEW-2026-09-23 P6). `sudo`/`doas` are peeled for deny forms
190
+ * only: the pre-gate peeling them would clear `sudo rm` as a contained delete.
185
191
  */
186
- const TRANSPARENT_WRAPPERS = new Set([
187
- "command",
188
- "env",
189
- "flock",
190
- "ionice",
191
- "nice",
192
- "nohup",
193
- "script",
194
- "setsid",
195
- "stdbuf",
196
- "time",
197
- "timeout",
198
- "xargs",
199
- ]);
200
-
201
- /** Options of wrapper commands that consume the following token as a value. */
202
- const WRAPPER_VALUE_OPTIONS = new Set(["-u", "-c", "-n", "-I", "-L", "-P", "--signal", "-s", "-k"]);
192
+ const WRAPPERS: Record<string, WrapperSpec> = {
193
+ // `builtin cd x` runs the builtin `cd` (PR #12 review: it hid a `cd` from the worktree guard).
194
+ builtin: { contained: true },
195
+ // Runs its argument as the command of that name (`busybox rm x`).
196
+ busybox: { contained: false, denyFormsOnly: true },
197
+ caffeinate: { values: ["-t", "-w"], contained: true, denyFormsOnly: true },
198
+ command: { contained: true },
199
+ doas: { values: ["-u", "-a"], unsafeValues: ["-C"], contained: false, denyFormsOnly: true },
200
+ exec: { values: ["-a"], contained: false, denyFormsOnly: true },
201
+ env: { values: ["-u", "--unset"], unsafeValues: ["-C", "--chdir"], scripts: ["-S", "--split-string"], contained: true },
202
+ flock: { values: ["-w", "--timeout", "-E", "--conflict-exit-code"], scripts: ["-c", "--command"], fileFirst: true, contained: false },
203
+ ionice: { values: ["-c", "--class", "-n", "--classdata"], contained: true },
204
+ nice: { values: ["-n", "--adjustment"], contained: true },
205
+ nohup: { contained: true },
206
+ script: {
207
+ values: ["-t", "-E", "--echo", "-m", "--logging-format"],
208
+ unsafeValues: ["-T", "--log-timing", "-I", "--log-in", "-O", "--log-out", "-B", "--log-io"],
209
+ scripts: ["-c", "--command"],
210
+ fileFirst: true,
211
+ contained: false,
212
+ },
213
+ setsid: { contained: true },
214
+ stdbuf: { values: ["-i", "-o", "-e", "--input", "--output", "--error"], contained: true },
215
+ sudo: {
216
+ values: ["-u", "-g", "-h", "-p", "-r", "-t", "-U", "-T", "-a", "--user", "--group", "--host", "--prompt", "--role", "--type", "--other-user", "--command-timeout", "--auth-type"],
217
+ unsafeValues: ["-C", "-D", "-R", "--close-from", "--chdir", "--chroot"],
218
+ contained: false,
219
+ denyFormsOnly: true,
220
+ },
221
+ time: { values: ["-f", "--format"], unsafeValues: ["-o", "--output"], contained: true },
222
+ timeout: { values: ["-s", "--signal", "-k", "--kill-after"], durationFirst: true, contained: true },
223
+ watch: { values: ["-n", "--interval", "-q", "--equexit"], contained: false, denyFormsOnly: true },
224
+ xargs: {
225
+ values: ["-I", "-L", "-n", "-P", "-s", "-E", "-d", "--delimiter", "--max-args", "--max-procs", "--max-chars", "--max-lines", "--eof"],
226
+ unsafeValues: ["-a", "--arg-file"],
227
+ contained: false,
228
+ },
229
+ };
203
230
 
204
231
  /**
205
232
  * Anything here writes, deletes, or fetches-and-writes. The list is only used to
@@ -230,46 +257,44 @@ const MUTATION_COMMANDS = new Set([
230
257
  "zip",
231
258
  ]);
232
259
 
260
+ /** The delete/truncate commands whose targets are every positional, bare names included. */
261
+ const DELETE_COMMANDS = new Set(["rm", "rmdir", "shred", "truncate"]);
262
+
233
263
  /**
234
- * The delete/truncate commands whose blast radius is exactly their (in-project,
235
- * resolved, concrete) path arguments, so an in-project use may be cleared by the
236
- * containment + git-recoverability gate rather than always reaching the
237
- * classifier. This is an *allowlist* for the "provably contained" conclusion —
238
- * omission is safe (the command still escalates to the classifier via the generic
239
- * mutation path). Overwrite/copy tools (cp, mv, dd, tee) are deliberately left
240
- * out for now: their destination semantics are subtler, so they keep classifying.
241
- * Membership here does not clear anything on its own: every target must resolve
242
- * inside the working directory and the recoverability check must pass.
264
+ * Claude Code's acceptEdits file commands (2.1.282, findings §36), with the
265
+ * options this check models; the only mutations that can be marked contained.
266
+ * Any other option, one carrying a value included (`cp -t dir`,
267
+ * `--target-directory=dir`, `mkdir -m`), leaves the command uncontained, as do
268
+ * symlink-following `cp -L`/`-H`. `sed` is on Claude Code's list but left out
269
+ * until its program is checked the way Claude Code checks it; Claude Code
270
+ * classified `sed -i '' …` anyway. This is an *allowlist* for the "provably
271
+ * contained" conclusion: omission only sends a command to the classifier.
243
272
  */
244
- const DELETE_COMMANDS = new Set(["rm", "rmdir", "shred", "truncate"]);
273
+ const ACCEPT_EDITS_OPTIONS: Record<string, OptionSpec> = {
274
+ rm: table({ none: "-f -R -r -i -v -I -d --force --recursive --verbose --dir --interactive" }),
275
+ rmdir: table({ none: "-p -v --parents --verbose" }),
276
+ mkdir: table({ none: "-p -v --parents --verbose" }),
277
+ touch: table({ none: "-a -c -m --no-create" }),
278
+ cp: table({ none: "-r -R -f -i -n -p -v -a -P --recursive --force --interactive --no-clobber --verbose --archive --no-dereference" }),
279
+ mv: table({ none: "-f -i -n -v --force --interactive --no-clobber --verbose" }),
280
+ };
281
+
282
+ /**
283
+ * The operands of an acceptEdits file command, or undefined when it carries an
284
+ * option `ACCEPT_EDITS_OPTIONS` does not model. After `--` every word is an operand.
285
+ */
286
+ function acceptEditsOperands(name: string, args: Token[]): Token[] | undefined {
287
+ const spec = ACCEPT_EDITS_OPTIONS[name];
288
+ if (!spec) return undefined;
289
+ const check = checkOptions(spec, args);
290
+ return check.ok ? check.parsed.positionals : undefined;
291
+ }
245
292
 
246
293
  /** Glob/other shell metacharacters we cannot enumerate, so a target carrying one is not "concrete". */
247
294
  function hasGlob(token: string): boolean {
248
295
  return /[*?\[\]]/.test(token);
249
296
  }
250
297
 
251
- /** `git reset --hard [ref]` — a whole-working-tree discard of uncommitted changes. */
252
- function isWholeTreeGitReset(args: Token[]): boolean {
253
- const positionals = args.filter((token) => !token.value.startsWith("-"));
254
- return positionals[0]?.value === "reset" && args.some((token) => token.value === "--hard");
255
- }
256
-
257
- /**
258
- * Whether git is being pointed at another repository or tree (`-C dir`,
259
- * `--git-dir[=]…`, `--work-tree[=]…`, `--namespace`, `--exec-path`). A
260
- * whole-tree op carrying one of these acts on THAT tree, so judging it against
261
- * the working directory would clear a reset of some other checkout.
262
- */
263
- function hasGitRetargetFlag(args: Token[]): boolean {
264
- return args.some((token) => {
265
- const value = token.value;
266
- if (!value.startsWith("-")) return false;
267
- if (GIT_GLOBAL_VALUE_FLAGS.has(value)) return true;
268
- const eq = value.indexOf("=");
269
- return eq > 0 && GIT_GLOBAL_VALUE_FLAGS.has(value.slice(0, eq));
270
- });
271
- }
272
-
273
298
  /**
274
299
  * Interpreters whose *scripts* can write files the token-level pass never sees
275
300
  * (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
@@ -312,32 +337,16 @@ const NETWORK_COMMANDS = new Set([
312
337
  ]);
313
338
 
314
339
  /**
315
- * git is **default-deny by subcommand**: only these read-only subcommands can be
316
- * fast-pathed, and everything else escalates. The original enumerated *mutating*
340
+ * git is **default-deny by subcommand and by option**: only the subcommands in
341
+ * `GIT_SUBCOMMAND_SPECS` (read-only-options.ts) can be fast-pathed, each only
342
+ * with the options its table names. The original enumerated *mutating*
317
343
  * subcommands and allowed the rest, so `git rm`, `git mv`, `git archive`,
318
- * `git config`, and `git update-ref` all slipped through (review finding N11).
319
- * Enumerating the safe set instead means a git subcommand added upstream
320
- * tomorrow escalates rather than being silently permitted.
344
+ * `git config`, and `git update-ref` all slipped through (review finding N11);
345
+ * until 2026-09-23 the options after an allowed subcommand were never looked
346
+ * at, so `git branch -D`, `git diff --output=f` and `git grep -O<prog>` passed
347
+ * as reads (SECURITY-REVIEW-2026-09-23 H1). `ls-remote` is not read-only: it
348
+ * contacts a remote.
321
349
  */
322
- const GIT_READ_ONLY_SUBCOMMANDS = new Set([
323
- "blame",
324
- "branch", // listing; mutation forms carry flags, and any flag we do not model escalates
325
- "cat-file",
326
- "describe",
327
- "diff",
328
- "grep",
329
- "log",
330
- "ls-files",
331
- "ls-remote",
332
- "ls-tree",
333
- "rev-parse",
334
- "shortlog",
335
- "show",
336
- "show-ref",
337
- "status",
338
- "tag", // listing only, same reasoning as branch
339
- ]);
340
-
341
350
  /**
342
351
  * git global flags that take a value. `git -c k=v <sub>` can turn a read into
343
352
  * code execution (`git -c protocol.ext.allow=always clone ext::sh …` — review
@@ -346,283 +355,148 @@ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
346
355
  */
347
356
  const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
348
357
 
349
- export interface Token {
350
- /** The token with quotes removed and `$'…'` decoded. */
351
- value: string;
352
- /** True when the token was written with any quoting or expansion syntax. */
353
- hadExpansion: boolean;
354
- }
355
-
356
- export interface Segment {
357
- tokens: Token[];
358
- /** Redirection targets found in this segment, in written order. */
359
- redirects: string[];
360
- raw: string;
361
- }
362
-
363
- /** Decode the escapes bash understands inside `$'…'`. */
364
- function decodeAnsiC(body: string): string {
365
- return body.replace(/\\(n|t|r|\\|'|"|a|b|f|v|0|x[0-9a-fA-F]{1,2})/g, (match, escape: string) => {
366
- switch (escape) {
367
- case "n":
368
- return "\n";
369
- case "t":
370
- return "\t";
371
- case "r":
372
- return "\r";
373
- case "\\":
374
- return "\\";
375
- case "'":
376
- return "'";
377
- case '"':
378
- return '"';
379
- case "a":
380
- return "\x07";
381
- case "b":
382
- return "\b";
383
- case "f":
384
- return "\f";
385
- case "v":
386
- return "\v";
387
- case "0":
388
- return "\0";
389
- default:
390
- return escape.startsWith("x") ? String.fromCharCode(Number.parseInt(escape.slice(1), 16)) : match;
391
- }
392
- });
393
- }
394
-
395
358
  /**
396
- * Syntax through which a command can run something other than what its
397
- * visible words say. The permission matcher refuses to let a prefix/wildcard
398
- * allow rule cover a command carrying any of it (the user approved `npm test
399
- * …`, not whatever `$(…)` evaluates to), and the pre-gate escalates on it.
359
+ * Commands that only print their arguments, so a command substitution among
360
+ * them is safe once the substituted command is: its output is printed, not
361
+ * read as an option, a path or a program (`echo "built $(date)"`). `echo`'s
362
+ * options only change how it prints. `printf` is not here: `printf $(echo
363
+ * -v) PATH ./bin` assigns a variable (PR #12 review).
400
364
  */
401
- export function hasInjectionSyntax(command: string): string | undefined {
402
- if (/\$\(/.test(command)) return "uses command substitution $( )";
403
- if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
404
- if (/[<>]\(/.test(command)) return "uses process substitution";
405
- if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
406
- if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
407
- if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
408
- return undefined;
409
- }
365
+ const PURE_OUTPUT = new Set(["echo"]);
410
366
 
411
- /** Syntax we do not model at all; its presence alone forces escalation. */
412
- export function hasUnmodelledSyntax(command: string): string | undefined {
413
- if (command.includes("\n")) return "spans multiple lines";
414
- const injection = hasInjectionSyntax(command);
415
- if (injection) return injection;
416
- if (/<<</.test(command)) return "uses a here-string";
417
- if (/<</.test(command)) return "uses a heredoc";
418
- // Brace expansion resolves to paths we cannot enumerate (review finding N3).
419
- if (/\{[^{}]*,[^{}]*\}/.test(command)) return "uses brace expansion, whose expanded paths cannot be checked";
420
- if (/\$\{?[A-Za-z_]/.test(command)) return "references environment variables, whose values are unknown here";
421
- return undefined;
367
+ /** A parameter bash sets itself (`$1`, `$@`, `$$`, `${#}`), as opposed to an environment variable. */
368
+ const SPECIAL_PARAMETER = /\$\{?[0-9@*#?$!-]/;
369
+
370
+ export interface Payload {
371
+ command: string;
372
+ args: Token[];
373
+ /** The wrappers peeled, outermost first. */
374
+ peeled: string[];
375
+ /** Command-position words spelled with a directory (`./cat`, `bin/ls`, `/bin/rm`), as written. */
376
+ pathNamed: string[];
377
+ /** Wide reading only: command lines a wrapper option runs (`flock -c '…'`, `env -S '…'`). */
378
+ scripts: string[];
422
379
  }
423
380
 
424
381
  /**
425
- * Split a command into pipeline/list segments and tokenize each. Unlike the
426
- * original, `<`, `>`, `&`, and `|` terminate a token even without surrounding
427
- * whitespace, so `cmd>file` and `a|b` are seen (review finding N4). An
428
- * unquoted newline ends a segment too (it is a command separator in bash).
382
+ * Peel wrappers to the command that actually runs (review finding N5).
383
+ *
384
+ * The two readings fail in opposite directions. `"strict"` (the default: the
385
+ * pre-gate and the guards) peels only what it fully understands, so anything
386
+ * else stays in command position and escalates. `"wide"` (deny/ask forms)
387
+ * also skips the values of options that change where the payload runs,
388
+ * returns the command lines wrapper options carry, and peels `sudo`/`doas`,
389
+ * because a deny rule should see every command a line might run.
429
390
  */
430
- export function parseCommand(command: string): { segments: Segment[]; parseFailed: boolean } {
431
- const segments: Segment[] = [];
432
- let tokens: Token[] = [];
433
- let redirects: string[] = [];
434
- let rawStart = 0;
435
-
436
- let current = "";
437
- let hadExpansion = false;
438
- let quoted = false;
439
- let inSingle = false;
440
- let inDouble = false;
441
- let escape = false;
442
- /** Set while consuming the token that follows a redirection operator. */
443
- let pendingRedirect = false;
444
-
445
- const pushToken = () => {
446
- if (!current && !quoted) return;
447
- if (pendingRedirect) {
448
- redirects.push(current);
449
- pendingRedirect = false;
450
- } else {
451
- tokens.push({ value: current, hadExpansion });
452
- }
453
- current = "";
454
- hadExpansion = false;
455
- quoted = false;
456
- };
457
-
458
- /** `at` = index of the separator; `width` = its length (`&&` is 2). */
459
- const pushSegment = (at: number, width = 1) => {
460
- pushToken();
461
- if (tokens.length > 0 || redirects.length > 0) {
462
- segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
463
- }
464
- tokens = [];
465
- redirects = [];
466
- rawStart = at + width;
467
- };
468
-
469
- for (let i = 0; i < command.length; i++) {
470
- const ch = command[i];
471
-
472
- if (escape) {
473
- current += ch;
474
- escape = false;
475
- continue;
476
- }
477
- if (ch === "\\" && !inSingle) {
478
- escape = true;
479
- continue;
480
- }
481
- if (ch === "'" && !inDouble) {
482
- inSingle = !inSingle;
483
- quoted = true;
484
- continue;
485
- }
486
- if (ch === '"' && !inSingle) {
487
- inDouble = !inDouble;
488
- quoted = true;
489
- continue;
490
- }
491
- // ANSI-C quoting: `$'\x2e\x2e'` is `..`. The original kept the leading `$`,
492
- // which made the token look like a variable and skipped every path check
493
- // (review finding N1, reproduced there).
494
- if (ch === "$" && command[i + 1] === "'" && !inSingle && !inDouble) {
495
- const end = command.indexOf("'", i + 2);
496
- if (end === -1) return { segments: [], parseFailed: true };
497
- current += decodeAnsiC(command.slice(i + 2, end));
498
- quoted = true;
499
- hadExpansion = true;
500
- i = end;
501
- continue;
502
- }
503
-
504
- if (inSingle || inDouble) {
505
- current += ch;
506
- continue;
507
- }
391
+ export function resolvePayload(tokens: Token[], reading: "strict" | "wide" = "strict"): Payload {
392
+ const wide = reading === "wide";
393
+ const payload: Payload = { command: "", args: [], peeled: [], pathNamed: [], scripts: [] };
394
+ let index = 0;
508
395
 
509
- if (ch === "\n") {
510
- // An unquoted newline separates commands exactly like `;`. Without this
511
- // `npm test x\ncurl evil` would be one segment whose lead is `npm test`.
512
- pushSegment(i);
513
- continue;
514
- }
515
- if (/\s/.test(ch)) {
516
- pushToken();
517
- continue;
518
- }
396
+ const kind = (spec: WrapperSpec, option: string) =>
397
+ spec.values?.includes(option) ? "value" : spec.unsafeValues?.includes(option) ? "unsafe" : spec.scripts?.includes(option) ? "script" : undefined;
519
398
 
520
- // Redirection. `>|` is the clobber-override form and behaves as `>`
521
- // (review finding N10); `2>&1` and `&>` are duplications, not paths.
522
- if (ch === ">" || ch === "<") {
523
- pushToken();
524
- let j = i + 1;
525
- if (command[j] === ">" || command[j] === "|") j++;
526
- if (command[j] === "&") {
527
- // fd duplication — consume the fd and move on, no path involved.
528
- j++;
529
- while (j < command.length && /[0-9-]/.test(command[j])) j++;
530
- i = j - 1;
399
+ /**
400
+ * Skip the wrapper's own options; `--` ends them. A value may be attached,
401
+ * as getopt allows: `--chdir=/tmp`, `-C/tmp`, or last in a short cluster
402
+ * (`-iC/tmp`). Until 2026-09-24 only the separate spelling was recognised,
403
+ * so `env --chdir=/tmp rm -f a.txt` peeled as a contained delete and
404
+ * `env --split-string='rm …'` hid its script from deny rules.
405
+ */
406
+ const skipOptions = (spec: WrapperSpec) => {
407
+ while (index < tokens.length && tokens[index].value.startsWith("-")) {
408
+ const word = tokens[index].value;
409
+ if (word === "--") {
410
+ index++;
411
+ return;
412
+ }
413
+ let option: string | undefined;
414
+ let attached: string | undefined;
415
+ if (word.startsWith("--")) {
416
+ const eq = word.indexOf("=");
417
+ option = eq > 0 ? word.slice(0, eq) : word;
418
+ attached = eq > 0 ? word.slice(eq + 1) : undefined;
419
+ } else {
420
+ for (let i = 1; i < word.length; i++) {
421
+ if (!kind(spec, `-${word[i]}`)) continue;
422
+ option = `-${word[i]}`;
423
+ attached = word.slice(i + 1) || undefined;
424
+ break;
425
+ }
426
+ }
427
+ const type = option === undefined ? undefined : kind(spec, option);
428
+ // Strict: an attached value that moves the payload or carries a script
429
+ // stays in command position, so the command escalates uncontained.
430
+ if (!wide && attached !== undefined && (type === "unsafe" || type === "script")) return;
431
+ index++;
432
+ if (type === undefined || attached !== undefined) {
433
+ if (wide && type === "script" && attached !== undefined) payload.scripts.push(attached);
531
434
  continue;
532
435
  }
533
- pendingRedirect = ch === ">";
534
- i = j - 1;
535
- continue;
436
+ if (index >= tokens.length) return;
437
+ if (type === "value") index++;
438
+ else if (wide && type === "unsafe") index++;
439
+ else if (wide && type === "script") payload.scripts.push(tokens[index++].value);
536
440
  }
537
-
538
- if (ch === "|" || ch === ";" || ch === "&") {
539
- // `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
540
- // next command is separate, which is all we need to know.
541
- const doubled = command[i + 1] === ch;
542
- pushSegment(i, doubled ? 2 : 1);
543
- if (doubled) i++;
544
- continue;
545
- }
546
-
547
- current += ch;
548
- }
549
-
550
- if (escape || inSingle || inDouble) return { segments: [], parseFailed: true };
551
- pushSegment(command.length);
552
- return { segments, parseFailed: false };
553
- }
554
-
555
- /** Peel wrappers to the command that actually runs (review finding N5). */
556
- export function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
557
- const peeled: string[] = [];
558
- let index = 0;
441
+ };
559
442
 
560
443
  for (;;) {
561
444
  // Leading `VAR=value` assignments are not the command.
562
445
  while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index].value)) index++;
563
- if (index >= tokens.length) return { command: "", args: [], peeled };
446
+ if (index >= tokens.length) return payload;
564
447
 
448
+ if (/[\\/]/.test(tokens[index].value)) payload.pathNamed.push(tokens[index].value);
565
449
  const name = commandName(tokens[index].value);
566
- if (!TRANSPARENT_WRAPPERS.has(name)) {
567
- return { command: name, args: tokens.slice(index + 1), peeled };
450
+ const spec = WRAPPERS[name];
451
+ if (!spec || (spec.denyFormsOnly && !wide)) {
452
+ payload.command = name;
453
+ payload.args = tokens.slice(index + 1);
454
+ return payload;
568
455
  }
569
456
 
570
- peeled.push(name);
457
+ payload.peeled.push(name);
571
458
  index++;
572
- // Step over the wrapper's own flags and their values.
573
- while (index < tokens.length && tokens[index].value.startsWith("-")) {
574
- const flag = tokens[index].value;
459
+ skipOptions(spec);
460
+ if (spec.fileFirst && index < tokens.length) {
575
461
  index++;
576
- if (WRAPPER_VALUE_OPTIONS.has(flag) && index < tokens.length) index++;
462
+ // `flock LOCK -c '…'`: options may follow the file too.
463
+ skipOptions(spec);
577
464
  }
578
- // `timeout 30 cmd` — a bare duration is not the payload.
579
- while (index < tokens.length && /^[0-9]+[smhd]?$/.test(tokens[index].value)) index++;
465
+ if (spec.durationFirst && index < tokens.length && /^[0-9.]+[smhd]?$/.test(tokens[index].value)) index++;
580
466
  }
581
467
  }
582
468
 
469
+ /** Whether every peeled wrapper keeps an in-project payload contained (see `WrapperSpec.contained`). */
470
+ function wrappersContained(peeled: readonly string[]): boolean {
471
+ return peeled.every((name) => WRAPPERS[name]?.contained);
472
+ }
473
+
474
+ /** Shells whose `-c` argument is a command line of its own. */
475
+ export const INLINE_SCRIPT_SHELLS: ReadonlySet<string> = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
476
+
477
+ /** Commands that change the shell's working directory. */
478
+ export const DIRECTORY_MOVERS: ReadonlySet<string> = new Set(["cd", "pushd", "popd"]);
479
+
480
+ /** Whether a segment's command (past `time` and wrappers) changes the shell's directory. */
481
+ export function movesDirectory(seg: Segment): boolean {
482
+ return DIRECTORY_MOVERS.has(resolvePayload(leadTokens(seg)).command);
483
+ }
484
+
583
485
  /**
584
- * A segment's tokens normalised for command-position checks: subshell parens
585
- * stripped from the head token only (`(cd` → `cd` — a later argument may
586
- * legitimately begin with `(`), leading `do`/`then`/`else` keywords skipped,
587
- * and as many trailing `)`s stripped off the last token as `(`s were opened at
588
- * the head, so `(git stash)` still matches subcommand `stash`. A subshell
589
- * whose closing paren lands in a *different* segment (`(a && git stash)`) is
590
- * a known limitation — the closer is only balanced within one segment.
591
- * Used by the bash/worktree guard pipelines, not by the escalation analyzer.
486
+ * A segment's tokens from its command word on, for command-position checks.
487
+ * `time` is a reserved word that the grammar reads as a command, so `time
488
+ * { rm x; }` arrives as the words `time { rm x` (and a `}` command): a leading
489
+ * `time`, its `-p`, and the `{`, `(` or `!` it times are skipped. The grammar
490
+ * has already taken that punctuation out of every other command. Used by the
491
+ * guards and the deny forms, not by the escalation analyzer.
592
492
  */
593
493
  export function leadTokens(seg: Segment): Token[] {
594
- const tokens = [...seg.tokens];
595
494
  let i = 0;
596
- let opened = 0;
597
- for (;;) {
598
- while (i < tokens.length) {
599
- const value = tokens[i].value;
600
- const lead = /^[({]+/.exec(value)?.[0] ?? "";
601
- if (lead.length === 0) break;
602
- opened += (lead.match(/\(/g) ?? []).length;
603
- const stripped = value.slice(lead.length);
604
- if (stripped.length === 0) {
605
- i++;
606
- continue;
607
- }
608
- tokens[i] = { ...tokens[i], value: stripped };
609
- break;
610
- }
611
- if (i < tokens.length && ["do", "then", "else"].includes(tokens[i].value)) {
612
- i++;
613
- continue;
614
- }
615
- break;
495
+ while (seg.tokens[i]?.value === "time") {
496
+ i++;
497
+ while (["-p", "--", "{", "(", "!"].includes(seg.tokens[i]?.value)) i++;
616
498
  }
617
- if (opened > 0 && tokens.length > i) {
618
- const last = tokens.length - 1;
619
- const trailing = /\)+$/.exec(tokens[last].value)?.[0];
620
- if (trailing) {
621
- const strip = Math.min(trailing.length, opened);
622
- tokens[last] = { ...tokens[last], value: tokens[last].value.slice(0, tokens[last].value.length - strip) };
623
- }
624
- }
625
- return tokens.slice(i);
499
+ return seg.tokens.slice(i);
626
500
  }
627
501
 
628
502
  /**
@@ -664,11 +538,137 @@ function looksLikePath(value: string): boolean {
664
538
  return value.startsWith("/") || value.includes("/");
665
539
  }
666
540
 
667
- /** `find` actions that execute or write (review finding N7 adds the last four). */
668
- function findHasAction(args: Token[]): boolean {
669
- return args.some(({ value }) =>
670
- ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fls", "-fprint", "-fprint0"].includes(value),
671
- );
541
+ /** Leading assignments that change nothing a command runs or reads: locale and display only. */
542
+ const INERT_ASSIGNMENTS = /^(LANG|LANGUAGE|LC_[A-Z]+|TZ|NO_COLOR|FORCE_COLOR|CLICOLOR|CLICOLOR_FORCE|TERM|COLUMNS|LINES)$/;
543
+
544
+ /**
545
+ * Whether an inert variable's VALUE keeps it inert. libc reads a file named by
546
+ * `TZ=:/path` or `TZ=/path`, and a locale name with a `/` is a path too, so
547
+ * only plain names pass: a zone name made of plain segments (`Asia/Tokyo`,
548
+ * read from the system zoneinfo) for TZ, and no `/` at all for the rest
549
+ * (PREGATE-REVIEW-2026-09-23 P4).
550
+ */
551
+ function isInertValue(name: string, value: string): boolean {
552
+ if (name === "TZ") return /^([A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*)?$/.test(value);
553
+ return /^[A-Za-z0-9_.,@:+-]*$/.test(value);
554
+ }
555
+
556
+ /**
557
+ * A `~` word other than `~` and `~/…`: bash expands `~name` to that user's
558
+ * home and `~-`/`~+` to `$OLDPWD`/`$PWD`, which `toAbsolute` would read as a
559
+ * directory literally named `~name` inside the working directory.
560
+ */
561
+ export function isUnknownTilde(value: string): boolean {
562
+ return value.startsWith("~") && value !== "~" && !value.startsWith("~/");
563
+ }
564
+
565
+ /**
566
+ * The paths a shell write must never land on, or take with it when it
567
+ * removes or moves a directory: protected tooling and agent configuration,
568
+ * the harness's `protectedDirs`, and credential and execution-primitive
569
+ * paths. Shared by the bash gate and the PowerShell gate (powershell-tree.ts).
570
+ */
571
+ export function isGuardedWritePath(path: string, cwd: string, protectedDirs: readonly string[] = []): boolean {
572
+ return isProtectedPath(path, cwd) || protectedDirs.some((dir) => isWithin(dir, path)) || isSensitivePath(path) || isExecutionPrimitivePath(path);
573
+ }
574
+
575
+ /**
576
+ * Whether removing or moving away `resolved` (spelled `absolute`) loses what
577
+ * git or the gate would keep: a working root, a `.git`, a directory holding
578
+ * one at any depth, or a guarded path or a directory holding one (`rm -rf .`,
579
+ * `mv .git x`, `rm -rf vendor` over `vendor/lib/.git`, `rm -rf .claude`).
580
+ */
581
+ export function removalLoses(absolute: string, resolved: string, roots: readonly string[], cwd: string, protectedDirs: readonly string[] = []): boolean {
582
+ const lost = (path: string) => path.split(/[\\/]/).includes(".git") || isGuardedWritePath(path, cwd, protectedDirs);
583
+ return roots.includes(resolved) || lost(absolute) || lost(resolved) || touchesGuardedPath(resolved, resolved, lost);
584
+ }
585
+
586
+ /** The last component of a bash path word, trailing slashes dropped (`a/b/` is `b`). */
587
+ function bashBasename(word: string): string {
588
+ const trimmed = word.replace(/\/+$/, "");
589
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1);
590
+ }
591
+
592
+ /** Whether a directory entry exists at `absolute` (a dangling symlink counts). */
593
+ function entryExists(absolute: string): boolean {
594
+ try {
595
+ lstatSync(absolute);
596
+ return true;
597
+ } catch {
598
+ return false;
599
+ }
600
+ }
601
+
602
+ /** Whether `resolved` (already realpath'd) is a directory. */
603
+ function isDirectory(resolved: string): boolean {
604
+ try {
605
+ return statSync(resolved).isDirectory();
606
+ } catch {
607
+ return false;
608
+ }
609
+ }
610
+
611
+ const FOLLOWS_DIR_ENTRY_SYMLINKS = "reads through symbolic links inside it, which can read outside the working directory";
612
+
613
+ /**
614
+ * A bash glob (one path component) as a RegExp: `*`, `?`, `[…]`/`[!…]`,
615
+ * everything else literal. Undefined when JavaScript cannot compile the
616
+ * bracket expression (`[z-a]`), which bash accepts and matches nothing with.
617
+ */
618
+ export function globComponentRegex(glob: string): RegExp | undefined {
619
+ let out = "";
620
+ for (let i = 0; i < glob.length; i++) {
621
+ const ch = glob[i];
622
+ if (ch === "*") out += "[^/]*";
623
+ else if (ch === "?") out += "[^/]";
624
+ else if (ch === "[") {
625
+ // A `]` first in the set, after any `!`/`^`, is a member, not the end.
626
+ const negation = glob[i + 1] === "!" || glob[i + 1] === "^" ? 1 : 0;
627
+ const close = glob.indexOf("]", i + 2 + negation);
628
+ if (close === -1) {
629
+ out += "\\[";
630
+ continue;
631
+ }
632
+ let body = glob.slice(i + 1, close);
633
+ const negated = body.startsWith("!") || body.startsWith("^");
634
+ if (negated) body = body.slice(1);
635
+ out += `[${negated ? "^" : ""}${body.replace(/[\\\]]/g, "\\$&")}]`;
636
+ i = close;
637
+ } else out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
638
+ }
639
+ try {
640
+ return new RegExp(`^${out}$`);
641
+ } catch {
642
+ return undefined;
643
+ }
644
+ }
645
+
646
+ /**
647
+ * The words bash expands a glob operand to, or undefined when the check cannot
648
+ * enumerate them: a glob in a directory component, a component starting with
649
+ * `.` (bash before 5.2 matches `.` and `..` with `.*` or `.?`), a bracket
650
+ * expression JavaScript cannot compile, or more than 2,000 matches. Only the last component is expanded, as bash would with
651
+ * `dotglob`, `globstar` and `nocaseglob` off (their non-interactive defaults).
652
+ * No match leaves the word as written, as bash does without `nullglob`.
653
+ */
654
+ function expandGlob(cwd: string, pattern: string, home: string): string[] | undefined {
655
+ const slash = pattern.lastIndexOf("/");
656
+ const dirPart = slash >= 0 ? pattern.slice(0, slash) : "";
657
+ const leaf = slash >= 0 ? pattern.slice(slash + 1) : pattern;
658
+ if (!leaf || /[*?[]/.test(dirPart) || leaf.startsWith(".")) return undefined;
659
+ if (isUnknownTilde(dirPart)) return undefined;
660
+ let entries: string[];
661
+ try {
662
+ entries = readdirSync(toAbsoluteBash(cwd, slash >= 0 ? dirPart || "/" : ".", home));
663
+ } catch {
664
+ return [pattern];
665
+ }
666
+ const regex = globComponentRegex(leaf);
667
+ if (!regex) return undefined;
668
+ const matches = entries.filter((entry) => !entry.startsWith(".") && regex.test(entry));
669
+ if (matches.length > 2_000) return undefined;
670
+ if (matches.length === 0) return [pattern];
671
+ return matches.map((entry) => (slash >= 0 ? `${dirPart}/${entry}` : entry));
672
672
  }
673
673
 
674
674
  /** The file named by a `-o FILE` / `-oFILE` / `--output=FILE` flag, if present. */
@@ -683,41 +683,103 @@ function outputFlagTarget(args: Token[]): string | undefined {
683
683
  }
684
684
 
685
685
  /**
686
- * Decide git: only an explicitly read-only subcommand with no flags we cannot
687
- * account for is safe. Returns undefined when safe, else the reason to escalate.
686
+ * Decide git: only an explicitly read-only subcommand, preceded by global
687
+ * options from `GIT_GLOBAL_SAFE` or an in-project `-C`, and carrying only the
688
+ * options its table names, is safe. Returns undefined when safe, else the
689
+ * reason to escalate.
688
690
  */
689
- function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean): string | undefined {
691
+ function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean, onFileReads: (words: Token[]) => void): string | undefined {
690
692
  let index = 0;
691
693
  while (index < args.length) {
692
694
  const token = args[index].value;
693
695
  if (!token.startsWith("-")) break;
694
- // `-c key=value` can reconfigure git into executing an arbitrary helper.
695
- if (token === "-c" || token.startsWith("-c=") || token === "--config-env") {
696
+ // `-c key=value` / `--config-env` can reconfigure git into executing an arbitrary helper.
697
+ if (token === "-c" || token.startsWith("-c") || token === "--config-env" || token.startsWith("--config-env=")) {
696
698
  return "passes git -c/--config-env, which can turn a read into code execution";
697
699
  }
698
- // `--git-dir=/x` spells the same flag as `--git-dir /x`; normalise both.
700
+ if (token === "-C") {
701
+ const dir = args[index + 1]?.value;
702
+ // `-C` retargets git at another directory; outside the working directory
703
+ // the operation is no longer provably in-project (was review gap: `git
704
+ // -C /etc status` classified safe).
705
+ if (!dir || isDirOutsideCwd(dir)) return `runs git -C ${dir ?? ""}, which points outside the working directory`;
706
+ index += 2;
707
+ continue;
708
+ }
709
+ if (GIT_GLOBAL_SAFE.has(token)) {
710
+ index++;
711
+ continue;
712
+ }
713
+ // `--git-dir`, `--work-tree`, `--exec-path`, `--namespace` and every other
714
+ // global option can point git at a repository or configuration this check
715
+ // has not seen, in-project or not (SECURITY-REVIEW-2026-09-23 H2).
699
716
  const eq = token.indexOf("=");
700
717
  const flag = eq > 0 ? token.slice(0, eq) : token;
701
- if (GIT_GLOBAL_VALUE_FLAGS.has(flag)) {
702
- const value = eq > 0 ? token.slice(eq + 1) : args[index + 1]?.value;
703
- // `-C`/`--git-dir`/`--work-tree` retarget git at another directory. If that
704
- // directory escapes the working directory the operation is no longer
705
- // provably in-project, so escalate rather than skipping the flag blindly
706
- // (was review gap: `git -C /etc status` classified safe).
707
- if ((flag === "-C" || flag === "--git-dir" || flag === "--work-tree") && value && isDirOutsideCwd(value)) {
708
- return `runs git ${flag} ${value}, which points outside the working directory`;
709
- }
710
- index += eq > 0 ? 1 : 2;
711
- continue;
718
+ const value = eq > 0 ? token.slice(eq + 1) : undefined;
719
+ if ((flag === "--git-dir" || flag === "--work-tree") && value !== undefined && isDirOutsideCwd(value)) {
720
+ return `runs git ${flag} ${value}, which points outside the working directory`;
712
721
  }
713
- index++;
722
+ return `passes git ${flag}, which can point git at another repository or configuration`;
714
723
  }
715
- const subcommand = args[index]?.value?.toLowerCase();
724
+ const subcommand = args[index]?.value;
716
725
  if (!subcommand) return undefined; // bare `git` prints help
717
- if (!GIT_READ_ONLY_SUBCOMMANDS.has(subcommand)) return `runs git ${subcommand}, which is not a read-only subcommand`;
726
+ const spec = GIT_SUBCOMMAND_SPECS[subcommand];
727
+ if (!spec) return `runs git ${subcommand}, which is not a read-only subcommand`;
728
+ const check = checkOptions(spec, args.slice(index + 1));
729
+ if (!check.ok) return `runs git ${subcommand} and ${check.reason}`;
730
+ if (!gitOperandsAllowed(subcommand, check.parsed)) {
731
+ return `runs git ${subcommand} with an operand and no --list, which creates a ref`;
732
+ }
733
+ if (GIT_FILE_OPERANDS.has(subcommand)) onFileReads(check.parsed.positionals);
718
734
  return undefined;
719
735
  }
720
736
 
737
+ /** The directory git runs in: `cwd`, then each leading `-C <dir>` in turn, as git applies them. */
738
+ function gitWorkingDirectory(args: Token[], cwd: string, home: string): string {
739
+ let dir = cwd;
740
+ for (let index = 0; index < args.length; ) {
741
+ const token = args[index].value;
742
+ if (token === "-C") {
743
+ const next = args[index + 1]?.value;
744
+ if (next) dir = toAbsoluteBash(dir, next, home);
745
+ index += 2;
746
+ } else if (GIT_GLOBAL_SAFE.has(token)) {
747
+ index++;
748
+ } else {
749
+ break;
750
+ }
751
+ }
752
+ return dir;
753
+ }
754
+
755
+ /**
756
+ * Subcommands whose operands can be files git reads outside the repository.
757
+ * `git diff <path> <path>` goes `--no-index` by itself when either path is
758
+ * outside the work tree or there is no repository, and reads both files
759
+ * (findings §25). Every operand is read-checked, revisions included: a
760
+ * revision is not a path unless a file of that name exists, and one that
761
+ * resolves inside the working directory passes.
762
+ */
763
+ const GIT_FILE_OPERANDS = new Set(["diff"]);
764
+
765
+ /**
766
+ * For a git command the options already proved read-only: why the checkout it
767
+ * runs in (after every `-C`, each relative to the last) could still make it
768
+ * run a program (git-checkout-programs.ts), or undefined.
769
+ */
770
+ function gitCheckoutReason(args: Token[], cwd: string, home: string, seen: Map<string, string | undefined>, hooks: readonly string[] = READ_HOOKS): string | undefined {
771
+ let dir = cwd;
772
+ for (let index = 0; index < args.length && args[index].value.startsWith("-"); index++) {
773
+ if (args[index].value === "-C" && args[index + 1]) dir = toAbsoluteBash(dir, args[++index].value, home);
774
+ }
775
+ // Memoized for one analysis only: nothing runs between its segments, but the
776
+ // model can rewrite `.git/config` between calls.
777
+ const key = `${hooks.join(",")}\0${dir}`;
778
+ if (!seen.has(key)) seen.set(key, checkoutGitRunsProgram(dir, home, hooks));
779
+ const why = seen.get(key);
780
+ return why && `runs git where ${why}`;
781
+ }
782
+
721
783
  export interface AnalyzeInput {
722
784
  command: string;
723
785
  cwd: string;
@@ -737,12 +799,36 @@ export interface AnalyzeInput {
737
799
  * only; the write and delete checks never consult this list.
738
800
  */
739
801
  readableRoots?: string[];
802
+ /**
803
+ * REALPATH-resolved directories a write may land in besides the working
804
+ * directory without leaving the working space: the workspace directories,
805
+ * passed only for a model that gets Claude Code's fast paths
806
+ * (permissions/matcher.ts `DecideInput.claudeCodeFastPaths`).
807
+ */
808
+ writableRoots?: string[];
809
+ }
810
+
811
+ /**
812
+ * Whether this simple command's words have the pre-gate's read-only proof.
813
+ * Reuse the entire analyzer, including option/operand rules and git's checkout
814
+ * checks, rather than treating a command name as a proof. No expansions,
815
+ * nested commands or stdin scripts are exempted by this narrower predicate.
816
+ * Redirects are NOT exempted: callers must still scan them independently.
817
+ * Pure apart from the analyzer's existing filesystem reads.
818
+ */
819
+ export function hasReadOnlyShellWords(segment: Segment, cwd: string, home: string): boolean {
820
+ if (segment.substitution !== undefined || segment.stdin?.length || segment.tokens.some((word) => word.dynamic || word.glob)) return false;
821
+ const { command, peeled, pathNamed } = resolvePayload(segment.tokens);
822
+ if (peeled.length || pathNamed.length || !(READ_ONLY_COMMANDS.has(command) || command === "git" || command === "find")) return false;
823
+ const evidence = analyzeShellCommand({ command: segment.raw, cwd, home });
824
+ return evidence.commands.length === 1 && evidence.writes.length === 0 && (evidence.verdict === "safe" || evidence.readsOnly);
740
825
  }
741
826
 
742
827
  /**
743
828
  * Classify a shell command. Never denies — see the module contract above.
744
829
  */
745
- export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [] }: AnalyzeInput): ShellEvidence {
830
+ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [], writableRoots = [] }: AnalyzeInput): ShellEvidence {
831
+ const checkoutReasons = new Map<string, string | undefined>();
746
832
  const evidence: ShellEvidence = {
747
833
  verdict: "safe",
748
834
  notes: [],
@@ -754,36 +840,38 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
754
840
  network: [],
755
841
  outsideReads: [],
756
842
  readOnlyOutside: false,
843
+ readsOnly: false,
757
844
  containedNonNetwork: false,
758
- wholeTree: false,
759
845
  };
760
846
  /**
761
847
  * Set by any escalation reason that is NOT a bare in-project mutation — i.e.
762
848
  * anything meaning the command reaches outside the project or cannot be fully
763
849
  * accounted for. When it stays false through an escalation, the only blocker
764
- * was in-project mutation, and the containment gate may consult recoverability.
850
+ * was in-project mutation (`containedNonNetwork`).
765
851
  */
766
852
  let uncontained = false;
767
853
  /** Set by any escalation that is not an outside-cwd read (see readOnlyOutside). */
768
854
  let escalatedBeyondReads = false;
769
- const escalate = (note: string, opts?: { contained?: boolean; outsideRead?: boolean }) => {
855
+ /** Set by any escalation that is neither an outside-cwd read nor a read naming a guarded path (see readsOnly). */
856
+ let escalatedBeyondNamedReads = false;
857
+ const escalate = (note: string, opts?: { contained?: boolean; outsideRead?: boolean; namedRead?: boolean }) => {
770
858
  evidence.verdict = "escalate";
771
859
  if (!opts?.contained) uncontained = true;
772
860
  if (!opts?.outsideRead) escalatedBeyondReads = true;
861
+ if (!opts?.outsideRead && !opts?.namedRead) escalatedBeyondNamedReads = true;
773
862
  if (!evidence.notes.includes(note)) evidence.notes.push(note);
774
863
  };
775
864
 
776
865
  const trimmed = command.trim();
777
866
  if (!trimmed) return { ...evidence, verdict: "escalate", notes: ["empty command"] };
778
867
 
779
- const unmodelled = hasUnmodelledSyntax(trimmed);
780
- if (unmodelled) escalate(unmodelled);
781
-
782
- const { segments, parseFailed } = parseCommand(trimmed);
868
+ const { segments, parseFailed, unavailable, unknownQuoting, complex } = parseCommand(trimmed);
783
869
  if (parseFailed) {
784
- escalate("could not be parsed (unbalanced quotes), so nothing about it is known");
870
+ escalate(unavailable ? `could not be parsed: ${unavailable}` : "could not be parsed as bash, so nothing about it is known");
785
871
  return evidence;
786
872
  }
873
+ if (unknownQuoting) escalate(unknownQuoting);
874
+ if (complex) escalate(complex);
787
875
 
788
876
  /**
789
877
  * Containment is checked against the *resolved* working directory. Write
@@ -796,6 +884,8 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
796
884
 
797
885
  /** `cd` changes what later relative paths mean; the original never tracked it (F6/N12). */
798
886
  let effectiveCwd = cwd;
887
+ /** The directory per subshell scope: a `cd` inside `$(…)` or `( … )` does not reach the parent. */
888
+ const cwdByScope = scopedTracker(cwd);
799
889
 
800
890
  /**
801
891
  * Resolve a write-target token, record it as evidence, and escalate on any
@@ -805,9 +895,16 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
805
895
  */
806
896
  const checkWriteTarget = (token: string) => {
807
897
  if (token === "/dev/null") return;
898
+ // A `~name`/`~-` target or a glob lands wherever bash expands it, which
899
+ // this check cannot know (SECURITY-REVIEW-2026-09-23 M2).
900
+ if (isUnknownTilde(token) || /[*?[]/.test(token)) {
901
+ evidence.writes.push({ token, absolute: token, outsideCwd: true });
902
+ escalate(`writes to ${token}, which bash expands to a path this check cannot resolve`);
903
+ return;
904
+ }
808
905
  const absolute = toAbsoluteBash(effectiveCwd, token, home);
809
906
  const resolved = resolveForContainment(absolute);
810
- const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
907
+ const outsideCwd = resolved === undefined || !(isWithin(containmentRoot, resolved) || writableRoots.some((root) => isWithin(root, resolved)));
811
908
  evidence.writes.push({ token, absolute, resolved, outsideCwd });
812
909
  if (resolved === undefined) {
813
910
  escalate(`writes to ${token}, which could not be resolved to a real path`);
@@ -836,15 +933,104 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
836
933
  }
837
934
  };
838
935
 
936
+ /**
937
+ * A word a read-only command reads. It escalates when it resolves outside
938
+ * the working directory and the readable roots, or onto a credential path
939
+ * — judged where it RESOLVES, so an in-project symlink named `notes` that
940
+ * points at a key file is the key file (SECURITY-REVIEW-2026-09-23 M1). A
941
+ * `~name`/`~-` word and a glob that could reach `..` are paths bash
942
+ * expands to places this check cannot see (M2); an ordinary glob is
943
+ * expanded one level here and every match is judged.
944
+ */
945
+ const escalateOutsideRead = (value: string, why: string) => {
946
+ if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
947
+ escalate(`reads ${value}, ${why}`, { outsideRead: true });
948
+ };
949
+ // `base` is where the reading program resolves a relative word (git's
950
+ // final `-C` directory); bash still expands globs from the shell's cwd.
951
+ const checkRead = (word: { value: string; glob?: boolean }, base = effectiveCwd) => {
952
+ const value = word.value;
953
+ if (!value) return;
954
+ if (isUnknownTilde(value)) {
955
+ escalateOutsideRead(value, "which bash expands to a directory outside what this check can see");
956
+ return;
957
+ }
958
+ const targets = word.glob ? expandGlob(effectiveCwd, value, home) : [value];
959
+ if (targets === undefined) {
960
+ escalateOutsideRead(value, "a glob whose matches cannot be checked");
961
+ return;
962
+ }
963
+ for (const target of targets) {
964
+ const absolute = toAbsoluteBash(base, target, home);
965
+ if (!looksLikePath(target) && !entryExists(absolute)) continue;
966
+ const resolved = resolveForContainment(absolute);
967
+ if (resolved !== undefined && isSensitivePath(resolved) && !isSensitivePath(absolute)) {
968
+ if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
969
+ escalate(`reads ${value}, which resolves to a credential or secret path`, { namedRead: true });
970
+ }
971
+ if (resolved !== undefined && (isWithin(containmentRoot, resolved) || readableRoots.some((root) => isWithin(root, resolved)))) continue;
972
+ escalateOutsideRead(value, "which is outside the working directory");
973
+ }
974
+ };
975
+
839
976
  for (const segment of segments) {
977
+ effectiveCwd = cwdByScope.get(segment);
840
978
  // Redirection targets are writes regardless of the command word: a bare
841
979
  // `> file` truncates/creates it with no command at all, and `git log > file`
842
980
  // writes it too. Check them first so the command-specific `continue`s below
843
981
  // (cd/git) can never skip a redirect (was review gap: bare-redirect writes and
844
982
  // read-only-command redirects were fast-pathed to "safe").
845
983
  for (const token of segment.redirects) checkWriteTarget(token);
984
+ // An input redirect (`tr a b < f`) opens `f` whatever the command is, so
985
+ // it is read-checked even for a command that takes no file operands. A
986
+ // literally named credential file is checked here too: checkRead only
987
+ // catches one reached through a symlink, leaving the literal name to the
988
+ // operand scan below, which never sees `inputs` (`cat < .env`).
989
+ for (const word of segment.inputs) {
990
+ if (isSensitivePath(word.value) || isSensitivePath(toAbsoluteBash(effectiveCwd, word.value, home))) {
991
+ if (!evidence.sensitivePaths.includes(word.value)) evidence.sensitivePaths.push(word.value);
992
+ escalate(`reads ${word.value} on stdin, which is a credential or secret path`, { namedRead: true });
993
+ }
994
+ checkRead(word);
995
+ }
996
+ if (segment.unknownTarget) escalate("redirects to or from a path an expansion computes, which cannot be resolved here");
997
+ // `cat <<< "$TOKEN"` prints the environment's value as surely as `echo $TOKEN`.
998
+ if (segment.expandsIntoInput) escalate("expands a parameter into the command's input, whose value is unknown here");
999
+
1000
+ // A word bash computes at run time. A parameter's value comes from the
1001
+ // environment (`$@` is empty in a `bash -c` line, so `cat $@/etc/passwd`
1002
+ // read as an in-project path); a substitution's output can be any words,
1003
+ // options included. The substituted commands are segments of their own
1004
+ // and judged below like any other.
1005
+ const dynamicKinds = new Set(segment.tokens.map((token) => token.dynamic).filter((kind) => kind !== undefined));
1006
+ if (dynamicKinds.has("variable")) {
1007
+ escalate(
1008
+ segment.tokens.some((token) => token.dynamic === "variable" && SPECIAL_PARAMETER.test(token.value))
1009
+ ? "references a shell special parameter ($1, $@, $$, …), whose value is unknown here"
1010
+ : "references environment variables, whose values are unknown here",
1011
+ );
1012
+ }
846
1013
 
847
- const { command: name, args, peeled } = resolvePayload(segment.tokens);
1014
+ // A leading `NAME=value` changes what the command runs or reads: git takes
1015
+ // its repository and configuration from GIT_* variables (the same
1016
+ // capability `git -c` is refused for), loaders and pagers from others, and
1017
+ // a bare `PATH=./bin` segment changes which program every later word runs.
1018
+ // Only locale and display variables are inert (SECURITY-REVIEW-2026-09-23 H2).
1019
+ for (const token of segment.tokens) {
1020
+ const assigned = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(token.value)?.[1];
1021
+ if (!assigned) break;
1022
+ const inert = INERT_ASSIGNMENTS.test(assigned) && isInertValue(assigned, token.value.slice(assigned.length + 1));
1023
+ if (!inert) escalate(`sets ${assigned}, which can change what the command runs or reads`);
1024
+ }
1025
+
1026
+ const { command: name, args, peeled, pathNamed } = resolvePayload(segment.tokens);
1027
+ if (dynamicKinds.has("substitution") && !PURE_OUTPUT.has(name)) {
1028
+ escalate(`passes a command substitution's output to ${name || "the shell"}, and its words are unknown here`);
1029
+ }
1030
+ // `diff <(git show HEAD:a) a`: bash hands a read-only command a pipe from a command judged on its own.
1031
+ if (dynamicKinds.has("process-input") && !READ_ONLY_COMMANDS.has(name) && name !== "find") {
1032
+ escalate(`passes a <( ) pipe to ${name || "the shell"}, which is not a read-only command`);
1033
+ }
848
1034
  if (!name) {
849
1035
  // A wrapper with nothing to wrap is a command of its own: a bare `env`
850
1036
  // (or `env -i`, `nice`) prints the whole process environment / state.
@@ -859,12 +1045,15 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
859
1045
  continue;
860
1046
  }
861
1047
  evidence.commands.push(name);
1048
+ // `./cat` or `bin/timeout` runs whatever file sits there, not the command
1049
+ // its basename names; Claude Code matches the command word exactly too.
1050
+ for (const word of pathNamed) escalate(`runs ${word}, a program named by its path, so its name says nothing about what it does`);
862
1051
  if (peeled.length > 0) {
863
1052
  // A transparent wrapper (timeout, env, nice) leaves the payload's own
864
- // arguments visible, so an in-project payload stays contained; xargs is
865
- // the exception — its targets come from stdin, unknown to this check.
1053
+ // arguments visible, so an in-project payload stays contained unless a
1054
+ // wrapper's spec says otherwise (xargs, flock, script).
866
1055
  escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`, {
867
- contained: !peeled.includes("xargs"),
1056
+ contained: wrappersContained(peeled),
868
1057
  });
869
1058
  }
870
1059
 
@@ -882,11 +1071,11 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
882
1071
  if (isSensitivePath(value) || isSensitivePath(absolute)) {
883
1072
  // The *original* token, never the resolved/expanded form (N18).
884
1073
  if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
885
- escalate(`names ${value}, which is a credential or secret path`);
1074
+ escalate(`names ${value}, which is a credential or secret path`, { namedRead: true });
886
1075
  }
887
1076
  if (isExecutionPrimitivePath(absolute)) {
888
1077
  if (!evidence.executionPrimitives.includes(value)) evidence.executionPrimitives.push(value);
889
- escalate(`touches ${value}, whose contents execute later without further approval`);
1078
+ escalate(`touches ${value}, whose contents execute later without further approval`, { namedRead: true });
890
1079
  }
891
1080
  }
892
1081
 
@@ -894,33 +1083,47 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
894
1083
  const target = args.find((token) => !token.value.startsWith("-"))?.value;
895
1084
  if (target) {
896
1085
  effectiveCwd = toAbsoluteBash(effectiveCwd, target, home);
1086
+ cwdByScope.set(segment, effectiveCwd);
897
1087
  escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
898
1088
  }
899
1089
  continue;
900
1090
  }
901
1091
 
902
- if (name === "git") {
903
- // `git reset --hard` is an in-project whole-tree discard: escalate, but
904
- // mark it contained so the recoverability gate can clear it when the tree
905
- // is clean. Any other non-read-only git subcommand is uncontained.
906
- if (isWholeTreeGitReset(args)) {
907
- if (hasGitRetargetFlag(args)) {
908
- // The reset acts on whatever -C/--git-dir/--work-tree names, not on
909
- // the working directory the recoverability judge would inspect.
910
- escalate("runs git reset --hard against another tree (-C/--git-dir/--work-tree), which cannot be judged here");
911
- continue;
912
- }
913
- evidence.wholeTree = true;
914
- escalate("runs git reset --hard, which discards uncommitted changes in the working tree", {
915
- contained: true,
916
- });
917
- continue;
1092
+ // Bash expands a glob before the command parses its options, so a match
1093
+ // that starts with `-` arrives as an option no table judged: with a file
1094
+ // named `--output=victim`, `sort *` writes `victim`
1095
+ // (AUTO-MODE-SECURITY-REVIEW-2026-09-24 H4). A glob this check cannot
1096
+ // enumerate escalates when its expansion could start with `-`.
1097
+ for (const token of args) {
1098
+ if (!token.glob) continue;
1099
+ const matches = expandGlob(effectiveCwd, token.value, home);
1100
+ const injects = matches === undefined ? /^[-*?[]/.test(token.value) : matches.some((match) => match !== token.value && match.startsWith("-"));
1101
+ if (injects) {
1102
+ escalate(`passes the glob ${token.value}, which can expand to a filename starting with -, read as an option`);
1103
+ break;
918
1104
  }
919
- const reason = gitEscalationReason(args, (dir) => {
920
- const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, dir, home));
921
- return resolved === undefined || !isWithin(containmentRoot, resolved);
922
- });
1105
+ }
1106
+
1107
+ if (name === "git") {
1108
+ const fileReads: Token[] = [];
1109
+ const reason =
1110
+ gitEscalationReason(
1111
+ args,
1112
+ (dir) => {
1113
+ if (isUnknownTilde(dir)) return true;
1114
+ const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, dir, home));
1115
+ return resolved === undefined || !isWithin(containmentRoot, resolved);
1116
+ },
1117
+ (words) => fileReads.push(...words),
1118
+ ) ?? gitCheckoutReason(args, effectiveCwd, home, checkoutReasons);
923
1119
  if (reason) escalate(reason);
1120
+ // git resolves a relative operand from its final `-C` directory, not
1121
+ // the shell's: `git -C sub diff ../../x/data.txt a.txt` can climb out
1122
+ // from `sub` where the same word stays inside from the working directory.
1123
+ else {
1124
+ const gitCwd = gitWorkingDirectory(args, effectiveCwd, home);
1125
+ for (const word of fileReads) checkRead(word, gitCwd);
1126
+ }
924
1127
  continue;
925
1128
  }
926
1129
 
@@ -928,53 +1131,124 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
928
1131
  escalate(`runs ${name}, whose script can read and write files this check cannot see`);
929
1132
  }
930
1133
 
931
- if (name === "find" && findHasAction(args)) {
932
- escalate("uses a find action that executes commands or writes files");
933
- }
934
-
935
1134
  const isMutation = MUTATION_COMMANDS.has(name);
936
1135
  const isDelete = DELETE_COMMANDS.has(name);
937
- // A delete confined to in-project paths is what the containment gate exists
938
- // to clear (subject to recoverability); any other mutation (cp/mv/tar/…)
939
- // stays uncontained and reaches the classifier as before.
940
- if (isMutation) escalate(`runs ${name}, which modifies the filesystem`, { contained: isDelete });
941
-
942
- if (!isMutation && !READ_ONLY_COMMANDS.has(name) && !PATTERN_FIRST_COMMANDS.has(name) && name !== "find") {
943
- escalate(`runs ${name}, which is not on the read-only allowlist`);
944
- }
945
-
946
- // A read-only command's path operands are read: one that resolves outside
947
- // the working directory escalates (Claude Code asks for every read outside
948
- // it — filesystem.ts — and removed env/printenv from its read-only list).
949
- // Bare names (`cat notes.txt`) resolve inside the cwd and stay fast-pathed;
950
- // a pattern-first command's first positional is its pattern, not a path.
951
- if (!isMutation && !NO_FILE_OPERANDS.has(name)) {
952
- const positionals = args.filter((token) => !token.value.startsWith("-")).map((token) => token.value);
953
- // The first positional is the pattern only when no flag supplies it:
954
- // with `-e PAT` / `-f FILE` (`--regexp`, `--file`) every positional is a
955
- // path, and `-f`'s value is itself a file read.
956
- const patternByFlag = args.some(({ value }) => /^(-[a-zA-Z]*[ef][a-zA-Z]*|--regexp(=.*)?|--file(=.*)?)$/.test(value));
957
- if (PATTERN_FIRST_COMMANDS.has(name) && !patternByFlag) positionals.shift();
958
- // `--file=FILE` carries its read inside the flag token.
959
- for (const { value } of args) {
960
- if (value.startsWith("--file=")) positionals.push(value.slice("--file=".length));
961
- }
962
- for (const value of positionals) {
963
- if (!looksLikePath(value)) continue;
964
- const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, value, home));
965
- if (resolved !== undefined && (isWithin(containmentRoot, resolved) || readableRoots.some((root) => isWithin(root, resolved)))) continue;
966
- if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
967
- escalate(`reads ${value}, which is outside the working directory`, { outsideRead: true });
1136
+ // Claude Code's acceptEdits file commands with modelled options are the
1137
+ // contained mutations, their targets checked below; any other mutation
1138
+ // (tar/dd/ln/…) stays uncontained.
1139
+ const fileOperands = acceptEditsOperands(name, args);
1140
+ if (isMutation) escalate(`runs ${name}, which modifies the filesystem`, { contained: fileOperands !== undefined });
1141
+
1142
+
1143
+ // A read-only command is proved read-only by its options, not its name:
1144
+ // each option must be in the command's table (read-only-options.ts), so
1145
+ // `rg --pre=<prog>`, `tree -o f` and `uniq in out` escalate
1146
+ // (SECURITY-REVIEW-2026-09-23 H1). Its operands, and the files named by
1147
+ // options such as `grep -f`, are then read-checked.
1148
+ if (name === "find") {
1149
+ const find = checkFind(args);
1150
+ if (!find.ok) escalate(find.reason);
1151
+ else for (const word of find.paths) checkRead(word);
1152
+ } else if (READ_ONLY_COMMANDS.has(name)) {
1153
+ const check = checkOptions(READ_ONLY_SPECS[name], args);
1154
+ if (!check.ok) {
1155
+ escalate(`runs ${name} and ${check.reason}`);
1156
+ } else {
1157
+ const spec = READ_ONLY_SPECS[name];
1158
+ const operandReason = spec.operandReason?.(check.parsed.positionals);
1159
+ if (operandReason) escalate(operandReason);
1160
+ const operands = [...check.parsed.positionals];
1161
+ // A pattern-first command's first operand is its pattern, unless an
1162
+ // option (`-e PAT`, `-f FILE`) supplies it; jq's first is its program.
1163
+ // `rg --files` lists files and takes no pattern, so every operand is a
1164
+ // path (PREGATE-REVIEW-2026-09-23 P3).
1165
+ const patternFirst =
1166
+ PATTERN_FIRST_COMMANDS.has(name) && !check.parsed.seen.has("--files") && ![...check.parsed.seen].some((option) => PATTERN_OPTIONS.has(option));
1167
+ // A glob where the pattern, program or format goes becomes that operand
1168
+ // only in its first match; the rest are operands this check never saw
1169
+ // (`grep *` reads every match but the first, jq's program is a filename).
1170
+ const lead = operands[0];
1171
+ if (lead?.glob && (patternFirst || spec.programFirst || spec.operandReason)) {
1172
+ escalate(`runs ${name} with the glob ${lead.value} as its first operand, which ${name} does not read as a file, so the other matches go unchecked`);
1173
+ }
1174
+ if (patternFirst || spec.programFirst) operands.shift();
1175
+ const reads = NO_FILE_OPERANDS.has(name) ? check.parsed.fileValues : [...operands, ...check.parsed.fileValues];
1176
+ for (const word of reads) checkRead(word);
1177
+ // A command that dereferences the entries of a directory operand
1178
+ // (`diff dir1 dir2`) reads through any symlink one level inside it,
1179
+ // which the operand's own containment check never sees (A1).
1180
+ if (spec.dereferencesDirEntries && !spec.dereferencesDirEntries.some((option) => check.parsed.seen.has(option))) {
1181
+ for (const word of operands) {
1182
+ // A glob could expand to a directory this check cannot enumerate, so
1183
+ // it escalates rather than being skipped (code-review 2026-09-24).
1184
+ if (word.glob) {
1185
+ escalate(`runs ${name} on the glob ${word.value}, which could name a directory that ${FOLLOWS_DIR_ENTRY_SYMLINKS}`);
1186
+ break;
1187
+ }
1188
+ const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, word.value, home));
1189
+ if (resolved !== undefined && isDirectory(resolved)) {
1190
+ escalate(`runs ${name} on the directory ${word.value}, which ${FOLLOWS_DIR_ENTRY_SYMLINKS}`);
1191
+ break;
1192
+ }
1193
+ }
1194
+ }
968
1195
  }
1196
+ } else if (!isMutation) {
1197
+ escalate(`runs ${name}, which is not on the read-only allowlist`);
969
1198
  }
970
1199
 
971
1200
  // The positional destinations of writing commands are the paths that get
972
1201
  // written. (Redirections were already handled at the top of the loop.)
973
1202
  const writeTokens: string[] = [];
974
- if (isDelete) {
1203
+ if (fileOperands !== undefined) {
1204
+ // Every operand is a path, bare names included. cp and mv write their
1205
+ // last operand and read the rest, so a source outside the working space
1206
+ // (or a credential) is an outside read and leaves the line uncontained.
1207
+ const copies = name === "cp" || name === "mv";
1208
+ const reads = copies ? fileOperands.slice(0, -1) : [];
1209
+ // The paths the write checks guard, beneath a directory operand as well
1210
+ // as at it: they protect the files, not the directory holding them.
1211
+ const guarded = (path: string) => isGuardedWritePath(path, effectiveCwd, protectedDirs);
1212
+ // Removing or moving away a working root, a `.git`, or a guarded path,
1213
+ // or a directory holding one, is never contained (`removalLoses`).
1214
+ for (const token of name === "mv" ? reads : name === "rm" || name === "rmdir" ? fileOperands : []) {
1215
+ const absolute = toAbsoluteBash(effectiveCwd, token.value, home);
1216
+ const resolved = resolveForContainment(absolute);
1217
+ if (resolved === undefined) continue;
1218
+ if (removalLoses(absolute, resolved, [containmentRoot, ...writableRoots], effectiveCwd, protectedDirs)) {
1219
+ escalate(`${name === "mv" ? "moves" : "removes"} ${token.value}, which is a working root, or is or holds a git repository or a protected, credential or execution-primitive path`);
1220
+ }
1221
+ }
1222
+ for (const token of reads) {
1223
+ if (isSensitivePath(toAbsoluteBash(effectiveCwd, token.value, home))) {
1224
+ if (!evidence.sensitivePaths.includes(token.value)) evidence.sensitivePaths.push(token.value);
1225
+ escalate(`copies or moves ${token.value}, a credential or secret path`);
1226
+ }
1227
+ checkRead(token);
1228
+ }
1229
+ const destination = copies ? fileOperands.at(-1) : undefined;
1230
+ if (destination === undefined) {
1231
+ writeTokens.push(...fileOperands.map((token) => token.value));
1232
+ } else if (reads.length === 0) {
1233
+ writeTokens.push(destination.value);
1234
+ } else {
1235
+ // Into an existing directory, each source lands beneath it (`cp
1236
+ // settings.json .claude` writes `.claude/settings.json`), and a
1237
+ // directory source brings its whole tree along.
1238
+ const intoDirectory = destination.value.endsWith("/") || isDirectory(toAbsoluteBash(effectiveCwd, destination.value, home));
1239
+ for (const source of reads) {
1240
+ const target = intoDirectory ? `${destination.value.replace(/\/+$/, "")}/${bashBasename(source.value)}` : destination.value;
1241
+ writeTokens.push(target);
1242
+ const sourceResolved = resolveForContainment(toAbsoluteBash(effectiveCwd, source.value, home));
1243
+ if (sourceResolved !== undefined && touchesGuardedPath(sourceResolved, toAbsoluteBash(effectiveCwd, target, home), guarded)) {
1244
+ escalate(`${name === "cp" ? "copies" : "moves"} ${source.value} to ${target}, which puts files on a protected, credential or execution-primitive path`);
1245
+ }
1246
+ }
1247
+ }
1248
+ } else if (isDelete) {
975
1249
  // A delete's targets are every non-flag positional, bare names included
976
- // (`rm notes.txt` has no slash but is still a real target the
977
- // recoverability gate must see). A glob target cannot be enumerated, so
1250
+ // (`rm notes.txt` has no slash but is still a real target that
1251
+ // must be resolved). A glob target cannot be enumerated, so
978
1252
  // it drops out of containment — the command then reaches the classifier.
979
1253
  for (const token of args) {
980
1254
  if (token.value.startsWith("-")) continue;
@@ -1013,11 +1287,11 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], re
1013
1287
  for (const token of writeTokens) checkWriteTarget(token);
1014
1288
  }
1015
1289
 
1016
- // The command escalated, but every reason was an in-project delete/whole-tree
1017
- // reset — nothing reached outside the project, the network, or the unknown. The
1018
- // containment gate may now consult git-recoverability (auto-mode/recoverability).
1290
+ // The command escalated, but every reason was an acceptEdits file command on
1291
+ // the working space: nothing reached outside it, the network, or the unknown.
1019
1292
  evidence.containedNonNetwork = evidence.verdict === "escalate" && !uncontained;
1020
1293
  evidence.readOnlyOutside = evidence.verdict === "escalate" && !escalatedBeyondReads;
1294
+ evidence.readsOnly = evidence.verdict === "escalate" && !escalatedBeyondNamedReads;
1021
1295
 
1022
1296
  return evidence;
1023
1297
  }