@pugi/cli 0.1.0-alpha.18 → 0.1.0-alpha.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/README.md +33 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +4 -4
- package/dist/core/repl/session.js +556 -0
- package/dist/core/repl/slash-commands.js +12 -3
- package/dist/runtime/cli.js +193 -1
- package/dist/runtime/commands/config.js +136 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/repl.js +24 -2
- package/dist/tui/status-bar.js +63 -3
- package/package.json +2 -2
|
@@ -41,7 +41,9 @@ export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
|
41
41
|
compact: 'Manual context compaction lands in α6.5b.',
|
|
42
42
|
memory: 'Session memory editor lands in α6.5b.',
|
|
43
43
|
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
44
|
-
|
|
44
|
+
// alpha 6.13: /privacy graduated from stub; nothing reads this at
|
|
45
|
+
// runtime but the type record stays exhaustive.
|
|
46
|
+
privacy: '',
|
|
45
47
|
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
46
48
|
mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
|
|
47
49
|
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
@@ -67,7 +69,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
67
69
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
68
70
|
// Settings
|
|
69
71
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
70
|
-
{ name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings'
|
|
72
|
+
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
71
73
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
72
74
|
{ name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
|
|
73
75
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
@@ -205,10 +207,17 @@ export function parseSlashCommand(input) {
|
|
|
205
207
|
// skeleton size + working-set utilisation at a glance.
|
|
206
208
|
return { kind: 'context' };
|
|
207
209
|
}
|
|
210
|
+
case 'privacy': {
|
|
211
|
+
// alpha 6.13: real handler - the session module prints the
|
|
212
|
+
// contract doc + the current mode banner. Tail is ignored (no
|
|
213
|
+
// sub-commands today; mode flips go through
|
|
214
|
+
// `pugi config set privacy=<mode>` from a fresh shell so the
|
|
215
|
+
// device flow + audit identity are wired correctly).
|
|
216
|
+
return { kind: 'privacy' };
|
|
217
|
+
}
|
|
208
218
|
case 'compact':
|
|
209
219
|
case 'memory':
|
|
210
220
|
case 'config':
|
|
211
|
-
case 'privacy':
|
|
212
221
|
case 'budget':
|
|
213
222
|
case 'mcp':
|
|
214
223
|
case 'undo': {
|
package/dist/runtime/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
|
20
20
|
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
21
21
|
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
22
22
|
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
23
|
+
import { runDeployCommand } from '../commands/deploy.js';
|
|
23
24
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
24
25
|
import { runConfigCommand } from './commands/config.js';
|
|
25
26
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
@@ -31,6 +32,7 @@ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
|
31
32
|
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
32
33
|
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
33
34
|
import { slugForCwd } from '../core/repl/history.js';
|
|
35
|
+
import { dispatchEdit, } from '../core/edits/index.js';
|
|
34
36
|
/**
|
|
35
37
|
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
36
38
|
*
|
|
@@ -42,7 +44,7 @@ import { slugForCwd } from '../core/repl/history.js';
|
|
|
42
44
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
43
45
|
* three are in lockstep.
|
|
44
46
|
*/
|
|
45
|
-
const PUGI_CLI_VERSION = "0.1.0-alpha.
|
|
47
|
+
const PUGI_CLI_VERSION = "0.1.0-alpha.20";
|
|
46
48
|
const handlers = {
|
|
47
49
|
accounts,
|
|
48
50
|
agents: dispatchAgents,
|
|
@@ -51,6 +53,7 @@ const handlers = {
|
|
|
51
53
|
budget: dispatchBudget,
|
|
52
54
|
code: runEngineTask('code'),
|
|
53
55
|
config: dispatchConfig,
|
|
56
|
+
deploy: dispatchDeploy,
|
|
54
57
|
doctor,
|
|
55
58
|
explain: runEngineTask('explain'),
|
|
56
59
|
fix: runEngineTask('fix'),
|
|
@@ -524,6 +527,16 @@ async function help(_args, flags, _session) {
|
|
|
524
527
|
' pugi ask "<question>" Surface a yes/no question modal locally.',
|
|
525
528
|
' pugi plan-review <task> Generate + present a plan-review modal.',
|
|
526
529
|
'',
|
|
530
|
+
'Deploy:',
|
|
531
|
+
' pugi deploy --target vercel <vercelProject> --project <id>',
|
|
532
|
+
' Trigger a Vercel deployment from the bound Git source.',
|
|
533
|
+
' Optional: --target-env production|preview, --ref <ref>,',
|
|
534
|
+
' --integration <id>.',
|
|
535
|
+
' pugi deploy --target render <renderService> --project <id>',
|
|
536
|
+
' Trigger a Render deployment (Sprint 2 — stub today).',
|
|
537
|
+
' pugi deploy --status <id> Vendor-agnostic status snapshot.',
|
|
538
|
+
' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
|
|
539
|
+
'',
|
|
527
540
|
'Sync safety:',
|
|
528
541
|
' pugi sync --dry-run --privacy metadata',
|
|
529
542
|
'',
|
|
@@ -2138,6 +2151,45 @@ function runEngineTask(kind) {
|
|
|
2138
2151
|
risks: ['adapter terminated without emitting a result event'],
|
|
2139
2152
|
};
|
|
2140
2153
|
}
|
|
2154
|
+
// α6.6 diff escalation — Layer A/B/C dispatcher.
|
|
2155
|
+
//
|
|
2156
|
+
// Some models emit file edits as inline SEARCH/REPLACE markers in
|
|
2157
|
+
// the final response rather than through tool calls (especially
|
|
2158
|
+
// Gemini and o1 family, which under-use tool schemas in long
|
|
2159
|
+
// reasoning chains). We run the dispatcher against the model's
|
|
2160
|
+
// final text so those markers still land on disk. Tool-call edits
|
|
2161
|
+
// (Layer-A equivalent already handled by `edit`/`write` tools) are
|
|
2162
|
+
// unaffected — the dispatcher only fires on prose blocks that
|
|
2163
|
+
// happen to contain markers.
|
|
2164
|
+
//
|
|
2165
|
+
// Scope: code / fix / build / explain only. `plan` is read-only
|
|
2166
|
+
// (the engine refuses write tools), so even a stray marker in plan
|
|
2167
|
+
// output gets ignored to honour the plan-mode contract.
|
|
2168
|
+
//
|
|
2169
|
+
// Dry-run + read-only short-circuits: when the flags forbid writes
|
|
2170
|
+
// we dispatch with `dryRun: true` so the operator still sees what
|
|
2171
|
+
// WOULD have been written, but nothing touches disk.
|
|
2172
|
+
let dispatchResults = [];
|
|
2173
|
+
if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
|
|
2174
|
+
dispatchResults = await runMarkerDispatch({
|
|
2175
|
+
root,
|
|
2176
|
+
result: {
|
|
2177
|
+
status: result.status,
|
|
2178
|
+
summary: result.summary,
|
|
2179
|
+
eventRefs: result.eventRefs,
|
|
2180
|
+
},
|
|
2181
|
+
dryRun: flags.dryRun,
|
|
2182
|
+
});
|
|
2183
|
+
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
2184
|
+
// operator-facing summary lists them alongside tool-driven edits.
|
|
2185
|
+
for (const dr of dispatchResults) {
|
|
2186
|
+
if (dr.ok && dr.absPath) {
|
|
2187
|
+
const rel = relative(root, dr.absPath);
|
|
2188
|
+
if (!result.filesChanged.includes(rel))
|
|
2189
|
+
result.filesChanged.push(rel);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2141
2193
|
// For `plan` we always write a plan.md artifact, regardless of
|
|
2142
2194
|
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
2143
2195
|
// produces a reviewable artifact — the reason is recorded inline.
|
|
@@ -2200,6 +2252,16 @@ function runEngineTask(kind) {
|
|
|
2200
2252
|
sessionEventsMirror: metrics.mirror,
|
|
2201
2253
|
risks: result.risks,
|
|
2202
2254
|
plan: planArtifact ? { path: planArtifact.relPath } : undefined,
|
|
2255
|
+
// α6.6 — per-edit dispatcher trace. Empty array when no inline
|
|
2256
|
+
// markers were detected in the model's final response.
|
|
2257
|
+
diffEdits: dispatchResults.map((dr) => ({
|
|
2258
|
+
layer: dr.layer,
|
|
2259
|
+
file: dr.file,
|
|
2260
|
+
ok: dr.ok,
|
|
2261
|
+
bytesWritten: dr.bytesWritten,
|
|
2262
|
+
reason: dr.reason,
|
|
2263
|
+
detail: dr.detail,
|
|
2264
|
+
})),
|
|
2203
2265
|
// The full event stream is useful for cabinet UI replay. We surface
|
|
2204
2266
|
// it in JSON mode only — text mode operators want the summary, not
|
|
2205
2267
|
// 30 turn-level lines.
|
|
@@ -2220,6 +2282,19 @@ function runEngineTask(kind) {
|
|
|
2220
2282
|
textLines.push('Files modified: none');
|
|
2221
2283
|
}
|
|
2222
2284
|
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
2285
|
+
if (dispatchResults.length > 0) {
|
|
2286
|
+
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
2287
|
+
const failCount = dispatchResults.length - okCount;
|
|
2288
|
+
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
2289
|
+
for (const dr of dispatchResults) {
|
|
2290
|
+
if (dr.ok) {
|
|
2291
|
+
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
2292
|
+
}
|
|
2293
|
+
else {
|
|
2294
|
+
textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2223
2298
|
if (result.risks.length > 0) {
|
|
2224
2299
|
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
2225
2300
|
}
|
|
@@ -2229,6 +2304,96 @@ function runEngineTask(kind) {
|
|
|
2229
2304
|
writeOutput(flags, payload, textLines.join('\n'));
|
|
2230
2305
|
};
|
|
2231
2306
|
}
|
|
2307
|
+
// Exported for the α6.6.1 triple-review remediation spec
|
|
2308
|
+
// (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
|
|
2309
|
+
// surface is not part of the public CLI API; this is a test seam.
|
|
2310
|
+
export async function runMarkerDispatch(input) {
|
|
2311
|
+
const { root, result, dryRun } = input;
|
|
2312
|
+
const dispatch = input.dispatchFn ?? dispatchEdit;
|
|
2313
|
+
// Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
|
|
2314
|
+
// engine's terminal status. A `blocked` (budget_exhausted, plan-mode
|
|
2315
|
+
// refusal) or `failed` result may still carry markers in the
|
|
2316
|
+
// partial `summary` text — applying them would mutate files the
|
|
2317
|
+
// CLI then exits non-zero on, leaving the workspace in an
|
|
2318
|
+
// unexpected state with no operator signal that "blocked but with
|
|
2319
|
+
// side effects" happened. Only `done` is allowed to write.
|
|
2320
|
+
if (result.status !== 'done')
|
|
2321
|
+
return [];
|
|
2322
|
+
// Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
|
|
2323
|
+
// from the body before scanning. The prefixes start with `[`; the
|
|
2324
|
+
// dispatcher tolerates leading prose but the cleaner the input the
|
|
2325
|
+
// less chance of accidental marker matches.
|
|
2326
|
+
const body = result.summary;
|
|
2327
|
+
if (!hasAnyMarkerSignal(body))
|
|
2328
|
+
return [];
|
|
2329
|
+
const modelTag = extractModelTag(result.eventRefs);
|
|
2330
|
+
try {
|
|
2331
|
+
return await dispatch(body, {
|
|
2332
|
+
modelTag,
|
|
2333
|
+
cwd: root,
|
|
2334
|
+
dryRun,
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
catch (error) {
|
|
2338
|
+
// Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
|
|
2339
|
+
// swallowed parser/applicator crashes silently — the operator
|
|
2340
|
+
// saw a clean "0 applied" rather than the actual stack trace,
|
|
2341
|
+
// and the bug only surfaced when someone manually `pugi resume`-d
|
|
2342
|
+
// a session and noticed the missing edits. Surface the failure
|
|
2343
|
+
// both to stderr (so live operators see it) and as a synthetic
|
|
2344
|
+
// DispatchResult (so JSON consumers and the audit log record it).
|
|
2345
|
+
//
|
|
2346
|
+
// R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
|
|
2347
|
+
// returned `detail: message` — i.e. the raw `Error.message`. That
|
|
2348
|
+
// string is constructed by whatever code path threw (parser,
|
|
2349
|
+
// applicator, fs layer, etc.) and may contain absolute paths,
|
|
2350
|
+
// secret fragments echoed in `oldString` context, or other
|
|
2351
|
+
// stack-bearing internals. The audit log and any JSON consumer
|
|
2352
|
+
// that surfaces `detail` to the operator (or worse, to a remote
|
|
2353
|
+
// monitoring pipe) would leak them. Stack already goes to stderr
|
|
2354
|
+
// for live diagnosis; the returned result must carry a safe,
|
|
2355
|
+
// static string so consumers can still detect "dispatcher
|
|
2356
|
+
// crashed" without re-rendering the underlying exception.
|
|
2357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2358
|
+
const stack = error instanceof Error && error.stack ? error.stack : message;
|
|
2359
|
+
process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
|
|
2360
|
+
return [
|
|
2361
|
+
{
|
|
2362
|
+
layer: 'layer-a',
|
|
2363
|
+
file: '',
|
|
2364
|
+
ok: false,
|
|
2365
|
+
bytesWritten: 0,
|
|
2366
|
+
reason: 'dispatcher_crash',
|
|
2367
|
+
detail: 'dispatcher crashed - see stderr for stack trace',
|
|
2368
|
+
},
|
|
2369
|
+
];
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Quick pre-filter: does the body contain ANY of the marker
|
|
2374
|
+
* signatures the dispatcher knows about? Saves a full parse on every
|
|
2375
|
+
* model response (most responses are pure prose and would otherwise
|
|
2376
|
+
* round-trip through the parser pointlessly).
|
|
2377
|
+
*/
|
|
2378
|
+
function hasAnyMarkerSignal(body) {
|
|
2379
|
+
return (body.includes('+++ NEW') ||
|
|
2380
|
+
body.includes('<<<<<<< SEARCH') ||
|
|
2381
|
+
body.includes('@@@ REWRITE') ||
|
|
2382
|
+
body.includes('@@@ AST') ||
|
|
2383
|
+
/^--- a\//m.test(body));
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Extract `model=<tag>` from eventRefs if the adapter emitted it.
|
|
2387
|
+
* Returns undefined when missing; dispatchEdit then auto-detects from
|
|
2388
|
+
* the payload itself.
|
|
2389
|
+
*/
|
|
2390
|
+
function extractModelTag(refs) {
|
|
2391
|
+
for (const ref of refs) {
|
|
2392
|
+
if (ref.startsWith('model='))
|
|
2393
|
+
return ref.slice('model='.length);
|
|
2394
|
+
}
|
|
2395
|
+
return undefined;
|
|
2396
|
+
}
|
|
2232
2397
|
/**
|
|
2233
2398
|
* Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
|
|
2234
2399
|
* already emits the canonical strings (`tool_calls=N`, `turns=N`,
|
|
@@ -3502,6 +3667,33 @@ async function jobs(args, flags, session) {
|
|
|
3502
3667
|
process.exitCode = exitCode;
|
|
3503
3668
|
}
|
|
3504
3669
|
}
|
|
3670
|
+
/**
|
|
3671
|
+
* `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
|
|
3672
|
+
* `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
|
|
3673
|
+
* to the deploy-specific flag set + exposes the credential store via
|
|
3674
|
+
* `resolveRuntimeConfig` so the deploy module stays decoupled from the
|
|
3675
|
+
* CLI's auth bootstrap.
|
|
3676
|
+
*/
|
|
3677
|
+
async function dispatchDeploy(args, flags, _session) {
|
|
3678
|
+
// Triple-review #391 P2: the global `parseArgs` in this file consumes
|
|
3679
|
+
// `--json` before the per-command args reach us, so `runDeployCommand`'s
|
|
3680
|
+
// internal parser never sees it and the JSON envelope path is silently
|
|
3681
|
+
// skipped. Re-inject the flag so downstream parsing surfaces the JSON
|
|
3682
|
+
// output contract the operator asked for. Idempotent: if the user wrote
|
|
3683
|
+
// `pugi deploy --json ...` the global parser stripped it; if they wrote
|
|
3684
|
+
// `pugi --json deploy ...` ditto. Either way the global flag is the
|
|
3685
|
+
// single source of truth and we forward it verbatim.
|
|
3686
|
+
const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
|
|
3687
|
+
const exitCode = await runDeployCommand(forwardedArgs, {
|
|
3688
|
+
write: (text) => process.stdout.write(text),
|
|
3689
|
+
writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
3690
|
+
}, {
|
|
3691
|
+
resolveConfig: () => resolveRuntimeConfig(),
|
|
3692
|
+
});
|
|
3693
|
+
if (exitCode !== 0) {
|
|
3694
|
+
process.exitCode = exitCode;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3505
3697
|
function notImplemented(command) {
|
|
3506
3698
|
return async (_args, flags) => {
|
|
3507
3699
|
const payload = {
|
|
@@ -58,6 +58,8 @@ export async function runConfigCommand(args, ctx) {
|
|
|
58
58
|
'pugi config get routing',
|
|
59
59
|
'pugi config set routing.<tag>.<budget>=<model>',
|
|
60
60
|
'pugi config unset routing.<tag>.<budget>',
|
|
61
|
+
'pugi config get privacy',
|
|
62
|
+
'pugi config set privacy=strict|balanced|permissive',
|
|
61
63
|
],
|
|
62
64
|
}, [
|
|
63
65
|
'Usage:',
|
|
@@ -71,6 +73,8 @@ export async function runConfigCommand(args, ctx) {
|
|
|
71
73
|
' pugi config get routing Show effective routing table (defaults + tenant overrides).',
|
|
72
74
|
' pugi config set routing.<tag>.<budget>=<model> Override the model for one (tag, budget) lane.',
|
|
73
75
|
' pugi config unset routing.<tag>.<budget> Remove a routing override (revert to default).',
|
|
76
|
+
' pugi config get privacy Show current tenant privacy mode + last-flip metadata.',
|
|
77
|
+
' pugi config set privacy=<mode> Flip privacy mode (strict | balanced | permissive).',
|
|
74
78
|
].join('\n'));
|
|
75
79
|
return;
|
|
76
80
|
}
|
|
@@ -81,6 +85,13 @@ export async function runConfigCommand(args, ctx) {
|
|
|
81
85
|
if (args[1] === 'routing') {
|
|
82
86
|
return runRoutingGet(ctx);
|
|
83
87
|
}
|
|
88
|
+
// alpha 6.13: `pugi config get privacy` hits the admin-api
|
|
89
|
+
// /api/admin/privacy/mode surface. The privacy mode is a tenant-
|
|
90
|
+
// scoped server-side setting (so the Anvil filter can enforce it),
|
|
91
|
+
// not a local-only preference.
|
|
92
|
+
if (args[1] === 'privacy') {
|
|
93
|
+
return runPrivacyGet(ctx);
|
|
94
|
+
}
|
|
84
95
|
return runConfigGet(args.slice(1), ctx);
|
|
85
96
|
case 'set':
|
|
86
97
|
// Special form: `pugi config set routing.<tag>.<budget>=<model>` hits
|
|
@@ -88,6 +99,40 @@ export async function runConfigCommand(args, ctx) {
|
|
|
88
99
|
if (args[1] && args[1].startsWith('routing.')) {
|
|
89
100
|
return runRoutingSet(args.slice(1), ctx);
|
|
90
101
|
}
|
|
102
|
+
// alpha 6.13: `pugi config set privacy=<mode>` (or `privacy <mode>`)
|
|
103
|
+
// flips the tenant-scoped server-side mode IFF <mode> is one of
|
|
104
|
+
// the new closed set (strict | balanced | permissive). Legacy
|
|
105
|
+
// values (local-only | metadata | full) still hit the local
|
|
106
|
+
// config schema via runConfigSet so existing operators do not
|
|
107
|
+
// get a surprise 4xx when their playbook still uses the old
|
|
108
|
+
// names. The unit spec for config.ts has a regression test for
|
|
109
|
+
// both code paths.
|
|
110
|
+
//
|
|
111
|
+
// Triple-review P2 fix (2026-05-25): the prior disambiguation
|
|
112
|
+
// only excluded the bare form (`privacy local-only`) - the `=`
|
|
113
|
+
// form (`privacy=local-only`) still routed to runPrivacySet and
|
|
114
|
+
// 4xx'd. We now check the value AFTER `=` and route legacy local
|
|
115
|
+
// values to runConfigSet; new-style values + unknown values
|
|
116
|
+
// continue to runPrivacySet so its client-side validator surfaces
|
|
117
|
+
// a structured "Unknown privacy mode" error (preserves the
|
|
118
|
+
// existing UX for typos like `privacy=paranoid`).
|
|
119
|
+
if (args[1]) {
|
|
120
|
+
const equalsForm = args[1].startsWith('privacy=');
|
|
121
|
+
const bareForm = args[1] === 'privacy';
|
|
122
|
+
if (equalsForm) {
|
|
123
|
+
const valueAfterEquals = args[1].slice('privacy='.length);
|
|
124
|
+
if (isLegacyLocalPrivacyValue(valueAfterEquals)) {
|
|
125
|
+
// Legacy local form (`privacy=local-only|metadata|full`) -
|
|
126
|
+
// split into `['privacy', '<value>']` so runConfigSet sees
|
|
127
|
+
// the same shape it would for the bare form.
|
|
128
|
+
return runConfigSet(['privacy', valueAfterEquals], ctx);
|
|
129
|
+
}
|
|
130
|
+
return runPrivacySet(args.slice(1), ctx);
|
|
131
|
+
}
|
|
132
|
+
if (bareForm && isNewPrivacyModeValue(args[2])) {
|
|
133
|
+
return runPrivacySet(args.slice(1), ctx);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
91
136
|
return runConfigSet(args.slice(1), ctx);
|
|
92
137
|
case 'unset':
|
|
93
138
|
if (args[1] && args[1].startsWith('routing.')) {
|
|
@@ -393,6 +438,97 @@ async function runRoutingUnset(args, ctx) {
|
|
|
393
438
|
? `routing.${tag}.${budget} reverted to default.`
|
|
394
439
|
: `routing.${tag}.${budget} had no override (nothing to remove).`);
|
|
395
440
|
}
|
|
441
|
+
/* ------------------------------------------------------------------ */
|
|
442
|
+
/* alpha 6.13 privacy 3-mode - config.privacy.* subcommands */
|
|
443
|
+
/* ------------------------------------------------------------------ */
|
|
444
|
+
/**
|
|
445
|
+
* Closed mirror of the server-side PRIVACY_MODES enum
|
|
446
|
+
* (apps/admin-api/src/privacy/privacy-mode.ts). Pinning the literal
|
|
447
|
+
* set here lets the CLI reject typos client-side before the round-
|
|
448
|
+
* trip to admin-api (better UX, smaller blast radius on a flaky
|
|
449
|
+
* network).
|
|
450
|
+
*/
|
|
451
|
+
const PRIVACY_MODES = ['strict', 'balanced', 'permissive'];
|
|
452
|
+
function isNewPrivacyModeValue(value) {
|
|
453
|
+
return typeof value === 'string' && PRIVACY_MODES.includes(value);
|
|
454
|
+
}
|
|
455
|
+
function isPrivacyMode(value) {
|
|
456
|
+
return PRIVACY_MODES.includes(value);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Legacy local-config privacy values from before alpha 6.13. Kept so
|
|
460
|
+
* `pugi config set privacy=local-only` continues to write to the local
|
|
461
|
+
* config file (matching the bare-form behaviour). Triple-review P2 fix
|
|
462
|
+
* (2026-05-25): the prior disambiguation only excluded the bare form;
|
|
463
|
+
* the `=` form routed to runPrivacySet and 4xx'd on the unknown mode.
|
|
464
|
+
*/
|
|
465
|
+
const LEGACY_LOCAL_PRIVACY_VALUES = [
|
|
466
|
+
'local-only',
|
|
467
|
+
'metadata',
|
|
468
|
+
'full',
|
|
469
|
+
];
|
|
470
|
+
function isLegacyLocalPrivacyValue(value) {
|
|
471
|
+
return (typeof value === 'string' &&
|
|
472
|
+
LEGACY_LOCAL_PRIVACY_VALUES.includes(value));
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* `pugi config get privacy` - fetch the current privacy mode snapshot
|
|
476
|
+
* from /api/admin/privacy/mode + render it in human-readable form.
|
|
477
|
+
*/
|
|
478
|
+
async function runPrivacyGet(ctx) {
|
|
479
|
+
const { apiUrl, apiKey } = resolveAdminApi();
|
|
480
|
+
const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey);
|
|
481
|
+
const lastUpdatedLine = snapshot.lastUpdated
|
|
482
|
+
? `(last set by ${snapshot.lastUpdatedBy ?? 'unknown'} on ${snapshot.lastUpdated})`
|
|
483
|
+
: '(no flips recorded; on implicit default)';
|
|
484
|
+
const text = [
|
|
485
|
+
`privacy.mode = ${snapshot.mode}`,
|
|
486
|
+
`privacy.defaultMode = ${snapshot.defaultMode}`,
|
|
487
|
+
lastUpdatedLine,
|
|
488
|
+
].join('\n');
|
|
489
|
+
ctx.writeOutput({
|
|
490
|
+
command: 'config.privacy.get',
|
|
491
|
+
apiUrl,
|
|
492
|
+
snapshot,
|
|
493
|
+
}, text);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* `pugi config set privacy=<mode>` - PUT to /api/admin/privacy/mode.
|
|
497
|
+
* Validates the mode client-side against the closed set before
|
|
498
|
+
* round-tripping.
|
|
499
|
+
*
|
|
500
|
+
* Accepts both `privacy <mode>` and `privacy=<mode>` argument forms so
|
|
501
|
+
* the operator can type it either way.
|
|
502
|
+
*/
|
|
503
|
+
async function runPrivacySet(args, ctx) {
|
|
504
|
+
const raw = args.join(' ').trim();
|
|
505
|
+
let mode;
|
|
506
|
+
if (raw.startsWith('privacy=')) {
|
|
507
|
+
mode = raw.slice('privacy='.length).trim();
|
|
508
|
+
}
|
|
509
|
+
else if (raw === 'privacy') {
|
|
510
|
+
throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
|
|
511
|
+
}
|
|
512
|
+
else if (raw.startsWith('privacy ')) {
|
|
513
|
+
mode = raw.slice('privacy '.length).trim();
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
throw new Error(`pugi config set privacy: unrecognised argument "${raw}". Try: pugi config set privacy=balanced`);
|
|
517
|
+
}
|
|
518
|
+
if (mode.length === 0) {
|
|
519
|
+
throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
|
|
520
|
+
}
|
|
521
|
+
if (!isPrivacyMode(mode)) {
|
|
522
|
+
throw new Error(`Unknown privacy mode "${mode}". Allowed: ${PRIVACY_MODES.join(', ')}.`);
|
|
523
|
+
}
|
|
524
|
+
const { apiUrl, apiKey } = resolveAdminApi();
|
|
525
|
+
const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey, { method: 'PUT', body: { mode } });
|
|
526
|
+
ctx.writeOutput({
|
|
527
|
+
command: 'config.privacy.set',
|
|
528
|
+
mode: snapshot.mode,
|
|
529
|
+
snapshot,
|
|
530
|
+
}, `privacy.mode = ${snapshot.mode}`);
|
|
531
|
+
}
|
|
396
532
|
/**
|
|
397
533
|
* Thin authenticated fetch helper. Adds the bearer token + accepts JSON +
|
|
398
534
|
* surfaces structured errors. Uses undici `request` (not native `fetch`)
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -6,6 +6,35 @@ import { decidePermission } from '../core/permission.js';
|
|
|
6
6
|
import { createReadRecord, hashContent } from '../core/file-cache.js';
|
|
7
7
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
8
8
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
9
|
+
/**
|
|
10
|
+
* α6.9 WriteGate marker — thrown by `gateOnCancellation` when the
|
|
11
|
+
* caller supplied a cancellation token that has already aborted. The
|
|
12
|
+
* tool dispatch loop in `tool-bridge.ts` recognises the name and folds
|
|
13
|
+
* the throw into a `status: 'aborted'` tool result rather than a hard
|
|
14
|
+
* error so the loop terminates cleanly.
|
|
15
|
+
*/
|
|
16
|
+
export class OperatorAbortedError extends Error {
|
|
17
|
+
constructor(toolName) {
|
|
18
|
+
super(`operator_aborted: ${toolName} refused — operator cancelled the dispatch.`);
|
|
19
|
+
this.name = 'OperatorAbortedError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* α6.9 WriteGate: refuse the tool dispatch when the active
|
|
24
|
+
* cancellation token has aborted. Idempotent (the token's `isAborted`
|
|
25
|
+
* is a getter, no side effects). Returns void on the happy path so the
|
|
26
|
+
* tool can proceed; throws `OperatorAbortedError` when cancelled.
|
|
27
|
+
*
|
|
28
|
+
* The audit trail still gets the call: `recordToolCall` already fired
|
|
29
|
+
* upstream of this guard so the abort + reason are persisted. The
|
|
30
|
+
* matching `recordToolResult` is fired by the caller in its catch
|
|
31
|
+
* block with `status: 'cancelled'` (see existing path for `error`).
|
|
32
|
+
*/
|
|
33
|
+
export function gateOnCancellation(ctx, toolName) {
|
|
34
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
35
|
+
throw new OperatorAbortedError(toolName);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
9
38
|
/**
|
|
10
39
|
* Re-check the permission decision against the *resolved* real path so
|
|
11
40
|
* a workspace-local symlink (`alias -> .env`) cannot bypass the protected
|
|
@@ -42,6 +71,13 @@ function permissionGatedResolve(ctx, inputPath, action, toolName) {
|
|
|
42
71
|
}
|
|
43
72
|
export function readTool(ctx, path) {
|
|
44
73
|
const toolCallId = recordToolCall(ctx.session, 'read', path);
|
|
74
|
+
// α6.9 WriteGate: fail fast on operator cancel BEFORE permission
|
|
75
|
+
// decision so a half-second post-cancel race never lands the read.
|
|
76
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
77
|
+
const reason = 'operator_aborted: read refused';
|
|
78
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
79
|
+
throw new OperatorAbortedError('read');
|
|
80
|
+
}
|
|
45
81
|
const decision = decidePermission({ tool: 'read', kind: 'read', target: path }, ctx.settings, ctx.root);
|
|
46
82
|
if (decision.decision !== 'allow') {
|
|
47
83
|
const reason = `Permission ${decision.decision} for read ${path}: ${decision.reason}`;
|
|
@@ -64,6 +100,14 @@ export function readTool(ctx, path) {
|
|
|
64
100
|
}
|
|
65
101
|
export function writeTool(ctx, path, content) {
|
|
66
102
|
const toolCallId = recordToolCall(ctx.session, 'write', path);
|
|
103
|
+
// α6.9 WriteGate: refuse the write when the operator has cancelled
|
|
104
|
+
// the dispatch. The audit log captures the cancellation reason so a
|
|
105
|
+
// post-mortem can distinguish operator_aborted from settings-deny.
|
|
106
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
107
|
+
const reason = 'operator_aborted: write refused';
|
|
108
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
109
|
+
throw new OperatorAbortedError('write');
|
|
110
|
+
}
|
|
67
111
|
const decision = decidePermission({ tool: 'write', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
68
112
|
if (decision.decision !== 'allow') {
|
|
69
113
|
const reason = `Permission ${decision.decision} for write ${path}: ${decision.reason}`;
|
|
@@ -95,6 +139,15 @@ export function writeTool(ctx, path, content) {
|
|
|
95
139
|
}
|
|
96
140
|
export function editTool(ctx, path, oldString, newString) {
|
|
97
141
|
const toolCallId = recordToolCall(ctx.session, 'edit', path);
|
|
142
|
+
// α6.9 WriteGate: refuse the edit when the operator has cancelled
|
|
143
|
+
// the dispatch. Edits are higher-risk than reads — surface the abort
|
|
144
|
+
// BEFORE we even consult permissions so a cancel-during-tool-loop
|
|
145
|
+
// never partially mutates the workspace.
|
|
146
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
147
|
+
const reason = 'operator_aborted: edit refused';
|
|
148
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
149
|
+
throw new OperatorAbortedError('edit');
|
|
150
|
+
}
|
|
98
151
|
const decision = decidePermission({ tool: 'edit', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
99
152
|
if (decision.decision !== 'allow') {
|
|
100
153
|
const reason = `Permission ${decision.decision} for edit ${path}: ${decision.reason}`;
|
|
@@ -140,6 +193,14 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
140
193
|
}
|
|
141
194
|
export function globTool(ctx, pattern) {
|
|
142
195
|
const toolCallId = recordToolCall(ctx.session, 'glob', pattern);
|
|
196
|
+
// α6.9 WriteGate: cancel-aware short-circuit. Glob is read-only but
|
|
197
|
+
// can be expensive on large trees; respecting the abort here keeps
|
|
198
|
+
// the tool loop responsive when the operator hits Ctrl+C mid-scan.
|
|
199
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
200
|
+
const reason = 'operator_aborted: glob refused';
|
|
201
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
202
|
+
throw new OperatorAbortedError('glob');
|
|
203
|
+
}
|
|
143
204
|
// Pugi globs are workspace-scoped. Reject any pattern that could enumerate
|
|
144
205
|
// outside the workspace:
|
|
145
206
|
// 1. absolute paths (`/etc/**/*`) — globSync resolves these against `/`
|
|
@@ -169,11 +230,28 @@ export function globTool(ctx, pattern) {
|
|
|
169
230
|
}
|
|
170
231
|
export function grepTool(ctx, query) {
|
|
171
232
|
const toolCallId = recordToolCall(ctx.session, 'grep', query);
|
|
233
|
+
// α6.9 WriteGate: refuse before scanning. Grep walks the whole
|
|
234
|
+
// workspace and can take seconds on a large repo; check abort first
|
|
235
|
+
// so a cancel mid-scan returns immediately rather than after the
|
|
236
|
+
// full walk completes.
|
|
237
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
238
|
+
const reason = 'operator_aborted: grep refused';
|
|
239
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
240
|
+
throw new OperatorAbortedError('grep');
|
|
241
|
+
}
|
|
172
242
|
const files = globTool(ctx, '**/*').filter((path) => !path.endsWith('/'));
|
|
173
243
|
const matches = [];
|
|
174
244
|
for (const path of files) {
|
|
175
245
|
if (matches.length >= 200)
|
|
176
246
|
break;
|
|
247
|
+
// α6.9 WriteGate: poll abort inside the file loop so a cancel
|
|
248
|
+
// arriving mid-scan terminates early. The per-file branch keeps
|
|
249
|
+
// the responsiveness bounded by the slowest single-file read.
|
|
250
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
251
|
+
const reason = `operator_aborted: grep stopped mid-scan after ${matches.length} matches`;
|
|
252
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
253
|
+
throw new OperatorAbortedError('grep');
|
|
254
|
+
}
|
|
177
255
|
// Permission gate every file read individually — grep used to bypass
|
|
178
256
|
// `decidePermission` and could surface lines from protected files
|
|
179
257
|
// (.env, *.sql, *.pem, ~/.ssh/**) when invoked from a directory walk.
|
|
@@ -241,6 +319,18 @@ export const BASH_DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
241
319
|
export const BASH_CHILD_MAXBUFFER = 10 * 1024 * 1024;
|
|
242
320
|
export function bashTool(ctx, command, options = {}) {
|
|
243
321
|
const toolCallId = recordToolCall(ctx.session, 'bash', command);
|
|
322
|
+
// α6.9 WriteGate: bash is the highest-risk tool surface. Refuse
|
|
323
|
+
// before the destructive-pattern classifier even runs so a
|
|
324
|
+
// cancelled dispatch never spawns a child process. Note: this is
|
|
325
|
+
// pre-spawn cancellation only; once the /bin/sh -c process is
|
|
326
|
+
// running, the synchronous spawnSync wait blocks until it exits or
|
|
327
|
+
// the 30s timeout fires. Phase 2 will wire SIGTERM forwarding via
|
|
328
|
+
// an async wrapper.
|
|
329
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
330
|
+
const reason = 'operator_aborted: bash refused';
|
|
331
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
332
|
+
throw new OperatorAbortedError('bash');
|
|
333
|
+
}
|
|
244
334
|
const decision = decidePermission({ tool: 'bash', kind: 'bash', target: command }, ctx.settings, ctx.root);
|
|
245
335
|
if (decision.decision !== 'allow') {
|
|
246
336
|
const reason = `Permission ${decision.decision} for bash: ${decision.reason}`;
|