@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -0,0 +1,831 @@
1
+ "use strict";
2
+ /**
3
+ * Capability ledger module — ADR-1244 Phase 3 (Decision D4).
4
+ *
5
+ * Manages a per-runtime install manifest (`.gsd-capabilities.json`) that records
6
+ * what each capability install wrote. Serves as the atomic commit point and
7
+ * reconciliation basis for Phase 4 upgrade/remove operations.
8
+ *
9
+ * LEAF MODULE — imports ONLY: node:fs, node:path, node:crypto. No other src/ imports.
10
+ *
11
+ * Exports:
12
+ * readLedger(runtimeDir) — structural-validated read, never throws
13
+ * readLedgerStrict(runtimeDir) — like readLedger but throws CorruptLedgerError when
14
+ * the file exists but is unparseable/invalid. The
15
+ * corrupt file is LEFT IN PLACE (not moved/quarantined)
16
+ * so every subsequent op also blocks until the user
17
+ * inspects and resolves it.
18
+ * writeLedger(runtimeDir, ledger) — atomic write (tmp + rename, crash-safe)
19
+ * recordInstall(runtimeDir, entry) — idempotent upsert of a ledger entry
20
+ * removeEntry(runtimeDir, capId) — remove a single entry by id
21
+ * reconcile(runtimeDir) — report orphans / stale entries (read-only)
22
+ * CorruptLedgerError — thrown by readLedgerStrict on corruption
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ const node_crypto_1 = __importDefault(require("node:crypto"));
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+ const LEDGER_FILE_NAME = '.gsd-capabilities.json';
34
+ const LEDGER_SCHEMA_VERSION = '1';
35
+ // ---------------------------------------------------------------------------
36
+ // CorruptLedgerError
37
+ // ---------------------------------------------------------------------------
38
+ /**
39
+ * Thrown by `readLedgerStrict` when the ledger file is present but cannot be
40
+ * parsed or is structurally invalid. The corrupt file is LEFT IN PLACE so that
41
+ * every subsequent operation also blocks until the user resolves it manually.
42
+ * Recovery: inspect the file, restore a backup, or move it aside to start fresh.
43
+ */
44
+ class CorruptLedgerError extends Error {
45
+ /** Absolute path of the corrupt ledger file. */
46
+ ledgerPath;
47
+ constructor(message, ledgerPath) {
48
+ super(message);
49
+ this.name = 'CorruptLedgerError';
50
+ this.ledgerPath = ledgerPath;
51
+ }
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // IO helpers
55
+ // ---------------------------------------------------------------------------
56
+ /** Pattern for valid capability IDs (must match this to be accepted as ledger keys). */
57
+ const VALID_ID_RE = /^[a-z][a-z0-9-]*$/;
58
+ /**
59
+ * DOS-3 / finding 5(a): GENEROUS DoS backstop bounds — NOT product limits. No legitimate capability
60
+ * declares this many files or shared-config edits, but a hostile ledger with a 100k+-element array
61
+ * is rejected before it can be iterated/spread into a Set (memory/CPU DoS). Raised from the prior
62
+ * 256/64 (which risked false-rejecting large-but-legitimate installs) to clearly-generous bounds.
63
+ */
64
+ const MAX_FILES = 10_000;
65
+ const MAX_SHARED_EDITS = 256;
66
+ /** Cap for `_pending.sharedFiles` (finding 3) — same generous bound as `sharedEdits`. */
67
+ const MAX_SHARED_FILES = 256;
68
+ /**
69
+ * Finding 3 (MEDIUM): GENEROUS DoS backstops on the ledger FILE itself, NOT product limits. The
70
+ * ledger is untrusted on-disk content; readLedgerRaw must not read+parse+materialize an unbounded
71
+ * file. Before reading, `statSync` and reject (fail-closed via the corrupt path) if `size` exceeds
72
+ * LEDGER_MAX_BYTES. And enforce MAX_ENTRIES during validation so a hostile ledger with millions of
73
+ * keys cannot weaponize Object.keys iteration. 8 MiB / 4096 entries are far beyond any real install
74
+ * (a typical entry is a few hundred bytes; 4096 capabilities is wildly more than any user installs).
75
+ */
76
+ const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
77
+ const MAX_ENTRIES = 4096;
78
+ /**
79
+ * Returns true when `id` must never be used as an object key or ledger entry id — either
80
+ * because it would cause prototype pollution or because it fails the kebab-case constraint.
81
+ *
82
+ * Security note: uses INLINE LITERAL key comparisons (do NOT use a Set or computed lookup)
83
+ * as required by the CodeQL prototype-pollution barrier — a Set.has call could itself be
84
+ * attacked via a poisoned prototype.
85
+ */
86
+ function isUnsafeCapabilityId(id) {
87
+ if (typeof id !== 'string')
88
+ return true;
89
+ if (id === '__proto__')
90
+ return true;
91
+ if (id === 'constructor')
92
+ return true;
93
+ if (id === 'prototype')
94
+ return true;
95
+ if (!VALID_ID_RE.test(id))
96
+ return true;
97
+ return false;
98
+ }
99
+ /**
100
+ * Sentinel for distinguishing IO errors (EACCES, EISDIR, EPERM, …) from
101
+ * parse/validation failures. Thrown internally by readLedgerRaw; caught by the
102
+ * two public readers to produce the right error type or return value.
103
+ */
104
+ class LedgerIOError extends Error {
105
+ code;
106
+ constructor(message, code) {
107
+ super(message);
108
+ this.name = 'LedgerIOError';
109
+ this.code = code;
110
+ }
111
+ }
112
+ /**
113
+ * Finding 2 (HIGH): the SINGLE shared robust bounded reader for every untrusted on-disk file the
114
+ * capability stack reads (the ledger here AND the .lock body in capability-lifecycle, which imports
115
+ * this). A path-`stat`(path)+`readFileSync`(path) pair is NOT safe: a FIFO, a symlink to a character
116
+ * device like /dev/zero, or a regular file SWAPPED/GROWN between the stat and the read defeats the
117
+ * size cap and can BLOCK (FIFO with no writer) or read UNBOUNDED (infinite device). Project-scope
118
+ * ledgers are repo-plantable, so this is a repo-borne DoS.
119
+ *
120
+ * The fix binds the type+size decision to the SAME open fd we read from:
121
+ * 1. openSync(path, O_RDONLY|O_NONBLOCK) — open ONCE, NON-BLOCKING. The O_NONBLOCK is essential:
122
+ * a plain openSync of a FIFO BLOCKS until a writer appears (the
123
+ * very hang we are defending against); O_NONBLOCK returns the fd
124
+ * immediately so fstat can reject it. (Symlinks are still followed
125
+ * to their target, as a read would; O_NONBLOCK is ignored for a
126
+ * regular file.)
127
+ * 2. fstatSync(fd) — stat the OPENED fd (not the path) — defeats the stat-then-read
128
+ * swap and reads the REAL target's type/size.
129
+ * 3. require stat.isFile() — reject FIFO / device / directory / symlink-to-nonregular. A
130
+ * directory keeps the legacy `EISDIR` code so existing callers
131
+ * that branch on it are unchanged.
132
+ * 4. require stat.size <= maxBytes — refuse an oversized regular file WITHOUT reading it whole.
133
+ * 5. read EXACTLY stat.size bytes from the fd — never an unbounded streaming read.
134
+ * 6. closeSync(fd) in finally.
135
+ *
136
+ * Returns the file content as a string, or null for ENOENT (genuinely missing). Throws LedgerIOError
137
+ * for every other condition (non-regular, oversized, IO error) so callers fail closed. Behavior for a
138
+ * normal small regular file is identical to the prior readFileSync(path,'utf8').
139
+ */
140
+ function readSmallRegularFile(filePath, maxBytes) {
141
+ const buf = readSmallRegularFileBuffer(filePath, maxBytes);
142
+ if (buf === null)
143
+ return null;
144
+ // Decode to UTF-8 for STRING consumers (JSON parsers, lock-body parsers). This decode is LOSSY for
145
+ // binary content (invalid byte sequences → U+FFFD), so a content-hash binding must NOT use this —
146
+ // it must hash the RAW bytes via readSmallRegularFileBuffer (#1459 finding 1b: a swapped binary
147
+ // artifact differing only in invalid-UTF-8 bytes would otherwise not change the digest).
148
+ return buf.toString('utf8');
149
+ }
150
+ /**
151
+ * #1459 finding 1 (HIGH): the RAW-BYTES variant of readSmallRegularFile. Identical open → fstat →
152
+ * require-regular-file → size-cap → read-exactly-size protocol (so a FIFO/device/swapped/oversized
153
+ * untrusted file can never block or read unbounded), but returns the bytes as a Buffer WITHOUT a
154
+ * UTF-8 decode. This is the SOLE correct reader for the consent content-hash binding: the binding
155
+ * must be byte-exact and INJECTIVE, and a utf8 decode is lossy (collapses distinct invalid byte
156
+ * sequences to U+FFFD) so two different binary artifacts could collide. Returns the bytes, or null
157
+ * for ENOENT (genuinely missing); throws LedgerIOError for every other fail-closed condition.
158
+ */
159
+ function readSmallRegularFileBuffer(filePath, maxBytes) {
160
+ // O_RDONLY | O_NONBLOCK: never block on opening a FIFO/device — return the fd so fstat can reject it.
161
+ const openFlags = node_fs_1.default.constants.O_RDONLY | node_fs_1.default.constants.O_NONBLOCK;
162
+ let fd;
163
+ try {
164
+ fd = node_fs_1.default.openSync(filePath, openFlags);
165
+ }
166
+ catch (err) {
167
+ const code = err.code;
168
+ if (code === 'ENOENT')
169
+ return null; // genuinely missing — not a corruption.
170
+ throw new LedgerIOError(`Cannot open ${filePath}: ${err.message}`, code);
171
+ }
172
+ try {
173
+ const st = node_fs_1.default.fstatSync(fd);
174
+ if (!st.isFile()) {
175
+ // FIFO / device / directory / symlink-to-nonregular. Preserve EISDIR for a directory so callers
176
+ // that distinguish it (and existing tests) still see that code; other non-regular kinds get a
177
+ // synthetic ENXIO. Either way it is an unreadable, fail-closed condition (not content parsing).
178
+ const code = st.isDirectory() ? 'EISDIR' : 'ENXIO';
179
+ throw new LedgerIOError(`Cannot read ${filePath}: not a regular file (unreadable; FIFO/device/directory) — refusing.`, code);
180
+ }
181
+ if (st.size > maxBytes) {
182
+ throw new LedgerIOError(`Cannot read ${filePath}: file size ${st.size} bytes exceeds the maximum of ${maxBytes} ` +
183
+ `bytes (refusing to read an oversized file). Inspect or move it aside.`, 'EFBIG');
184
+ }
185
+ if (st.size === 0)
186
+ return Buffer.alloc(0);
187
+ const buf = Buffer.allocUnsafe(st.size);
188
+ let off = 0;
189
+ // Read EXACTLY st.size bytes from the fd (never a streaming/unbounded read).
190
+ while (off < st.size) {
191
+ const n = node_fs_1.default.readSync(fd, buf, off, st.size - off, off);
192
+ if (n <= 0)
193
+ break; // EOF earlier than fstat reported (truncated under us) — return what we got.
194
+ off += n;
195
+ }
196
+ // Return EXACTLY the bytes we read (off may be < st.size on a truncated-under-us read).
197
+ return off === buf.length ? buf : buf.subarray(0, off);
198
+ }
199
+ catch (err) {
200
+ if (err instanceof LedgerIOError)
201
+ throw err;
202
+ throw new LedgerIOError(`Cannot read ${filePath}: ${err.message}`, err.code);
203
+ }
204
+ finally {
205
+ try {
206
+ node_fs_1.default.closeSync(fd);
207
+ }
208
+ catch { /* best-effort */ }
209
+ }
210
+ }
211
+ /**
212
+ * Read and structurally validate the ledger file. Throws LedgerIOError when the
213
+ * file cannot be read due to an OS error (EACCES, EISDIR, EPERM, …). Returns
214
+ * null when the file is missing (ENOENT) or when its content fails validation.
215
+ * Never throws for parse or validation failures — those become null.
216
+ */
217
+ function readLedgerRaw(runtimeDir) {
218
+ const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
219
+ // Finding 3 (MEDIUM) + Finding 2 (HIGH): the ledger file is untrusted. Read it via the shared
220
+ // fd-based bounded reader (open → fstat → require regular file → size cap → read exactly size). A
221
+ // FIFO/device/symlink-to-device or a stat-then-read swap can no longer block or bypass the cap; an
222
+ // oversized/non-regular file is surfaced as a LedgerIOError (a "cannot read" condition, not a
223
+ // content-parse failure) so readLedger returns null and readLedgerStrict rethrows it — every
224
+ // subsequent op then fails closed until the user resolves it, exactly like the corrupt path.
225
+ let raw;
226
+ try {
227
+ const content = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
228
+ if (content === null)
229
+ return null; // genuinely missing — not a corruption.
230
+ raw = content;
231
+ }
232
+ catch (err) {
233
+ if (err instanceof LedgerIOError)
234
+ throw err; // non-regular / oversized / IO — fail closed.
235
+ throw new LedgerIOError(`Cannot read ledger at ${filePath}: ${err.message}`, err.code);
236
+ }
237
+ try {
238
+ const parsed = JSON.parse(raw);
239
+ if (typeof parsed !== 'object' || parsed === null)
240
+ return null;
241
+ const p = parsed;
242
+ // Schema version must be the expected value (not any string) — finding 11.
243
+ if (p['version'] !== LEDGER_SCHEMA_VERSION)
244
+ return null;
245
+ // updatedAt must be a non-empty string — finding 11.
246
+ if (typeof p['updatedAt'] !== 'string' || !p['updatedAt'])
247
+ return null;
248
+ if (typeof p['entries'] !== 'object' || p['entries'] === null || Array.isArray(p['entries']))
249
+ return null;
250
+ // Validate each entry via isValidLedgerEntry — THE single validator (ROOT FIX 1).
251
+ // This eliminates the previous inline duplication and guarantees readLedger and
252
+ // isValidLedgerEntry can never diverge.
253
+ const entries = p['entries'];
254
+ const keys = Object.keys(entries);
255
+ // Finding 3 (MEDIUM): cap the entry COUNT so a hostile ledger with millions of keys cannot
256
+ // weaponize per-entry validation/iteration (the size cap above already bounds the parse; this
257
+ // bounds the post-parse key count). Generous DoS backstop, not a product limit.
258
+ if (keys.length > MAX_ENTRIES)
259
+ return null;
260
+ for (const key of keys) {
261
+ if (!isValidLedgerEntry(key, entries[key]))
262
+ return null;
263
+ }
264
+ return {
265
+ version: p['version'],
266
+ updatedAt: p['updatedAt'],
267
+ entries: entries,
268
+ };
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ /**
275
+ * Validate a single ledger entry object against the per-entry shape that readLedger enforces.
276
+ * This is THE single validator — readLedger/readLedgerRaw call it per-entry instead of
277
+ * duplicating inline checks (ROOT FIX 1 — single source of truth; #1459 will also consume this).
278
+ *
279
+ * Returns true when the entry is structurally valid for the given `id` key.
280
+ * Returns false for any structural violation:
281
+ * - id is an unsafe prototype-pollution key (__proto__, constructor, prototype)
282
+ * - id fails the kebab-case constraint (VALID_ID_RE)
283
+ * - entry.id field missing or not matching the key
284
+ * - missing/wrong-type required fields (version, source, integrity)
285
+ * - files[] with non-string members
286
+ * - sharedEdits[] with missing / non-string file or marker fields
287
+ * - _pending present but wrong shape (kind not 'install'/'upgrade', bad backupName, missing sharedFiles[])
288
+ */
289
+ function isValidLedgerEntry(id, entry) {
290
+ // ROOT FIX 3: reject unsafe ids using inline literal checks (CodeQL-safe pattern).
291
+ if (isUnsafeCapabilityId(id))
292
+ return false;
293
+ if (typeof entry !== 'object' || entry === null)
294
+ return false;
295
+ const e = entry;
296
+ if (typeof e['id'] !== 'string' || e['id'] !== id)
297
+ return false;
298
+ if (typeof e['version'] !== 'string')
299
+ return false;
300
+ if (typeof e['source'] !== 'string')
301
+ return false;
302
+ if (typeof e['integrity'] !== 'string')
303
+ return false;
304
+ if (!Array.isArray(e['files']))
305
+ return false;
306
+ // DOS-3 / finding 5(a): cap array sizes so a hostile ledger cannot weaponize a 100k+-element
307
+ // files[] (or sharedEdits[]/_pending.sharedFiles[]) into a memory/CPU DoS at validation/reconcile
308
+ // time. These are GENEROUS DoS backstops, NOT product limits — no legitimate capability declares
309
+ // 10k files or 256 shared-config edits, but a 100k+ hostile array is rejected (not iterated).
310
+ if (e['files'].length > MAX_FILES)
311
+ return false;
312
+ for (const f of e['files']) {
313
+ if (typeof f !== 'string')
314
+ return false;
315
+ }
316
+ if (!Array.isArray(e['sharedEdits']))
317
+ return false;
318
+ if (e['sharedEdits'].length > MAX_SHARED_EDITS)
319
+ return false; // DOS-3 (see above)
320
+ for (const se of e['sharedEdits']) {
321
+ if (se === null || typeof se !== 'object')
322
+ return false;
323
+ const seObj = se;
324
+ if (typeof seObj['file'] !== 'string' || !seObj['file'])
325
+ return false;
326
+ if (typeof seObj['marker'] !== 'string' || !seObj['marker'])
327
+ return false;
328
+ }
329
+ // Validate _pending shape if present (ROOT FIX 1 — previously only in readLedgerRaw).
330
+ if (Object.prototype.hasOwnProperty.call(e, '_pending')) {
331
+ const pending = e['_pending'];
332
+ if (pending !== undefined) {
333
+ if (typeof pending !== 'object' || pending === null)
334
+ return false;
335
+ const p = pending;
336
+ if (p['kind'] !== 'install' && p['kind'] !== 'upgrade')
337
+ return false;
338
+ // backupName must be string or null — not a number or object.
339
+ if (p['backupName'] !== null && typeof p['backupName'] !== 'string')
340
+ return false;
341
+ if (!Array.isArray(p['sharedFiles']))
342
+ return false;
343
+ // Finding 3: _pending.sharedFiles was previously ONLY Array.isArray-checked, so a hostile
344
+ // ledger with a 500k-element (or non-string) _pending.sharedFiles was accepted and later
345
+ // spread into a Set + iterated in reconcileCapabilities (DoS bypass). Cap its length with the
346
+ // same generous bound as sharedFiles and require every member to be a string.
347
+ if (p['sharedFiles'].length > MAX_SHARED_FILES)
348
+ return false;
349
+ for (const sf of p['sharedFiles']) {
350
+ if (typeof sf !== 'string')
351
+ return false;
352
+ }
353
+ }
354
+ }
355
+ return true;
356
+ }
357
+ /**
358
+ * Validate a WHOLE ledger-file object against the SAME structural rules a strict read enforces
359
+ * (finding 5 — LOW): the schema version, a non-empty `updatedAt`, an entries map within MAX_ENTRIES,
360
+ * and every entry valid via isValidLedgerEntry. Used by recordInstall to gate the in-lock
361
+ * `baseLedger` fast-path so an invalid caller-supplied base can never be written verbatim. Never
362
+ * throws; returns false for any structural violation.
363
+ */
364
+ function isValidLedgerFile(base) {
365
+ if (typeof base !== 'object' || base === null || Array.isArray(base))
366
+ return false;
367
+ const b = base;
368
+ if (b['version'] !== LEDGER_SCHEMA_VERSION)
369
+ return false;
370
+ if (typeof b['updatedAt'] !== 'string' || !b['updatedAt'])
371
+ return false;
372
+ const entriesVal = b['entries'];
373
+ if (typeof entriesVal !== 'object' || entriesVal === null || Array.isArray(entriesVal))
374
+ return false;
375
+ const entries = entriesVal;
376
+ const keys = Object.keys(entries);
377
+ if (keys.length > MAX_ENTRIES)
378
+ return false;
379
+ for (const key of keys) {
380
+ if (!isValidLedgerEntry(key, entries[key]))
381
+ return false;
382
+ }
383
+ return true;
384
+ }
385
+ /**
386
+ * Read and structurally validate the ledger file.
387
+ *
388
+ * Returns null if the file is missing or structurally invalid.
389
+ * Returns the parsed ledger when the file is valid.
390
+ * On IO errors (EACCES, EISDIR, EPERM), returns null (non-throwing, compatible with old API).
391
+ * Never throws.
392
+ */
393
+ function readLedger(runtimeDir) {
394
+ try {
395
+ return readLedgerRaw(runtimeDir);
396
+ }
397
+ catch (err) {
398
+ if (err instanceof LedgerIOError) {
399
+ // IO error — treat as unreadable (return null) so callers are not broken.
400
+ // readLedgerStrict will surface the real error.
401
+ return null;
402
+ }
403
+ return null;
404
+ }
405
+ }
406
+ /**
407
+ * Like `readLedger` but distinguishes missing-vs-corrupt, and surfaces IO errors distinctly:
408
+ * - File missing → returns null (no ledger yet, fresh start is fine).
409
+ * - File present and valid → returns the parsed LedgerFile.
410
+ * - File present but unparseable/invalid CONTENT → throws CorruptLedgerError. The file is
411
+ * LEFT IN PLACE (not moved, renamed, or deleted) so every subsequent operation also
412
+ * blocks until the user resolves it. Recovery: inspect the file, restore a backup,
413
+ * or move it aside yourself to start fresh.
414
+ * - File present but unreadable (EACCES, EPERM, EISDIR, …) → throws LedgerIOError with
415
+ * the original OS errno/code preserved. This is an IO/permission problem — NOT a content
416
+ * corruption — and callers should surface it as such (finding 4).
417
+ *
418
+ * Callers that must fail-closed on corruption (upgrade, remove, install) should use this
419
+ * instead of `readLedger` so they never mistake a corrupt file for "not installed".
420
+ */
421
+ function readLedgerStrict(runtimeDir) {
422
+ const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
423
+ let raw;
424
+ try {
425
+ raw = readLedgerRaw(runtimeDir);
426
+ }
427
+ catch (err) {
428
+ if (err instanceof LedgerIOError) {
429
+ // IO error (EACCES, EPERM, EISDIR, …) — rethrow as-is so callers see it as an IO
430
+ // problem with the original errno, not as content corruption (finding 4).
431
+ throw err;
432
+ }
433
+ throw err; // unexpected — propagate
434
+ }
435
+ if (raw !== null)
436
+ return raw;
437
+ // readLedgerRaw returned null: either genuinely missing or present-but-invalid (or unreadable).
438
+ // ROOT FIX 4: use lstatSync (not existsSync) to detect dangling/broken symlinks.
439
+ // existsSync follows the symlink and returns false for a broken symlink, making the ledger
440
+ // appear "missing" when it is actually an IO problem — so a broken symlink would silently
441
+ // allow a "fresh install" over a dangling ledger pointer, losing all prior records.
442
+ // lstatSync checks the directory entry itself (not the target) — if it exists (even as a
443
+ // broken symlink), that is NOT "missing": surface it as an IO error so every subsequent op
444
+ // also fails closed until the user resolves it.
445
+ let lstatResult = null;
446
+ try {
447
+ lstatResult = node_fs_1.default.lstatSync(filePath);
448
+ }
449
+ catch (lstatErr) {
450
+ const lstatCode = lstatErr.code;
451
+ if (lstatCode === 'ENOENT')
452
+ return null; // genuinely missing directory entry — fresh start is fine.
453
+ // Any other lstat error (EACCES, EPERM, …) — treat as IO failure.
454
+ throw new LedgerIOError(`Cannot stat ledger at ${filePath}: ${lstatErr.message}`, lstatCode);
455
+ }
456
+ // lstat succeeded — the path exists in the directory (could be a broken symlink, dir, etc.).
457
+ if (lstatResult.isSymbolicLink()) {
458
+ // Broken symlink: the entry exists but the target is unreadable. This is an IO problem,
459
+ // not content corruption — surface as LedgerIOError (not CorruptLedgerError) so callers
460
+ // distinguish "I/O problem" from "corrupt content" (ROOT FIX 4).
461
+ throw new LedgerIOError(`Ledger path ${filePath} is a broken or dangling symlink. ` +
462
+ `Remove or fix the symlink so the ledger can be read normally.`, 'ENOENT');
463
+ }
464
+ // BC-1: distinguish a future/unsupported SCHEMA VERSION from genuine corruption. readLedgerRaw
465
+ // returns null both when the JSON is unparseable AND when it parses cleanly but carries a
466
+ // version string we do not support (currently only '1' exists). A version bump should surface a
467
+ // clear "unsupported schema version X" message, not a misleading "corrupt or invalid". This is a
468
+ // best-effort re-parse for the message only — the file is still LEFT IN PLACE.
469
+ //
470
+ // FIRST SCHEMA BUMP: when a v2 schema is introduced, ADD A MIGRATION BRANCH here (and in
471
+ // readLedgerRaw) — read the old shape, migrate it forward, and write the upgraded ledger — rather
472
+ // than throwing. Until then there are no v0/v2 ledgers in the wild (no released version wrote one),
473
+ // so blocking on an unknown version is the safe fail-closed behavior.
474
+ try {
475
+ // Finding 2 (HIGH): the reparse is ALSO a read of the untrusted ledger path — a FIFO/device or a
476
+ // file swapped after the first read must not block/bypass the cap here. Route it through the same
477
+ // bounded fd reader (a null/throw means there's nothing safely reparseable → fall through to the
478
+ // generic corrupt message).
479
+ const reparsedRaw = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
480
+ const reparsed = reparsedRaw === null ? null : JSON.parse(reparsedRaw);
481
+ if (typeof reparsed === 'object' && reparsed !== null) {
482
+ const ver = reparsed['version'];
483
+ if (typeof ver === 'string' && ver !== LEDGER_SCHEMA_VERSION) {
484
+ throw new CorruptLedgerError(`Capability ledger at ${filePath} uses unsupported ledger schema version "${ver}" ` +
485
+ `(this build supports version "${LEDGER_SCHEMA_VERSION}"). Upgrade GSD to a build that ` +
486
+ `understands this ledger, or move the file aside to start fresh.`, filePath);
487
+ }
488
+ }
489
+ }
490
+ catch (reparseErr) {
491
+ // A CorruptLedgerError from the unsupported-version branch must propagate; any other error
492
+ // (re-read/parse failure) means it is genuinely corrupt — fall through to the generic message.
493
+ if (reparseErr instanceof CorruptLedgerError)
494
+ throw reparseErr;
495
+ }
496
+ // File exists (not a symlink, not missing) but failed validation — throw. The file is
497
+ // intentionally LEFT IN PLACE so that every subsequent op is also blocked until the user
498
+ // resolves it (finding 1): auto-moving it would let the NEXT op proceed as fresh state
499
+ // → data-loss/orphan outcome.
500
+ // W-2: the recovery hint must be platform-aware — a POSIX `mv` with a forward-slash path is wrong
501
+ // on Windows (backslash paths, no `mv`). Show the native rename command for the running platform.
502
+ const moveHint = process.platform === 'win32'
503
+ ? `ren "${filePath}" "${node_path_1.default.basename(filePath)}.bak" (or PowerShell: Move-Item "${filePath}" "${filePath}.bak")`
504
+ : `mv "${filePath}" "${filePath}.bak"`;
505
+ throw new CorruptLedgerError(`Capability ledger at ${filePath} is present but corrupt or invalid. ` +
506
+ `Inspect the file to recover your capability records, restore a known-good backup, ` +
507
+ `or move it aside to start fresh (e.g. ${moveHint}).`, filePath);
508
+ }
509
+ /** W-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */
510
+ const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
511
+ const RENAME_MAX_ATTEMPTS = 3;
512
+ const RENAME_RETRY_BACKOFF_MS = 50;
513
+ /** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
514
+ let _renameSleepBuf = null;
515
+ function renameBackoff() {
516
+ if (_renameSleepBuf === null)
517
+ _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
518
+ Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
519
+ }
520
+ /** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */
521
+ const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']);
522
+ /**
523
+ * fsync the directory CONTAINING `dest` so the just-completed rename is durable across a power loss
524
+ * (DUR-2). Some platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) —
525
+ * those are tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real
526
+ * storage error) is RETHROWN as a clear durability-uncertain error rather than silently swallowed;
527
+ * the rename may already be visible, so the caller must NOT claim success when durability could not
528
+ * be confirmed. The directory fd is always closed (finally).
529
+ */
530
+ function fsyncContainingDir(dest) {
531
+ let dirFd = null;
532
+ try {
533
+ dirFd = node_fs_1.default.openSync(node_path_1.default.dirname(dest), 'r');
534
+ node_fs_1.default.fsyncSync(dirFd);
535
+ }
536
+ catch (err) {
537
+ const code = err.code;
538
+ if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
539
+ // Real storage error (e.g. EIO): the rename may already be visible but its durability could
540
+ // NOT be confirmed. Rethrow rather than silently claim success (finding 4).
541
+ throw new Error(`Directory fsync of "${node_path_1.default.dirname(dest)}" failed (${code}); durability of the ledger ` +
542
+ `rename could NOT be confirmed: ${err.message}`);
543
+ }
544
+ /* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */
545
+ }
546
+ finally {
547
+ if (dirFd !== null) {
548
+ try {
549
+ node_fs_1.default.closeSync(dirFd);
550
+ }
551
+ catch { /* best-effort */ }
552
+ }
553
+ }
554
+ }
555
+ /**
556
+ * Write the ledger atomically AND durably (tmp file in the same dir → fsync → close → rename →
557
+ * dir fsync, no truncating fallback). Using a local implementation rather than platformWriteSync
558
+ * so that a crash or power-loss mid-write cannot produce a zero-byte / truncated ledger — the
559
+ * corrupt file that LEDGER-1 mishandled (ADR-1244 D4 fix).
560
+ *
561
+ * Durability sequence (DUR-1 / DUR-2):
562
+ * 1. writeFileSync(fd, content) — full-buffer write (no short-writes).
563
+ * 2. fsyncSync(fd) — flush the file's bytes to stable storage BEFORE the rename;
564
+ * otherwise a power-loss AFTER a successful rename can leave a
565
+ * zero/partial ledger (total loss). If fsync throws, the temp is
566
+ * unlinked and the error rethrown (treated as a write failure) —
567
+ * we NEVER rename a possibly-unflushed file live.
568
+ * 3. closeSync(fd) — a close error can also signal delayed-writeback failure;
569
+ * unlink the temp and rethrow before the rename.
570
+ * 4. renameSync(tmp, dest) — atomic install (retried on transient Windows AV locks, W-1).
571
+ * 5. fsyncSync(dirname fd) — make the rename itself durable (DUR-2).
572
+ *
573
+ * Security hardening (adversarial re-review):
574
+ * - Temp path includes a random nonce (not just pid) to avoid predictable names and resist
575
+ * collision between concurrent processes.
576
+ * - Temp file is created with the exclusive `wx` flag (O_EXCL) so a pre-planted symlink at the
577
+ * same path cannot redirect the write to another file.
578
+ * - On any failure (write, fsync, close, or rename) the temp file is cleaned up before
579
+ * rethrowing, and the primary error is always preserved (finding 13).
580
+ */
581
+ function writeLedger(runtimeDir, ledger) {
582
+ const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
583
+ const content = JSON.stringify(ledger, null, 2) + '\n';
584
+ node_fs_1.default.mkdirSync(runtimeDir, { recursive: true });
585
+ // Unique nonce in the name prevents predictable-path attacks; wx (O_EXCL) prevents
586
+ // a pre-existing symlink from silently redirecting the write.
587
+ const nonce = node_crypto_1.default.randomBytes(4).toString('hex');
588
+ const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`;
589
+ const fd = node_fs_1.default.openSync(tmpPath, 'wx'); // exclusive create — throws if already exists
590
+ let primaryErr = null;
591
+ try {
592
+ // Write as a Buffer in one call to prevent short-writes (finding 6).
593
+ // fs.writeFileSync(fd, …) internally uses a write-all loop that flushes the
594
+ // entire buffer before returning, unlike a bare writeSync which may short-write.
595
+ node_fs_1.default.writeFileSync(fd, content);
596
+ // DUR-1: fsync the file's contents to stable storage BEFORE closing/renaming. Without this a
597
+ // power-loss after a successful rename can leave a zero/partial ledger → total loss.
598
+ node_fs_1.default.fsyncSync(fd);
599
+ }
600
+ catch (err) {
601
+ primaryErr = err instanceof Error ? err : new Error(String(err));
602
+ }
603
+ finally {
604
+ // closeSync can also throw (finding 2): a close error on the write fd can signal
605
+ // delayed-writeback failure, meaning the data may not have been durably committed
606
+ // to storage. In that case we must NOT install the possibly-unflushed temp as the
607
+ // live ledger — unlink it and rethrow the close error before the rename.
608
+ let closeErr = null;
609
+ try {
610
+ node_fs_1.default.closeSync(fd);
611
+ }
612
+ catch (err) {
613
+ closeErr = err instanceof Error ? err : new Error(String(err));
614
+ }
615
+ // If the write OR fsync failed, always clean up and rethrow that error (DUR-1).
616
+ if (primaryErr !== null) {
617
+ try {
618
+ node_fs_1.default.unlinkSync(tmpPath);
619
+ }
620
+ catch { /* best-effort — no orphan */ }
621
+ throw primaryErr;
622
+ }
623
+ // Write+fsync succeeded but close threw — unlink the possibly-unflushed temp and rethrow
624
+ // the close error. NEVER proceed to rename a potentially unflushed file (finding 2).
625
+ if (closeErr !== null) {
626
+ try {
627
+ node_fs_1.default.unlinkSync(tmpPath);
628
+ }
629
+ catch { /* best-effort — no orphan */ }
630
+ throw closeErr;
631
+ }
632
+ // Write, fsync, and close all succeeded — fall through to rename.
633
+ }
634
+ // W-1: renameSync can transiently fail on Windows when an AV scanner / file indexer holds a
635
+ // brief lock (EPERM/EBUSY/EACCES). Retry a few times with a short backoff before giving up.
636
+ let renameErr = null;
637
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
638
+ try {
639
+ node_fs_1.default.renameSync(tmpPath, filePath);
640
+ renameErr = null;
641
+ break;
642
+ }
643
+ catch (err) {
644
+ renameErr = err instanceof Error ? err : new Error(String(err));
645
+ const code = err.code ?? '';
646
+ if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
647
+ renameBackoff();
648
+ continue;
649
+ }
650
+ break;
651
+ }
652
+ }
653
+ if (renameErr !== null) {
654
+ // Clean up the orphaned temp file before rethrowing.
655
+ try {
656
+ node_fs_1.default.unlinkSync(tmpPath);
657
+ }
658
+ catch { /* best-effort */ }
659
+ throw renameErr;
660
+ }
661
+ // DUR-2: make the rename durable by fsyncing the containing directory (best-effort).
662
+ fsyncContainingDir(filePath);
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // Mutation operations
666
+ // ---------------------------------------------------------------------------
667
+ /**
668
+ * Record a capability installation in the ledger (idempotent).
669
+ *
670
+ * If an entry with the same id already exists it is replaced. The `updatedAt`
671
+ * timestamp is refreshed on every call. Rejects ids that would cause prototype
672
+ * pollution (__proto__, constructor, prototype).
673
+ *
674
+ * Uses `readLedgerStrict` so that a corrupt-but-present ledger fails closed (throws
675
+ * CorruptLedgerError, leaving the file in place) rather than silently overwriting it.
676
+ *
677
+ * DOS-4: `opts.baseLedger` lets an IN-LOCK caller pass the ledger it has ALREADY strict-read this
678
+ * critical section so recordInstall does not redundantly re-read+re-validate it (install does up to
679
+ * three strict reads per op). It is ONLY safe when the caller holds the mutation lock (so the
680
+ * on-disk ledger cannot change underneath the passed snapshot) AND obtained it via readLedgerStrict
681
+ * (so corruption was already fail-closed). The standalone strict read remains the DEFAULT — omit
682
+ * `baseLedger` and the strict guarantee is unchanged. A null/missing baseLedger falls back to the
683
+ * strict read; a non-object baseLedger is rejected.
684
+ */
685
+ function recordInstall(runtimeDir, entry, opts) {
686
+ // ROOT FIX 3: reject ALL unsafe ids with a throw (not silent return) — this includes
687
+ // prototype-pollution keys AND non-kebab ids. Using isUnsafeCapabilityId (which uses
688
+ // inline literal === checks — CodeQL-safe pattern) as the single gate.
689
+ if (isUnsafeCapabilityId(entry.id)) {
690
+ throw new Error(`Invalid capability id "${entry.id}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
691
+ `Unsafe or non-kebab ids are rejected to prevent prototype pollution and ledger corruption.`);
692
+ }
693
+ // ROOT FIX 3 (finding 3): validate the WHOLE entry — not just entry.id — against the single
694
+ // per-entry validator. Otherwise recordInstall could write a structurally-invalid entry (e.g.
695
+ // files:[123] or a malformed sharedEdits member) that every subsequent readLedger/readLedgerStrict
696
+ // would then reject as corrupt — turning a bad write into a persistent self-inflicted lockout.
697
+ // Validating here makes recordInstall fail FAST (throw, write nothing) on a malformed entry.
698
+ if (!isValidLedgerEntry(entry.id, entry)) {
699
+ throw new Error(`Refusing to record a structurally-invalid ledger entry for "${entry.id}": the entry fails ` +
700
+ `the ledger schema (check files[]/sharedEdits[]/version/source/integrity types). ` +
701
+ `Writing it would corrupt the ledger so every later read rejects it.`);
702
+ }
703
+ // DOS-4 + finding 5 (LOW): use the caller-supplied in-lock base ONLY when it passes the SAME
704
+ // validation a strict read would (version, updatedAt, entry-count cap, and every entry via
705
+ // isValidLedgerEntry). Previously the base was accepted on a shallow `entries is an object` check
706
+ // and written VERBATIM — so a caller passing an invalid base (bad version/updatedAt, or a malformed
707
+ // entry) would write a self-corrupting ledger that every later read rejects. Now an INVALID base is
708
+ // ignored and we fall back to the strict read (the default, unchanged strict guarantee), so the
709
+ // ledger is only ever derived from validated state.
710
+ let existing;
711
+ const base = opts?.baseLedger;
712
+ if (base !== undefined && base !== null && isValidLedgerFile(base)) {
713
+ existing = base;
714
+ }
715
+ else {
716
+ // readLedgerStrict: returns null when missing, parsed ledger when valid,
717
+ // throws CorruptLedgerError (leaving file in place) when present-but-corrupt.
718
+ existing = readLedgerStrict(runtimeDir);
719
+ }
720
+ const ledger = existing ?? {
721
+ version: LEDGER_SCHEMA_VERSION,
722
+ updatedAt: new Date().toISOString(),
723
+ entries: {},
724
+ };
725
+ ledger.entries[entry.id] = entry;
726
+ ledger.updatedAt = new Date().toISOString();
727
+ writeLedger(runtimeDir, ledger);
728
+ }
729
+ /**
730
+ * Remove a single capability entry from the ledger by id.
731
+ *
732
+ * Returns true if the entry was present and removed, false if GENUINELY not found.
733
+ *
734
+ * Finding 4 (fail-closed): uses `readLedgerStrict` (not the non-throwing `readLedger`) so a
735
+ * corrupt-but-present ledger THROWS (CorruptLedgerError / LedgerIOError, file left in place)
736
+ * rather than returning false. Returning false on corruption would let a corrupt ledger
737
+ * masquerade as "entry not installed" — a silent no-op that hides recorded state. `false` is
738
+ * now reserved exclusively for a genuinely-missing ledger or a genuinely-absent entry.
739
+ */
740
+ function removeEntry(runtimeDir, capId) {
741
+ const ledger = readLedgerStrict(runtimeDir); // throws on corrupt-present / IO error (fail-closed)
742
+ if (ledger === null)
743
+ return false; // genuinely missing ledger — nothing installed
744
+ if (!Object.prototype.hasOwnProperty.call(ledger.entries, capId))
745
+ return false;
746
+ delete ledger.entries[capId];
747
+ ledger.updatedAt = new Date().toISOString();
748
+ writeLedger(runtimeDir, ledger);
749
+ return true;
750
+ }
751
+ /**
752
+ * Check ledger consistency against the filesystem.
753
+ *
754
+ * Read-only — never mutates the ledger or the filesystem. Reports:
755
+ * - orphans: entries with one or more recorded files missing on disk.
756
+ * - stale: (reserved, always empty in Phase 3).
757
+ * - warnings: problems encountered while reading the ledger.
758
+ */
759
+ function reconcile(runtimeDir) {
760
+ const result = { orphans: [], stale: [], warnings: [] };
761
+ const ledger = readLedger(runtimeDir);
762
+ if (ledger === null) {
763
+ const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
764
+ // Finding 5: use lstatSync (not existsSync) to detect the directory ENTRY itself. existsSync
765
+ // FOLLOWS the symlink and returns false for a dangling/broken symlink — so a ledger that is a
766
+ // broken symlink would be reported "missing" (no warning) when it is actually an unreadable IO
767
+ // problem. lstatSync stats the entry without following it: any entry present (even a broken
768
+ // symlink) is NOT "missing" and must surface a warning.
769
+ let entryExists = false;
770
+ try {
771
+ node_fs_1.default.lstatSync(filePath);
772
+ entryExists = true;
773
+ }
774
+ catch (lstatErr) {
775
+ // ENOENT — genuinely absent: nothing installed, not a warning. Any other error (EACCES,
776
+ // EPERM, …) means the entry is present-but-unreadable → treat as a parse/IO warning.
777
+ if (lstatErr.code !== 'ENOENT')
778
+ entryExists = true;
779
+ }
780
+ if (entryExists) {
781
+ result.warnings.push(`Ledger file exists but could not be parsed: ${filePath}`);
782
+ }
783
+ // Missing ledger is not a warning — it simply means nothing has been installed.
784
+ return result;
785
+ }
786
+ for (const id of Object.keys(ledger.entries)) {
787
+ const entry = ledger.entries[id];
788
+ const missing = [];
789
+ for (const file of entry.files) {
790
+ // Harden against hostile ledger JSON: a non-string member, or one that is
791
+ // absolute or escapes runtimeDir via "..", must not crash reconcile or become
792
+ // an existence oracle for files outside the runtime config dir.
793
+ if (typeof file !== 'string' || file === '' || node_path_1.default.isAbsolute(file) || file.split(/[/\\]/).includes('..')) {
794
+ // Note: do NOT String(file) — a hostile value like { toString: null } would throw.
795
+ const shown = typeof file === 'string' ? file : `<${typeof file}>`;
796
+ result.warnings.push(`Ledger entry "${id}" has an invalid file path; skipped: ${shown}`);
797
+ continue;
798
+ }
799
+ const resolved = node_path_1.default.join(runtimeDir, file);
800
+ if (!node_fs_1.default.existsSync(resolved)) {
801
+ missing.push(file);
802
+ }
803
+ }
804
+ if (missing.length > 0) {
805
+ result.orphans.push({ id, missing });
806
+ }
807
+ }
808
+ return result;
809
+ }
810
+ module.exports = {
811
+ readLedger,
812
+ readLedgerStrict,
813
+ writeLedger,
814
+ recordInstall,
815
+ removeEntry,
816
+ reconcile,
817
+ isValidLedgerEntry,
818
+ isUnsafeCapabilityId,
819
+ // Finding 2 (HIGH): the SINGLE shared bounded fd reader — also consumed by capability-lifecycle's
820
+ // lock-body reads so every untrusted file read goes through the regular-file + size-capped fd path.
821
+ readSmallRegularFile,
822
+ // #1459 finding 1 (HIGH): the RAW-BYTES variant — the SOLE correct reader for the byte-exact,
823
+ // injective consent content-hash binding (a utf8 decode is lossy and could collide binary artifacts).
824
+ readSmallRegularFileBuffer,
825
+ // Exported for testing / introspection
826
+ LEDGER_FILE_NAME,
827
+ CorruptLedgerError,
828
+ LedgerIOError,
829
+ // DoS backstop bounds — shared with the lifecycle/CLI early count check (finding 5).
830
+ MAX_SHARED_FILES,
831
+ };