@quickchatai/pi-quickreview 0.0.0-stage → 1.1.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 (689) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +75 -2
  3. package/assets/agents/pass-abstraction.md +64 -0
  4. package/assets/agents/pass-ask-human.md +134 -0
  5. package/assets/agents/pass-backcompat.md +67 -0
  6. package/assets/agents/pass-baseline.md +61 -0
  7. package/assets/agents/pass-behaviour-delta.md +64 -0
  8. package/assets/agents/pass-bounds.md +36 -0
  9. package/assets/agents/pass-claims.md +31 -0
  10. package/assets/agents/pass-concurrency.md +168 -0
  11. package/assets/agents/pass-consistency.md +57 -0
  12. package/assets/agents/pass-contracts.md +30 -0
  13. package/assets/agents/pass-data-safety.md +54 -0
  14. package/assets/agents/pass-deferred.md +35 -0
  15. package/assets/agents/pass-delivery.md +37 -0
  16. package/assets/agents/pass-duplication.md +60 -0
  17. package/assets/agents/pass-intent.md +63 -0
  18. package/assets/agents/pass-lint-conformance.md +46 -0
  19. package/assets/agents/pass-maintainability.md +67 -0
  20. package/assets/agents/pass-placement.md +68 -0
  21. package/assets/agents/pass-prod-risk.md +24 -0
  22. package/assets/agents/pass-rollout.md +32 -0
  23. package/assets/agents/pass-roundtrip.md +46 -0
  24. package/assets/agents/pass-security.md +31 -0
  25. package/assets/agents/pass-simplicity.md +68 -0
  26. package/assets/agents/pass-statelessness.md +27 -0
  27. package/assets/agents/pass-style.md +37 -0
  28. package/assets/agents/pass-sweep.md +57 -0
  29. package/assets/agents/pass-tests.md +58 -0
  30. package/assets/agents/pass-ui.md +23 -0
  31. package/assets/agents/pass-validate.md +50 -0
  32. package/assets/agents/pass-widened.md +46 -0
  33. package/assets/agents/scan/pass-scan.md +86 -0
  34. package/assets/config.example.toml +1067 -0
  35. package/assets/deploy/reviewer/AGENTS.override.md +12 -0
  36. package/assets/profiles/default/README.md +21 -0
  37. package/assets/profiles/default/addenda/.gitkeep +0 -0
  38. package/assets/profiles/default/profile.toml +17 -0
  39. package/assets/profiles/example-django/README.md +21 -0
  40. package/assets/profiles/example-django/addenda/10-migration-discipline.md +13 -0
  41. package/assets/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
  42. package/assets/profiles/example-django/addenda/30-test-doctrine.md +12 -0
  43. package/assets/profiles/example-django/addenda/40-orm-usage.md +19 -0
  44. package/assets/profiles/example-django/addenda/50-declared-shape.md +10 -0
  45. package/assets/profiles/example-django/addenda/60-config-placement.md +14 -0
  46. package/assets/profiles/example-django/heuristics.toml +24 -0
  47. package/assets/profiles/example-django/profile.toml +9 -0
  48. package/assets/profiles/fast/README.md +24 -0
  49. package/assets/profiles/fast/profile.toml +59 -0
  50. package/assets/profiles/minimal/README.md +17 -0
  51. package/assets/profiles/minimal/addenda/.gitkeep +0 -0
  52. package/assets/profiles/minimal/profile.toml +8 -0
  53. package/assets/profiles/snappy/README.md +24 -0
  54. package/assets/profiles/snappy/addenda/10-race-as-question.md +6 -0
  55. package/assets/profiles/snappy/profile.toml +46 -0
  56. package/assets/prompts/companion-day-open.md +46 -0
  57. package/assets/prompts/companion-first-run.md +104 -0
  58. package/assets/prompts/companion.md +821 -0
  59. package/assets/prompts/conversation-system.md +117 -0
  60. package/assets/prompts/eod-sweep.md +120 -0
  61. package/assets/prompts/onboarding.md +74 -0
  62. package/assets/prompts/pass-common.md +52 -0
  63. package/assets/prompts/pass-quality-common.md +22 -0
  64. package/assets/prompts/round-first.md +874 -0
  65. package/assets/prompts/round-follow-up.md +991 -0
  66. package/assets/prompts/round-re-review.md +889 -0
  67. package/assets/prompts/round-recheck.md +196 -0
  68. package/assets/prompts/round-staleness.md +856 -0
  69. package/assets/schemas/review-result.v1.json +391 -0
  70. package/assets/schemas/scan-result.v1.json +421 -0
  71. package/assets/schemas/version.v1.json +57 -0
  72. package/assets/seed-manifest.json +19 -0
  73. package/assets/seeds/agents/pass-baseline.md +61 -0
  74. package/assets/seeds/agents/pass-contracts.md +30 -0
  75. package/assets/seeds/agents/pass-data-safety.md +54 -0
  76. package/assets/seeds/agents/pass-delivery.md +37 -0
  77. package/assets/seeds/agents/pass-prod-risk.md +24 -0
  78. package/assets/seeds/agents/pass-security.md +31 -0
  79. package/assets/seeds/agents/pass-statelessness.md +27 -0
  80. package/assets/seeds/agents/pass-ui.md +23 -0
  81. package/assets/seeds/agents/pass-validate.md +50 -0
  82. package/assets/seeds/prompts/conversation-system.md +117 -0
  83. package/assets/seeds/prompts/eod-sweep.md +120 -0
  84. package/assets/seeds/prompts/pass-common.md +52 -0
  85. package/dist/extensions/bash-guard.d.ts +467 -0
  86. package/dist/extensions/bash-guard.d.ts.map +1 -0
  87. package/dist/extensions/bash-guard.js +3058 -0
  88. package/dist/extensions/bash-guard.js.map +1 -0
  89. package/dist/extensions/finding-clusters.d.ts +33 -0
  90. package/dist/extensions/finding-clusters.d.ts.map +1 -0
  91. package/dist/extensions/finding-clusters.js +218 -0
  92. package/dist/extensions/finding-clusters.js.map +1 -0
  93. package/dist/extensions/ledger-pin.d.ts +76 -0
  94. package/dist/extensions/ledger-pin.d.ts.map +1 -0
  95. package/dist/extensions/ledger-pin.js +331 -0
  96. package/dist/extensions/ledger-pin.js.map +1 -0
  97. package/dist/extensions/lens.d.ts +43 -0
  98. package/dist/extensions/lens.d.ts.map +1 -0
  99. package/dist/extensions/lens.js +150 -0
  100. package/dist/extensions/lens.js.map +1 -0
  101. package/dist/extensions/pi-tool.d.ts +22 -0
  102. package/dist/extensions/pi-tool.d.ts.map +1 -0
  103. package/dist/extensions/pi-tool.js +20 -0
  104. package/dist/extensions/pi-tool.js.map +1 -0
  105. package/dist/extensions/quickreview.d.ts +21 -0
  106. package/dist/extensions/quickreview.d.ts.map +1 -0
  107. package/dist/extensions/quickreview.js +132 -0
  108. package/dist/extensions/quickreview.js.map +1 -0
  109. package/dist/src/anchors.d.ts +54 -0
  110. package/dist/src/anchors.d.ts.map +1 -0
  111. package/dist/src/anchors.js +127 -0
  112. package/dist/src/anchors.js.map +1 -0
  113. package/dist/src/assetroot.d.ts +16 -0
  114. package/dist/src/assetroot.d.ts.map +1 -0
  115. package/dist/src/assetroot.js +77 -0
  116. package/dist/src/assetroot.js.map +1 -0
  117. package/dist/src/authwatch.d.ts +61 -0
  118. package/dist/src/authwatch.d.ts.map +1 -0
  119. package/dist/src/authwatch.js +210 -0
  120. package/dist/src/authwatch.js.map +1 -0
  121. package/dist/src/budget.d.ts +15 -0
  122. package/dist/src/budget.d.ts.map +1 -0
  123. package/dist/src/budget.js +32 -0
  124. package/dist/src/budget.js.map +1 -0
  125. package/dist/src/casebudget.d.ts +3 -0
  126. package/dist/src/casebudget.d.ts.map +1 -0
  127. package/dist/src/casebudget.js +3 -0
  128. package/dist/src/casebudget.js.map +1 -0
  129. package/dist/src/cigate.d.ts +116 -0
  130. package/dist/src/cigate.d.ts.map +1 -0
  131. package/dist/src/cigate.js +176 -0
  132. package/dist/src/cigate.js.map +1 -0
  133. package/dist/src/codespan.d.ts +46 -0
  134. package/dist/src/codespan.d.ts.map +1 -0
  135. package/dist/src/codespan.js +75 -0
  136. package/dist/src/codespan.js.map +1 -0
  137. package/dist/src/contextpack.d.ts +56 -0
  138. package/dist/src/contextpack.d.ts.map +1 -0
  139. package/dist/src/contextpack.js +195 -0
  140. package/dist/src/contextpack.js.map +1 -0
  141. package/dist/src/contract.d.ts +8 -0
  142. package/dist/src/contract.d.ts.map +1 -0
  143. package/dist/src/contract.js +21 -0
  144. package/dist/src/contract.js.map +1 -0
  145. package/dist/src/coverage.d.ts +32 -0
  146. package/dist/src/coverage.d.ts.map +1 -0
  147. package/dist/src/coverage.js +85 -0
  148. package/dist/src/coverage.js.map +1 -0
  149. package/dist/src/datafence.d.ts +19 -0
  150. package/dist/src/datafence.d.ts.map +1 -0
  151. package/dist/src/datafence.js +60 -0
  152. package/dist/src/datafence.js.map +1 -0
  153. package/dist/src/dedup.d.ts +65 -0
  154. package/dist/src/dedup.d.ts.map +1 -0
  155. package/dist/src/dedup.js +459 -0
  156. package/dist/src/dedup.js.map +1 -0
  157. package/dist/src/depsources.d.ts +304 -0
  158. package/dist/src/depsources.d.ts.map +1 -0
  159. package/dist/src/depsources.js +3367 -0
  160. package/dist/src/depsources.js.map +1 -0
  161. package/dist/src/diffinput.d.ts +10 -0
  162. package/dist/src/diffinput.d.ts.map +1 -0
  163. package/dist/src/diffinput.js +172 -0
  164. package/dist/src/diffinput.js.map +1 -0
  165. package/dist/src/doubtwords.d.ts +10 -0
  166. package/dist/src/doubtwords.d.ts.map +1 -0
  167. package/dist/src/doubtwords.js +13 -0
  168. package/dist/src/doubtwords.js.map +1 -0
  169. package/dist/src/ecosystem.d.ts +148 -0
  170. package/dist/src/ecosystem.d.ts.map +1 -0
  171. package/dist/src/ecosystem.js +338 -0
  172. package/dist/src/ecosystem.js.map +1 -0
  173. package/dist/src/engine/agentdir.d.ts +5 -0
  174. package/dist/src/engine/agentdir.d.ts.map +1 -0
  175. package/dist/src/engine/agentdir.js +21 -0
  176. package/dist/src/engine/agentdir.js.map +1 -0
  177. package/dist/src/engine/auth.d.ts +214 -0
  178. package/dist/src/engine/auth.d.ts.map +1 -0
  179. package/dist/src/engine/auth.js +374 -0
  180. package/dist/src/engine/auth.js.map +1 -0
  181. package/dist/src/engine/budget.d.ts +60 -0
  182. package/dist/src/engine/budget.d.ts.map +1 -0
  183. package/dist/src/engine/budget.js +113 -0
  184. package/dist/src/engine/budget.js.map +1 -0
  185. package/dist/src/engine/capfd.d.ts +6 -0
  186. package/dist/src/engine/capfd.d.ts.map +1 -0
  187. package/dist/src/engine/capfd.js +21 -0
  188. package/dist/src/engine/capfd.js.map +1 -0
  189. package/dist/src/engine/caseout.d.ts +66 -0
  190. package/dist/src/engine/caseout.d.ts.map +1 -0
  191. package/dist/src/engine/caseout.js +107 -0
  192. package/dist/src/engine/caseout.js.map +1 -0
  193. package/dist/src/engine/context.d.ts +57 -0
  194. package/dist/src/engine/context.d.ts.map +1 -0
  195. package/dist/src/engine/context.js +212 -0
  196. package/dist/src/engine/context.js.map +1 -0
  197. package/dist/src/engine/envmap.d.ts +13 -0
  198. package/dist/src/engine/envmap.d.ts.map +1 -0
  199. package/dist/src/engine/envmap.js +372 -0
  200. package/dist/src/engine/envmap.js.map +1 -0
  201. package/dist/src/engine/extfiles.d.ts +7 -0
  202. package/dist/src/engine/extfiles.d.ts.map +1 -0
  203. package/dist/src/engine/extfiles.js +23 -0
  204. package/dist/src/engine/extfiles.js.map +1 -0
  205. package/dist/src/engine/keyroute.d.ts +21 -0
  206. package/dist/src/engine/keyroute.d.ts.map +1 -0
  207. package/dist/src/engine/keyroute.js +58 -0
  208. package/dist/src/engine/keyroute.js.map +1 -0
  209. package/dist/src/engine/lens/cache-group.d.ts +20 -0
  210. package/dist/src/engine/lens/cache-group.d.ts.map +1 -0
  211. package/dist/src/engine/lens/cache-group.js +31 -0
  212. package/dist/src/engine/lens/cache-group.js.map +1 -0
  213. package/dist/src/engine/lens/cache-run.d.ts +65 -0
  214. package/dist/src/engine/lens/cache-run.d.ts.map +1 -0
  215. package/dist/src/engine/lens/cache-run.js +159 -0
  216. package/dist/src/engine/lens/cache-run.js.map +1 -0
  217. package/dist/src/engine/lens/deadline.d.ts +54 -0
  218. package/dist/src/engine/lens/deadline.d.ts.map +1 -0
  219. package/dist/src/engine/lens/deadline.js +111 -0
  220. package/dist/src/engine/lens/deadline.js.map +1 -0
  221. package/dist/src/engine/lens/engine-tools.d.ts +13 -0
  222. package/dist/src/engine/lens/engine-tools.d.ts.map +1 -0
  223. package/dist/src/engine/lens/engine-tools.js +37 -0
  224. package/dist/src/engine/lens/engine-tools.js.map +1 -0
  225. package/dist/src/engine/lens/env.d.ts +23 -0
  226. package/dist/src/engine/lens/env.d.ts.map +1 -0
  227. package/dist/src/engine/lens/env.js +42 -0
  228. package/dist/src/engine/lens/env.js.map +1 -0
  229. package/dist/src/engine/lens/launch-plan.d.ts +63 -0
  230. package/dist/src/engine/lens/launch-plan.d.ts.map +1 -0
  231. package/dist/src/engine/lens/launch-plan.js +203 -0
  232. package/dist/src/engine/lens/launch-plan.js.map +1 -0
  233. package/dist/src/engine/lens/leaf-cache.d.ts +22 -0
  234. package/dist/src/engine/lens/leaf-cache.d.ts.map +1 -0
  235. package/dist/src/engine/lens/leaf-cache.js +115 -0
  236. package/dist/src/engine/lens/leaf-cache.js.map +1 -0
  237. package/dist/src/engine/lens/permits.d.ts +14 -0
  238. package/dist/src/engine/lens/permits.d.ts.map +1 -0
  239. package/dist/src/engine/lens/permits.js +18 -0
  240. package/dist/src/engine/lens/permits.js.map +1 -0
  241. package/dist/src/engine/lens/registry.d.ts +55 -0
  242. package/dist/src/engine/lens/registry.d.ts.map +1 -0
  243. package/dist/src/engine/lens/registry.js +140 -0
  244. package/dist/src/engine/lens/registry.js.map +1 -0
  245. package/dist/src/engine/lens/round-lens.d.ts +16 -0
  246. package/dist/src/engine/lens/round-lens.d.ts.map +1 -0
  247. package/dist/src/engine/lens/round-lens.js +46 -0
  248. package/dist/src/engine/lens/round-lens.js.map +1 -0
  249. package/dist/src/engine/lens/runlens.d.ts +103 -0
  250. package/dist/src/engine/lens/runlens.d.ts.map +1 -0
  251. package/dist/src/engine/lens/runlens.js +538 -0
  252. package/dist/src/engine/lens/runlens.js.map +1 -0
  253. package/dist/src/engine/lens/section.d.ts +19 -0
  254. package/dist/src/engine/lens/section.d.ts.map +1 -0
  255. package/dist/src/engine/lens/section.js +40 -0
  256. package/dist/src/engine/lens/section.js.map +1 -0
  257. package/dist/src/engine/lens/semaphore.d.ts +13 -0
  258. package/dist/src/engine/lens/semaphore.d.ts.map +1 -0
  259. package/dist/src/engine/lens/semaphore.js +44 -0
  260. package/dist/src/engine/lens/semaphore.js.map +1 -0
  261. package/dist/src/engine/lens/tool.d.ts +34 -0
  262. package/dist/src/engine/lens/tool.d.ts.map +1 -0
  263. package/dist/src/engine/lens/tool.js +345 -0
  264. package/dist/src/engine/lens/tool.js.map +1 -0
  265. package/dist/src/engine/lens/wave.d.ts +63 -0
  266. package/dist/src/engine/lens/wave.d.ts.map +1 -0
  267. package/dist/src/engine/lens/wave.js +95 -0
  268. package/dist/src/engine/lens/wave.js.map +1 -0
  269. package/dist/src/engine/livesessions.d.ts +21 -0
  270. package/dist/src/engine/livesessions.d.ts.map +1 -0
  271. package/dist/src/engine/livesessions.js +57 -0
  272. package/dist/src/engine/livesessions.js.map +1 -0
  273. package/dist/src/engine/livewire.d.ts +18 -0
  274. package/dist/src/engine/livewire.d.ts.map +1 -0
  275. package/dist/src/engine/livewire.js +31 -0
  276. package/dist/src/engine/livewire.js.map +1 -0
  277. package/dist/src/engine/openable.d.ts +37 -0
  278. package/dist/src/engine/openable.d.ts.map +1 -0
  279. package/dist/src/engine/openable.js +120 -0
  280. package/dist/src/engine/openable.js.map +1 -0
  281. package/dist/src/engine/pisdk.d.ts +226 -0
  282. package/dist/src/engine/pisdk.d.ts.map +1 -0
  283. package/dist/src/engine/pisdk.js +552 -0
  284. package/dist/src/engine/pisdk.js.map +1 -0
  285. package/dist/src/engine/port.d.ts +144 -0
  286. package/dist/src/engine/port.d.ts.map +1 -0
  287. package/dist/src/engine/port.js +16 -0
  288. package/dist/src/engine/port.js.map +1 -0
  289. package/dist/src/engine/prompts.d.ts +159 -0
  290. package/dist/src/engine/prompts.d.ts.map +1 -0
  291. package/dist/src/engine/prompts.js +339 -0
  292. package/dist/src/engine/prompts.js.map +1 -0
  293. package/dist/src/engine/render.d.ts +10 -0
  294. package/dist/src/engine/render.d.ts.map +1 -0
  295. package/dist/src/engine/render.js +23 -0
  296. package/dist/src/engine/render.js.map +1 -0
  297. package/dist/src/engine/reviewruntime.d.ts +13 -0
  298. package/dist/src/engine/reviewruntime.d.ts.map +1 -0
  299. package/dist/src/engine/reviewruntime.js +35 -0
  300. package/dist/src/engine/reviewruntime.js.map +1 -0
  301. package/dist/src/engine/reviewsdk.d.ts +75 -0
  302. package/dist/src/engine/reviewsdk.d.ts.map +1 -0
  303. package/dist/src/engine/reviewsdk.js +180 -0
  304. package/dist/src/engine/reviewsdk.js.map +1 -0
  305. package/dist/src/engine/round.d.ts +166 -0
  306. package/dist/src/engine/round.d.ts.map +1 -0
  307. package/dist/src/engine/round.js +543 -0
  308. package/dist/src/engine/round.js.map +1 -0
  309. package/dist/src/engine/roundagents.d.ts +17 -0
  310. package/dist/src/engine/roundagents.d.ts.map +1 -0
  311. package/dist/src/engine/roundagents.js +49 -0
  312. package/dist/src/engine/roundagents.js.map +1 -0
  313. package/dist/src/engine/roundevents.d.ts +110 -0
  314. package/dist/src/engine/roundevents.d.ts.map +1 -0
  315. package/dist/src/engine/roundevents.js +276 -0
  316. package/dist/src/engine/roundevents.js.map +1 -0
  317. package/dist/src/engine/roundsdk.d.ts +33 -0
  318. package/dist/src/engine/roundsdk.d.ts.map +1 -0
  319. package/dist/src/engine/roundsdk.js +76 -0
  320. package/dist/src/engine/roundsdk.js.map +1 -0
  321. package/dist/src/engine/sdkruntime.d.ts +23 -0
  322. package/dist/src/engine/sdkruntime.d.ts.map +1 -0
  323. package/dist/src/engine/sdkruntime.js +340 -0
  324. package/dist/src/engine/sdkruntime.js.map +1 -0
  325. package/dist/src/engine/select.d.ts +16 -0
  326. package/dist/src/engine/select.d.ts.map +1 -0
  327. package/dist/src/engine/select.js +32 -0
  328. package/dist/src/engine/select.js.map +1 -0
  329. package/dist/src/engine/sessionwrap.d.ts +4 -0
  330. package/dist/src/engine/sessionwrap.d.ts.map +1 -0
  331. package/dist/src/engine/sessionwrap.js +31 -0
  332. package/dist/src/engine/sessionwrap.js.map +1 -0
  333. package/dist/src/engine/toolfence.d.ts +90 -0
  334. package/dist/src/engine/toolfence.d.ts.map +1 -0
  335. package/dist/src/engine/toolfence.js +378 -0
  336. package/dist/src/engine/toolfence.js.map +1 -0
  337. package/dist/src/engine/tooluid.d.ts +128 -0
  338. package/dist/src/engine/tooluid.d.ts.map +1 -0
  339. package/dist/src/engine/tooluid.js +401 -0
  340. package/dist/src/engine/tooluid.js.map +1 -0
  341. package/dist/src/envstrip.d.ts +10 -0
  342. package/dist/src/envstrip.d.ts.map +1 -0
  343. package/dist/src/envstrip.js +15 -0
  344. package/dist/src/envstrip.js.map +1 -0
  345. package/dist/src/escalationprompt.d.ts +22 -0
  346. package/dist/src/escalationprompt.d.ts.map +1 -0
  347. package/dist/src/escalationprompt.js +54 -0
  348. package/dist/src/escalationprompt.js.map +1 -0
  349. package/dist/src/execfn.d.ts +29 -0
  350. package/dist/src/execfn.d.ts.map +1 -0
  351. package/dist/src/execfn.js +55 -0
  352. package/dist/src/execfn.js.map +1 -0
  353. package/dist/src/finding-chain.d.ts +40 -0
  354. package/dist/src/finding-chain.d.ts.map +1 -0
  355. package/dist/src/finding-chain.js +76 -0
  356. package/dist/src/finding-chain.js.map +1 -0
  357. package/dist/src/finding-row.d.ts +24 -0
  358. package/dist/src/finding-row.d.ts.map +1 -0
  359. package/dist/src/finding-row.js +26 -0
  360. package/dist/src/finding-row.js.map +1 -0
  361. package/dist/src/findingkey.d.ts +99 -0
  362. package/dist/src/findingkey.d.ts.map +1 -0
  363. package/dist/src/findingkey.js +210 -0
  364. package/dist/src/findingkey.js.map +1 -0
  365. package/dist/src/findingsrecord.d.ts +175 -0
  366. package/dist/src/findingsrecord.d.ts.map +1 -0
  367. package/dist/src/findingsrecord.js +410 -0
  368. package/dist/src/findingsrecord.js.map +1 -0
  369. package/dist/src/followup-mode.d.ts +4 -0
  370. package/dist/src/followup-mode.d.ts.map +1 -0
  371. package/dist/src/followup-mode.js +6 -0
  372. package/dist/src/followup-mode.js.map +1 -0
  373. package/dist/src/gate.d.ts +199 -0
  374. package/dist/src/gate.d.ts.map +1 -0
  375. package/dist/src/gate.js +315 -0
  376. package/dist/src/gate.js.map +1 -0
  377. package/dist/src/ghcred.d.ts +58 -0
  378. package/dist/src/ghcred.d.ts.map +1 -0
  379. package/dist/src/ghcred.js +139 -0
  380. package/dist/src/ghcred.js.map +1 -0
  381. package/dist/src/githubAuth.d.ts +32 -0
  382. package/dist/src/githubAuth.d.ts.map +1 -0
  383. package/dist/src/githubAuth.js +116 -0
  384. package/dist/src/githubAuth.js.map +1 -0
  385. package/dist/src/githubcore.d.ts +20 -0
  386. package/dist/src/githubcore.d.ts.map +1 -0
  387. package/dist/src/githubcore.js +20 -0
  388. package/dist/src/githubcore.js.map +1 -0
  389. package/dist/src/glob.d.ts +11 -0
  390. package/dist/src/glob.d.ts.map +1 -0
  391. package/dist/src/glob.js +69 -0
  392. package/dist/src/glob.js.map +1 -0
  393. package/dist/src/guards.d.ts +93 -0
  394. package/dist/src/guards.d.ts.map +1 -0
  395. package/dist/src/guards.js +306 -0
  396. package/dist/src/guards.js.map +1 -0
  397. package/dist/src/heartbeat.d.ts +29 -0
  398. package/dist/src/heartbeat.d.ts.map +1 -0
  399. package/dist/src/heartbeat.js +87 -0
  400. package/dist/src/heartbeat.js.map +1 -0
  401. package/dist/src/heuristics.d.ts +247 -0
  402. package/dist/src/heuristics.d.ts.map +1 -0
  403. package/dist/src/heuristics.js +927 -0
  404. package/dist/src/heuristics.js.map +1 -0
  405. package/dist/src/homes.d.ts +181 -0
  406. package/dist/src/homes.d.ts.map +1 -0
  407. package/dist/src/homes.js +242 -0
  408. package/dist/src/homes.js.map +1 -0
  409. package/dist/src/hunkheader.d.ts +11 -0
  410. package/dist/src/hunkheader.d.ts.map +1 -0
  411. package/dist/src/hunkheader.js +18 -0
  412. package/dist/src/hunkheader.js.map +1 -0
  413. package/dist/src/include.d.ts +435 -0
  414. package/dist/src/include.d.ts.map +1 -0
  415. package/dist/src/include.js +1692 -0
  416. package/dist/src/include.js.map +1 -0
  417. package/dist/src/launchvocab.d.ts +36 -0
  418. package/dist/src/launchvocab.d.ts.map +1 -0
  419. package/dist/src/launchvocab.js +59 -0
  420. package/dist/src/launchvocab.js.map +1 -0
  421. package/dist/src/ledgercarry.d.ts +24 -0
  422. package/dist/src/ledgercarry.d.ts.map +1 -0
  423. package/dist/src/ledgercarry.js +109 -0
  424. package/dist/src/ledgercarry.js.map +1 -0
  425. package/dist/src/lensids.d.ts +7 -0
  426. package/dist/src/lensids.d.ts.map +1 -0
  427. package/dist/src/lensids.js +53 -0
  428. package/dist/src/lensids.js.map +1 -0
  429. package/dist/src/lensplanning.d.ts +8 -0
  430. package/dist/src/lensplanning.d.ts.map +1 -0
  431. package/dist/src/lensplanning.js +126 -0
  432. package/dist/src/lensplanning.js.map +1 -0
  433. package/dist/src/lensresult.d.ts +94 -0
  434. package/dist/src/lensresult.d.ts.map +1 -0
  435. package/dist/src/lensresult.js +162 -0
  436. package/dist/src/lensresult.js.map +1 -0
  437. package/dist/src/lenstuning.d.ts +97 -0
  438. package/dist/src/lenstuning.d.ts.map +1 -0
  439. package/dist/src/lenstuning.js +326 -0
  440. package/dist/src/lenstuning.js.map +1 -0
  441. package/dist/src/lintconfig.d.ts +31 -0
  442. package/dist/src/lintconfig.d.ts.map +1 -0
  443. package/dist/src/lintconfig.js +543 -0
  444. package/dist/src/lintconfig.js.map +1 -0
  445. package/dist/src/live/events.d.ts +188 -0
  446. package/dist/src/live/events.d.ts.map +1 -0
  447. package/dist/src/live/events.js +136 -0
  448. package/dist/src/live/events.js.map +1 -0
  449. package/dist/src/live/writer.d.ts +54 -0
  450. package/dist/src/live/writer.d.ts.map +1 -0
  451. package/dist/src/live/writer.js +516 -0
  452. package/dist/src/live/writer.js.map +1 -0
  453. package/dist/src/localreview.d.ts +128 -0
  454. package/dist/src/localreview.d.ts.map +1 -0
  455. package/dist/src/localreview.js +426 -0
  456. package/dist/src/localreview.js.map +1 -0
  457. package/dist/src/loosenedtests.d.ts +13 -0
  458. package/dist/src/loosenedtests.d.ts.map +1 -0
  459. package/dist/src/loosenedtests.js +188 -0
  460. package/dist/src/loosenedtests.js.map +1 -0
  461. package/dist/src/memoryport.d.ts +264 -0
  462. package/dist/src/memoryport.d.ts.map +1 -0
  463. package/dist/src/memoryport.js +445 -0
  464. package/dist/src/memoryport.js.map +1 -0
  465. package/dist/src/migrations.d.ts +106 -0
  466. package/dist/src/migrations.d.ts.map +1 -0
  467. package/dist/src/migrations.js +373 -0
  468. package/dist/src/migrations.js.map +1 -0
  469. package/dist/src/oneshot.d.ts +12 -0
  470. package/dist/src/oneshot.d.ts.map +1 -0
  471. package/dist/src/oneshot.js +8 -0
  472. package/dist/src/oneshot.js.map +1 -0
  473. package/dist/src/packing.d.ts +11 -0
  474. package/dist/src/packing.d.ts.map +1 -0
  475. package/dist/src/packing.js +31 -0
  476. package/dist/src/packing.js.map +1 -0
  477. package/dist/src/passes.d.ts +16 -0
  478. package/dist/src/passes.d.ts.map +1 -0
  479. package/dist/src/passes.js +104 -0
  480. package/dist/src/passes.js.map +1 -0
  481. package/dist/src/passtasks.d.ts +68 -0
  482. package/dist/src/passtasks.d.ts.map +1 -0
  483. package/dist/src/passtasks.js +360 -0
  484. package/dist/src/passtasks.js.map +1 -0
  485. package/dist/src/pathinside.d.ts +5 -0
  486. package/dist/src/pathinside.d.ts.map +1 -0
  487. package/dist/src/pathinside.js +9 -0
  488. package/dist/src/pathinside.js.map +1 -0
  489. package/dist/src/pibuiltins.d.ts +36 -0
  490. package/dist/src/pibuiltins.d.ts.map +1 -0
  491. package/dist/src/pibuiltins.js +83 -0
  492. package/dist/src/pibuiltins.js.map +1 -0
  493. package/dist/src/plankey.d.ts +4 -0
  494. package/dist/src/plankey.d.ts.map +1 -0
  495. package/dist/src/plankey.js +10 -0
  496. package/dist/src/plankey.js.map +1 -0
  497. package/dist/src/planner.d.ts +375 -0
  498. package/dist/src/planner.d.ts.map +1 -0
  499. package/dist/src/planner.js +722 -0
  500. package/dist/src/planner.js.map +1 -0
  501. package/dist/src/plugin/args.d.ts +19 -0
  502. package/dist/src/plugin/args.d.ts.map +1 -0
  503. package/dist/src/plugin/args.js +107 -0
  504. package/dist/src/plugin/args.js.map +1 -0
  505. package/dist/src/plugin/index.d.ts +3 -0
  506. package/dist/src/plugin/index.d.ts.map +1 -0
  507. package/dist/src/plugin/index.js +14 -0
  508. package/dist/src/plugin/index.js.map +1 -0
  509. package/dist/src/plugin/login.d.ts +39 -0
  510. package/dist/src/plugin/login.d.ts.map +1 -0
  511. package/dist/src/plugin/login.js +73 -0
  512. package/dist/src/plugin/login.js.map +1 -0
  513. package/dist/src/plugin/review.d.ts +27 -0
  514. package/dist/src/plugin/review.d.ts.map +1 -0
  515. package/dist/src/plugin/review.js +298 -0
  516. package/dist/src/plugin/review.js.map +1 -0
  517. package/dist/src/plugin/runner.d.ts +6 -0
  518. package/dist/src/plugin/runner.d.ts.map +1 -0
  519. package/dist/src/plugin/runner.js +65 -0
  520. package/dist/src/plugin/runner.js.map +1 -0
  521. package/dist/src/plugin/scan.d.ts +79 -0
  522. package/dist/src/plugin/scan.d.ts.map +1 -0
  523. package/dist/src/plugin/scan.js +348 -0
  524. package/dist/src/plugin/scan.js.map +1 -0
  525. package/dist/src/plugin/session.d.ts +56 -0
  526. package/dist/src/plugin/session.d.ts.map +1 -0
  527. package/dist/src/plugin/session.js +2 -0
  528. package/dist/src/plugin/session.js.map +1 -0
  529. package/dist/src/plugin/setup.d.ts +19 -0
  530. package/dist/src/plugin/setup.d.ts.map +1 -0
  531. package/dist/src/plugin/setup.js +142 -0
  532. package/dist/src/plugin/setup.js.map +1 -0
  533. package/dist/src/plugin/versiongate.d.ts +26 -0
  534. package/dist/src/plugin/versiongate.d.ts.map +1 -0
  535. package/dist/src/plugin/versiongate.js +83 -0
  536. package/dist/src/plugin/versiongate.js.map +1 -0
  537. package/dist/src/postingkey.d.ts +8 -0
  538. package/dist/src/postingkey.d.ts.map +1 -0
  539. package/dist/src/postingkey.js +25 -0
  540. package/dist/src/postingkey.js.map +1 -0
  541. package/dist/src/postingpolicy.d.ts +108 -0
  542. package/dist/src/postingpolicy.d.ts.map +1 -0
  543. package/dist/src/postingpolicy.js +341 -0
  544. package/dist/src/postingpolicy.js.map +1 -0
  545. package/dist/src/pr-identity.d.ts +3 -0
  546. package/dist/src/pr-identity.d.ts.map +1 -0
  547. package/dist/src/pr-identity.js +9 -0
  548. package/dist/src/pr-identity.js.map +1 -0
  549. package/dist/src/prdelta.d.ts +181 -0
  550. package/dist/src/prdelta.d.ts.map +1 -0
  551. package/dist/src/prdelta.js +501 -0
  552. package/dist/src/prdelta.js.map +1 -0
  553. package/dist/src/pricing.d.ts +94 -0
  554. package/dist/src/pricing.d.ts.map +1 -0
  555. package/dist/src/pricing.js +209 -0
  556. package/dist/src/pricing.js.map +1 -0
  557. package/dist/src/priorfindings.d.ts +167 -0
  558. package/dist/src/priorfindings.d.ts.map +1 -0
  559. package/dist/src/priorfindings.js +445 -0
  560. package/dist/src/priorfindings.js.map +1 -0
  561. package/dist/src/priortasks.d.ts +31 -0
  562. package/dist/src/priortasks.d.ts.map +1 -0
  563. package/dist/src/priortasks.js +119 -0
  564. package/dist/src/priortasks.js.map +1 -0
  565. package/dist/src/profilelookup.d.ts +59 -0
  566. package/dist/src/profilelookup.d.ts.map +1 -0
  567. package/dist/src/profilelookup.js +98 -0
  568. package/dist/src/profilelookup.js.map +1 -0
  569. package/dist/src/profileoverrides.d.ts +91 -0
  570. package/dist/src/profileoverrides.d.ts.map +1 -0
  571. package/dist/src/profileoverrides.js +313 -0
  572. package/dist/src/profileoverrides.js.map +1 -0
  573. package/dist/src/profiles.d.ts +144 -0
  574. package/dist/src/profiles.d.ts.map +1 -0
  575. package/dist/src/profiles.js +604 -0
  576. package/dist/src/profiles.js.map +1 -0
  577. package/dist/src/promptdoc.d.ts +3 -0
  578. package/dist/src/promptdoc.d.ts.map +1 -0
  579. package/dist/src/promptdoc.js +18 -0
  580. package/dist/src/promptdoc.js.map +1 -0
  581. package/dist/src/prtarget.d.ts +70 -0
  582. package/dist/src/prtarget.d.ts.map +1 -0
  583. package/dist/src/prtarget.js +181 -0
  584. package/dist/src/prtarget.js.map +1 -0
  585. package/dist/src/recheckorder.d.ts +32 -0
  586. package/dist/src/recheckorder.d.ts.map +1 -0
  587. package/dist/src/recheckorder.js +5 -0
  588. package/dist/src/recheckorder.js.map +1 -0
  589. package/dist/src/reviewapi.d.ts +207 -0
  590. package/dist/src/reviewapi.d.ts.map +1 -0
  591. package/dist/src/reviewapi.js +939 -0
  592. package/dist/src/reviewapi.js.map +1 -0
  593. package/dist/src/reviewbotfindings.d.ts +33 -0
  594. package/dist/src/reviewbotfindings.d.ts.map +1 -0
  595. package/dist/src/reviewbotfindings.js +80 -0
  596. package/dist/src/reviewbotfindings.js.map +1 -0
  597. package/dist/src/reviewconfig.d.ts +741 -0
  598. package/dist/src/reviewconfig.d.ts.map +1 -0
  599. package/dist/src/reviewconfig.js +797 -0
  600. package/dist/src/reviewconfig.js.map +1 -0
  601. package/dist/src/reviewdelta.d.ts +51 -0
  602. package/dist/src/reviewdelta.d.ts.map +1 -0
  603. package/dist/src/reviewdelta.js +119 -0
  604. package/dist/src/reviewdelta.js.map +1 -0
  605. package/dist/src/reviewdest.d.ts +86 -0
  606. package/dist/src/reviewdest.d.ts.map +1 -0
  607. package/dist/src/reviewdest.js +142 -0
  608. package/dist/src/reviewdest.js.map +1 -0
  609. package/dist/src/reviewerstub.d.ts +12 -0
  610. package/dist/src/reviewerstub.d.ts.map +1 -0
  611. package/dist/src/reviewerstub.js +28 -0
  612. package/dist/src/reviewerstub.js.map +1 -0
  613. package/dist/src/reviewhost.d.ts +58 -0
  614. package/dist/src/reviewhost.d.ts.map +1 -0
  615. package/dist/src/reviewhost.js +135 -0
  616. package/dist/src/reviewhost.js.map +1 -0
  617. package/dist/src/reviewpost.d.ts +79 -0
  618. package/dist/src/reviewpost.d.ts.map +1 -0
  619. package/dist/src/reviewpost.js +233 -0
  620. package/dist/src/reviewpost.js.map +1 -0
  621. package/dist/src/reviewrender.d.ts +154 -0
  622. package/dist/src/reviewrender.d.ts.map +1 -0
  623. package/dist/src/reviewrender.js +480 -0
  624. package/dist/src/reviewrender.js.map +1 -0
  625. package/dist/src/reviewstate.d.ts +62 -0
  626. package/dist/src/reviewstate.d.ts.map +1 -0
  627. package/dist/src/reviewstate.js +167 -0
  628. package/dist/src/reviewstate.js.map +1 -0
  629. package/dist/src/reviewstore.d.ts +17 -0
  630. package/dist/src/reviewstore.d.ts.map +1 -0
  631. package/dist/src/reviewstore.js +105 -0
  632. package/dist/src/reviewstore.js.map +1 -0
  633. package/dist/src/round-progress.d.ts +120 -0
  634. package/dist/src/round-progress.d.ts.map +1 -0
  635. package/dist/src/round-progress.js +172 -0
  636. package/dist/src/round-progress.js.map +1 -0
  637. package/dist/src/round-stage-deadlines.d.ts +12 -0
  638. package/dist/src/round-stage-deadlines.d.ts.map +1 -0
  639. package/dist/src/round-stage-deadlines.js +10 -0
  640. package/dist/src/round-stage-deadlines.js.map +1 -0
  641. package/dist/src/session-command.d.ts +19 -0
  642. package/dist/src/session-command.d.ts.map +1 -0
  643. package/dist/src/session-command.js +20 -0
  644. package/dist/src/session-command.js.map +1 -0
  645. package/dist/src/settledread.d.ts +61 -0
  646. package/dist/src/settledread.d.ts.map +1 -0
  647. package/dist/src/settledread.js +135 -0
  648. package/dist/src/settledread.js.map +1 -0
  649. package/dist/src/severity.d.ts +167 -0
  650. package/dist/src/severity.d.ts.map +1 -0
  651. package/dist/src/severity.js +364 -0
  652. package/dist/src/severity.js.map +1 -0
  653. package/dist/src/severityrank.d.ts +8 -0
  654. package/dist/src/severityrank.d.ts.map +1 -0
  655. package/dist/src/severityrank.js +16 -0
  656. package/dist/src/severityrank.js.map +1 -0
  657. package/dist/src/standing-review.d.ts +45 -0
  658. package/dist/src/standing-review.d.ts.map +1 -0
  659. package/dist/src/standing-review.js +43 -0
  660. package/dist/src/standing-review.js.map +1 -0
  661. package/dist/src/storerows.d.ts +225 -0
  662. package/dist/src/storerows.d.ts.map +1 -0
  663. package/dist/src/storerows.js +7 -0
  664. package/dist/src/storerows.js.map +1 -0
  665. package/dist/src/styleguide.d.ts +25 -0
  666. package/dist/src/styleguide.d.ts.map +1 -0
  667. package/dist/src/styleguide.js +272 -0
  668. package/dist/src/styleguide.js.map +1 -0
  669. package/dist/src/sweepstatus.d.ts +42 -0
  670. package/dist/src/sweepstatus.d.ts.map +1 -0
  671. package/dist/src/sweepstatus.js +97 -0
  672. package/dist/src/sweepstatus.js.map +1 -0
  673. package/dist/src/templates.d.ts +243 -0
  674. package/dist/src/templates.d.ts.map +1 -0
  675. package/dist/src/templates.js +847 -0
  676. package/dist/src/templates.js.map +1 -0
  677. package/dist/src/types.d.ts +639 -0
  678. package/dist/src/types.d.ts.map +1 -0
  679. package/dist/src/types.js +90 -0
  680. package/dist/src/types.js.map +1 -0
  681. package/dist/src/unifieddiff.d.ts +22 -0
  682. package/dist/src/unifieddiff.d.ts.map +1 -0
  683. package/dist/src/unifieddiff.js +176 -0
  684. package/dist/src/unifieddiff.js.map +1 -0
  685. package/dist/src/yieldloop.d.ts +2 -0
  686. package/dist/src/yieldloop.d.ts.map +1 -0
  687. package/dist/src/yieldloop.js +14 -0
  688. package/dist/src/yieldloop.js.map +1 -0
  689. package/package.json +60 -4
