@cruxy/cli 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +62 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/commands/limits.js +76 -0
  10. package/dist/cli/commands/login.js +18 -5
  11. package/dist/cli/commands/pr.js +10 -1
  12. package/dist/cli/commands/rollback.js +10 -2
  13. package/dist/cli/commands/run.js +55 -5
  14. package/dist/cli/commands/sessions.js +156 -0
  15. package/dist/cli/program.js +4 -0
  16. package/dist/cli/repl.js +25 -0
  17. package/dist/cli/session-factory.js +31 -10
  18. package/dist/config/credential-lifetime.js +42 -0
  19. package/dist/config/credentials.js +66 -0
  20. package/dist/config/schema.js +141 -9
  21. package/dist/constants.js +12 -2
  22. package/dist/errors/boundary.js +4 -4
  23. package/dist/errors/constructors.js +136 -57
  24. package/dist/errors/types.js +18 -0
  25. package/dist/index.js +27 -1
  26. package/dist/jobs/manager.js +269 -17
  27. package/dist/limits/cache.js +21 -5
  28. package/dist/mcp/client.js +16 -0
  29. package/dist/onboarding/flow.js +121 -6
  30. package/dist/onboarding/steps.js +112 -0
  31. package/dist/render/limits-report.js +213 -0
  32. package/dist/render/limits-view.js +125 -0
  33. package/dist/sandbox/service.js +9 -0
  34. package/dist/sandbox/types.js +15 -0
  35. package/dist/session/index.js +3 -1
  36. package/dist/session/list.js +20 -6
  37. package/dist/session/log.js +120 -21
  38. package/dist/session/prune.js +106 -0
  39. package/dist/session/resume.js +5 -0
  40. package/dist/subagent/orchestrator.js +71 -31
  41. package/dist/subagent/spawn-tool.js +11 -4
  42. package/dist/tools/schema-depth.js +18 -0
  43. package/dist/tui/limits-panel.js +62 -30
  44. package/dist/usage/collect.js +20 -1
  45. package/dist/usage/summary.js +48 -1
  46. package/dist/usage/types.js +27 -0
  47. package/package.json +2 -2
