@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,763 @@
1
+ "use strict";
2
+ /**
3
+ * Capability consent store — issue #1459 (capability trust model bypassable).
4
+ *
5
+ * A USER-OWNED store, living OUTSIDE any repository at `${GSD_HOME||homedir()}/.gsd/consent.json`,
6
+ * that binds each PROJECT-scope third-party capability activation to a decision the user made on
7
+ * THIS machine. Before #1459 a project's in-repo ledger entry was treated as the consent signal —
8
+ * but a project ledger is repo-plantable, so cloning/forging a repo activated executable surfaces
9
+ * and command dispatch with no user decision (the trust model was bypassable). The consent store
10
+ * moves the authoritative signal off the repo tree: a project overlay is INACTIVE until a matching
11
+ * consent record exists in this user-owned store.
12
+ *
13
+ * CONTENT BINDING (the security crux — #1459 round 2, findings CB-1/CB-2/TRUST2-5). The consent
14
+ * record is bound to a RECOMPUTED full-bundle content hash (`bundleContentHash`), NOT to the ledger
15
+ * `integrity` (which is `''` for path/git/dir installs and taken verbatim from the repo-plantable
16
+ * project ledger — `'' === ''` is no binding) NOR to the `disclosureSignature` alone (which covers
17
+ * only executable surfaces, so a declarative-only cap has a constant signature and a repo-write
18
+ * attacker could swap `capability.json` for a malicious gate/contribution while consent still
19
+ * matched). `bundleContentHash` is recomputed by the loader at load over EVERY file in the bundle
20
+ * (manifest AND artifacts AND identity), so any tamper — declarative-only swap, hook-script edit,
21
+ * empty-integrity local install — changes the hash and leaves the cap inactive. `integrity` and
22
+ * `disclosureSignature` remain on the record for the human disclosure + re-consent-on-executable-
23
+ * change UX (TRUST-2); they are NO LONGER the security binding.
24
+ *
25
+ * LEAF MODULE — imports ONLY: node:fs, node:path, node:os, node:crypto, and the shared bounded
26
+ * fd reader (readSmallRegularFile) from ./capability-ledger.cjs.
27
+ *
28
+ * Schema: `{ version: "1", records: { "<JSON({r,i})>": ConsentRecord } }`. The store is UNRELEASED
29
+ * (no migration/back-compat shims needed); the only version is "1".
30
+ *
31
+ * Exports:
32
+ * consentStorePath(gsdHome?) — resolve the store path (GSD_HOME||homedir() rule).
33
+ * bundleContentHash(capDir) — recomputed sha512 over the whole bundle (the binding).
34
+ * readConsentStore(gsdHome?) — bounded, NON-THROWING read; bad input → { records: {} }.
35
+ * hasProjectConsent({...}) — true iff a record matches the recomputed contentHash.
36
+ * recordProjectConsent({...}) — atomic+durable+LOCKED write of a project-scope record.
37
+ * revokeProjectConsent({...}) — atomic+LOCKED delete of a project-scope record (no-op if absent).
38
+ */
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
42
+ const node_fs_1 = __importDefault(require("node:fs"));
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ const node_os_1 = __importDefault(require("node:os"));
45
+ const node_crypto_1 = __importDefault(require("node:crypto"));
46
+ /* eslint-disable @typescript-eslint/no-require-imports */
47
+ const ledgerMod = require('./capability-ledger.cjs');
48
+ // #1459 finding 4: the SHARED hardened lock primitive (single source of truth for lifecycle + consent).
49
+ // Before this, the consent lock used a naive mtime-only 60s steal that would STEAL A LIVE WRITER (a
50
+ // slow/paused holder past 60s is reclaimed → original writer resumes and overwrites = lost update). The
51
+ // shared primitive never stale-steals a verified-live same-host holder (pid + start-time identity) and
52
+ // only reclaims a provably-dead/unverifiable holder (dead-pid fast path or the hard deadman).
53
+ const lockMod = require('./capability-lock.cjs');
54
+ /**
55
+ * The consent store has GENUINELY-CONTENDED writers (two different projects installing concurrently
56
+ * both write the ONE global consent.json), so it must SERIALIZE under brief contention rather than fail
57
+ * — a larger steal/retry budget than the lifecycle's small sub-second default. Combined with #1459
58
+ * finding 3 (throw on a NULL handle), this throws only when contention truly outlasts the budget.
59
+ */
60
+ const CONSENT_LOCK_MAX_ATTEMPTS = 50;
61
+ /* eslint-enable @typescript-eslint/no-require-imports */
62
+ // ---------------------------------------------------------------------------
63
+ // Constants
64
+ // ---------------------------------------------------------------------------
65
+ const CONSENT_SCHEMA_VERSION = '1';
66
+ const CONSENT_DIRNAME = '.gsd';
67
+ const CONSENT_FILE_NAME = 'consent.json';
68
+ /**
69
+ * GENEROUS DoS backstop on the store FILE — NOT a product limit. The consent store is untrusted
70
+ * on-disk content; the bounded reader must not read+parse an unbounded file. A few hundred bytes
71
+ * per record × MAX_RECORDS is far below this; 8 MiB is wildly more than any real store.
72
+ */
73
+ const CONSENT_MAX_BYTES = 8 * 1024 * 1024;
74
+ /**
75
+ * GENEROUS cap on the record COUNT so a hostile store with millions of keys cannot weaponize
76
+ * Object.keys iteration. 4096 project×capability consents is far more than any user accumulates.
77
+ * Enforced on BOTH read (refuse a hostile store wholesale) AND write (recordProjectConsent refuses
78
+ * to grow the store past it — CONSENT-MAXRECORDS-WRITE-1).
79
+ */
80
+ const MAX_RECORDS = 4096;
81
+ /**
82
+ * CB-1/CB-2 content-hash bound: the maximum total bytes summed over every regular file in a bundle
83
+ * `bundleContentHash` will hash. A legitimate capability bundle is a handful of small declarative
84
+ * files plus a few scripts; 16 MiB is far more than any real bundle. A bundle exceeding this (a
85
+ * hostile or runaway tree) fails closed: bundleContentHash throws rather than hashing unbounded
86
+ * content, so the loader leaves the cap inactive.
87
+ */
88
+ const BUNDLE_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
89
+ /** Per-file size cap inside a bundle (each file is read via the shared bounded fd reader). */
90
+ const BUNDLE_MAX_FILE_BYTES = BUNDLE_MAX_TOTAL_BYTES;
91
+ /**
92
+ * Bound the bundle ENTRY count so a pathological tree of millions of empty files (or a very deep tree)
93
+ * cannot DoS the walk. #1459 finding 2 (round 6): the cap is enforced on the CUMULATIVE entry count as
94
+ * the walk STREAMS each directory (fs.opendirSync + readSync) — it throws the MOMENT the running count
95
+ * exceeds this, BEFORE collecting/sorting a whole directory's entries — so a huge single directory (or a
96
+ * deep tree) cannot force unbounded memory/CPU before the fail-closed cap. Backed by a mutable variable
97
+ * with a test seam (`_setBundleMaxFilesForTest`) so a test can drive the bound deterministically without
98
+ * planting 100k files; production code never mutates it.
99
+ */
100
+ const BUNDLE_MAX_FILES_DEFAULT = 100_000;
101
+ let BUNDLE_MAX_FILES = BUNDLE_MAX_FILES_DEFAULT;
102
+ /** Valid capability id (kebab-case, lowercase, leading letter). */
103
+ const VALID_ID_RE = /^[a-z][a-z0-9-]*$/;
104
+ // ---------------------------------------------------------------------------
105
+ // Safety helpers (prototype-pollution-safe; CodeQL inline-literal barrier)
106
+ // ---------------------------------------------------------------------------
107
+ /**
108
+ * Returns true when `id` must never be used as an object key / record id — either because it would
109
+ * cause prototype pollution or because it fails the kebab-case constraint. Uses INLINE LITERAL key
110
+ * comparisons (no Set / computed lookup) per the CodeQL prototype-pollution barrier.
111
+ */
112
+ function isUnsafeCapabilityId(id) {
113
+ if (typeof id !== 'string')
114
+ return true;
115
+ if (id === '__proto__')
116
+ return true;
117
+ if (id === 'constructor')
118
+ return true;
119
+ if (id === 'prototype')
120
+ return true;
121
+ if (!VALID_ID_RE.test(id))
122
+ return true;
123
+ return false;
124
+ }
125
+ /** The canonical IN-MEMORY lookup key for a (projectRoot, id) pair (NUL-joined). */
126
+ function consentKey(realRoot, id) {
127
+ return realRoot + String.fromCharCode(0) + id;
128
+ }
129
+ /**
130
+ * The ON-DISK key (WIN-3): an unambiguous JSON-object string `{"r":<realpath>,"i":<id>}`. The prior
131
+ * space-joined `<realpath> <id>` form was ambiguous when a path contained a space (Windows
132
+ * `C:\Users\John Smith\...`): two distinct (root,id) pairs could collide. A JSON-stringified object
133
+ * key encodes both components unambiguously, so distinct pairs never collide on disk.
134
+ */
135
+ function diskKey(realRoot, id) {
136
+ return JSON.stringify({ r: realRoot, i: id });
137
+ }
138
+ /**
139
+ * Best-effort realpath of a project root. A non-existent path cannot be realpath'd; fall back to
140
+ * path.resolve so a record can still be written/looked-up consistently (both record and lookup use
141
+ * this same function, so they agree).
142
+ */
143
+ function realpathProject(projectRoot) {
144
+ try {
145
+ return node_fs_1.default.realpathSync(projectRoot);
146
+ }
147
+ catch {
148
+ return node_path_1.default.resolve(projectRoot);
149
+ }
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Path resolution
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Resolve the consent store path. Uses the SAME `gsdHome || GSD_HOME || homedir()` rule the loader
156
+ * and CLI use, so a consent record written by the CLI is found by the loader. The store NEVER lives
157
+ * under a repository — it is user-owned, machine-local config.
158
+ */
159
+ function consentStorePath(gsdHome) {
160
+ const home = gsdHome || process.env['GSD_HOME'] || node_os_1.default.homedir();
161
+ return node_path_1.default.join(home, CONSENT_DIRNAME, CONSENT_FILE_NAME);
162
+ }
163
+ /** The path-separator BYTE used to join raw-byte path segments — `/` (0x2f) on every platform we hash on. */
164
+ const SEP_BYTE = Buffer.from('/');
165
+ /** On Windows the OS separator is `\\` (0x5c); normalize it to `/` at the BYTE level for cross-platform determinism. */
166
+ const WIN_SEP_BYTE = 0x5c;
167
+ /** Join a parent raw-byte path and a raw-byte segment with the `/` separator byte. An empty parent → the segment alone. */
168
+ function joinBytes(parent, segment) {
169
+ if (parent.length === 0)
170
+ return Buffer.from(segment);
171
+ return Buffer.concat([parent, SEP_BYTE, segment]);
172
+ }
173
+ /** Normalize Windows `\\` separator bytes to `/` in a raw-byte relpath (no-op on POSIX paths). */
174
+ function normalizeSepBytes(rel) {
175
+ if (process.platform !== 'win32')
176
+ return rel;
177
+ const out = Buffer.from(rel);
178
+ for (let i = 0; i < out.length; i++)
179
+ if (out[i] === WIN_SEP_BYTE)
180
+ out[i] = 0x2f;
181
+ return out;
182
+ }
183
+ /**
184
+ * Recursively collect every REGULAR file AND every DIRECTORY under `absDir` as RAW-BYTE POSIX-relative
185
+ * paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Bounded:
186
+ * throws if the entry count or total byte size exceeds the caps (fail closed — a hostile/runaway tree
187
+ * never hashes unbounded content). A non-regular entry encountered IN the tree (FIFO/device) is a
188
+ * fail-closed throw — a bundle must be plain files and directories.
189
+ *
190
+ * #1459 finding 2 (MED/HIGH, ROUND 6): the enumeration ITSELF is bounded. Instead of
191
+ * `fs.readdirSync` (which loads + sorts a WHOLE directory before the count cap — so a malicious bundle
192
+ * with a huge single directory, or a very deep tree, forces unbounded memory/CPU before fail-closing),
193
+ * we STREAM each level via fs.opendirSync + dir.readSync() and increment a CUMULATIVE entry counter
194
+ * (`count.n`) across the recursive walk, throwing the MOMENT it exceeds BUNDLE_MAX_FILES — BEFORE
195
+ * collecting (let alone sorting) the rest of the level. Determinism is preserved: the BOUNDED set of a
196
+ * level is still sorted (by raw-byte name) before lstat/recursion, and the FINAL digest sorts over all
197
+ * rel byte strings. The cap is cumulative, so a deep tree spread across many nested dirs cannot blow it.
198
+ *
199
+ * #1459 finding 2 (LOW): directories (including EMPTY ones) are emitted as typed DIR markers so that
200
+ * adding/removing an empty directory CHANGES the canonical hash. Capability code can branch on a
201
+ * directory's existence, so a bare-dir add must be observable to the binding.
202
+ *
203
+ * #1459 finding 4 (LOW): dir entries are read as raw-byte Buffer names (`encoding: 'buffer'`) and the
204
+ * abs/rel paths are concatenated at the BYTE level, so an invalid-UTF-8 filename is never lossily
205
+ * decoded — two filenames that differ only in invalid bytes produce distinct rel byte strings.
206
+ *
207
+ * @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
208
+ * @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
209
+ * @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
210
+ */
211
+ function collectBundleEntries(absDir, relDir, acc, total, count) {
212
+ let dir;
213
+ try {
214
+ // RAW-BYTE streaming open: dirent names are Buffers (encoding: 'buffer'), so an invalid-UTF-8
215
+ // filename is preserved verbatim. opendirSync + readSync iterates one entry at a time, so the cap
216
+ // can fail closed BEFORE the whole directory is materialized/sorted.
217
+ dir = node_fs_1.default.opendirSync(absDir, { encoding: 'buffer' });
218
+ }
219
+ catch (err) {
220
+ throw new Error(`bundleContentHash: cannot read directory "${absDir.toString('utf8')}": ${err.message}`);
221
+ }
222
+ // Collect ONLY the BOUNDED set of this level's dirents — the cumulative counter throws the moment it
223
+ // crosses the cap, so the array can never grow past it. We still sort this bounded set (by raw-byte
224
+ // name) so the byte/count accounting walk is reproducible across platforms.
225
+ const levelEntries = [];
226
+ try {
227
+ for (;;) {
228
+ let ent;
229
+ try {
230
+ ent = dir.readSync();
231
+ }
232
+ catch (err) {
233
+ throw new Error(`bundleContentHash: cannot read directory "${absDir.toString('utf8')}": ${err.message}`);
234
+ }
235
+ if (ent === null)
236
+ break;
237
+ // BOUND THE ENUMERATION ITSELF: increment the cumulative counter and fail closed BEFORE this entry
238
+ // is retained/sorted, so a huge directory (or deep tree) cannot be loaded/sorted in full first.
239
+ count.n++;
240
+ if (count.n > BUNDLE_MAX_FILES) {
241
+ throw new Error(`bundleContentHash: bundle entry count exceeds ${BUNDLE_MAX_FILES} (refusing)`);
242
+ }
243
+ levelEntries.push(ent);
244
+ }
245
+ }
246
+ finally {
247
+ try {
248
+ dir.closeSync();
249
+ }
250
+ catch { /* best-effort */ }
251
+ }
252
+ levelEntries.sort((a, b) => Buffer.compare(a.name, b.name));
253
+ for (const ent of levelEntries) {
254
+ const name = ent.name; // Buffer
255
+ const abs = joinBytes(absDir, name);
256
+ const rel = normalizeSepBytes(joinBytes(relDir, name));
257
+ // lstat the entry (Buffer path): a symlink must NOT be followed (it could escape the bundle to
258
+ // /etc/passwd or to an infinite device). Re-lstat to be certain across platforms.
259
+ let st;
260
+ try {
261
+ st = node_fs_1.default.lstatSync(abs);
262
+ }
263
+ catch (err) {
264
+ throw new Error(`bundleContentHash: cannot lstat "${abs.toString('utf8')}": ${err.message}`);
265
+ }
266
+ if (st.isSymbolicLink()) {
267
+ // A symlink in the bundle is suspicious and unhashable safely (it would either escape the
268
+ // bundle or follow to a non-regular target). Fail closed.
269
+ throw new Error(`bundleContentHash: refusing to hash a symlink in the bundle: "${abs.toString('utf8')}"`);
270
+ }
271
+ if (st.isDirectory()) {
272
+ // Emit a typed DIR marker for THIS directory (so an empty dir is bound), then recurse into it.
273
+ acc.push({ abs, rel, kind: 'dir' });
274
+ collectBundleEntries(abs, rel, acc, total, count);
275
+ continue;
276
+ }
277
+ if (!st.isFile()) {
278
+ throw new Error(`bundleContentHash: refusing to hash a non-regular file in the bundle: "${abs.toString('utf8')}"`);
279
+ }
280
+ acc.push({ abs, rel, kind: 'file' });
281
+ total.bytes += st.size;
282
+ if (total.bytes > BUNDLE_MAX_TOTAL_BYTES) {
283
+ throw new Error(`bundleContentHash: bundle size exceeds ${BUNDLE_MAX_TOTAL_BYTES} bytes (refusing)`);
284
+ }
285
+ }
286
+ }
287
+ /** Encode an unsigned 32-bit length as 4 big-endian bytes (the path-length frame). */
288
+ function uint32be(n) {
289
+ const b = Buffer.allocUnsafe(4);
290
+ b.writeUInt32BE(n >>> 0, 0);
291
+ return b;
292
+ }
293
+ /**
294
+ * Encode an unsigned 64-bit length as 8 big-endian bytes (the content-length frame). A bundle file is
295
+ * size-capped well below 2^53 so writeBigUInt64BE of a BigInt is exact and never overflows.
296
+ */
297
+ function uint64be(n) {
298
+ const b = Buffer.allocUnsafe(8);
299
+ b.writeBigUInt64BE(BigInt(n), 0);
300
+ return b;
301
+ }
302
+ /** Typed entry tags so a FILE and a DIR at the same relpath can never produce the same digest input. */
303
+ const TAG_FILE = Buffer.from([0x01]);
304
+ const TAG_DIR = Buffer.from([0x02]);
305
+ /**
306
+ * The recomputed full-bundle content hash (#1459 CB-1/CB-2/TRUST2-5) — the SECURITY BINDING. A
307
+ * `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file
308
+ * AND directory under `capDir` (recursively).
309
+ *
310
+ * Canonicalization (#1459 findings 1 + 4 — the prior `relpath + NUL + content + NUL` over utf8-decoded
311
+ * STRINGS was non-injective, lossy in CONTENT, AND lossy in the PATH component):
312
+ * - LENGTH-FRAMED, no ambiguous delimiters. A leading fixed-width entry COUNT, then per entry
313
+ * (sorted by raw-byte relpath): a 1-byte TYPE tag, uint32 path-byte-length + the raw path bytes,
314
+ * and (for a FILE) uint64 content-byte-length + the raw content bytes. Because every component is
315
+ * length-prefixed, a NUL (or any byte) inside a path or file content can never be mistaken for a
316
+ * boundary — two different (path, content) splits cannot collide.
317
+ * - RAW BYTES end to end, never utf8-decoded — for BOTH content AND the path. File bytes are read via
318
+ * the ledger's RAW-BYTES bounded reader (readSmallRegularFileBuffer); the PATH bytes come straight
319
+ * from a raw-byte (`encoding: 'buffer'`) dir walk (#1459 finding 4), so two binary artifacts that
320
+ * differ only in invalid-UTF-8 bytes — whether in their CONTENT or in their FILENAME (both of which
321
+ * a utf8 decode would collapse to U+FFFD) — produce DIFFERENT digests.
322
+ * - DETERMINISTIC across platforms: entries sorted by the raw-byte relpath whose separators are
323
+ * normalized to the `/` byte, so an on-disk reorder and a Windows-vs-POSIX separator difference do
324
+ * not matter.
325
+ *
326
+ * Throws (fail closed) on an unreadable dir, a non-regular/symlinked bundle entry, or a bundle that
327
+ * exceeds the size/count caps — the loader treats a throw as "no matching consent" (inactive).
328
+ *
329
+ * Each file's bytes are read via the SHARED bounded fd reader (open → fstat → require regular file →
330
+ * size cap → read exactly size), so a file swapped for a FIFO/device between the walk and the read
331
+ * cannot block or read unbounded.
332
+ */
333
+ function bundleContentHash(capDir) {
334
+ // Resolve to an absolute path, then carry it as RAW BYTES so the walk never lossily decodes a name.
335
+ const rootBytes = Buffer.from(node_path_1.default.resolve(capDir));
336
+ const entries = [];
337
+ collectBundleEntries(rootBytes, Buffer.alloc(0), entries, { bytes: 0 }, { n: 0 });
338
+ // Sort by the raw-byte (separator-normalized) relpath so the digest is identical on Windows and POSIX,
339
+ // and is independent of the on-disk creation/readdir order. Tie-break on kind so a (degenerate, never
340
+ // produced on a real fs) file-and-dir same-relpath pair still has a stable order.
341
+ entries.sort((a, b) => {
342
+ const c = Buffer.compare(a.rel, b.rel);
343
+ if (c !== 0)
344
+ return c;
345
+ return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
346
+ });
347
+ const hash = node_crypto_1.default.createHash('sha512');
348
+ // Header: a fixed-width entry COUNT frames the whole stream (so a truncated/extended entry list
349
+ // cannot be confused with a different bundle).
350
+ hash.update(uint64be(entries.length));
351
+ for (const ent of entries) {
352
+ const pathBytes = ent.rel; // RAW path bytes (finding 4) — never utf8-decoded.
353
+ if (ent.kind === 'dir') {
354
+ // Typed DIR marker: tag + length-framed path. No content — binds the directory's mere existence.
355
+ hash.update(TAG_DIR);
356
+ hash.update(uint32be(pathBytes.length));
357
+ hash.update(pathBytes);
358
+ continue;
359
+ }
360
+ // FILE: tag + length-framed path + length-framed RAW content bytes (no utf8 decode).
361
+ const content = ledgerMod.readSmallRegularFileBuffer(ent.abs, BUNDLE_MAX_FILE_BYTES);
362
+ // null here would mean the file vanished between walk and read — fail closed.
363
+ if (content === null) {
364
+ throw new Error(`bundleContentHash: file vanished during hash: "${ent.abs.toString('utf8')}"`);
365
+ }
366
+ hash.update(TAG_FILE);
367
+ hash.update(uint32be(pathBytes.length));
368
+ hash.update(pathBytes);
369
+ hash.update(uint64be(content.length));
370
+ hash.update(content);
371
+ }
372
+ return `sha512-${hash.digest('base64')}`;
373
+ }
374
+ // ---------------------------------------------------------------------------
375
+ // Read (bounded, non-throwing)
376
+ // ---------------------------------------------------------------------------
377
+ /**
378
+ * Validate a single record object. Rejects anything not matching the schema — a malformed/tampered
379
+ * record is dropped (fail closed: it cannot grant consent). Returns true only for a structurally-
380
+ * complete project-scope record carrying a contentHash binding.
381
+ */
382
+ function isValidConsentRecord(rec) {
383
+ if (typeof rec !== 'object' || rec === null || Array.isArray(rec))
384
+ return false;
385
+ const r = rec;
386
+ if (typeof r['projectRoot'] !== 'string' || !r['projectRoot'])
387
+ return false;
388
+ if (typeof r['id'] !== 'string' || isUnsafeCapabilityId(r['id']))
389
+ return false;
390
+ if (r['scope'] !== 'project')
391
+ return false;
392
+ if (typeof r['integrity'] !== 'string')
393
+ return false;
394
+ if (typeof r['disclosureSignature'] !== 'string')
395
+ return false;
396
+ // The security binding MUST be present and non-empty — a record without a contentHash can never
397
+ // match a recomputed hash and is treated as invalid (fail closed).
398
+ if (typeof r['contentHash'] !== 'string' || !r['contentHash'])
399
+ return false;
400
+ if (typeof r['consentedAt'] !== 'string' || !r['consentedAt'])
401
+ return false;
402
+ return true;
403
+ }
404
+ /**
405
+ * Read the consent store. NON-THROWING and BOUNDED: a missing, corrupt, oversized, non-regular
406
+ * (FIFO/device), or wrong-shape store yields an empty `{ records: {} }`. Invalid individual records
407
+ * are dropped. A store whose record count exceeds MAX_RECORDS is refused wholesale (hostile DoS).
408
+ */
409
+ function readConsentStore(gsdHome) {
410
+ const empty = { records: {} };
411
+ const filePath = consentStorePath(gsdHome);
412
+ let raw;
413
+ try {
414
+ raw = ledgerMod.readSmallRegularFile(filePath, CONSENT_MAX_BYTES);
415
+ }
416
+ catch {
417
+ // Non-regular (FIFO/device/dir), oversized, or IO error → fail closed to empty.
418
+ return empty;
419
+ }
420
+ if (raw === null || raw === '')
421
+ return empty; // genuinely missing / empty.
422
+ let parsed;
423
+ try {
424
+ parsed = JSON.parse(raw);
425
+ }
426
+ catch {
427
+ return empty; // corrupt JSON.
428
+ }
429
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
430
+ return empty;
431
+ const p = parsed;
432
+ const recordsVal = p['records'];
433
+ if (typeof recordsVal !== 'object' || recordsVal === null || Array.isArray(recordsVal))
434
+ return empty;
435
+ const records = recordsVal;
436
+ const keys = Object.keys(records);
437
+ if (keys.length > MAX_RECORDS)
438
+ return empty; // hostile record count — refuse the whole store.
439
+ // Re-key by the canonical NUL key so lookups never depend on the disk-key's serialization.
440
+ const out = { records: {} };
441
+ for (const key of keys) {
442
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
443
+ continue; // proto-safe.
444
+ const rec = records[key];
445
+ if (!isValidConsentRecord(rec))
446
+ continue;
447
+ out.records[consentKey(rec.projectRoot, rec.id)] = rec;
448
+ }
449
+ return out;
450
+ }
451
+ // ---------------------------------------------------------------------------
452
+ // Has (the security match is the recomputed contentHash)
453
+ // ---------------------------------------------------------------------------
454
+ /**
455
+ * True iff a consent record exists for `(realpath(projectRoot), id)` whose `contentHash` equals the
456
+ * supplied (recomputed-by-the-loader) value. The contentHash is THE security binding (#1459
457
+ * CB-1/CB-2): it covers the whole bundle (manifest AND artifacts AND identity), so a swapped
458
+ * declarative manifest, a tampered hook script, or an empty-integrity local install all fail to
459
+ * match. An unsafe id is rejected (→ false) before any lookup. Prototype-pollution-safe (NUL keys +
460
+ * hasOwnProperty).
461
+ */
462
+ function hasProjectConsent(args) {
463
+ const { gsdHome, projectRoot, id, contentHash } = args;
464
+ if (isUnsafeCapabilityId(id))
465
+ return false;
466
+ if (typeof contentHash !== 'string' || !contentHash)
467
+ return false;
468
+ const store = readConsentStore(gsdHome);
469
+ const key = consentKey(realpathProject(projectRoot), id);
470
+ if (!Object.prototype.hasOwnProperty.call(store.records, key))
471
+ return false;
472
+ const rec = store.records[key];
473
+ return rec.contentHash === contentHash;
474
+ }
475
+ /** The consent-store lock path — keyed on the consent store DIRECTORY (one lock per machine store). */
476
+ function consentLockPath(gsdHome) {
477
+ return node_path_1.default.join(node_path_1.default.dirname(consentStorePath(gsdHome)), '.consent.lock');
478
+ }
479
+ /**
480
+ * CONSENT-CONCURRENCY-1 (HIGH): record/revoke do a read-modify-write of the ONE global consent.json.
481
+ * Two DIFFERENT projects writing the same store concurrently would lose-update without a lock (project B
482
+ * reads, project A writes, project B overwrites with its stale snapshot, dropping A's record). The lock
483
+ * is keyed on the consent store DIRECTORY so all consent writers on this machine serialize.
484
+ *
485
+ * #1459 finding 4 (MEDIUM): this now uses the SHARED hardened lock primitive (capability-lock) — the
486
+ * SAME steal protocol as the lifecycle lock. The old self-contained consent lock stole any holder past
487
+ * a 60s mtime regardless of liveness, so a slow/paused LIVE writer would be stolen and its store
488
+ * overwritten (lost update). The shared primitive NEVER stale-steals a verified-live same-host holder
489
+ * (pid + process-start-time identity) and reclaims only a provably-dead/unverifiable holder (dead-pid
490
+ * fast path or the hard deadman) — so a live writer is never stolen and a crashed writer never deadlocks.
491
+ */
492
+ function acquireConsentLock(dir) {
493
+ // waitForFresh: a contended fresh/live holder is WAITED FOR (back off + retry), not failed-fast, so
494
+ // two genuinely-racing consent writers serialize; null only when contention outlasts the budget.
495
+ return lockMod.acquireLock(node_path_1.default.join(dir, '.consent.lock'), { maxAttempts: CONSENT_LOCK_MAX_ATTEMPTS, waitForFresh: true });
496
+ }
497
+ /** Release the consent lock (shared primitive — token + inode owner-safe; never deletes a successor's). */
498
+ function releaseConsentLock(handle) {
499
+ lockMod.releaseLock(handle);
500
+ }
501
+ // ---------------------------------------------------------------------------
502
+ // Atomic + durable write (mirrors capability-ledger.writeLedger)
503
+ // ---------------------------------------------------------------------------
504
+ /**
505
+ * Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync).
506
+ * WIN-4 (#1459 round 2): ENOENT is tolerated too — the containing dir can vanish between rename and
507
+ * fsync on an aggressively-swept tmp tree (Windows/CI), and a missing dir cannot be fsync'd.
508
+ */
509
+ const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF', 'ENOENT']);
510
+ /** fsync the directory containing `dest` so a rename is durable across a power loss (best-effort). */
511
+ function fsyncContainingDir(dest) {
512
+ let dirFd = null;
513
+ try {
514
+ dirFd = node_fs_1.default.openSync(node_path_1.default.dirname(dest), 'r');
515
+ node_fs_1.default.fsyncSync(dirFd);
516
+ }
517
+ catch (err) {
518
+ const code = err.code;
519
+ if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
520
+ throw new Error(`Directory fsync of "${node_path_1.default.dirname(dest)}" failed (${code}); durability of the consent ` +
521
+ `store rename could NOT be confirmed: ${err.message}`);
522
+ }
523
+ /* tolerated errno (or no code) — best-effort */
524
+ }
525
+ finally {
526
+ if (dirFd !== null) {
527
+ try {
528
+ node_fs_1.default.closeSync(dirFd);
529
+ }
530
+ catch { /* best-effort */ }
531
+ }
532
+ }
533
+ }
534
+ /** WIN-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */
535
+ const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
536
+ const RENAME_MAX_ATTEMPTS = 3;
537
+ const RENAME_RETRY_BACKOFF_MS = 50;
538
+ let _renameSleepBuf = null;
539
+ function renameBackoff() {
540
+ if (_renameSleepBuf === null)
541
+ _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
542
+ Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
543
+ }
544
+ /**
545
+ * Serialize the store to disk atomically + durably (tmp with O_EXCL → write-all → fsync → close →
546
+ * rename → dir fsync; temp cleaned up on any failure). Mirrors the capability-ledger writeLedger
547
+ * durability idiom so a crash/power-loss mid-write can never produce a truncated consent store.
548
+ *
549
+ * WIN-1 / CONSENT-ATOMIC-WRITE parity (#1459 round 2): the renameSync is retried with backoff on the
550
+ * transient Windows AV/indexer errnos (EPERM/EBUSY/EACCES), matching writeLedger.
551
+ *
552
+ * The on-disk JSON uses the unambiguous JSON-object disk key (WIN-3); the in-memory store is keyed by
553
+ * the canonical NUL key, so we re-key here.
554
+ */
555
+ function writeConsentStore(gsdHome, store) {
556
+ const filePath = consentStorePath(gsdHome);
557
+ const dir = node_path_1.default.dirname(filePath);
558
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
559
+ const onDisk = {
560
+ version: CONSENT_SCHEMA_VERSION,
561
+ records: {},
562
+ };
563
+ for (const key of Object.keys(store.records)) {
564
+ const rec = store.records[key];
565
+ onDisk.records[diskKey(rec.projectRoot, rec.id)] = rec;
566
+ }
567
+ const content = JSON.stringify(onDisk, null, 2) + '\n';
568
+ const nonce = node_crypto_1.default.randomBytes(4).toString('hex');
569
+ const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`;
570
+ const fd = node_fs_1.default.openSync(tmpPath, 'wx'); // exclusive create — defeats a pre-planted symlink.
571
+ let primaryErr = null;
572
+ try {
573
+ node_fs_1.default.writeFileSync(fd, content); // write-all loop — no short writes.
574
+ node_fs_1.default.fsyncSync(fd); // flush bytes to stable storage BEFORE the rename.
575
+ }
576
+ catch (err) {
577
+ primaryErr = err instanceof Error ? err : new Error(String(err));
578
+ }
579
+ finally {
580
+ let closeErr = null;
581
+ try {
582
+ node_fs_1.default.closeSync(fd);
583
+ }
584
+ catch (err) {
585
+ closeErr = err instanceof Error ? err : new Error(String(err));
586
+ }
587
+ if (primaryErr !== null) {
588
+ try {
589
+ node_fs_1.default.unlinkSync(tmpPath);
590
+ }
591
+ catch { /* best-effort — no orphan */ }
592
+ throw primaryErr;
593
+ }
594
+ if (closeErr !== null) {
595
+ try {
596
+ node_fs_1.default.unlinkSync(tmpPath);
597
+ }
598
+ catch { /* best-effort — no orphan */ }
599
+ throw closeErr;
600
+ }
601
+ }
602
+ // WIN-1: retry the rename on transient Windows AV/indexer locks before giving up (writeLedger parity).
603
+ let renameErr = null;
604
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
605
+ try {
606
+ node_fs_1.default.renameSync(tmpPath, filePath);
607
+ renameErr = null;
608
+ break;
609
+ }
610
+ catch (err) {
611
+ renameErr = err instanceof Error ? err : new Error(String(err));
612
+ const code = err.code ?? '';
613
+ if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
614
+ renameBackoff();
615
+ continue;
616
+ }
617
+ break;
618
+ }
619
+ }
620
+ if (renameErr !== null) {
621
+ try {
622
+ node_fs_1.default.unlinkSync(tmpPath);
623
+ }
624
+ catch { /* best-effort */ }
625
+ throw renameErr;
626
+ }
627
+ fsyncContainingDir(filePath);
628
+ }
629
+ /**
630
+ * Record a PROJECT-scope consent: that the user, on THIS machine, accepted capability `id` at the
631
+ * given `projectRoot`, bound to the recomputed bundle `contentHash` (the security binding) plus the
632
+ * `integrity` + `disclosureSignature` (kept for the disclosure/re-consent UX). Rejects an unsafe id
633
+ * (throws, writing nothing). Idempotent: re-recording the same (projectRoot, id) overwrites in place;
634
+ * other records are preserved.
635
+ *
636
+ * CONSENT-CONCURRENCY-1: the whole read-modify-write runs UNDER the consent-store lock so two
637
+ * different projects writing concurrently cannot lose each other's record.
638
+ * CONSENT-MAXRECORDS-WRITE-1: refuses to grow the store past MAX_RECORDS BEFORE writing (a clear
639
+ * 'consent store full' throw), leaving the on-disk store intact.
640
+ *
641
+ * #1459 finding 3 (MEDIUM): if the consent-store lock CANNOT be acquired, this THROWS rather than
642
+ * proceeding UNLOCKED — an unlocked read-modify-write is exactly the lost-update vector the lock exists
643
+ * to prevent. The lifecycle treats a consent-write failure as NON-FATAL + warns (round-2 IC-05), so
644
+ * throwing here is safe: an install still succeeds; the cap simply stays inactive until consent can be
645
+ * written. (The OLD code returned a null handle and proceeded unlocked — that is the bug.)
646
+ */
647
+ function recordProjectConsent(args) {
648
+ const { gsdHome, projectRoot, id, integrity, disclosureSignature, contentHash } = args;
649
+ if (isUnsafeCapabilityId(id)) {
650
+ throw new Error(`Invalid capability id "${String(id)}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
651
+ `Unsafe or non-kebab ids are rejected to keep the consent store prototype-pollution-safe.`);
652
+ }
653
+ if (typeof contentHash !== 'string' || !contentHash) {
654
+ throw new Error(`recordProjectConsent: a non-empty contentHash is required (it is the security binding). ` +
655
+ `Compute it via bundleContentHash(capDir) over the installed bundle.`);
656
+ }
657
+ const realRoot = realpathProject(projectRoot);
658
+ const lockDir = node_path_1.default.dirname(consentStorePath(gsdHome));
659
+ try {
660
+ node_fs_1.default.mkdirSync(lockDir, { recursive: true });
661
+ }
662
+ catch { /* best-effort — write also mkdirs */ }
663
+ // #1459 finding 3: never proceed UNLOCKED. A null handle (live holder / contention budget exhausted)
664
+ // → throw rather than risk a lost update.
665
+ const lock = acquireConsentLock(lockDir);
666
+ if (lock === null) {
667
+ throw new Error(`recordProjectConsent: could not acquire the consent-store lock at ${consentLockPath(gsdHome)} ` +
668
+ `(another writer holds it). Refusing to write the consent store UNLOCKED (a lost-update risk). ` +
669
+ `Retry; if a stale lock persists past the deadman it is reclaimed automatically.`);
670
+ }
671
+ try {
672
+ const store = readConsentStore(gsdHome);
673
+ const key = consentKey(realRoot, id);
674
+ // CONSENT-MAXRECORDS-WRITE-1: enforce the cap BEFORE the write. A re-record of an EXISTING key
675
+ // does not grow the store (allowed); only ADDING a new key when already at the cap is refused.
676
+ if (!Object.prototype.hasOwnProperty.call(store.records, key) && Object.keys(store.records).length >= MAX_RECORDS) {
677
+ throw new Error(`consent store full: already at the maximum of ${MAX_RECORDS} consent records. Revoke an ` +
678
+ `unused consent (gsd capability trust revoke) before recording a new one.`);
679
+ }
680
+ store.records[key] = {
681
+ projectRoot: realRoot,
682
+ id,
683
+ scope: 'project',
684
+ integrity,
685
+ disclosureSignature,
686
+ contentHash,
687
+ consentedAt: new Date().toISOString(),
688
+ };
689
+ writeConsentStore(gsdHome, store);
690
+ }
691
+ finally {
692
+ releaseConsentLock(lock);
693
+ }
694
+ }
695
+ /**
696
+ * Revoke a PROJECT-scope consent record. No-op (and never throws) when the record is absent or the
697
+ * id is unsafe. Atomic, LOCKED write of the resulting store. Used on `capability remove` and
698
+ * `trust revoke`.
699
+ *
700
+ * #1459 finding 3 (MEDIUM): if the consent-store lock CANNOT be acquired, this THROWS rather than
701
+ * doing an unlocked read-modify-write (the lost-update vector). An ABSENT-record no-op still happens
702
+ * UNDER the lock (so a concurrent record cannot interleave); only a genuine lock-acquire failure throws.
703
+ */
704
+ function revokeProjectConsent(args) {
705
+ const { gsdHome, projectRoot, id } = args;
706
+ if (isUnsafeCapabilityId(id))
707
+ return; // an unsafe id was never stored — nothing to revoke.
708
+ const realRoot = realpathProject(projectRoot);
709
+ const lockDir = node_path_1.default.dirname(consentStorePath(gsdHome));
710
+ try {
711
+ node_fs_1.default.mkdirSync(lockDir, { recursive: true });
712
+ }
713
+ catch { /* best-effort — write also mkdirs */ }
714
+ // #1459 finding 3: never proceed UNLOCKED — a null handle throws rather than deleting unlocked.
715
+ const lock = acquireConsentLock(lockDir);
716
+ if (lock === null) {
717
+ throw new Error(`revokeProjectConsent: could not acquire the consent-store lock at ${consentLockPath(gsdHome)} ` +
718
+ `(another writer holds it). Refusing to modify the consent store UNLOCKED (a lost-update risk). ` +
719
+ `Retry; if a stale lock persists past the deadman it is reclaimed automatically.`);
720
+ }
721
+ try {
722
+ const store = readConsentStore(gsdHome);
723
+ const key = consentKey(realRoot, id);
724
+ if (!Object.prototype.hasOwnProperty.call(store.records, key))
725
+ return; // absent — no-op.
726
+ delete store.records[key];
727
+ writeConsentStore(gsdHome, store);
728
+ }
729
+ finally {
730
+ releaseConsentLock(lock);
731
+ }
732
+ }
733
+ /**
734
+ * #1459 finding 2 (round 6): TEST-ONLY — override the cumulative bundle entry-count cap and return a
735
+ * restore() that resets it to the production default. Lets a test prove the streaming walk fails closed
736
+ * at the bound without planting 100k real files. Never called by production code.
737
+ */
738
+ function _setBundleMaxFilesForTest(n) {
739
+ const prev = BUNDLE_MAX_FILES;
740
+ BUNDLE_MAX_FILES = n;
741
+ return () => { BUNDLE_MAX_FILES = prev; };
742
+ }
743
+ module.exports = {
744
+ consentStorePath,
745
+ bundleContentHash,
746
+ readConsentStore,
747
+ hasProjectConsent,
748
+ recordProjectConsent,
749
+ revokeProjectConsent,
750
+ // Exported for testing / introspection.
751
+ MAX_RECORDS,
752
+ CONSENT_FILE_NAME,
753
+ // #1459 finding 3/4: the consent-store lock path + the shared lock primitive's test seams (so tests
754
+ // can plant a lock and inject deterministic liveness probes to verify the never-steal-a-live-writer
755
+ // and dead-holder-reclaim behavior). Not part of the CLI surface.
756
+ consentLockPath,
757
+ _setLockProbes: lockMod._setLockProbes,
758
+ _resetLockProbes: lockMod._resetLockProbes,
759
+ // #1459 finding 2 (round 6): a TEST-ONLY seam to drive the cumulative entry-count cap deterministically
760
+ // (so a test can prove the streaming walk fails closed at the bound without planting 100k real files).
761
+ // Returns a restore() that resets the cap to its production default. Not part of the CLI surface.
762
+ _setBundleMaxFilesForTest,
763
+ };