@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
@@ -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 stores hold them — an in-memory
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 the project's JSON file for `always` — and neither
10
- * decides anything. They hold entries; `core/approvals/matcher.ts` compares them. There is exactly
11
- * one comparison engine and it is not here.
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 stay fail-closed-on-auto-approval: a missing, unreadable, malformed or partly-malformed
57
- * file yields fewer grants (at worst none) rather than throwing, because an empty allow-list only
58
- * ever means *prompt*.
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`) so tests can point it
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
- const { grants, migrated } = PersistedApprovalGrants.load(filePath, options?.onNotice);
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
- this.tryPersist();
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
- static load(filePath, onNotice) {
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 { grants: [], migrated: false };
369
- const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
370
- if (!parsed || typeof parsed !== 'object')
371
- return { grants: [], migrated: false };
372
- const record = parsed;
373
- if (Array.isArray(record.grants)) {
374
- const fallbackTime = fileWriteTime(filePath);
375
- const grants = record.grants
376
- .map((value) => readGrant(value, fallbackTime))
377
- .filter((grant) => grant !== null);
378
- return { grants, migrated: false };
379
- }
380
- if (Array.isArray(record.prefixes)) {
381
- return PersistedApprovalGrants.migrateFromV1(record.prefixes, filePath, onNotice);
382
- }
383
- return { grants: [], migrated: false };
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
- catch {
386
- // Corrupt / unreadable → behave as empty. Fail-closed on auto-approval: an empty store only
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
- /** Each v1 prefix → an `exact` entry for the same string, with ONE notice naming the file. */
392
- static migrateFromV1(prefixes, filePath, onNotice) {
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
- for (const prefix of prefixes) {
396
- if (typeof prefix !== 'string')
397
- continue;
398
- const entry = shellGrantEntry(prefix);
399
- if (entry.pattern.length === 0)
400
- continue;
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
- 'again. Nothing was removed; some commands may prompt once more.',
939
+ `again.${dropped.length === 0 ? ' Nothing was removed;' : ''} some commands may prompt ` +
940
+ 'once more.',
412
941
  });
413
942
  }
414
- return { grants: migrated.list(), migrated: true };
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
- /** Add a grant and persist the whole store. A duplicate entry rewrites nothing. */
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. Returns whether one was
440
- * there.
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. Never throws: the grants are already in force for this session, and a
454
- * read-only checkout must not end a run over a bookkeeping write.
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
- writeFileSync(this.filePath, JSON.stringify(file, null, 2) + '\n', 'utf8');
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
- // Intentionally swallowed — see the doc comment.
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
  }