package/README.md CHANGED
@@ -282,7 +282,7 @@ branch on them:
282
282
  | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`, `CRUXY_E_ROLLBACK_APPROVAL_REQUIRED` |
283
283
 
284
284
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
285
- provider-agnostic, built over `fetch`, with no vendor SDKs.
285
+ cruxy's client for the cruxy gateway, built over `fetch`, with no vendor SDKs.
286
286
 
287
287
  ## License
288
288
 
@@ -50,6 +50,21 @@ async function driveLoop(args, renderer,
50
50
  routed) {
51
51
  const { provider, registry, config, ctx } = args;
52
52
  const { logger } = ctx;
53
+ // THE ONE PER-TURN FIELD ON AN OTHERWISE SESSION-SCOPED CTX (cli#244).
54
+ //
55
+ // `spawn_subagent` needs to reach the collector of the turn that called it, so
56
+ // a child's requests land in that turn's usage record instead of vanishing —
57
+ // `/usage` counted a fan-out as zero while `/budget` counted it in full. The
58
+ // orchestrator is built once per session and the collector is built once per
59
+ // `send`, and this tool call is the only thing that crosses between them.
60
+ //
61
+ // COPIED, NEVER MUTATED. The session hands the same `ctx` object to every turn
62
+ // and every subagent; writing this turn's sink onto it would leave the field
63
+ // pointing at a finished turn's collector for the rest of the session, and
64
+ // entries would then be filed under a run that had already been written out.
65
+ const toolCtx = args.onRequestUsage
66
+ ? { ...ctx, onRequestUsage: args.onRequestUsage }
67
+ : ctx;
53
68
  // Work on a copy so we never mutate the caller's array as a side effect; the
54
69
  // extended history is returned for the caller to adopt. Reassigned wholesale
55
70
  // when the mid-loop compaction seam folds away an older prefix.
@@ -272,7 +287,7 @@ routed) {
272
287
  toolResults.push(blocked);
273
288
  continue;
274
289
  }
275
- const result = await runToolCall(call, registry, ctx);
290
+ const result = await runToolCall(call, registry, toolCtx);
276
291
  renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
277
292
  toolResults.push(result);
278
293
  // after-tool + on-file-change (C.19): fire once the action is done.
@@ -237,7 +237,10 @@ export class Session {
237
237
  // every real model request this turn drives — the main loop, compaction, and
238
238
  // (in plan mode) the propose + execution steps — so usage is captured exactly
239
239
  // where the provider reports it, honestly (unknown when it reports nothing).
240
- const collector = new UsageCollector();
240
+ // `"turn"` is the DEFAULT origin, not a blanket label (cli#244): this same
241
+ // collector also receives every subagent this turn spawns, and those arrive
242
+ // already tagged `"subagent"` so they are not filed as things the user did.
243
+ const collector = new UsageCollector(undefined, "turn");
241
244
  const startedAt = new Date().toISOString();
242
245
  const onReq = (req) => collector.record(req);
243
246
  // Compact *before* the agent call so the turn runs against a bounded history.
@@ -326,13 +329,25 @@ export class Session {
326
329
  const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
327
330
  this.lastRun = record;
328
331
  this.args.onRunUsage?.(record);
329
- // The budget draws down from the SAME record the usage store persists, so
330
- // `/budget` and `/usage` can never report different spends for THIS turn's
331
- // own requests. They can differ by a subagent's worth: the orchestrator
332
- // folds each child's spend into the budget (cli#212 an admission check
333
- // whose own dispatches never move the numerator is not a check) and does not
334
- // write child runs to the store. See the note at that call site.
335
- this.args.budget?.record(record);
332
+ // THE STORE GETS EVERY ENTRY; THE BUDGET GETS ONLY THE TURN'S OWN.
333
+ //
334
+ // Since cli#244 this record also carries the requests of every subagent the
335
+ // turn spawned, which is what finally makes `/usage` and `/budget` agree for
336
+ // one scope. But the orchestrator ALREADY folded each child into the budget
337
+ // the moment that child finished it has to, because the next admission
338
+ // check inside this same turn must see the spend (cli#212/#243), and end of
339
+ // turn is far too late for that. Handing the same entries over a second time
340
+ // here would count a fan-out twice and halve the effective session cap on
341
+ // exactly the turns most likely to need it.
342
+ //
343
+ // So the filter is not a detail: it is the seam between an immediate fold
344
+ // for admission and a deferred one for accounting. Anything that gets its
345
+ // own `budget.record` elsewhere — a subagent here, and a background job or a
346
+ // forced `/compact` in their own records — must be excluded from this one.
347
+ this.args.budget?.record({
348
+ ...record,
349
+ entries: record.entries.filter((e) => e.origin !== "subagent"),
350
+ });
336
351
  // after-run (C.19): advisory by default (a blocking after-run hook throws
337
352
  // and surfaces at the boundary). The turn already completed and its history
338
353
  // is adopted above — an advisory failure never rewrites it.
@@ -424,8 +439,46 @@ export class Session {
424
439
  * cut or the summary call failed.
425
440
  */
426
441
  async compact() {
427
- const { messages, compacted } = await this.runCompaction(this.messages);
442
+ // A FORCED COMPACTION IS A REAL REQUEST, AND IT GETS A RECORD (cli#254).
443
+ //
444
+ // Every other compaction path already threads the caller's sink — the
445
+ // automatic threshold check, the mid-loop seam — and this one dropped it at
446
+ // the top, so the summarize call's tokens reached neither the store nor the
447
+ // budget. `Session.usage` did accumulate them (see `runCompaction`), but
448
+ // nothing reports off that field, so the spend was effectively invisible on
449
+ // a request the user explicitly asked for.
450
+ //
451
+ // IT CANNOT FOLLOW PLAN MODE'S PRECEDENT, which is why this is not the
452
+ // one-line fix it looks like. Plan mode threads `onRequestUsage` into the
453
+ // parent turn's collector because it runs INSIDE `send`, where that
454
+ // collector is a live local. `/compact` is a slash command dispatched
455
+ // between turns: there is no run in flight, so there is no collector to
456
+ // reach. It builds its own and publishes through the two sinks the session
457
+ // already holds — the same shape a background job needs, and for the same
458
+ // reason (no turn to belong to).
459
+ //
460
+ // Origin `"compact"` rather than `"turn"` or nothing. Not `"turn"`: it is
461
+ // not one, and `runCount` would then count a turn the user never took.
462
+ // Not unattributed either — absence means "written by a build before the
463
+ // field existed", and borrowing that bucket would make a current record
464
+ // indistinguishable from history (see `UsageEntry.origin`).
465
+ const collector = new UsageCollector(undefined, "compact");
466
+ const startedAt = new Date().toISOString();
467
+ const { messages, compacted } = await this.runCompaction(this.messages, (req) => collector.record(req));
428
468
  this.messages = messages;
469
+ // Nothing to publish when nothing was spent: `runCompaction` returns early
470
+ // when there is no clean cut, without calling the model at all. An empty
471
+ // record would pad `runCount` and shorten the store's retention window to
472
+ // say that a `/compact` did nothing.
473
+ if (collector.count > 0) {
474
+ const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
475
+ this.args.onRunUsage?.(record);
476
+ // Unfiltered, unlike the turn's fold: these entries are this record's own
477
+ // and nothing else has handed them over. `lastRun` is deliberately NOT
478
+ // moved — the one-shot prints it as the run's usage, and a compaction is
479
+ // not the run the user asked about.
480
+ this.args.budget?.record(record);
481
+ }
429
482
  return compacted;
430
483
  }
431
484
  /**
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { captureExclusion } from "../checkpoint/coverage.js";
2
+ import { ceilingExclusion } from "../checkpoint/coverage.js";
3
3
  /**
4
4
  * Classify a pending tool action into a {@link RiskTier} + a tight session
5
5
  * {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
@@ -48,10 +48,9 @@ export function classify(action, cwd) {
48
48
  *
49
49
  * • `capture.ts` enumerates with `git ls-files --cached --others
