@cruxy/cli 1.7.0 → 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/README.md +1 -1
- 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/errors/constructors.js +94 -1
- package/dist/errors/types.js +17 -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/file/apply-patch.js +164 -70
- 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 +2 -2
package/dist/approval/policy.js
CHANGED
|
@@ -8,16 +8,118 @@ import { promptForApproval } from "./prompt.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export class SessionAllowlist {
|
|
10
10
|
grants = [];
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Record a session grant for `request`'s scope. No-op when nothing is safe to
|
|
13
|
+
* grant — and no-op when an identical grant is already held (cli#196).
|
|
14
|
+
*
|
|
15
|
+
* DEDUPE ON WRITE. Approving `git status` twice recorded two entries that
|
|
16
|
+
* matched exactly the same set of future actions, and `size()` counted both.
|
|
17
|
+
* That was invisible while nothing listed them: two identical rows change no
|
|
18
|
+
* decision, and `allows()` short-circuits on the first. `/permissions` is the
|
|
19
|
+
* surface that makes it visible, and a list showing the same grant twice reads
|
|
20
|
+
* as two different permissions the user cannot tell apart — or, worse, invites
|
|
21
|
+
* revoking one and being surprised the other still allows the command.
|
|
22
|
+
*
|
|
23
|
+
* Identity is the (tier, scope) pair, which is exactly what `allows()` matches
|
|
24
|
+
* on: two entries agreeing on both are indistinguishable to every reader, so
|
|
25
|
+
* collapsing them removes a row and never a permission.
|
|
26
|
+
*/
|
|
12
27
|
grant(request) {
|
|
13
28
|
if (request.scope.kind === "none")
|
|
14
29
|
return;
|
|
15
|
-
|
|
30
|
+
const entry = { tier: request.tier, scope: request.scope };
|
|
31
|
+
if (this.grants.some((g) => sameGrant(g, entry)))
|
|
32
|
+
return;
|
|
33
|
+
this.grants.push(entry);
|
|
16
34
|
}
|
|
17
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Does an existing grant cover `request`? Requires same tier **and** scope
|
|
37
|
+
* match — and, before either, that the action is **reversible**.
|
|
38
|
+
*
|
|
39
|
+
* THE CEILING OUTRANKS THE GRANT (cli#193). A session grant is keyed on the
|
|
40
|
+
* program token, so approving `git status` with "allow this session" grants
|
|
41
|
+
* `git` — and without this line a later `git push --force` in the same root
|
|
42
|
+
* matched that grant and ran silently. The asymmetry that made it indefensible:
|
|
43
|
+
* with no grant at all, a force-push prompts even in auto-approve; the safest-
|
|
44
|
+
* feeling act available to a user (approving a read-only command) was what
|
|
45
|
+
* disarmed the gate.
|
|
46
|
+
*
|
|
47
|
+
* The check lives HERE, in the allowlist, rather than at either policy's call
|
|
48
|
+
* site, because there are two of them and they share one allowlist:
|
|
49
|
+
* `InteractivePolicy.decide` and `PlanExecutionPolicy.decide` each ask
|
|
50
|
+
* `allows()` first, so a fix in either one leaves the other open. This is the
|
|
51
|
+
* chokepoint every grant lookup passes through — the only place the ceiling
|
|
52
|
+
* cannot be routed around by adding a third caller.
|
|
53
|
+
*
|
|
54
|
+
* The gate is `irreversible`, NOT `tier` (every shell command is
|
|
55
|
+
* `destructive`, `ls` included) — see {@link ApprovalRequest.irreversible}.
|
|
56
|
+
*
|
|
57
|
+
* The cost is real and was accepted knowingly: a granted `git` no longer covers
|
|
58
|
+
* `git commit`, so an edit→commit loop re-prompts. The grant keeps its value
|
|
59
|
+
* for everything the checkpoint can restore, and the prompt now says out loud
|
|
60
|
+
* why the grant did not apply (see {@link overriddenGrant}) rather than looking
|
|
61
|
+
* like the grant was forgotten.
|
|
62
|
+
*/
|
|
18
63
|
allows(request) {
|
|
64
|
+
if (request.irreversible)
|
|
65
|
+
return false;
|
|
19
66
|
return this.grants.some((g) => g.tier === request.tier && scopeCovers(g.scope, request));
|
|
20
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The grant the ceiling just overrode, or `null` if there was none.
|
|
70
|
+
*
|
|
71
|
+
* Purely for the prompt's copy: when a user who said "allow `git` this
|
|
72
|
+
* session" is asked anyway, the only useful thing to tell them is that we
|
|
73
|
+
* remember the grant and this action is outside what it can cover. Without it
|
|
74
|
+
* the re-prompt is indistinguishable from the allowlist having lost the grant.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately answers `null` for a reversible request — there the grant was
|
|
77
|
+
* not overridden, {@link allows} already honored it.
|
|
78
|
+
*/
|
|
79
|
+
overriddenGrant(request) {
|
|
80
|
+
if (!request.irreversible)
|
|
81
|
+
return null;
|
|
82
|
+
const match = this.grants.find((g) => g.tier === request.tier && scopeCovers(g.scope, request));
|
|
83
|
+
return match ? match.scope : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every standing grant, in the order they were made — the read side
|
|
87
|
+
* `/permissions` renders (cli#196).
|
|
88
|
+
*
|
|
89
|
+
* The order is the API, not an implementation detail: {@link revoke} takes a
|
|
90
|
+
* position in THIS list, because a `Scope` has no stable id and the user is
|
|
91
|
+
* pointing at a row they can see.
|
|
92
|
+
*/
|
|
93
|
+
list() {
|
|
94
|
+
return this.grants;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Drop the grant at `index` in {@link list} order. Returns what was removed,
|
|
98
|
+
* or `null` when the index names no row.
|
|
99
|
+
*
|
|
100
|
+
* #196 said this view would hold "nothing mutable", and revoking is a
|
|
101
|
+
* DELIBERATE REVERSAL of that line rather than something that slipped in
|
|
102
|
+
* beside it. The argument there was the failure mode: an agent mid-turn
|
|
103
|
+
* holding a grant that vanishes. The answer is to define what revoking means
|
|
104
|
+
* narrowly enough that the failure cannot occur — **no future lookup
|
|
105
|
+
* matches** — and to say so out loud on the surface that offers it. An action
|
|
106
|
+
* already past `allows()` keeps the decision it was given; nothing reaches
|
|
107
|
+
* back into a turn to un-approve work that is running. What the user gets is
|
|
108
|
+
* the guarantee they actually asked for, which is about the NEXT command.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately NOT routed through `approvalMutex`. The mutex serializes
|
|
111
|
+
* prompting and checkpointing so two actions cannot interleave a snapshot;
|
|
112
|
+
* this touches neither. Taking it would buy nothing and cost the thing that
|
|
113
|
+
* makes revoke worth having — with a prompt pending, `runExclusive` would
|
|
114
|
+
* block `/permissions revoke` behind the very action the user is trying to
|
|
115
|
+
* stop granting.
|
|
116
|
+
*/
|
|
117
|
+
revoke(index) {
|
|
118
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.grants.length) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return this.grants.splice(index, 1)[0];
|
|
122
|
+
}
|
|
21
123
|
clear() {
|
|
22
124
|
this.grants = [];
|
|
23
125
|
}
|
|
@@ -25,6 +127,36 @@ export class SessionAllowlist {
|
|
|
25
127
|
return this.grants.length;
|
|
26
128
|
}
|
|
27
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether two grants allow exactly the same set of actions — the dedupe test.
|
|
132
|
+
*
|
|
133
|
+
* Field by field per scope kind rather than a JSON compare, because identity
|
|
134
|
+
* here means "matches the same requests" and not every field participates in
|
|
135
|
+
* matching: `file-subtree.exact` is a label for the prompt, and two entries
|
|
136
|
+
* differing only there cover an identical set of targets. The mirror of
|
|
137
|
+
* {@link scopeCovers} — if a field is read there, it is compared here.
|
|
138
|
+
*/
|
|
139
|
+
function sameGrant(a, b) {
|
|
140
|
+
if (a.tier !== b.tier || a.scope.kind !== b.scope.kind)
|
|
141
|
+
return false;
|
|
142
|
+
const [x, y] = [a.scope, b.scope];
|
|
143
|
+
if (x.kind === "shell-prefix" && y.kind === "shell-prefix") {
|
|
144
|
+
return x.token === y.token && x.root === y.root;
|
|
145
|
+
}
|
|
146
|
+
if (x.kind === "shell-exact" && y.kind === "shell-exact") {
|
|
147
|
+
return x.command === y.command && x.root === y.root;
|
|
148
|
+
}
|
|
149
|
+
if (x.kind === "mcp-tool" && y.kind === "mcp-tool") {
|
|
150
|
+
return x.server === y.server && x.tool === y.tool && x.root === y.root;
|
|
151
|
+
}
|
|
152
|
+
if (x.kind === "file-subtree" && y.kind === "file-subtree") {
|
|
153
|
+
// `exact` is not compared: it describes the same absolute path either way,
|
|
154
|
+
// and `scopeCovers` never reads it — two entries differing only there cover
|
|
155
|
+
// an identical set of targets.
|
|
156
|
+
return x.root === y.root;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
28
160
|
/**
|
|
29
161
|
* Whether `scope` covers `request`. Shell: the command must be *provably simple*
|
|
30
162
|
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
@@ -95,7 +227,9 @@ export class InteractivePolicy {
|
|
|
95
227
|
}
|
|
96
228
|
async decide(request) {
|
|
97
229
|
// A grant the allowlist answers shows the user nothing (P3) — left
|
|
98
|
-
// unflagged so a caller can render the change instead.
|
|
230
|
+
// unflagged so a caller can render the change instead. `allows` enforces the
|
|
231
|
+
// ceiling itself (cli#193), so an irreversible action never lands here no
|
|
232
|
+
// matter how broad the grant that would otherwise cover it.
|
|
99
233
|
if (this.allowlist.allows(request))
|
|
100
234
|
return { allow: true };
|
|
101
235
|
// Auto-approve: allowed without a prompt, and deliberately NOT flagged as
|
|
@@ -120,7 +254,10 @@ export class InteractivePolicy {
|
|
|
120
254
|
// dishonest. See `ApprovalRequest.irreversible`.
|
|
121
255
|
if (this.autoApprove() && !request.irreversible)
|
|
122
256
|
return { allow: true };
|
|
123
|
-
|
|
257
|
+
// If a grant matched and the ceiling refused it anyway, name it at the
|
|
258
|
+
// prompt — a user who said "allow `git` this session" is owed the reason
|
|
259
|
+
// they are being asked again (cli#193).
|
|
260
|
+
const choice = await promptForApproval(request, this.io, this.allowlist.overriddenGrant(request));
|
|
124
261
|
switch (choice.kind) {
|
|
125
262
|
case "once":
|
|
126
263
|
return { allow: true, prompted: true };
|
package/dist/approval/prompt.js
CHANGED
|
@@ -9,14 +9,19 @@ import { themeForColor } from "../theme/index.js";
|
|
|
9
9
|
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
10
10
|
* is a reject.
|
|
11
11
|
*/
|
|
12
|
-
export async function promptForApproval(request, io
|
|
12
|
+
export async function promptForApproval(request, io,
|
|
13
|
+
/**
|
|
14
|
+
* The session grant the irreversibility ceiling overrode, when there is one
|
|
15
|
+
* (cli#193). Optional and defaulted, so every existing caller is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
overriddenGrant = null) {
|
|
13
18
|
// The terminal is about to belong to this prompt: yield the live region
|
|
14
19
|
// BEFORE the question lands, or it would be painted into a region the next
|
|
15
20
|
// repaint erases (U.2/U.4). `finally` gives it back on every path, including
|
|
16
21
|
// the default-deny ones.
|
|
17
22
|
io.beginPrompt?.();
|
|
18
23
|
try {
|
|
19
|
-
io.write(render(request, io.color, io.columns ?? resolveColumns()));
|
|
24
|
+
io.write(render(request, io.color, io.columns ?? resolveColumns(), overriddenGrant));
|
|
20
25
|
const key = (await io.readKey()).toLowerCase();
|
|
21
26
|
io.write("\n");
|
|
22
27
|
switch (key) {
|
|
@@ -46,7 +51,7 @@ export async function promptForApproval(request, io) {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
49
|
-
export function render(request, color, columns = resolveColumns()) {
|
|
54
|
+
export function render(request, color, columns = resolveColumns(), overriddenGrant = null) {
|
|
50
55
|
const t = themeForColor(color);
|
|
51
56
|
const destructive = request.tier === "destructive";
|
|
52
57
|
// Risk survives all FOUR degradations now (U.11 color/unicode/reader + U.12
|
|
@@ -58,11 +63,34 @@ export function render(request, color, columns = resolveColumns()) {
|
|
|
58
63
|
const label = tierLabel(request.tier, t);
|
|
59
64
|
const lines = [];
|
|
60
65
|
lines.push(...header(request.summary, mark, label, t, columns));
|
|
66
|
+
lines.push(...grantOverridden(overriddenGrant, t, columns));
|
|
61
67
|
lines.push(...whyAsking(request.irreversible, t, columns));
|
|
62
68
|
lines.push(detail(request, t, columns));
|
|
63
69
|
lines.push(choices(request.scope, t));
|
|
64
70
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
65
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The line that says the ceiling outranked a grant the user really did give
|
|
74
|
+
* (cli#193). It is shown ONLY when a grant actually matched and was refused for
|
|
75
|
+
* irreversibility — not on every irreversible prompt — so it never claims a
|
|
76
|
+
* permission the user never gave.
|
|
77
|
+
*
|
|
78
|
+
* It exists because the alternative reads as a bug. A user who chose "allow
|
|
79
|
+
* `git` this session" and is then asked about `git push --force` has no way to
|
|
80
|
+
* tell "we remember, and this one is outside it" from "the grant was dropped";
|
|
81
|
+
* the second reading makes the whole feature look unreliable and trains people
|
|
82
|
+
* to stop reading prompts. The label is the SAME string the `[a]` choice offered
|
|
83
|
+
* at grant time (`scopeLabel`), so the sentence quotes back the words the user
|
|
84
|
+
* actually agreed to rather than a paraphrase of them.
|
|
85
|
+
*
|
|
86
|
+
* Reflowed, never truncated — a half-sentence here would be worse than silence.
|
|
87
|
+
*/
|
|
88
|
+
function grantOverridden(scope, t, width) {
|
|
89
|
+
const label = scope ? scopeLabel(scope) : null;
|
|
90
|
+
if (label === null)
|
|
91
|
+
return [];
|
|
92
|
+
return reflow(`you allowed ${label} this session, but this action is irreversible and always asks`, Math.max(1, width - 2)).map((l) => t.warning(` ${l}`));
|
|
93
|
+
}
|
|
66
94
|
/**
|
|
67
95
|
* The one-clause reason the checkpoint cannot restore this action, when there is
|
|
68
96
|
* one. Rendered for every mode, not just the auto ones — the prompt has no way
|
|
@@ -155,14 +183,31 @@ function choices(scope, t) {
|
|
|
155
183
|
: `[a] allow ${t.strong(grant)} this session`;
|
|
156
184
|
return ` ${t.muted(`[y] approve once ${t.glyph.sep}`)} ${a} ${t.muted(`${t.glyph.sep} [n] reject ${t.glyph.sep} [t] reject & instruct:`)}`;
|
|
157
185
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Short human label for what a session grant covers, or null when a scope
|
|
188
|
+
* grants nothing.
|
|
189
|
+
*
|
|
190
|
+
* THE PROMPT'S OWN WORDS, and exported because `/permissions` has to speak them
|
|
191
|
+
* (cli#196): a view that renamed a grant would be describing a permission the
|
|
192
|
+
* user never agreed to, and the row would not match the sentence they said yes
|
|
193
|
+
* to. One function, so the two cannot drift.
|
|
194
|
+
*
|
|
195
|
+
* The file case was wrong in both surfaces until now. `file-subtree` is a
|
|
196
|
+
* directory grant OR — under the root-cap in `fileScope` — an exact-file grant,
|
|
197
|
+
* and both rendered as `changes under package.json/`: a directory that does not
|
|
198
|
+
* exist, claiming every file "under" it. `Scope.exact` is what separates them,
|
|
199
|
+
* and the exact form names the file it actually covers.
|
|
200
|
+
*/
|
|
201
|
+
export function scopeLabel(scope) {
|
|
160
202
|
if (scope.kind === "shell-prefix")
|
|
161
203
|
return `${scope.token} commands`;
|
|
162
204
|
if (scope.kind === "shell-exact")
|
|
163
205
|
return `re-runs of \`${scope.command}\``;
|
|
164
|
-
if (scope.kind === "file-subtree")
|
|
165
|
-
return
|
|
206
|
+
if (scope.kind === "file-subtree") {
|
|
207
|
+
return scope.exact
|
|
208
|
+
? `changes to ${path.basename(scope.root)}`
|
|
209
|
+
: `changes under ${path.basename(scope.root)}/`;
|
|
210
|
+
}
|
|
166
211
|
if (scope.kind === "mcp-tool")
|
|
167
212
|
return `re-calls of \`${scope.tool}\` on ${scope.server}`;
|
|
168
213
|
return null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scarcestWindow } from "../limits/index.js";
|
|
2
2
|
import { compactTokens } from "../render/units.js";
|
|
3
3
|
import { MAX_TIER_MULTIPLIER, multiplierForTier, weightedFor, } from "../usage/weighted.js";
|
|
4
4
|
/**
|
|
@@ -31,6 +31,31 @@ export class SessionBudget {
|
|
|
31
31
|
attachLimits(limits) {
|
|
32
32
|
this.limits = limits;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Kick a re-read of the server denominator. Fire-and-forget, and deliberately
|
|
36
|
+
* NOT awaited by any caller (cli#212).
|
|
37
|
+
*
|
|
38
|
+
* WHOSE JOB THIS IS MOVED. The only thing driving `refresh` used to be the
|
|
39
|
+
* TUI's post-turn hook, which skipped the probe entirely while the limits
|
|
40
|
+
* panel was closed — defensible while the reading was only a status figure,
|
|
41
|
+
* and wrong the moment it became the denominator an admission check divides
|
|
42
|
+
* by. A closed panel meant one reading for a whole session, and the REPL and
|
|
43
|
+
* headless runs — which have no panel at all — never probed once, so every
|
|
44
|
+
* fan-out they admitted was weighed against whatever the first probe said.
|
|
45
|
+
*
|
|
46
|
+
* The reading is not a panel's, so the turn drives it: `Session.send` calls
|
|
47
|
+
* this on EVERY turn, on every surface. Nothing blocks on it — a turn that
|
|
48
|
+
* waited on a status read would be the paint-path mistake one layer up — so
|
|
49
|
+
* this turn still admits against the previous answer, which is the price of
|
|
50
|
+
* never letting the gateway hold a turn. It lands in time for the fan-out
|
|
51
|
+
* seam inside this turn, and for the next turn's admission.
|
|
52
|
+
*/
|
|
53
|
+
refreshLimits() {
|
|
54
|
+
// Swallowed rather than propagated: this is a background read for a bound
|
|
55
|
+
// nobody is waiting on, and the honest response to not getting an answer is
|
|
56
|
+
// the `unreadable` headroom the last reading already yields.
|
|
57
|
+
void this.limits?.refresh?.().catch(() => { });
|
|
58
|
+
}
|
|
34
59
|
/** The session cap in weighted tokens, or `null` when the user has set none. */
|
|
35
60
|
get limit() {
|
|
36
61
|
return this.limitWeighted;
|
|
@@ -75,7 +100,18 @@ export class SessionBudget {
|
|
|
75
100
|
return null;
|
|
76
101
|
return Math.max(0, this.limitWeighted - this.spentWeighted);
|
|
77
102
|
}
|
|
78
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The server's denominator, resolved to a statement (see {@link ServerHeadroom}).
|
|
105
|
+
*
|
|
106
|
+
* `scarcestWindow`, NOT the rail's `bindingWindow`. Both windows gate every
|
|
107
|
+
* request, so what may be spent right now is `min(remaining)` — a token count.
|
|
108
|
+
* The rail's rule picks the smaller remaining FRACTION, which is the right
|
|
109
|
+
* answer to a different question ("which one will stop you first") and can
|
|
110
|
+
* name the window with more tokens left in it. `limits/reduce.ts` carries the
|
|
111
|
+
* worked example of the two disagreeing; the short version is that using the
|
|
112
|
+
* fraction here would hand this function a ceiling the other window refuses,
|
|
113
|
+
* and it would refuse it after the tokens were spent.
|
|
114
|
+
*/
|
|
79
115
|
serverHeadroom() {
|
|
80
116
|
const state = this.limits?.current();
|
|
81
117
|
if (!state)
|
|
@@ -104,8 +140,8 @@ export class SessionBudget {
|
|
|
104
140
|
why: `this account is ${budget.kind}, which has no weighted pool`,
|
|
105
141
|
};
|
|
106
142
|
}
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
143
|
+
const scarcest = scarcestWindow(budget.monthly, budget.burst);
|
|
144
|
+
if (!scarcest) {
|
|
109
145
|
return {
|
|
110
146
|
kind: "unreadable",
|
|
111
147
|
why: "the pool reported no readable window",
|
|
@@ -113,9 +149,9 @@ export class SessionBudget {
|
|
|
113
149
|
}
|
|
114
150
|
return {
|
|
115
151
|
kind: "window",
|
|
116
|
-
name:
|
|
117
|
-
remaining:
|
|
118
|
-
cap:
|
|
152
|
+
name: scarcest.name,
|
|
153
|
+
remaining: scarcest.window.remaining,
|
|
154
|
+
cap: scarcest.window.cap,
|
|
119
155
|
};
|
|
120
156
|
}
|
|
121
157
|
/**
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { promises as fsp } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { runGitCapture } from "../vcs/git.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { walkRepo } from "../indexing/walker.js";
|
|
5
|
+
import { captureExclusion } from "./coverage.js";
|
|
6
6
|
/**
|
|
7
7
|
* Snapshot-scope enumeration (C.32): every regular file the agent could touch —
|
|
8
8
|
* tracked + untracked-non-ignored — and nothing it must never see:
|
|
9
9
|
* • gitignored paths (they are not the run's undo unit and may be huge),
|
|
10
|
-
* •
|
|
11
|
-
*
|
|
12
|
-
* • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
|
|
10
|
+
* • everything {@link captureExclusion} names — the secrets denylist, `.git/`,
|
|
11
|
+
* `node_modules/`, and `.cruxy/` itself,
|
|
13
12
|
* • symlinks and other non-regular files (restore writes plain files only).
|
|
14
13
|
*
|
|
14
|
+
* The per-path filter is {@link captureExclusion} rather than a local predicate
|
|
15
|
+
* because auto-approve's irreversibility rule reads the same function: what the
|
|
16
|
+
* snapshot skips is exactly what the ceiling must refuse to call restorable.
|
|
17
|
+
*
|
|
15
18
|
* In a git repo the file list comes from `git ls-files` (read-only), which
|
|
16
19
|
* honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
|
|
17
20
|
* exactly. Outside a repo, the indexing walker enumerates with its gitignore
|
|
@@ -25,7 +28,7 @@ export async function captureFiles(root, gitWorkTree) {
|
|
|
25
28
|
: await walkerCandidates(absRoot);
|
|
26
29
|
const files = [];
|
|
27
30
|
for (const relPath of candidates) {
|
|
28
|
-
if (relPath === "" ||
|
|
31
|
+
if (relPath === "" || captureExclusion(relPath) !== null)
|
|
29
32
|
continue;
|
|
30
33
|
const absPath = path.join(absRoot, ...relPath.split("/"));
|
|
31
34
|
// lstat: a symlink must be seen as a symlink, not its target.
|
|
@@ -43,12 +46,6 @@ export async function captureFiles(root, gitWorkTree) {
|
|
|
43
46
|
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
44
47
|
return files;
|
|
45
48
|
}
|
|
46
|
-
/** Never capture cruxy's own state dir or a secret-bearing path. */
|
|
47
|
-
function isExcluded(relPath) {
|
|
48
|
-
return (relPath === GLOBAL_DIR_NAME ||
|
|
49
|
-
relPath.startsWith(`${GLOBAL_DIR_NAME}/`) ||
|
|
50
|
-
isSecretPath(relPath));
|
|
51
|
-
}
|
|
52
49
|
/** Tracked + untracked-non-ignored, straight from git (paths relative to root). */
|
|
53
50
|
async function gitCandidates(absRoot) {
|
|
54
51
|
const res = runGitCapture(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absRoot);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { GLOBAL_DIR_NAME } from "../constants.js";
|
|
2
|
+
import { ALWAYS_IGNORE_DIRS, isSecretPath } from "../indexing/walker.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a checkpoint does **not** capture, stated once and consumed by both sides
|
|
5
|
+
* of the guarantee:
|
|
6
|
+
*
|
|
7
|
+
* • {@link captureFiles} filters its enumeration through it, so the snapshot
|
|
8
|
+
* never contains these paths.
|
|
9
|
+
* • `classify.ts` derives `irreversible` from it, so auto-approve never runs
|
|
10
|
+
* an action targeting them unprompted.
|
|
11
|
+
*
|
|
12
|
+
* One predicate, two consumers, because the bug this exists to prevent is the
|
|
13
|
+
* two drifting apart. The ceiling used to reason about *root containment* —
|
|
14
|
+
* "inside the workspace, therefore restorable" — while the capturer had always
|
|
15
|
+
* excluded four classes of path inside that same workspace. Every path in the
|
|
16
|
+
* gap auto-ran with nothing to restore from. Adding an exclusion here now
|
|
17
|
+
* tightens the ceiling in the same commit that narrows the snapshot; they cannot
|
|
18
|
+
* disagree again.
|
|
19
|
+
*
|
|
20
|
+
* ── What this does NOT cover ──
|
|
21
|
+
* The capture set also excludes anything **gitignored** (`git ls-files
|
|
22
|
+
* --exclude-standard`, and the walker's `IgnoreMatcher` outside a repo), which
|
|
23
|
+
* cannot be decided from a path alone — it needs the repo's ignore files, and
|
|
24
|
+
* `classify` is a pure synchronous function by design (it runs on every action,
|
|
25
|
+
* including the tier check in `approval/mutex.ts`). So a gitignored build
|
|
26
|
+
* directory — `rm -rf dist` — is still classified reversible when it is not.
|
|
27
|
+
* That residual is deliberate and bounded: the four classes below are the ones
|
|
28
|
+
* whose loss is *permanent* (history, secrets, the checkpoint store itself),
|
|
29
|
+
* while generic ignored output is regenerable. It is recorded in cli#241 rather
|
|
30
|
+
* than papered over.
|
|
31
|
+
*/
|
|
32
|
+
export function captureExclusion(relPath) {
|
|
33
|
+
const first = relPath.split("/")[0];
|
|
34
|
+
const reason = NEVER_CAPTURED_DIRS.get(first);
|
|
35
|
+
if (reason)
|
|
36
|
+
return reason;
|
|
37
|
+
if (isSecretPath(relPath)) {
|
|
38
|
+
return "it matches the secrets denylist, which the checkpoint refuses to copy into `.cruxy/` or the git object DB";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Directory names never enumerated by either capture substrate, with the reason
|
|
44
|
+
* the checkpoint cannot restore what is under them. Keyed on the first path
|
|
45
|
+
* segment: exclusion is inherited by everything beneath.
|
|
46
|
+
*
|
|
47
|
+
* Sourced from {@link ALWAYS_IGNORE_DIRS} — the walker's own always-ignore set —
|
|
48
|
+
* so the two lists are the same list. The assertion below fails the build if a
|
|
49
|
+
* name is added there without a reason here.
|
|
50
|
+
*/
|
|
51
|
+
const NEVER_CAPTURED_DIRS = new Map([
|
|
52
|
+
[
|
|
53
|
+
".git",
|
|
54
|
+
"`.git/` is never enumerated, so history, refs, the index, and the stash are outside every checkpoint",
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
"node_modules",
|
|
58
|
+
"`node_modules/` is always ignored, so nothing installed there is captured",
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
GLOBAL_DIR_NAME,
|
|
62
|
+
`\`${GLOBAL_DIR_NAME}/\` holds the checkpoints themselves, which a checkpoint cannot contain`,
|
|
63
|
+
],
|
|
64
|
+
]);
|
|
65
|
+
for (const name of ALWAYS_IGNORE_DIRS) {
|
|
66
|
+
if (!NEVER_CAPTURED_DIRS.has(name)) {
|
|
67
|
+
throw new Error(`checkpoint/coverage.ts: no reason recorded for always-ignored directory \`${name}\` — ` +
|
|
68
|
+
`every path the capturer skips must be one auto-approve refuses to treat as restorable`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -42,6 +42,11 @@ export const COMMAND_CATALOG = [
|
|
|
42
42
|
{ name: "/init", summary: "scaffold a project CRUXY.md and load it" },
|
|
43
43
|
{ name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
|
|
44
44
|
{ name: "/status", summary: "show what this session is set up to do" },
|
|
45
|
+
{
|
|
46
|
+
name: "/permissions",
|
|
47
|
+
summary: "show what runs without asking, and drop a standing grant",
|
|
48
|
+
args: "[revoke <n>]",
|
|
49
|
+
},
|
|
45
50
|
{
|
|
46
51
|
name: "/diff",
|
|
47
52
|
summary: "show uncommitted changes in the workspace",
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -16,7 +16,7 @@ import { SandboxService } from "../../sandbox/index.js";
|
|
|
16
16
|
import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
|
|
17
17
|
import { DEFAULT_MODE, } from "../../agent/index.js";
|
|
18
18
|
import { runInteractive } from "../repl.js";
|
|
19
|
-
import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
|
|
19
|
+
import { ContextGauge, createKeyLease, createGitView, createOverviewView, createPermissionsView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
|
|
20
20
|
import { buildAgentSession } from "../session-factory.js";
|
|
21
21
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
22
22
|
import { resetLspServices } from "../../lsp/index.js";
|
|
@@ -384,6 +384,11 @@ export async function executeRun(promptParts, opts) {
|
|
|
384
384
|
tui.attachViews([
|
|
385
385
|
createOverviewView(session, workspaceGit, () => tui.servedTier(), workspaceDisk),
|
|
386
386
|
createGitView(() => session.toolContext.workspace.roots(), workspaceGit),
|
|
387
|
+
// Next to Overview, which reports the mode: this one reports what the
|
|
388
|
+
// mode plus the session's standing grants add up to. Registered
|
|
389
|
+
// unconditionally — a session with no grants yet is exactly the session
|
|
390
|
+
// whose user most needs to find the screen before there are any.
|
|
391
|
+
createPermissionsView(session),
|
|
387
392
|
// Registered even when background jobs are disabled — the view says so,
|
|
388
393
|
// and a nav whose rows appear and vanish with config is worse than one
|
|
389
394
|
// row that explains itself. `session.jobs` is undefined in that case.
|
|
@@ -3,6 +3,7 @@ import { existsSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { COMMAND_CATALOG } from "./command-catalog.js";
|
|
5
5
|
import { contextReport } from "../agent/context.js";
|
|
6
|
+
import { permissionsReport, revokedLine } from "../agent/permissions.js";
|
|
6
7
|
import { buildSessionStatus } from "../agent/status.js";
|
|
7
8
|
import { resolveCheckpointDiff } from "../checkpoint/diff.js";
|
|
8
9
|
import { rollbackLatestRun } from "../checkpoint/run-rollback.js";
|
|
@@ -11,6 +12,7 @@ import { resolveSlash } from "../hooks/index.js";
|
|
|
11
12
|
import { contextReportLines } from "../render/context-view.js";
|
|
12
13
|
import { compactTokens } from "../render/units.js";
|
|
13
14
|
import { renderUnifiedDiff } from "../render/index.js";
|
|
15
|
+
import { permissionsReportLines } from "../render/permissions-view.js";
|
|
14
16
|
import { sessionStatusLines } from "../render/status-view.js";
|
|
15
17
|
import { defaultExportName, exportMarkdown } from "../session/index.js";
|
|
16
18
|
import { readDiskCapacitySync } from "../utils/disk.js";
|
|
@@ -104,6 +106,10 @@ export async function dispatchCommand(input, ctx) {
|
|
|
104
106
|
handleStatus(ctx);
|
|
105
107
|
return { kind: "handled" };
|
|
106
108
|
}
|
|
109
|
+
if (trimmed === "/permissions" || trimmed.startsWith("/permissions ")) {
|
|
110
|
+
handlePermissions(trimmed, ctx);
|
|
111
|
+
return { kind: "handled" };
|
|
112
|
+
}
|
|
107
113
|
if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
|
|
108
114
|
await handleDiff(trimmed, ctx);
|
|
109
115
|
return { kind: "handled" };
|
|
@@ -337,6 +343,59 @@ function handleStatus(ctx) {
|
|
|
337
343
|
out.print(out.fit(line));
|
|
338
344
|
}
|
|
339
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* `/permissions` (cli#196) — what this session may do without asking, and the
|
|
348
|
+
* one way to take a standing grant back.
|
|
349
|
+
*
|
|
350
|
+
* REVOKE IS THE ARGUMENT FORM, not a selectable row. Views take no input: the
|
|
351
|
+
* TUI's key lease belongs to overlays, and a permissions screen that grabbed it
|
|
352
|
+
* to offer a cursor would be a drawer wearing a view's clothes. The argument
|
|
353
|
+
* form is also the one a user can script, a test can drive, and a REPL without
|
|
354
|
+
* a picker can still reach — `/permissions revoke 2` means the same thing in
|
|
355
|
+
* every shell.
|
|
356
|
+
*
|
|
357
|
+
* The number is the row number the list just printed. A `Scope` has no stable
|
|
358
|
+
* id to name instead, and inventing one would put a token on screen that exists
|
|
359
|
+
* for no other purpose; the user is pointing at a visible row, so the row's
|
|
360
|
+
* position is the honest handle. It is re-read from `list()` at the moment of
|
|
361
|
+
* the revoke, so a grant recorded since the last print shifts nothing — the
|
|
362
|
+
* order is append-only and a revoke only ever removes.
|
|
363
|
+
*/
|
|
364
|
+
function handlePermissions(input, ctx) {
|
|
365
|
+
const { out, session } = ctx;
|
|
366
|
+
const t = out.theme;
|
|
367
|
+
const arg = input.slice("/permissions".length).trim();
|
|
368
|
+
if (arg !== "") {
|
|
369
|
+
const match = /^revoke\s+(\S+)$/.exec(arg);
|
|
370
|
+
if (!match) {
|
|
371
|
+
out.print(t.muted("usage: /permissions [revoke <n>]"));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const allowlist = session.allowlist;
|
|
375
|
+
if (!allowlist) {
|
|
376
|
+
// Not "nothing to revoke": a session with no allowlist wired has no grant
|
|
377
|
+
// surface at all, and saying which is more useful than a failed lookup.
|
|
378
|
+
out.print(t.muted("this session does not track grants"));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Parsed strictly — `Number("2x")` is NaN but `parseInt("2x")` is 2, and a
|
|
382
|
+
// typo must never resolve to a row the user did not mean to drop.
|
|
383
|
+
const n = /^\d+$/.test(match[1]) ? Number(match[1]) : NaN;
|
|
384
|
+
const grant = Number.isNaN(n) ? null : allowlist.revoke(n - 1);
|
|
385
|
+
if (!grant) {
|
|
386
|
+
const count = allowlist.size();
|
|
387
|
+
out.print(t.muted(count === 0
|
|
388
|
+
? "no standing grants to revoke"
|
|
389
|
+
: `no grant ${match[1]} — /permissions lists 1${count === 1 ? "" : `-${count}`}`));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
out.print(t.muted(revokedLine(grant)));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
for (const line of permissionsReportLines(permissionsReport(session), t)) {
|
|
396
|
+
out.print(out.fit(line));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
340
399
|
/**
|
|
341
400
|
* `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
|
|
342
401
|
*
|
|
@@ -522,6 +522,10 @@ opts = {}) {
|
|
|
522
522
|
onRunUsage,
|
|
523
523
|
budget: sessionBudget,
|
|
524
524
|
jobs: jobManager,
|
|
525
|
+
// The SAME allowlist the plan policy and the interactive policy share, so
|
|
526
|
+
// `/permissions` lists the grants this session's prompts actually spend —
|
|
527
|
+
// not a second list that agrees with them only by luck.
|
|
528
|
+
allowlist,
|
|
525
529
|
recorder: opts.recorder,
|
|
526
530
|
restore: opts.restore,
|
|
527
531
|
});
|