@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.
- package/dist/agent/loop.js +9 -0
- package/dist/agent/permissions.js +72 -0
- package/dist/agent/session.js +30 -1
- package/dist/approval/classify.js +194 -44
- package/dist/approval/policy.js +142 -5
- package/dist/approval/prompt.js +52 -7
- package/dist/budget/session-budget.js +43 -7
- package/dist/checkpoint/capture.js +9 -12
- package/dist/checkpoint/coverage.js +70 -0
- package/dist/cli/command-catalog.js +5 -0
- package/dist/cli/commands/run.js +6 -1
- package/dist/cli/session-commands.js +59 -0
- package/dist/cli/session-factory.js +4 -0
- package/dist/indexing/walker.js +7 -2
- package/dist/limits/index.js +1 -1
- package/dist/limits/reduce.js +46 -4
- package/dist/mcp/bounds.js +21 -3
- package/dist/plan/policy.js +22 -6
- package/dist/render/permissions-view.js +88 -0
- package/dist/render/state.js +22 -3
- package/dist/subagent/orchestrator.js +157 -13
- package/dist/subagent/spawn-tool.js +30 -9
- package/dist/tools/index.js +1 -0
- package/dist/tools/schema-depth.js +67 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/limits-panel.js +6 -0
- package/dist/tui/panels.js +20 -1
- package/dist/tui/permissions-view.js +30 -0
- package/dist/tui/renderer.js +47 -15
- package/dist/usage/collect.js +3 -0
- package/dist/usage/types.js +10 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* the
|
|
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
|
-
*
|
|
146
|
-
* list to be correct; the
|
|
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
|
|
172
|
-
if (
|
|
173
|
-
return `\`${
|
|
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
|
-
*
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
201
|
-
* restored from the snapshot exactly like an edited one. What matters is
|
|
202
|
-
*
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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"
|