50
50
  * --exclude-standard` — tracked + untracked-non-ignored files **under one
51
- * root** — and then filters that list through
52
- * {@link captureExclusion}. So: nothing outside a declared root, nothing
53
- * gitignored, and nothing under `.git/`, `node_modules/`, `.cruxy/`, or the
54
- * secrets denylist.
51
+ * root** — and then filters that list through `captureExclusion`. So:
52
+ * nothing outside a declared root, nothing gitignored, and nothing under
53
+ * `.git/`, `node_modules/`, `.cruxy/`, or the secrets denylist.
55
54
  * • Only file *content* and the executable bit ({@link CaptureFile.mode} is
56
55
  * `100644` or `100755`). Not the rest of the permission word, not ownership,
57
56
  * not flags. And regular files only — a symlink is skipped on capture and
@@ -74,6 +73,18 @@ export function classify(action, cwd) {
74
73
  * the capturer itself filters on, so the ceiling cannot drift from the snapshot
75
74
  * again.
76
75
  *
76
+ * ── Stricter than the snapshot, on purpose (cli#241, the narrow slice) ──
77
+ * The rules read {@link ceilingExclusion}, not `captureExclusion`. The two differ
78
+ * by `CEILING_ONLY`: a short list of filename classes the capturer should keep
79
+ * snapshotting when a repo tracks them, and auto-approve should stop assuming it
80
+ * can put back — Terraform state, a local database, a `*.local.*` config. Those
81
+ * are the paths that are usually gitignored (so in no snapshot AND in no commit)
82
+ * and whose contents no build, registry, or `.tf` file can reconstruct.
83
+ *
84
+ * It is asymmetric by design. Widening `captureExclusion` to cover them would
85
+ * make the *snapshot* skip a `dist/` or a `terraform.tfstate` that a repo tracks,
86
+ * closing a prompt by opening a hole. Widening the ceiling costs one prompt.
87
+ *
77
88
  * ── Braces and belt ──
78
89
  * The rules that fail *closed*, needing no list to be correct: an unprovable
79
90
  * command (any shell metacharacter); an argument resolving outside the root; an
@@ -83,16 +94,65 @@ export function classify(action, cwd) {
83
94
  *
84
95
  * {@link ESCAPES_ROOT} and {@link MUTATES_UNCAPTURED_STATE} are the belt: two
85
96
  * denylists of program names, and denylists lose — `node deploy.js` still
86
- * reaches the network (cli#194), and `install -m 000` still evades the
87
- * permission rule. A name is a prediction about what a program will do, so it
88
- * cannot fail closed by construction. The end of that argument is confinement
89
- * keyed on the *mode* rather than the program, which cli#194 tracks; these lists
90
- * are what holds until then, not a sandbox.
97
+ * reaches the network, and `install -m 000` still evades the permission rule. A
98
+ * name is a prediction about what a program will do, so it cannot fail closed by
99
+ * construction. Why the belt is still a denylist, and what the alternative
100
+ * would and would not have bought, is the cli#194 section at the end of this
101
+ * block.
102
+ *
103
+ * One residual is deliberate, named, and **accepted rather than pending**:
104
+ * generic **gitignored** paths (`rm -rf dist` in a repo that ignores `dist/`)
105
+ * cannot be decided from a path alone — it needs the repo's ignore files, and
106
+ * this classifier is pure and synchronous by design. cli#241 records why the
107
+ * remedies are worse than the residual, and why the permanent-loss subset was
108
+ * split out into `CEILING_ONLY` instead. The cost is paid in both directions and
109
+ * both were taken knowingly: `rm -rf dist` in a repo that ignores `dist/` still
110
+ * auto-runs (regenerable — that is the whole selection rule), and a
111
+ * `CEILING_ONLY` path the repo actually *tracks* — a committed
112
+ * `terraform.tfstate`, a `*.sqlite` test fixture — is in the snapshot and prompts
113
+ * anyway, because a path-only rule cannot tell that repo from the one that
114
+ * ignores it.
115
+ *
116
+ * ── Why the belt stays a denylist (cli#194, accepted, not built) ──
117
+ * The end of the denylists-lose argument is a rule keyed on the *mode* rather
118
+ * than the program: an auto-approving mode may only run what it can confine, so
119
+ * `node deploy.js` reaches no registry because there is no route — not because
120
+ * we recognised a name. That shape is right, for the reason every other rule
121
+ * here is right: it is a fact about our own configuration, decidable before a
122
+ * command is even read, and it fails closed by construction because an
123
+ * unconfigured sandbox means the mode is simply unavailable. It is not being
124
+ * built, and both reasons live here rather than in the issue, because this is
125
+ * the file that would have changed.
126
+ *
127
+ * **Confinement is not equivalent to the host.** `sandbox/policy.ts:36-40`
128
+ * bind-mounts the cwd read-write at its identical absolute path, so the
129
+ * container inherits the *host's* `node_modules`: darwin-arm64 `.node` binaries
130
+ * loaded under a linux/arm64 image, with `--network none` forbidding the
131
+ * reinstall that would fix them, a `--read-only` rootfs, and a 64m `/tmp`. This
132
+ * repo's own suite goes red in the box. cli#194's own argument then turns on
133
+ * it — a gate people read as noise is a gate they stop reading, and a mode that
134
+ * reddens your suite is a mode nobody arms.
135
+ *
136
+ * **And the rule would disarm the mode by default.** `sandbox.enabled` is
137
+ * `false` (`config/schema.ts:417`), so "no confinement, no auto-approval" makes
138
+ * auto-approve unavailable-by-default for every user on every platform until
139
+ * they stand up a container runtime. That is the product decision the issue was
140
+ * actually asking for, and the answer is no.
141
+ *
142
+ * What confinement WOULD have bought, stated precisely so the residual is not
143
+ * over-claimed: for the **shell + test execution surface only**, `ESCAPES_ROOT`
144
+ * stops being load-bearing on the auto-approve path. Nothing else. `ctx.sandbox`
145
+ * is consulted at `tools/shell/exec.ts:80` and `testing/run-tests-tool.ts:196`
146
+ * and nowhere else — `write_file`, `edit_file`, and `apply_patch` execute on the
147
+ * host in every mode, confined or not.
91
148
  *
92
- * One residual is deliberate and named: generic **gitignored** paths (`rm -rf
93
- * dist`) cannot be decided from a path alone it needs the repo's ignore files,
94
- * and this classifier is pure and synchronous by design. See
95
- * {@link captureExclusion} for why that tail is the low-value one.
149
+ * Nor does the belt demote to a UX nicety even for that surface, because
150
+ * `irreversible` gates the GRANT path as well as the auto path:
151
+ * `SessionAllowlist.allows` refuses every irreversible request (cli#193), and
152
+ * manual mode is never confined. A `curl` misclassified as reversible would let
153
+ * "allow `curl` this session" cover every later `curl` — unprompted, on the
154
+ * host, for the rest of the session. These lists stay security-relevant on the
155
+ * grant path in every mode, sandbox or no sandbox.
96
156
  */
97
157
  /**
98
158
  * `git` subcommands that only read. Everything else `git` does is irreversible
@@ -256,10 +316,12 @@ function shellIrreversibility(command, root) {
256
316
  * Decidable from the SCOPE ALONE, which is the whole reason this can be a
257
317
  * function rather than a guess:
258
318
  *
259
- * • `mcp-tool` — {@link mcpRequest} marks every MCP call irreversible
260
- * unconditionally, so no request can clear the ceiling. Blocked, not dead
261
- * code: the fix is #239's per-tool judgement, and on the day that lands
262
- * these grants start working with no change here.
319
+ * • `mcp-tool` — {@link mcpRequest} marks every MCP call irreversible, so no
320
+ * request can clear the ceiling and no such grant can ever match. Settled
321
+ * rather than pending (#239, closed as accepted): the arguments of any MCP
322
+ * call reach a process no checkpoint reaches, which is as true of
323
+ * `read_file_content` as of `slack_send_message`. Permanently inert by
324
+ * design, not waiting on a fix.
263
325
  * • `shell-exact` (a test grant) — the scope carries the exact command and
264
326
  * root, and {@link testRequest} derives `irreversible` from those same two
265
327
  * values through this same function. Same inputs, same verdict, every time:
@@ -278,7 +340,7 @@ function shellIrreversibility(command, root) {
278
340
  */
279
341
  export function deadGrantReason(scope) {
280
342
  if (scope.kind === "mcp-tool") {
281
- return "every MCP call is classified irreversible, so the ceiling refuses this grant before it is ever matched (cli#239)";
343
+ return "every MCP call is irreversible — its arguments reach a process no checkpoint can reach — so the ceiling refuses this grant before it is ever matched";
282
344
  }
283
345
  if (scope.kind === "shell-exact") {
284
346
  return shellIrreversibility(scope.command, scope.root);
@@ -318,8 +380,10 @@ function argIrreversibility(arg, root) {
318
380
  * checkpoint could not restore `abs`, or `null` when it could. `label` is what
319
381
  * the message names — the token the user actually typed, for a shell argument.
320
382
  *
321
- * Three ways a path falls outside the snapshot, and the last two are cli#241:
322
- * being outside the root was never the only one.
383
+ * Four ways a path falls outside what a restore can put back, and the last three
384
+ * are cli#241: being outside the root was never the only one. The fourth is the
385
+ * ceiling being deliberately stricter than the snapshot — see
386
+ * {@link ceilingExclusion}.
323
387
  */
324
388
  function pathIrreversibility(abs, root, label = abs) {
325
389
  if (!isInside(root, abs)) {
@@ -332,10 +396,11 @@ function pathIrreversibility(abs, root, label = abs) {
332
396
  return `\`${label}\` names the workspace root itself, which contains paths the checkpoint does not capture (\`.git/\`, ignored output, secrets)`;
