fix(#1459): user-owned consent store gates third-party capability activation; env/cwd in disclosure; loader validator parity (#1473)

This commit is contained in:
Tom Boucher
2026-06-20 01:59:03 -04:00
committed by GitHub
parent 81d4b6d573
commit e7855bc217
30 changed files with 5125 additions and 685 deletions

824
src/capability-consent.cts Normal file
View File

@@ -0,0 +1,824 @@
/**
* Capability consent store — issue #1459 (capability trust model bypassable).
*
* A USER-OWNED store, living OUTSIDE any repository at `${GSD_HOME||homedir()}/.gsd/consent.json`,
* that binds each PROJECT-scope third-party capability activation to a decision the user made on
* THIS machine. Before #1459 a project's in-repo ledger entry was treated as the consent signal —
* but a project ledger is repo-plantable, so cloning/forging a repo activated executable surfaces
* and command dispatch with no user decision (the trust model was bypassable). The consent store
* moves the authoritative signal off the repo tree: a project overlay is INACTIVE until a matching
* consent record exists in this user-owned store.
*
* CONTENT BINDING (the security crux — #1459 round 2, findings CB-1/CB-2/TRUST2-5). The consent
* record is bound to a RECOMPUTED full-bundle content hash (`bundleContentHash`), NOT to the ledger
* `integrity` (which is `''` for path/git/dir installs and taken verbatim from the repo-plantable
* project ledger — `'' === ''` is no binding) NOR to the `disclosureSignature` alone (which covers
* only executable surfaces, so a declarative-only cap has a constant signature and a repo-write
* attacker could swap `capability.json` for a malicious gate/contribution while consent still
* matched). `bundleContentHash` is recomputed by the loader at load over EVERY file in the bundle
* (manifest AND artifacts AND identity), so any tamper — declarative-only swap, hook-script edit,
* empty-integrity local install — changes the hash and leaves the cap inactive. `integrity` and
* `disclosureSignature` remain on the record for the human disclosure + re-consent-on-executable-
* change UX (TRUST-2); they are NO LONGER the security binding.
*
* LEAF MODULE — imports ONLY: node:fs, node:path, node:os, node:crypto, and the shared bounded
* fd reader (readSmallRegularFile) from ./capability-ledger.cjs.
*
* Schema: `{ version: "1", records: { "<JSON({r,i})>": ConsentRecord } }`. The store is UNRELEASED
* (no migration/back-compat shims needed); the only version is "1".
*
* Exports:
* consentStorePath(gsdHome?) — resolve the store path (GSD_HOME||homedir() rule).
* bundleContentHash(capDir) — recomputed sha512 over the whole bundle (the binding).
* readConsentStore(gsdHome?) — bounded, NON-THROWING read; bad input → { records: {} }.
* hasProjectConsent({...}) — true iff a record matches the recomputed contentHash.
* recordProjectConsent({...}) — atomic+durable+LOCKED write of a project-scope record.
* revokeProjectConsent({...}) — atomic+LOCKED delete of a project-scope record (no-op if absent).
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
/* eslint-disable @typescript-eslint/no-require-imports */
const ledgerMod = require('./capability-ledger.cjs') as {
readSmallRegularFile: (filePath: string, maxBytes: number) => string | null;
// #1459 finding 1 (HIGH): the RAW-BYTES reader — bundleContentHash MUST hash raw bytes, not a lossy
// utf8-decoded string, so two binary artifacts differing only in invalid-UTF-8 bytes cannot collide.
// #1459 finding 4 (LOW): accepts a RAW-BYTE Buffer path too — an invalid-UTF-8 FILENAME must be
// reopened by its exact bytes (a utf8-decoded string path would resolve to a U+FFFD-mangled name).
// fs.openSync accepts a Buffer path at runtime; widening the type here reflects that.
readSmallRegularFileBuffer: (filePath: string | Buffer, maxBytes: number) => Buffer | null;
};
// #1459 finding 4: the SHARED hardened lock primitive (single source of truth for lifecycle + consent).
// Before this, the consent lock used a naive mtime-only 60s steal that would STEAL A LIVE WRITER (a
// slow/paused holder past 60s is reclaimed → original writer resumes and overwrites = lost update). The
// shared primitive never stale-steals a verified-live same-host holder (pid + start-time identity) and
// only reclaims a provably-dead/unverifiable holder (dead-pid fast path or the hard deadman).
const lockMod = require('./capability-lock.cjs') as {
acquireLock: (lockPath: string, opts?: { maxAttempts?: number; waitForFresh?: boolean }) => { path: string; token: string; dev: number | null; ino: number | null } | null;
releaseLock: (handle: { path: string; token: string; dev: number | null; ino: number | null } | null) => void;
_setLockProbes: (probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>) => void;
_resetLockProbes: () => void;
};
/**
* The consent store has GENUINELY-CONTENDED writers (two different projects installing concurrently
* both write the ONE global consent.json), so it must SERIALIZE under brief contention rather than fail
* — a larger steal/retry budget than the lifecycle's small sub-second default. Combined with #1459
* finding 3 (throw on a NULL handle), this throws only when contention truly outlasts the budget.
*/
const CONSENT_LOCK_MAX_ATTEMPTS = 50;
/* eslint-enable @typescript-eslint/no-require-imports */
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CONSENT_SCHEMA_VERSION = '1';
const CONSENT_DIRNAME = '.gsd';
const CONSENT_FILE_NAME = 'consent.json';
/**
* GENEROUS DoS backstop on the store FILE — NOT a product limit. The consent store is untrusted
* on-disk content; the bounded reader must not read+parse an unbounded file. A few hundred bytes
* per record × MAX_RECORDS is far below this; 8 MiB is wildly more than any real store.
*/
const CONSENT_MAX_BYTES = 8 * 1024 * 1024;
/**
* GENEROUS cap on the record COUNT so a hostile store with millions of keys cannot weaponize
* Object.keys iteration. 4096 project×capability consents is far more than any user accumulates.
* Enforced on BOTH read (refuse a hostile store wholesale) AND write (recordProjectConsent refuses
* to grow the store past it — CONSENT-MAXRECORDS-WRITE-1).
*/
const MAX_RECORDS = 4096;
/**
* CB-1/CB-2 content-hash bound: the maximum total bytes summed over every regular file in a bundle
* `bundleContentHash` will hash. A legitimate capability bundle is a handful of small declarative
* files plus a few scripts; 16 MiB is far more than any real bundle. A bundle exceeding this (a
* hostile or runaway tree) fails closed: bundleContentHash throws rather than hashing unbounded
* content, so the loader leaves the cap inactive.
*/
const BUNDLE_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
/** Per-file size cap inside a bundle (each file is read via the shared bounded fd reader). */
const BUNDLE_MAX_FILE_BYTES = BUNDLE_MAX_TOTAL_BYTES;
/**
* Bound the bundle ENTRY count so a pathological tree of millions of empty files (or a very deep tree)
* cannot DoS the walk. #1459 finding 2 (round 6): the cap is enforced on the CUMULATIVE entry count as
* the walk STREAMS each directory (fs.opendirSync + readSync) — it throws the MOMENT the running count
* exceeds this, BEFORE collecting/sorting a whole directory's entries — so a huge single directory (or a
* deep tree) cannot force unbounded memory/CPU before the fail-closed cap. Backed by a mutable variable
* with a test seam (`_setBundleMaxFilesForTest`) so a test can drive the bound deterministically without
* planting 100k files; production code never mutates it.
*/
const BUNDLE_MAX_FILES_DEFAULT = 100_000;
let BUNDLE_MAX_FILES = BUNDLE_MAX_FILES_DEFAULT;
/** Valid capability id (kebab-case, lowercase, leading letter). */
const VALID_ID_RE = /^[a-z][a-z0-9-]*$/;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ConsentRecord {
projectRoot: string;
id: string;
scope: 'project';
/** Ledger integrity at consent time — kept for the human disclosure UX, NOT the security binding. */
integrity: string;
/** Executable-surface disclosure signature — kept for re-consent-on-executable-change UX (TRUST-2). */
disclosureSignature: string;
/**
* THE security binding (#1459 CB-1/CB-2): a recomputed full-bundle content hash. The loader
* recomputes `bundleContentHash(capDir)` at load and activates the cap only when it equals this.
*/
contentHash: string;
consentedAt: string;
}
interface ConsentStore {
/** Map of `${realpath(projectRoot)}<NUL>${id}` (the canonical in-memory key) → ConsentRecord. */
records: Record<string, ConsentRecord>;
}
// ---------------------------------------------------------------------------
// Safety helpers (prototype-pollution-safe; CodeQL inline-literal barrier)
// ---------------------------------------------------------------------------
/**
* Returns true when `id` must never be used as an object key / record id — either because it would
* cause prototype pollution or because it fails the kebab-case constraint. Uses INLINE LITERAL key
* comparisons (no Set / computed lookup) per the CodeQL prototype-pollution barrier.
*/
function isUnsafeCapabilityId(id: unknown): boolean {
if (typeof id !== 'string') return true;
if (id === '__proto__') return true;
if (id === 'constructor') return true;
if (id === 'prototype') return true;
if (!VALID_ID_RE.test(id)) return true;
return false;
}
/** The canonical IN-MEMORY lookup key for a (projectRoot, id) pair (NUL-joined). */
function consentKey(realRoot: string, id: string): string {
return realRoot + String.fromCharCode(0) + id;
}
/**
* The ON-DISK key (WIN-3): an unambiguous JSON-object string `{"r":<realpath>,"i":<id>}`. The prior
* space-joined `<realpath> <id>` form was ambiguous when a path contained a space (Windows
* `C:\Users\John Smith\...`): two distinct (root,id) pairs could collide. A JSON-stringified object
* key encodes both components unambiguously, so distinct pairs never collide on disk.
*/
function diskKey(realRoot: string, id: string): string {
return JSON.stringify({ r: realRoot, i: id });
}
/**
* Best-effort realpath of a project root. A non-existent path cannot be realpath'd; fall back to
* path.resolve so a record can still be written/looked-up consistently (both record and lookup use
* this same function, so they agree).
*/
function realpathProject(projectRoot: string): string {
try {
return fs.realpathSync(projectRoot);
} catch {
return path.resolve(projectRoot);
}
}
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
/**
* Resolve the consent store path. Uses the SAME `gsdHome || GSD_HOME || homedir()` rule the loader
* and CLI use, so a consent record written by the CLI is found by the loader. The store NEVER lives
* under a repository — it is user-owned, machine-local config.
*/
function consentStorePath(gsdHome?: string): string {
const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
return path.join(home, CONSENT_DIRNAME, CONSENT_FILE_NAME);
}
// ---------------------------------------------------------------------------
// Bundle content hash (the security binding — CB-1/CB-2/TRUST2-5)
// ---------------------------------------------------------------------------
/**
* A bundle entry collected by the walk: either a regular FILE or a (possibly empty) DIRECTORY.
*
* #1459 finding 4 (LOW): both the absolute path (`abs`, for re-reading FILE bytes) and the relative
* path (`rel`, the path component of the digest) are RAW BYTE Buffers, NOT decoded strings. On POSIX a
* filename is an arbitrary byte sequence that may not be valid UTF-8; reading dir entries as strings
* coerces each invalid byte through U+FFFD, so two files whose NAMES differ only in invalid-UTF-8 bytes
* would collapse to the same string → the same path bytes → a hash COLLISION (a repo-write attacker
* could swap one for the other without changing the binding). Carrying raw bytes end to end keeps the
* path component LOSSLESS.
*/
interface BundleEntry {
/** Absolute path on disk as RAW BYTES (Buffer) — fs accepts a Buffer path on POSIX. DIR markers reuse it for recursion. */
abs: Buffer;
/** NORMALIZED POSIX relpath relative to the bundle root as RAW BYTES (path separators are the `/` byte 0x2f). */
rel: Buffer;
/** Entry kind — a typed marker so a file and a directory at the same relpath never collide. */
kind: 'file' | 'dir';
}
/** The path-separator BYTE used to join raw-byte path segments — `/` (0x2f) on every platform we hash on. */
const SEP_BYTE = Buffer.from('/');
/** On Windows the OS separator is `\\` (0x5c); normalize it to `/` at the BYTE level for cross-platform determinism. */
const WIN_SEP_BYTE = 0x5c;
/** Join a parent raw-byte path and a raw-byte segment with the `/` separator byte. An empty parent → the segment alone. */
function joinBytes(parent: Buffer, segment: Buffer): Buffer {
if (parent.length === 0) return Buffer.from(segment);
return Buffer.concat([parent, SEP_BYTE, segment]);
}
/** Normalize Windows `\\` separator bytes to `/` in a raw-byte relpath (no-op on POSIX paths). */
function normalizeSepBytes(rel: Buffer): Buffer {
if (process.platform !== 'win32') return rel;
const out = Buffer.from(rel);
for (let i = 0; i < out.length; i++) if (out[i] === WIN_SEP_BYTE) out[i] = 0x2f;
return out;
}
/**
* Recursively collect every REGULAR file AND every DIRECTORY under `absDir` as RAW-BYTE POSIX-relative
* paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Bounded:
* throws if the entry count or total byte size exceeds the caps (fail closed — a hostile/runaway tree
* never hashes unbounded content). A non-regular entry encountered IN the tree (FIFO/device) is a
* fail-closed throw — a bundle must be plain files and directories.
*
* #1459 finding 2 (MED/HIGH, ROUND 6): the enumeration ITSELF is bounded. Instead of
* `fs.readdirSync` (which loads + sorts a WHOLE directory before the count cap — so a malicious bundle
* with a huge single directory, or a very deep tree, forces unbounded memory/CPU before fail-closing),
* we STREAM each level via fs.opendirSync + dir.readSync() and increment a CUMULATIVE entry counter
* (`count.n`) across the recursive walk, throwing the MOMENT it exceeds BUNDLE_MAX_FILES — BEFORE
* collecting (let alone sorting) the rest of the level. Determinism is preserved: the BOUNDED set of a
* level is still sorted (by raw-byte name) before lstat/recursion, and the FINAL digest sorts over all
* rel byte strings. The cap is cumulative, so a deep tree spread across many nested dirs cannot blow it.
*
* #1459 finding 2 (LOW): directories (including EMPTY ones) are emitted as typed DIR markers so that
* adding/removing an empty directory CHANGES the canonical hash. Capability code can branch on a
* directory's existence, so a bare-dir add must be observable to the binding.
*
* #1459 finding 4 (LOW): dir entries are read as raw-byte Buffer names (`encoding: 'buffer'`) and the
* abs/rel paths are concatenated at the BYTE level, so an invalid-UTF-8 filename is never lossily
* decoded — two filenames that differ only in invalid bytes produce distinct rel byte strings.
*
* @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
* @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
* @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
*/
function collectBundleEntries(absDir: Buffer, relDir: Buffer, acc: BundleEntry[], total: { bytes: number }, count: { n: number }): void {
let dir: fs.Dir;
try {
// RAW-BYTE streaming open: dirent names are Buffers (encoding: 'buffer'), so an invalid-UTF-8
// filename is preserved verbatim. opendirSync + readSync iterates one entry at a time, so the cap
// can fail closed BEFORE the whole directory is materialized/sorted.
dir = fs.opendirSync(absDir, { encoding: 'buffer' } as unknown as fs.OpenDirOptions);
} catch (err) {
throw new Error(`bundleContentHash: cannot read directory "${absDir.toString('utf8')}": ${(err as Error).message}`);
}
// Collect ONLY the BOUNDED set of this level's dirents — the cumulative counter throws the moment it
// crosses the cap, so the array can never grow past it. We still sort this bounded set (by raw-byte
// name) so the byte/count accounting walk is reproducible across platforms.
const levelEntries: fs.Dirent<Buffer>[] = [];
try {
for (;;) {
let ent: fs.Dirent<Buffer> | null;
try {
ent = dir.readSync() as unknown as fs.Dirent<Buffer> | null;
} catch (err) {
throw new Error(`bundleContentHash: cannot read directory "${absDir.toString('utf8')}": ${(err as Error).message}`);
}
if (ent === null) break;
// BOUND THE ENUMERATION ITSELF: increment the cumulative counter and fail closed BEFORE this entry
// is retained/sorted, so a huge directory (or deep tree) cannot be loaded/sorted in full first.
count.n++;
if (count.n > BUNDLE_MAX_FILES) {
throw new Error(`bundleContentHash: bundle entry count exceeds ${BUNDLE_MAX_FILES} (refusing)`);
}
levelEntries.push(ent);
}
} finally {
try { dir.closeSync(); } catch { /* best-effort */ }
}
levelEntries.sort((a, b) => Buffer.compare(a.name, b.name));
for (const ent of levelEntries) {
const name = ent.name; // Buffer
const abs = joinBytes(absDir, name);
const rel = normalizeSepBytes(joinBytes(relDir, name));
// lstat the entry (Buffer path): a symlink must NOT be followed (it could escape the bundle to
// /etc/passwd or to an infinite device). Re-lstat to be certain across platforms.
let st: fs.Stats;
try {
st = fs.lstatSync(abs);
} catch (err) {
throw new Error(`bundleContentHash: cannot lstat "${abs.toString('utf8')}": ${(err as Error).message}`);
}
if (st.isSymbolicLink()) {
// A symlink in the bundle is suspicious and unhashable safely (it would either escape the
// bundle or follow to a non-regular target). Fail closed.
throw new Error(`bundleContentHash: refusing to hash a symlink in the bundle: "${abs.toString('utf8')}"`);
}
if (st.isDirectory()) {
// Emit a typed DIR marker for THIS directory (so an empty dir is bound), then recurse into it.
acc.push({ abs, rel, kind: 'dir' });
collectBundleEntries(abs, rel, acc, total, count);
continue;
}
if (!st.isFile()) {
throw new Error(`bundleContentHash: refusing to hash a non-regular file in the bundle: "${abs.toString('utf8')}"`);
}
acc.push({ abs, rel, kind: 'file' });
total.bytes += st.size;
if (total.bytes > BUNDLE_MAX_TOTAL_BYTES) {
throw new Error(`bundleContentHash: bundle size exceeds ${BUNDLE_MAX_TOTAL_BYTES} bytes (refusing)`);
}
}
}
/** Encode an unsigned 32-bit length as 4 big-endian bytes (the path-length frame). */
function uint32be(n: number): Buffer {
const b = Buffer.allocUnsafe(4);
b.writeUInt32BE(n >>> 0, 0);
return b;
}
/**
* Encode an unsigned 64-bit length as 8 big-endian bytes (the content-length frame). A bundle file is
* size-capped well below 2^53 so writeBigUInt64BE of a BigInt is exact and never overflows.
*/
function uint64be(n: number): Buffer {
const b = Buffer.allocUnsafe(8);
b.writeBigUInt64BE(BigInt(n), 0);
return b;
}
/** Typed entry tags so a FILE and a DIR at the same relpath can never produce the same digest input. */
const TAG_FILE = Buffer.from([0x01]);
const TAG_DIR = Buffer.from([0x02]);
/**
* The recomputed full-bundle content hash (#1459 CB-1/CB-2/TRUST2-5) — the SECURITY BINDING. A
* `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file
* AND directory under `capDir` (recursively).
*
* Canonicalization (#1459 findings 1 + 4 — the prior `relpath + NUL + content + NUL` over utf8-decoded
* STRINGS was non-injective, lossy in CONTENT, AND lossy in the PATH component):
* - LENGTH-FRAMED, no ambiguous delimiters. A leading fixed-width entry COUNT, then per entry
* (sorted by raw-byte relpath): a 1-byte TYPE tag, uint32 path-byte-length + the raw path bytes,
* and (for a FILE) uint64 content-byte-length + the raw content bytes. Because every component is
* length-prefixed, a NUL (or any byte) inside a path or file content can never be mistaken for a
* boundary — two different (path, content) splits cannot collide.
* - RAW BYTES end to end, never utf8-decoded — for BOTH content AND the path. File bytes are read via
* the ledger's RAW-BYTES bounded reader (readSmallRegularFileBuffer); the PATH bytes come straight
* from a raw-byte (`encoding: 'buffer'`) dir walk (#1459 finding 4), so two binary artifacts that
* differ only in invalid-UTF-8 bytes — whether in their CONTENT or in their FILENAME (both of which
* a utf8 decode would collapse to U+FFFD) — produce DIFFERENT digests.
* - DETERMINISTIC across platforms: entries sorted by the raw-byte relpath whose separators are
* normalized to the `/` byte, so an on-disk reorder and a Windows-vs-POSIX separator difference do
* not matter.
*
* Throws (fail closed) on an unreadable dir, a non-regular/symlinked bundle entry, or a bundle that
* exceeds the size/count caps — the loader treats a throw as "no matching consent" (inactive).
*
* Each file's bytes are read via the SHARED bounded fd reader (open → fstat → require regular file →
* size cap → read exactly size), so a file swapped for a FIFO/device between the walk and the read
* cannot block or read unbounded.
*/
function bundleContentHash(capDir: string): string {
// Resolve to an absolute path, then carry it as RAW BYTES so the walk never lossily decodes a name.
const rootBytes = Buffer.from(path.resolve(capDir));
const entries: BundleEntry[] = [];
collectBundleEntries(rootBytes, Buffer.alloc(0), entries, { bytes: 0 }, { n: 0 });
// Sort by the raw-byte (separator-normalized) relpath so the digest is identical on Windows and POSIX,
// and is independent of the on-disk creation/readdir order. Tie-break on kind so a (degenerate, never
// produced on a real fs) file-and-dir same-relpath pair still has a stable order.
entries.sort((a, b) => {
const c = Buffer.compare(a.rel, b.rel);
if (c !== 0) return c;
return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
});
const hash = crypto.createHash('sha512');
// Header: a fixed-width entry COUNT frames the whole stream (so a truncated/extended entry list
// cannot be confused with a different bundle).
hash.update(uint64be(entries.length));
for (const ent of entries) {
const pathBytes = ent.rel; // RAW path bytes (finding 4) — never utf8-decoded.
if (ent.kind === 'dir') {
// Typed DIR marker: tag + length-framed path. No content — binds the directory's mere existence.
hash.update(TAG_DIR);
hash.update(uint32be(pathBytes.length));
hash.update(pathBytes);
continue;
}
// FILE: tag + length-framed path + length-framed RAW content bytes (no utf8 decode).
const content = ledgerMod.readSmallRegularFileBuffer(ent.abs, BUNDLE_MAX_FILE_BYTES);
// null here would mean the file vanished between walk and read — fail closed.
if (content === null) {
throw new Error(`bundleContentHash: file vanished during hash: "${ent.abs.toString('utf8')}"`);
}
hash.update(TAG_FILE);
hash.update(uint32be(pathBytes.length));
hash.update(pathBytes);
hash.update(uint64be(content.length));
hash.update(content);
}
return `sha512-${hash.digest('base64')}`;
}
// ---------------------------------------------------------------------------
// Read (bounded, non-throwing)
// ---------------------------------------------------------------------------
/**
* Validate a single record object. Rejects anything not matching the schema — a malformed/tampered
* record is dropped (fail closed: it cannot grant consent). Returns true only for a structurally-
* complete project-scope record carrying a contentHash binding.
*/
function isValidConsentRecord(rec: unknown): rec is ConsentRecord {
if (typeof rec !== 'object' || rec === null || Array.isArray(rec)) return false;
const r = rec as Record<string, unknown>;
if (typeof r['projectRoot'] !== 'string' || !r['projectRoot']) return false;
if (typeof r['id'] !== 'string' || isUnsafeCapabilityId(r['id'])) return false;
if (r['scope'] !== 'project') return false;
if (typeof r['integrity'] !== 'string') return false;
if (typeof r['disclosureSignature'] !== 'string') return false;
// The security binding MUST be present and non-empty — a record without a contentHash can never
// match a recomputed hash and is treated as invalid (fail closed).
if (typeof r['contentHash'] !== 'string' || !r['contentHash']) return false;
if (typeof r['consentedAt'] !== 'string' || !r['consentedAt']) return false;
return true;
}
/**
* Read the consent store. NON-THROWING and BOUNDED: a missing, corrupt, oversized, non-regular
* (FIFO/device), or wrong-shape store yields an empty `{ records: {} }`. Invalid individual records
* are dropped. A store whose record count exceeds MAX_RECORDS is refused wholesale (hostile DoS).
*/
function readConsentStore(gsdHome?: string): ConsentStore {
const empty: ConsentStore = { records: {} };
const filePath = consentStorePath(gsdHome);
let raw: string | null;
try {
raw = ledgerMod.readSmallRegularFile(filePath, CONSENT_MAX_BYTES);
} catch {
// Non-regular (FIFO/device/dir), oversized, or IO error → fail closed to empty.
return empty;
}
if (raw === null || raw === '') return empty; // genuinely missing / empty.
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return empty; // corrupt JSON.
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return empty;
const p = parsed as Record<string, unknown>;
const recordsVal = p['records'];
if (typeof recordsVal !== 'object' || recordsVal === null || Array.isArray(recordsVal)) return empty;
const records = recordsVal as Record<string, unknown>;
const keys = Object.keys(records);
if (keys.length > MAX_RECORDS) return empty; // hostile record count — refuse the whole store.
// Re-key by the canonical NUL key so lookups never depend on the disk-key's serialization.
const out: ConsentStore = { records: {} };
for (const key of keys) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; // proto-safe.
const rec = records[key];
if (!isValidConsentRecord(rec)) continue;
out.records[consentKey(rec.projectRoot, rec.id)] = rec;
}
return out;
}
// ---------------------------------------------------------------------------
// Has (the security match is the recomputed contentHash)
// ---------------------------------------------------------------------------
/**
* True iff a consent record exists for `(realpath(projectRoot), id)` whose `contentHash` equals the
* supplied (recomputed-by-the-loader) value. The contentHash is THE security binding (#1459
* CB-1/CB-2): it covers the whole bundle (manifest AND artifacts AND identity), so a swapped
* declarative manifest, a tampered hook script, or an empty-integrity local install all fail to
* match. An unsafe id is rejected (→ false) before any lookup. Prototype-pollution-safe (NUL keys +
* hasOwnProperty).
*/
function hasProjectConsent(args: {
gsdHome?: string;
projectRoot: string;
id: string;
contentHash: string;
}): boolean {
const { gsdHome, projectRoot, id, contentHash } = args;
if (isUnsafeCapabilityId(id)) return false;
if (typeof contentHash !== 'string' || !contentHash) return false;
const store = readConsentStore(gsdHome);
const key = consentKey(realpathProject(projectRoot), id);
if (!Object.prototype.hasOwnProperty.call(store.records, key)) return false;
const rec = store.records[key];
return rec.contentHash === contentHash;
}
// ---------------------------------------------------------------------------
// Cross-process mutual exclusion (CONSENT-CONCURRENCY-1) — via the SHARED lock primitive
// ---------------------------------------------------------------------------
type ConsentLock = { path: string; token: string; dev: number | null; ino: number | null };
/** The consent-store lock path — keyed on the consent store DIRECTORY (one lock per machine store). */
function consentLockPath(gsdHome?: string): string {
return path.join(path.dirname(consentStorePath(gsdHome)), '.consent.lock');
}
/**
* CONSENT-CONCURRENCY-1 (HIGH): record/revoke do a read-modify-write of the ONE global consent.json.
* Two DIFFERENT projects writing the same store concurrently would lose-update without a lock (project B
* reads, project A writes, project B overwrites with its stale snapshot, dropping A's record). The lock
* is keyed on the consent store DIRECTORY so all consent writers on this machine serialize.
*
* #1459 finding 4 (MEDIUM): this now uses the SHARED hardened lock primitive (capability-lock) — the
* SAME steal protocol as the lifecycle lock. The old self-contained consent lock stole any holder past
* a 60s mtime regardless of liveness, so a slow/paused LIVE writer would be stolen and its store
* overwritten (lost update). The shared primitive NEVER stale-steals a verified-live same-host holder
* (pid + process-start-time identity) and reclaims only a provably-dead/unverifiable holder (dead-pid
* fast path or the hard deadman) — so a live writer is never stolen and a crashed writer never deadlocks.
*/
function acquireConsentLock(dir: string): ConsentLock | null {
// waitForFresh: a contended fresh/live holder is WAITED FOR (back off + retry), not failed-fast, so
// two genuinely-racing consent writers serialize; null only when contention outlasts the budget.
return lockMod.acquireLock(path.join(dir, '.consent.lock'), { maxAttempts: CONSENT_LOCK_MAX_ATTEMPTS, waitForFresh: true });
}
/** Release the consent lock (shared primitive — token + inode owner-safe; never deletes a successor's). */
function releaseConsentLock(handle: ConsentLock | null): void {
lockMod.releaseLock(handle);
}
// ---------------------------------------------------------------------------
// Atomic + durable write (mirrors capability-ledger.writeLedger)
// ---------------------------------------------------------------------------
/**
* Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync).
* WIN-4 (#1459 round 2): ENOENT is tolerated too — the containing dir can vanish between rename and
* fsync on an aggressively-swept tmp tree (Windows/CI), and a missing dir cannot be fsync'd.
*/
const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF', 'ENOENT']);
/** fsync the directory containing `dest` so a rename is durable across a power loss (best-effort). */
function fsyncContainingDir(dest: string): void {
let dirFd: number | null = null;
try {
dirFd = fs.openSync(path.dirname(dest), 'r');
fs.fsyncSync(dirFd);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
throw new Error(
`Directory fsync of "${path.dirname(dest)}" failed (${code}); durability of the consent ` +
`store rename could NOT be confirmed: ${(err as Error).message}`,
);
}
/* tolerated errno (or no code) — best-effort */
} finally {
if (dirFd !== null) { try { fs.closeSync(dirFd); } catch { /* best-effort */ } }
}
}
/** WIN-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const RENAME_MAX_ATTEMPTS = 3;
const RENAME_RETRY_BACKOFF_MS = 50;
let _renameSleepBuf: Int32Array | null = null;
function renameBackoff(): void {
if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
}
/**
* Serialize the store to disk atomically + durably (tmp with O_EXCL → write-all → fsync → close →
* rename → dir fsync; temp cleaned up on any failure). Mirrors the capability-ledger writeLedger
* durability idiom so a crash/power-loss mid-write can never produce a truncated consent store.
*
* WIN-1 / CONSENT-ATOMIC-WRITE parity (#1459 round 2): the renameSync is retried with backoff on the
* transient Windows AV/indexer errnos (EPERM/EBUSY/EACCES), matching writeLedger.
*
* The on-disk JSON uses the unambiguous JSON-object disk key (WIN-3); the in-memory store is keyed by
* the canonical NUL key, so we re-key here.
*/
function writeConsentStore(gsdHome: string | undefined, store: ConsentStore): void {
const filePath = consentStorePath(gsdHome);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const onDisk: { version: string; records: Record<string, ConsentRecord> } = {
version: CONSENT_SCHEMA_VERSION,
records: {},
};
for (const key of Object.keys(store.records)) {
const rec = store.records[key];
onDisk.records[diskKey(rec.projectRoot, rec.id)] = rec;
}
const content = JSON.stringify(onDisk, null, 2) + '\n';
const nonce = crypto.randomBytes(4).toString('hex');
const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`;
const fd = fs.openSync(tmpPath, 'wx'); // exclusive create — defeats a pre-planted symlink.
let primaryErr: Error | null = null;
try {
fs.writeFileSync(fd, content); // write-all loop — no short writes.
fs.fsyncSync(fd); // flush bytes to stable storage BEFORE the rename.
} catch (err) {
primaryErr = err instanceof Error ? err : new Error(String(err));
} finally {
let closeErr: Error | null = null;
try { fs.closeSync(fd); } catch (err) { closeErr = err instanceof Error ? err : new Error(String(err)); }
if (primaryErr !== null) {
try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ }
throw primaryErr;
}
if (closeErr !== null) {
try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ }
throw closeErr;
}
}
// WIN-1: retry the rename on transient Windows AV/indexer locks before giving up (writeLedger parity).
let renameErr: Error | null = null;
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
try {
fs.renameSync(tmpPath, filePath);
renameErr = null;
break;
} catch (err) {
renameErr = err instanceof Error ? err : new Error(String(err));
const code = (err as NodeJS.ErrnoException).code ?? '';
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
renameBackoff();
continue;
}
break;
}
}
if (renameErr !== null) {
try { fs.unlinkSync(tmpPath); } catch { /* best-effort */ }
throw renameErr;
}
fsyncContainingDir(filePath);
}
/**
* Record a PROJECT-scope consent: that the user, on THIS machine, accepted capability `id` at the
* given `projectRoot`, bound to the recomputed bundle `contentHash` (the security binding) plus the
* `integrity` + `disclosureSignature` (kept for the disclosure/re-consent UX). Rejects an unsafe id
* (throws, writing nothing). Idempotent: re-recording the same (projectRoot, id) overwrites in place;
* other records are preserved.
*
* CONSENT-CONCURRENCY-1: the whole read-modify-write runs UNDER the consent-store lock so two
* different projects writing concurrently cannot lose each other's record.
* CONSENT-MAXRECORDS-WRITE-1: refuses to grow the store past MAX_RECORDS BEFORE writing (a clear
* 'consent store full' throw), leaving the on-disk store intact.
*
* #1459 finding 3 (MEDIUM): if the consent-store lock CANNOT be acquired, this THROWS rather than
* proceeding UNLOCKED — an unlocked read-modify-write is exactly the lost-update vector the lock exists
* to prevent. The lifecycle treats a consent-write failure as NON-FATAL + warns (round-2 IC-05), so
* throwing here is safe: an install still succeeds; the cap simply stays inactive until consent can be
* written. (The OLD code returned a null handle and proceeded unlocked — that is the bug.)
*/
function recordProjectConsent(args: {
gsdHome?: string;
projectRoot: string;
id: string;
integrity: string;
disclosureSignature: string;
contentHash: string;
}): void {
const { gsdHome, projectRoot, id, integrity, disclosureSignature, contentHash } = args;
if (isUnsafeCapabilityId(id)) {
throw new Error(
`Invalid capability id "${String(id)}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
`Unsafe or non-kebab ids are rejected to keep the consent store prototype-pollution-safe.`,
);
}
if (typeof contentHash !== 'string' || !contentHash) {
throw new Error(
`recordProjectConsent: a non-empty contentHash is required (it is the security binding). ` +
`Compute it via bundleContentHash(capDir) over the installed bundle.`,
);
}
const realRoot = realpathProject(projectRoot);
const lockDir = path.dirname(consentStorePath(gsdHome));
try { fs.mkdirSync(lockDir, { recursive: true }); } catch { /* best-effort — write also mkdirs */ }
// #1459 finding 3: never proceed UNLOCKED. A null handle (live holder / contention budget exhausted)
// → throw rather than risk a lost update.
const lock = acquireConsentLock(lockDir);
if (lock === null) {
throw new Error(
`recordProjectConsent: could not acquire the consent-store lock at ${consentLockPath(gsdHome)} ` +
`(another writer holds it). Refusing to write the consent store UNLOCKED (a lost-update risk). ` +
`Retry; if a stale lock persists past the deadman it is reclaimed automatically.`,
);
}
try {
const store = readConsentStore(gsdHome);
const key = consentKey(realRoot, id);
// CONSENT-MAXRECORDS-WRITE-1: enforce the cap BEFORE the write. A re-record of an EXISTING key
// does not grow the store (allowed); only ADDING a new key when already at the cap is refused.
if (!Object.prototype.hasOwnProperty.call(store.records, key) && Object.keys(store.records).length >= MAX_RECORDS) {
throw new Error(
`consent store full: already at the maximum of ${MAX_RECORDS} consent records. Revoke an ` +
`unused consent (gsd capability trust revoke) before recording a new one.`,
);
}
store.records[key] = {
projectRoot: realRoot,
id,
scope: 'project',
integrity,
disclosureSignature,
contentHash,
consentedAt: new Date().toISOString(),
};
writeConsentStore(gsdHome, store);
} finally {
releaseConsentLock(lock);
}
}
/**
* Revoke a PROJECT-scope consent record. No-op (and never throws) when the record is absent or the
* id is unsafe. Atomic, LOCKED write of the resulting store. Used on `capability remove` and
* `trust revoke`.
*
* #1459 finding 3 (MEDIUM): if the consent-store lock CANNOT be acquired, this THROWS rather than
* doing an unlocked read-modify-write (the lost-update vector). An ABSENT-record no-op still happens
* UNDER the lock (so a concurrent record cannot interleave); only a genuine lock-acquire failure throws.
*/
function revokeProjectConsent(args: { gsdHome?: string; projectRoot: string; id: string }): void {
const { gsdHome, projectRoot, id } = args;
if (isUnsafeCapabilityId(id)) return; // an unsafe id was never stored — nothing to revoke.
const realRoot = realpathProject(projectRoot);
const lockDir = path.dirname(consentStorePath(gsdHome));
try { fs.mkdirSync(lockDir, { recursive: true }); } catch { /* best-effort — write also mkdirs */ }
// #1459 finding 3: never proceed UNLOCKED — a null handle throws rather than deleting unlocked.
const lock = acquireConsentLock(lockDir);
if (lock === null) {
throw new Error(
`revokeProjectConsent: could not acquire the consent-store lock at ${consentLockPath(gsdHome)} ` +
`(another writer holds it). Refusing to modify the consent store UNLOCKED (a lost-update risk). ` +
`Retry; if a stale lock persists past the deadman it is reclaimed automatically.`,
);
}
try {
const store = readConsentStore(gsdHome);
const key = consentKey(realRoot, id);
if (!Object.prototype.hasOwnProperty.call(store.records, key)) return; // absent — no-op.
delete store.records[key];
writeConsentStore(gsdHome, store);
} finally {
releaseConsentLock(lock);
}
}
/**
* #1459 finding 2 (round 6): TEST-ONLY — override the cumulative bundle entry-count cap and return a
* restore() that resets it to the production default. Lets a test prove the streaming walk fails closed
* at the bound without planting 100k real files. Never called by production code.
*/
function _setBundleMaxFilesForTest(n: number): () => void {
const prev = BUNDLE_MAX_FILES;
BUNDLE_MAX_FILES = n;
return () => { BUNDLE_MAX_FILES = prev; };
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export = {
consentStorePath,
bundleContentHash,
readConsentStore,
hasProjectConsent,
recordProjectConsent,
revokeProjectConsent,
// Exported for testing / introspection.
MAX_RECORDS,
CONSENT_FILE_NAME,
// #1459 finding 3/4: the consent-store lock path + the shared lock primitive's test seams (so tests
// can plant a lock and inject deterministic liveness probes to verify the never-steal-a-live-writer
// and dead-holder-reclaim behavior). Not part of the CLI surface.
consentLockPath,
_setLockProbes: lockMod._setLockProbes,
_resetLockProbes: lockMod._resetLockProbes,
// #1459 finding 2 (round 6): a TEST-ONLY seam to drive the cumulative entry-count cap deterministically
// (so a test can prove the streaming walk fails closed at the bound without planting 100k real files).
// Returns a restore() that resets the cap to its production default. Not part of the CLI surface.
_setBundleMaxFilesForTest,
};