@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.
- package/LICENSE +6 -6
- package/dist/config/loader.d.ts +13 -0
- package/dist/config/loader.js +55 -11
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.js +46 -24
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +142 -1
- package/dist/config/shell-policy.js +114 -7
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/types.d.ts +19 -0
- package/dist/config/types.js.map +1 -1
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +31 -0
- package/dist/core/GthAbstractAgent.js +159 -4
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +314 -18
- package/dist/core/GthAgentRunner.js +1032 -101
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/approvals/approvalRequest.d.ts +247 -0
- package/dist/core/approvals/approvalRequest.js +315 -0
- package/dist/core/approvals/approvalRequest.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +165 -9
- package/dist/core/approvals/grants.js +702 -57
- package/dist/core/approvals/grants.js.map +1 -1
- package/dist/core/plainToolIndication.d.ts +11 -1
- package/dist/core/plainToolIndication.js +25 -10
- package/dist/core/plainToolIndication.js.map +1 -1
- package/dist/core/shell/abstention.d.ts +1 -1
- package/dist/core/shell/abstention.js +65 -11
- package/dist/core/shell/abstention.js.map +1 -1
- package/dist/core/shell/alignment.d.ts +491 -0
- package/dist/core/shell/alignment.js +687 -0
- package/dist/core/shell/alignment.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +53 -41
- package/dist/core/shell/approvalCapture.js +0 -42
- package/dist/core/shell/approvalCapture.js.map +1 -1
- package/dist/core/shell/approvalStop.d.ts +121 -3
- package/dist/core/shell/approvalStop.js +153 -90
- package/dist/core/shell/approvalStop.js.map +1 -1
- package/dist/core/shell/hardline.d.ts +26 -4
- package/dist/core/shell/hardline.js +321 -16
- package/dist/core/shell/hardline.js.map +1 -1
- package/dist/core/shell/negotiation.d.ts +352 -63
- package/dist/core/shell/negotiation.js +295 -123
- package/dist/core/shell/negotiation.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +135 -0
- package/dist/core/shell/openWorld.js +572 -35
- package/dist/core/shell/openWorld.js.map +1 -1
- package/dist/core/shell/provenance.d.ts +91 -0
- package/dist/core/shell/provenance.js +136 -0
- package/dist/core/shell/provenance.js.map +1 -0
- package/dist/core/shell/rater.d.ts +394 -163
- package/dist/core/shell/rater.js +536 -238
- package/dist/core/shell/rater.js.map +1 -1
- package/dist/core/shell/raterHealth.d.ts +101 -0
- package/dist/core/shell/raterHealth.js +121 -0
- package/dist/core/shell/raterHealth.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +17 -8
- package/dist/core/shell/raterModel.js +11 -8
- package/dist/core/shell/raterModel.js.map +1 -1
- package/dist/core/shell/rejection.d.ts +20 -5
- package/dist/core/shell/rejection.js +18 -4
- package/dist/core/shell/rejection.js.map +1 -1
- package/dist/core/toolDisplay.d.ts +42 -0
- package/dist/core/toolDisplay.js +31 -0
- package/dist/core/toolDisplay.js.map +1 -1
- package/dist/core/types.d.ts +134 -16
- package/dist/core/types.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +36 -6
- package/dist/providers/configurationPassthrough.js +40 -28
- package/dist/providers/configurationPassthrough.js.map +1 -1
- package/dist/providers/openrouter.js +36 -6
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.js +21 -0
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js +103 -21
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/fileUtils.d.ts +7 -1
- package/dist/utils/fileUtils.js +17 -3
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/globalConfigUtils.d.ts +5 -2
- package/dist/utils/globalConfigUtils.js +14 -3
- package/dist/utils/globalConfigUtils.js.map +1 -1
- package/dist/utils/vertexaiUtils.js +61 -3
- package/dist/utils/vertexaiUtils.js.map +1 -1
- package/package.json +4 -4
- package/schema/gsloth-config.schema.json +34 -0
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* EXT-71 (spec §3, §3.1, §6) — **what the escalation menu remembers**, in the one entry grammar.
|
|
5
5
|
*
|
|
6
6
|
* A grant is an {@link ApprovalEntry} plus the metadata §3 requires a list to be able to show:
|
|
7
|
-
* **when** it was made and **at what scope**. Two
|
|
7
|
+
* **when** it was made and **at what scope**. Two kinds of store hold them — an in-memory
|
|
8
8
|
* {@link ApprovalGrantStore} for the life of one runner instance, and a
|
|
9
|
-
* {@link PersistedApprovalGrants} backed by
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* {@link PersistedApprovalGrants} backed by a project JSON file for `always` — and neither decides
|
|
10
|
+
* anything. They hold entries; `core/approvals/matcher.ts` compares them. There is exactly one
|
|
11
|
+
* comparison engine and it is not here.
|
|
12
|
+
*
|
|
13
|
+
* **Both are list-agnostic, and that is what keeps the two sides symmetric.** The runner holds four
|
|
14
|
+
* of them: a session and a persisted store for what the menu *approved*, and the same pair for what
|
|
15
|
+
* it *refused* ([[EXT-107]]). A store never learns which list it is; the file path decides that, and
|
|
16
|
+
* the runner decides which list it hands to the matcher. A second class for refusals would be the
|
|
17
|
+
* same code with one string changed, free to drift from its twin on every later edit.
|
|
12
18
|
*
|
|
13
19
|
* ## The menu never widens
|
|
14
20
|
*
|
|
@@ -53,9 +59,49 @@
|
|
|
53
59
|
* `annotations` is absent on a `shell` grant and on any grant written before it existed; a grant
|
|
54
60
|
* without one simply has nothing to invalidate it and stands as it did.
|
|
55
61
|
*
|
|
56
|
-
* Reads
|
|
57
|
-
*
|
|
58
|
-
*
|
|
62
|
+
* Reads never throw: a missing, unreadable, malformed or partly-malformed file yields fewer grants
|
|
63
|
+
* (at worst none) rather than raising at a user mid-run. **Fewer grants is fail-closed on the allow
|
|
64
|
+
* side only** — an empty allow-list means *prompt*, while an empty DENY list means nothing refuses,
|
|
65
|
+
* so the same recovery loses safety on one side and buys it on the other.
|
|
66
|
+
*
|
|
67
|
+
* **Which is why a read that lost something says so** ([[EXT-143]]). Recovering quietly was
|
|
68
|
+
* defensible while this class held one list; with two it hides a lost refusal, and this is a project
|
|
69
|
+
* file people hand-edit and commit whose characteristic failure is a typo. A user who saved twenty
|
|
70
|
+
* refusals and later broke the file has no other way to learn that none of them are in force — the
|
|
71
|
+
* gate behaves exactly as though they had never been saved. So a whole file that cannot be read, and
|
|
72
|
+
* an individual entry that cannot be read, are both reported at {@link StatusLevel.ERROR}
|
|
73
|
+
* ({@link unreadableFileNotice}, {@link skippedEntriesNotice}), while the fallback stays exactly
|
|
74
|
+
* what it was.
|
|
75
|
+
*
|
|
76
|
+
* **And a store that could not read its file does not write it** ([[EXT-144]]). A write here
|
|
77
|
+
* rewrites the WHOLE file from what is held in memory, which after a failed load is nothing — so one
|
|
78
|
+
* saved answer would replace twenty saved ones with itself, turning a trailing comma into permanent
|
|
79
|
+
* loss. Reading fails soft; writing over what the reader could not parse is the one recovery that
|
|
80
|
+
* cannot be undone, so {@link PersistedApprovalGrants.tryPersist} refuses it and says so
|
|
81
|
+
* ({@link refusedWriteNotice}). The answer still holds for this session — the runner keeps its own
|
|
82
|
+
* in-memory copy — it is simply not written down.
|
|
83
|
+
*
|
|
84
|
+
* ## What this store CLAIMS is what the file holds
|
|
85
|
+
*
|
|
86
|
+
* [[EXT-149]] — **the store holds only grants the file is believed to hold**, and every method that
|
|
87
|
+
* answers a question about the file answers it from that. {@link PersistedApprovalGrants.add}
|
|
88
|
+
* returns whether the grant reached disk and takes back one that did not;
|
|
89
|
+
* {@link PersistedApprovalGrants.remove} returns whether the deletion reached disk. A write that
|
|
90
|
+
* merely FAILED — an unwritable checkout, a directory that is gone — is reported
|
|
91
|
+
* ({@link failedWriteNotice}) rather than swallowed, because the caller above stamps an answer
|
|
92
|
+
* `always` or `session` from these returns and a surface renders them as *saved to this project*.
|
|
93
|
+
*
|
|
94
|
+
* The one imprecision is deliberate and is in the safe direction: `inSync` is store-level, so after
|
|
95
|
+
* a failed write an `add` of a grant the file DOES already hold answers `false`. Under-claiming
|
|
96
|
+
* costs a re-prompt; over-claiming is the defect.
|
|
97
|
+
*
|
|
98
|
+
* ## A rewrite gives back the keys it does not use
|
|
99
|
+
*
|
|
100
|
+
* [[EXT-151]] — this is a file people hand-edit, and a whole-file rewrite from a store that models
|
|
101
|
+
* two keys would delete everything else in it. Top-level keys this version does not use are carried
|
|
102
|
+
* across the read and written back ({@link preservedKeys}), so a rewrite touches `version` and
|
|
103
|
+
* `grants` and nothing else. Deleting the parts of a user's file we do not recognise is the worst
|
|
104
|
+
* of the available answers; reporting the deletion is only the second worst.
|
|
59
105
|
*/
|
|
60
106
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
61
107
|
import { approvalEntrySchema, renderApprovalEntryObject } from '#src/config/schema.js';
|
|
@@ -64,6 +110,53 @@ import { describeApprovalEntry, MCP_FAIL_CLOSED_ANNOTATIONS, } from '#src/core/a
|
|
|
64
110
|
import { UNRESOLVED_MCP_SERVER } from '#src/core/approvals/mcpSubjects.js';
|
|
65
111
|
import { normalizeCommand } from '#src/core/shell/normalize.js';
|
|
66
112
|
import { StatusLevel } from '#src/core/types.js';
|
|
113
|
+
/**
|
|
114
|
+
* [[EXT-149]] — **does an unreadable file hold anything a human could get back by fixing it?**
|
|
115
|
+
*
|
|
116
|
+
* The write guard refuses either way ({@link StoreReadState}); this decides only what the user is
|
|
117
|
+
* TOLD, and the two messages make opposite promises. The predicate is *is any text left once JSON's
|
|
118
|
+
* own punctuation and whitespace are removed*: a file emptied by hand, one an editor left holding a
|
|
119
|
+
* newline, and one truncated to `{` or `{"` all hold nothing, while a trailing comma inside a real
|
|
120
|
+
* entry list leaves every entry's own text behind.
|
|
121
|
+
*
|
|
122
|
+
* **Every line ending counts as whitespace, and that is load-bearing.** `\s` matches `\r` as well as
|
|
123
|
+
* `\n`, so a file written on a CRLF checkout classifies exactly as the same file written with LF. A
|
|
124
|
+
* predicate that reached the same place by splitting on `'\n'` would leave a stray `\r` behind and
|
|
125
|
+
* call an empty file full — with no crash, on Windows only.
|
|
126
|
+
*
|
|
127
|
+
* `undefined` means the read itself threw (a permission error, say), where the content is unknown
|
|
128
|
+
* and the conservative answer is that there is something to protect.
|
|
129
|
+
*/
|
|
130
|
+
function holdsRecoverableText(raw) {
|
|
131
|
+
if (raw === undefined)
|
|
132
|
+
return true;
|
|
133
|
+
return raw.replace(/[\s{}[\],:"]/g, '').length > 0;
|
|
134
|
+
}
|
|
135
|
+
/** The top-level keys this version writes, and so the only ones a rewrite may replace. */
|
|
136
|
+
const OWN_KEYS_V2 = ['version', 'grants'];
|
|
137
|
+
/** The v1 keys a migration consumes: `prefixes` becomes grants, `version` is rewritten. */
|
|
138
|
+
const OWN_KEYS_V1 = ['version', 'prefixes'];
|
|
139
|
+
/**
|
|
140
|
+
* [[EXT-151]] — **the top-level keys this version does not use**, kept so a rewrite gives them back
|
|
141
|
+
* instead of deleting them.
|
|
142
|
+
*
|
|
143
|
+
* This is a file people hand-edit and commit, and a whole-file rewrite from an in-memory model of
|
|
144
|
+
* two keys silently destroys every other one. The protection was already there for a file with no
|
|
145
|
+
* `grants` key at all ([[EXT-144]] refuses to write it); a file with `grants` AND a key of the
|
|
146
|
+
* user's own read cleanly and lost the second half on the next save — the same loss, guarded on one
|
|
147
|
+
* side of a line the user cannot see.
|
|
148
|
+
*
|
|
149
|
+
* Preserving rather than merely reporting is the choice, because reporting a deletion is still a
|
|
150
|
+
* deletion. Nothing here makes an unknown key mean anything: it is carried, not interpreted.
|
|
151
|
+
*/
|
|
152
|
+
function preservedKeys(parsed, consumed) {
|
|
153
|
+
const kept = {};
|
|
154
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
155
|
+
if (!consumed.includes(key))
|
|
156
|
+
kept[key] = value;
|
|
157
|
+
}
|
|
158
|
+
return kept;
|
|
159
|
+
}
|
|
67
160
|
/** The version this module writes. */
|
|
68
161
|
const PERSISTED_VERSION = 2;
|
|
69
162
|
/**
|
|
@@ -334,10 +427,300 @@ export class ApprovalGrantStore {
|
|
|
334
427
|
return this.grants.length;
|
|
335
428
|
}
|
|
336
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* [[EXT-143]] — the words a load-failure notice uses for what the file holds
|
|
432
|
+
* ({@link PersistedApprovalGrantsOptions.holds}), and what it says when the caller did not say.
|
|
433
|
+
*
|
|
434
|
+
* **The fallback is deliberately vague, and a confident default would be the bug.** These notices
|
|
435
|
+
* exist to correct a false belief about a specific file; a default of `'approvals'` would put that
|
|
436
|
+
* exact word into the message a *deny* store prints, so the one sentence written to stop a user
|
|
437
|
+
* trusting something that is not in force would misname what they lost. A caller that says nothing
|
|
438
|
+
* gets a sentence that is true of either file instead.
|
|
439
|
+
*/
|
|
440
|
+
function savedNoun(holds) {
|
|
441
|
+
return holds ?? 'decisions';
|
|
442
|
+
}
|
|
443
|
+
/** The reason clause for a file that parsed but holds no entry list this version can read. */
|
|
444
|
+
const UNRECOGNISED_SHAPE = 'the file holds no list of saved entries this version recognises';
|
|
445
|
+
/**
|
|
446
|
+
* [[EXT-143]] — **did this shape lose something a human saved?**
|
|
447
|
+
*
|
|
448
|
+
* {@link unreadableFileNotice} asserts a loss, so it may only fire where there is one. A file whose
|
|
449
|
+
* entry list is absent or empty — `{}`, a bare `{"version": 2}`, a v1 `{"prefixes": []}`, a JSON
|
|
450
|
+
* `null` — holds nothing, and telling its owner every session that saved answers they do not have
|
|
451
|
+
* are not in force is the same over-claim the consequence sentence had to drop, one level down.
|
|
452
|
+
*
|
|
453
|
+
* What counts as a loss is **a non-empty list under any key at all**, or **a value sitting where a
|
|
454
|
+
* list belongs** — a `grants` key holding something other than an array, or a scalar where the store
|
|
455
|
+
* object should be. Neither is what emptying the file by hand produces (that yields `{}`, or an empty
|
|
456
|
+
* file, which fails to parse and is reported with the reader's own reason instead), so both are
|
|
457
|
+
* content this version cannot read.
|
|
458
|
+
*
|
|
459
|
+
* **Any key, deliberately, and not just `grants`/`prefixes`.** Those two are the only keys a shipped
|
|
460
|
+
* version ever wrote, so keying the test on them would be defensible — but the reader this notice
|
|
461
|
+
* exists for is the one who hand-edits the file, and a list they typed under a name we do not know is
|
|
462
|
+
* still a list we are not reading. Silence there would be the very trap the notice was added to
|
|
463
|
+
* close: a file that looks full and holds nothing the gate can see.
|
|
464
|
+
*/
|
|
465
|
+
function holdsSavedEntries(parsed) {
|
|
466
|
+
if (Array.isArray(parsed))
|
|
467
|
+
return parsed.length > 0;
|
|
468
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
469
|
+
return parsed !== null;
|
|
470
|
+
const values = Object.values(parsed);
|
|
471
|
+
if (values.some((value) => Array.isArray(value) && value.length > 0))
|
|
472
|
+
return true;
|
|
473
|
+
const { grants, prefixes } = parsed;
|
|
474
|
+
return [grants, prefixes].some((list) => list !== undefined && !Array.isArray(list));
|
|
475
|
+
}
|
|
476
|
+
/** How much of one unreadable entry a notice quotes back, and how many it quotes at all. */
|
|
477
|
+
const SKIPPED_ENTRY_CHARS = 160;
|
|
478
|
+
const SKIPPED_ENTRIES_NAMED = 5;
|
|
479
|
+
/** Clip a quoted fragment so one enormous entry cannot become the whole message. */
|
|
480
|
+
function clip(text, limit) {
|
|
481
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
482
|
+
}
|
|
483
|
+
/** The reason clause for a file that could not be opened, parsed or written, in its own words. */
|
|
484
|
+
function describeIoFailure(error) {
|
|
485
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
486
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
487
|
+
return collapsed.length > 0 ? clip(collapsed, 200) : 'the file could not be opened';
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* [[EXT-143]] — **the whole file could not be read, so nothing saved in it is in force.**
|
|
491
|
+
*
|
|
492
|
+
* It names the file, the reason (a JSON parser's own message points a hand-editor straight at their
|
|
493
|
+
* trailing comma) and the **consequence**, which is the part a user cannot infer. Saying only that
|
|
494
|
+
* a file failed to parse would leave the reader to guess whether their saved answers still hold.
|
|
495
|
+
*
|
|
496
|
+
* **The consequence stops at what is certain, and a prompt is not certain.** A failed load empties
|
|
497
|
+
* one rule list; it decides nothing. A call that list covered is then settled by whatever else the
|
|
498
|
+
* gate holds, so at `bypass` — or under any allow entry matching the same command, with the gate
|
|
499
|
+
* fully on — a saved refusal that broke does not come back as a question: the command runs, unasked.
|
|
500
|
+
* A sentence promising a prompt would understate the loss in exactly the configuration the deny
|
|
501
|
+
* store exists for, so this one names the possible outcomes and claims none of them.
|
|
502
|
+
*
|
|
503
|
+
* ## The level, for this notice and {@link skippedEntriesNotice} alike
|
|
504
|
+
*
|
|
505
|
+
* **{@link StatusLevel.ERROR}, not a warning**, and the axis is **filterability**. `consoleLevel` is
|
|
506
|
+
* user-configurable down to `error`, at which a WARNING is dropped entirely while the session runs
|
|
507
|
+
* on and an ERROR is still shown — and the whole defect being fixed is a user not being told. How
|
|
508
|
+
* much was lost is deliberately *not* the axis: bounded scope is a real argument about prominence,
|
|
509
|
+
* but a loss filtered to nothing is silence whatever its size, and silence is the thing this exists
|
|
510
|
+
* to end. Two supporting reasons apply to both notices equally: the state is one the user did not
|
|
511
|
+
* choose and cannot otherwise discover, unlike the `bypass` advisory (a WARNING) which describes
|
|
512
|
+
* something they just did; and it is time-limited, because the next saved entry rewrites this file
|
|
513
|
+
* from a store that never held what could not be read.
|
|
514
|
+
*
|
|
515
|
+
* Two bounds on that argument, so a later reader does not over-read it. ERROR is not unmissable —
|
|
516
|
+
* `consoleLevel: 'stream'` (6) filters ERROR (5) too, and the true claim is only that ERROR
|
|
517
|
+
* dominates WARNING at every setting. And it is an argument about the **console** surface: the Ink
|
|
518
|
+
* TUI does not consult `consoleLevel` at all, dropping only INFO/DEBUG from the transcript, so there
|
|
519
|
+
* a WARNING and an ERROR are equally visible and this reasoning buys nothing.
|
|
520
|
+
*
|
|
521
|
+
* It is **not** fatal. The session continues on whatever rules remain, because a refusal to start
|
|
522
|
+
* over a bookkeeping file would be a worse answer than any of them.
|
|
523
|
+
*/
|
|
524
|
+
function unreadableFileNotice(filePath, holds, reason) {
|
|
525
|
+
return {
|
|
526
|
+
level: StatusLevel.ERROR,
|
|
527
|
+
message: `Your saved shell ${savedNoun(holds)} could not be read from ${filePath} (${reason}). ` +
|
|
528
|
+
'None of them are in force in this session — nothing in this file applies to any call, so a ' +
|
|
529
|
+
'call it covered is left to the rest of the gate: it may be refused by another rule, it may ' +
|
|
530
|
+
'run without asking, or you may be prompted. Fix the file to restore them. Until you do, the ' +
|
|
531
|
+
'file is left as it is and answers you save are not written to it.',
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* [[EXT-144]] — **the answer was not saved, and the file it would have been saved to is untouched.**
|
|
536
|
+
*
|
|
537
|
+
* The write side of {@link unreadableFileNotice}, and the reason it can be worded as flatly as it
|
|
538
|
+
* is: nothing is lost by the time this is read. A persist rewrites the whole file from the store,
|
|
539
|
+
* and a store whose load failed holds nothing, so writing would have replaced everything the user
|
|
540
|
+
* saved with the one entry they just answered — a recoverable syntax error made permanent by the
|
|
541
|
+
* keystroke most likely to follow the notice that reported it.
|
|
542
|
+
*
|
|
543
|
+
* It says three things, and each is one the reader cannot infer:
|
|
544
|
+
*
|
|
545
|
+
* - **The answer was not saved**, which contradicts what the surface that took the answer already
|
|
546
|
+
* told them — a menu label promising `always` is written before this code runs, so silence here
|
|
547
|
+
* would leave a false claim standing as the last word.
|
|
548
|
+
* - **The file was left as it is**, which is the whole recovery wherever there is something to
|
|
549
|
+
* recover: the entries are still on disk and come back when the file parses. Nothing has to have
|
|
550
|
+
* been copied in advance, and nothing new was put beside it to reconcile. **An empty file is the
|
|
551
|
+
* exception and is worded separately**, because it holds no entries that could still be there and
|
|
552
|
+
* none that could come back — a sentence promising both would be this node's own defect, told
|
|
553
|
+
* rather than done.
|
|
554
|
+
* - **What to do** — fix the error the load already quoted, then answer again **in a new session**.
|
|
555
|
+
*
|
|
556
|
+
* **"In a new session" is load-bearing and must not be trimmed.** A store reads its file once and
|
|
557
|
+
* keeps what it found: the runner caches the instance for the life of the runner
|
|
558
|
+
* (`persistedGrantsLoaded` / `persistedDenialsLoaded`, set once and never reset), so a user who
|
|
559
|
+
* repairs the file and answers again in the same session is refused by the same cached state and
|
|
560
|
+
* gets this identical message in a loop. Telling them to answer again *now* would make the one
|
|
561
|
+
* sentence that exists to correct a false belief about their file into another one.
|
|
562
|
+
*
|
|
563
|
+
* {@link StatusLevel.ERROR}, on the same filterability axis argued in {@link unreadableFileNotice}:
|
|
564
|
+
* `consoleLevel` is user-configurable down to `error`, at which a WARNING is dropped entirely, and a
|
|
565
|
+
* user who believes an answer was saved when it was not is exactly who this exists for.
|
|
566
|
+
*
|
|
567
|
+
* **One per refused answer, deliberately not de-duplicated.** Each answer is a separate thing the
|
|
568
|
+
* user believes they have written down, and collapsing the second and third would leave two of those
|
|
569
|
+
* beliefs uncorrected.
|
|
570
|
+
*/
|
|
571
|
+
function refusedWriteNotice(filePath, holds, state) {
|
|
572
|
+
// A file with nothing recoverable in it has nothing to preserve, so the recovery sentence written
|
|
573
|
+
// for the case this guard exists for would be a false promise here: there is nothing still there
|
|
574
|
+
// and nothing that comes back. It is still not overwritten — see {@link StoreReadState} — so what
|
|
575
|
+
// the reader needs instead is the one action that makes the file readable again.
|
|
576
|
+
const recovery = state === 'holdsNothing'
|
|
577
|
+
? `It has been left exactly as it is, and there is nothing in it to recover: it holds no ` +
|
|
578
|
+
`saved shell ${savedNoun(holds)} — only empty space, or the punctuation of a file that was ` +
|
|
579
|
+
'cut short. Delete it, or put an empty pair of braces in it, and answer again in a new ' +
|
|
580
|
+
'session to save this one.'
|
|
581
|
+
: `Saving would have replaced everything in it with this one entry, so it has been left ` +
|
|
582
|
+
`exactly as it is and the shell ${savedNoun(holds)} you saved in it are still there. Fix ` +
|
|
583
|
+
'the error reported when it was read and they come back; then answer again in a new ' +
|
|
584
|
+
'session to add this one.';
|
|
585
|
+
return {
|
|
586
|
+
level: StatusLevel.ERROR,
|
|
587
|
+
message: `This answer was NOT saved to ${filePath}, because that file could not be read when this ` +
|
|
588
|
+
`session started. ${recovery} For now the answer applies to this session only.`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* [[EXT-149]] — **the file could be read, and could not be written.**
|
|
593
|
+
*
|
|
594
|
+
* The sibling of {@link refusedWriteNotice} and a different case from it. There the file holds
|
|
595
|
+
* somebody else's content and the store declines to touch it; here the store was entitled to write
|
|
596
|
+
* and the write threw — an unwritable checkout, a settings directory that has since gone, a full
|
|
597
|
+
* disk. Nothing was lost either way, which is why both can be worded this flatly.
|
|
598
|
+
*
|
|
599
|
+
* **It exists because this outcome used to be silent.** The throw is swallowed so a bookkeeping
|
|
600
|
+
* write can never end a run, and swallowing it left the one surface that had already promised the
|
|
601
|
+
* user something — a menu label reading `always`, a notice reading *removed from this project's
|
|
602
|
+
* saved refusals* — as the last word on a file that never changed.
|
|
603
|
+
*
|
|
604
|
+
* It names the file, the reason in the operating system's own words (which is what points a reader
|
|
605
|
+
* at a permission bit or a missing directory), and the consequence, which is the half nobody can
|
|
606
|
+
* infer and the half that differs per {@link WritePurpose}.
|
|
607
|
+
*
|
|
608
|
+
* {@link StatusLevel.ERROR} for the two the user answered for, on the filterability axis argued in
|
|
609
|
+
* {@link unreadableFileNotice}: `consoleLevel` is configurable down to `error`, where a WARNING is
|
|
610
|
+
* dropped entirely, and a user who believes an answer was written down when it was not is exactly
|
|
611
|
+
* who this exists for. A failed MIGRATION is a WARNING instead, and the difference is the axis
|
|
612
|
+
* itself: nobody was told anything about it, nothing they hold is wrong, and the whole consequence
|
|
613
|
+
* is that the same INFO notice appears again next session.
|
|
614
|
+
*/
|
|
615
|
+
function failedWriteNotice(filePath, holds, purpose, reason) {
|
|
616
|
+
if (purpose === 'lift') {
|
|
617
|
+
return {
|
|
618
|
+
level: StatusLevel.ERROR,
|
|
619
|
+
message: `${filePath} could NOT be updated (${reason}), so this entry is still saved in it. It is ` +
|
|
620
|
+
'lifted for the rest of this session and it will be back in the next one, until that file ' +
|
|
621
|
+
'can be written or you remove the entry from it by hand.',
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (purpose === 'migrate') {
|
|
625
|
+
return {
|
|
626
|
+
level: StatusLevel.WARNING,
|
|
627
|
+
message: `Your saved shell ${savedNoun(holds)} (${filePath}) could not be rewritten in the current ` +
|
|
628
|
+
`format (${reason}). They are in force for this session and the file is unchanged, so this ` +
|
|
629
|
+
'is reported again in your next session until that file can be written.',
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
level: StatusLevel.ERROR,
|
|
634
|
+
message: `This answer was NOT saved to ${filePath}, because that file could not be written ` +
|
|
635
|
+
`(${reason}). Nothing in it was lost — it was read normally and is left exactly as it is. ` +
|
|
636
|
+
'The answer applies to this session only; a new session will not have it.',
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* [[EXT-143]] — **the entries that could not be read, each named where it sits in the file.**
|
|
641
|
+
*
|
|
642
|
+
* A position and the text itself, because the point of the message is that the user can go and find
|
|
643
|
+
* the thing: this is a file they may have committed, and "one of your entries is malformed" sends
|
|
644
|
+
* them reading forty of them. One notice per file rather than one per entry, the same choice the
|
|
645
|
+
* migration notice makes — a line each would bury the count in its own repetition.
|
|
646
|
+
*
|
|
647
|
+
* **The same {@link StatusLevel.ERROR} the whole-file case gets**, on the same filterability axis,
|
|
648
|
+
* argued once in {@link unreadableFileNotice}. Bounded scope was the obvious reason to go quieter
|
|
649
|
+
* here and is the wrong axis: at `consoleLevel: error` a WARNING is filtered to nothing, and this
|
|
650
|
+
* case emits no file-level notice to fall back on, so the one refusal the human typed would be lost
|
|
651
|
+
* in exactly the silence the whole notice exists to end. The bound is still worth saying, and the
|
|
652
|
+
* message says it — the rest of the file is in force — which is a statement to the reader, not a
|
|
653
|
+
* reason to make it easier to miss.
|
|
654
|
+
*
|
|
655
|
+
* The level is the same on both sides for a second reason: raising it only for a broken *deny* entry
|
|
656
|
+
* would make {@link PersistedApprovalGrantsOptions.holds} decide something, and it is a noun. A
|
|
657
|
+
* per-side level means reopening that seam deliberately, not arriving there by wording.
|
|
658
|
+
*/
|
|
659
|
+
function skippedEntriesNotice(filePath, holds, skipped) {
|
|
660
|
+
const named = skipped
|
|
661
|
+
.slice(0, SKIPPED_ENTRIES_NAMED)
|
|
662
|
+
.map(({ position, value }) => {
|
|
663
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
664
|
+
return `entry ${position} — ${clip(rendered, SKIPPED_ENTRY_CHARS)}`;
|
|
665
|
+
})
|
|
666
|
+
.join('; ');
|
|
667
|
+
const unnamed = skipped.length - SKIPPED_ENTRIES_NAMED;
|
|
668
|
+
const rest = unnamed > 0 ? `; and ${unnamed} more` : '';
|
|
669
|
+
const one = skipped.length === 1;
|
|
670
|
+
return {
|
|
671
|
+
level: StatusLevel.ERROR,
|
|
672
|
+
message: `${skipped.length} ${one ? 'entry' : 'entries'} in your saved shell ${savedNoun(holds)} ` +
|
|
673
|
+
`(${filePath}) could not be read and ${one ? 'was' : 'were'} skipped: ${named}${rest}. ` +
|
|
674
|
+
`${one ? 'It is' : 'They are'} not in force; the rest of the file is.`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* [[EXT-151]] — **the v1 members the migration could not carry, and the rewrite therefore DELETED.**
|
|
679
|
+
*
|
|
680
|
+
* The migration's twin of {@link skippedEntriesNotice}, and a separate message because the two
|
|
681
|
+
* outcomes differ in the one way the reader cares about: a skipped v2 entry is still in their file
|
|
682
|
+
* and can be fixed, and a dropped v1 prefix is gone from it the moment the migration writes. Saying
|
|
683
|
+
* so is the whole point — this path used to be the one place a loss happened with a sentence beside
|
|
684
|
+
* it claiming that nothing had been removed.
|
|
685
|
+
*
|
|
686
|
+
* Same shape as its twin — a position and the text itself, capped at {@link SKIPPED_ENTRIES_NAMED}
|
|
687
|
+
* with a count for the rest — because the reader's job is the same: find the thing in a file they
|
|
688
|
+
* may have committed. Same {@link StatusLevel.ERROR}, on the filterability axis argued in
|
|
689
|
+
* {@link unreadableFileNotice}.
|
|
690
|
+
*
|
|
691
|
+
* **Only the caller that performed the rewrite may send this**, and only when the rewrite landed:
|
|
692
|
+
* every sentence in it is about a file that has already changed. The constructor is that caller, and
|
|
693
|
+
* the read hands it the dropped members rather than reporting them from inside itself.
|
|
694
|
+
*/
|
|
695
|
+
function droppedPrefixesNotice(filePath, holds, dropped) {
|
|
696
|
+
const named = dropped
|
|
697
|
+
.slice(0, SKIPPED_ENTRIES_NAMED)
|
|
698
|
+
.map(({ position, value }) => {
|
|
699
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
700
|
+
return `entry ${position} — ${clip(rendered, SKIPPED_ENTRY_CHARS)}`;
|
|
701
|
+
})
|
|
702
|
+
.join('; ');
|
|
703
|
+
const unnamed = dropped.length - SKIPPED_ENTRIES_NAMED;
|
|
704
|
+
const rest = unnamed > 0 ? `; and ${unnamed} more` : '';
|
|
705
|
+
const one = dropped.length === 1;
|
|
706
|
+
return {
|
|
707
|
+
level: StatusLevel.ERROR,
|
|
708
|
+
message: `${dropped.length} ${one ? 'entry' : 'entries'} in your saved shell ${savedNoun(holds)} ` +
|
|
709
|
+
`(${filePath}) could not be carried into the current format and ${one ? 'has' : 'have'} been ` +
|
|
710
|
+
`REMOVED from the file: ${named}${rest}. ${one ? 'It is' : 'They are'} not in force and the ` +
|
|
711
|
+
`file no longer holds ${one ? 'it' : 'them'}; add ${one ? 'it' : 'them'} back as a command ` +
|
|
712
|
+
'if you still want it.',
|
|
713
|
+
};
|
|
714
|
+
}
|
|
337
715
|
/**
|
|
338
716
|
* The persisted (`always`) grant store, backed by a JSON file whose path is injected (the runner
|
|
339
|
-
* resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json`
|
|
340
|
-
* at a temp dir.
|
|
717
|
+
* resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json` for approvals and
|
|
718
|
+
* `…/shell-denylist.json` for refusals) so tests can point it at a temp dir.
|
|
719
|
+
*
|
|
720
|
+
* **One class, two files.** Which list a store's entries belong to is the caller's question, not
|
|
721
|
+
* this class's: it holds {@link ApprovalGrant} records and writes them back. The only thing either
|
|
722
|
+
* side configures is {@link PersistedApprovalGrantsOptions.legacyPrefixMigration}, which the deny
|
|
723
|
+
* store turns off.
|
|
341
724
|
*
|
|
342
725
|
* ## The v1 migration, and the direction it goes
|
|
343
726
|
*
|
|
@@ -355,51 +738,196 @@ export class ApprovalGrantStore {
|
|
|
355
738
|
export class PersistedApprovalGrants {
|
|
356
739
|
store;
|
|
357
740
|
filePath;
|
|
741
|
+
onNotice;
|
|
742
|
+
holds;
|
|
743
|
+
/**
|
|
744
|
+
* [[EXT-144]] — **what the load found in the file**, which decides whether it may ever be
|
|
745
|
+
* rewritten ({@link StoreReadState}). Anything but `readable` is a file whose load failed, exactly
|
|
746
|
+
* the outcomes {@link unreadableFileNotice} reports, and none of them may be written over.
|
|
747
|
+
*
|
|
748
|
+
* **An entry-level loss is deliberately not one of them.** A file whose `grants` array parsed but
|
|
749
|
+
* held one malformed member is a file this version *can* read: the rest of it is in force, which
|
|
750
|
+
* {@link skippedEntriesNotice} states to the user as a promise, and blocking every future save
|
|
751
|
+
* over one cosmetic typo would disable a working feature to protect an entry the reader has
|
|
752
|
+
* already been pointed at — by position and quoted text for the first
|
|
753
|
+
* {@link SKIPPED_ENTRIES_NAMED}, and by a count of the rest beyond that.
|
|
754
|
+
*/
|
|
755
|
+
readState;
|
|
756
|
+
/**
|
|
757
|
+
* [[EXT-151]] — the top-level keys the read found and this version does not use, written back on
|
|
758
|
+
* every rewrite so a save cannot delete the parts of a user's file we do not recognise.
|
|
759
|
+
*/
|
|
760
|
+
preserved;
|
|
761
|
+
/**
|
|
762
|
+
* Whether the file is believed to hold what this store holds — true after a read that succeeded
|
|
763
|
+
* or a write that landed, false after one that was refused or threw.
|
|
764
|
+
*
|
|
765
|
+
* It exists so {@link add} can answer honestly for an entry it did not have to write: "already
|
|
766
|
+
* held" is only "already in the file" if the file ever received it, and on a read-only checkout it
|
|
767
|
+
* did not.
|
|
768
|
+
*/
|
|
769
|
+
inSync;
|
|
358
770
|
constructor(filePath, options) {
|
|
359
771
|
this.filePath = filePath;
|
|
360
|
-
|
|
772
|
+
this.onNotice = options?.onNotice;
|
|
773
|
+
this.holds = options?.holds;
|
|
774
|
+
const { grants, migrated, readState, preserved, dropped } = PersistedApprovalGrants.load(filePath, options);
|
|
775
|
+
this.readState = readState;
|
|
776
|
+
this.preserved = preserved;
|
|
777
|
+
this.inSync = readState === 'readable';
|
|
361
778
|
this.store = new ApprovalGrantStore(grants);
|
|
362
|
-
if (migrated)
|
|
363
|
-
|
|
779
|
+
if (migrated) {
|
|
780
|
+
// [[EXT-151]] — the loss is announced only once the rewrite that causes it has landed.
|
|
781
|
+
// The notice tells the reader their entries are gone from the file, and if this write failed
|
|
782
|
+
// the file is untouched: it still holds every prefix, still in the old format, and the next
|
|
783
|
+
// session migrates it again. Announcing the loss anyway would be a sentence about a file that
|
|
784
|
+
// contradicts the file — this node's whole subject.
|
|
785
|
+
if (this.tryPersist('migrate') && dropped.length > 0) {
|
|
786
|
+
this.onNotice?.(droppedPrefixesNotice(filePath, this.holds, dropped));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
364
789
|
}
|
|
365
|
-
|
|
790
|
+
/**
|
|
791
|
+
* Read the file, and **report anything it lost on the way** ([[EXT-143]]).
|
|
792
|
+
*
|
|
793
|
+
* Every outcome that drops something a human saved reaches {@link
|
|
794
|
+
* PersistedApprovalGrantsOptions.onNotice}: an unreadable or unparseable file, a file whose shape
|
|
795
|
+
* this version does not recognise — a v1 `prefixes` file on the deny side, where the migration is
|
|
796
|
+
* deliberately off, is one — and any individual entry the grammar rejects. **Silence is reserved
|
|
797
|
+
* for the outcomes that lost nothing**, of which there are three: a file that is not there, one
|
|
798
|
+
* that reads cleanly, and one whose entry list is absent or empty ({@link holdsSavedEntries}).
|
|
799
|
+
*
|
|
800
|
+
* The recovery is unchanged and deliberately unchanged: a failure here yields fewer grants rather
|
|
801
|
+
* than throwing. **What that degrades to is not the same on the two sides**, which is why the
|
|
802
|
+
* notice describes the loss and not an outcome — a lost `always` approval means the human is asked
|
|
803
|
+
* again, while a lost `always` refusal means nothing refuses, and at `bypass` the call simply runs.
|
|
804
|
+
*/
|
|
805
|
+
static load(filePath, options) {
|
|
806
|
+
const onNotice = options?.onNotice;
|
|
807
|
+
const holds = options?.holds;
|
|
808
|
+
const empty = {
|
|
809
|
+
grants: [],
|
|
810
|
+
migrated: false,
|
|
811
|
+
readState: 'readable',
|
|
812
|
+
preserved: {},
|
|
813
|
+
dropped: [],
|
|
814
|
+
};
|
|
815
|
+
let parsed;
|
|
816
|
+
/**
|
|
817
|
+
* The file's own bytes, kept so a failed parse can tell an EMPTY file from a full one
|
|
818
|
+
* ([[EXT-144]]). `undefined` means the read itself threw — a permission error, say — where the
|
|
819
|
+
* content is unknown and the conservative answer is that there is something to protect.
|
|
820
|
+
*/
|
|
821
|
+
let raw;
|
|
822
|
+
/**
|
|
823
|
+
* The unrecognised-shape notice — but only where something was actually lost.
|
|
824
|
+
*
|
|
825
|
+
* [[EXT-144]] — and the write guard asks this same question, so the notice and the guard can
|
|
826
|
+
* never disagree about whether this file holds something we failed to read. A shape that holds
|
|
827
|
+
* saved entries is content by definition, so it is never the `blank` case.
|
|
828
|
+
*/
|
|
829
|
+
const reportLostToShape = () => {
|
|
830
|
+
const lost = holdsSavedEntries(parsed);
|
|
831
|
+
if (lost)
|
|
832
|
+
onNotice?.(unreadableFileNotice(filePath, holds, UNRECOGNISED_SHAPE));
|
|
833
|
+
// [[EXT-151]] — a shape that lost nothing is still WRITTEN later, so its keys are carried the
|
|
834
|
+
// same way a readable file's are: `{"version": 2, "note": "…"}` holds no entry list and no
|
|
835
|
+
// loss, and a rewrite that dropped the note would be this node's defect on a third path. A
|
|
836
|
+
// shape that DID lose something is never rewritten, so it has nothing to carry.
|
|
837
|
+
return {
|
|
838
|
+
...empty,
|
|
839
|
+
readState: lost ? 'holdsContent' : 'readable',
|
|
840
|
+
preserved: !lost && parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
841
|
+
? preservedKeys(parsed, OWN_KEYS_V2)
|
|
842
|
+
: {},
|
|
843
|
+
};
|
|
844
|
+
};
|
|
366
845
|
try {
|
|
367
846
|
if (!existsSync(filePath))
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return {
|
|
847
|
+
return empty;
|
|
848
|
+
raw = readFileSync(filePath, 'utf8');
|
|
849
|
+
parsed = JSON.parse(raw);
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
// Corrupt / unreadable → behave as empty. An empty store approves nothing by itself, so there
|
|
853
|
+
// is nothing to gain by throwing at a user mid-run — but empty is NOT fail-closed on the deny
|
|
854
|
+
// side, where it refuses nothing either and a lost refusal at `bypass` is a command that runs.
|
|
855
|
+
// That asymmetry is the whole reason they are told rather than quietly degraded.
|
|
856
|
+
onNotice?.(unreadableFileNotice(filePath, holds, describeIoFailure(e)));
|
|
857
|
+
// [[EXT-144]] — the store is empty, so a persist from here would replace whatever the file
|
|
858
|
+
// holds with what this session saves next. Which is the whole danger where the file holds
|
|
859
|
+
// something a human could get back, and nothing at all where it holds nothing — a distinction
|
|
860
|
+
// the user is told about, and the reason this is not one boolean. Nothing is preserved on this
|
|
861
|
+
// path: the parse failed, so there are no keys to carry, and the file is not rewritten anyway.
|
|
862
|
+
return {
|
|
863
|
+
...empty,
|
|
864
|
+
readState: holdsRecoverableText(raw) ? 'holdsContent' : 'holdsNothing',
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
if (!parsed || typeof parsed !== 'object')
|
|
868
|
+
return reportLostToShape();
|
|
869
|
+
const record = parsed;
|
|
870
|
+
if (Array.isArray(record.grants)) {
|
|
871
|
+
const fallbackTime = fileWriteTime(filePath);
|
|
872
|
+
const grants = [];
|
|
873
|
+
const skipped = [];
|
|
874
|
+
record.grants.forEach((value, index) => {
|
|
875
|
+
const grant = readGrant(value, fallbackTime);
|
|
876
|
+
// The position is the entry's place in the file's own list, 1-based, so the number in the
|
|
877
|
+
// message is a number the reader can count to in their editor.
|
|
878
|
+
if (grant === null)
|
|
879
|
+
skipped.push({ position: index + 1, value });
|
|
880
|
+
else
|
|
881
|
+
grants.push(grant);
|
|
882
|
+
});
|
|
883
|
+
if (skipped.length > 0)
|
|
884
|
+
onNotice?.(skippedEntriesNotice(filePath, holds, skipped));
|
|
885
|
+
// Still `readable`, even with entries skipped: this file WAS read, and the entries around a
|
|
886
|
+
// malformed one are in force. See {@link PersistedApprovalGrants.readState}.
|
|
887
|
+
//
|
|
888
|
+
// [[EXT-151]] — and this is the path where a rewrite used to delete the rest of the user's
|
|
889
|
+
// file. It read cleanly, so nothing warned them; the next save wrote back `version` and
|
|
890
|
+
// `grants` and dropped every other key they had typed.
|
|
891
|
+
return {
|
|
892
|
+
grants,
|
|
893
|
+
migrated: false,
|
|
894
|
+
readState: 'readable',
|
|
895
|
+
preserved: preservedKeys(record, OWN_KEYS_V2),
|
|
896
|
+
dropped: [],
|
|
897
|
+
};
|
|
384
898
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// ever means "prompt", so there is nothing to gain by throwing at a user mid-run.
|
|
388
|
-
return { grants: [], migrated: false };
|
|
899
|
+
if ((options?.legacyPrefixMigration ?? true) && Array.isArray(record.prefixes)) {
|
|
900
|
+
return PersistedApprovalGrants.migrateFromV1(record.prefixes, record, filePath, holds, onNotice);
|
|
389
901
|
}
|
|
902
|
+
return reportLostToShape();
|
|
390
903
|
}
|
|
391
|
-
/**
|
|
392
|
-
|
|
904
|
+
/**
|
|
905
|
+
* Each v1 prefix → an `exact` entry for the same string, with ONE notice naming the file.
|
|
906
|
+
*
|
|
907
|
+
* **A prefix this cannot migrate is REMOVED from the file, and is named** ([[EXT-151]]). A member
|
|
908
|
+
* that is not a string, or that normalizes to nothing, becomes no entry — and the rewrite below is
|
|
909
|
+
* what makes that permanent. That is a real loss on a path where nothing else reports one: the
|
|
910
|
+
* skipped-entries notice belongs to the v2 reader and never fires here. It is reported at
|
|
911
|
+
* {@link StatusLevel.ERROR} on the same filterability axis every other loss on this seam uses.
|
|
912
|
+
*
|
|
913
|
+
* **And the migration notice no longer claims that nothing was removed while removing something.**
|
|
914
|
+
* That sentence is kept for the case where it is true — which is the ordinary case, and where the
|
|
915
|
+
* reassurance is worth having — and dropped where a member went.
|
|
916
|
+
*/
|
|
917
|
+
static migrateFromV1(prefixes, record, filePath, holds, onNotice) {
|
|
393
918
|
const grantedAt = fileWriteTime(filePath);
|
|
394
919
|
const migrated = new ApprovalGrantStore();
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
920
|
+
const dropped = [];
|
|
921
|
+
prefixes.forEach((prefix, index) => {
|
|
922
|
+
// The position is the member's place in the file's own `prefixes` list, 1-based, so the number
|
|
923
|
+
// in the message is a number the reader can count to in their editor.
|
|
924
|
+
const entry = typeof prefix === 'string' ? shellGrantEntry(prefix) : null;
|
|
925
|
+
if (entry === null || entry.pattern.length === 0) {
|
|
926
|
+
dropped.push({ position: index + 1, value: prefix });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
401
929
|
migrated.add({ entry, grantedAt, scope: 'always' });
|
|
402
|
-
}
|
|
930
|
+
});
|
|
403
931
|
if (migrated.size() > 0) {
|
|
404
932
|
// ONE notice for the whole file, not one per entry: the user needs to know their saved
|
|
405
933
|
// approvals changed meaning, once, and a line per entry would bury that in its own repetition.
|
|
@@ -408,10 +936,23 @@ export class PersistedApprovalGrants {
|
|
|
408
936
|
message: `Your saved shell approvals (${filePath}) were stored in an older format that remembered ` +
|
|
409
937
|
'a command PREFIX, which also approved longer commands starting with it. Each is now ' +
|
|
410
938
|
'remembered as exactly the command it was, so a variant with extra arguments will ask ' +
|
|
411
|
-
|
|
939
|
+
`again.${dropped.length === 0 ? ' Nothing was removed;' : ''} some commands may prompt ` +
|
|
940
|
+
'once more.',
|
|
412
941
|
});
|
|
413
942
|
}
|
|
414
|
-
|
|
943
|
+
// The dropped members are NOT reported here. They are handed to the constructor, which reports
|
|
944
|
+
// them only if its rewrite actually lands — see the dropped field on LoadedGrants.
|
|
945
|
+
//
|
|
946
|
+
// A v1 file parsed, so this is not the whole-file loss the write guard exists for, and the
|
|
947
|
+
// migration rewrite goes ahead — carrying back every top-level key that is not the two this
|
|
948
|
+
// migration consumes, so a user who kept a note beside their prefixes still has it afterwards.
|
|
949
|
+
return {
|
|
950
|
+
grants: migrated.list(),
|
|
951
|
+
migrated: true,
|
|
952
|
+
readState: 'readable',
|
|
953
|
+
preserved: preservedKeys(record, OWN_KEYS_V1),
|
|
954
|
+
dropped,
|
|
955
|
+
};
|
|
415
956
|
}
|
|
416
957
|
/** Every grant. */
|
|
417
958
|
list() {
|
|
@@ -425,44 +966,148 @@ export class PersistedApprovalGrants {
|
|
|
425
966
|
size() {
|
|
426
967
|
return this.store.size();
|
|
427
968
|
}
|
|
428
|
-
/**
|
|
969
|
+
/**
|
|
970
|
+
* [[EXT-144]] — **may this store write its file at all?** False when the load failed, in which
|
|
971
|
+
* case every write is refused.
|
|
972
|
+
*
|
|
973
|
+
* Public because the caller has to know *before* it builds the record it is about to hand over:
|
|
974
|
+
* a grant is stamped with the scope it actually got, the runner's in-memory stores can hold the
|
|
975
|
+
* very object they are passed, and a scope corrected after the fact would be corrected inside
|
|
976
|
+
* somebody else's store. Asking first is what lets an answer that cannot be written down be
|
|
977
|
+
* recorded as the session-only thing it is.
|
|
978
|
+
*
|
|
979
|
+
* **It answers about the FILE being readable, never about a write succeeding.** A store whose
|
|
980
|
+
* path cannot be written — a read-only checkout — still answers true here, because nothing was
|
|
981
|
+
* lost by reading it and the next write may well land.
|
|
982
|
+
*/
|
|
983
|
+
canPersist() {
|
|
984
|
+
return this.readState === 'readable';
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Add a grant and persist the whole store. A duplicate entry rewrites nothing.
|
|
988
|
+
*
|
|
989
|
+
* **Returns whether the grant is now recorded in the file** ([[EXT-144]]), which is not the same
|
|
990
|
+
* question as whether it is in force — it is in force either way, held here and in the runner's
|
|
991
|
+
* session store. `false` says only that a restart will not find it.
|
|
992
|
+
*/
|
|
429
993
|
add(grant) {
|
|
994
|
+
// A duplicate wrote nothing, so the honest answer is whether the FILE already received what this
|
|
995
|
+
// store holds. `canPersist()` would be the wrong question and a false promise: on a read-only
|
|
996
|
+
// checkout it is true while every write has thrown, so re-answering an entry the store already
|
|
997
|
+
// holds would report it as recorded in a file that never got it.
|
|
430
998
|
if (!this.store.add(grant))
|
|
431
|
-
return;
|
|
432
|
-
this.tryPersist()
|
|
999
|
+
return this.inSync;
|
|
1000
|
+
if (this.tryPersist('save'))
|
|
1001
|
+
return true;
|
|
1002
|
+
// [[EXT-149]] — **a write that did not land must not leave this store claiming the grant**,
|
|
1003
|
+
// whether it was refused or merely failed. What this store holds is what the approvals display
|
|
1004
|
+
// labels as *saved to this project* (`getRefusals`) and counts as persisted
|
|
1005
|
+
// (`getAllowlistCounts`), and what {@link remove} offers to delete from a file — so a grant kept
|
|
1006
|
+
// here after a failed write would be rendered as written down, and then "lifted" out of a file
|
|
1007
|
+
// that never had it.
|
|
1008
|
+
//
|
|
1009
|
+
// [[EXT-144]] drew this line at the REFUSED write only, on the ground that a failed one leaves
|
|
1010
|
+
// the file this store's own and the next call may well succeed. What that argument misses is
|
|
1011
|
+
// that nothing is lost by dropping it: the runner holds every answer in its own session store,
|
|
1012
|
+
// so the human's answer stays in force for the session either way. What it costs is real and
|
|
1013
|
+
// small — a checkout that becomes writable mid-session writes only the answers given after that
|
|
1014
|
+
// — and a display that says `session` about a session-only answer is worth more.
|
|
1015
|
+
this.store.remove(grant.entry);
|
|
1016
|
+
return false;
|
|
433
1017
|
}
|
|
434
1018
|
/** The grant held under this entry's identity, or `undefined`. */
|
|
435
1019
|
find(entry) {
|
|
436
1020
|
return this.store.find(entry);
|
|
437
1021
|
}
|
|
438
1022
|
/**
|
|
439
|
-
* Drop the grant held under this entry's identity and rewrite the file.
|
|
440
|
-
*
|
|
1023
|
+
* Drop the grant held under this entry's identity and rewrite the file.
|
|
1024
|
+
*
|
|
1025
|
+
* **Returns whether the deletion reached the FILE** ([[EXT-149]]) — the mirror of what {@link add}
|
|
1026
|
+
* answers, and not the same question as whether the entry is still in force here. The in-memory
|
|
1027
|
+
* removal happens either way and is what lifts the entry for this session; `false` says only that
|
|
1028
|
+
* a restart will find it again. It used to return `true` after a write that threw, which is how
|
|
1029
|
+
* the `/approvals` lift came to report a deletion that had not happened.
|
|
1030
|
+
*
|
|
1031
|
+
* `false` is also the answer when there was no such grant. The two are distinguishable with
|
|
1032
|
+
* {@link find} beforehand, and the caller that reports to a user has already established the entry
|
|
1033
|
+
* was there — it is offering to lift something it just listed.
|
|
441
1034
|
*
|
|
442
1035
|
* The write is what makes §4.7.4's invalidation a one-time event: a removal held only in memory
|
|
443
1036
|
* would be undone by the next session reloading the same stale snapshot, so the user would be
|
|
444
1037
|
* told their grant had been withdrawn once per session, forever.
|
|
1038
|
+
*
|
|
1039
|
+
* **The [[EXT-144]] refusal cannot strand a removal half-done, and by construction rather than by
|
|
1040
|
+
* a check here:** a store that could not read its file is empty — the load recovered nothing, and
|
|
1041
|
+
* {@link add} takes back what it could not write — so there is never a grant to remove, and this
|
|
1042
|
+
* returns before reaching the write. If that invariant is ever broken, restore it rather than
|
|
1043
|
+
* teaching this method to unwind.
|
|
445
1044
|
*/
|
|
446
1045
|
remove(entry) {
|
|
447
1046
|
if (!this.store.remove(entry))
|
|
448
1047
|
return false;
|
|
449
|
-
this.tryPersist();
|
|
450
|
-
return true;
|
|
1048
|
+
return this.tryPersist('lift');
|
|
451
1049
|
}
|
|
452
1050
|
/**
|
|
453
|
-
* Write the file
|
|
454
|
-
* read-only checkout must not end a run over a bookkeeping
|
|
1051
|
+
* Write the file, and **report whether the write landed**. Never throws: the grants are already
|
|
1052
|
+
* in force for this session, and a read-only checkout must not end a run over a bookkeeping
|
|
1053
|
+
* write.
|
|
1054
|
+
*
|
|
1055
|
+
* **Never throws is not never says.** [[EXT-149]] — a swallowed throw left the surfaces that had
|
|
1056
|
+
* already promised the user something as the last word on a file that never changed, so a failed
|
|
1057
|
+
* write is reported ({@link failedWriteNotice}) in the words of whichever {@link WritePurpose}
|
|
1058
|
+
* asked for it.
|
|
1059
|
+
*
|
|
1060
|
+
* ## [[EXT-144]] — it refuses to write over a file it could not read
|
|
1061
|
+
*
|
|
1062
|
+
* This is a WHOLE-FILE rewrite from what the store holds, and a store whose load failed holds
|
|
1063
|
+
* nothing. So without this guard one saved answer replaces every entry in the file with itself:
|
|
1064
|
+
* a trailing comma — the characteristic failure of a file people hand-edit and commit — becomes
|
|
1065
|
+
* unrecoverable loss at the next prompt, with no backup and nothing on disk to go back to.
|
|
1066
|
+
*
|
|
1067
|
+
* **The deny side is why it refuses rather than saving a copy first.** A user whose refusals have
|
|
1068
|
+
* silently stopped applying reaches for *always reject*, and that keystroke is what would make the
|
|
1069
|
+
* loss permanent. Refusing leaves the file exactly as they left it: fix the comma and all of it
|
|
1070
|
+
* comes back, with nothing to reconcile and nothing needing to have been copied in advance.
|
|
1071
|
+
*
|
|
1072
|
+
* **A sibling `.corrupt` copy was the alternative, and it is worse in this system**, because it
|
|
1073
|
+
* would leave the user holding a merge they cannot perform — the live file with the one entry they
|
|
1074
|
+
* just answered, a copy beside it with the twenty they had, and no tool to combine them. It would
|
|
1075
|
+
* also write a second file into a directory people commit, and it would make the file parse again,
|
|
1076
|
+
* so the load-time error that is the user's only signal would go quiet while nineteen refusals
|
|
1077
|
+
* stayed out of force. And its guarantee is conditional on a write that can itself fail, which is
|
|
1078
|
+
* the same read-only checkout this method already has to survive.
|
|
1079
|
+
*
|
|
1080
|
+
* The cost is a re-prompt and nothing else, which is the direction every ambiguity in this design
|
|
1081
|
+
* resolves — and it is the same degradation the load side already makes ([[EXT-107]]: a store that
|
|
1082
|
+
* cannot be read means `always` becomes `session`), now applied consistently to the write.
|
|
455
1083
|
*/
|
|
456
|
-
tryPersist() {
|
|
1084
|
+
tryPersist(purpose) {
|
|
1085
|
+
if (this.readState !== 'readable') {
|
|
1086
|
+
this.onNotice?.(refusedWriteNotice(this.filePath, this.holds, this.readState));
|
|
1087
|
+
this.inSync = false;
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
457
1090
|
const file = {
|
|
458
1091
|
version: PERSISTED_VERSION,
|
|
459
1092
|
grants: this.store.list(),
|
|
460
1093
|
};
|
|
461
1094
|
try {
|
|
462
|
-
|
|
1095
|
+
// [[EXT-151]] — the user's own keys FIRST, so `version` and `grants` cannot be displaced by a
|
|
1096
|
+
// preserved key of the same name. `preservedKeys` excludes both, so this is belt and braces —
|
|
1097
|
+
// and it is the ordering that keeps it so, which is why it is stated rather than assumed.
|
|
1098
|
+
const written = { ...this.preserved, ...file };
|
|
1099
|
+
writeFileSync(this.filePath, JSON.stringify(written, null, 2) + '\n', 'utf8');
|
|
1100
|
+
this.inSync = true;
|
|
1101
|
+
return true;
|
|
463
1102
|
}
|
|
464
|
-
catch {
|
|
465
|
-
//
|
|
1103
|
+
catch (e) {
|
|
1104
|
+
// The throw is swallowed — a bookkeeping write must not end a run — but it is not SILENT
|
|
1105
|
+
// ([[EXT-149]]). The file no longer holds what this store holds, which is what {@link add}
|
|
1106
|
+
// and {@link remove} report to their callers, and the user is told in the words of whatever
|
|
1107
|
+
// they were promised.
|
|
1108
|
+
this.onNotice?.(failedWriteNotice(this.filePath, this.holds, purpose, describeIoFailure(e)));
|
|
1109
|
+
this.inSync = false;
|
|
1110
|
+
return false;
|
|
466
1111
|
}
|
|
467
1112
|
}
|
|
468
1113
|
}
|