@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.50

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 (263) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/budgets.js +98 -0
  67. package/dist/core/engine/context-prefix.js +155 -0
  68. package/dist/core/engine/intent.js +260 -0
  69. package/dist/core/engine/native-pugi.js +860 -211
  70. package/dist/core/engine/prompts.js +88 -2
  71. package/dist/core/engine/strip-internal-fields.js +124 -0
  72. package/dist/core/engine/tool-bridge.js +1045 -36
  73. package/dist/core/feedback/queue.js +177 -0
  74. package/dist/core/feedback/submitter.js +145 -0
  75. package/dist/core/file-cache.js +113 -1
  76. package/dist/core/hooks/events.js +44 -0
  77. package/dist/core/hooks/index.js +15 -0
  78. package/dist/core/hooks/registry.js +213 -0
  79. package/dist/core/hooks/runner.js +236 -0
  80. package/dist/core/hooks/v2/event-emitter.js +115 -0
  81. package/dist/core/hooks/v2/executor.js +282 -0
  82. package/dist/core/hooks/v2/index.js +25 -0
  83. package/dist/core/hooks/v2/lifecycle.js +104 -0
  84. package/dist/core/hooks/v2/loader.js +216 -0
  85. package/dist/core/hooks/v2/matcher.js +125 -0
  86. package/dist/core/hooks/v2/trust.js +143 -0
  87. package/dist/core/hooks/v2/types.js +86 -0
  88. package/dist/core/lsp/cache.js +105 -0
  89. package/dist/core/lsp/client.js +776 -0
  90. package/dist/core/lsp/language-detect.js +66 -0
  91. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  92. package/dist/core/mcp/client.js +75 -6
  93. package/dist/core/mcp/http-server.js +553 -0
  94. package/dist/core/mcp/orchestrator-tools.js +662 -0
  95. package/dist/core/mcp/permission.js +190 -0
  96. package/dist/core/mcp/registry.js +24 -2
  97. package/dist/core/mcp/server-tools.js +219 -0
  98. package/dist/core/mcp/server.js +397 -0
  99. package/dist/core/memory/dual-write.js +416 -0
  100. package/dist/core/memory/phase1-kinds.js +20 -0
  101. package/dist/core/memory-sync/queue.js +158 -0
  102. package/dist/core/onboarding/ensure-initialized.js +133 -0
  103. package/dist/core/onboarding/marker.js +111 -0
  104. package/dist/core/onboarding/telemetry-state.js +108 -0
  105. package/dist/core/output-style/presets.js +176 -0
  106. package/dist/core/output-style/state.js +185 -0
  107. package/dist/core/path-security.js +284 -2
  108. package/dist/core/permissions/auto-classifier.js +124 -0
  109. package/dist/core/permissions/circuit-breaker.js +83 -0
  110. package/dist/core/permissions/gate.js +278 -0
  111. package/dist/core/permissions/index.js +20 -0
  112. package/dist/core/permissions/mode.js +174 -0
  113. package/dist/core/permissions/state.js +241 -0
  114. package/dist/core/permissions/tool-class.js +93 -0
  115. package/dist/core/prd-check/parser.js +215 -0
  116. package/dist/core/prd-check/reporter.js +127 -0
  117. package/dist/core/prd-check/session-review.js +557 -0
  118. package/dist/core/prd-check/verifiers.js +223 -0
  119. package/dist/core/pugi-md/context-injector.js +76 -0
  120. package/dist/core/pugi-md/walk-up.js +207 -0
  121. package/dist/core/release-notes/parser.js +241 -0
  122. package/dist/core/release-notes/state.js +116 -0
  123. package/dist/core/repl/history.js +11 -1
  124. package/dist/core/repl/model-pricing.js +135 -0
  125. package/dist/core/repl/session.js +1897 -37
  126. package/dist/core/repl/slash-commands.js +430 -15
  127. package/dist/core/repl/store/session-store.js +31 -2
  128. package/dist/core/repl/workspace-context.js +22 -0
  129. package/dist/core/repo-map/build.js +125 -0
  130. package/dist/core/repo-map/cache.js +185 -0
  131. package/dist/core/repo-map/extractor.js +254 -0
  132. package/dist/core/repo-map/formatter.js +145 -0
  133. package/dist/core/repo-map/scanner.js +211 -0
  134. package/dist/core/retry-budget/budget.js +284 -0
  135. package/dist/core/retry-budget/index.js +5 -0
  136. package/dist/core/session.js +92 -0
  137. package/dist/core/settings.js +80 -0
  138. package/dist/core/share/formatter.js +271 -0
  139. package/dist/core/share/redactor.js +221 -0
  140. package/dist/core/share/uploader.js +267 -0
  141. package/dist/core/skills/defaults.js +457 -0
  142. package/dist/core/smoke/headless-driver.js +174 -0
  143. package/dist/core/smoke/orchestrator.js +194 -0
  144. package/dist/core/smoke/runner.js +238 -0
  145. package/dist/core/smoke/scenario-parser.js +316 -0
  146. package/dist/core/subagents/dispatcher-real.js +600 -0
  147. package/dist/core/subagents/dispatcher.js +113 -24
  148. package/dist/core/subagents/index.js +18 -5
  149. package/dist/core/subagents/isolation-matrix.js +213 -0
  150. package/dist/core/subagents/spawn.js +19 -4
  151. package/dist/core/telemetry/emitter.js +229 -0
  152. package/dist/core/telemetry/queue.js +251 -0
  153. package/dist/core/theme/context.js +91 -0
  154. package/dist/core/theme/presets.js +228 -0
  155. package/dist/core/theme/state.js +181 -0
  156. package/dist/core/todos/invariant.js +10 -0
  157. package/dist/core/todos/state.js +177 -0
  158. package/dist/core/transport/version-interceptor.js +166 -0
  159. package/dist/core/vim/keymap.js +288 -0
  160. package/dist/core/vim/state.js +92 -0
  161. package/dist/core/worktree-manager/cleanup.js +123 -0
  162. package/dist/core/worktree-manager/manager.js +303 -0
  163. package/dist/index.js +28 -0
  164. package/dist/runtime/bootstrap.js +190 -0
  165. package/dist/runtime/cli.js +3241 -343
  166. package/dist/runtime/commands/cancel.js +231 -0
  167. package/dist/runtime/commands/chain.js +489 -0
  168. package/dist/runtime/commands/codegraph-status.js +227 -0
  169. package/dist/runtime/commands/compact.js +297 -0
  170. package/dist/runtime/commands/cost.js +199 -0
  171. package/dist/runtime/commands/delegate.js +242 -11
  172. package/dist/runtime/commands/dispatch.js +126 -0
  173. package/dist/runtime/commands/doctor.js +412 -0
  174. package/dist/runtime/commands/feedback.js +184 -0
  175. package/dist/runtime/commands/hooks.js +184 -0
  176. package/dist/runtime/commands/lsp.js +368 -0
  177. package/dist/runtime/commands/mcp.js +879 -0
  178. package/dist/runtime/commands/memory.js +508 -0
  179. package/dist/runtime/commands/model.js +237 -0
  180. package/dist/runtime/commands/onboarding.js +275 -0
  181. package/dist/runtime/commands/patch.js +128 -0
  182. package/dist/runtime/commands/permissions.js +112 -0
  183. package/dist/runtime/commands/plan.js +143 -0
  184. package/dist/runtime/commands/prd-check.js +285 -0
  185. package/dist/runtime/commands/redo-blob-store.js +92 -0
  186. package/dist/runtime/commands/redo.js +361 -0
  187. package/dist/runtime/commands/release-notes.js +229 -0
  188. package/dist/runtime/commands/repo-map.js +95 -0
  189. package/dist/runtime/commands/report.js +299 -0
  190. package/dist/runtime/commands/resume.js +118 -0
  191. package/dist/runtime/commands/review-consensus.js +17 -2
  192. package/dist/runtime/commands/rewind.js +333 -0
  193. package/dist/runtime/commands/sessions.js +163 -0
  194. package/dist/runtime/commands/share.js +316 -0
  195. package/dist/runtime/commands/status.js +186 -0
  196. package/dist/runtime/commands/stickers.js +82 -0
  197. package/dist/runtime/commands/style.js +194 -0
  198. package/dist/runtime/commands/theme.js +196 -0
  199. package/dist/runtime/commands/undo.js +32 -0
  200. package/dist/runtime/commands/update.js +289 -0
  201. package/dist/runtime/commands/vim.js +140 -0
  202. package/dist/runtime/commands/worktree.js +177 -0
  203. package/dist/runtime/commands/worktrees.js +155 -0
  204. package/dist/runtime/headless-repl.js +195 -0
  205. package/dist/runtime/headless.js +543 -0
  206. package/dist/runtime/load-hooks-or-exit.js +71 -0
  207. package/dist/runtime/plan-decompose.js +531 -0
  208. package/dist/runtime/version.js +65 -0
  209. package/dist/tools/agent-tool.js +229 -0
  210. package/dist/tools/apply-patch.js +556 -0
  211. package/dist/tools/ask-user-question.js +213 -0
  212. package/dist/tools/ask-user.js +115 -0
  213. package/dist/tools/bash.js +203 -4
  214. package/dist/tools/file-tools.js +85 -14
  215. package/dist/tools/lsp-tools.js +189 -0
  216. package/dist/tools/mcp-tool.js +260 -0
  217. package/dist/tools/multi-edit.js +361 -0
  218. package/dist/tools/powershell.js +268 -0
  219. package/dist/tools/registry.js +51 -0
  220. package/dist/tools/skill-tool.js +96 -0
  221. package/dist/tools/tasks.js +208 -0
  222. package/dist/tools/todo-write.js +184 -0
  223. package/dist/tools/web-fetch.js +147 -2
  224. package/dist/tools/web-search.js +458 -0
  225. package/dist/tui/agent-progress-card.js +111 -0
  226. package/dist/tui/agent-tree.js +10 -0
  227. package/dist/tui/ask-modal.js +2 -2
  228. package/dist/tui/ask-user-question-prompt.js +192 -0
  229. package/dist/tui/compact-banner.js +81 -0
  230. package/dist/tui/conversation-pane.js +82 -8
  231. package/dist/tui/cost-table.js +111 -0
  232. package/dist/tui/doctor-table.js +46 -0
  233. package/dist/tui/feedback-prompt.js +156 -0
  234. package/dist/tui/input-box.js +218 -3
  235. package/dist/tui/markdown-render.js +4 -4
  236. package/dist/tui/onboarding-wizard.js +240 -0
  237. package/dist/tui/permissions-picker.js +86 -0
  238. package/dist/tui/render.js +35 -0
  239. package/dist/tui/repl-render.js +313 -35
  240. package/dist/tui/repl-splash-art.js +1 -1
  241. package/dist/tui/repl-splash-mascot.js +32 -8
  242. package/dist/tui/repl-splash.js +2 -2
  243. package/dist/tui/repl.js +85 -5
  244. package/dist/tui/splash.js +1 -1
  245. package/dist/tui/status-bar.js +94 -16
  246. package/dist/tui/status-table.js +7 -0
  247. package/dist/tui/stickers-art.js +136 -0
  248. package/dist/tui/style-table.js +28 -0
  249. package/dist/tui/theme-table.js +29 -0
  250. package/dist/tui/thinking-spinner.js +123 -0
  251. package/dist/tui/tool-stream-pane.js +52 -3
  252. package/dist/tui/update-banner.js +27 -2
  253. package/dist/tui/vim-input.js +267 -0
  254. package/dist/tui/welcome-banner.js +107 -0
  255. package/dist/tui/welcome-data.js +293 -0
  256. package/docs/examples/codegraph.mcp.json +10 -0
  257. package/package.json +12 -6
  258. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  259. package/test/scenarios/compact-force.scenario.txt +11 -0
  260. package/test/scenarios/identity.scenario.txt +11 -0
  261. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  262. package/test/scenarios/walkback.scenario.txt +12 -0
  263. package/dist/core/engine/compaction-hook.js +0 -154