333
397
  }
334
398
  const rel = path.relative(root, abs).split(path.sep).join("/");
335
- const excluded = captureExclusion(rel);
336
- return excluded
337
- ? `\`${label}\` is not captured by the checkpoint ${excluded}`
338
- : null;
399
+ // A predicate phrase, not a bare reason: the ceiling distinguishes "the
400
+ // snapshot never holds this" from "the snapshot may hold this and must not be
401
+ // relied on to", and only it knows which one fired.
402
+ const verdict = ceilingExclusion(rel);
403
+ return verdict ? `\`${label}\` ${verdict}` : null;
339
404
  }
340
405
  /**
341
406
  * Why the checkpoint cannot restore a file action, or `null` when it can.
@@ -500,12 +565,80 @@ function rollbackRequest(action, root) {
500
565
  const ROLLBACK_IRREVERSIBLE = "a rollback overwrites the working tree and is not itself checkpointed, so the current state would be unrecoverable";
501
566
  // ── mcp (call an external MCP server's tool, C.27) ──────────────────────────────
502
567
  /**
503
- * An MCP tool call. Always `destructive` the tool is arbitrary code in a
504
- * trusted-but-external server, running UNSANDBOXED. Crucially, the classifier
505
- * NEVER consults the server's `readOnlyHint` (or any server-supplied
506
- * annotation): a server cannot mark its own tool low-risk, so this can only ever
507
- * be destructive. Grantable at the tightest scope — the exact server+tool pair —
508
- * so approving one MCP tool for the session never widens to any other tool.
568
+ * An MCP tool call. Always `destructive`, and always `irreversible`. The second
569
+ * of those was the contested one; #239 is where it was settled, and this is the
570
+ * argument, kept here rather than in a ticket because a closed ticket is not
571
+ * where the next person will look.
572
+ *
573
+ * WHY ONE VERDICT FOR EVERY TOOL IS THE ACCURATE ONE, NOT THE LAZY ONE. It reads
574
+ * like the classifier declining to judge: `slack_send_message` cannot be unsent
575
+ * and `read_file_content` changes nothing, yet both come back irreversible. But
576
+ * the question this field asks is not "does the tool mutate something" — it is
577
+ * "does the checkpoint taken before this action provably contain everything the
578
+ * action can damage" (see {@link ApprovalRequest.irreversible}). Every MCP call,
579
+ * read or write, hands its ARGUMENTS to a process cruxy neither controls nor can
580
+ * roll back: another machine for a `url` server, an unsandboxed child for stdio.
581
+ * Data that has left cannot be recalled and no checkpoint contains it. That
582
+ * effect is identical for every tool on every server, so a verdict identical for
583
+ * every tool is the one that matches the facts. The judgement is per-tool
584
+ * INVARIANT, which is not the same as absent.
585
+ *
586
+ * The honest limit: a stdio server doing pure local arithmetic exfiltrates
587
+ * nothing, and for that one the blanket verdict really is too strong. Telling it
588
+ * apart from the others is exactly what cannot be done from here — and this
589
+ * file's cardinal rule already says what to do with an action it cannot bound.
590
+ *
591
+ * WHY NOT THE SERVER'S OWN ANNOTATIONS. MCP defines `readOnlyHint`,
592
+ * `destructiveHint`, and `idempotentHint`. Cruxy reads none of them, at three
593
+ * layers: `McpClient`'s `RawToolSchema` accepts only name/description/inputSchema
594
+ * so zod strips `annotations` at the wire boundary, `mcp/adapter.ts` forwards no
595
+ * hint, and nothing carrying one reaches this function. Deliberate, pinned by
596
+ * test, and for four reasons that stack:
597
+ *
598
+ * 1. THE SPEC SAYS NOT TO. "Clients MUST consider tool annotations to be
599
+ * untrusted unless they come from trusted servers."
600
+ * 2. CRUXY CANNOT PRODUCE A "TRUSTED SERVER" IN THE SENSE THAT CARVE-OUT
601
+ * NEEDS. `fingerprintMcpServers` hashes the INVOCATION — server id,
602
+ * `command`, `args`, `url`, `env`, `credentialRef`, header names — and not
603
+ * the code. A trusted stdio server whose package auto-updates keeps an
604
+ * identical fingerprint while running entirely different code, and
605
+ * `mcp/types.ts` says the equivalent for a url server's behavior. Trust here
606
+ * means "I accept running this", never "I have checked what it says about
607
+ * itself".
608
+ * 3. IT WOULD MAKE THE TOOL LIST A PRIVILEGE-GRANTING CHANNEL. Today a lying
609
+ * server gets its lie demarcated as untrusted data and nothing more. If one
610
+ * boolean in that same list decided whether the ceiling applies, a server
611
+ * could set `readOnlyHint: true`, collect a single "allow re-calls of this
612
+ * tool", and run unprompted from then on — and `notifications/tools/
613
+ * list_changed` lets it re-advertise mid-session. The party being gated
614
+ * would control the gate.
615
+ * 4. EVEN AN HONEST ANNOTATION ANSWERS A DIFFERENT QUESTION. `readOnlyHint`
616
+ * describes effects on the SERVER's state. It says nothing about the
617
+ * arguments travelling to it, which is the effect above — and the spec names
618
+ * that risk separately in its own client guidance: "Show tool inputs to the
619
+ * user before calling the server, to avoid malicious or accidental data
620
+ * exfiltration."
621
+ *
622
+ * WHAT WAS CONSIDERED INSTEAD. Tool-name heuristics are worse than the denylist
623
+ * residual tracked in #194, not merely as bad: there the program name comes from
624
+ * the user's own command, here the string is chosen by the untrusted server, so a
625
+ * server that wants a standing grant simply names its tool `read_status`. A
626
+ * user-declared per-tool reversibility list is the only option grounded in a user
627
+ * assertion rather than a server claim, but the user's evidence for the assertion
628
+ * is the server's own description of the tool, it would have to be user-scope
629
+ * only (a cloned repo must not ship its own reversibility assertions — the rule
630
+ * `headers` already lives under), and described plainly it is auto-approve in a
631
+ * different coat. A first-use "is this reversible?" prompt is worse again: a
632
+ * second prompt stacked on the approval prompt, asking a question the user cannot
633
+ * answer better than this function can, whose answer would have to be persisted —
634
+ * and `mcp-trust.json` is deliberately the ONLY persisted security decision in
635
+ * the product. The one approach that would actually hold is to stop predicting
636
+ * and start observing — sandbox stdio servers with egress denied — which is the
637
+ * same answer #194 reaches, and which does not apply to url servers at all.
638
+ *
639
+ * Grantable at the tightest scope — the exact server+tool pair — so approving one
640
+ * MCP tool never widens to any other. That scope is permanently inert as a
641
+ * consequence of the verdict above; {@link Scope} explains why it is kept anyway.
509
642
  */
