@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.
@@ -8,16 +8,118 @@ import { promptForApproval } from "./prompt.js";
8
8
  */
9
9
  export class SessionAllowlist {
10
10
  grants = [];
11
- /** Record a session grant for `request`'s scope. No-op when nothing is safe to grant. */
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
- this.grants.push({ tier: request.tier, scope: request.scope });
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
- /** Does an existing grant cover `request`? Requires same tier **and** scope match. */
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
- const choice = await promptForApproval(request, this.io);
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 };
@@ -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
- /** Short human label for what a session grant would allow, or null if none. */
159
- function scopeLabel(scope) {
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 `changes under ${path.basename(scope.root)}/`;
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 { bindingWindow } from "../limits/index.js";
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
- /** The server's denominator, resolved to a statement (see {@link ServerHeadroom}). */
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 binding = bindingWindow(budget.monthly, budget.burst);
108
- if (!binding) {
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: binding.name,
117
- remaining: binding.window.remaining,
118
- cap: binding.window.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 { isSecretPath, walkRepo } from "../indexing/walker.js";
5
- import { GLOBAL_DIR_NAME } from "../constants.js";
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
- * • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
11
- * copy a secret into `.cruxy/` or the git object DB),
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 === "" || isExcluded(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",
@@ -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
  });
@@ -1,8 +1,13 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globToRegexBody, isBinary } from "./util.js";
4
- /** Directories never descended into, regardless of ignore files. */
5
- const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
4
+ /**
5
+ * Directories never descended into, regardless of ignore files. Exported because
6
+ * `checkpoint/coverage.ts` pairs each name with the reason a checkpoint cannot
7
+ * restore what is under it — a name added here without a reason there fails the
8
+ * build, so the walker cannot quietly widen what the ceiling still calls safe.
9
+ */
10
+ export const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
6
11
  const DEFAULT_IGNORE_FILES = [".gitignore", ".cruxyignore"];
7
12
  /**
8
13
  * Hard denylist for secret-bearing files. Applied independently of ignore files
@@ -8,4 +8,4 @@
8
8
  * rendering lives in `tui/limits-panel.ts` — nothing here formats anything.
9
9
  */
10
10
  export { LimitsCache } from "./cache.js";
11
- export { reduceLimits, bindingWindow, usedFraction } from "./reduce.js";
11
+ export { reduceLimits, bindingWindow, scarcestWindow, usedFraction, } from "./reduce.js";