@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- 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
|
+
};
|