510
643
  function mcpRequest(action, root) {
511
644
  const server = action.server ?? "";
@@ -520,15 +653,12 @@ function mcpRequest(action, root) {
520
653
  summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
521
654
  targets: [],
522
655
  cwd: root,
523
- // Same reasoning as the tier: the server cannot vouch for itself. A
524
- // `readOnlyHint` is a claim by the thing being gated, and the checkpoint
525
- // cannot reach whatever it did anyway.
526
- //
527
- // But it is unconditional, which is this classifier declining to judge
528
- // and since cli#193 (the ceiling outranks grants) that verdict is what makes
529
- // the `mcp-tool` scope above unreachable. Tracked in #239; the fix is a
530
- // per-tool judgement here, never a ceiling carve-out for MCP.
531
- irreversible: "an MCP tool runs unsandboxed in an external server, so its effects are outside the checkpoint entirely",
656
+ // Per-tool INVARIANT, not per-tool absent: the arguments leave for a process
657
+ // the checkpoint cannot reach whatever the tool does with them afterwards.
658
+ // The reason names the arguments explicitly, because "it ran externally" is
659
+ // the half a user already assumed and the sent data is the half they may not
660
+ // have. Never a ceiling carve-out for MCP see the block above.
661
+ irreversible: "an MCP tool runs unsandboxed in an external server, so its effects — including the arguments it was sent are outside the checkpoint entirely",
532
662
  };
533
663
  }
