@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.
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- 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
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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: ${
|
|
143
|
+
detail: `git diff failed: ${diffResult.stderr}`,
|
|
134
144
|
};
|
|
135
145
|
}
|
|
136
|
-
|
|
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,
|
|
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(
|
|
186
|
+
return { ok: true, value: { filesChanged: countDiffFiles(diffText) } };
|
|
152
187
|
}
|
|
153
|
-
const apply = runGit(['apply', '-'], opts.cwd,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
268
|
+
if (existsSync(worktreeLexical)) {
|
|
176
269
|
try {
|
|
177
|
-
rmSync(
|
|
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 && !
|
|
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',
|