@@ -0,0 +1,3058 @@
1
+ // bash-guard — pi extension enforcing the sessions' no-exec policy at the
2
+ // tool-call boundary. Review sessions read code; they never run it. The guard
3
+ // inspects every bash tool call and blocks any segment whose program is not on
4
+ // the read-only allowlist, plus mutating `gh` and `git` verbs (sanctioned mutations go
5
+ // through the repo's own scripts/*.sh surface, which IS allowed for the
6
+ // specific script basenames below — approve-pr.sh, mint-token.sh (read-only
7
+ // gh needs it) and the pr-*/post-review/reply-thread helpers; deploy.sh
8
+ // stays unreachable).
9
+ //
10
+ // The checker is pure (checkBashCommand) so tests need no pi. The pi hookup
11
+ // (`pi.on("tool_call", …)` returning {block, reason}) is a VERIFIED capability
12
+ // per the pi control-surface findings (§5), and its `toolName` and `input`
13
+ // fields are seen in production at pi 1.3.0 (a vetoed call's exec-policy reason
14
+ // reached the model).
15
+ //
16
+ // This guard is ONE LAYER of the exec boundary, not the whole of it. It vets
17
+ // command strings, and a command guard never sees a FILE the session writes —
18
+ // which is the decisive ingredient in every bypass of a string-only guard. So:
19
+ //
20
+ // · it also vets the WRITE tools, on one question — is this path a
21
+ // PROTECTED write target (`isProtectedWriteTarget`)? That is git's
22
+ // configuration surface (`isGitConfigSurface` — the repo config scope,
23
+ // where a plain `git diff` executes a program named by
24
+ // `<clone>/.git/config` with no suspicious token in the command at all),
25
+ // this system's own policy surface (`isPolicySurface` — config.toml, the
26
+ // sanctioned scripts, the compiled guard, the seeds), the memory-scope
27
+ // files a session's own recall reads (`isMemoryScopeSurface` — the recall
28
+ // hint and the retired list), and the wake-delivery files
29
+ // (`isDeliverySurface` — the inject dirs and the bridge's `.done/`);
30
+ // · the global/XDG/system scopes are closed OUTSIDE this file, by
31
+ // `sessionGitConfigPins` in src/sessions.ts, which pins where git looks for
32
+ // configuration at spawn. The guard's job there is only to stop a caller
33
+ // RE-POINTING the pin (COMMAND_INJECTING_ENV).
34
+ import { readFileSync, realpathSync } from "node:fs";
35
+ import { posix } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ /**
38
+ * Read-only programs a review session may run directly.
39
+ *
40
+ * **`awk` is deliberately ABSENT, and must not be re-added.** No regex gating
41
+ * of awk is sound: it is a programming language in which `system(x)`,
42
+ * `x | getline`, `print | x` and `print > x` are all first-class and every one
43
+ * takes an ARBITRARY EXPRESSION, so a bare identifier holding the command
44
+ * defeats any rule keyed on the token after the operator. VERIFIED executing:
45
+ *
46
+ * awk 'BEGIN{c="sh"; print "<cmd>" | c}' → ran <cmd>
47
+ * awk 'BEGIN{f="<path>"; print "x" > f}' → wrote <path>
48
+ * awk 'BEGIN{f=".git/config"; printf … >> f}' → then a plain `git diff` ran <cmd>
49
+ *
50
+ * The first is arbitrary execution with no config file touched, so the
51
+ * round-boundary integrity check cannot see it either.
52
+ *
53
+ * The cost is close to zero: `awk` appears in no prompt, no agent and no
54
+ * session-sanctioned script. A reviewer keeps `rg`, `grep`, `jq`, `cut`,
55
+ * `sort`, `uniq`, `head`, `tail`, `wc`, `nl`, `tr` and `sed -n`; `gawk`,
56
+ * `mawk` and `nawk` are off ALLOWED too.
57
+ *
58
+ * `sed` stays, on a narrower argument: its script language is small and
59
+ * fixed — its file/exec verbs are the `e`/`w`/`W`/`r`/`R` commands and the
60
+ * `e`/`w` flags of `s` — so `checkSedInvocation` PARSES every invocation
61
+ * (options, addresses, any `s` delimiter) and refuses anything it cannot
62
+ * classify. awk's language takes an expression anywhere; sed's does not.
63
+ *
64
+ * Exported in ORDER because the sentence every built pass reads ("Allowed
65
+ * commands: …", `packages/pi-plugin/src/include.ts`) is rendered from this array: a model told a
66
+ * tool exists and then refused reads the refusal as a malfunction, so the
67
+ * enforcing half is the only author of the advertised half;
68
+ * `tests/fences.test.ts` holds the advertised set equal to this array, runs
69
+ * every advertised program through `checkBashCommand` (from source), and
70
+ * separately checks the rendered sentence against `packages/pi-plugin/dist/src/include.js`.
71
+ */
72
+ export const ALLOWED_PROGRAMS = [
73
+ "git",
74
+ "rg",
75
+ "grep",
76
+ "cat",
77
+ "ls",
78
+ "find",
79
+ "head",
80
+ "tail",
81
+ "wc",
82
+ "jq",
83
+ "sed",
84
+ "sort",
85
+ "uniq",
86
+ "diff",
87
+ "gh",
88
+ "echo",
89
+ "printf",
90
+ "cut",
91
+ "tr",
92
+ "xargs",
93
+ "dirname",
94
+ "basename",
95
+ "test",
96
+ "true",
97
+ "false",
98
+ "date",
99
+ "nl",
100
+ "stat",
101
+ ];
102
+ const ALLOWED = new Set(ALLOWED_PROGRAMS);
103
+ /** Programs whose whole point is to run code the caller supplies. They are off
104
+ * ALLOWED, so the generic refusal already covers them — this exists only so the
105
+ * reason names the replacement instead of leaving the model to guess and retry.
106
+ * (`node`/`python`/… are handled by AGENT_SPAWNERS with their own reason.) */
107
+ const INTERPRETER_REASONS = {
108
+ awk: "exec policy: 'awk' is a programming language that can run commands (system(), `print | cmd`, `cmd | getline`) and write files, including through a variable no guard can read — use rg/grep/jq/cut/sort/sed -n for text",
109
+ gawk: "exec policy: 'gawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
110
+ mawk: "exec policy: 'mawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
111
+ nawk: "exec policy: 'nawk' is awk — see the awk rule; use rg/grep/jq/cut/sort/sed -n for text",
112
+ perl: "exec policy: 'perl' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
113
+ ruby: "exec policy: 'ruby' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
114
+ php: "exec policy: 'php' runs arbitrary code — use rg/grep/jq/cut/sort/sed -n for text",
115
+ };
116
+ /** Sanctioned sandbox scripts — the controlled surface for routine GitHub
117
+ * reads and gate-mirrored posting. mint-token.sh is sanctioned because the
118
+ * templates allow raw READ-ONLY `gh`, which needs `GH_TOKEN="$(…)"`.
119
+ * deploy.sh stays unreachable. */
120
+ const SANCTIONED_SCRIPTS = new Set([
121
+ "approve-pr.sh",
122
+ "mint-token.sh",
123
+ "pr-context.sh",
124
+ "pr-checks.sh",
125
+ // Mandated by all three round templates. Read-only by construction — GETs
126
+ // only, no method override, no body — and its two delegates, pr-threads.sh
127
+ // and pr-checks.sh, are sanctioned too. `tests/fences.test.ts` derives this
128
+ // set from the prompts, so the exec policy and the prompt contract cannot
129
+ // disagree.
130
+ "pr-self.sh",
131
+ "pr-threads.sh",
132
+ "post-review.sh",
133
+ "reply-thread.sh",
134
+ // `[review] render_outputs` (packages/pi-plugin/src/reviewrender.ts): reads the round's
135
+ // findings record, writes the two pinned scratch files under state/. Posts
136
+ // nothing; posting still goes through post-review.sh / approve-pr.sh.
137
+ "render-review.sh",
138
+ // The round templates stage the verdict at verdict/<name>.tmp and publish
139
+ // it with this — one rename inside verdict/, so the daemon never reads a
140
+ // half-written verdict. `mv` itself stays off ALLOWED: the script takes only
141
+ // the two verdict-file name shapes, writes nothing outside verdict/, reads
142
+ // no config and posts nothing. Its `--from-record` mode also READS the
143
+ // round's own findings record to build the verdict from fields the session
144
+ // already wrote — still no other write.
145
+ "commit-verdict.sh",
146
+ ]);
147
+ /** Agent-spawning programs. They are off ALLOWED already; this set exists so
148
+ * the refusal tells the model what to do instead, and so the single-shepherd
149
+ * invariant has a rule that greps.
150
+ * The PR's main pi is the only scheduler of agents, and the lens tool (`lens` /
151
+ * `lens_wave`) is its only launcher. */
152
+ const AGENT_SPAWNERS = new Set([
153
+ "pi",
154
+ "claude",
155
+ "codex",
156
+ "node",
157
+ "npm",
158
+ "npx",
159
+ "pnpm",
160
+ "yarn",
161
+ "bun",
162
+ "bunx",
163
+ "deno",
164
+ "tsx",
165
+ "ts-node",
166
+ ]);
167
+ const AGENT_SPAWN_REASON = "exec policy: never spawn an agent or a runtime from bash — the lens tool (`lens` / `lens_wave`) is the only launcher (single-shepherd invariant)";
168
+ /** Relative under scripts/ only: no `..`, no absolute paths, exactly one
169
+ * path segment after scripts/. */
170
+ const SCRIPT_PATH_RE = /^(?:\.\/)?scripts\/([A-Za-z0-9._-]+\.sh)$/;
171
+ function block(reason) {
172
+ return { allowed: false, reason };
173
+ }
174
+ function sanctionedScript(token, cwd, policy) {
175
+ const m = SCRIPT_PATH_RE.exec(token);
176
+ if (!m)
177
+ return { sanctioned: false };
178
+ return {
179
+ sanctioned: !policy.replay &&
180
+ !(policy.noPost && POST_SCRIPTS.has(m[1])) &&
181
+ trustedScriptCwd(cwd, policy) &&
182
+ SANCTIONED_SCRIPTS.has(m[1]),
183
+ name: m[1],
184
+ };
185
+ }
186
+ /** The sanctioned scripts that post to GitHub: review, approval and thread reply. */
187
+ const POST_SCRIPTS = new Set([
188
+ "post-review.sh",
189
+ "approve-pr.sh",
190
+ "reply-thread.sh",
191
+ ]);
192
+ const TAKEOVER_NO_POST = "exec policy: a takeover posts nothing — the operator works in the session with read tools; a review, an approval or a reply goes out from a round";
193
+ /** Why a script is refused: the replay's or the takeover's own reason where one
194
+ * applies, else `fallback`. */
195
+ function notSanctioned(policy, name, fallback) {
196
+ if (policy.replay)
197
+ return REPLAY_NO_SCRIPTS;
198
+ if (policy.noPost && name !== undefined && POST_SCRIPTS.has(name))
199
+ return TAKEOVER_NO_POST;
200
+ return fallback;
201
+ }
202
+ /** Only the daemon-selected checkout owns the sanctioned script bytes. */
203
+ function trustedScriptCwd(cwd, policy) {
204
+ if (cwd === null)
205
+ return false;
206
+ try {
207
+ return realpathSync.native(cwd) === realpathSync.native(policy.sandboxRoot);
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ /** A changed shell directory is not inferred from a reviewed path. Only an
214
+ * explicit cd to the daemon's absolute root preserves script authorization;
215
+ * every other cd conservatively invalidates it for this whole command. */
216
+ function scriptCwdForCommand(cmd, cwd, policy) {
217
+ for (const segment of splitShellSegments(cmd)) {
218
+ const words = shellWords(segment).words;
219
+ let at = 0;
220
+ while (words[at] === "env" || ASSIGNMENT_RE.test(words[at] ?? ""))
221
+ at++;
222
+ const program = (words[at] ?? "").replace(/^\(+/, "");
223
+ if (basenameOf(program) !== "cd")
224
+ continue;
225
+ const args = words.slice(at + 1).filter((word) => word !== "--");
226
+ if (args.length !== 1 || !args[0].startsWith("/") || !trustedScriptCwd(args[0], policy))
227
+ return null;
228
+ }
229
+ return cwd;
230
+ }
231
+ const REPLAY_NO_SCRIPTS = "exec policy: a regression replay session runs no sandbox script — it reads the tree and writes one findings file";
232
+ /**
233
+ * `gh` is READ-ONLY, by ALLOWLIST: only the read verbs named here are allowed,
234
+ * plus `gh api` without a mutating method or body flag. A blocklist would
235
+ * leave verbs like `gh pr review <n> --approve` open — a direct approval
236
+ * around the mutation gate, the CI gate and `dry_run`, using the installation
237
+ * token the session can mint from the sanctioned `scripts/mint-token.sh`
238
+ * (which carries `pull_requests: write`) — and would need extending every time
239
+ * `gh` grows a verb. Every posting mutation goes through the sanctioned
240
+ * scripts, which mirror the gate's guards.
241
+ */
242
+ const GH_READONLY_VERBS = {
243
+ pr: new Set(["view", "list", "diff", "checks", "status"]),
244
+ issue: new Set(["view", "list", "status"]),
245
+ run: new Set(["view", "list"]),
246
+ // `gh workflow run` DISPATCHES a workflow — CI execution the reviewer must
247
+ // never start (scoping rule 2: the reviewer reads CI results, never runs it).
248
+ workflow: new Set(["view", "list"]),
249
+ release: new Set(["view", "list"]),
250
+ repo: new Set(["view"]),
251
+ label: new Set(["list"]),
252
+ cache: new Set(["list"]),
253
+ auth: new Set(["status"]),
254
+ };
255
+ /** `gh` namespaces that are read-only whatever the verb. */
256
+ const GH_READONLY_NAMESPACES = new Set([
257
+ "search",
258
+ "status",
259
+ "version",
260
+ "help",
261
+ ]);
262
+ /**
263
+ * `gh api` flags that carry a REQUEST BODY, and so promote gh's default GET to
264
+ * POST. Enumerated against `gh api --help` on gh 2.93.0 and confirmed
265
+ * empirically against a loopback server:
266
+ *
267
+ * gh api <path> --input f → POST gh api <path> → GET
268
+ * gh api <path> --input=f → POST gh api <path> --method GET … → GET
269
+ *
270
+ * `--input` matters most: a session can write `{"event":"APPROVE"}` with its
271
+ * ordinary write tool, mint the token from the sanctioned
272
+ * `scripts/mint-token.sh`, and `gh api .../pulls/<n>/reviews --input` it — a
273
+ * complete approval channel.
274
+ *
275
+ * NOTE — `-i` is deliberately NOT a body flag. In gh it is `--include` (print
276
+ * response status line and headers), a pure READ flag; `--input` has no short
277
+ * form, and no other short flag carries a body. Verified in `gh api --help`
278
+ * and by probe: `gh api <path> -i` → GET.
279
+ */
280
+ const GH_API_BODY_FLAGS = new Set([
281
+ "--input",
282
+ "-f",
283
+ "-F",
284
+ "--field",
285
+ "--raw-field",
286
+ ]);
287
+ const GH_API_BODY_FLAG_ATTACHED = /^(--input|--field|--raw-field)=/;
288
+ /**
289
+ * The SHORT body flags, structurally rather than by spelling.
290
+ *
291
+ * The set above is complete by flag NAME, but a name is not a form:
292
+ * `-fevent=APPROVE` attaches the value, and `-if` clusters the boolean `-i` in
293
+ * front of `-f`. Both send POST, verified on gh 2.93.0:
294
+ *
295
+ * gh api <path> -fevent=APPROVE → POST
296
+ * gh api <path> -Fevent=APPROVE → POST
297
+ * gh api <path> -if event=APPROVE → POST
298
+ * gh api <path> -i → GET
299
+ *
300
+ * So the rule is a shape, not more literals: any single-dash cluster
301
+ * containing `f` or `F` carries a body;
302
+ * `-i` alone contains neither, so `--include` keeps working. `-X`/`-XPOST`
303
+ * contain neither either, and are handled by the explicit-method branch.
304
+ */
305
+ const GH_API_SHORT_BODY_CLUSTER = /^-[A-Za-z]*[fF]/;
306
+ /**
307
+ * What method will this `gh api` invocation actually send?
308
+ *
309
+ * An explicit `-X`/`--method` wins, otherwise a body flag promotes GET to
310
+ * POST, otherwise it is gh's default GET. Only GET (and HEAD) is allowed.
311
+ */
312
+ function ghApiEffectiveMethod(args) {
313
+ let explicit = null;
314
+ let body = false;
315
+ for (let j = 0; j < args.length; j++) {
316
+ const a = args[j];
317
+ if (a === "--")
318
+ break;
319
+ if (a === "--method") {
320
+ explicit = (args[j + 1] ?? "").toUpperCase();
321
+ j++;
322
+ continue;
323
+ }
324
+ const attached = /^--method=(.*)$/.exec(a);
325
+ if (attached) {
326
+ explicit = attached[1].toUpperCase();
327
+ continue;
328
+ }
329
+ if (GH_API_BODY_FLAGS.has(a) || GH_API_BODY_FLAG_ATTACHED.test(a))
330
+ body = true;
331
+ if (/^--(?:header|jq|template|preview|hostname|cache|input|field|raw-field)$/.test(a)) {
332
+ j++; // a value, never a flag
333
+ continue;
334
+ }
335
+ if (/^-[A-Za-z]/.test(a) && !a.startsWith("--")) {
336
+ // A short cluster: switches, then at most one flag that takes the rest of
337
+ // the word (or the next word) as its value.
338
+ if (GH_API_SHORT_BODY_CLUSTER.test(a))
339
+ body = true;
340
+ for (let k = 1; k < a.length; k++) {
341
+ const ch = a[k];
342
+ if (!"XHqtpfF".includes(ch))
343
+ continue;
344
+ const rest = a.slice(k + 1);
345
+ if (ch === "X")
346
+ explicit = (rest || (args[j + 1] ?? "")).toUpperCase();
347
+ if (rest === "")
348
+ j++;
349
+ break;
350
+ }
351
+ }
352
+ }
353
+ if (explicit !== null)
354
+ return explicit || "GET";
355
+ return body ? "POST" : "GET";
356
+ }
357
+ function checkGh(args) {
358
+ // Step over leading `-`-prefixed tokens to find the namespace. NOTE: only the
359
+ // token, never a value — gh's own global flags (`--version`, `--help`) take
360
+ // none, and every `--repo`-style flag belongs to a subcommand and follows it.
361
+ // So `gh --repo o/n pr view` lands on `o/n`, which is not a namespace and is
362
+ // REFUSED. That fails closed, which is correct, and the reason names `o/n`
363
+ // rather than `pr`.
364
+ let i = 0;
365
+ while (i < args.length && args[i].startsWith("-"))
366
+ i++;
367
+ const ns = args[i];
368
+ if (ns === undefined)
369
+ return { allowed: true }; // bare `gh` prints help
370
+ if (ns === "api") {
371
+ const method = ghApiEffectiveMethod(args.slice(i + 1));
372
+ if (method !== "GET" && method !== "HEAD") {
373
+ return block(`exec policy: this 'gh api' sends ${method}, not GET — every GitHub write goes through the ONE mutation gate (scripts/*.sh), never raw gh`);
374
+ }
375
+ return { allowed: true };
376
+ }
377
+ if (GH_READONLY_NAMESPACES.has(ns))
378
+ return { allowed: true };
379
+ const verbs = GH_READONLY_VERBS[ns];
380
+ if (verbs === undefined) {
381
+ return block(`exec policy: 'gh ${ns}' is not a sanctioned read-only gh command`);
382
+ }
383
+ // Skip flags between the namespace and its verb (`gh pr --help`).
384
+ let k = i + 1;
385
+ while (k < args.length && args[k].startsWith("-"))
386
+ k++;
387
+ const verb = args[k];
388
+ if (verb === undefined)
389
+ return { allowed: true }; // `gh pr` alone prints help
390
+ if (verbs.has(verb))
391
+ return { allowed: true };
392
+ return block(`exec policy: 'gh ${ns} ${verb}' is a mutating command — every GitHub write goes through the sanctioned scripts/*.sh, never raw gh`);
393
+ }
394
+ /**
395
+ * `git` is READ-ONLY too, and — like `gh` — by ALLOWLIST, which keeps `git
396
+ * push` (a GitHub write around the one gate) and the discard family
397
+ * (`reset --hard`, `checkout --`, `clean -fdx`, `stash`) out. A blocklist of
398
+ * write verbs does not hold:
399
+ *
400
+ * 1. It is never closed: `send-pack` (the plumbing for `push`), `bundle`,
401
+ * `archive`, `svn`, `p4`, `daemon`, `fast-export`, `symbolic-ref`,
402
+ * `reflog`, `difftool`, `update-server-info`, `branch -D` / `tag -d`…
403
+ * 2. It keys on the verb, and `-c alias.x='!<shell>' x` supplies a NEW verb
404
+ * in one token. VERIFIED executing.
405
+ * 3. `git -C /some/repo <alias>` runs an alias out of that repo's own
406
+ * config. Under an allowlist the alias name is simply not a sanctioned
407
+ * read verb, so the whole class dies without enumerating it.
408
+ *
409
+ * A blocked read verb is a loud, recoverable refusal naming what to do; an
410
+ * unblocked write verb is an ungated mutation. The failure modes are not
411
+ * symmetric.
412
+ */
413
+ const GIT_READONLY_VERBS = new Set([
414
+ "log",
415
+ "show",
416
+ "diff",
417
+ "diff-tree",
418
+ "diff-files",
419
+ "diff-index",
420
+ "status",
421
+ "blame",
422
+ "annotate",
423
+ "cat-file",
424
+ "rev-parse",
425
+ "rev-list",
426
+ "ls-files",
427
+ "ls-tree",
428
+ "ls-remote",
429
+ "grep",
430
+ "describe",
431
+ "shortlog",
432
+ "merge-base",
433
+ "name-rev",
434
+ "whatchanged",
435
+ "show-ref",
436
+ "show-branch",
437
+ "check-ignore",
438
+ "check-attr",
439
+ "count-objects",
440
+ "verify-commit",
441
+ "verify-tag",
442
+ "version",
443
+ // `range-diff` is the natural tool for a force-push re-review (what changed
444
+ // between the old and new versions of a series); `for-each-ref` is the
445
+ // scriptable ref read.
446
+ "range-diff",
447
+ "for-each-ref",
448
+ // A read of the remote. It cannot mutate GitHub, and the daemon fetches the
449
+ // base into every worktree already — but a session occasionally needs a ref
450
+ // the round did not anticipate, and refusing it would wedge that review.
451
+ "fetch",
452
+ ]);
453
+ /**
454
+ * Namespaces whose read-only sub-form is allowed and whose write forms are not.
455
+ * `branch` and `tag` list by default but delete with a flag; `worktree`,
456
+ * `remote` and `config` all carry both.
457
+ */
458
+ const GIT_READONLY_SUBVERBS = {
459
+ worktree: new Set(["list"]),
460
+ remote: new Set(["-v", "--verbose", "show", "get-url"]),
461
+ config: new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]),
462
+ branch: new Set([
463
+ "--list",
464
+ "-l",
465
+ "--contains",
466
+ "--merged",
467
+ "--no-merged",
468
+ "--show-current",
469
+ "-a",
470
+ "-r",
471
+ "-v",
472
+ "-vv",
473
+ "--all",
474
+ "--remotes",
475
+ ]),
476
+ tag: new Set(["--list", "-l", "--contains", "--points-at", "--merged"]),
477
+ };
478
+ /**
479
+ * Options that let git run a command of the caller's choosing.
480
+ *
481
+ * The family is **anything that changes WHICH CONFIG GIT READS, or names a
482
+ * program directly** — three members:
483
+ *
484
+ * 1. INJECT config inline — `-c key=value`, `--config-env=key=ENVVAR`.
485
+ * 2. NAME a program — `--upload-pack`, `--receive-pack`, `--exec`,
486
+ * `--exec-path` (where git looks for its own subcommand binaries).
487
+ * 3. RELOCATE the config git reads — at ANY of its three scopes:
488
+ * · repo — `--git-dir`, `--work-tree`, `GIT_DIR`, `GIT_WORK_TREE`
489
+ * · global — `GIT_CONFIG_GLOBAL`, and the search path it falls back
490
+ * to: `HOME` (`$HOME/.gitconfig`) and `XDG_CONFIG_HOME`
491
+ * (`$XDG_CONFIG_HOME/git/config`)
492
+ * · system — `GIT_CONFIG_SYSTEM`
493
+ * The env half lives in COMMAND_INJECTING_ENV below.
494
+ *
495
+ * All three end in the same place, because git executes config-supplied shell
496
+ * from `alias.*` (`!cmd`), `core.pager`, `credential.helper` (`!cmd`),
497
+ * `core.sshCommand`, `diff.external`, `diff.*.textconv` and `filter.*.clean`.
498
+ *
499
+ * VERIFIED by execution, not inferred:
500
+ *
501
+ * git -c "alias.probe=!<cmd>" probe → ran <cmd>
502
+ * git --git-dir=<dir> diff --no-index <A> <B> → ran <cmd>
503
+ * HOME=<dir> git diff --no-index <A> <B> → ran <cmd>
504
+ * XDG_CONFIG_HOME=<dir> git diff --no-index <A> <B> → ran <cmd>
505
+ *
506
+ * Category 3 is the worst of them. The minimum viable evil "repo" is ONE
507
+ * PLAIN-TEXT FILE: `--no-index` means git never validates the directory as a
508
+ * repository, so there is no `objects/`, no `refs/`, no `git init` and no exec
509
+ * bit — a session writes the config with its ordinary write tool.
510
+ * `--ext-diff` is NOT required, because `git diff` honours `diff.external` by
511
+ * default. Note the implicit SEARCH PATH (`HOME`, `XDG_CONFIG_HOME`) is as
512
+ * much a relocator as the explicit pointers (`GIT_CONFIG_GLOBAL`,
513
+ * `GIT_CONFIG_SYSTEM`).
514
+ *
515
+ * `-C <dir>` is deliberately NOT here. `-C` DOES reach config: it chdirs, git
516
+ * then DISCOVERS the repository there and reads its config, arriving at the
517
+ * same `diff.external`. It stays allowed because discovery requires a VALID
518
+ * repository whose config the session controls, and `git init` and
519
+ * `git clone` are both off the verb allowlist above — so the session cannot
520
+ * create one. That is a real bound, but a different one from "cannot reach
521
+ * config".
522
+ *
523
+ * Options that can inject commands on either side of the verb. Top-level
524
+ * `-c` is checked separately before the verb.
525
+ *
526
+ * `git show -c` / `git log -c` / `git diff-tree -c` is git's COMBINED
527
+ * MERGE DIFF option, not config injection — and it is exactly the tool the
528
+ * recheck needs: a hand-resolved conflict is audited against BOTH merge
529
+ * parents. Git's top-level `-c` must precede the subcommand anyway, so the
530
+ * before-verb check loses nothing.
531
+ */
532
+ const GIT_COMMAND_INJECTING_OPTS = /^(?:--config-env|--upload-pack|--receive-pack|--exec|--exec-path|--git-dir|--work-tree)(?:=|$)/;
533
+ /**
534
+ * The read verbs' own WRITE option. `--output=<file>` (and `--output <file>`)
535
+ * is a diff option: `git log`, `git show`, `git diff` and every verb that
536
+ * takes diff options write their output INTO THAT FILE, and
537
+ * `--format=format:<text>` or a committed blob (`git show HEAD:<path>`) makes
538
+ * the bytes anything at all. VERIFIED (git 2.55): `git show HEAD:a.json
539
+ * --output <dir>/o2` wrote the blob byte for byte — past the write veto,
540
+ * which reads redirections and `sed -i` only. A reviewer reads output on
541
+ * stdout; it never needs git to write a file. Exact name only: git refuses
542
+ * an abbreviation (`--outp`, `--outpu` — ambiguous with
543
+ * `--output-indicator-*`), and `--output-indicator-new` is a read option.
544
+ */
545
+ const GIT_OUTPUT_FILE_OPT = /^--output(?:=|$)/;
546
+ /**
547
+ * `git grep`'s `--open-files-in-pager[=<cmd>]` / `-O[<cmd>]` RUNS the named
548
+ * program on the matching files — VERIFIED: `git grep -O"touch <f>" a` and
549
+ * `git grep -nO…` created the file. It is arbitrary execution through a read
550
+ * verb, not a config relocation, so it is its own rule. parse-options accepts
551
+ * any unique abbreviation of the long form (`--op`, `--open=…` VERIFIED),
552
+ * and `-O` can end a short cluster (`-nO<cmd>`) — so a short cluster is
553
+ * walked letter by letter, stopping at
554
+ * the options that take the rest of the word as their value.
555
+ */
556
+ const GIT_GREP_PAGER_LONG = "open-files-in-pager";
557
+ const GIT_GREP_SHORT_WITH_VALUE = new Set(["A", "B", "C", "e", "f", "m"]);
558
+ function isGitGrepPagerOpt(a) {
559
+ const long = /^--([a-z-]+)(?:=|$)/.exec(a);
560
+ if (long)
561
+ return long[1].length >= 2 && GIT_GREP_PAGER_LONG.startsWith(long[1]);
562
+ if (!/^-[^-]/.test(a))
563
+ return false;
564
+ for (const ch of a.slice(1)) {
565
+ if (ch === "O")
566
+ return true;
567
+ if (GIT_GREP_SHORT_WITH_VALUE.has(ch))
568
+ return false;
569
+ }
570
+ return false;
571
+ }
572
+ function checkGit(rawGitArgs) {
573
+ // Judged as git RECEIVES them: bash removes inner quotes and backslashes
574
+ // before git parses an option, so `--up"load"-pack=…`, `--git-"dir"=…` and
575
+ // `--out"put"=…` are the options they spell.
576
+ const args = rawGitArgs.map(dequote).filter((a) => a.length > 0);
577
+ // Leading global options, before the verb.
578
+ let i = 0;
579
+ while (i < args.length && args[i].startsWith("-")) {
580
+ if (/^-c(?:=|$)/.test(args[i]) || GIT_COMMAND_INJECTING_OPTS.test(args[i])) {
581
+ return block(`exec policy: 'git ${args[i]}' can hand git a command to run (alias/pager/sshCommand/diff.external) — the reviewer never executes anything`);
582
+ }
583
+ if (args[i] === "-C")
584
+ i++; // a directory, not a verb — skip its value
585
+ i++;
586
+ }
587
+ const verb = args[i];
588
+ if (verb === undefined)
589
+ return { allowed: true }; // bare `git` prints usage
590
+ // …and after it, where the program-naming and config-relocating forms are
591
+ // equally effective. `-c` is excluded here: see the constant above.
592
+ for (const a of args.slice(i + 1)) {
593
+ if (GIT_COMMAND_INJECTING_OPTS.test(a)) {
594
+ return block(`exec policy: 'git ${verb} ${a}' can hand git a command to run — the reviewer never executes anything`);
595
+ }
596
+ if (GIT_OUTPUT_FILE_OPT.test(a)) {
597
+ return block(`exec policy: 'git ${verb} ${a}' makes git WRITE its output into a file of the caller's choosing — the reviewer reads git's output on stdout and never writes a file through it.`);
598
+ }
599
+ if (verb === "grep" && isGitGrepPagerOpt(a)) {
600
+ return block(`exec policy: 'git grep ${a}' runs a program on the matching files — the reviewer never executes anything. Read the matches on stdout.`);
601
+ }
602
+ }
603
+ const sub = GIT_READONLY_SUBVERBS[verb];
604
+ if (sub !== undefined) {
605
+ // POSITIONAL, not `some()` over every argument. A scan of all args would
606
+ // accept the read-only marker wherever it appeared — including as the
607
+ // VALUE of a write form, e.g. `git branch -m list`. The first token after
608
+ // the verb is the one that decides what the command does.
609
+ return sub.has(args[i + 1] ?? "")
610
+ ? { allowed: true }
611
+ : block(`exec policy: 'git ${verb}' is only allowed in its read-only form (${[...sub].join(", ")})`);
612
+ }
613
+ if (verb === "push" || verb === "send-pack") {
614
+ return block(`exec policy: 'git ${verb}' is a GitHub mutation — every write goes through the ONE mutation gate, never a session's shell`);
615
+ }
616
+ if (GIT_READONLY_VERBS.has(verb))
617
+ return { allowed: true };
618
+ return block(`exec policy: 'git ${verb}' is not a sanctioned read-only git command — this worktree is the daemon's and the reviewer only reads it (use 'git show <rev>:<path>' to read another revision)`);
619
+ }
620
+ /**
621
+ * Environment variables that name a PROGRAM for an otherwise-allowed command to
622
+ * run — the env twin of `git -c`. `VAR=val` prefixes are skipped by the
623
+ * splitter below, so without this a read verb carries the payload instead of
624
+ * the verb doing it.
625
+ *
626
+ * VERIFIED, not inferred:
627
+ * GIT_EXTERNAL_DIFF="<cmd>" git diff --ext-diff -- <file> → ran <cmd>
628
+ *
629
+ * The git family (pager, external diff, ssh, editor, askpass, and the
630
+ * `GIT_CONFIG_*` set, which reproduces `-c` exactly) plus the generic loader
631
+ * and shell hooks that would subvert ANY allowed program.
632
+ */
633
+ const COMMAND_INJECTING_ENV = new Set([
634
+ "GIT_PAGER",
635
+ "PAGER",
636
+ "GIT_EXTERNAL_DIFF",
637
+ "GIT_DIFF_OPTS",
638
+ "GIT_SSH",
639
+ "GIT_SSH_COMMAND",
640
+ "GIT_EDITOR",
641
+ "GIT_SEQUENCE_EDITOR",
642
+ "GIT_ASKPASS",
643
+ "GIT_PROXY_COMMAND",
644
+ "GIT_CONFIG",
645
+ "GIT_CONFIG_GLOBAL",
646
+ "GIT_CONFIG_SYSTEM",
647
+ "GIT_CONFIG_COUNT",
648
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
649
+ "GIT_TEMPLATE_DIR",
650
+ // The env twins of `--git-dir` / `--work-tree` / `--exec-path`: the
651
+ // config-RELOCATING third member of the family, at the REPO scope.
652
+ // `GIT_DIR=<dir> git diff --no-index A B` runs `diff.external` out of
653
+ // `<dir>/config` — VERIFIED executing, with `<dir>` holding nothing but
654
+ // that one plain-text file.
655
+ "GIT_DIR",
656
+ "GIT_COMMON_DIR",
657
+ "GIT_WORK_TREE",
658
+ "GIT_EXEC_PATH",
659
+ // …and the same category at the GLOBAL/XDG scope. `GIT_CONFIG_GLOBAL` and
660
+ // `GIT_CONFIG_SYSTEM` above are the EXPLICIT pointers; these two are the
661
+ // implicit SEARCH PATH those fall back to — `$HOME/.gitconfig` and
662
+ // `$XDG_CONFIG_HOME/git/config`. Same primitive and the same cost as the
663
+ // repo-scope form: one plain-text file written with the ordinary write tool,
664
+ // plus one ALLOWED command.
665
+ // VERIFIED executing: HOME=<dir> git diff --no-index A B.
666
+ "HOME",
667
+ "XDG_CONFIG_HOME",
668
+ "EDITOR",
669
+ "VISUAL",
670
+ "SSH_ASKPASS",
671
+ "LD_PRELOAD",
672
+ "LD_AUDIT",
673
+ "LD_LIBRARY_PATH",
674
+ "DYLD_INSERT_LIBRARIES",
675
+ "BASH_ENV",
676
+ "ENV",
677
+ "PATH",
678
+ "SHELL",
679
+ "IFS",
680
+ "PYTHONSTARTUP",
681
+ "NODE_OPTIONS",
682
+ "PERL5OPT",
683
+ ]);
684
+ /** `GIT_CONFIG_KEY_0=alias.x GIT_CONFIG_VALUE_0=!cmd` is `-c` by another name. */
685
+ const COMMAND_INJECTING_ENV_RE = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
686
+ /**
687
+ * Environment variables that RELOCATE this system's own policy inputs — the
688
+ * same primitive as `HOME=` above, aimed at us instead of at git (fenced by
689
+ * `tests/fences.test.ts` and `tests/bash-guard.test.ts`).
690
+ *
691
+ * The posting scripts are sanctioned, and every guard knob they consult is
692
+ * read out of `$QCR_CONFIG`. Without this,
693
+ *
694
+ * QCR_CONFIG=/tmp/mine.toml bash scripts/approve-pr.sh 5 body.md
695
+ *
696
+ * would pass: `guard_enabled` treats a knob as ON unless the value is
697
+ * literally `false`, so one hand-written TOML file — created with the ordinary
698
+ * write tool — turns guards 2 through 5 off on a REAL approval. The same file
699
+ * carries `dry_run`, so it would also flip a dry-run install into posting for
700
+ * real (AGENTS.md invariant 6).
701
+ *
702
+ * `QCR_STATE_DB` relocates the database the guards query for the head mark and
703
+ * the approval-withheld state; `QCR_DRYRUN_DIR` relocates where a suppressed
704
+ * intent is recorded, which is the only evidence a dry-run run leaves.
705
+ *
706
+ * SCOPE: this closes the ENV-PREFIX spelling. The two further routes —
707
+ * `bash -c '<sanctioned script> ; <anything>'` past the segment policy, and
708
+ * overwriting the config / the posting scripts / the compiled
709
+ * guard outright — are closed elsewhere: the `bash -c` payload is re-parsed as
710
+ * its own command (`bashCPayload`, from `checkSegment`), and every
711
+ * policy-carrying file is unwritable at BOTH the write-tool and the
712
+ * shell-redirection boundary (`isPolicySurface`).
713
+ *
714
+ * `QCR_POSTING_RECEIPT_DIR` is a relocator, not an argument: it names a
715
+ * DIRECTORY, exactly like `QCR_DRYRUN_DIR` and `QCR_STATE_DB` beside it, not a
716
+ * per-call value like `QCR_EXPECTED_HEAD`; the daemon never sets it (it relies
717
+ * on the scripts' default `$ROOT/state/posting-receipts`, and `verdicts.ts`
718
+ * reads that same default), so blocking it breaks no dispatch path; and a
719
+ * session has no business moving where the posting RECEIPT — the record that
720
+ * settles a round — is written.
721
+ *
722
+ * `QCR_INSTALL` is a relocator too: the install locator (src/install.ts,
723
+ * mirrored by scripts/install-locator.sh) finds the live config in
724
+ * `$XDG_CONFIG_HOME/qcr/$QCR_INSTALL/`, so naming another install is
725
+ * `QCR_CONFIG=` by another spelling. (`HOME=` and `XDG_CONFIG_HOME=`, the
726
+ * other two inputs, are refused above as git relocators.) The daemon hands
727
+ * each session the path it resolved as `QCR_CONFIG` in the pane environment —
728
+ * a session's own `QCR_CONFIG=` prefix is still refused here.
729
+ *
730
+ * NOT here, deliberately: `QCR_EXPECTED_HEAD`, `QCR_POSTING_KEY`,
731
+ * `QCR_ROUND_NO`, `QCR_POST_RESPONSE`. Those are per-invocation ARGUMENTS the
732
+ * daemon hands the script — a session setting one changes what this call is
733
+ * about, which the guards then check; it does not change what the guards ARE.
734
+ * Blocking them would break the daemon's own dispatch path.
735
+ */
736
+ const POLICY_RELOCATING_ENV = new Set([
737
+ "QCR_CONFIG",
738
+ "QCR_SANDBOX_ROOT",
739
+ "QCR_INSTALL",
740
+ "QCR_STATE_DB",
741
+ "QCR_DRYRUN_DIR",
742
+ "QCR_POSTING_RECEIPT_DIR",
743
+ ]);
744
+ /**
745
+ * Env prefixes that move where a sanctioned script SENDS the App's credentials.
746
+ * Every script that talks to GitHub reads its host from `GITHUB_API`, and
747
+ * `mint-token.sh` posts the App's signed JWT to it to mint an installation
748
+ * token — so `GITHUB_API=http://anywhere scripts/mint-token.sh` would hand the
749
+ * JWT (which can mint a token for every installation of the App) to a host
750
+ * the session chose, through the one network channel this policy otherwise
751
+ * leaves closed: curl, wget and every interpreter are refused. The daemon never
752
+ * sets it for a session; the scripts' own default (`https://api.github.com`) is
753
+ * the only host a review needs. Tests point the scripts at a loopback stub by
754
+ * running them directly, never through this guard.
755
+ */
756
+ const CREDENTIAL_RELOCATING_ENV = new Set(["GITHUB_API"]);
757
+ function envInjectionBlock(name) {
758
+ if (CREDENTIAL_RELOCATING_ENV.has(name)) {
759
+ return block(`exec policy: '${name}=' moves the host the sanctioned scripts send the App's credentials to — a session never chooses where they go`);
760
+ }
761
+ if (POLICY_RELOCATING_ENV.has(name)) {
762
+ return block(`exec policy: '${name}=' relocates the posting guards' own configuration — the guards are not a knob a session may move`);
763
+ }
764
+ if (!COMMAND_INJECTING_ENV.has(name) && !COMMAND_INJECTING_ENV_RE.test(name))
765
+ return null;
766
+ return block(`exec policy: '${name}=' names a program for another command to run — the reviewer never executes anything`);
767
+ }
768
+ /** One segment's program + args, env prefixes and subshell parens skipped.
769
+ * `segment` is the RAW text the tokens came from — needed for `bash -c`, whose
770
+ * payload the whitespace split has already shredded (see `bashCPayload`). */
771
+ function checkSegment(segment, tokens, depth, cwd, policy) {
772
+ let i = 0;
773
+ let afterEnv = false;
774
+ for (; i < tokens.length; i++) {
775
+ const raw = tokens[i].replace(/^['"(]+|['")]+$/g, "");
776
+ if (raw.length === 0)
777
+ continue;
778
+ const assign = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(raw);
779
+ if (assign) {
780
+ const refused = envInjectionBlock(assign[1]);
781
+ if (refused)
782
+ return refused;
783
+ continue; // VAR=val prefix
784
+ }
785
+ if (raw === "env") {
786
+ afterEnv = true;
787
+ continue;
788
+ }
789
+ if (afterEnv && raw.startsWith("-")) {
790
+ // `env -S '<string>'` SPLITS its string into a program and arguments —
791
+ // a second command line inside this one (`env -S 'bash -c "git push"'`).
792
+ // Nothing a reviewer does needs it.
793
+ if (/^-[A-Za-z0-9]*S|^--split-string/.test(raw)) {
794
+ return block("exec policy: 'env -S' assembles a second command line inside this one — the reviewer never launders a command through env");
795
+ }
796
+ continue; // env -i etc.
797
+ }
798
+ if (/^\d*[<>]/.test(raw))
799
+ continue; // redirection remnant
800
+ // Program token found.
801
+ const rawArgs = tokens.slice(i + 1);
802
+ const args = rawArgs.map((t) => t.replace(/^['"]+|['"]+$/g, ""));
803
+ if (basenameOf(raw) === "bash") {
804
+ // `bash -c <payload>` is a whole command line inside ONE word, so it is
805
+ // judged from the RAW segment, never from these tokens.
806
+ const lifted = bashCPayload(segment);
807
+ if (lifted.kind === "payload") {
808
+ if (depth >= MAX_NESTED_COMMAND_DEPTH)
809
+ return block("exec policy: 'bash -c' nested too deep to analyse — refused");
810
+ return checkBashCommand(lifted.payload, depth + 1, cwd, policy);
811
+ }
812
+ if (lifted.kind === "unliftable")
813
+ return block(lifted.reason);
814
+ }
815
+ return checkProgram(raw, args, rawArgs, depth, textAfterWords(segment, i + 1), cwd, policy);
816
+ }
817
+ return { allowed: true }; // empty segment
818
+ }
819
+ /** Deepest `bash -c` nesting the guard will unwrap before refusing outright —
820
+ * a bound, because a payload can itself be another `bash -c`. */
821
+ const MAX_NESTED_COMMAND_DEPTH = 8;
822
+ /**
823
+ * Split text into shell WORDS: quote-aware, DEQUOTED, split only on unquoted
824
+ * whitespace. `'…'`, `"…"` (with `\"` and `\\`), `$'…'` (ANSI-C: `\n`, `\t`,
825
+ * `\\`, `\'`, `\"`) and backslash escapes are all resolved, so what comes out
826
+ * is what bash would hand the program as argv. Unlike the whitespace re-split
827
+ * in `checkBashCommand`, this preserves separators (`;`, newline, `&&`) that
828
+ * live INSIDE a quoted word — which is exactly what a `bash -c` payload is.
829
+ */
830
+ export function shellWords(s) {
831
+ const ANSI_C = {
832
+ n: "\n",
833
+ t: "\t",
834
+ r: "\r",
835
+ "\\": "\\",
836
+ "'": "'",
837
+ '"': '"',
838
+ };
839
+ const words = [];
840
+ let cur = "";
841
+ let has = false;
842
+ let q = null;
843
+ let undecoded = false;
844
+ for (let i = 0; i < s.length; i++) {
845
+ const ch = s[i];
846
+ if (q === "'") {
847
+ if (ch === "'")
848
+ q = null;
849
+ else
850
+ cur += ch;
851
+ continue;
852
+ }
853
+ if (q === "$'") {
854
+ if (ch === "'")
855
+ q = null;
856
+ else if (ch === "\\") {
857
+ const next = s[i + 1] ?? "";
858
+ if (ANSI_C[next] === undefined)
859
+ undecoded = true;
860
+ cur += ANSI_C[next] ?? next;
861
+ i++;
862
+ }
863
+ else
864
+ cur += ch;
865
+ continue;
866
+ }
867
+ if (q === '"') {
868
+ if (ch === '"')
869
+ q = null;
870
+ else if (ch === "\\" && (s[i + 1] === '"' || s[i + 1] === "\\")) {
871
+ cur += s[i + 1];
872
+ i++;
873
+ }
874
+ else
875
+ cur += ch;
876
+ continue;
877
+ }
878
+ if (ch === "$" && s[i + 1] === "'") {
879
+ q = "$'";
880
+ has = true;
881
+ i++;
882
+ continue;
883
+ }
884
+ if (ch === "'" || ch === '"') {
885
+ q = ch;
886
+ has = true;
887
+ continue;
888
+ }
889
+ if (ch === "\\") {
890
+ cur += s[i + 1] ?? "";
891
+ i++;
892
+ has = true;
893
+ continue;
894
+ }
895
+ if (/\s/.test(ch)) {
896
+ if (has) {
897
+ words.push(cur);
898
+ cur = "";
899
+ has = false;
900
+ }
901
+ continue;
902
+ }
903
+ cur += ch;
904
+ has = true;
905
+ }
906
+ if (has)
907
+ words.push(cur);
908
+ return { words, unterminated: q !== null, undecoded };
909
+ }
910
+ /**
911
+ * The `bash -c` payload of a raw segment, dequoted with its inner separators
912
+ * intact. Env prefixes and `env` are stepped over (the prefix itself is vetted
913
+ * by `checkSegment`). Two shapes are `unliftable`, and both REFUSE rather than
914
+ * fall through to a weaker check:
915
+ * · the payload is not literally present — the segment was cut at a `$(` or
916
+ * backtick, so bash would run whatever the substitution produces
917
+ * (`bash -c "$(cat x)"` — VERIFIED running the file's contents);
918
+ * · an option that takes a VALUE sits before `-c` (`-o pipefail`, `-O …`,
919
+ * `--rcfile f`, `--init-file f`), so which word is the payload depends on
920
+ * bash's option table rather than on position (`bash -o pipefail -c '…'`
921
+ * — VERIFIED running the payload).
922
+ * Nothing a reviewer does needs either. It is `bash` specifically: `sh -c` is
923
+ * refused outright.
924
+ */
925
+ function bashCPayload(segment) {
926
+ const { words, unterminated, undecoded } = shellWords(segment);
927
+ let i = 0;
928
+ while (i < words.length) {
929
+ const w = words[i];
930
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
931
+ i++;
932
+ continue;
933
+ }
934
+ if (w === "env") {
935
+ i++;
936
+ while (i < words.length && words[i].startsWith("-"))
937
+ i++;
938
+ continue;
939
+ }
940
+ break;
941
+ }
942
+ const prog = words[i];
943
+ if (prog === undefined || basenameOf(prog) !== "bash")
944
+ return { kind: "none" };
945
+ for (let j = i + 1; j < words.length; j++) {
946
+ const w = words[j];
947
+ // A non-option, or `--` / `-` (end of options), before any -c: this is
948
+ // `bash <script>`, and checkProgram holds it to the sanctioned set.
949
+ if (!w.startsWith("-") || w === "-" || w === "--")
950
+ return { kind: "none" };
951
+ if (/^-[A-Za-z]*[oO]/.test(w) || /^--(?:rcfile|init-file)/.test(w)) {
952
+ return {
953
+ kind: "unliftable",
954
+ reason: `exec policy: 'bash ${w} …' — an option taking a value before -c hides which word is the command string; refused`,
955
+ };
956
+ }
957
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) {
958
+ if (unterminated) {
959
+ return {
960
+ kind: "unliftable",
961
+ reason: "exec policy: this 'bash -c' payload is assembled at runtime by a substitution or an unclosed quote — the reviewer runs only commands the guard can read",
962
+ };
963
+ }
964
+ if (undecoded) {
965
+ return {
966
+ kind: "unliftable",
967
+ reason: "exec policy: this 'bash -c' payload uses a $'…' escape the guard cannot decode — the reviewer runs only commands the guard can read",
968
+ };
969
+ }
970
+ const next = words[j + 1];
971
+ return { kind: "payload", payload: (next === "--" ? words[j + 2] : next) ?? "" };
972
+ }
973
+ }
974
+ return { kind: "none" };
975
+ }
976
+ function checkProgram(prog, args, rawArgs, depth,
977
+ /** The segment's text after `prog`, verbatim — quotes, and the newlines a
978
+ * whitespace split loses, intact. */
979
+ rawText, cwd, policy) {
980
+ // Sanctioned scripts, invoked directly.
981
+ const direct = sanctionedScript(prog, cwd, policy);
982
+ if (direct.name !== undefined || /(^|\/)scripts\//.test(prog)) {
983
+ return direct.sanctioned
984
+ ? { allowed: true }
985
+ : block(notSanctioned(policy, direct.name, `exec policy: '${prog}' is not a sanctioned script`));
986
+ }
987
+ const base = basenameOf(prog);
988
+ if (base === "cd")
989
+ return { allowed: true }; // builtin, argument is a path not a program
990
+ // Checked before the allowlist so the reason is the directive one, and
991
+ // before `sh`/`bash`, which have their own narrower rules below.
992
+ if (AGENT_SPAWNERS.has(base))
993
+ return block(`${AGENT_SPAWN_REASON} — '${base}' is blocked`);
994
+ if (base === "sh") {
995
+ // No option after -n may switch execution back on or introduce a command.
996
+ const words = rawText === undefined ? args.map(dequote) : shellWords(rawText).words;
997
+ const next = words[1];
998
+ return words[0] === "-n" &&
999
+ (next === undefined || next === "--" || next === "-" || !/^[-+]/.test(next))
1000
+ ? { allowed: true }
1001
+ : block("exec policy: 'sh' is only allowed as 'sh -n [--] <file>' (syntax check)");
1002
+ }
1003
+ if (base === "bash") {
1004
+ // A `bash -c` with a readable payload never reaches here: `checkSegment`
1005
+ // lifts it from the raw segment and re-parses it. What does reach here is
1006
+ // `bash -c` through `xargs`, or a -c form
1007
+ // `bashCPayload` did not recognise — and a command string nobody vetted
1008
+ // must not run, so any -c BEFORE the script is refused. Otherwise bash may
1009
+ // ONLY launch a sanctioned scripts/*.sh (flags before it allowed; anything
1010
+ // after the script is the script's own argument).
1011
+ for (const a of args) {
1012
+ if (!a.startsWith("-")) {
1013
+ const script = sanctionedScript(a, cwd, policy);
1014
+ return script.sanctioned
1015
+ ? { allowed: true }
1016
+ : block(notSanctioned(policy, script.name, `exec policy: 'bash ${a}' is not a sanctioned scripts/*.sh`));
1017
+ }
1018
+ if (/^-[A-Za-z]*c/.test(a)) {
1019
+ return block("exec policy: this 'bash -c' carries a command string the guard cannot read (through xargs, or a form it does not lift) — refused");
1020
+ }
1021
+ }
1022
+ return block("exec policy: 'bash' may only run sanctioned scripts/*.sh");
1023
+ }
1024
+ if (base === "gh" || base === "git") {
1025
+ // gh and git are judged on the argv they RECEIVE. Plain quotes and
1026
+ // backslashes are removed exactly (`dequote`); ANSI-C `$'…'` and locale
1027
+ // `$"…"` quoting can spell any byte (or a translated string), so a word
1028
+ // carrying either is refused rather than guessed at.
1029
+ const unmodelled = args.find((a) => opensUnmodelledQuoting(a));
1030
+ if (unmodelled !== undefined) {
1031
+ return block(`exec policy: '${base}' argument ${unmodelled} uses $'…' or $"…" quoting, which the guard does not decode — spell it with plain quotes`);
1032
+ }
1033
+ if (base === "gh")
1034
+ return checkGh(args.map(dequote).filter((a) => a.length > 0));
1035
+ return checkGit(args.filter((a) => a.length > 0));
1036
+ }
1037
+ if (base === "xargs") {
1038
+ // xargs contributes arguments the guard cannot inspect. Classify its own
1039
+ // options before locating the program, including detached option values.
1040
+ let at = 0;
1041
+ for (; at < args.length; at++) {
1042
+ const option = dequote(args[at]);
1043
+ if (option === "--") {
1044
+ at++;
1045
+ break;
1046
+ }
1047
+ if (!option.startsWith("-"))
1048
+ break;
1049
+ if (/^(?:-[nLPsEId]|--(?:max-args|max-lines|max-procs|max-chars|delimiter))$/.test(option))
1050
+ at++;
1051
+ }
1052
+ const target = args[at];
1053
+ if (target === undefined)
1054
+ return { allowed: true };
1055
+ const from = at + 1;
1056
+ const checked = checkProgram(dequote(target), args.slice(from), rawArgs?.slice(from), depth, rawText === undefined ? undefined : textAfterWords(rawText, from), cwd, policy);
1057
+ if (!checked.allowed)
1058
+ return checked;
1059
+ const reader = basenameOf(dequote(target));
1060
+ if (["find", "uniq", "sh", "xargs"].includes(reader)) {
1061
+ return block(`exec policy: 'xargs ${reader}' can receive executable or output operands from stdin — use a direct, fully specified reader invocation`);
1062
+ }
1063
+ if (["sed", "sort", "git", "gh", "rg"].includes(reader)) {
1064
+ const replaces = args
1065
+ .slice(0, at)
1066
+ .some((a) => /^(?:-[a-zA-Z]*[Ii]|--replace)/.test(dequote(a)));
1067
+ if (replaces || dequote(args[args.length - 1] ?? "") !== "--") {
1068
+ return block(`exec policy: 'xargs ${reader}' without a closing '--' or with replacement — stdin must only supply file operands after all checked options`);
1069
+ }
1070
+ }
1071
+ return checked;
1072
+ }
1073
+ if (ALLOWED.has(base)) {
1074
+ // Allowlisted for READING is not allowlisted outright: sed/sort/find each
1075
+ // carry a write or exec mode (all VERIFIED).
1076
+ return checkReaderModes(base, args, rawArgs, rawText);
1077
+ }
1078
+ // ALLOWLIST MEMBERSHIP is what decides — this table only makes the refusal
1079
+ // name a replacement. Deliberately AFTER the check above: were it first,
1080
+ // removing `awk` from ALLOWED would be unobservable to the tests, and a
1081
+ // defence whose removal changes nothing is not a defence.
1082
+ const interpreter = INTERPRETER_REASONS[base];
1083
+ if (interpreter !== undefined)
1084
+ return block(interpreter);
1085
+ return block(`exec policy: '${base}' is not an allowed read-only command`);
1086
+ }
1087
+ /** Quote/substitution-aware segment splitter (a quote-blind split shreds
1088
+ * `rg 'a|b'` on the quoted pipe and vets pattern fragments as programs). Rules:
1089
+ * - single quotes: everything inside is literal;
1090
+ * - double quotes: separators are literal, but `$(` and backticks still
1091
+ * execute, so they still split (quote state restored at the matching `)`);
1092
+ * - backslash escapes the next char (outside single quotes);
1093
+ * - unquoted `; & | && || newline ( )` split; `$(`/`)` track nesting so the
1094
+ * command FOLLOWING a substitution lands in its own segment and is vetted
1095
+ * (merging it back would let `A="$(cat x)" python3 e.py` through). */
1096
+ export function splitShellSegments(cmd) {
1097
+ const segs = [];
1098
+ let cur = "";
1099
+ let q = null;
1100
+ const substStack = [];
1101
+ const push = () => {
1102
+ segs.push(cur);
1103
+ cur = "";
1104
+ };
1105
+ for (let i = 0; i < cmd.length; i++) {
1106
+ const ch = cmd[i];
1107
+ if (ch === "\\" && q !== "'") {
1108
+ cur += ch + (cmd[i + 1] ?? "");
1109
+ i++;
1110
+ continue;
1111
+ }
1112
+ if (q === "'") {
1113
+ if (ch === "'")
1114
+ q = null;
1115
+ cur += ch;
1116
+ continue;
1117
+ }
1118
+ if (ch === "'" && q !== '"') {
1119
+ q = "'";
1120
+ cur += ch;
1121
+ continue;
1122
+ }
1123
+ if (ch === '"') {
1124
+ q = q === '"' ? null : '"';
1125
+ cur += ch;
1126
+ continue;
1127
+ }
1128
+ if (ch === "$" && cmd[i + 1] === "(") {
1129
+ substStack.push(q);
1130
+ q = null;
1131
+ push();
1132
+ i++;
1133
+ continue;
1134
+ }
1135
+ if (ch === ")" && substStack.length > 0) {
1136
+ q = substStack.pop();
1137
+ push();
1138
+ continue;
1139
+ }
1140
+ if (ch === "`") {
1141
+ push();
1142
+ continue;
1143
+ }
1144
+ if (q === '"') {
1145
+ cur += ch; // separators are literal inside double quotes
1146
+ continue;
1147
+ }
1148
+ if (ch === "\n" || ch === ";" || ch === "(" || ch === ")") {
1149
+ push();
1150
+ continue;
1151
+ }
1152
+ if (ch === "&" || ch === "|") {
1153
+ push();
1154
+ if (cmd[i + 1] === ch)
1155
+ i++;
1156
+ continue;
1157
+ }
1158
+ cur += ch;
1159
+ }
1160
+ segs.push(cur);
1161
+ return segs;
1162
+ }
1163
+ /**
1164
+ * Files that carry the OpenViking SERVER's root key.
1165
+ *
1166
+ * `cat` and `grep` are legitimately on the read-only allowlist, so the program
1167
+ * check alone would let a session read `~/.openviking/ov.conf` — which in
1168
+ * api_key mode holds `root_api_key`, an account-wide superuser that can list
1169
+ * every USER key in plaintext. That would hand a review session companion
1170
+ * memory, the exact reach the two-user design exists to deny.
1171
+ *
1172
+ * This is the third layer, not the only one: the key is never on an argv or in
1173
+ * a session's environment, and `writeOvcliConf` refuses to write it as a
1174
+ * session credential. Matching is on the SPELLING anywhere in the command, so
1175
+ * a redirect, a `--file=` form or an argument to any allowed reader is covered.
1176
+ */
1177
+ const ROOT_CREDENTIAL_PATTERNS = [
1178
+ {
1179
+ re: /(^|[^A-Za-z0-9_-])ov\.conf\b/,
1180
+ what: "the OpenViking server config (it holds root_api_key)",
1181
+ },
1182
+ { re: /openviking_root_key/, what: "the OpenViking root key secret" },
1183
+ { re: /root_api_key/, what: "the OpenViking root key" },
1184
+ { re: /openviking-keys\.env/, what: "the companion's OpenViking key file" },
1185
+ ];
1186
+ /** The root-credential check, plus the App-secret belt below.
1187
+ * Exported so the reason is testable on its own. extensions/cli/bash-guard-ambient.ts
1188
+ * is the same call with `ctx` defaulted to this process's own cwd, home and
1189
+ * secrets dirs. */
1190
+ export function checkSecretPaths(cmd, ctx) {
1191
+ const root = checkRootCredentialSpelling(cmd);
1192
+ if (!root.allowed)
1193
+ return root;
1194
+ return checkAppSecretWords(cmd, ctx);
1195
+ }
1196
+ function checkRootCredentialSpelling(text) {
1197
+ for (const { re, what } of ROOT_CREDENTIAL_PATTERNS) {
1198
+ if (re.test(text)) {
1199
+ return block(`exec policy: reading ${what} is never sanctioned — review sessions authenticate as the reviewer USER and nothing else`);
1200
+ }
1201
+ }
1202
+ return { allowed: true };
1203
+ }
1204
+ /** The in-container secrets mount (deploy/ansible entrypoint `SECRETS`). */
1205
+ export const CONTAINER_SECRETS_DIR = "/opt/quickreview/secrets";
1206
+ /** The files the secrets dir holds, by their shipped names
1207
+ * (deploy/collect-secrets.sh, the src/config.ts defaults). */
1208
+ export const APP_SECRET_FILES = [
1209
+ "app.pem",
1210
+ "webhook_secret",
1211
+ "tunnel_token",
1212
+ "openai_api_key",
1213
+ "openai_embeddings_api_key",
1214
+ "slack_bot_token",
1215
+ "slack_app_token",
1216
+ // The reviewer's OpenViking key (the root key has its own rule above). The
1217
+ // session's memory extension reads it in-process, never through bash.
1218
+ "openviking_reviewer_key",
1219
+ ];
1220
+ const APP_SECRET_NAME_RE = new RegExp(`^(?:${APP_SECRET_FILES.map(escapeRe).join("|")}|openai[A-Za-z0-9_.-]*key)$`);
1221
+ /** Secrets-dir spellings recognisable without any context. */
1222
+ const SECRET_DIR_SPELLINGS = [
1223
+ /^\/+opt\/+quickreview\/+secrets(?:\/|$)/,
1224
+ /(?:^|\/)qcr\/+[^/]+\/+secrets(?:\/|$)/,
1225
+ /(?:^|\/)\.config\/+quickreview-bot(?:\/|$)/, // the legacy dir — refused, never read
1226
+ ];
1227
+ /**
1228
+ * This process's secrets dirs — self-contained on purpose (no `src/` import):
1229
+ * bash-guard is registered GLOBALLY on the VM and in the bench sandbox, and
1230
+ * must load wherever pi finds it. Mirrors `locateSecrets` in src/install.ts:
1231
+ * the container mount, `$QCR_SECRETS_MOUNT`, `$QC_SECRETS_DIR`,
1232
+ * `<config home>/secrets` (including absolute `$QCR_CONFIG_HOME`), the legacy `~/.config/quickreview-bot`, and a
1233
+ * `[github] secrets_dir` read best-effort from `$QCR_CONFIG`.
1234
+ */
1235
+ export function defaultSecretPathContext(env, cwd) {
1236
+ const home = homeFor(env);
1237
+ const xdg = (env.XDG_CONFIG_HOME ?? "").trim() || `${home}/.config`;
1238
+ const install = (env.QCR_INSTALL ?? "").trim() || "default";
1239
+ const configOverride = (env.QCR_CONFIG_HOME ?? "").trim();
1240
+ const configDir = configOverride.startsWith("/")
1241
+ ? configOverride.replace(/\/+$/, "")
1242
+ : `${xdg}/qcr/${install}`;
1243
+ const dirs = new Set([
1244
+ CONTAINER_SECRETS_DIR,
1245
+ `${configDir}/secrets`,
1246
+ `${home}/.config/quickreview-bot`, // the legacy dir — refused, never read
1247
+ ]);
1248
+ for (const v of [
1249
+ env.QCR_SECRETS_MOUNT,
1250
+ env.QC_SECRETS_DIR,
1251
+ configuredSecretsDir(env.QCR_CONFIG),
1252
+ ]) {
1253
+ if (typeof v === "string" && v.trim() !== "")
1254
+ dirs.add(expandTilde(v.trim(), home));
1255
+ }
1256
+ return {
1257
+ cwd,
1258
+ env,
1259
+ dirs: [...dirs]
1260
+ .filter((d) => d.startsWith("/"))
1261
+ .map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1")),
1262
+ runDirs: defaultRunDirs(env),
1263
+ };
1264
+ }
1265
+ /** The IPC run dirs this process can name: the directory of its own socket
1266
+ * (a worker host's env) and the container's. Any other `<state>/run` is caught
1267
+ * by the spelling rule `RUN_DIR_SPELLING`. */
1268
+ function defaultRunDirs(env) {
1269
+ const out = new Set(["/opt/quickreview/state/run"]);
1270
+ const sock = (env.QCR_IPC_SOCKET ?? "").trim();
1271
+ if (sock.startsWith("/"))
1272
+ out.add(posix.dirname(sock));
1273
+ return [...out].map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1"));
1274
+ }
1275
+ function homeFor(env) {
1276
+ return (env.HOME ?? "").trim() || "/root";
1277
+ }
1278
+ function expandTilde(p, home) {
1279
+ if (p === "~")
1280
+ return home;
1281
+ return p.startsWith("~/") ? `${home}/${p.slice(2)}` : p;
1282
+ }
1283
+ /** `[github] secrets_dir` from the live config, by a line scan — never a
1284
+ * reason to fail: an unreadable config simply adds nothing. */
1285
+ function configuredSecretsDir(configPath) {
1286
+ if (typeof configPath !== "string" || configPath.trim() === "")
1287
+ return null;
1288
+ try {
1289
+ let section = "";
1290
+ for (const line of readFileSync(configPath, "utf8").split("\n")) {
1291
+ const head = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line);
1292
+ if (head) {
1293
+ section = head[1].trim();
1294
+ continue;
1295
+ }
1296
+ if (section !== "github")
1297
+ continue;
1298
+ const m = /^\s*secrets_dir\s*=\s*["']([^"']*)["']/.exec(line);
1299
+ if (m && m[1].trim() !== "")
1300
+ return m[1].trim();
1301
+ }
1302
+ }
1303
+ catch {
1304
+ /* unreadable config: nothing to add */
1305
+ }
1306
+ return null;
1307
+ }
1308
+ /** Programs whose first positional (or `-e` value) is a PATTERN or script,
1309
+ * not a file. */
1310
+ const PATTERN_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "sed"]);
1311
+ /** Their options that consume the NEXT word as a value, so the value is not
1312
+ * mistaken for the pattern. (`-e`/`--regexp`/`--expression` are handled
1313
+ * separately: their value IS the pattern.) */
1314
+ /** Value-taking in rg (`-E` an encoding, `-T` a type) but a plain switch in
1315
+ * grep (`-T` initial tab, `-E` extended regexes) and sed (`-E`). */
1316
+ const RG_ONLY_VALUE_OPTS = new Set(["-E", "-T"]);
1317
+ const PATTERN_PROGRAM_VALUE_OPTS = new Set([
1318
+ "-A",
1319
+ "-B",
1320
+ "-C",
1321
+ "-m",
1322
+ "-d",
1323
+ "-D",
1324
+ "-g",
1325
+ "-t",
1326
+ "-T",
1327
+ "-M",
1328
+ "-j",
1329
+ "-E",
1330
+ "--after-context",
1331
+ "--before-context",
1332
+ "--context",
1333
+ "--max-count",
1334
+ "--glob",
1335
+ "--iglob",
1336
+ "--type",
1337
+ "--type-not",
1338
+ "--max-columns",
1339
+ "--threads",
1340
+ "--encoding",
1341
+ "--sort",
1342
+ "--sortr",
1343
+ "--max-depth",
1344
+ "--color",
1345
+ "--colors",
1346
+ "--replace",
1347
+ "--path-separator",
1348
+ "--max-filesize",
1349
+ "--ignore-file",
1350
+ "--type-add",
1351
+ "--engine",
1352
+ "--devices",
1353
+ "--directories",
1354
+ "--binary-files",
1355
+ "--label",
1356
+ "--include",
1357
+ "--exclude",
1358
+ "--exclude-dir",
1359
+ "--line-length",
1360
+ ]);
1361
+ function secretBlock(word, why) {
1362
+ return block(`exec policy: '${word}' ${why} — a review session never reads the App's credentials (app.pem, webhook_secret, tunnel_token, the OpenAI and Slack keys, or the secrets dir); the sanctioned scripts (mint-token.sh, the posting scripts) read them themselves`);
1363
+ }
1364
+ const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
1365
+ /** Preserve each shell word's original spelling so secret-path expansion can
1366
+ * still inspect quotes and escapes, while a multiword pattern stays one word. */
1367
+ function rawShellWords(text) {
1368
+ const words = [];
1369
+ let start = -1;
1370
+ let quote = null;
1371
+ for (let i = 0; i < text.length; i++) {
1372
+ const c = text[i];
1373
+ if (start < 0 && !/\s/.test(c))
1374
+ start = i;
1375
+ if (c === "\\" && quote !== "'") {
1376
+ i++;
1377
+ continue;
1378
+ }
1379
+ if (quote !== null) {
1380
+ if (c === quote)
1381
+ quote = null;
1382
+ }
1383
+ else if (c === "'" || c === '"')
1384
+ quote = c;
1385
+ else if (/\s/.test(c) && start >= 0) {
1386
+ words.push(text.slice(start, i));
1387
+ start = -1;
1388
+ }
1389
+ }
1390
+ if (start >= 0)
1391
+ words.push(text.slice(start));
1392
+ return words;
1393
+ }
1394
+ function checkAppSecretWords(cmd, ctx) {
1395
+ let cwd = ctx.cwd;
1396
+ for (const segment of splitShellSegments(cmd)) {
1397
+ const words = rawShellWords(segment);
1398
+ const exempt = patternWordIndexes(words);
1399
+ for (let i = 0; i < words.length; i++) {
1400
+ if (exempt.has(i))
1401
+ continue;
1402
+ const res = judgeSecretWord(words[i], { ...ctx, cwd });
1403
+ if (!res.allowed)
1404
+ return res;
1405
+ }
1406
+ // A `cd` moves where every later relative word lands. Approximate on
1407
+ // purpose (a subshell's cd leaks forward here) — that errs toward refusal.
1408
+ const p = programIndex(words);
1409
+ if (p >= 0 && basenameOf(dequoteShellWord(words[p])) === "cd") {
1410
+ const target = words.slice(p + 1).find((w) => !w.startsWith("-"));
1411
+ if (target !== undefined) {
1412
+ const t = expandShellWord(dequoteShellWord(target), ctx.env);
1413
+ if (!/[*?[$]/.test(t))
1414
+ cwd = posix.resolve(cwd, t);
1415
+ }
1416
+ }
1417
+ }
1418
+ return { allowed: true };
1419
+ }
1420
+ /** Index of a segment's program word: past `VAR=val` prefixes and `env`. */
1421
+ function programIndex(words) {
1422
+ let p = 0;
1423
+ while (p < words.length && (ASSIGNMENT_RE.test(words[p]) || /^\d*[<>]/.test(words[p])))
1424
+ p++;
1425
+ if (p < words.length && dequoteShellWord(words[p]) === "env") {
1426
+ p++;
1427
+ while (p < words.length && (words[p].startsWith("-") || ASSIGNMENT_RE.test(words[p])))
1428
+ p++;
1429
+ }
1430
+ return p < words.length ? p : -1;
1431
+ }
1432
+ /** Which words of a segment are a pattern/script argument of grep/rg/sed. */
1433
+ function patternWordIndexes(words) {
1434
+ const out = new Set();
1435
+ let p = programIndex(words);
1436
+ if (p < 0)
1437
+ return out;
1438
+ // `xargs grep foo` — the pattern belongs to xargs' program.
1439
+ if (basenameOf(dequoteShellWord(words[p])) === "xargs") {
1440
+ p++;
1441
+ while (p < words.length && words[p].startsWith("-"))
1442
+ p++;
1443
+ }
1444
+ if (p >= words.length || !PATTERN_PROGRAMS.has(basenameOf(dequoteShellWord(words[p]))))
1445
+ return out;
1446
+ const isRg = basenameOf(dequoteShellWord(words[p])) === "rg";
1447
+ let explicit = false;
1448
+ let firstPositional = -1;
1449
+ for (let i = p + 1; i < words.length; i++) {
1450
+ const w = dequoteShellWord(words[i]);
1451
+ if (w === "--") {
1452
+ if (firstPositional < 0 && i + 1 < words.length)
1453
+ firstPositional = i + 1;
1454
+ break;
1455
+ }
1456
+ if (w === "-f" || w === "--file") {
1457
+ explicit = true; // the next word names a file, never a literal pattern
1458
+ i++;
1459
+ continue;
1460
+ }
1461
+ if (/^(?:-f.|--file=)/.test(w)) {
1462
+ explicit = true;
1463
+ continue;
1464
+ }
1465
+ if (w === "-e" || w === "--regexp" || w === "--expression") {
1466
+ explicit = true;
1467
+ out.add(i + 1);
1468
+ i++;
1469
+ continue;
1470
+ }
1471
+ if (/^(?:-e.|--regexp=|--expression=)/.test(w)) {
1472
+ explicit = true;
1473
+ out.add(i);
1474
+ continue;
1475
+ }
1476
+ if (w.startsWith("-")) {
1477
+ if (PATTERN_PROGRAM_VALUE_OPTS.has(w) && (isRg || !RG_ONLY_VALUE_OPTS.has(w)))
1478
+ i++;
1479
+ continue;
1480
+ }
1481
+ if (/^\d*[<>]/.test(w))
1482
+ continue;
1483
+ if (firstPositional < 0)
1484
+ firstPositional = i;
1485
+ }
1486
+ if (!explicit && firstPositional >= 0)
1487
+ out.add(firstPositional);
1488
+ return out;
1489
+ }
1490
+ function basenameOf(w) {
1491
+ return w.slice(w.lastIndexOf("/") + 1);
1492
+ }
1493
+ /** The word bash would see: `$'…'` escapes decoded, then quotes, backslashes
1494
+ * and backticks removed. (A glob char inside quotes is literal to bash, but
1495
+ * judging it as a glob only errs toward refusal.) */
1496
+ function dequoteShellWord(word) {
1497
+ const ansi = word.replace(/\$'((?:\\.|[^'\\])*)'/g, (_m, body) => body
1498
+ .replace(/\\x([0-9A-Fa-f]{1,2})/g, (_x, h) => String.fromCharCode(parseInt(h, 16)))
1499
+ .replace(/\\u([0-9A-Fa-f]{1,4})/g, (_x, h) => String.fromCharCode(parseInt(h, 16)))
1500
+ .replace(/\\([0-7]{1,3})/g, (_x, o) => String.fromCharCode(parseInt(o, 8))));
1501
+ return ansi.replace(/["'\\`]/g, "");
1502
+ }
1503
+ /** `~`, `$HOME`/`${HOME}`, `$XDG_CONFIG_HOME`, `$QCR_INSTALL` — the variables
1504
+ * a secrets path is spelled with. Anything else is left as written. */
1505
+ function expandShellWord(w, env) {
1506
+ const home = homeFor(env);
1507
+ const vars = {
1508
+ HOME: home,
1509
+ XDG_CONFIG_HOME: (env.XDG_CONFIG_HOME ?? "").trim() || `${home}/.config`,
1510
+ QCR_INSTALL: (env.QCR_INSTALL ?? "").trim() || "default",
1511
+ };
1512
+ const out = w.replace(/\$\{?(HOME|XDG_CONFIG_HOME|QCR_INSTALL)\}?(?![A-Za-z0-9_])/g, (_m, n) => vars[n]);
1513
+ return expandTilde(out, home);
1514
+ }
1515
+ /** `{a,b}` brace expansion, bounded at 64 results. */
1516
+ function braceExpand(w, budget = 64) {
1517
+ const m = /^(.*?)\{([^{}]*,[^{}]*)\}(.*)$/.exec(w);
1518
+ if (!m)
1519
+ return [w];
1520
+ const out = [];
1521
+ for (const alt of m[2].split(",")) {
1522
+ for (const x of braceExpand(`${m[1]}${alt}${m[3]}`, budget)) {
1523
+ out.push(x);
1524
+ if (out.length >= budget)
1525
+ return out;
1526
+ }
1527
+ }
1528
+ return out;
1529
+ }
1530
+ const GLOB_CHARS = /[*?[]/;
1531
+ function validClass(cls) {
1532
+ try {
1533
+ new RegExp(cls);
1534
+ return true;
1535
+ }
1536
+ catch {
1537
+ return false;
1538
+ }
1539
+ }
1540
+ function globToRegex(glob) {
1541
+ let re = "";
1542
+ for (let i = 0; i < glob.length; i++) {
1543
+ const c = glob[i];
1544
+ if (c === "*") {
1545
+ if (glob[i + 1] === "*") {
1546
+ re += ".*";
1547
+ i++;
1548
+ }
1549
+ else
1550
+ re += "[^/]*";
1551
+ }
1552
+ else if (c === "?")
1553
+ re += "[^/]";
1554
+ else if (c === "[") {
1555
+ const close = glob.indexOf("]", i + 2);
1556
+ if (close < 0)
1557
+ re += "\\[";
1558
+ else {
1559
+ let body = glob.slice(i + 1, close);
1560
+ if (body.startsWith("!"))
1561
+ body = "^" + body.slice(1);
1562
+ const cls = `[${body.replace(/\\/g, "\\\\")}]`;
1563
+ // A class JavaScript cannot compile (a reversed range: `[name:pass-a]` in a quoted
1564
+ // JSON argument) must not throw out of the guard. It widens to any one character
1565
+ // but `/`, the most a bracket expression can match in bash, so a judge built on
1566
+ // this regex refuses at least as much as the word could reach.
1567
+ re += validClass(cls) ? cls : "[^/]";
1568
+ i = close;
1569
+ }
1570
+ }
1571
+ else
1572
+ re += c.replace(/[.+^${}()|\\/]/g, "\\$&");
1573
+ }
1574
+ return new RegExp(`^${re}$`);
1575
+ }
1576
+ /** The concrete paths a glob is tried against: each dir, and each shipped
1577
+ * file inside it. */
1578
+ function secretCandidates(dirs) {
1579
+ const out = [];
1580
+ for (const d of dirs) {
1581
+ out.push(d);
1582
+ for (const f of APP_SECRET_FILES)
1583
+ out.push(`${d}/${f}`);
1584
+ }
1585
+ return out;
1586
+ }
1587
+ /** Do the glob's leading components match a whole secrets dir? Then whatever
1588
+ * follows them (`app_id`, a file the name list does not carry) is inside it. */
1589
+ function globPrefixHitsDir(absGlob, dirs) {
1590
+ const parts = absGlob.split("/").filter((c) => c.length > 0);
1591
+ return dirs.some((d) => {
1592
+ const dparts = d.split("/").filter((c) => c.length > 0);
1593
+ if (parts.length < dparts.length)
1594
+ return false;
1595
+ // `**` can span components; an any-depth prefix is the whole-path regex's job.
1596
+ const prefix = parts.slice(0, dparts.length);
1597
+ if (prefix.some((c) => c.includes("**")))
1598
+ return false;
1599
+ return globToRegex(`/${prefix.join("/")}`).test(d);
1600
+ });
1601
+ }
1602
+ function judgeSecretWord(raw, ctx) {
1603
+ // Every piece of the word that could be a path: the word itself, the part
1604
+ // after a redirection operator, after `=` (`--file=`, `VAR=`), after a
1605
+ // short option (`-f/path`) and after an `@` (pi's and curl's file prefix).
1606
+ const pieces = new Set();
1607
+ for (const piece of dequoteShellWord(raw).split(/[<>|;&()]+/)) {
1608
+ if (piece === "")
1609
+ continue;
1610
+ pieces.add(piece);
1611
+ const eq = piece.indexOf("=");
1612
+ if (eq >= 0)
1613
+ pieces.add(piece.slice(eq + 1));
1614
+ if (/^-[A-Za-z]./.test(piece))
1615
+ pieces.add(piece.slice(2));
1616
+ if (piece.startsWith("@"))
1617
+ pieces.add(piece.slice(1));
1618
+ }
1619
+ for (const piece of pieces) {
1620
+ for (const w of braceExpand(expandShellWord(piece, ctx.env))) {
1621
+ const res = judgeSecretPath(w, ctx);
1622
+ if (!res.allowed)
1623
+ return res;
1624
+ }
1625
+ }
1626
+ return { allowed: true };
1627
+ }
1628
+ /** `<anything>/state/run`, the dir itself or its socket or launch files: the
1629
+ * spelling that needs no context, for a path that is not in `runDirs`. */
1630
+ const RUN_DIR_SPELLING = /(?:^|\/)state\/+run(?:\/+(?:qcr\.sock|caps)(?:\/|$)|\/*$)/;
1631
+ function runBlock(w) {
1632
+ return block(`exec policy: '${w}' is inside the daemon's IPC run dir — the worker socket and the capability launch files; a review session never reads them`);
1633
+ }
1634
+ /** The daemon's IPC run dir (`<state>/run`) is closed to reads, by spelling:
1635
+ * the dir, everything under it, and a glob that can expand into it. */
1636
+ function judgeRunPath(w, abs, ctx) {
1637
+ const dirs = ctx.runDirs ?? [];
1638
+ if (RUN_DIR_SPELLING.test(abs))
1639
+ return runBlock(w);
1640
+ for (const d of dirs)
1641
+ if (abs === d || abs.startsWith(`${d}/`))
1642
+ return runBlock(w);
1643
+ if (GLOB_CHARS.test(w)) {
1644
+ const re = globToRegex(abs);
1645
+ if (dirs.some((d) => re.test(d) || re.test(`${d}/qcr.sock`) || re.test(`${d}/caps`)))
1646
+ return runBlock(w);
1647
+ if (globPrefixHitsDir(abs, dirs))
1648
+ return runBlock(w);
1649
+ }
1650
+ return { allowed: true };
1651
+ }
1652
+ /** One expanded, unquoted word judged as a path. */
1653
+ export function judgeSecretPath(w, ctx) {
1654
+ if (w === "")
1655
+ return { allowed: true };
1656
+ const components = w.split("/").filter((c) => c.length > 0);
1657
+ for (const c of components) {
1658
+ if (APP_SECRET_NAME_RE.test(c))
1659
+ return secretBlock(w, "names one of the App's secret files");
1660
+ }
1661
+ for (const re of SECRET_DIR_SPELLINGS) {
1662
+ if (re.test(w))
1663
+ return secretBlock(w, "names the App's secrets dir");
1664
+ }
1665
+ const abs = posix.resolve(ctx.cwd, w);
1666
+ for (const d of ctx.dirs) {
1667
+ if (abs === d || abs.startsWith(`${d}/`))
1668
+ return secretBlock(w, `resolves into the App's secrets dir (${d})`);
1669
+ }
1670
+ if (GLOB_CHARS.test(w)) {
1671
+ const re = globToRegex(abs);
1672
+ if (secretCandidates(ctx.dirs).some((p) => re.test(p)) || globPrefixHitsDir(abs, ctx.dirs))
1673
+ return secretBlock(w, "is a glob that can expand into the App's secrets dir");
1674
+ for (const c of components) {
1675
+ if (!GLOB_CHARS.test(c))
1676
+ continue;
1677
+ const cre = globToRegex(c);
1678
+ const literal = c.replace(/\[[^\]]*\]|[*?]/g, "").length;
1679
+ const hit = APP_SECRET_FILES.find((f) => cre.test(f) && literal * 2 >= f.length);
1680
+ if (hit !== undefined)
1681
+ return secretBlock(w, `is a glob that matches the App's secret file '${hit}'`);
1682
+ }
1683
+ }
1684
+ return judgeRunPath(w, abs, ctx);
1685
+ }
1686
+ /**
1687
+ * The file TOOLS' secret veto. pi's built-in `read` takes a `path` and
1688
+ * resolves `~` and a leading `@` itself (VERIFIED against pi 1.0.0's
1689
+ * dist/core/tools/read.js schema and path-utils.js `resolveToCwd`), so the
1690
+ * judgement the bash belt applies to a word applies to that path — plus the
1691
+ * OpenViking root-credential spellings.
1692
+ */
1693
+ export function checkReadPath(path, c) {
1694
+ const root = checkRootCredentialSpelling(path);
1695
+ if (!root.allowed)
1696
+ return root;
1697
+ const p = path.startsWith("@") ? path.slice(1) : path;
1698
+ const w = expandShellWord(p, c.env);
1699
+ const proc = judgeProcPath(w, c.cwd);
1700
+ if (!proc.allowed)
1701
+ return proc;
1702
+ return judgeSecretPath(w, c);
1703
+ }
1704
+ /**
1705
+ * A process's environment, memory, command line and open descriptors —
1706
+ * `/proc/<pid>/environ`, `/mem`, `/cmdline`, `/fd/…` and `/fdinfo/…`.
1707
+ *
1708
+ * The descriptors are the IPC capability's door: a worker host holds its
1709
+ * command's capability as an inherited fd, and on the bot every process shares
1710
+ * one uid, so `cat /proc/<pid>/fd/<n>` would hand another command's capability
1711
+ * to the model. The same spelling rule closes it.
1712
+ *
1713
+ * Same family as the secret belt above, different payload: a review session's
1714
+ * parent process holds credentials in memory and argv (the engine's env, the
1715
+ * token handed over at spawn), and `cat` is a sanctioned reader, so
1716
+ * `cat /proc/$PPID/environ` would hand them to the model. Yama's
1717
+ * `ptrace_scope >= 1` blocks a sibling's `/proc/<pid>/mem` where the host sets
1718
+ * it; this rule is the same refusal everywhere, in the policy itself, and it
1719
+ * is not configurable.
1720
+ *
1721
+ * Judged on the SPELLING of every word, whatever program reads it (`cat`,
1722
+ * `head`, `xxd`, `strings`, `tr … <`, a redirect, `dd if=`): the path is
1723
+ * normalised against the cwd (a `cd /proc/self` earlier in the command moves
1724
+ * it), `/proc/<pid>/root/…` hops are followed, `$PPID`/`$$`/`$(…)` in a
1725
+ * component stand for any pid, and a glob is tried against concrete
1726
+ * candidates (`/proc/[0-9]*` + `/e*`, `/proc/self/*`). A grep/rg PATTERN is
1727
+ * exempt, as above: reviewing code that reads `/proc/self/environ` is
1728
+ * ordinary. `/proc/cpuinfo`, `/proc/self/status` and `ls /proc` stay open.
1729
+ *
1730
+ * Same reach as the secret belt: variable indirection into the directory
1731
+ * (`p=/proc/self; cat $p/environ`) is not followed.
1732
+ */
1733
+ const PROC_PID = String.raw `(?:self|thread-self|\d+)`;
1734
+ const PROC_SECRET_RE = new RegExp(`^/proc/${PROC_PID}(?:/task/\\d+)?/(?:environ|mem|cmdline|fd(?:/.*)?|fdinfo(?:/.*)?)$`);
1735
+ const PROC_ROOT_RE = new RegExp(`^/proc/${PROC_PID}(?:/task/\\d+)?/root(/.*)?$`);
1736
+ const PROC_GLOB_CANDIDATES = (() => {
1737
+ const out = [];
1738
+ for (const pid of ["1", "4242", "self", "thread-self"]) {
1739
+ for (const leaf of ["environ", "mem", "cmdline", "fd", "fd/3", "fdinfo", "fdinfo/3"]) {
1740
+ out.push(`/proc/${pid}/${leaf}`, `/proc/${pid}/task/1/${leaf}`);
1741
+ }
1742
+ }
1743
+ out.push("/proc/self/root/proc/self/environ", "/proc/1/root/proc/1/environ");
1744
+ return out;
1745
+ })();
1746
+ /** A dynamic part of a word (`$VAR`, `${…}`, `$$`, `$(…)`, backticks) becomes
1747
+ * a one-component wildcard, so `/proc/$PPID/environ` reads as a glob. */
1748
+ function procDynamicToGlob(text) {
1749
+ return text
1750
+ .replace(/\$\((?:[^()]|\([^()]*\))*\)/g, "*")
1751
+ .replace(/`[^`]*`/g, "*")
1752
+ .replace(/\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*|[$!?#@*0-9-])/g, "*");
1753
+ }
1754
+ /** The command with substitutions opened up, so the words inside `$(…)` and
1755
+ * backticks are judged as words of their own. */
1756
+ function procOpenSubstitutions(text) {
1757
+ return text.replace(/\$\(|[`()]/g, " ");
1758
+ }
1759
+ function procSecretBlock(word) {
1760
+ return block(`exec policy: '${word}' reads a process's environment, memory or command line — never sanctioned; /proc/<pid>/environ, /mem and /cmdline hold credentials and argv that a review session has no business reading`);
1761
+ }
1762
+ /** One expanded word judged as a path. */
1763
+ export function judgeProcPath(w, cwd) {
1764
+ if (w === "")
1765
+ return { allowed: true };
1766
+ let abs = posix.resolve(cwd, w);
1767
+ for (let m = PROC_ROOT_RE.exec(abs); m !== null; m = PROC_ROOT_RE.exec(abs))
1768
+ abs = m[1] || "/";
1769
+ if (GLOB_CHARS.test(abs)) {
1770
+ const re = globToRegex(abs);
1771
+ if (PROC_GLOB_CANDIDATES.some((c) => re.test(c)))
1772
+ return procSecretBlock(w);
1773
+ return { allowed: true };
1774
+ }
1775
+ return PROC_SECRET_RE.test(abs) ? procSecretBlock(w) : { allowed: true };
1776
+ }
1777
+ function judgeProcWord(raw, cwd) {
1778
+ const pieces = new Set();
1779
+ for (const piece of dequoteShellWord(raw).split(/[\s<>|;&()]+/)) {
1780
+ if (piece === "")
1781
+ continue;
1782
+ pieces.add(piece);
1783
+ for (let eq = piece.indexOf("="); eq >= 0; eq = piece.indexOf("=", eq + 1))
1784
+ pieces.add(piece.slice(eq + 1));
1785
+ if (/^-[A-Za-z]./.test(piece))
1786
+ pieces.add(piece.slice(2));
1787
+ if (piece.startsWith("@"))
1788
+ pieces.add(piece.slice(1));
1789
+ }
1790
+ for (const piece of pieces) {
1791
+ for (const w of braceExpand(piece)) {
1792
+ const res = judgeProcPath(w, cwd);
1793
+ if (!res.allowed)
1794
+ return res;
1795
+ }
1796
+ }
1797
+ return { allowed: true };
1798
+ }
1799
+ /** Refuse a command that names `/proc/<pid>/{environ,mem,cmdline,fd,fdinfo}`. */
1800
+ export function checkProcSecrets(cmd, cwd) {
1801
+ for (const text of [procOpenSubstitutions(cmd), procDynamicToGlob(cmd)]) {
1802
+ let at = cwd;
1803
+ for (const segment of splitShellSegments(text)) {
1804
+ const words = rawShellWords(segment);
1805
+ const exempt = patternWordIndexes(words);
1806
+ for (let i = 0; i < words.length; i++) {
1807
+ if (exempt.has(i))
1808
+ continue;
1809
+ const res = judgeProcWord(words[i], at);
1810
+ if (!res.allowed)
1811
+ return res;
1812
+ }
1813
+ const p = programIndex(words);
1814
+ if (p >= 0 && basenameOf(dequoteShellWord(words[p])) === "cd") {
1815
+ const target = words.slice(p + 1).find((w) => !w.startsWith("-"));
1816
+ if (target !== undefined) {
1817
+ const t = dequoteShellWord(target);
1818
+ if (!/[*?[$]/.test(t))
1819
+ at = posix.resolve(at, t);
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ return { allowed: true };
1825
+ }
1826
+ /**
1827
+ * Git's configuration and hook surface — the files that turn an ORDINARY git
1828
+ * command into arbitrary shell execution.
1829
+ *
1830
+ * This is the half of the exec boundary a command-string guard cannot reach:
1831
+ * once the decisive ingredient is A FILE THE SESSION WRITES, the command
1832
+ * itself can be innocent. VERIFIED executing, with the config-scope pin in
1833
+ * force:
1834
+ *
1835
+ * printf '[diff]\n\texternal = <cmd>\n' >> <clone>/.git/config
1836
+ * git diff <base> <head> → ran <cmd>
1837
+ *
1838
+ * `git diff <base> <head>` is the single most ordinary command a review session
1839
+ * runs. There is no token in it for any blocklist to catch, and no env pin to
1840
+ * apply: git has no "ignore the local config" switch, unlike the global and
1841
+ * system scopes (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`/`GIT_CONFIG_NOSYSTEM`,
1842
+ * which `sessionGitConfigPins` in src/sessions.ts nails down at spawn).
1843
+ *
1844
+ * So the repo scope is closed at the WRITE boundary instead. The rule is not a
1845
+ * list of attacks — it is git's own list of the places it reads configuration
1846
+ * and executables from, which is closed and documented:
1847
+ *
1848
+ * · anything inside a `.git` directory — `config`, `config.worktree`,
1849
+ * `hooks/*`, `info/attributes`, `worktrees/<n>/config.worktree`
1850
+ * · `.gitconfig` — the global scope's file
1851
+ * · `<any>/git/config` — the XDG scope's file
1852
+ * · `.gitattributes` — the other half of `diff.*.textconv`
1853
+ * and `filter.*.clean`; useless to an attacker without a config, but it is
1854
+ * the same surface and a review session never writes one.
1855
+ *
1856
+ * A review session READS code (scoping rule 2); it has no legitimate reason to
1857
+ * write any of these, in the worktree or anywhere else. Its own writes — the
1858
+ * verdict file, the ledger, the review body and comment JSON — live under
1859
+ * `state/`, `verdict/` and `inject/`, none of which this touches.
1860
+ *
1861
+ * REACH, stated plainly: this binds the `write`/`edit` TOOL path and shell
1862
+ * REDIRECTION targets. It does NOT bind a write smuggled inside a quoted
1863
+ * program body (`awk 'BEGIN{print > "..."}'`) or through a sanctioned
1864
+ * script. Those remain the bash program allowlist's job, and the allowlist is
1865
+ * what keeps the set of programs that can write a file at all small.
1866
+ */
1867
+ const GIT_CONFIG_NAMES = {
1868
+ directory: ".git",
1869
+ global: ".gitconfig",
1870
+ xdg: "git",
1871
+ config: "config",
1872
+ attributes: ".gitattributes",
1873
+ };
1874
+ const GIT_CONFIG_SURFACE_RE = [
1875
+ new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.directory)}(/|$)`),
1876
+ new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.global)}$`),
1877
+ new RegExp(`(^|/)${GIT_CONFIG_NAMES.xdg}/${GIT_CONFIG_NAMES.config}$`),
1878
+ new RegExp(`(^|/)${escapeRe(GIT_CONFIG_NAMES.attributes)}$`),
1879
+ ];
1880
+ /**
1881
+ * Is this path part of git's config/hook surface? Exported so both boundaries
1882
+ * — the bash redirection scan below and the write/edit tool veto in `activate`
1883
+ * — ask ONE question, and a future third caller cannot ask a narrower one.
1884
+ */
1885
+ function protectedPathSpelling(path) {
1886
+ return path.replace(/^['"]+|['"]+$/g, "").replace(/\/+$/, "");
1887
+ }
1888
+ export function isGitConfigSurface(path) {
1889
+ // Trailing shell noise a redirection token can carry; quotes are stripped by
1890
+ // the caller, and a bare `~` expansion never changes which rule matches.
1891
+ const p = protectedPathSpelling(path);
1892
+ if (p.length === 0)
1893
+ return false;
1894
+ return GIT_CONFIG_SURFACE_RE.some((re) => re.test(p));
1895
+ }
1896
+ /**
1897
+ * This system's OWN policy-carrying surface — the analogue of
1898
+ * `isGitConfigSurface`, one level out. `isGitConfigSurface` protects the files
1899
+ * that turn an ordinary GIT command into shell execution; this protects the
1900
+ * files that decide what every layer of THIS boundary does. A review session
1901
+ * READS code (scoping rule 2); it never writes any of these, so a write to one
1902
+ * is always a reconfiguration of its own controls:
1903
+ *
1904
+ * · `config.toml` — the guards' and the gate's configuration. The
1905
+ * posting scripts read `dry_run` and every `[guards]` knob out of it per
1906
+ * invocation, and `guard_enabled` treats a knob as ON unless it is literally
1907
+ * `false`, so ONE write turns guards 1-5 off and flips a dry-run install to
1908
+ * live on the next sanctioned post — AGENTS.md invariant 6, by a redirection.
1909
+ * · `scripts/**` — the sanctioned posting route (the shims that
1910
+ * hand over to the daemon). Overwriting one posts around it.
1911
+ * · `dist/**` — the COMPILED artifacts the container actually
1912
+ * runs, `packages/pi-plugin/dist/extensions/bash-guard.js` among them: overwrite the compiled
1913
+ * guard and the boundary is off for every later round in that container.
1914
+ * · `extensions/**` — the guard's own source and the other pi
1915
+ * extensions, which `deploy.sh sync` ships and the build compiles.
1916
+ * · `prompts/**`, `agents/**`, `seeds/**` — the frozen seeds (rule 6); the
1917
+ * `agents/` rule also covers the BUILT `state/agents/**` pass bodies pi
1918
+ * loads each round (a `state/agents/…` path has `agents/` after a slash).
1919
+ * · `AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`
1920
+ * — pi's project-context files. pi loads the FIRST of them it finds in the
1921
+ * cwd into EVERY session and pass it starts there (`loadContextFileFromDir`,
1922
+ * pi 0.87.1, unchanged at 0.99.1 and 1.0.0 — VERIFY-ON-PIN: the candidate list is pi's), and every session
1923
+ * and pass runs in the sandbox root. One write plants instructions in every
1924
+ * later session until the next deploy — the overseer's included.
1925
+ * · `.pi/**` — pi's PROJECT resource dir: `settings.json`,
1926
+ * `extensions/`, `mcp.json` (pi 0.99+). Every project is trusted
1927
+ * (`defaultProjectTrust = "always"`, the no-stall decision), so a file
1928
+ * planted here loads into every later session and pass started in that
1929
+ * root: an extension runs as code, a `mcp.json` launches server processes,
1930
+ * and a `+builtin:mcp` in project settings re-enables what the global
1931
+ * settings switch off (packages/pi-plugin/src/pibuiltins.ts) — project entries override the
1932
+ * user's. As a SHAPE this also covers `<pi home>/.pi/agent/…`, the
1933
+ * session's own global pi settings and login; a session writes neither.
1934
+ *
1935
+ * The list is OURS, closed and documented (docs/exec-boundary.md), not a list
1936
+ * of attacks. `POLICY_SURFACE_FILES` / `POLICY_SURFACE_DIRS` — top-level names
1937
+ * RELATIVE TO THE SANDBOX ROOT — and `POLICY_SURFACE_PATHS` (whole paths under
1938
+ * it) are the ONE definition three consumers read:
1939
+ * the write/edit tool veto and the shell-redirection scan (both through
1940
+ * `isPolicySurface`, as a path SHAPE: the name after any prefix, because the
1941
+ * guard does not know the session's cwd) and layer 4's round-boundary
1942
+ * integrity check (`policySurfaceWatchPaths` in src/execboundary.ts, as
1943
+ * concrete paths under the root — the one layer that also sees the write no
1944
+ * command string can show, `T=config.toml; cat p > $T`). Being a SHAPE, the
1945
+ * write veto also refuses `scripts/…`, `dist/…` etc. inside the REVIEWED
1946
+ * repo's worktree; a reviewer never writes there either (scoping rule 2).
1947
+ * `state/agents/**` — the BUILT pass bodies — is refused at the write boundary
1948
+ * through the `agents/` shape but deliberately NOT watched by layer 4: the
1949
+ * daemon itself rebuilds it mid-round on feedback ingest and the day change.
1950
+ */
1951
+ export const POLICY_SURFACE_FILES = [
1952
+ "config.toml",
1953
+ "AGENTS.override.md",
1954
+ "AGENTS.md",
1955
+ "AGENTS.MD",
1956
+ "CLAUDE.md",
1957
+ "CLAUDE.MD",
1958
+ ];
1959
+ /** Companion-owned policy intents change normally during a round, so they
1960
+ * have the write veto without a round hash, like built state/agents bodies. */
1961
+ const DYNAMIC_POLICY_FILES = ["qcr-profile-write.json", ".qcr-profile-write.json.tmp"];
1962
+ export const POLICY_SURFACE_DIRS = [
1963
+ "scripts",
1964
+ "dist",
1965
+ "extensions",
1966
+ "prompts",
1967
+ "agents",
1968
+ "seeds",
1969
+ ".pi",
1970
+ ];
1971
+ /** Policy files named by their PATH under the sandbox root, not a bare name:
1972
+ * the review core's export map decides which compiled guard and gate an
1973
+ * `@quickchatai/pi-quickreview/...` import loads (every shim, every worker),
1974
+ * while a package.json anywhere else is no policy at all. */
1975
+ export const POLICY_SURFACE_PATHS = ["packages/pi-plugin/package.json"];
1976
+ function escapeRe(s) {
1977
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1978
+ }
1979
+ const POLICY_SURFACE_RE = [
1980
+ new RegExp(`(^|/)(?:${[...POLICY_SURFACE_FILES, ...DYNAMIC_POLICY_FILES].map(escapeRe).join("|")})$`),
1981
+ new RegExp(`(^|/)(?:${POLICY_SURFACE_DIRS.map(escapeRe).join("|")})/.+`),
1982
+ new RegExp(`(^|/)(?:${POLICY_SURFACE_PATHS.map(escapeRe).join("|")})$`),
1983
+ ];
1984
+ /** Is this path part of this system's own policy surface? Exported so both
1985
+ * boundaries ask ONE question — see `POLICY_SURFACE_RE`. */
1986
+ export function isPolicySurface(path) {
1987
+ const p = protectedPathSpelling(path);
1988
+ if (p.length === 0)
1989
+ return false;
1990
+ return POLICY_SURFACE_RE.some((re) => re.test(p));
1991
+ }
1992
+ /**
1993
+ * The MEMORY-SCOPE files — what decides how far a PR session's OWN memory
1994
+ * recall may reach. The daemon publishes both into the session's pi home,
1995
+ * beside its `ovcli.conf` (src/memory/ovsession.ts), and the vendored
1996
+ * OpenViking extension reads them on every prompt:
1997
+ *
1998
+ * · `recall-hints/<session>.json` — the RECALL HINT (`ovRecallHintPathIn`):
1999
+ * the round's query and the memory AREAS the session's recall and its
2000
+ * memory tools may reach (`experiences/` alone — vendored patches
2001
+ * 0008/0009/0011). One write of `{"areas":["finding-cases","events"]}`
2002
+ * would widen the session's own scope to the finding cases and the
2003
+ * feedback events no round may read, until the daemon writes the hint
2004
+ * again at the next round.
2005
+ * · `retired-uris.json` — the RETIRED list (`ovRetiredPathIn`) the recall
2006
+ * excludes. Emptied, it brings retired entries back into every prompt's
2007
+ * recall.
2008
+ *
2009
+ * A review session READS memory and never writes either file; the daemon does,
2010
+ * whole and atomically, every round. Matched as a SHAPE like the policy
2011
+ * surface — the distinctive name under ANY prefix, because the guard does not
2012
+ * know the pi home (and a `cd` into `.openviking/` drops that part of the
2013
+ * spelling) — and NOT watched by layer 4: the daemon rewrites the hint at
2014
+ * every round's dispatch, so a round-boundary hash would read its own work as
2015
+ * a change (the `state/agents/**` reasoning). The write veto is the
2016
+ * protection these files have.
2017
+ */
2018
+ export const MEMORY_SCOPE_SURFACE = ["recall-hints/", "retired-uris.json"];
2019
+ const MEMORY_SCOPE_SURFACE_RE = [
2020
+ /(^|\/)recall-hints(\/|$)/, // the hint dir, any file in it (its `.tmp-<pid>` too)
2021
+ /(^|\/)retired-uris\.json(\.tmp-[^/]*)?$/, // the retired list, and its atomic-write temp
2022
+ ];
2023
+ /** Is this path one of the memory-scope files a session's recall reads?
2024
+ * Exported so both write boundaries ask ONE question. */
2025
+ export function isMemoryScopeSurface(path) {
2026
+ const p = protectedPathSpelling(path);
2027
+ if (p.length === 0)
2028
+ return false;
2029
+ return MEMORY_SCOPE_SURFACE_RE.some((re) => re.test(p));
2030
+ }
2031
+ /**
2032
+ * The WAKE-DELIVERY files — the daemon's inject dirs
2033
+ * (`<root>/inject/<session>/`, `PiSessionManager.injectDir`) and the inject
2034
+ * bridge's record of what pi accepted. The bridge takes pending wakes in name
2035
+ * order and moves each one pi accepts into `.done/`; the daemon reads that
2036
+ * move as "this turn is now running" — above all for the overseer, whose
2037
+ * Slack command rights are the served turn's (`src/slack/origin.ts`). One
2038
+ * planted `.done/<queued wake>.md`, or a queued wake emptied in place (the
2039
+ * bridge moves an empty file to `.done/` without serving it), would hand a
2040
+ * running turn the rights of one still queued; a file dropped into another
2041
+ * session's inject dir is a prompt in that session.
2042
+ *
2043
+ * A session never writes any of them; the daemon writes the wakes and the
2044
+ * bridge moves them. Matched as a SHAPE: the `inject` dir and anything in it
2045
+ * under any prefix, a `.done` dir and anything in it (a `cd` into the inject
2046
+ * dir drops the `inject/` part), and the wake file's own name
2047
+ * (`<ISO stamp>-<seq>[--cmd-<id>].md`, its atomic-write temp included — a `cd`
2048
+ * into the session's dir drops every directory). Not watched by layer 4: the
2049
+ * daemon and the bridge rewrite these dirs all the time, so a round-boundary
2050
+ * hash would report their own work. The write veto is the protection, and a
2051
+ * session has no program that moves or removes a file.
2052
+ */
2053
+ export const DELIVERY_SURFACE = ["inject/", ".done/", "<wake file>.md"];
2054
+ const DELIVERY_SURFACE_RE = [
2055
+ /(^|\/)inject(\/|$)/, // every session's inject dir, any file in it
2056
+ /(^|\/)\.done(\/|$)/, // the bridge's accepted wakes
2057
+ /(^|\/)\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d{4,}(--cmd-[^/]*)?\.md(\.tmp-[^/]*)?$/, // a wake file
2058
+ ];
2059
+ /** Is this path one of the wake-delivery files? Exported so both write
2060
+ * boundaries ask ONE question. */
2061
+ export function isDeliverySurface(path) {
2062
+ const p = protectedPathSpelling(path);
2063
+ if (p.length === 0)
2064
+ return false;
2065
+ return DELIVERY_SURFACE_RE.some((re) => re.test(p));
2066
+ }
2067
+ /** The full set a session may not write, from ONE definition: git's own config
2068
+ * and hook surface, this system's policy surface, the memory-scope files its
2069
+ * own recall reads and the wake-delivery files. Both the write/edit tool veto
2070
+ * (`checkWritePath`) and the shell-redirection / sed scan
2071
+ * (`checkProtectedWrites`) refuse exactly this, so they cannot drift. */
2072
+ export function isProtectedWriteTarget(path) {
2073
+ return (isGitConfigSurface(path) ||
2074
+ isPolicySurface(path) ||
2075
+ isMemoryScopeSurface(path) ||
2076
+ isDeliverySurface(path));
2077
+ }
2078
+ /** Veto reason for a wake-delivery write target. */
2079
+ function deliverySurfaceWriteBlock(path) {
2080
+ return block(`exec policy: '${path}' is a wake-delivery file (an inject dir, the bridge's .done/ or a wake file) — the daemon writes wakes there and reads their delivery to decide whose turn is running. A session never writes them.`);
2081
+ }
2082
+ /** Veto reason for a memory-scope write target. */
2083
+ function memoryScopeWriteBlock(path) {
2084
+ return block(`exec policy: '${path}' is a memory-scope file the daemon writes for this session's recall (the recall hint or the retired list) — writing it would widen what this session's own memory recall may reach. The reviewer reads memory; it never sets its own scope.`);
2085
+ }
2086
+ /** Veto reason for a policy-surface write target. */
2087
+ function policySurfaceWriteBlock(path) {
2088
+ return block(`exec policy: '${path}' is part of this system's own policy surface (config.toml, scripts/, dist/, extensions/, prompts/agents/seeds, .pi/) — writing it reconfigures the posting guards, the mutation gate or the compiled boundary itself. The reviewer reads code; it never edits its own controls.`);
2089
+ }
2090
+ /** Whichever refusal fits the target — git's config surface and the memory
2091
+ * scope keep their own, more specific messages. */
2092
+ function protectedWriteBlock(path) {
2093
+ if (isGitConfigSurface(path))
2094
+ return gitConfigWriteBlock(path);
2095
+ if (isMemoryScopeSurface(path))
2096
+ return memoryScopeWriteBlock(path);
2097
+ if (isDeliverySurface(path))
2098
+ return deliverySurfaceWriteBlock(path);
2099
+ return policySurfaceWriteBlock(path);
2100
+ }
2101
+ function gitConfigWriteBlock(path) {
2102
+ return block(`exec policy: '${path}' is part of git's configuration surface — writing it makes an ORDINARY git command (a plain 'git diff') execute a program of the writer's choosing. The reviewer reads code; it never configures git.`);
2103
+ }
2104
+ /**
2105
+ * A redirection target is EXPANDED by bash before the file is opened:
2106
+ * pathname expansion (`*`, `?`, `[…]`) turns a pattern matching one existing
2107
+ * file into that file, and a sequence brace with one element (`{s..s}`) is one
2108
+ * word — `> recall-hint?/pr-1.json`, `> retired-uri[s].json`,
2109
+ * `> retired-uris.js*` and `> recall-hint{s..s}/…` each overwrite the real
2110
+ * file (VERIFIED), while a literal name check sees none of them. So a target
2111
+ * carrying either syntax is judged by every protected NAME it could expand
2112
+ * onto: each brace group is widened to `*`, each pattern component is replaced
2113
+ * by each protected component name it matches (`globToRegex`), and every
2114
+ * resulting path is put to the same `isProtectedWriteTarget` — for every
2115
+ * set, git's config surface (`.gi?/config`) and the policy surface
2116
+ * (`config.tom?`) included. A pattern no protected name can satisfy
2117
+ * (`> /tmp/out-*.txt`) is left alone.
2118
+ */
2119
+ const BRACE_GROUP_RE = /\{[^{}]*(?:,|\.\.)[^{}]*\}/g;
2120
+ /** The component names the protected sets are spelled with: what a pattern
2121
+ * component is tried against. One representative temp name stands for the
2122
+ * retired list's `.tmp-<pid>`; wake patterns are judged as a filename
2123
+ * family by possibleWakePattern below. */
2124
+ const PROTECTED_COMPONENT_NAMES = [
2125
+ ...Object.values(GIT_CONFIG_NAMES),
2126
+ ...POLICY_SURFACE_FILES,
2127
+ ...DYNAMIC_POLICY_FILES,
2128
+ ...POLICY_SURFACE_DIRS,
2129
+ ...MEMORY_SCOPE_SURFACE.map((p) => p.replace(/\/$/, "")),
2130
+ `${MEMORY_SCOPE_SURFACE[1]}.tmp-1`,
2131
+ ...DELIVERY_SURFACE.filter((p) => p.endsWith("/")).map((p) => p.slice(0, -1)),
2132
+ ];
2133
+ /** Past this many candidate paths a pattern is refused without enumerating
2134
+ * the rest — a target that broad is no reviewer's write. */
2135
+ const PATTERN_CANDIDATE_CAP = 512;
2136
+ /** A conservative intersection with wake filenames. Before the first glob
2137
+ * metacharacter the literal prefix must fit a timestamp/sequence; after an
2138
+ * optional command marker any suffix may be part of its id. The remaining
2139
+ * pattern is deliberately unconstrained, so uncertainty refuses a write. */
2140
+ function possibleWakePattern(part) {
2141
+ if (!GLOB_CHARS.test(part))
2142
+ return false;
2143
+ const prefix = part.split(/[*?[]/, 1)[0];
2144
+ const stem = "0000-00-00T00-00-00-000Z-0000";
2145
+ for (let i = 0; i < Math.min(prefix.length, stem.length); i++) {
2146
+ if (stem[i] === "0" ? !/[0-9]/.test(prefix[i]) : prefix[i] !== stem[i])
2147
+ return false;
2148
+ }
2149
+ if (prefix.length <= stem.length)
2150
+ return true;
2151
+ const rest = prefix.slice(stem.length).replace(/^\d*/, "");
2152
+ if ("--cmd-".startsWith(rest) || rest.startsWith("--cmd-"))
2153
+ return true;
2154
+ if (".md".startsWith(rest))
2155
+ return true;
2156
+ if (!rest.startsWith(".md"))
2157
+ return false;
2158
+ const tail = rest.slice(3);
2159
+ return ".tmp-".startsWith(tail) || tail.startsWith(".tmp-");
2160
+ }
2161
+ /** The protected path a redirection-target PATTERN can expand onto, or null
2162
+ * (also null for a target with no pattern syntax at all). */
2163
+ export function protectedPatternHit(target) {
2164
+ const widened = target.replace(BRACE_GROUP_RE, "*");
2165
+ if (!GLOB_CHARS.test(widened))
2166
+ return null;
2167
+ let paths = [""];
2168
+ const parts = widened.split("/");
2169
+ if (possibleWakePattern(parts[parts.length - 1]))
2170
+ return target;
2171
+ for (const [k, part] of parts.entries()) {
2172
+ const options = [part];
2173
+ if (GLOB_CHARS.test(part)) {
2174
+ const re = globToRegex(part);
2175
+ for (const name of PROTECTED_COMPONENT_NAMES)
2176
+ if (re.test(name))
2177
+ options.push(name);
2178
+ }
2179
+ const next = [];
2180
+ for (const p of paths)
2181
+ for (const o of options)
2182
+ next.push(k === 0 ? o : `${p}/${o}`);
2183
+ if (next.length > PATTERN_CANDIDATE_CAP)
2184
+ return target;
2185
+ paths = next;
2186
+ }
2187
+ return paths.find((p) => isProtectedWriteTarget(p)) ?? null;
2188
+ }
2189
+ /** One redirection target (dequoted), judged literally and as a pattern. */
2190
+ function judgeRedirectTarget(target) {
2191
+ const pattern = target.replace(BRACE_GROUP_RE, "*").split("/").at(-1);
2192
+ if (possibleWakePattern(pattern))
2193
+ return deliverySurfaceWriteBlock(target);
2194
+ if (isProtectedWriteTarget(target))
2195
+ return protectedWriteBlock(target);
2196
+ const hit = protectedPatternHit(target);
2197
+ if (hit === null)
2198
+ return { allowed: true };
2199
+ if (hit === target)
2200
+ return block(`exec policy: '${target}' is a pattern too broad to judge — bash expands it before it opens the file, and it may name a protected file. Name the file you write.`);
2201
+ const literal = protectedWriteBlock(hit);
2202
+ return block((literal.reason ?? "").replace(`'${hit}'`, `'${target}' (a pattern bash expands before it opens the file — it can name '${hit}')`));
2203
+ }
2204
+ /** Operator token shapes AFTER `normalizeRedirections`: `>`/`>>`, optionally
2205
+ * fd-prefixed, bare or with the target attached. */
2206
+ const REDIRECT_OP_RE = /^\d*>>?$/;
2207
+ const REDIRECT_OP_ATTACHED_RE = /^\d*>>?(.+)$/;
2208
+ /** A shell word with its quoting removed. bash opens `con"fig".toml`,
2209
+ * `config\.toml` and `config.toml` as the SAME file, so a write target is
2210
+ * judged by the name bash would open, not by its spelling (VERIFIED writing). */
2211
+ function dequote(word) {
2212
+ return word.replace(/["'\\]/g, "");
2213
+ }
2214
+ /** Whether `text` opens ANSI-C (`$'…'`) or locale (`$"…"`) quoting — forms
2215
+ * `dequote` does not decode. Only a `$` OUTSIDE quotes opens one: inside
2216
+ * single or double quotes `$'`/`$"` is literal (`--jq 'test("x$")'`).
2217
+ * Exported because `src/usage.ts` audits the same forms. */
2218
+ export function opensUnmodelledQuoting(text) {
2219
+ let quote = null;
2220
+ for (let i = 0; i < text.length; i++) {
2221
+ const c = text[i];
2222
+ if (quote === "'") {
2223
+ if (c === "'")
2224
+ quote = null;
2225
+ continue;
2226
+ }
2227
+ if (c === "\\") {
2228
+ i++;
2229
+ continue;
2230
+ }
2231
+ if (quote === '"') {
2232
+ if (c === '"')
2233
+ quote = null;
2234
+ continue;
2235
+ }
2236
+ if (c === "$" && (text[i + 1] === "'" || text[i + 1] === '"'))
2237
+ return true;
2238
+ if (c === "'" || c === '"')
2239
+ quote = c;
2240
+ }
2241
+ return false;
2242
+ }
2243
+ /**
2244
+ * Every spelling of an OUTPUT redirection, rewritten onto the canonical `>` /
2245
+ * `>>` before anything else reads the command, so the write-target scan and
2246
+ * the segment splitter both see one form. Exported because `src/usage.ts`
2247
+ * borrows it the way it borrows `splitShellSegments` — the TOKENISER, not the
2248
+ * policy. Bash has more of these than `>`:
2249
+ * · fd-dups (`2>&1`, `>&2`, `1>&-`) name no file and are dropped;
2250
+ * · `>&file`, `&>file`, `&>>file` write stdout+stderr to a FILE;
2251
+ * · `{v}>file` opens the file on a descriptor bash picks and stores in `v`;
2252
+ * · `<>file` opens read-write — `printf x 1<>config.toml` OVERWRITES it;
2253
+ * · `>|file` overrides noclobber.
2254
+ * All five VERIFIED writing the target; a scan that knew only `>` and `>>`
2255
+ * would miss each.
2256
+ */
2257
+ export function normalizeRedirections(cmd) {
2258
+ return cmd
2259
+ .replace(/(?:\{[A-Za-z_][A-Za-z0-9_]*\}|\d*)>&\s*(?:\d+|-)(?=$|[\s;&|)])/g, " ") // fd-dup: names no file
2260
+ .replace(/(?:\{[A-Za-z_][A-Za-z0-9_]*\}|\d*)>&/g, ">") // >&file
2261
+ .replace(/&>/g, ">") // &>file, &>>file
2262
+ .replace(/\{[A-Za-z_][A-Za-z0-9_]*\}(>>?|<>)/g, "$1") // {v}>file
2263
+ .replace(/(\d*)<>/g, "$1>") // read-write open
2264
+ .replace(/(>>?)\|/g, "$1"); // noclobber override
2265
+ }
2266
+ /**
2267
+ * Shell redirections and `sed -i` whose target is a PROTECTED write surface —
2268
+ * git's config/hook surface, this system's own policy surface (config.toml,
2269
+ * scripts/, dist/, extensions/, prompts/agents/seeds, .pi/, the pi context
2270
+ * files) or the memory-scope files a session's recall reads.
2271
+ *
2272
+ * The segment splitter deliberately skips redirection remnants when hunting for
2273
+ * the program token, so `cat evil > config.toml` and `printf '…' > .git/config`
2274
+ * both vet as an allowlisted program (`cat`/`printf`) and pass the per-segment
2275
+ * check. That is right for finding the PROGRAM and wrong for finding the TARGET,
2276
+ * so the target is asked about separately, over the raw command — the same shape
2277
+ * `checkSecretPaths` already uses for the root-credential paths, and for the
2278
+ * same reason: the path, not the program, is what distinguishes it. This is one
2279
+ * of the TWO boundaries `isProtectedWriteTarget` binds; the other is the
2280
+ * write/edit tool veto in `checkWritePath`, and both read the same predicate so
2281
+ * they cannot drift apart.
2282
+ *
2283
+ * `tee`, `cp`, `mv` and `dd` are the other ways to write a named file, and they
2284
+ * need no case here: none is on `ALLOWED`, so `checkProgram` refuses them
2285
+ * outright whatever their target. `sort -o`, `find -fprintf` and `awk` are
2286
+ * refused whole (reader-mode rules / off the allowlist), so they cannot reach a
2287
+ * target either. `sed -i` is the one allowlisted writer that names its target as
2288
+ * a plain argument rather than after a `>`; the reader-mode rules already refuse
2289
+ * `sed -i` outright, so this scan is defense in depth for it. The
2290
+ * redirection itself has more spellings than `>`/`>>` — see
2291
+ * `normalizeRedirections`, which folds all of them onto those two first.
2292
+ */
2293
+ export function checkProtectedWrites(cmd) {
2294
+ const tokens = normalizeRedirections(cmd)
2295
+ .split(/\s+/)
2296
+ .filter((t) => t.length > 0);
2297
+ // `sed -i` / `sed -i.bak` / `sed -ne … -i` — the one allowlisted program that
2298
+ // rewrites a file named as a plain argument. When it is in play EVERY token
2299
+ // is a candidate target; being over-broad here costs a refusal on a command
2300
+ // that also mentions a protected path, which no legitimate read does.
2301
+ const sedInPlace = tokens.some((t) => /(^|\/)sed$/.test(t)) &&
2302
+ tokens.some((t) => /^-[a-zA-Z]*i([a-zA-Z]*)?([.'"].*)?$/.test(t) ||
2303
+ t === "--in-place" ||
2304
+ t.startsWith("--in-place="));
2305
+ for (let i = 0; i < tokens.length; i++) {
2306
+ const t = tokens[i];
2307
+ // `> path`, `>> path` — target is the next token. Tested BEFORE the
2308
+ // attached form, which would otherwise swallow a bare `>>` by reading its
2309
+ // second `>` as the target.
2310
+ if (REDIRECT_OP_RE.test(t)) {
2311
+ const target = tokens[i + 1];
2312
+ if (target !== undefined && isProtectedWriteTarget(dequote(target)))
2313
+ return protectedWriteBlock(dequote(target));
2314
+ continue;
2315
+ }
2316
+ // `>path`, `>>path`, `2>path` — target attached to the operator.
2317
+ const attached = REDIRECT_OP_ATTACHED_RE.exec(t);
2318
+ if (attached) {
2319
+ const target = dequote(attached[1]);
2320
+ if (isProtectedWriteTarget(target))
2321
+ return protectedWriteBlock(target);
2322
+ continue;
2323
+ }
2324
+ if (sedInPlace && isProtectedWriteTarget(dequote(t)))
2325
+ return protectedWriteBlock(dequote(t));
2326
+ }
2327
+ // The whitespace scan above is quote-blind on purpose (over-broad: a quoted
2328
+ // `> config.toml` inside a pattern is refused too) and therefore cannot see
2329
+ // two things bash does: an operator GLUED to the word before it
2330
+ // (`printf x>retired-uris.json` — `x>` is no token it knows), and
2331
+ // a target bash EXPANDS (`> retired-uri[s].json`). Both are judged here,
2332
+ // over the redirections bash would actually perform — `redirectTargets`
2333
+ // reads quotes, so a `>` inside a quoted grep pattern is no write and its
2334
+ // `.*` is never mistaken for a pattern target.
2335
+ for (const target of redirectTargets(normalizeRedirections(cmd))) {
2336
+ const res = judgeRedirectTarget(target);
2337
+ if (!res.allowed)
2338
+ return res;
2339
+ }
2340
+ return { allowed: true };
2341
+ }
2342
+ /**
2343
+ * The target of every UNQUOTED output redirection in a command (after
2344
+ * `normalizeRedirections` has folded the spellings onto `>`/`>>`), each with
2345
+ * its quoting removed as bash removes it — `$'…'` and `$"…"` included. A
2346
+ * `>` inside single or double quotes, or escaped, is text, not an operator;
2347
+ * one glued to the word before it (`x>file`) is an operator. Process
2348
+ * substitution (`>(…)`) names no file and yields nothing. Exported because
2349
+ * `src/usage.ts` borrows it the way it borrows `splitShellSegments` — the
2350
+ * TOKENISER, not the policy.
2351
+ */
2352
+ export function redirectTargets(cmd) {
2353
+ const out = [];
2354
+ let q = null;
2355
+ for (let i = 0; i < cmd.length; i++) {
2356
+ const c = cmd[i];
2357
+ if (q === "'") {
2358
+ if (c === "'")
2359
+ q = null;
2360
+ continue;
2361
+ }
2362
+ if (c === "\\") {
2363
+ i++;
2364
+ continue;
2365
+ }
2366
+ if (q === '"') {
2367
+ if (c === '"')
2368
+ q = null;
2369
+ continue;
2370
+ }
2371
+ if (c === "'" || c === '"') {
2372
+ q = c;
2373
+ continue;
2374
+ }
2375
+ if (c !== ">")
2376
+ continue;
2377
+ let j = i + 1;
2378
+ if (cmd[j] === ">")
2379
+ j++;
2380
+ while (j < cmd.length && (cmd[j] === " " || cmd[j] === "\t"))
2381
+ j++;
2382
+ // One shell word: up to the first unquoted blank or metacharacter.
2383
+ const start = j;
2384
+ let wq = null;
2385
+ for (; j < cmd.length; j++) {
2386
+ const d = cmd[j];
2387
+ if (wq !== null) {
2388
+ if (d === wq)
2389
+ wq = null;
2390
+ else if (wq === '"' && d === "\\")
2391
+ j++;
2392
+ continue;
2393
+ }
2394
+ if (d === "\\") {
2395
+ j++;
2396
+ continue;
2397
+ }
2398
+ if (d === "'" || d === '"') {
2399
+ wq = d;
2400
+ continue;
2401
+ }
2402
+ if (/[\s;&|()<>]/.test(d))
2403
+ break;
2404
+ }
2405
+ const raw = cmd.slice(start, Math.min(j, cmd.length));
2406
+ if (raw.length > 0)
2407
+ out.push(dequoteShellWord(raw.replace(/\$"/g, '"')));
2408
+ i = j - 1;
2409
+ }
2410
+ return out;
2411
+ }
2412
+ /** Pure policy check over a full bash command string: split into segments
2413
+ * (quote-aware) and vet each segment's first program token. */
2414
+ export function checkBashCommand(cmd, depth, cwd, policy) {
2415
+ const normalized = normalizeRedirections(cmd);
2416
+ const scriptCwd = scriptCwdForCommand(normalized, cwd, policy);
2417
+ // Checked over the WHOLE command, before the per-segment program check: the
2418
+ // programs that would read a root credential (cat, grep, jq…) are all
2419
+ // legitimately allowed, so the path is the only thing that distinguishes it.
2420
+ const secret = checkSecretPaths(normalized, { ...policy.secrets, cwd: cwd ?? policy.cwd });
2421
+ if (!secret.allowed)
2422
+ return secret;
2423
+ // Same reason again: `/proc/<pid>/environ` is read by `cat`.
2424
+ const proc = checkProcSecrets(normalized, cwd ?? policy.cwd);
2425
+ if (!proc.allowed)
2426
+ return proc;
2427
+ // Same shape, same reason: a PROTECTED write surface
2428
+ // (`isProtectedWriteTarget`). A redirection target is not a program token,
2429
+ // so the per-segment walk below never sees it.
2430
+ const protectedWrite = checkProtectedWrites(normalized);
2431
+ if (!protectedWrite.allowed)
2432
+ return protectedWrite;
2433
+ for (const segment of splitShellSegments(normalized)) {
2434
+ const tokens = segment
2435
+ .trim()
2436
+ .split(/\s+/)
2437
+ .filter((t) => t.length > 0);
2438
+ if (tokens.length === 0)
2439
+ continue;
2440
+ const res = checkSegment(segment, tokens, depth, scriptCwd, policy);
2441
+ if (!res.allowed)
2442
+ return res;
2443
+ }
2444
+ return { allowed: true };
2445
+ }
2446
+ /** Regex alternatives matching every prefix of `name` at least `min` long. */
2447
+ function longOptionPrefixes(name, min) {
2448
+ const out = [];
2449
+ for (let n = min; n <= name.length; n++)
2450
+ out.push(name.slice(0, n));
2451
+ return out.join("|");
2452
+ }
2453
+ const READER_MODE_RULES = {
2454
+ // -exec/-ok run a command; -delete removes files; the -fprint family writes
2455
+ // an arbitrary path (the `-fprintf` bypass above). find's primaries do not
2456
+ // cluster, so exact names are the right shape here.
2457
+ find: {
2458
+ flags: /^-(?:exec|execdir|ok|okdir|delete|fprintf|fprint|fprint0|fls)$/,
2459
+ what: "runs a command or writes a file",
2460
+ },
2461
+ // --pre runs a program on every file searched; --hostname-bin runs one to
2462
+ // name the host in hyperlinks. rg's long options do not abbreviate.
2463
+ rg: {
2464
+ flags: /^--(?:pre|hostname-bin)(?:=|$)/,
2465
+ what: "runs a command",
2466
+ },
2467
+ // -o/--output writes; --compress-program names a program sort execs when it
2468
+ // spills to temp files (refused on documented behaviour, not on a probe —
2469
+ // sort does not spill on a small input).
2470
+ // `o` is unambiguous in sort's short-option set, so a cluster carrying it is
2471
+ // an output redirect.
2472
+ sort: {
2473
+ // getopt_long accepts any unique prefix of a long option (`--out`, `--com`).
2474
+ flags: new RegExp(`^(?:--(?:${longOptionPrefixes("output", 1)}|${longOptionPrefixes("compress-program", 2)})(?:=|$)|-(?!-)[a-zA-Z]*o)`),
2475
+ what: "writes a file or names a program to run",
2476
+ },
2477
+ // sed is not here: `checkSedInvocation` parses it, which no pattern can match.
2478
+ // There is deliberately no `awk` entry: awk's exec and write forms take
2479
+ // arbitrary EXPRESSIONS (`print "cmd" | c`), so no rule here can gate it.
2480
+ // awk is off ALLOWED instead; see the comment there.
2481
+ };
2482
+ /**
2483
+ * sed, read as sed reads it. The regex rules above match spellings; sed's
2484
+ * script language is small enough to PARSE, so the decision is made on the
2485
+ * parse: every option is classified, every script is split into commands, and
2486
+ * the guard allows only an invocation whose every part it understood and none
2487
+ * of which writes, reads another file or runs a command. Fails closed: an
2488
+ * option it does not know, an abbreviation it cannot resolve to one safe
2489
+ * option, a script it cannot finish parsing, a script it cannot see (a
2490
+ * `$`-expansion or a backtick in an argument; `xargs`, which can append
2491
+ * options and scripts from stdin) are all refused.
2492
+ *
2493
+ * What it refuses inside a script: the `e`, `w`, `W`, `r` and `R` commands
2494
+ * (run a command, write a file, read a file into the stream), at any address
2495
+ * (`/re/`, `\cREc`, a number, `$`, `first~step`, `addr,+N`, negated with `!`);
2496
+ * and an `s` command whose flags carry `e` (run the pattern space as a
2497
+ * command) or `w` (write) — with ANY delimiter, flags in any order.
2498
+ */
2499
+ export function checkSedInvocation(words) {
2500
+ const scripts = [];
2501
+ let positional = null;
2502
+ let explicit = false;
2503
+ // GNU getopt_long accepts any unambiguous prefix of a long option.
2504
+ const LONG = [
2505
+ ["expression", "script"],
2506
+ ["file", "refuse"],
2507
+ ["in-place", "refuse"],
2508
+ ["line-length", "value"],
2509
+ ["null-data", "flag"],
2510
+ ["zero-terminated", "flag"],
2511
+ ["quiet", "flag"],
2512
+ ["silent", "flag"],
2513
+ ["regexp-extended", "flag"],
2514
+ ["separate", "flag"],
2515
+ ["sandbox", "flag"],
2516
+ ["debug", "flag"],
2517
+ ["posix", "flag"],
2518
+ ["unbuffered", "flag"],
2519
+ ["follow-symlinks", "flag"],
2520
+ ["binary", "flag"],
2521
+ ["help", "flag"],
2522
+ ["version", "flag"],
2523
+ ];
2524
+ for (let i = 0; i < words.length; i++) {
2525
+ const w = words[i];
2526
+ if (w === "--") {
2527
+ for (const rest of words.slice(i + 1))
2528
+ if (positional === null && !explicit)
2529
+ positional = rest;
2530
+ break;
2531
+ }
2532
+ if (w.startsWith("--")) {
2533
+ const [name, value] = w.slice(2).split(/=(.*)/s);
2534
+ const hits = LONG.filter(([n]) => n.startsWith(name));
2535
+ const exact = LONG.find(([n]) => n === name);
2536
+ const hit = exact ?? (hits.length === 1 ? hits[0] : undefined);
2537
+ if (name === "" || hit === undefined)
2538
+ return `an option the guard cannot classify ('${w}')`;
2539
+ const [full, kind] = hit;
2540
+ if (kind === "refuse")
2541
+ return `--${full}, which reads a script file or rewrites files in place`;
2542
+ if (kind === "script") {
2543
+ const script = value ?? words[++i];
2544
+ if (script === undefined)
2545
+ return `--${full} with no script`;
2546
+ scripts.push(script);
2547
+ explicit = true;
2548
+ }
2549
+ else if (kind === "value" && value === undefined)
2550
+ i++;
2551
+ continue;
2552
+ }
2553
+ if (w.startsWith("-") && w.length > 1) {
2554
+ for (let j = 1; j < w.length; j++) {
2555
+ const c = w[j];
2556
+ if ("nrEsuz".includes(c))
2557
+ continue;
2558
+ if (c === "e" || c === "l") {
2559
+ const rest = w.slice(j + 1);
2560
+ const arg = rest.length > 0 ? rest : words[++i];
2561
+ if (arg === undefined)
2562
+ return `-${c} with no argument`;
2563
+ if (c === "e") {
2564
+ scripts.push(arg);
2565
+ explicit = true;
2566
+ }
2567
+ break;
2568
+ }
2569
+ if (c === "f" || c === "i")
2570
+ return `-${c}, which reads a script file or rewrites files in place`;
2571
+ return `an option the guard cannot classify (-${c})`;
2572
+ }
2573
+ continue;
2574
+ }
2575
+ if (!explicit && positional === null)
2576
+ positional = w;
2577
+ }
2578
+ if (!explicit) {
2579
+ if (positional === null)
2580
+ return null; // sed with no script: an error, not a risk
2581
+ scripts.push(positional);
2582
+ }
2583
+ for (const script of scripts) {
2584
+ const why = sedScriptDanger(script);
2585
+ if (why !== null)
2586
+ return why;
2587
+ }
2588
+ return null;
2589
+ }
2590
+ /** The first command in a sed script that writes, reads a file or runs a
2591
+ * command — or that the parser cannot classify — or null when every command
2592
+ * is a plain stream edit. */
2593
+ export function sedScriptDanger(script) {
2594
+ const s = script;
2595
+ let i = 0;
2596
+ const at = (k = i) => s[k] ?? "";
2597
+ const skipBlank = () => {
2598
+ while (i < s.length && /[ \t]/.test(at()))
2599
+ i++;
2600
+ };
2601
+ // A delimited part (`/re/`, `\cREc`, an s or y part): up to the next
2602
+ // unescaped delimiter. Brackets are not special here: a misread there only
2603
+ // moves where the flags are scanned from, and the flag scan runs to the end
2604
+ // of the command.
2605
+ const delimited = (d) => {
2606
+ while (i < s.length) {
2607
+ const c = at();
2608
+ if (c === "\\") {
2609
+ i += 2;
2610
+ continue;
2611
+ }
2612
+ if (c === "\n" && d !== "\n")
2613
+ return false;
2614
+ i++;
2615
+ if (c === d)
2616
+ return true;
2617
+ }
2618
+ return false;
2619
+ };
2620
+ // The two delimited parts of an `s` or `y` command; true when they cannot be
2621
+ // read (no usable delimiter, or either part unterminated).
2622
+ const badSedDelimiter = (d) => d === "" || d === "\\" || d === "\n" || !delimited(d) || !delimited(d);
2623
+ const address = () => {
2624
+ const c = at();
2625
+ if (/[0-9]/.test(c)) {
2626
+ while (/[0-9]/.test(at()))
2627
+ i++;
2628
+ if (at() === "~") {
2629
+ i++;
2630
+ while (/[0-9]/.test(at()))
2631
+ i++;
2632
+ }
2633
+ return true;
2634
+ }
2635
+ if (c === "$") {
2636
+ i++;
2637
+ return true;
2638
+ }
2639
+ if (c === "/" || c === "\\") {
2640
+ const d = c === "/" ? "/" : at(i + 1);
2641
+ i += c === "/" ? 1 : 2;
2642
+ if (d === "" || d === "\n" || !delimited(d))
2643
+ return false;
2644
+ while (/[IM]/.test(at()))
2645
+ i++;
2646
+ return true;
2647
+ }
2648
+ return false;
2649
+ };
2650
+ while (i < s.length) {
2651
+ while (i < s.length && /[ \t\n;]/.test(at()))
2652
+ i++;
2653
+ if (i >= s.length)
2654
+ break;
2655
+ if (address()) {
2656
+ skipBlank();
2657
+ if (at() === ",") {
2658
+ i++;
2659
+ skipBlank();
2660
+ if (at() === "+" || at() === "~") {
2661
+ i++;
2662
+ while (/[0-9]/.test(at()))
2663
+ i++;
2664
+ }
2665
+ else if (!address())
2666
+ return "an address the guard cannot read";
2667
+ }
2668
+ }
2669
+ skipBlank();
2670
+ while (at() === "!") {
2671
+ i++;
2672
+ skipBlank();
2673
+ }
2674
+ const cmd = at();
2675
+ i++;
2676
+ switch (cmd) {
2677
+ case "{":
2678
+ case "}":
2679
+ case "=":
2680
+ case "d":
2681
+ case "D":
2682
+ case "g":
2683
+ case "G":
2684
+ case "h":
2685
+ case "H":
2686
+ case "n":
2687
+ case "N":
2688
+ case "p":
2689
+ case "P":
2690
+ case "x":
2691
+ case "z":
2692
+ case "F":
2693
+ break;
2694
+ case "l":
2695
+ case "L":
2696
+ case "q":
2697
+ case "Q":
2698
+ skipBlank();
2699
+ while (/[0-9]/.test(at()))
2700
+ i++;
2701
+ break;
2702
+ case "#":
2703
+ // A comment runs to the end of the line.
2704
+ while (i < s.length && at() !== "\n")
2705
+ i++;
2706
+ break;
2707
+ case ":":
2708
+ case "b":
2709
+ case "t":
2710
+ case "T":
2711
+ // A label ends at whitespace, `;`, `}` or a newline, and what follows
2712
+ // is the NEXT command — GNU reads `bx e cmd` as a branch, then `e`.
2713
+ skipBlank();
2714
+ while (i < s.length && !/[\s;}]/.test(at()))
2715
+ i++;
2716
+ break;
2717
+ case "a":
2718
+ case "i":
2719
+ case "c":
2720
+ // Text to the end of the line, with backslash-newline continuations.
2721
+ while (i < s.length && at() !== "\n")
2722
+ i += at() === "\\" ? 2 : 1;
2723
+ break;
2724
+ case "y": {
2725
+ const d = at();
2726
+ i++;
2727
+ if (badSedDelimiter(d))
2728
+ return "a y command the guard cannot read";
2729
+ break;
2730
+ }
2731
+ case "s": {
2732
+ const d = at();
2733
+ i++;
2734
+ if (badSedDelimiter(d))
2735
+ return "an s command the guard cannot read";
2736
+ // The flags: everything up to the end of the command. GNU's flags are
2737
+ // g p N i I m M e w — `e` runs the pattern space as a command, `w`
2738
+ // writes a file.
2739
+ const start = i;
2740
+ while (i < s.length && !/[;\n}]/.test(at()))
2741
+ i++;
2742
+ const flags = s.slice(start, i);
2743
+ if (/[ew]/.test(flags))
2744
+ return `an s command with an ${/e/.test(flags) ? "e" : "w"} flag`;
2745
+ break;
2746
+ }
2747
+ case "e":
2748
+ case "w":
2749
+ case "W":
2750
+ case "r":
2751
+ case "R":
2752
+ return `a ${cmd} command`;
2753
+ default:
2754
+ return `a command the guard cannot classify (${JSON.stringify(cmd)})`;
2755
+ }
2756
+ }
2757
+ return null;
2758
+ }
2759
+ /** `text` after its first `n` whitespace-separated words, verbatim — the same
2760
+ * words `checkBashCommand` splits a segment into. */
2761
+ function textAfterWords(text, n) {
2762
+ const word = /\S+/g;
2763
+ let end = 0;
2764
+ for (let k = 0; k < n; k++) {
2765
+ const m = word.exec(text);
2766
+ if (m === null)
2767
+ return "";
2768
+ end = m.index + m[0].length;
2769
+ }
2770
+ return text.slice(end);
2771
+ }
2772
+ /** Shell words of a sed invocation's argument text, as bash will hand them to
2773
+ * sed — or null when a word's value is not knowable from the text (a `$`
2774
+ * expansion, ANSI-C or locale quoting, a backtick, an unclosed quote). */
2775
+ export function sedWords(text) {
2776
+ const out = [];
2777
+ let cur = "";
2778
+ let inWord = false;
2779
+ for (let i = 0; i < text.length; i++) {
2780
+ const c = text[i];
2781
+ if (c === "'") {
2782
+ const end = text.indexOf("'", i + 1);
2783
+ if (end < 0)
2784
+ return null;
2785
+ cur += text.slice(i + 1, end);
2786
+ i = end;
2787
+ inWord = true;
2788
+ }
2789
+ else if (c === '"') {
2790
+ i++;
2791
+ for (; i < text.length && text[i] !== '"'; i++) {
2792
+ const d = text[i];
2793
+ if (d === "$" || d === "`")
2794
+ return null;
2795
+ if (d === "\\" && /["\\$`\n]/.test(text[i + 1] ?? ""))
2796
+ cur += text[++i];
2797
+ else
2798
+ cur += d;
2799
+ }
2800
+ if (i >= text.length)
2801
+ return null;
2802
+ inWord = true;
2803
+ }
2804
+ else if (c === "\\") {
2805
+ if (text[i + 1] === "\n") {
2806
+ i++; // a line continuation joins, it adds nothing
2807
+ continue;
2808
+ }
2809
+ cur += text[++i] ?? "";
2810
+ inWord = true;
2811
+ }
2812
+ else if (c === "$" || c === "`") {
2813
+ return null;
2814
+ }
2815
+ else if (/\s/.test(c)) {
2816
+ if (inWord)
2817
+ out.push(cur);
2818
+ cur = "";
2819
+ inWord = false;
2820
+ }
2821
+ else {
2822
+ cur += c;
2823
+ inWord = true;
2824
+ }
2825
+ }
2826
+ if (inWord)
2827
+ out.push(cur);
2828
+ return out;
2829
+ }
2830
+ /**
2831
+ * Does this invocation of an allowlisted reader use one of its write/exec
2832
+ * modes? Exported so the reason is testable on its own.
2833
+ */
2834
+ export function checkReaderModes(prog, args, rawArgs, rawText) {
2835
+ if (prog === "sed") {
2836
+ // From the verbatim text when there is one: a newline inside a quoted
2837
+ // script ends an `a`/`i`/`c` text, a comment or a label, so re-joined
2838
+ // tokens would hide the command after it (`'1a x⏎e'`).
2839
+ const words = rawText !== undefined
2840
+ ? sedWords(rawText)
2841
+ : rawArgs !== undefined
2842
+ ? sedWords(rawArgs.join(" "))
2843
+ : [...args];
2844
+ const why = words === null
2845
+ ? "an argument the guard cannot read as words ($-expansion, $'…' quoting, a backtick, a quote cut by the command splitter)"
2846
+ : checkSedInvocation(words);
2847
+ if (why !== null) {
2848
+ return block(`exec policy: this 'sed' has ${why}, which writes a file or runs a command, or cannot be checked — the reviewer reads code and never writes or runs anything. Use 'sed -n' with plain p/s///g scripts.`);
2849
+ }
2850
+ return { allowed: true };
2851
+ }
2852
+ let words = rawText === undefined ? args.map(dequote) : shellWords(rawText).words;
2853
+ if (["find", "sort", "uniq"].includes(prog)) {
2854
+ // As for sed: an argument value the guard cannot see (`$flag`, a command
2855
+ // substitution) could be the very option these rules refuse.
2856
+ const text = rawText ?? rawArgs?.join(" ");
2857
+ const read = text === undefined ? null : sedWords(text);
2858
+ if (text !== undefined && read === null) {
2859
+ return block(`exec policy: '${prog}' has an argument the guard cannot read as words ($-expansion, a backtick, a quote cut by the command splitter), which could be an option that writes a file or runs a command — spell it out`);
2860
+ }
2861
+ if (read !== null)
2862
+ words = read;
2863
+ }
2864
+ if (["find", "sort", "uniq"].includes(prog) &&
2865
+ (args.some(opensUnmodelledQuoting) ||
2866
+ (rawText !== undefined && opensUnmodelledQuoting(rawText)))) {
2867
+ return block(`exec policy: '${prog}' has quoting the guard cannot decode`);
2868
+ }
2869
+ if (prog === "uniq") {
2870
+ let operands = 0;
2871
+ let options = true;
2872
+ for (let i = 0; i < words.length; i++) {
2873
+ const a = words[i];
2874
+ if (options && a === "--") {
2875
+ options = false;
2876
+ continue;
2877
+ }
2878
+ if (options && a.startsWith("-") && a !== "-") {
2879
+ if (/^--(?:skip-fields|skip-chars|check-chars)$/.test(a))
2880
+ i++;
2881
+ else if (!a.startsWith("--")) {
2882
+ // A short cluster: f, s and w take the rest of the cluster as their
2883
+ // value, or the next word when they end it.
2884
+ const at = a.slice(1).search(/[fsw]/);
2885
+ if (at >= 0 && at === a.length - 2)
2886
+ i++;
2887
+ }
2888
+ continue;
2889
+ }
2890
+ operands++;
2891
+ }
2892
+ return operands > 1
2893
+ ? block("exec policy: 'uniq' with an output operand writes a file — use only an input operand")
2894
+ : { allowed: true };
2895
+ }
2896
+ const rule = READER_MODE_RULES[prog];
2897
+ if (rule === undefined)
2898
+ return { allowed: true };
2899
+ for (const a of words) {
2900
+ if (a.length > 0 && a.startsWith("-") && rule.flags.test(a)) {
2901
+ return block(`exec policy: '${prog} ${a}' ${rule.what} — the reviewer reads code and never writes or runs anything. Use the read-only form.`);
2902
+ }
2903
+ }
2904
+ return { allowed: true };
2905
+ }
2906
+ /**
2907
+ * The write/edit tools' policy check. The write half of the two boundaries
2908
+ * `isProtectedWriteTarget` binds — the other is the shell redirection /
2909
+ * `sed -i` scan in `checkProtectedWrites`, and both read the same predicate so
2910
+ * a session cannot write through one what it is refused through the other.
2911
+ * Refuses git's config/hook surface, this system's own policy surface
2912
+ * (config.toml, scripts/, dist/, extensions/, prompts/agents/seeds, .pi/, the
2913
+ * pi context files) and the memory-scope files its recall reads (the recall
2914
+ * hint, the retired list); everything else a session writes — the verdict file,
2915
+ * the ledger, /tmp bodies — is allowed. This is not a write allowlist.
2916
+ */
2917
+ export function checkWritePath(path) {
2918
+ for (const spelling of toolPathSpellings(path)) {
2919
+ if (isProtectedWriteTarget(spelling))
2920
+ return protectedWriteBlock(spelling);
2921
+ }
2922
+ return { allowed: true };
2923
+ }
2924
+ /** The spellings of a file tool's `path` argument that name a destination: as
2925
+ * given, and as pi opens it (unicode spaces normalised, the `@` prefix dropped,
2926
+ * a `file://` URL decoded). */
2927
+ function toolPathSpellings(path) {
2928
+ const out = [path];
2929
+ let p = path.replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
2930
+ if (p.startsWith("@"))
2931
+ p = p.slice(1);
2932
+ if (/^file:\/\//.test(p)) {
2933
+ try {
2934
+ p = fileURLToPath(p);
2935
+ }
2936
+ catch {
2937
+ // pi cannot open an undecodable URL either
2938
+ }
2939
+ }
2940
+ if (p !== path)
2941
+ out.push(p);
2942
+ return out;
2943
+ }
2944
+ /** Tool names that take a file path and write it. Kept broad on purpose: a
2945
+ * name this list misses is a hole, and a name it carries that does not exist
2946
+ * costs nothing. // VERIFY-ON-PIN: pi's write-tool naming. */
2947
+ const WRITE_TOOLS = new Set([
2948
+ "write",
2949
+ "edit",
2950
+ "multi_edit",
2951
+ "multiedit",
2952
+ "str_replace",
2953
+ "create",
2954
+ "write_file",
2955
+ "apply_patch",
2956
+ ]);
2957
+ /** Tool names that take a file or directory path and READ it: pi's
2958
+ * built-in `read`, and its optional `grep`/`find`/`ls` (off by default — pi
2959
+ * 1.0.0 `DEFAULT_TOOL_NAMES` is read/bash/edit/write — but a settings layer
2960
+ * can switch them on). Broad for the same reason as WRITE_TOOLS.
2961
+ * // VERIFY-ON-PIN: pi's read-tool naming beyond the four built-ins. */
2962
+ const READ_TOOLS = new Set(["read", "grep", "find", "ls", "read_file", "view", "cat"]);
2963
+ function policyFromConfig(config) {
2964
+ const dirs = new Set([CONTAINER_SECRETS_DIR, ...config.secretDirs]);
2965
+ return {
2966
+ sandboxRoot: config.sandboxRoot,
2967
+ cwd: config.cwd ?? config.sandboxRoot,
2968
+ replay: config.replay,
2969
+ noPost: config.noPost === true,
2970
+ secrets: {
2971
+ env: config.pathEnv ?? {},
2972
+ dirs: [...dirs]
2973
+ .filter((d) => d.startsWith("/"))
2974
+ .map((d) => posix.normalize(d).replace(/(.)\/+$/, "$1")),
2975
+ runDirs: defaultRunDirs(config.pathEnv ?? {}),
2976
+ },
2977
+ };
2978
+ }
2979
+ /** The three env-decoded guard inputs: the sandbox root (QCR_SANDBOX_ROOT, else
2980
+ * the cwd), QCR_REPLAY and QCR_TAKEOVER. The one place the env is read for them. */
2981
+ export function guardFlagsFromEnv(env, cwd) {
2982
+ return {
2983
+ sandboxRoot: env.QCR_SANDBOX_ROOT ?? cwd,
2984
+ replay: env.QCR_REPLAY === "1",
2985
+ noPost: env.QCR_TAKEOVER === "1",
2986
+ };
2987
+ }
2988
+ /** The config the env describes: QCR_SANDBOX_ROOT (else the cwd), QCR_REPLAY,
2989
+ * QCR_TAKEOVER, and the secrets dirs `defaultSecretPathContext` resolves from the same env. */
2990
+ export function bashGuardConfigFromEnv(env, cwd) {
2991
+ return {
2992
+ cwd,
2993
+ ...guardFlagsFromEnv(env, cwd),
2994
+ secretDirs: defaultSecretPathContext(env, cwd).dirs,
2995
+ pathEnv: {
2996
+ HOME: env.HOME,
2997
+ XDG_CONFIG_HOME: env.XDG_CONFIG_HOME,
2998
+ QCR_INSTALL: env.QCR_INSTALL,
2999
+ QCR_IPC_SOCKET: env.QCR_IPC_SOCKET,
3000
+ },
3001
+ };
3002
+ }
3003
+ /**
3004
+ * The factory a session loads: the guard over this config. A pi loaded by
3005
+ * path gets it through the env shim, extensions/cli/bash-guard.ts, and an SDK
3006
+ * session as this extension factory. VERIFIED (pi control-surface findings §5):
3007
+ * a tool_call handler can return { block: true, reason } to veto the call, and
3008
+ * event.input is mutable. pi 1.3.0 emits `{ type: "tool_call", toolName,
3009
+ * toolCallId, input }` and its read/grep/find/ls tools take `path` (grep also
3010
+ * `glob`).
3011
+ * `toolName` and `input` on the event payload are seen in production (above).
3012
+ * // VERIFY-ON-PIN: `path`/`file_path` on a file tool's input (following
3013
+ * extensions/companion-guard.ts, which already vetoes writes this way).
3014
+ */
3015
+ export function createBashGuard(config) {
3016
+ const policy = policyFromConfig(config);
3017
+ return (pi) => {
3018
+ pi.on("tool_call", (event, ctx) => {
3019
+ // `arguments` is not on pi 1.4.0's ToolCallEvent; the probe stays for a
3020
+ // pi that names the field that way (VERIFY-ON-PIN).
3021
+ const ev = event;
3022
+ const input = (ev.input ?? ev.arguments);
3023
+ // The session's own cwd: an SDK session shares its host's process.
3024
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : policy.cwd;
3025
+ // The App-secret belt on every file tool, read AND write: an
3026
+ // `edit` that fails to match still answers with what it read.
3027
+ if (typeof ev.toolName === "string" &&
3028
+ (READ_TOOLS.has(ev.toolName) || WRITE_TOOLS.has(ev.toolName))) {
3029
+ for (const p of [input?.path, input?.file_path, input?.filePath, input?.glob]) {
3030
+ if (typeof p !== "string")
3031
+ continue;
3032
+ const res = checkReadPath(p, { ...policy.secrets, cwd });
3033
+ if (!res.allowed)
3034
+ return { block: true, reason: res.reason ?? "exec policy: secret path blocked" };
3035
+ }
3036
+ }
3037
+ if (ev.toolName === "bash") {
3038
+ const cmd = input?.command;
3039
+ if (typeof cmd !== "string")
3040
+ return;
3041
+ const res = checkBashCommand(cmd, 0, cwd, policy);
3042
+ if (!res.allowed)
3043
+ return { block: true, reason: res.reason ?? "exec policy: command blocked" };
3044
+ return;
3045
+ }
3046
+ if (typeof ev.toolName === "string" && WRITE_TOOLS.has(ev.toolName)) {
3047
+ const target = input?.path ?? input?.file_path ?? input?.filePath;
3048
+ if (typeof target !== "string")
3049
+ return;
3050
+ const res = checkWritePath(target);
3051
+ if (!res.allowed)
3052
+ return { block: true, reason: res.reason ?? "exec policy: write blocked" };
3053
+ }
3054
+ return;
3055
+ });
3056
+ };
3057
+ }
3058
+ //# sourceMappingURL=bash-guard.js.map