@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.
- package/README.md +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +55 -5
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +136 -57
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/jobs/manager.js +269 -17
- package/dist/limits/cache.js +21 -5
- package/dist/mcp/client.js +16 -0
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +62 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- 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
|
-
|
|
285
|
+
cruxy's client for the cruxy gateway, built over `fetch`, with no vendor SDKs.
|
|
286
286
|
|
|
287
287
|
## License
|
|
288
288
|
|
package/dist/agent/loop.js
CHANGED
|
@@ -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,
|
|
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.
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
this
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
53
|
-
*
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
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) ────────────────────────────────────────────────
|
package/dist/approval/prompt.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
package/dist/approval/service.js
CHANGED
|
@@ -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
|
|
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
|
}
|