@@ -119,6 +119,63 @@ const DESTRUCTIVE_PATTERNS = [
119
119
  // History destruction
120
120
  { pattern: 'history -c' },
121
121
  { pattern: ' >/dev/null 2>&1; rm' },
122
+ // ---------------------------------------------------------------
123
+ // Patterns ported from KeiSeiKit `destructive-guard.sh` (Apache-2.0)
124
+ // and `safety-guard.sh` BLOCKED_PATTERNS array. Upstream source:
125
+ // /tmp/KeiSeiKit/hooks/destructive-guard.sh (lines 7-13)
126
+ // /tmp/KeiSeiKit/hooks/safety-guard.sh (lines 14-50)
127
+ // Attribution: licenses/keiseikit-LICENSE-NOTICE.md.
128
+ //
129
+ // The patterns below need word-boundary matching because their
130
+ // tokens (kill, halt, reboot, ...) appear as substrings of common
131
+ // unrelated words (skills, default, chrooted-rebooter, etc.).
132
+ // Substring `.includes` cannot express that — `regex` is required.
133
+ // ---------------------------------------------------------------
134
+ // Process termination — `kill`, `pkill`, `killall` at command head
135
+ // or after `sudo`. Matches `kill 1234`, `kill -9 $$`, `sudo killall
136
+ // node`, but NOT `skill issue` (no leading boundary) or
137
+ // `git commit -m "skill kill story"` (the kill is inside a quoted
138
+ // string — quote-aware split handled upstream; here we still need
139
+ // the boundary). Anchored to start-of-component or `sudo ` prefix.
140
+ {
141
+ pattern: 'kill',
142
+ regex: /^(?:sudo\s+)?(?:pkill|killall|kill)\b/,
143
+ },
144
+ // System power state — reboot / shutdown / halt / poweroff / init 0
145
+ // / init 6. KeiSei matches these anywhere in the command; we
146
+ // tighten to start-of-component or `sudo ` prefix to avoid FPs on
147
+ // file paths or variable names containing the substring.
148
+ {
149
+ pattern: 'reboot',
150
+ regex: /^(?:sudo\s+)?reboot\b/,
151
+ },
152
+ {
153
+ pattern: 'shutdown',
154
+ regex: /^(?:sudo\s+)?shutdown\b/,
155
+ },
156
+ {
157
+ pattern: 'halt',
158
+ regex: /^(?:sudo\s+)?halt\b/,
159
+ },
160
+ {
161
+ pattern: 'poweroff',
162
+ regex: /^(?:sudo\s+)?poweroff\b/,
163
+ },
164
+ {
165
+ pattern: 'init 0',
166
+ regex: /^(?:sudo\s+)?init\s+0\b/,
167
+ },
168
+ {
169
+ pattern: 'init 6',
170
+ regex: /^(?:sudo\s+)?init\s+6\b/,
171
+ },
172
+ // `git clean -f` (without -dx) — KeiSei lists this as destructive
173
+ // because it still deletes untracked files. Pugi previously only
174
+ // gated `git clean -fdx`; broaden to any `-f` variant.
175
+ {
176
+ pattern: 'git clean -f',
177
+ regex: /\bgit\s+clean\s+-[A-Za-z]*f/,
178
+ },
122
179
  ];
