@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.
@@ -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
- privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
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', stub: true },
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': {
@@ -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.18";
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`)
@@ -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}`;