534
664
  // ── file (write / edit / patch) ────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import { renderActionPreview } from "../render/diff.js";
4
4
  import { resolveColumns } from "../render/capabilities.js";
5
5
  import { fitMiddle, reflow, visibleWidth } from "../render/layout.js";
6
6
  import { themeForColor } from "../theme/index.js";
7
+ import { deadGrantReason } from "./classify.js";
7
8
  /**
8
9
  * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
9
10
  * read a follow-up line (reason / instruction). Anything else — including EOF —
@@ -175,13 +176,58 @@ function detail(request, t, width) {
175
176
  }
176
177
  return renderActionPreview(request.action.preview, t, width);
177
178
  }
178
- /** The choices line, including a short label of what an `a` grant would cover. */
179
+ /**
180
+ * The choices line, including a short label of what an `a` grant would cover —
181
+ * and, when it would cover nothing, saying so instead of offering it.
182
+ *
183
+ * THREE STATES, NOT TWO. A scope is unnameable (`none` — there is nothing to
184
+ * grant), or nameable and spendable, or nameable and **inert**: a grant the
185
+ * allowlist will happily record and {@link SessionAllowlist.allows} can never
186
+ * honor, because every action the scope could match is refused by the ceiling
187
+ * before any scope matching happens (cli#193). This line rendered the third
188
+ * exactly like the second, so the user consented to something that bought
189
+ * nothing and only found out at the NEXT prompt, from the #193 override line.
190
+ * That ordering is backwards: the override line explains a grant already given,
191
+ * and the place to decline a grant is the place it is asked for.
192
+ *
193
+ * THE PREDICATE IS {@link deadGrantReason}, NOT `request.irreversible`, and the
194
+ * difference is the whole correctness of this function. `irreversible` is about
195
+ * THIS action; a dead grant is about whether the SCOPE can ever match a
196
+ * reversible one. They part company on `shell-prefix`: `git push --force` is
197
+ * irreversible, so {@link whyAsking} fires — but a `git` grant still covers
198
+ * `git log`, so it is narrow, not dead, and must stay on offer. Gating this line
199
+ * on `irreversible` would withdraw a live grant every time the action in front
200
+ * of it happened to be one the grant could not have covered anyway.
201
+ *
202
+ * THE REASON IS NOT REPEATED HERE. A grant is inert only because its actions are
203
+ * irreversible, so `whyAsking` has already printed the concrete reason one line
204
+ * up. A second copy on the choices line would add no fact and spend the width
205
+ * that keeps this line readable at 80 columns.
206
+ */
179
207
  function choices(scope, t) {
180
- const grant = scopeLabel(scope);
181
- const a = grant === null
182
- ? `${t.muted("[a] allow this kind (n/a here)")}`
183
- : `[a] allow ${t.strong(grant)} this session`;
184
- return ` ${t.muted(`[y] approve once ${t.glyph.sep}`)} ${a} ${t.muted(`${t.glyph.sep} [n] reject ${t.glyph.sep} [t] reject & instruct:`)}`;
208
+ return ` ${t.muted(`[y] approve once ${t.glyph.sep}`)} ${grantChoice(scope, t)} ${t.muted(`${t.glyph.sep} [n] reject ${t.glyph.sep} [t] reject & instruct:`)}`;
209
+ }
210
+ /**
211
+ * The `[a]` clause alone: a live offer, or a muted statement that there is none.
212
+ *
213
+ * An inert grant is still NAMED. "(n/a here)" is right for `none`, where there
214
+ * was never anything to describe, but a user looking at an MCP call or a `curl`
215
+ * is owed the specific thing being declined — otherwise the line reads as the
216
+ * prompt having no opinion rather than a considered "not this one". The label is
217
+ * {@link scopeLabel}'s, so the words match everywhere a grant is ever spoken of.
218
+ */
219
+ function grantChoice(scope, t) {
220
+ const nothingToGrant = t.muted("[a] allow this kind (n/a here)");
221
+ if (scope.kind === "none")
222
+ return nothingToGrant;
223
+ const label = scopeLabel(scope);
224
+ // Unreachable — `scopeLabel` answers null only for `none` — but this is the
225
+ // narrowing that lets the compiler prove it, not a guess about behavior.
226
+ if (label === null)
227
+ return nothingToGrant;
228
+ if (deadGrantReason(scope) !== null)
229
+ return t.muted(`[a] allow ${label} (n/a — this always asks)`);
230
+ return `[a] allow ${t.strong(label)} this session`;
185
231
  }