123
180
  /**
124
181
  * Compound separators. We split on `&&`, `||`, `;`, `|` to classify
@@ -289,6 +346,93 @@ const BUILD_TEST_PREFIXES = [
289
346
  'tsc -p',
290
347
  'eslint',
291
348
  'prettier --check',
349
+ // P0 fix 2026-05-29 (#37 CRITICAL): customer-blocking gap surfaced
350
+ // during dogfood. Engine emitted `chmod +x build.sh`, `node script.js`,
351
+ // `python3 -m pytest`, `git status`, `pnpm build`, `docker ps`, etc.
352
+ // and the classifier returned `unknown` → permission matrix denied
353
+ // в bypassPermissions mode (which the customer expected to auto-allow
354
+ // basic dev tools). Customers could not run ANY real build/test/git
355
+ // workflow through Pugi.
356
+ //
357
+ // The prefixes below cover three classes of developer tooling that
358
+ // are always allowed in `auto`/`dontAsk`/`bypassPermissions` modes,
359
+ // `ask` in interactive modes, and `deny` in `plan` (read-only) mode:
360
+ // - Language runtimes: `node`, `python`, `python3`, `ruby`, etc.
361
+ // - Native build chains: `gcc`, `clang`, `cmake`, `rustc`, etc.
362
+ // - Container/k8s read-class: `docker ps/inspect/logs`, `kubectl get`.
363
+ //
364
+ // Destructive variants are already gated upstream by DESTRUCTIVE_PATTERNS
365
+ // (e.g. `docker system prune`, `kubectl delete --all`). The first-token
366
+ // gate in classifyComponent runs THIS list before the unknown fallback.
367
+ //
368
+ // Language runtime invocations (first-token match, with or without args).
369
+ 'node',
370
+ 'python',
371
+ 'python3',
372
+ 'ruby',
373
+ 'perl',
374
+ 'php',
375
+ 'deno',
376
+ 'bun',
377
+ 'tsx',
378
+ 'ts-node',
379
+ // Native build chains.
380
+ 'gcc',
381
+ 'g++',
382
+ 'clang',
383
+ 'clang++',
384
+ 'cmake',
385
+ 'rustc',
386
+ 'javac',
387
+ 'java',
388
+ // Container/k8s read-class (the destructive subcommands are pre-empted
389
+ // by DESTRUCTIVE_PATTERNS: `docker system prune`, `kubectl delete --all`,
390
+ // `kubectl delete namespace`).
391
+ 'docker ps',
392
+ 'docker images',
393
+ 'docker inspect',
394
+ 'docker logs',
395
+ 'docker version',
396
+ 'docker info',
397
+ 'docker exec',
398
+ 'docker run',
399
+ 'docker stop',
400
+ 'docker start',
401
+ 'docker restart',
402
+ 'docker rm',
403
+ 'docker rmi',
404
+ 'docker build',
405
+ 'docker tag',
406
+ 'docker compose',
407
+ 'docker-compose',
408
+ 'kubectl get',
409
+ 'kubectl describe',
410
+ 'kubectl logs',
411
+ 'kubectl exec',
412
+ 'kubectl apply',
413
+ 'kubectl create',
414
+ 'kubectl rollout',
415
+ 'kubectl port-forward',
416
+ 'kubectl config',
417
+ // Git read+write surface (network ops already handled by NETWORK_PREFIXES;
418
+ // destructive ops `reset --hard`/`clean -fdx`/`push --force` blocked above).
419
+ // Note: WRITE_WORKSPACE_PREFIXES already covers `git commit/add/checkout/...`.
420
+ // These entries handle plain `git rev-list`, `git cherry-pick`, `git worktree`,
421
+ // `git submodule`, etc that customer scripts commonly invoke.
422
+ 'git rev-list',
423
+ 'git cherry-pick',
424
+ 'git worktree',
425
+ 'git submodule',
426
+ 'git blame',
427
+ 'git describe',
428
+ 'git tag --list',
429
+ 'git tag -l',
430
+ 'git for-each-ref',
431
+ 'git ls-remote',
432
+ // gh CLI (GitHub). `gh repo delete` / `gh release delete` reach into
433
+ // network operations but are non-destructive for the local workspace.
434
+ // Permission matrix asks before allowing in auto.
435
+ 'gh',
292
436
  ];
293
437
  /** Single-token read-only commands. Argument-free entries match exact. */
