@cruxy/cli 1.7.1 → 1.8.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.
@@ -131,6 +131,7 @@ routed) {
131
131
  // routing, which is why the fallback below still exists.
132
132
  let servedTier;
133
133
  let routingMode;
134
+ let reasoningEffort;
134
135
  // Live progress while waiting on the model; dismissed by the first delta.
135
136
  // Token context is whatever the loop has actually accumulated (U.4): zero
136
137
  // on the first turn → no figure shown, never a fabricated number.
@@ -151,6 +152,7 @@ routed) {
151
152
  case "routing":
152
153
  servedTier = ev.routing.tier;
153
154
  routingMode = ev.routing.mode;
155
+ reasoningEffort = ev.routing.reasoning_effort;
154
156
  // Publish to the renderer HERE, not at the `setPhase` above. That one
155
157
  // fires before `provider.stream` is called, so the served tier is not
156
158
  // knowable yet and it can only carry `routed?.tier` — what this run
@@ -160,6 +162,12 @@ routed) {
160
162
  renderer?.servedRouting({
161
163
  tier: ev.routing.tier,
162
164
  ...(ev.routing.mode !== undefined ? { mode: ev.routing.mode } : {}),
165
+ // Conditionally spread like `mode`, and for the same reason: a
166
+ // gateway that reported no effort must reach the surfaces as
167
+ // ABSENT, not as an empty string they would then draw.
168
+ ...(ev.routing.reasoning_effort !== undefined
169
+ ? { reasoningEffort: ev.routing.reasoning_effort }
170
+ : {}),
163
171
  });
164
172
  break;
165
173
  case "text_delta":
@@ -219,6 +227,7 @@ routed) {
219
227
  args.onRequestUsage?.({
220
228
  tier: servedTier ?? routed?.tier,
221
229
  ...(routingMode !== undefined ? { routingMode } : {}),
230
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
222
231
  usage: sawUsage ? { ...reqUsage } : undefined,
223
232
  });
224
233
  // ── Record the assistant turn ───────────────────────────────────────────
@@ -0,0 +1,72 @@
1
+ import { deadGrantReason, scopeLabel } from "../approval/index.js";
2
+ import { modeDescription, modeAutoApproves } from "./mode.js";
3
+ /**
4
+ * Assembling the permissions screen (cli#196) — one builder, two callers.
5
+ *
6
+ * The same shape `agent/status.ts` uses, for the same reason: `/permissions`
7
+ * and the Permissions view show one screen, and the surest way to keep them
8
+ * from drifting is for there to be nothing to drift. Both call this;
9
+ * `render/permissions-view.ts` renders whatever it returns.
10
+ *
11
+ * WHAT IS NOT HERE: any probe, and any second opinion. The mode comes from the
12
+ * session, the grants come from the allowlist the session's policies actually
13
+ * spend, each label comes from the prompt's own `scopeLabel`, and each inert
14
+ * mark comes from the classifier's own rules. Nothing on this screen is
15
+ * re-derived from a copy — a permissions view that disagreed with the gate
16
+ * would be worse than no view at all.
17
+ */
18
+ /**
19
+ * Where a grant applies, as one path.
20
+ *
21
+ * Every scope kind spells this `root`, and the two meanings behind that one
22
+ * field are worth knowing when reading a row: the three command/tool kinds are
23
+ * BOUND to the root they were taken in (C.26), while a `file-subtree` IS its
24
+ * path. The column answers the same question for both — "where does this let it
25
+ * happen" — which is why one column is right.
26
+ */
27
+ function grantWhere(grant) {
28
+ return grant.scope.root;
29
+ }
30
+ /** One grant as a row, numbered by its position in `list()`. */
31
+ function grantRow(grant, index) {
32
+ const dead = deadGrantReason(grant.scope);
33
+ return {
34
+ // 1-based: the number is typed by a human at `/permissions revoke <n>`, and
35
+ // `revoke` takes the array index — the conversion happens once, at the
36
+ // command, and never in the middle of a report.
37
+ n: index + 1,
38
+ tier: grant.tier,
39
+ // `scopeLabel` returns null only for `scope: "none"`, which an allowlist
40
+ // never holds (`grant()` refuses it) — so the fallback is unreachable
41
+ // rather than a case with a sensible answer.
42
+ label: scopeLabel(grant.scope) ?? "an unnameable scope",
43
+ where: grantWhere(grant),
44
+ ...(dead === null ? {} : { dead }),
45
+ };
46
+ }
47
+ /** Everything `/permissions` and the Permissions view show, from live state. */
48
+ export function permissionsReport(session) {
49
+ const mode = session.getMode();
50
+ const allowlist = session.allowlist;
51
+ return {
52
+ mode,
53
+ modeDescription: modeDescription(mode),
54
+ autoApproves: modeAutoApproves(mode),
55
+ // Absent, not empty, when nothing is wired — see `PermissionsReport.grants`.
56
+ ...(allowlist ? { grants: allowlist.list().map(grantRow) } : {}),
57
+ };
58
+ }
59
+ /**
60
+ * The one-line confirmation for a revoked grant, so the command and any future
61
+ * caller say the same thing about what just happened.
62
+ *
63
+ * It names the grant in the words it was listed under and states the limit in
64
+ * the same breath: revoking means no FUTURE lookup matches it. An action
65
+ * already past the gate keeps the decision it was given, and a user who is
66
+ * revoking because something is running deserves to be told that here rather
67
+ * than to infer it from a command that says only "revoked".
68
+ */
69
+ export function revokedLine(grant) {
70
+ const label = scopeLabel(grant.scope) ?? "that grant";
71
+ return `revoked: ${label} (${grantWhere(grant)}) — it will ask again next time; anything already approved keeps running`;
72
+ }
@@ -154,6 +154,14 @@ export class Session {
154
154
  get toolRegistry() {
155
155
  return this.args.registry;
156
156
  }
157
+ /**
158
+ * The standing session grants (cli#196), or undefined when no allowlist was
159
+ * wired. `/permissions` reads and revokes through this; nothing else on the
160
+ * shell side touches it.
161
+ */
162
+ get allowlist() {
163
+ return this.args.allowlist;
164
+ }
157
165
  /** The background-job manager (C.28), or undefined when jobs are disabled.
158
166
  * The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
159
167
  * run` uses it to cancel every live job on exit. */
@@ -213,6 +221,14 @@ export class Session {
213
221
  // model iterations WITHIN this turn — it just never leaks into the next one.
214
222
  for (const tool of this.args.registry.list())
215
223
  tool.onTurnStart?.();
224
+ // Re-read the account's headroom (P9 / cli#212), on EVERY turn and on every
225
+ // surface — the REPL and headless included, neither of which has a rail to
226
+ // have justified the probe before. It is fired here rather than after the
227
+ // turn for two reasons: it overlaps the turn instead of the process's exit,
228
+ // so a one-shot never waits on a status read to finish; and it lands while
229
+ // this turn is still running, which is when the fan-out seam asks. Never
230
+ // awaited — see `SessionBudget.refreshLimits`.
231
+ this.args.budget?.refreshLimits();
216
232
  this.messages.push({ role: "user", content: userPrompt });
217
233
  // Record the user's turn before anything can fail (P2): a turn that dies in
218
234
  // the provider still leaves what the user asked for on disk.
@@ -311,7 +327,11 @@ export class Session {
311
327
  this.lastRun = record;
312
328
  this.args.onRunUsage?.(record);
313
329
  // The budget draws down from the SAME record the usage store persists, so
314
- // `/budget` and `/usage` can never report different spends for one turn.
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.
315
335
  this.args.budget?.record(record);
316
336
  // after-run (C.19): advisory by default (a blocking after-run hook throws
317
337
  // and surfaces at the boundary). The turn already completed and its history
@@ -508,6 +528,7 @@ export class Session {
508
528
  let sawUsage = false;
509
529
  let servedTier;
510
530
  let routingMode;
531
+ let reasoningEffort;
511
532
  for await (const ev of this.args.provider.stream({
512
533
  system: SUMMARY_SYSTEM,
513
534
  messages: [{ role: "user", content: transcript }],
@@ -517,6 +538,7 @@ export class Session {
517
538
  case "routing":
518
539
  servedTier = ev.routing.tier;
519
540
  routingMode = ev.routing.mode;
541
+ reasoningEffort = ev.routing.reasoning_effort;
520
542
  break;
521
543
  case "text_delta":
522
544
  text += ev.text;
@@ -536,9 +558,16 @@ export class Session {
536
558
  // Attribute this compaction request to the tier that served it — the
537
559
  // gateway's answer over the `summarize` tier this run asked for, same
538
560
  // precedence and same reasoning as the main loop.
561
+ //
562
+ // Compaction has no live surface of its own — it runs between turns, with
563
+ // no renderer to publish routing to — so the usage record is the ONLY place
564
+ // its effort can land. That matters more here than on the main loop: a
565
+ // summarize call reasoning at `high` is exactly the kind of silent spend a
566
+ // per-request record exists to expose.
539
567
  onRequestUsage?.({
540
568
  tier: servedTier ?? routed?.tier,
541
569
  ...(routingMode !== undefined ? { routingMode } : {}),
570
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
542
571
  usage: sawUsage ? { ...usage } : undefined,
543
572
  });
544
573
  if (!text.trim())
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { captureExclusion } from "../checkpoint/coverage.js";
2
3
  /**
3
4
  * Classify a pending tool action into a {@link RiskTier} + a tight session
4
5
  * {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
@@ -47,29 +48,51 @@ export function classify(action, cwd) {
47
48
  *
48
49
  * • `capture.ts` enumerates with `git ls-files --cached --others
49
50
  * --exclude-standard` — tracked + untracked-non-ignored files **under one
50
- * root**. So: nothing outside a declared root, and nothing gitignored
51
- * (`node_modules/`, build output, `.env`).
52
- * `git-store.ts` states it outright: "no ref is ever created or moved, and
53
- * HEAD / the index / the stash are never written." `.git/` is never
54
- * enumerated at all. So: **no git history, refs, branches, or stash.**
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.
55
+ * Only file *content* and the executable bit ({@link CaptureFile.mode} is
56
+ * `100644` or `100755`). Not the rest of the permission word, not ownership,
57
+ * not flags. And regular files only — a symlink is skipped on capture and
58
+ * restore writes plain files.
55
59
  * • Nothing off-disk. A checkpoint cannot un-send a push, un-publish a
56
60
  * package, or un-call an HTTP endpoint.
57
61
  *
58
- * Everything a checkpoint DOES cover, it covers completely: `withCheckpointGate`
59
- * snapshots the whole root before the first mutation of a run, so arbitrary
60
- * in-root file damage — including `rm -rf` of the workspace is restorable.
61
- * That is why the rules key on *escape* (out of the root, into `.git`, onto the
62
- * network) rather than on how destructive an in-root command looks.
62
+ * ── The correction (cli#241) ──
63
+ * The rules used to key on *root containment*: inside the workspace meant
64
+ * restorable, because `withCheckpointGate` snapshots the whole root before a
65
+ * run's first mutation. But "the whole root" was never true the capturer had
66
+ * always excluded the four classes above, all of which live *inside* the root.
67
+ * Inside-the-root and captured-by-the-checkpoint are different sets, and every
68
+ * path in the gap auto-ran with nothing to restore from: `rm -rf .git`,
69
+ * `rm .env`, `rm -rf node_modules`, `rm -rf .cruxy`. No unlisted binary
70
+ * required — ordinary `rm` cleared all four gates.
63
71
  *
64
- * ── Residual risk, stated plainly ──
65
- * No static analysis can prove a shell command's effects. {@link ESCAPES_ROOT}
66
- * is a denylist, and denylists lose: `node deploy.js` or an unlisted CLI can
67
- * still reach the network. It is a belt. The braces are the two rules that fail
68
- * *closed* — an unprovable command (any shell metacharacter) and any argument
69
- * resolving outside the root are both irreversible without consulting any list
70
- * plus the pre-command checkpoint, which bounds the on-disk half absolutely.
71
- * Auto-approve is a deliberate, per-session, visibly-announced choice; this is
72
- * the ceiling on that choice, not a sandbox.
72
+ * So containment is now necessary but not sufficient. A path must be inside the
73
+ * root AND inside the capture set, and the capture set is read from the function
74
+ * the capturer itself filters on, so the ceiling cannot drift from the snapshot
75
+ * again.
76
+ *
77
+ * ── Braces and belt ──
78
+ * The rules that fail *closed*, needing no list to be correct: an unprovable
79
+ * command (any shell metacharacter); an argument resolving outside the root; an
80
+ * argument naming a path the capturer skips; an argument naming the root itself
81
+ * (a container whose contents are only partly captured). Those are the braces,
82
+ * and they are what the guarantee rests on.
83
+ *
84
+ * {@link ESCAPES_ROOT} and {@link MUTATES_UNCAPTURED_STATE} are the belt: two
85
+ * 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.
91
+ *
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.
73
96
  */
74
97
  /**
75
98
  * `git` subcommands that only read. Everything else `git` does is irreversible
@@ -139,11 +162,43 @@ const ESCAPES_ROOT = new Map([
139
162
  ["mkfs", "it formats a filesystem, which no checkpoint can restore"],
140
163
  ["diskutil", "it changes disk state, which no checkpoint can restore"],
141
164
  ]);
165
+ /**
166
+ * Programs that change file state the snapshot does not record. Not "scary
167
+ * verbs" — each names an attribute {@link CaptureFile} has no field for, so
168
+ * restore provably cannot put it back:
169
+ *
170
+ * • permissions beyond the executable bit (`mode` is `100644` or `100755`),
171
+ * • ownership and file flags (no field at all),
172
+ * • symlink-ness (capture skips non-regular files; restore writes plain ones).
173
+ *
174
+ * `chmod -R 000 .` is the case that motivates this: every file stays captured
175
+ * and byte-identical, so `restore.ts` sees nothing changed and reverts nothing,
176
+ * while the tree is now unreadable. Being a name list this is a belt, not a
177
+ * brace — `install -m`, `setfacl`, and a script calling `os.chmod` all evade it.
178
+ */
179
+ const MUTATES_UNCAPTURED_STATE = new Map([
180
+ [
181
+ "chmod",
182
+ "a snapshot records only the executable bit, so any other permission change is not restored",
183
+ ],
184
+ ["chown", "a snapshot records no ownership, so it cannot be restored"],
185
+ ["chgrp", "a snapshot records no group ownership, so it cannot be restored"],
186
+ ["chflags", "a snapshot records no file flags, so they cannot be restored"],
187
+ [
188
+ "setfacl",
189
+ "a snapshot records no access-control lists, so they cannot be restored",
190
+ ],
191
+ [
192
+ "ln",
193
+ "a snapshot captures regular files only, so a link it creates — and a captured file it replaces — fall outside the checkpoint",
194
+ ],
195
+ ]);
142
196
  /**
143
197
  * Why the checkpoint cannot restore `command`, or `null` when it can.
144
198
  *
145
- * Three gates, in order of how hard they fail closed. The first two need no
146
- * list to be correct; the third is the belt described above.
199
+ * Gates in order of how hard they fail closed. The metacharacter gate and the
200
+ * argument gate need no list to be correct; the `git`/package-manager gates are
201
+ * allowlists over a known surface; the two name maps are the belt.
147
202
  */
148
203
  function shellIrreversibility(command, root) {
149
204
  const tokens = commandTokens(command);
@@ -168,47 +223,137 @@ function shellIrreversibility(command, root) {
168
223
  const escapes = ESCAPES_ROOT.get(program);
169
224
  if (escapes)
170
225
  return `\`${program}\` is not checkpointable — ${escapes}`;
171
- const outside = args.find((a) => escapesRoot(a, root));
172
- if (outside) {
173
- return `\`${outside}\` resolves outside the workspace root, which the checkpoint does not capture`;
226
+ const attribute = MUTATES_UNCAPTURED_STATE.get(program);
227
+ if (attribute)
228
+ return `\`${program}\` is not checkpointable ${attribute}`;
229
+ // Every remaining argument is treated as a candidate path. Note this asks
230
+ // "would the checkpoint cover this path", NOT "does the command write it" —
231
+ // `cat .env` is classified irreversible too. That over-inclusion is the
232
+ // deliberate direction of error: a false positive costs one prompt, a false
233
+ // negative costs the file. `tokens[0]` is excluded because it is what *runs*,
234
+ // not what is written, and running a file does not destroy it — otherwise
235
+ // `./node_modules/.bin/vitest` would prompt on every invocation.
236
+ for (const arg of args) {
237
+ const reason = argIrreversibility(arg, root);
238
+ if (reason)
239
+ return reason;
174
240
  }
175
241
  return null;
176
242
  }
243
+ // ── dead grants (cli#196) ─────────────────────────────────────────────────────
177
244
  /**
178
- * Whether a command argument names a path the checkpoint would not cover.
245
+ * Why an already-recorded grant can never match another action, or `null` when
246
+ * it is still spendable.
247
+ *
248
+ * Since cli#193 the ceiling is checked BEFORE any scope matching, so a grant
249
+ * whose every possible match is irreversible is inert the moment it is written:
250
+ * the user was prompted, said "allow this session", and bought nothing. The
251
+ * grant is still recorded (`grant()` does not judge), and `/permissions` lists
252
+ * it — listing it looking exactly like a live one is what would re-create the
253
+ * "the grant was forgotten" confusion {@link SessionAllowlist.overriddenGrant}
254
+ * exists to prevent, one surface further along.
255
+ *
256
+ * Decidable from the SCOPE ALONE, which is the whole reason this can be a
257
+ * function rather than a guess:
258
+ *
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.
263
+ * • `shell-exact` (a test grant) — the scope carries the exact command and
264
+ * root, and {@link testRequest} derives `irreversible` from those same two
265
+ * values through this same function. Same inputs, same verdict, every time:
266
+ * a `pnpm install` test grant is inert on the subcommand parse alone.
267
+ * • `shell-prefix` — only where the program name decides it regardless of
268
+ * arguments, i.e. the two name maps. NOT the `git`/package-manager gates:
269
+ * those turn on the subcommand, and a `git` grant that cannot cover
270
+ * `git commit` still covers `git log` — that grant is narrow, not dead.
179
271
  *
180
- * Handles `--flag=<path>` as well as a bare path, because the flag form is how
181
- * an out-of-root target most often sneaks past a naive check. Absolute paths and
182
- * anything containing `..` are resolved against the root and tested; a plain
183
- * relative token cannot escape, so it is left alone. (`~` and `*` never reach
184
- * here {@link commandTokens} already rejects the whole command for those.)
272
+ * `file-subtree` is not judged. A subtree rooted somewhere the capturer skips
273
+ * (inside `node_modules/`, say) is genuinely inert, but deciding that needs the
274
+ * WORKSPACE root to relativize against and the scope carries only its own
275
+ * absolute path and the ordinary case, a subtree of source files, is live. A
276
+ * missing mark on a rare inert grant is a lesser wrong than a wrong mark on a
277
+ * common live one.
185
278
  */
186
- function escapesRoot(arg, root) {
279
+ export function deadGrantReason(scope) {
280
+ 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)";
282
+ }
283
+ if (scope.kind === "shell-exact") {
284
+ return shellIrreversibility(scope.command, scope.root);
285
+ }
286
+ if (scope.kind === "shell-prefix") {
287
+ const program = path.basename(scope.token);
288
+ const escapes = ESCAPES_ROOT.get(program);
289
+ if (escapes)
290
+ return `\`${program}\` is not checkpointable — ${escapes}`;
291
+ const attribute = MUTATES_UNCAPTURED_STATE.get(program);
292
+ if (attribute)
293
+ return `\`${program}\` is not checkpointable — ${attribute}`;
294
+ }
295
+ return null;
296
+ }
297
+ /**
298
+ * Why the checkpoint could not restore the path a command argument names, or
299
+ * `null` when it could (or when the token names no path at all).
300
+ *
301
+ * Handles `--flag=<path>` as well as a bare token, because the flag form is how
302
+ * an out-of-root target most often sneaks past a naive check. Bare options are
303
+ * skipped: `-rf` resolves to a path that exists in no capture set and would only
304
+ * produce noise. (`~` and `*` never reach here — {@link commandTokens} already
305
+ * rejects the whole command for those.)
306
+ */
307
+ function argIrreversibility(arg, root) {
187
308
  const eq = arg.indexOf("=");
188
309
  const candidate = eq === -1 ? arg : arg.slice(eq + 1);
189
310
  if (candidate === "")
190
- return false;
191
- const looksLikePath = candidate.startsWith("/") || candidate.split("/").includes("..");
192
- if (!looksLikePath)
193
- return false;
194
- return !isInside(root, path.resolve(root, candidate));
311
+ return null;
312
+ if (eq === -1 && candidate.startsWith("-"))
313
+ return null;
314
+ return pathIrreversibility(path.resolve(root, candidate), root, arg);
315
+ }
316
+ /**
317
+ * The shared containment+coverage test behind every path-bearing rule: why the
318
+ * checkpoint could not restore `abs`, or `null` when it could. `label` is what
319
+ * the message names — the token the user actually typed, for a shell argument.
320
+ *
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.
323
+ */
324
+ function pathIrreversibility(abs, root, label = abs) {
325
+ if (!isInside(root, abs)) {
326
+ return `\`${label}\` resolves outside the workspace root, which the checkpoint does not capture`;
327
+ }
328
+ if (abs === root) {
329
+ // The root is a container, not a captured path: it holds `.git/`, `.cruxy/`,
330
+ // gitignored output and secrets, none of which come back. `rm -rf .` and
331
+ // `chmod -R 000 .` are inside the root and still unrecoverable.
332
+ return `\`${label}\` names the workspace root itself, which contains paths the checkpoint does not capture (\`.git/\`, ignored output, secrets)`;
333
+ }
334
+ 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;
195
339
  }
196
340
  /**
197
341
  * Why the checkpoint cannot restore a file action, or `null` when it can.
198
342
  *
199
343
  * A delete is not special here — `patchHasDelete` raises the *tier* because a
200
- * delete deserves a louder prompt, but a deleted file inside the root is
201
- * restored from the snapshot exactly like an edited one. What matters is
202
- * containment, and that every target is known: an action carrying no resolved
344
+ * delete deserves a louder prompt, but a deleted file inside the capture set is
345
+ * restored from the snapshot exactly like an edited one. What matters is that
346
+ * every target is known, contained, AND captured: an action carrying no resolved
203
347
  * target cannot be shown to be covered, so it is not.
204
348
  */
205
349
  function fileIrreversibility(targets, root) {
206
350
  if (targets.length === 0) {
207
351
  return "the action names no resolvable target, so the checkpoint cannot be shown to cover it";
208
352
  }
209
- const outside = targets.find((t) => !isInside(root, t));
210
- if (outside) {
211
- return `\`${outside}\` is outside the workspace root, which the checkpoint does not capture`;
353
+ for (const target of targets) {
354
+ const reason = pathIrreversibility(target, root);
355
+ if (reason)
356
+ return reason;
212
357
  }
213
358
  return null;
214
359
  }
@@ -378,6 +523,11 @@ function mcpRequest(action, root) {
378
523
  // Same reasoning as the tier: the server cannot vouch for itself. A
379
524
  // `readOnlyHint` is a claim by the thing being gated, and the checkpoint
380
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.
381
531
  irreversible: "an MCP tool runs unsandboxed in an external server, so its effects are outside the checkpoint entirely",
382
532
  };
383
533
  }
@@ -416,10 +566,10 @@ function fileScope(targets, root) {
416
566
  const dir = commonDir(targets);
417
567
  if (dir === root || !isInside(root, dir)) {
418
568
  return targets.length === 1
419
- ? { kind: "file-subtree", root: targets[0] } // exact-file grant
569
+ ? { kind: "file-subtree", root: targets[0], exact: true } // exact-file grant
420
570
  : { kind: "none" };
421
571
  }
422
- return { kind: "file-subtree", root: dir };
572
+ return { kind: "file-subtree", root: dir, exact: false };
423
573
  }
424
574
  function fileSummary(action, targets, root) {
425
575
  const verb = action.kind === "write"