@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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 (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. package/package.json +9 -6
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Verify hook — β1b Pl10 (2026-05-26).
3
+ *
4
+ * After the edit-dispatcher writes a multi-file change to the
5
+ * workspace, this hook fires three lightweight checks against the
6
+ * post-state and reports each result back to the engine loop as a
7
+ * status event:
8
+ *
9
+ * 1. tsc — if `tsconfig.json` is present in the workspace root, run
10
+ * `tsc --noEmit` to catch compile-time breakage. Pass/fail tracked
11
+ * per file is overkill at this stage; we surface the exit code +
12
+ * first ~40 lines of stderr.
13
+ * 2. tests — if package.json has a `test` script AND a test runner
14
+ * is available (jest / vitest / node --test), run `pnpm test
15
+ * --bail` (or `npm test --bail`). Same exit-code + tail of output
16
+ * contract.
17
+ * 3. URL probes — extract every `https?://...` literal from the diff
18
+ * (README + code) and HEAD-probe each. A response < 400 counts as
19
+ * live; >=400 surfaces as a warning. Capped at 8 unique URLs per
20
+ * hook to avoid spending the budget on doc rot.
21
+ *
22
+ * Retry contract (β1b r1 rescope): the hook itself is stateless and
23
+ * runs ONCE per invocation, returning a structured report. The
24
+ * "feedback → model → re-edit" retry orchestrator does NOT exist yet
25
+ * in the engine loop — it was promised as part of β1b Pl10 but never
26
+ * shipped because the engine refactor that hosts it is bigger than
27
+ * the verify-hook itself can absorb. Retry orchestration is deferred
28
+ * to β6 plan-mode integration where the loop already needs a
29
+ * model→hook feedback channel for plan replay. Today the operator
30
+ * re-runs `pugi code` to re-drive verification after a fail. The
31
+ * stateless hook ships unchanged so the β6 driver can wrap it.
32
+ *
33
+ * Why HEAD and not GET for URL probes:
34
+ * - HEAD avoids the body fetch; cheaper + faster.
35
+ * - SSRF guard from `web-fetch.ts::validateHostnameForFetch` runs
36
+ * before every probe so private IPs / localhost cannot ride.
37
+ * - Some servers reject HEAD (rare but real); on 4xx-from-HEAD we
38
+ * do NOT escalate to GET — that would burn the budget. We surface
39
+ * a `head_rejected` warning so the operator decides.
40
+ *
41
+ * Skip cases (return early with `skipped: true` on the relevant
42
+ * check):
43
+ * - tsc: no `tsconfig.json` at workspace root.
44
+ * - tests: no `package.json` OR no `test` script.
45
+ * - urls: no `https?://...` literals in the diff.
46
+ *
47
+ * Best-effort: every failure mode degrades to a structured report; the
48
+ * hook itself NEVER throws. The engine loop decides whether to
49
+ * surface as a hard fail vs a model-correctable warning.
50
+ *
51
+ * Brand voice: ASCII only, no banned words.
52
+ */
53
+ import { spawnSync } from 'node:child_process';
54
+ import { existsSync, readFileSync } from 'node:fs';
55
+ import { resolve } from 'node:path';
56
+ import { validateHostnameForFetch } from '../../tools/web-fetch.js';
57
+ const DEFAULT_TIMEOUT_MS = 30_000;
58
+ const DEFAULT_MAX_URL_PROBES = 8;
59
+ const URL_LITERAL_RE = /(https?:\/\/[^\s"'<>()`\\\]]+)/g;
60
+ /**
61
+ * Drive one verify pass. Synchronous tsc + test child processes,
62
+ * concurrent URL probes (up to `maxUrlProbes`). Returns once every
63
+ * check has completed (no streaming events — the caller wraps the
64
+ * report into its own status event format).
65
+ */
66
+ export async function runVerifyHook(input) {
67
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
68
+ const runProc = input.runProc ?? defaultRunProc;
69
+ return {
70
+ tsc: runTscCheck(input.workspaceRoot, runProc, timeoutMs),
71
+ tests: runTestsCheck(input.workspaceRoot, runProc, timeoutMs),
72
+ urls: await runUrlChecks(input),
73
+ };
74
+ }
75
+ /* ----------------------- tsc check ---------------------- */
76
+ function runTscCheck(workspaceRoot, runProc, timeoutMs) {
77
+ const tsconfig = resolve(workspaceRoot, 'tsconfig.json');
78
+ if (!existsSync(tsconfig)) {
79
+ return { ok: true, skipped: true, reason: 'no_tsconfig' };
80
+ }
81
+ // Prefer `pnpm exec tsc` because pnpm-aware monorepos hoist tsc into
82
+ // `node_modules/.bin`; fallback to bare `tsc` for global installs.
83
+ // We try `pnpm exec tsc` first only if a `pnpm-lock.yaml` is at the
84
+ // workspace root; otherwise we go straight to `tsc`.
85
+ const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
86
+ const cmd = pnpmLock ? 'pnpm' : 'tsc';
87
+ const args = pnpmLock ? ['exec', 'tsc', '--noEmit'] : ['--noEmit'];
88
+ const result = runProc(cmd, args, workspaceRoot, timeoutMs);
89
+ if (result.exitCode === 0)
90
+ return { ok: true };
91
+ return {
92
+ ok: false,
93
+ reason: `tsc_exit_${result.exitCode}`,
94
+ detail: tailOutput(result.stdout, result.stderr, 40),
95
+ };
96
+ }
97
+ /* ----------------------- tests check ---------------------- */
98
+ function runTestsCheck(workspaceRoot, runProc, timeoutMs) {
99
+ const pkgPath = resolve(workspaceRoot, 'package.json');
100
+ if (!existsSync(pkgPath)) {
101
+ return { ok: true, skipped: true, reason: 'no_package_json' };
102
+ }
103
+ let pkg;
104
+ try {
105
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
106
+ }
107
+ catch {
108
+ return { ok: true, skipped: true, reason: 'malformed_package_json' };
109
+ }
110
+ if (!pkg.scripts || typeof pkg.scripts.test !== 'string') {
111
+ return { ok: true, skipped: true, reason: 'no_test_script' };
112
+ }
113
+ const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
114
+ // Prefer pnpm test --bail; some test runners reject the flag (node
115
+ // --test ignores it), so we surface non-zero exits clearly but do
116
+ // not retry without --bail.
117
+ // Both pnpm + npm accept `<cmd> test -- --bail`; the runner-side
118
+ // flag-forwarding contract is identical, so the ternary collapses to
119
+ // a single args literal.
120
+ const cmd = pnpmLock ? 'pnpm' : 'npm';
121
+ const args = ['test', '--', '--bail'];
122
+ const result = runProc(cmd, args, workspaceRoot, timeoutMs);
123
+ if (result.exitCode === 0)
124
+ return { ok: true };
125
+ return {
126
+ ok: false,
127
+ reason: `tests_exit_${result.exitCode}`,
128
+ detail: tailOutput(result.stdout, result.stderr, 60),
129
+ };
130
+ }
131
+ /* ----------------------- url probes ---------------------- */
132
+ async function runUrlChecks(input) {
133
+ const diffText = input.diffText ?? '';
134
+ if (diffText.length === 0) {
135
+ return { ok: true, skipped: true, reason: 'no_diff_text' };
136
+ }
137
+ const urls = extractUrls(diffText);
138
+ if (urls.length === 0) {
139
+ return { ok: true, skipped: true, reason: 'no_urls' };
140
+ }
141
+ const cap = input.maxUrlProbes ?? DEFAULT_MAX_URL_PROBES;
142
+ const probed = urls.slice(0, cap);
143
+ const probeFn = input.probeFn ?? defaultProbeFn;
144
+ const failures = [];
145
+ for (const url of probed) {
146
+ // Hostname SSRF guard — never probe a localhost / private IP even
147
+ // when a literal in the diff points there.
148
+ let parsed;
149
+ try {
150
+ parsed = new URL(url);
151
+ }
152
+ catch {
153
+ failures.push({ url, error: 'invalid_url' });
154
+ continue;
155
+ }
156
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
157
+ failures.push({ url, error: `unsupported_scheme_${parsed.protocol}` });
158
+ continue;
159
+ }
160
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
161
+ const guard = await validateHostnameForFetch(hostname);
162
+ if (guard) {
163
+ failures.push({ url, error: `ssrf_refused: ${guard}` });
164
+ continue;
165
+ }
166
+ try {
167
+ const r = await probeFn(url);
168
+ if ('error' in r) {
169
+ failures.push({ url, error: r.error });
170
+ continue;
171
+ }
172
+ if (r.status >= 400) {
173
+ failures.push({ url, error: `http_${r.status}` });
174
+ }
175
+ }
176
+ catch (error) {
177
+ failures.push({
178
+ url,
179
+ error: error instanceof Error ? error.message : String(error),
180
+ });
181
+ }
182
+ }
183
+ if (failures.length === 0) {
184
+ return { ok: true, probedCount: probed.length };
185
+ }
186
+ return {
187
+ ok: false,
188
+ reason: `url_probe_failed`,
189
+ probedCount: probed.length,
190
+ failures,
191
+ detail: failures.map((f) => `${f.url} → ${f.error}`).join('; '),
192
+ };
193
+ }
194
+ /**
195
+ * Extract unique http(s) URLs from a diff/text blob. Order preserved
196
+ * (first-seen) so the cap picks the earliest-mentioned ones, which
197
+ * intuitively matches the operator's expectation.
198
+ */
199
+ export function extractUrls(text) {
200
+ const seen = new Set();
201
+ const out = [];
202
+ let match;
203
+ // Reset the regex's lastIndex; URL_LITERAL_RE is module-scoped and
204
+ // /g means stateful exec calls.
205
+ URL_LITERAL_RE.lastIndex = 0;
206
+ while ((match = URL_LITERAL_RE.exec(text)) !== null) {
207
+ const raw = match[1];
208
+ if (!raw)
209
+ continue;
210
+ // Strip a trailing punctuation that the regex tolerates inside
211
+ // the match (sentences in Markdown often end `... https://x).`).
212
+ // We also strip `!` and `?` so prose like "see https://x!" lands
213
+ // as `https://x`.
214
+ const cleaned = raw.replace(/[.,;:!?)\]>]+$/, '');
215
+ if (cleaned.length === 0)
216
+ continue;
217
+ if (seen.has(cleaned))
218
+ continue;
219
+ seen.add(cleaned);
220
+ out.push(cleaned);
221
+ }
222
+ return out;
223
+ }
224
+ /* ----------------------- defaults ---------------------- */
225
+ function defaultRunProc(cmd, args, cwd, timeoutMs) {
226
+ const result = spawnSync(cmd, [...args], {
227
+ cwd,
228
+ encoding: 'utf8',
229
+ timeout: timeoutMs,
230
+ // Inherit a minimal env — every check is read-only against the
231
+ // workspace and we do not want to leak PUGI_API_KEY into a
232
+ // sub-process accidentally.
233
+ env: { ...process.env, PUGI_API_KEY: undefined, PUGI_LOGIN_TOKEN: undefined },
234
+ });
235
+ return {
236
+ exitCode: typeof result.status === 'number' ? result.status : -1,
237
+ stdout: result.stdout ?? '',
238
+ stderr: result.stderr ?? '',
239
+ };
240
+ }
241
+ async function defaultProbeFn(url) {
242
+ // Lazy-import undici so the verify-hook module stays cheap when
243
+ // url probes are skipped.
244
+ const { request } = await import('undici');
245
+ try {
246
+ const response = await request(url, {
247
+ method: 'HEAD',
248
+ bodyTimeout: 5_000,
249
+ headersTimeout: 5_000,
250
+ });
251
+ // Drain so the connection releases promptly.
252
+ try {
253
+ await response.body.dump();
254
+ }
255
+ catch {
256
+ /* swallow */
257
+ }
258
+ return { status: response.statusCode };
259
+ }
260
+ catch (error) {
261
+ return { error: error instanceof Error ? error.message : String(error) };
262
+ }
263
+ }
264
+ function tailOutput(stdout, stderr, maxLines) {
265
+ const merged = `${stdout}\n${stderr}`.trim();
266
+ if (merged.length === 0)
267
+ return '';
268
+ const lines = merged.split('\n');
269
+ if (lines.length <= maxLines)
270
+ return merged;
271
+ return `... (${lines.length - maxLines} earlier lines elided)\n${lines.slice(-maxLines).join('\n')}`;
272
+ }
273
+ //# sourceMappingURL=verify-hook.js.map
@@ -30,10 +30,12 @@
30
30
  * Brand voice: ASCII only, no emoji, no banned words.