186
232
  /**
187
233
  * Short human label for what a session grant covers, or null when a scope
@@ -1,19 +1,13 @@
1
1
  import { approvalRequired } from "../errors/index.js";
2
- import { shouldUseColor } from "../errors/index.js";
3
2
  import { classify } from "./classify.js";
4
- import { InteractivePolicy, SessionAllowlist } from "./policy.js";
5
- import { defaultPromptIO } from "./prompt.js";
6
3
  export class ApprovalService {
7
4
  cwd;
8
5
  interactive;
9
- allowlist = new SessionAllowlist();
10
6
  policy;
11
7
  constructor(opts) {
12
8
  this.cwd = opts.cwd;
13
9
  this.interactive = opts.interactive;
14
- this.policy =
15
- opts.policy ??
16
- new InteractivePolicy(this.allowlist, opts.io ?? defaultPromptIO(shouldUseColor()));
10
+ this.policy = opts.policy;
17
11
  }
18
12
  /**
19
13
  * Decide whether `action` may proceed. Read-only ⇒ allow. Non-interactive ⇒
@@ -30,8 +24,4 @@ export class ApprovalService {
30
24
  }
31
25
  return this.policy.decide(request);
32
26
  }
33
- /** Clear all session grants (a fresh session). */
34
- resetSession() {
35
- this.allowlist.clear();
36
- }
37
27
  }
