/** * 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: { "": 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)}${id}` (the canonical in-memory key) → ConsentRecord. */ records: Record; } // --------------------------------------------------------------------------- // 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":,"i":}`. The prior * space-joined ` ` 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[] = []; try { for (;;) { let ent: fs.Dirent | null; try { ent = dir.readSync() as unknown as fs.Dirent | 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-` 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; 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; const recordsVal = p['records']; if (typeof recordsVal !== 'object' || recordsVal === null || Array.isArray(recordsVal)) return empty; const records = recordsVal as Record; 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 } = { 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, };