294
438
  const READ_TOKENS = new Set([
@@ -327,6 +471,16 @@ const READ_TOKENS = new Set([
327
471
  'cut',
328
472
  'sort',
329
473
  'uniq',
474
+ // P0 fix 2026-05-29 (#37 CRITICAL): structured-data inspection tools
475
+ // are pure stdin/stdout transformers (no FS write, no network) when
476
+ // не paired с `>` redirection (the redirection branch above promotes
477
+ // к write_workspace independently). Common в dev scripts for parsing
478
+ // package.json, tsconfig.json, Helm values.yaml, etc.
479
+ // `tee` is INTENTIONALLY excluded — it writes by definition, even
480
+ // в protected paths (`tee /etc/...` is already in DESTRUCTIVE_PATTERNS).
481
+ 'jq',
482
+ 'yq',
483
+ 'column',
330
484
  ]);
331
485
  const READ_PREFIXES = [
332
486
  'git status',
@@ -361,13 +515,23 @@ const WRITE_WORKSPACE_PREFIXES = [
361
515
  'git tag',
362
516
  'git rebase',
363
517
  'git merge',
518
+ // P0 fix 2026-05-29 (#37 CRITICAL): file-permission ops are common
519
+ // в build scripts (`chmod +x build.sh`, `chown $USER file`). The
520
+ // destructive variants (`chmod 777 /`, `chmod -R 777 /`, `chmod -R
521
+ // 777 ~`, `chown -R root /`, `chown -R / ...`) are pre-empted by
522
+ // DESTRUCTIVE_PATTERNS which runs BEFORE this list — safe to add
523
+ // here for the non-destructive path. detectProtectedWrite's `\bchmod\b`
524
+ // / `\bchown\b` regex also catches writes into protected paths
525
+ // regardless of this list.
526
+ 'chmod ',
527
+ 'chown ',
364
528
  ];
365
529
  /**
366
530
  * Protected-write triggers. If a command writes to any of these paths
367
531
  * the class is `write_protected` regardless of the operation type.
368
532
  *
369
533
  * Wildcards are handled as substring matches (e.g. `/.ssh/` matches
370
- * `~/.ssh/foo` and `/Users/x/.ssh/bar`).
534
+ * `~/.ssh/foo` and `[HOME]/USER/.ssh/bar`).
371
535
  */
372
536
  const PROTECTED_PATH_SUBSTRINGS = [
373
537
  '/.ssh/',
@@ -388,6 +552,40 @@ const PROTECTED_PATH_SUBSTRINGS = [
388
552
  '/usr/',
389
553
  '/var/',
390
554
  ];
555
+ /**
556
+ * Protected basename triggers — files whose CONTENT must never leak
557
+ * through the bash surface, even when the literal path is workspace-
558
+ * local. Mirrors `permission.ts::protectedBasenames` and `.env.*`
559
+ * pattern so the read-tool gate (which fires on `read .env`) and the
560
+ * bash gate (which fires on `cat .env`) stay symmetric.
561
+ *
562
+ * P0 fix 2026-05-28 (Codex audit): before this list existed, the
563
+ * engine model could circumvent the `read` tool's `protectedTargetReason`
564
+ * check by emitting `bash cat .env` — the classifier saw `cat` (read
565
+ * token) + `.env` (not in PROTECTED_PATH_SUBSTRINGS) and returned class
566
+ * `read`, which the permission matrix allows under every mode. The
567
+ * `local-first-invariants` spec proved the leak: `pugi explain .env`
568
+ * surfaced `SECRET=should_never_leak` in the engine summary.
569
+ *
570
+ * Match shape: the substring must touch a `.` boundary (`/.env`,
571
+ * ` .env`, `.env\b`) or appear as the full token so a path like
572
+ * `apps/codeforge/file.env-template` (no real secret) does not
573
+ * over-trigger.
574
+ */
575
+ const PROTECTED_BASENAME_PATTERNS = [
576
+ // `.env`, `.env.production`, `.env.local` — anywhere in the command.
577
+ // Boundary on the left is start/whitespace/quote/`/`, on the right
578
+ // start/whitespace/end/quote/`>`/`|`/`;`.
579
+ /(^|[\s'"\/=])\.env(\.[A-Za-z0-9_-]+)?($|[\s'"<>|;&])/,
580
+ // SSH key basenames (covers both `id_rsa` and `id_ed25519` even
581
+ // outside `~/.ssh/`). The `/.ssh/` substring above gates the
582
+ // directory case; this catches a key file copied to the workspace.
583
+ /(^|[\s'"\/])id_(rsa|ed25519|ecdsa|dsa)(\.pub)?($|[\s'"<>|;&])/,
584
+ // Other credential basenames mirrored from permission.ts.
585
+ /(^|[\s'"\/])\.npmrc($|[\s'"<>|;&])/,
586
+ /(^|[\s'"\/])\.pypirc($|[\s'"<>|;&])/,
587
+ /(^|[\s'"\/])\.gitconfig($|[\s'"<>|;&])/,
588
+ ];
391
589
  /**
392
590
  * Obfuscation triggers — any of these forces the `unknown` class so
393
591
  * the permission engine can fail closed.
@@ -469,6 +667,26 @@ function classifyComponent(cmd, ctx) {
469
667
  matched: protectedRead.matched,
470
668
  };
471
669
  }
670
+ // 4a-bis. Parent-traversal in read arguments. The file-tools layer
671
+ // refuses `..` segments via `resolveWorkspacePath`, but the bash
672
+ // surface had no equivalent gate — the engine could emit
673
+ // `cat ../README.md` or `ls ..` to enumerate / read outside the
674
+ // workspace, sidestepping the path-security check that the `read`
675
+ // and `glob` tools enforce.
676
+ //
677
+ // P0 fix 2026-05-28 (Codex audit): treat `..` as a path segment
678
+ // (`../`, ` ..`, `..\n`) in any read-class command as a workspace
679
+ // escape. We classify it as `write_protected` so the auto/dontAsk
680
+ // modes refuse, mirroring the `Path escapes workspace` semantics
681
+ // the file-tools layer already provides.
682
+ const traversal = detectParentTraversalRead(trimmed);
683
+ if (traversal) {
684
+ return {
685
+ class: 'write_protected',
686
+ reason: traversal.reason,
687
+ matched: traversal.matched,
688
+ };
689
+ }
472
690
  // 4b. .env writes are always protected, even inside the workspace
473
691
  // (CEO directive feedback_never_delete_untracked_env.md).
474
692
  const envWrite = detectEnvWrite(trimmed);
@@ -525,6 +743,25 @@ function classifyComponent(cmd, ctx) {
525
743
  if (trimmed === 'make' || trimmed.startsWith('make ')) {
526
744
  return { class: 'build_test', reason: 'make runner', matched: 'make' };
527
745
  }
746
+ // 7c. Operator-override safe tokens (P0 fix 2026-05-29 #37).
747
+ // `PUGI_CLASSIFIER_EXTRA_SAFE=tool1,tool2,...` extends the BUILD_TEST
748
+ // first-token list at runtime. This is a security-sensitive escape
749
+ // hatch — operators can add their custom build tools without a
750
+ // recompile. Destructive patterns ALREADY ran above (step 1) so this
751
+ // cannot whitelist `rm`, `mkfs`, `git push --force`, etc. The match
752
+ // is strict first-token equality — not substring — and the env var
753
+ // is read fresh on every classify call so tests can mutate it.
754
+ const extraSafe = readExtraSafeTokens();
755
+ if (extraSafe.size > 0) {
756
+ const firstTokenForExtraSafe = trimmed.split(/\s+/)[0] ?? '';
757
+ if (extraSafe.has(firstTokenForExtraSafe)) {
758
+ return {
759
+ class: 'build_test',
760
+ reason: `PUGI_CLASSIFIER_EXTRA_SAFE override: ${firstTokenForExtraSafe}`,
761
+ matched: firstTokenForExtraSafe,
762
+ };
763
+ }
764
+ }
528
765
  // 7c. Bare `cd <path>` (inside workspace — the cwd-escape detector
529
766
  // upgrades the class to write_protected when the target is
530
767
  // outside). Standalone `cd` (HOME) is escape, also handled by the
@@ -568,7 +805,15 @@ function classifyComponent(cmd, ctx) {
568
805
  }
569
806
  function findDestructiveMatch(cmd) {
570
807
  const upper = cmd.toUpperCase();
571
- for (const { pattern, caseInsensitive } of DESTRUCTIVE_PATTERNS) {
808
+ for (const { pattern, caseInsensitive, regex } of DESTRUCTIVE_PATTERNS) {
809
+ if (regex) {
810
+ // Word-boundary regex form (KeiSei-derived patterns). Match
811
+ // against the trimmed component so `^` anchors to command head,
812
+ // not surrounding whitespace from the compound split.
813
+ if (regex.test(cmd.trim()))
814
+ return pattern;
815
+ continue;
816
+ }
572
817
  if (caseInsensitive) {
573
818
  if (upper.includes(pattern))
574
819
  return pattern;
@@ -637,14 +882,57 @@ function nestingDepth(cmd, open, close) {
637
882
  function escapeRegex(s) {
638
883
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
639
884
  }
885
+ /**
886
+ * Operator-override safe tokens. Read from `PUGI_CLASSIFIER_EXTRA_SAFE`
887
+ * (comma-separated). Allows operators to extend the BUILD_TEST first-
888
+ * token list at runtime for site-specific tooling без recompile.
889
+ *
890
+ * Security note: destructive substring patterns run BEFORE this gate
891
+ * (step 1 in classifyComponent), so this cannot whitelist `rm`, `mkfs`,
892
+ * `git push --force`, etc. The env var only adds tools to the benign
893
+ * build_test class. Invalid entries (empty strings, тokens containing
894
+ * shell metas) are silently dropped to avoid surprising classifications.
895
+ *
896
+ * Read fresh on every call so per-test mutations work и so operators
897
+ * can update without restarting the agent loop. The cost (one env var
898
+ * read + Set construction per call) is negligible for the classifier's
899
+ * call frequency.
900
+ */
901
+ function readExtraSafeTokens() {
902
+ const raw = process.env.PUGI_CLASSIFIER_EXTRA_SAFE;
903
+ if (!raw || raw.trim() === '')
904
+ return new Set();
905
+ const tokens = new Set();
906
+ for (const candidate of raw.split(',')) {
907
+ const trimmed = candidate.trim();
908
+ if (trimmed === '')
909
+ continue;
910
+ // Reject anything containing shell metas or whitespace — only bare
911
+ // tool names allowed. Defends against accidental
912
+ // `PUGI_CLASSIFIER_EXTRA_SAFE='rm -rf /'` smuggling.
913
+ if (/[\s;|&<>$`(){}\[\]'"\\]/.test(trimmed))
914
+ continue;
915
+ tokens.add(trimmed);
916
+ }
917
+ return tokens;
918
+ }
640
919
  function detectProtectedWrite(cmd, ctx) {
641
920
  // Surface every write target this command produces so we can both
642
921
  // protected-path-check and outside-workspace-check them uniformly.
643
922
  // Captures `sort -o`, `uniq <in> <out>`, `sed -i` files, `awk '... > "file"'`,
644
923
  // and `>` / `>>` redirections without surrounding whitespace.
645
924
  const writeTargets = extractWriteTargets(cmd);
925
+ // Strip heredoc bodies before substring scan. Heredoc payloads are
926
+ // DATA (file contents the script writes), not commands the shell
927
+ // executes — a `package.json` body containing `/usr/local/bin/...`
928
+ // would FP as "Write into protected path: /usr/" under the broad
929
+ // includes() scan below. The per-target check at the bottom of this
930
+ // function still catches real `cat > /usr/file << EOF` attempts
931
+ // because extractWriteTargets reads the redirection target, not the
932
+ // heredoc body. CEO dogfood 2026-05-28 (#28 follow-up).
933
+ const cmdForScan = stripHeredocBodies(cmd);
646
934
  for (const needle of PROTECTED_PATH_SUBSTRINGS) {
647
- if (!cmd.includes(needle))
935
+ if (!cmdForScan.includes(needle))
648
936
  continue;
649
937
  // Reading from a protected path is allowed at the classifier
650
938
  // layer (the permission engine still gates `read`); writing is
@@ -706,6 +994,56 @@ function detectProtectedWrite(cmd, ctx) {
706
994
  * Conservative — we do not try to resolve shell vars or globs; the
707
995
  * caller still gates absolute paths via `looksAbsoluteOutsideWorkspace`.
708
996
  */
997
+ /**
998
+ * Strip heredoc bodies so substring scans (e.g. `cmd.includes('/usr/')`)
999
+ * do not false-positive on file content the script is *writing*. A
1000
+ * heredoc starts с `<< 'WORD'` (or `<< WORD` / `<<-WORD`) and ends на a
1001
+ * line containing only WORD. The body between is DATA, not commands.
1002
+ *
1003
+ * Best-effort: handles single-heredoc-per-command (the common case)
1004
+ * AND multiple sequential heredocs. Nested heredocs (heredoc-inside-
1005
+ * heredoc) are rare and out of scope — the substring scan still gates
1006
+ * the outer command, just без stripping the nested body. Per-target
1007
+ * detection at detectProtectedWrite's tail loop catches real
1008
+ * `cat > /usr/file << EOF` attacks regardless of body content.
1009
+ *
1010
+ * CEO dogfood 2026-05-28 (#28): `cat > package.json << 'EOF'\n{"bin":
1011
+ * "/usr/local/bin/foo"}\nEOF` was rejected as "Write into protected
1012
+ * path: /usr/" because the broad substring scan saw `/usr/` in the
1013
+ * JSON body. With heredoc-body stripping, the scan now sees only
1014
+ * `cat > package.json << 'EOF' EOF` which contains no protected path.
1015
+ */
1016
+ function stripHeredocBodies(cmd) {
1017
+ // Match `<< [-]'WORD'` or `<< [-]"WORD"` or `<< [-]WORD` (quoted form
1018
+ // disables variable expansion in real bash; we treat all three the
1019
+ // same for stripping). Capture the WORD so we can find the close
1020
+ // marker.
1021
+ const heredocStart = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
1022
+ let out = cmd;
1023
+ let safetyLoops = 0;
1024
+ let match;
1025
+ while ((match = heredocStart.exec(out)) !== null) {
1026
+ if (++safetyLoops > 16)
1027
+ break;
1028
+ const word = match[2];
1029
+ if (!word)
1030
+ continue;
1031
+ const headEnd = match.index + match[0].length;
1032
+ // Find the close-marker line: `\n<optional indent>WORD<\n|$>`.
1033
+ const closeRegex = new RegExp(`\\n\\s*${word}(?:\\n|$)`);
1034
+ closeRegex.lastIndex = headEnd;
1035
+ const closeMatch = closeRegex.exec(out.slice(headEnd));
1036
+ if (!closeMatch)
1037
+ break;
1038
+ const closeStart = headEnd + closeMatch.index;
1039
+ const closeEnd = closeStart + closeMatch[0].length;
1040
+ // Replace heredoc body + close marker с single space so the regex
1041
+ // iterator's lastIndex stays meaningful.
1042
+ out = out.slice(0, headEnd) + ' ' + out.slice(closeEnd);
1043
+ heredocStart.lastIndex = headEnd + 1;
1044
+ }
1045
+ return out;
1046
+ }
709
1047
  function extractWriteTargets(cmd) {
710
1048
  const targets = [];
711
1049
  // Shell redirection (`>`, `>>`) with optional whitespace. Skip
@@ -777,14 +1115,72 @@ function detectProtectedRead(cmd) {
777
1115
  firstToken === 'find';
778
1116
  if (!isReadTool)
779
1117
  return null;
1118
+ // Strip heredoc bodies so `cat > config << 'EOF'\n... /etc/... \nEOF`
1119
+ // does not FP as "Read from protected path" when first-token=`cat` +
1120
+ // redirection writes к workspace-local file. Heredoc payload is data.
1121
+ // CEO dogfood 2026-05-28 (#28).
1122
+ const cmdForScan = stripHeredocBodies(cmd);
780
1123
  for (const needle of PROTECTED_PATH_SUBSTRINGS) {
781
- if (cmd.includes(needle)) {
1124
+ if (cmdForScan.includes(needle)) {
782
1125
  return {
783
1126
  reason: `Read from protected path: ${needle}`,
784
1127
  matched: needle,
785
1128
  };
786
1129
  }
787
1130
  }
1131
+ // P0 fix 2026-05-28: extend protected-read detection to credential
1132
+ // basenames (`cat .env`, `head id_rsa`, `grep TOKEN .env.production`).
1133
+ // Without this branch, the engine model can bypass the `read` tool's
1134
+ // `protectedTargetReason` gate by emitting a bash `cat` — the read
1135
+ // tool refuses, the model falls back to bash, and the classifier
1136
+ // (which only knew about full-path substrings) classified `cat .env`
1137
+ // as benign `read`. The `local-first-invariants` spec proved the leak.
1138
+ for (const pattern of PROTECTED_BASENAME_PATTERNS) {
1139
+ const match = cmd.match(pattern);
1140
+ if (match) {
1141
+ return {
1142
+ reason: `Read from protected basename: ${match[0].trim()}`,
1143
+ matched: match[0].trim(),
1144
+ };
1145
+ }
1146
+ }
1147
+ return null;
1148
+ }
1149
+ /**
1150
+ * Detect parent-traversal segments (`..`) inside read-class commands.
1151
+ * The file-tools layer (`resolveWorkspacePath`) refuses these for the
1152
+ * `read`/`glob`/`grep` tools, but bash had no equivalent gate. We
1153
+ * trigger on the SAME shape `path-security.ts` rejects: a `..` segment
1154
+ * separated by `/` or whitespace. Quoted/escaped variants get the same
1155
+ * treatment.
1156
+ *
1157
+ * Returns null on the safe path (no `..` segment) so the caller falls
1158
+ * through to the regular read classification.
1159
+ */
1160
+ function detectParentTraversalRead(cmd) {
1161
+ const firstToken = cmd.split(/\s+/)[0] ?? '';
1162
+ const isReadTool = READ_TOKENS.has(firstToken) ||
1163
+ READ_PREFIX_TOKENS.has(firstToken) ||
1164
+ firstToken === 'sed' ||
1165
+ firstToken === 'awk' ||
1166
+ firstToken === 'find';
1167
+ if (!isReadTool)
1168
+ return null;
1169
+ // Match `..` as a path segment: preceded by start/whitespace/quote/`/`
1170
+ // and followed by `/`, end-of-string, whitespace, or shell metas.
1171
+ // Avoids over-matching `v1..v2` (range syntax inside a single token)
1172
+ // and `1..5` (numeric ranges) because those lack the path boundary.
1173
+ const traversalPattern = /(^|[\s'"\/])\.\.(\/|$|[\s'"<>|;&])/;
1174
+ const m = cmd.match(traversalPattern);
1175
+ if (m) {
1176
+ return {
1177
+ reason: 'Read command escapes workspace via parent traversal',
1178
+ matched: '..',
1179
+ };
1180
+ }
1181
+ // Absolute path read of /etc, /usr, /var, etc is already covered by
1182
+ // PROTECTED_PATH_SUBSTRINGS in detectProtectedRead — no extra branch
1183
+ // needed here.
788
1184
  return null;
789
1185
  }
790
1186
  function detectEnvWrite(cmd) {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Resumer — read SessionStore events for a session, apply the L8
3
+ * compact mask + the L9 rewind mask, and return the visible transcript
4
+ * the REPL bootstrap (or a programmatic consumer) should render.
5
+ *
6
+ * Separation of concerns:
7
+ *
8
+ * - This module owns the READ path: list sessions, load events,
9
+ * reconstruct a clean transcript stream. No writes.
10
+ * - The WRITE path (append a rewind-marker, undo-rewind) lives in
11
+ * `./rewinder.ts`.
12
+ * - The REPL session lifecycle (lockfile, Ink mount, dispatch FSM)
13
+ * stays in `core/repl/session.ts`. We do NOT spin up the REPL here.
14
+ *
15
+ * Why route resume through this module at all (vs. operators using
16
+ * `core/repl/store/*` directly):
17
+ *
18
+ * The store returns RAW events. Most consumers want masked events —
19
+ * i.e. the chronological list after compact-boundary masking AND
20
+ * rewind-marker masking. Doing both passes inline at every call site
21
+ * would scatter the mask logic; centralising it here means a future
22
+ * third mask (named checkpoints? selective edit?) lands in one place.
23
+ */
24
+ import { homedir } from 'node:os';
25
+ import { applyCompactMask } from '../compact/buffer-rewriter.js';
26
+ import { SqliteSessionStore, resolveProjectStoreDir, } from '../repl/store/index.js';
27
+ import { applyRewindMask, findLatestActiveRewind } from './rewinder.js';
28
+ /**
29
+ * Composed mask: compact-mask first (collapses summarised slices into
30
+ * boundary markers + kept tail), then rewind-mask (drops everything
31
+ * inside an active rewind range, including any compaction markers that
32
+ * fell inside it).
33
+ *
34
+ * Order matters: compact-mask reads `coversUntilOffset` against the
35
+ * RAW event indices. Running rewind-mask first would shift indices and
36
+ * break the compact replay anchor. The result is the chronological
37
+ * stream the operator should SEE, with infra rows (rewind markers)
38
+ * stripped.
39
+ */
40
+ export function applyAllMasks(events) {
41
+ return applyRewindMask(applyCompactMask(events));
42
+ }
43
+ /**
44
+ * List sessions a `pugi resume` invocation could open. Uses the
45
+ * READ-ONLY store view so the call never takes the lockfile — safe to
46
+ * run alongside a live REPL. Each row carries derived metadata
47
+ * (`visibleEventCount`, `hasActiveRewind`) so the renderer does not
48
+ * need to re-walk events.
49
+ *
50
+ * Returns an empty array when the project store does not exist (no
51
+ * sessions ever started for this project slug). Callers surface a
52
+ * "nothing to resume" message in that branch.
53
+ */
54
+ export async function listResumableSessions(input) {
55
+ const dir = input.storeDir ?? resolveProjectStoreDir(input.projectSlug, input.home ?? homedir());
56
+ const limit = clampLimit(input.limit ?? 10, 1, 50);
57
+ let view;
58
+ try {
59
+ view = await SqliteSessionStore.openReadOnly(dir);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ try {
65
+ const rows = await view.list({ project: input.projectSlug, limit });
66
+ const out = [];
67
+ for (const row of rows) {
68
+ const events = await view.events(row.id);
69
+ const visible = applyAllMasks(events);
70
+ const latest = findLatestActiveRewind(events);
71
+ out.push({
72
+ row,
73
+ visibleEventCount: visible.length,
74
+ hasActiveRewind: latest !== null,
75
+ updatedAt: row.updatedAt,
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ finally {
84
+ await view.close();
85
+ }
86
+ }
87
+ /**
88
+ * Load one session for replay. The caller (REPL bootstrap, tests,
89
+ * future programmatic exporters) gets BOTH the raw event stream and
90
+ * the masked view so it can choose its rendering strategy. Returns
91
+ * null when the session does not exist; throws when the store cannot
92
+ * be opened (the caller surfaces a one-line error).
93
+ *
94
+ * The PID lockfile contention is NOT relevant here — we use the
95
+ * read-only view. Concurrent writers from a live REPL are safe.
96
+ */
97
+ export async function loadSessionForReplay(input) {
98
+ const dir = input.storeDir ?? resolveProjectStoreDir(input.projectSlug, input.home ?? homedir());
99
+ const view = await SqliteSessionStore.openReadOnly(dir);
100
+ try {
101
+ const row = await view.get(input.sessionId);
102
+ if (!row)
103
+ return null;
104
+ const rawEvents = await view.events(row.id);
105
+ const visibleEvents = applyAllMasks(rawEvents);
106
+ const latest = findLatestActiveRewind(rawEvents);
107
+ return {
108
+ row,
109
+ rawEvents,
110
+ visibleEvents,
111
+ hasActiveRewind: latest !== null,
112
+ };
113
+ }
114
+ finally {
115
+ await view.close();
116
+ }
117
+ }
118
+ /**
119
+ * Load raw + masked events through an already-open SessionStore.
120
+ *
121
+ * Used by the in-REPL `/rewind` slash handler — the live REPL already
122
+ * holds the writer lock, so we cannot open the read-only view in the
123
+ * same process. The store reference IS the active write handle; we
124
+ * just call `loadEvents` and run the masks.
125
+ *
126
+ * Same shape as `loadSessionForReplay` minus the read-only-view setup.
127
+ */
128
+ export async function loadFromStore(store, sessionId) {
129
+ const row = await store.getSession(sessionId);
130
+ if (!row)
131
+ return null;
132
+ const rawEvents = await store.loadEvents(sessionId);
133
+ const visibleEvents = applyAllMasks(rawEvents);
134
+ const latest = findLatestActiveRewind(rawEvents);
135
+ return {
136
+ row,
137
+ rawEvents,
138
+ visibleEvents,
139
+ hasActiveRewind: latest !== null,
140
+ };
141
+ }
142
+ function clampLimit(raw, min, max) {
143
+ if (!Number.isFinite(raw) || raw < min)
144
+ return min;
145
+ if (raw > max)
146
+ return max;
147
+ return Math.floor(raw);
148
+ }
149
+ //# sourceMappingURL=resumer.js.map