31
31
  */
32
32
  import { spawnSync } from 'node:child_process';
33
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
33
+ import { existsSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
34
34
  import { randomUUID } from 'node:crypto';
35
35
  import { resolve, sep } from 'node:path';
36
36
  import { OperatorAbortedError } from '../../tools/file-tools.js';
37
+ import { applySecurityGate } from './security-gate.js';
38
+ import { extractPatchPaths } from '../../tools/apply-patch.js';
37
39
  /**
38
40
  * Create a scratch worktree under `.pugi/worktrees/<uuid>`. The path is
39
41
  * guaranteed unique (uuid) so multiple agent loops can run in parallel
@@ -121,25 +123,58 @@ export function promoteWorktree(opts) {
121
123
  detail: `worktree path does not exist: ${opts.worktreePath}`,
122
124
  };
123
125
  }
124
- // Capture the diff. Include both unstaged AND staged changes — the
125
- // agent loop typically stages nothing (it just writes files), but
126
- // honoring both is forward-compatible with hand-edits inside the
127
- // worktree.
128
- const diff = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
129
- if (diff.status !== 0) {
126
+ // Capture the diff against the base SHA. `git diff <baseSha>`
127
+ // (no `--cached`) compares the WORKING TREE against the base, which
128
+ // covers both unstaged AND staged changes in a single invocation —
129
+ // anything the working tree shows is included. `--binary` ensures
130
+ // non-text files survive the round-trip.
131
+ //
132
+ // Note: untracked files that were NEVER staged stay invisible — git
133
+ // diff has no native flag to include them. The agent loop must
134
+ // `git add` any new file it wants promoted; the CLI surface
135
+ // documents this explicitly so the contract is not surprising.
136
+ // (Staging is enough to expose the file; the file does not need to
137
+ // be committed.)
138
+ const diffResult = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
139
+ if (diffResult.status !== 0) {
130
140
  return {
131
141
  ok: false,
132
142
  reason: 'git_command_failed',
133
- detail: `git diff failed: ${diff.stderr}`,
143
+ detail: `git diff failed: ${diffResult.stderr}`,
134
144
  };
135
145
  }
136
- if (diff.stdout.trim().length === 0) {
146
+ const diffText = diffResult.stdout;
147
+ if (diffText.trim().length === 0) {
137
148
  return { ok: true, value: { filesChanged: 0 } };
138
149
  }
150
+ // SECURITY GATE (R1 fix 2026-05-26, PR #413 r1) — every path mentioned
151
+ // in the worktree's diff goes through the same `applySecurityGate`
152
+ // chokepoint as the apply_patch + Layer A/B/C applicators. A staged
153
+ // `.env` (or `../../etc/passwd`, or a symlink into a protected target)
154
+ // inside the worktree must NOT slip into the operator's main tree just
155
+ // because the worktree itself was a sandboxed scratch dir. Without
156
+ // this gate, `promoteWorktree` was a clean bypass of every other edit
157
+ // primitive's safety net.
158
+ const diffPaths = extractPatchPaths(diffText);
159
+ const failedPaths = [];
160
+ for (const file of diffPaths) {
161
+ const gate = applySecurityGate(file, { cwd: opts.cwd, toolName: 'layer-c' });
162
+ if (!gate.ok) {
163
+ failedPaths.push(`${file}: ${gate.reason}`);
164
+ }
165
+ }
166
+ if (failedPaths.length > 0) {
167
+ return {
168
+ ok: false,
169
+ reason: 'protected_file_in_worktree',
170
+ detail: `worktree diff touches protected/escaping paths: ${failedPaths.join('; ')}`,
171
+ files: failedPaths,
172
+ };
173
+ }
139
174
  // `git apply --check` validates the diff against the main tree first.
140
175
  // Refuse early on conflict so the operator can resolve before we
141
176
  // touch any file.
142
- const check = runGit(['apply', '--check', '-'], opts.cwd, diff.stdout);
177
+ const check = runGit(['apply', '--check', '-'], opts.cwd, diffText);
143
178
  if (check.status !== 0) {
144
179
  return {
145
180
  ok: false,
@@ -148,9 +183,9 @@ export function promoteWorktree(opts) {
148
183
  };
149
184
  }
150
185
  if (opts.dryRun) {
151
- return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
186
+ return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
152
187
  }
153
- const apply = runGit(['apply', '-'], opts.cwd, diff.stdout);
188
+ const apply = runGit(['apply', '-'], opts.cwd, diffText);
154
189
  if (apply.status !== 0) {
155
190
  return {
156
191
  ok: false,
@@ -158,23 +193,81 @@ export function promoteWorktree(opts) {
158
193
  detail: `git apply failed: ${apply.stderr}`,
159
194
  };
160
195
  }
161
- return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
196
+ return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
162
197
  }
163
198
  /**
164
199
  * Drop a worktree both from git's bookkeeping and from disk. Idempotent —
165
200
  * a missing path returns `worktree_missing` which the caller can ignore
166
201
  * on the cleanup-after-error path.
202
+ *
203
+ * Security (R1 fix 2026-05-26, PR #413 r1): we MUST validate the path is
204
+ * a real subdirectory of `<cwd>/.pugi/worktrees/` BEFORE running either
205
+ * `git worktree remove --force` or `rmSync`. Without this gate, a
206
+ * typo like `pugi worktree drop ../some-dir` recursively deleted an
207
+ * arbitrary directory: `git worktree remove` correctly failed (path not
208
+ * registered), but the `rmSync(worktreePath, recursive: true)` below
209
+ * still fired regardless.
210
+ *
211
+ * We resolve both `cwd` and `worktreePath` through `realpathSync` so a
212
+ * caller passing a symlink that points outside `.pugi/worktrees/` is
213
+ * still rejected. When the worktree path does not exist on disk at all
214
+ * (idempotent re-drop of an already-removed worktree), we fall back to
215
+ * the lexical containment check — the rejection only matters when there
216
+ * is a real directory to delete.
167
217
  */
168
218
  export function dropWorktree(worktreePath, cwd) {
219
+ // SECURITY GATE — validate containment under `<cwd>/.pugi/worktrees/`
220
+ // BEFORE any destructive call. Two-tier check:
221
+ // 1. lexical containment using resolved (but not realpath'd) paths,
222
+ // catches the operator-typo + missing-worktree cases.
223
+ // 2. realpath containment when the path exists, catches symlink
224
+ // shenanigans.
225
+ const scratchRootLexical = resolve(cwd, '.pugi', 'worktrees');
226
+ const worktreeLexical = resolve(cwd, worktreePath);
227
+ const insideLexical = worktreeLexical.startsWith(scratchRootLexical + sep) &&
228
+ worktreeLexical !== scratchRootLexical;
229
+ if (!insideLexical) {
230
+ return {
231
+ ok: false,
232
+ reason: 'invalid_worktree_path',
233
+ detail: `worktree path ${worktreePath} is not under ${scratchRootLexical}`,
234
+ };
235
+ }
236
+ if (existsSync(worktreeLexical)) {
237
+ try {
238
+ const realScratchRoot = realpathSync(scratchRootLexical);
239
+ const realWorktree = realpathSync(worktreeLexical);
240
+ const insideReal = realWorktree.startsWith(realScratchRoot + sep) &&
241
+ realWorktree !== realScratchRoot;
242
+ if (!insideReal) {
243
+ return {
244
+ ok: false,
245
+ reason: 'invalid_worktree_path',
246
+ detail: `worktree realpath ${realWorktree} escapes ${realScratchRoot}`,
247
+ };
248
+ }
249
+ }
250
+ catch (error) {
251
+ // realpath failed for a path that exists — surface as
252
+ // invalid_worktree_path so we never recurse into rmSync on an
253
+ // unreadable path.
254
+ return {
255
+ ok: false,
256
+ reason: 'invalid_worktree_path',
257
+ detail: `cannot realpath worktree path: ${error instanceof Error ? error.message : String(error)}`,
258
+ };
259
+ }
260
+ }
169
261
  // `git worktree remove --force` cleans the metadata in `.git/worktrees`.
170
262
  // If the worktree was created by another process and already pruned,
171
263
  // git returns non-zero — we still try to `rmSync` the dir to leave the
172
- // filesystem consistent.
173
- const remove = runGit(['worktree', 'remove', '--force', worktreePath], cwd);
264
+ // filesystem consistent. Path containment has already been validated
265
+ // above so the rmSync below is bounded to `.pugi/worktrees/`.
266
+ const remove = runGit(['worktree', 'remove', '--force', worktreeLexical], cwd);
174
267
  const gitCleanFailed = remove.status !== 0;
175
- if (existsSync(worktreePath)) {
268
+ if (existsSync(worktreeLexical)) {
176
269
  try {
177
- rmSync(worktreePath, { recursive: true, force: true });
270
+ rmSync(worktreeLexical, { recursive: true, force: true });
178
271
  }
179
272
  catch (error) {
180
273
  if (gitCleanFailed) {
@@ -186,7 +279,7 @@ export function dropWorktree(worktreePath, cwd) {
186
279
  }
187
280
  }
188
281
  }
189
- if (gitCleanFailed && !worktreePath.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
282
+ if (gitCleanFailed && !worktreeLexical.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
190
283
  // A worktree that wasn't created by us (path is outside our naming
191
284
  // convention) is suspicious — surface the failure so the operator
192
285
  // can diagnose.
@@ -1,3 +1,10 @@
1
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). The interceptor stamps the
2
+ // outbound X-Pugi-Cli-Version header, inspects the inbound recommended/
3
+ // server-version headers, and throws PugiCliUpgradeRequiredError on a
4
+ // 426 server response. The top-level catch in `runtime/cli.ts` /
5
+ // `index.ts` renders the upgrade message and exits 1.
6
+ import { assertNotUpgradeRequired, injectClientVersionHeader, inspectVersionResponse, } from '../transport/version-interceptor.js';
7
+ import { PUGI_CLI_VERSION } from '../../runtime/version.js';
1
8
  /**
2
9
  * Anvil-backed engine loop client.
3
10
  *
@@ -54,23 +61,77 @@ export class AnvilEngineLoopClient {
54
61
  options.signal.addEventListener('abort', onAbort);
55
62
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
56
63
  try {
64
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). Stamp the outbound
65
+ // X-Pugi-Cli-Version header so the admin-api middleware can
66
+ // decide whether to honour, soft-warn, or 426 this request.
67
+ const outboundHeaders = injectClientVersionHeader({
68
+ 'content-type': 'application/json',
69
+ authorization: `Bearer ${this.config.apiKey}`,
70
+ 'user-agent': 'pugi-cli/0.0.1',
71
+ }, PUGI_CLI_VERSION);
57
72
  const res = await fetch(url, {
58
73
  method: 'POST',
59
- headers: {
60
- 'content-type': 'application/json',
61
- authorization: `Bearer ${this.config.apiKey}`,
62
- 'user-agent': 'pugi-cli/0.0.1',
63
- },
74
+ headers: outboundHeaders,
64
75
  body: JSON.stringify({
65
76
  personaSlug: options.personaSlug,
66
77
  messages,
67
78
  tools,
68
79
  maxTokens: options.maxTokens,
69
80
  temperature: options.temperature,
81
+ // β1 (audit E2): the admin-api `EngineRequestDto` accepts
82
+ // these optional fields (see `pugi-engine.controller.ts:230`
83
+ // EngineRequestDto schema). Before this fix the CLI dropped
84
+ // them, which forced the controller to fall back to legacy
85
+ // per-persona resolution + emit `command="(none)"` in its
86
+ // structured logs. `undefined` keys are stripped by
87
+ // `JSON.stringify` so the payload stays clean for fixture
88
+ // clients that exact-match the body shape.
89
+ command: options.command,
90
+ // β1a r1: `tag` is `EngineDispatchTag` object shape now —
91
+ // `JSON.stringify` serialises it as `{tag, priority?,
92
+ // budget_hint?}` matching `EngineDispatchTagDto`. Previously
93
+ // this was a bare string and the server's `IsIn` validator
94
+ // rejected every payload with HTTP 400.
95
+ tag: options.tag,
96
+ model: options.model,
70
97
  }),
71
98
  signal: controller.signal,
72
99
  });
73
100
  const text = await res.text();
101
+ // PR-CLI-SERVER-VERSION-HANDSHAKE: cache server-recommended +
102
+ // server-version headers so UpdateBanner / `pugi doctor` can
103
+ // surface them, then short-circuit on 426 by throwing
104
+ // PugiCliUpgradeRequiredError. The throw bubbles to the
105
+ // top-level catch in index.ts which renders the upgrade banner.
106
+ // The getter shim handles both real `Response` (`.headers.get`)
107
+ // and minimal fixture/stub responses (`.headers?.[name]`) so
108
+ // existing transport tests that mock `fetch` with `{status, text}`
109
+ // don't need to grow a Headers polyfill just to keep passing.
110
+ //
111
+ // Cache poison guard: skip the inspect step on 426. A hostile
112
+ // upstream (proxy with a compromised cert pin, or a transient MITM
113
+ // on a coffee-shop network) could otherwise forge an
114
+ // `X-Pugi-Cli-Upgrade-Recommended` header alongside a 426 status
115
+ // and poison `cachedServerRecommendation` for the rest of the REPL
116
+ // session — `UpdateBanner` would then surface attacker-chosen
117
+ // version strings to the operator. The 426 body still carries the
118
+ // legitimate `recommendedVersion` field, which assertNotUpgrade-
119
+ // Required parses + throws with, so the operator-facing banner
120
+ // remains accurate via the error path.
121
+ if (res.status !== 426) {
122
+ inspectVersionResponse((name) => {
123
+ const h = res.headers;
124
+ if (h && typeof h.get === 'function') {
125
+ return h.get(name);
126
+ }
127
+ if (h && typeof h === 'object') {
128
+ const lowered = h[name.toLowerCase()];
129
+ return lowered ?? null;
130
+ }
131
+ return null;
132
+ });
133
+ }
134
+ assertNotUpgradeRequired(res.status, text, PUGI_CLI_VERSION);
74
135
  if (res.status === 200) {
75
136
  try {
76
137
  const json = JSON.parse(text);
@@ -119,6 +180,55 @@ export class AnvilEngineLoopClient {
119
180
  };
120
181
  }
121
182
  if (res.status === 401 || res.status === 403) {
183
+ // 403 has two distinct causes:
184
+ // 1. genuinely invalid / expired token (auth_missing) — the
185
+ // old default.
186
+ // 2. tenant authenticated successfully but the privacy mode
187
+ // (strict / balanced policy) refused upstream LLM dispatch.
188
+ // The admin-api returns
189
+ // `{ code: 'privacy_strict_upstream_blocked', mode, model,
190
+ // message: '...switch via pugi config set privacy=...' }`.
191
+ // Reported as `auth_missing` the user runs `pugi login`
192
+ // again, which does nothing — the actual fix is a privacy-
193
+ // mode change. Parse the body and route accordingly.
194
+ // (2026-05-27 P0.3 — dogfood surfaced this on /api/pugi/engine
195
+ // for a strict-mode tenant; see memory feedback_no_fake_dispatch_promises
196
+ // for the broader "misleading error" pattern.)
197
+ try {
198
+ const parsed = text ? JSON.parse(text) : null;
199
+ // 2026-05-27 dogfood cycle 2: distinct error code for the
200
+ // infra-side "PII scrubber down" case. Previously the engine
201
+ // server returned `privacy_strict_upstream_blocked` here even
202
+ // when the tenant was on BALANCED (the scrubber crash forced
203
+ // a fail-closed). Operators chased the wrong fix ("switch
204
+ // privacy") for hours. Server now emits
205
+ // `pii_scrubber_unavailable` — surface a distinct remediation
206
+ // that points at the infra side, not the operator's privacy
207
+ // posture.
208
+ if (parsed?.code === 'pii_scrubber_unavailable') {
209
+ return {
210
+ stop: 'error',
211
+ code: 'privacy_blocked',
212
+ message: parsed.message ?? 'PII scrubber unavailable; privacy filter refused dispatch.',
213
+ remediation: 'Infra-side issue (not your tenant privacy mode). Wait for ops to restore ' +
214
+ 'the PiiScrubberService, OR temporarily switch your tenant to permissive via ' +
215
+ '`pugi config set privacy=permissive`.',
216
+ };
217
+ }
218
+ if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
219
+ return {
220
+ stop: 'error',
221
+ code: 'privacy_blocked',
222
+ message: parsed.message ?? 'Tenant privacy mode forbids upstream LLM dispatch.',
223
+ remediation: 'pugi config set privacy=balanced — OR configure a self-hosted Anvil model.',
224
+ };
225
+ }
226
+ }
227
+ catch {
228
+ // Body not JSON — fall through to the generic auth_missing
229
+ // branch below; the 200-char text echo on `failed` will at
230
+ // least give the operator the raw response to triage.
231
+ }
122
232
  return {
123
233
  stop: 'error',
124
234
  code: 'auth_missing',