@@ -206,13 +206,22 @@ export class SessionBudget {
206
206
  // with its token cap cut to what the allowance covers — refusing outright
207
207
  // would leave a user with real headroom unable to ask anything at all. A
208
208
  // fan-out of many, though, is narrowed to exactly one.
209
+ //
210
+ // "RUN", NOT "TURN" (cli#245). `count: 1` used to mean a turn and the reason
211
+ // said so; it now also reaches here from a lone `spawn` and from a
212
+ // background job, and this is a SHARED decision object — one string handed
213
+ // to whoever asked. Naming the caller would make the sentence false for two
214
+ // of the three, and branching on which one asked would put caller knowledge
215
+ // inside the one place that deliberately has none. `run` is the word this
216
+ // module already uses for all three (see `AdmissionRequest.count`), and it
217
+ // is true of each.
209
218
  const cap = Math.max(1, Math.floor(allowance.weighted / multiplier));
210
219
  if (req.count === 1) {
211
220
  return {
212
221
  kind: "narrowed",
213
222
  count: 1,
214
223
  maxTokens: cap,
215
- reason: `capped this turn at ${compactTokens(cap)} tokens — ` +
224
+ reason: `capped this run at ${compactTokens(cap)} tokens — ` +
216
225
  `${allowanceLabel(allowance)} leaves room for about that much on ${req.tier}`,
217
226
  };
218
227
  }