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,
};

View File

@@ -162,6 +162,25 @@ class LedgerIOError extends Error {
* normal small regular file is identical to the prior readFileSync(path,'utf8').
*/
function readSmallRegularFile(filePath: string, maxBytes: number): string | null {
const buf = readSmallRegularFileBuffer(filePath, maxBytes);
if (buf === null) return null;
// Decode to UTF-8 for STRING consumers (JSON parsers, lock-body parsers). This decode is LOSSY for
// binary content (invalid byte sequences → U+FFFD), so a content-hash binding must NOT use this —
// it must hash the RAW bytes via readSmallRegularFileBuffer (#1459 finding 1b: a swapped binary
// artifact differing only in invalid-UTF-8 bytes would otherwise not change the digest).
return buf.toString('utf8');
}
/**
* #1459 finding 1 (HIGH): the RAW-BYTES variant of readSmallRegularFile. Identical open → fstat →
* require-regular-file → size-cap → read-exactly-size protocol (so a FIFO/device/swapped/oversized
* untrusted file can never block or read unbounded), but returns the bytes as a Buffer WITHOUT a
* UTF-8 decode. This is the SOLE correct reader for the consent content-hash binding: the binding
* must be byte-exact and INJECTIVE, and a utf8 decode is lossy (collapses distinct invalid byte
* sequences to U+FFFD) so two different binary artifacts could collide. Returns the bytes, or null
* for ENOENT (genuinely missing); throws LedgerIOError for every other fail-closed condition.
*/
function readSmallRegularFileBuffer(filePath: string, maxBytes: number): Buffer | null {
// O_RDONLY | O_NONBLOCK: never block on opening a FIFO/device — return the fd so fstat can reject it.
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK;
let fd: number;
@@ -191,7 +210,7 @@ function readSmallRegularFile(filePath: string, maxBytes: number): string | null
'EFBIG',
);
}
if (st.size === 0) return '';
if (st.size === 0) return Buffer.alloc(0);
const buf = Buffer.allocUnsafe(st.size);
let off = 0;
// Read EXACTLY st.size bytes from the fd (never a streaming/unbounded read).
@@ -200,7 +219,8 @@ function readSmallRegularFile(filePath: string, maxBytes: number): string | null
if (n <= 0) break; // EOF earlier than fstat reported (truncated under us) — return what we got.
off += n;
}
return buf.toString('utf8', 0, off);
// Return EXACTLY the bytes we read (off may be < st.size on a truncated-under-us read).
return off === buf.length ? buf : buf.subarray(0, off);
} catch (err) {
if (err instanceof LedgerIOError) throw err;
throw new LedgerIOError(`Cannot read ${filePath}: ${(err as Error).message}`, (err as NodeJS.ErrnoException).code);
@@ -808,6 +828,9 @@ export = {
// Finding 2 (HIGH): the SINGLE shared bounded fd reader — also consumed by capability-lifecycle's
// lock-body reads so every untrusted file read goes through the regular-file + size-capped fd path.
readSmallRegularFile,
// #1459 finding 1 (HIGH): the RAW-BYTES variant — the SOLE correct reader for the byte-exact,
// injective consent content-hash binding (a utf8 decode is lossy and could collide binary artifacts).
readSmallRegularFileBuffer,
// Exported for testing / introspection
LEDGER_FILE_NAME,
CorruptLedgerError,

View File

@@ -22,7 +22,6 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
/* eslint-disable @typescript-eslint/no-require-imports */
const sourceMod = require('./capability-source.cjs') as {
@@ -55,14 +54,31 @@ const trustMod = require('./capability-trust.cjs') as {
parsed: { kind: string; raw: string; target: string },
strict: string[] | null | undefined,
) => { allowed: boolean; reason: string | null };
// #1459: the consent-binding signature (single source of truth for loader + lifecycle).
signatureForManifest: (manifest: Record<string, unknown>, stagedDir?: string) => string;
};
const { platformWriteSync, execTool } = require('./shell-command-projection.cjs') as {
const consentMod = require('./capability-consent.cjs') as {
recordProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string; integrity: string; disclosureSignature: string; contentHash: string }) => void;
revokeProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string }) => void;
/** #1459 CB-1/CB-2: recompute the full-bundle content hash (the consent security binding). */
bundleContentHash: (capDir: string) => string;
/** #1459 IC-05/WIN-2: resolve the consent store path for an unwritable-store warning message. */
consentStorePath: (gsdHome?: string) => string;
};
const projectRootMod = require('./project-root.cjs') as {
// #1459 IC-01/CB-4: the canonical consent project root (RECORD site parity with the loader LOOKUP).
consentProjectRoot: (cwd: string) => string;
};
// #1459 finding 4: the SHARED hardened lock primitive (single source of truth for lifecycle + consent).
const lockMod = require('./capability-lock.cjs') as {
acquireLock: (lockPath: string) => { 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;
getProcessStartTime: (pid: number) => string | null;
_setLockProbes: (probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>) => void;
_resetLockProbes: () => void;
};
const { platformWriteSync } = require('./shell-command-projection.cjs') as {
platformWriteSync: (filePath: string, content: string) => void;
execTool: (
program: string,
args: string[],
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number },
) => { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null };
};
/* eslint-enable @typescript-eslint/no-require-imports */
@@ -72,8 +88,21 @@ const { platformWriteSync, execTool } = require('./shell-command-projection.cjs'
interface Disclosure {
hooks: Array<{ event: string; script: string }>;
commandModules: Array<{ family: string; module: string }>;
mcpServers: Array<{ name: string; command: string; argv: string[] }>;
// #1459 TRUST2-3: router (which exported fn runs) is part of the disclosed/consent-bound surface.
commandModules: Array<{ family: string; module: string; router: string }>;
// #1459: env (string→string) and cwd are part of the disclosed/consent-bound MCP surface; TRUST2-2
// adds transport/url/headers for non-stdio servers; TRUST2-4 adds the raw args array.
mcpServers: Array<{
name: string;
transport: string;
command: string;
argv: string[];
rawArgs: unknown[];
url: string;
headers: Record<string, string>;
env: Record<string, string>;
cwd?: string;
}>;
hasExecutable: boolean;
missingArtifacts: string[];
}
@@ -113,6 +142,19 @@ interface LifecycleOptions {
/** Scope root: holds .gsd/capabilities/<id>, the ledger, and shared config files. */
runtimeDir: string;
hostVersion: string;
/**
* #1459: the scope of this operation. A PROJECT-scope consented install/upgrade records a user
* consent in the user-owned consent store (see consentStoreDir) and a remove revokes it; GLOBAL
* scope (under the user's own home) records nothing. Defaults to 'project' when a consentStoreDir
* is supplied (the conservative choice — bind consent unless explicitly global).
*/
scope?: 'global' | 'project';
/**
* #1459: the USER-OWNED consent home (`GSD_HOME||homedir()`) where project-scope consent records
* live — OUTSIDE any repo. When omitted, no consent record is written/revoked (back-compat for
* callers that have not wired the consent store; the loader then leaves the project cap inactive).
*/
consentStoreDir?: string;
/** capabilities.strict_known_registries policy value. */
strictKnownRegistries?: string[] | null;
/** Whether the user has consented to executable surfaces (CLI/runtime edge supplies this). */
@@ -211,541 +253,37 @@ function newBackupName(id: string): string {
// Cross-process mutual exclusion
// ---------------------------------------------------------------------------
/**
* A lock older than this is a CANDIDATE for stealing (the holder may have crashed). A same-host
* lock past this age whose recorded pid is DEAD is stolen immediately (fast local recovery).
*/
const LOCK_STALE_MS = 60_000;
/**
* HARD deadman timeout (finding 1). A lock older than this is stolen REGARDLESS of pid liveness or
* host. This is the only thing that can break a permanent deadlock caused by:
* - PID REUSE: a crashed holder's pid reused by an unrelated long-lived process makes
* `isPidAlive` return true forever, so the dead-pid fast-recovery branch never fires.
* - CROSS-HOST (NFS): a remote holder's pid is meaningless to local `process.kill(pid,0)`, so
* liveness cannot be judged at all — only the deadman can reclaim such a lock.
* Much larger than LOCK_STALE_MS so a genuinely slow-but-live SAME-host holder is given a wide grace
* window (it is protected by the same-host liveness check until then); 10 minutes is far longer than
* any real sub-second capability fs critical section.
*/
const LOCK_DEADMAN_MS = 600_000;
// The lock primitive is now a SHARED LEAF module (src/capability-lock.cts → capability-lock.cjs),
// used by BOTH this module and capability-consent (#1459 finding 4): one hardened steal protocol
// (pid + process-start-time identity + hard deadman; never steals a verified-live same-host holder)
// instead of two divergent ones. lockMod owns acquire/release; this module only computes the
// per-runtimeDir lock PATH and re-exports the test seams its #1462 lock tests drive.
// Non-lock orphan-sweep / id constants (kept local — not part of the shared lock primitive).
/** A `.staging/*` dir younger than this may belong to an in-flight resolve; do not sweep it. */
const STAGING_ORPHAN_MS = 600_000;
/** A `.gsd-capabilities.json.tmp.*` temp younger than this may belong to an in-flight write; spare it (W-3/DUR-5). */
const LEDGER_TMP_ORPHAN_MS = 300_000;
/** Valid capability id (kebab-case). Used to reject tampered ledger keys before acting on them. */
const KEBAB_ID_RE = /^[a-z][a-z0-9-]*$/;
/**
* Finding 2 (HIGH): the lockfile body is UNTRUSTED content. A well-formed lock body is a tiny JSON
* object (a few hundred bytes at most). The body is read via the shared fd-based bounded reader
* (ledgerMod.readSmallRegularFile): open → fstat → require a REGULAR file (reject FIFO/device/dir,
* which could block/read-unbounded) → enforce this size cap on the fstat → read exactly size bytes.
* A non-regular/oversized body is treated as UNPARSEABLE (no pid/host) → routed to the deadman policy
* (cannot verify liveness → steal only after the deadman). 64 KiB is orders of magnitude larger than
* any legitimate lock body.
*/
const LOCK_MAX_BODY_BYTES = 64 * 1024;
type LockHandle = { path: string; token: string; dev: number | null; ino: number | null };
/**
* A held lock: the lockfile path, the unique OWNER TOKEN we wrote into it, and the (dev, ino) of the
* lockfile inode captured at acquire (finding 4). releaseLock re-confirms BOTH the token AND the
* captured dev/ino still match the path on disk immediately before rmSync, so a successor lock that
* replaced ours at the same path (different inode) is never deleted. dev/ino are null when the post-
* create stat could not be taken (best-effort) — then release falls back to the token check alone.
*/
interface LockHandle { path: string; token: string; dev: number | null; ino: number | null; }
let _lockSeq = 0;
/**
* A per-acquire unique token so release is owner-safe (never deletes a successor's lock). The FIRST
* `-`-delimited segment is the holder PID — acquireLock parses it back out to check liveness before
* stealing a stale lock (CONC-1).
*/
function newLockToken(): string {
return `${process.pid}-${Date.now()}-${++_lockSeq}`;
}
/** Bounded steal/retry attempts so a pathological never-acquirable lock cannot recurse forever (CONC-2). */
const LOCK_MAX_ATTEMPTS = 8;
const LOCK_RETRY_BACKOFF_MS = 25;
let _lockSleepBuf: Int32Array | null = null;
function lockBackoff(): void {
// Small jittered backoff between steal attempts (yields the thread via Atomics.wait).
if (_lockSleepBuf === null) _lockSleepBuf = new Int32Array(new SharedArrayBuffer(4));
const jitter = Math.floor(Math.random() * LOCK_RETRY_BACKOFF_MS);
Atomics.wait(_lockSleepBuf, 0, 0, LOCK_RETRY_BACKOFF_MS + jitter);
}
/**
* Parse the holder PID from a legacy plain-token lockfile body (the first `-`-delimited segment).
* Returns null when the body has no numeric leading segment (e.g. JSON content, or legacy no-pid).
*/
function lockHolderPid(body: string): number | null {
const seg = body.split('-')[0];
if (!/^\d+$/.test(seg)) return null;
const pid = Number(seg);
return Number.isInteger(pid) && pid > 0 ? pid : null;
}
/**
* Parsed view of a lockfile body. `hostname` is null for a legacy lock (no hostname was recorded
* before finding 1) — a null hostname is treated as SAME-host (conservative, backward compatible:
* legacy locks were always same-machine since the lock predates cross-host concerns). `startTime`
* is the holder process's recorded start-time (finding 1, process-start-time liveness); null for a
* legacy lock or one whose body did not record it — a null recorded start-time cannot be matched, so
* liveness cannot be verified and the holder is treated as NOT verified-live (steal-eligible).
*/
interface ParsedLock { pid: number | null; hostname: string | null; startTime: string | null; ts: number | null; }
/**
* Parse a lockfile body into { pid, hostname, startTime, ts }. The new format (finding 1) is JSON
* `{ token, pid, hostname, startTime, ts }`; a legacy body is a plain `pid-ts-seq` token (or
* non-numeric junk). Never throws — unparseable content yields all-null.
*
* Finding 1 (HIGH) — lock-steal TOCTOU: `ts` is the body's OWN recorded timestamp. The age decision
* is bound to `now - ts` (a FRESH replacement body carries a FRESH ts → small age → not stolen), NOT
* to the file `mtime` (which a stale-old `mtime` on a freshly-replaced body would mis-report). `ts` is
* also the per-body identity re-checked immediately before the atomic rename-steal. A legacy/no-`ts`
* body yields ts:null and the caller falls back to the file `mtime` age.
*/
function parseLockBody(body: string): ParsedLock {
const trimmed = body.trim();
if (trimmed.startsWith('{')) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const p = parsed as Record<string, unknown>;
const pidVal = p['pid'];
const pid = typeof pidVal === 'number' && Number.isInteger(pidVal) && pidVal > 0 ? pidVal : null;
const hostVal = p['hostname'];
const hostname = typeof hostVal === 'string' && hostVal ? hostVal : null;
const stVal = p['startTime'];
const startTime = typeof stVal === 'string' && stVal ? stVal : null;
const tsVal = p['ts'];
const ts = typeof tsVal === 'number' && Number.isFinite(tsVal) ? tsVal : null;
return { pid, hostname, startTime, ts };
}
} catch { /* fall through to legacy parse */ }
}
// Legacy plain-token body: hostname/startTime/ts were never recorded → null (treated as same-host,
// unverifiable liveness, mtime-age fallback).
return { pid: lockHolderPid(trimmed), hostname: null, startTime: null, ts: null };
}
/**
* Finding 1 (HIGH) — future/implausible `ts` deadlock. Derive the lock AGE (ms) from the body's own
* `ts` when that ts is TRUSTWORTHY, else fall back to the file `mtime`. A `ts` is distrusted when it is
* in the FUTURE (now - ts < 0 — a planted body or a clock-skewed/back-stepped writer) or implausibly
* far in the future (small forward skew is tolerated, but a `ts` more than the deadman ahead of now is
* nonsense). A trusted future `ts` would keep `age = now - ts <= LOCK_STALE_MS` forever, so the lock
* would never become stale/deadman/steal-eligible → permanent block. Falling back to `mtime` keeps
* stale/deadman recovery working (a future mtime is far less likely, and the deadman still bounds it).
* A null `ts` (legacy/garbage/no-ts body) also uses the `mtime` age.
*/
function lockAgeMs(ts: number | null, mtimeMs: number): number {
if (ts !== null) {
const age = Date.now() - ts;
// Trust the body ts ONLY when it is not in the future and not implausibly far ahead. A small
// forward clock skew (age slightly negative) is rejected too — any future ts is distrusted.
if (age >= 0 && age <= Number.MAX_SAFE_INTEGER) return age;
}
// MEDIUM finding: if mtime is ALSO in the future (planted lock, clock stepped backward after write),
// `Date.now() - mtimeMs` is negative → age <= LOCK_STALE_MS forever → permanent deadlock. A mtime
// MORE than LOCK_STALE_MS / 2 in the future is untrustworthy (planted or a significant clock step);
// return MAX_SAFE_INTEGER so the lock routes into the normal steal decision tree (verified-live
// same-host holders are still protected there — that check is age-independent). A small negative
// (sub-second jitter from filesystem timestamp precision) is clamped to 0 (treat as brand-new / fresh)
// rather than MAX_SAFE_INTEGER, so a lock written and immediately stat'd is never mis-stolen.
const mtimeAge = Date.now() - mtimeMs;
if (mtimeAge >= 0) return mtimeAge;
// mtimeAge is negative → mtime is in the future. Small jitter (within LOCK_STALE_MS / 2, i.e. 30s)
// → clamp to 0 (fresh, conservative). Large future (> 30s) → untrustworthy → MAX_SAFE_INTEGER.
return mtimeAge >= -(LOCK_STALE_MS / 2) ? 0 : Number.MAX_SAFE_INTEGER;
}
/** Is the parsed lock from THIS host? A null (legacy) hostname is treated as same-host. */
function isSameHost(parsed: ParsedLock): boolean {
return parsed.hostname === null || parsed.hostname === os.hostname();
}
/**
* Best-effort process start-time for `pid`, as an OPAQUE platform-specific string used ONLY for
* equality comparison (never parsed as a date). The pair (pid, startTime) uniquely identifies a
* process instance: even if a crashed holder's pid is REUSED by an unrelated process, the new
* process's start-time differs, so a recorded start-time that no longer matches proves pid-reuse.
*
* Platform handling (all bounded — the shell-outs only run on the rare STEAL-decision path, never the
* happy path):
* - Linux: read `/proc/<pid>/stat` field 22 (starttime, in clock ticks since boot). No shell-out.
* Field 2 (comm) may contain spaces/parens, so we split AFTER the last ')' to index reliably.
* - macOS/other POSIX: `ps -p <pid> -o lstart=` via the bounded execTool seam (process start
* wall-clock; stable for a given live process).
* - Windows: PowerShell `(Get-Process -Id <pid>).StartTime.Ticks` via the bounded execTool seam.
* Returns null on ANY error / unobtainable value — a null observed start-time means liveness cannot
* be VERIFIED (so the holder is treated as not-verified-live → steal-eligible past the deadman).
*/
function getProcessStartTime(pid: number): string | null {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
if (process.platform === 'linux') {
// Field 22 is `starttime`. comm (field 2) is wrapped in parens and may itself contain spaces
// and ')'; everything after the LAST ')' is space-delimited and stable to index.
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
const rparen = stat.lastIndexOf(')');
if (rparen === -1) return null;
const rest = stat.slice(rparen + 1).trim().split(/\s+/);
// After comm, fields are state(0) ppid(1) ... starttime is field 22 overall → index 19 of rest.
const starttime = rest[19];
return typeof starttime === 'string' && /^\d+$/.test(starttime) ? starttime : null;
}
if (process.platform === 'win32') {
const res = execTool(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.Ticks`],
{ timeout: 5_000 },
);
if (res.exitCode !== 0 || res.error) return null;
const out = res.stdout.trim();
return /^\d+$/.test(out) ? out : null;
}
// macOS and other POSIX: ps lstart is the process's start wall-clock (stable per live process).
const res = execTool('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 5_000 });
if (res.exitCode !== 0 || res.error) return null;
const out = res.stdout.trim();
return out ? out : null;
} catch {
return null;
}
}
/**
* THIS process's start-time, captured ONCE at module load so we never re-shell on every lock write
* (the happy path stamps it from this cached value). Best-effort — null if unobtainable here.
*/
const _selfStartTime: string | null = getProcessStartTime(process.pid);
/**
* Serialize the lockfile body (finding 1): JSON carrying the owner token, pid, hostname, this
* process's cached start-time, and a timestamp. startTime lets a later acquirer verify the recorded
* holder is still the SAME process instance (defeats pid-reuse) without ever re-shelling here.
*/
function lockFileBody(token: string): string {
return JSON.stringify({ token, pid: process.pid, hostname: os.hostname(), startTime: _selfStartTime, ts: Date.now() });
}
/**
* Test seams (finding 1): the steal-decision path goes through these indirections so unit tests can
* mock liveness + process start-time DETERMINISTICALLY (without depending on real OS pids beyond the
* current process). The defaults are the real implementations. `_setLockProbes`/`_resetLockProbes`
* are exported for tests ONLY — they are not part of the CLI surface.
*/
const _lockProbes: {
isPidAlive: (pid: number) => boolean;
getProcessStartTime: (pid: number) => string | null;
} = { isPidAlive: _realIsPidAlive, getProcessStartTime };
/** Is `pid` a live process? `process.kill(pid, 0)` succeeds for a live (signalable) process. */
function _realIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true; // signalable → alive
} catch (err) {
// EPERM means the process exists but we cannot signal it (still ALIVE). ESRCH means it's gone.
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}
function isPidAlive(pid: number): boolean {
return _lockProbes.isPidAlive(pid);
}
/**
* Finding 2 (HIGH): parse the lockfile body via the SHARED fd-based bounded reader. The body is
* untrusted: a FIFO/device/symlink-to-device `.lock` (or a swapped/grown file) would block or read
* unbounded under a path-`stat`+`readFileSync`; an oversized/garbage body is a memory DoS. The
* shared reader (open → fstat → require regular file → size cap → read exactly size) returns null for
* a non-regular/oversized/IO body (it throws → we swallow), routing the holder to the deadman policy
* (no verifiable pid/host/startTime → steal only after the deadman). A normal small body is read and
* parsed. Never throws.
*/
function readParsedLockBounded(lockPath: string): ParsedLock {
const allNull: ParsedLock = { pid: null, hostname: null, startTime: null, ts: null };
try {
const body = ledgerMod.readSmallRegularFile(lockPath, LOCK_MAX_BODY_BYTES);
if (body === null) return allNull; // vanished/missing — cannot verify anything.
return parseLockBody(body);
} catch {
// Non-regular (FIFO/device/dir), oversized, or unreadable untrusted body → unparseable.
return allNull;
}
}
/**
* Finding 1 (HIGH): the per-body IDENTITY used to confirm, immediately before the atomic rename-steal,
* that the lock A decided to steal is STILL the same body instance (B did not replace it). Binds
* (dev, ino) from a fresh stat AND the body's own `ts` (when JSON). A null on any field means we could
* not read it (vanished/non-regular/oversized) — the caller treats that as "changed" and retries
* rather than stealing. Never throws.
*/
interface LockIdentity { dev: number | null; ino: number | null; ts: number | null; }
function lockIdentity(lockPath: string): LockIdentity {
let dev: number | null = null;
let ino: number | null = null;
try {
const st = fs.statSync(lockPath);
dev = typeof st.dev === 'number' ? st.dev : null;
ino = typeof st.ino === 'number' ? st.ino : null;
} catch {
return { dev: null, ino: null, ts: null }; // vanished/unstatable — treat as changed.
}
// ts comes from the (bounded) body; null for a legacy/no-ts body — then only dev/ino gate the steal.
const ts = readParsedLockBounded(lockPath).ts;
return { dev, ino, ts };
}
/**
* Two lock identities refer to the SAME body instance only when dev AND ino match AND the `ts` is
* unchanged. A null dev/ino on EITHER side (unreadable/vanished) is treated as a CHANGE (fail-safe:
* do not steal). A null `ts` on BOTH sides (legacy bodies) does not block the match — dev/ino carry it.
*
* Finding 3 (LOW): if the DECISION body (a) had a non-null JSON `ts`, the recheck body (b) MUST carry
* the SAME non-null `ts`. A recheck `ts` that is now null/absent (the body was rewritten to no-ts or
* garbage on the same inode) is NOT the same instance — treating it as "same" would contradict the
* "ts re-confirmed before steal" invariant and let A steal a body it can no longer identify. So a
* disappearing ts (a.ts !== null && b.ts === null) is a CHANGE → do not steal, retry.
*/
function sameLockInstance(a: LockIdentity, b: LockIdentity): boolean {
if (a.dev === null || a.ino === null || b.dev === null || b.ino === null) return false;
if (a.dev !== b.dev || a.ino !== b.ino) return false;
// If the decision body recorded a ts, it must STILL be present AND unchanged on recheck. A fresh
// replacement body carries a fresh ts (mismatch); a no-ts/garbage rewrite drops it (now null) —
// either way the body changed under us → not the same instance.
if (a.ts !== null && a.ts !== b.ts) return false;
return true;
}
/**
* Is the recorded SAME-host holder VERIFIED-LIVE (finding 1, process-start-time)? True ONLY when ALL
* hold: the pid signals alive AND the lock recorded a non-null start-time AND the pid's CURRENT
* observed start-time matches that recorded value. Any failure — dead pid, no recorded start-time,
* unobtainable current start-time, or a MISMATCH (= pid-reuse: the pid is alive but belongs to a
* different process instance now) — means NOT verified-live, so the holder may be stolen. This is the
* crux that defeats pid-reuse WITHOUT ever stealing a genuinely-live holder.
*/
function holderVerifiedLive(parsed: ParsedLock): boolean {
if (parsed.pid === null) return false;
if (!isPidAlive(parsed.pid)) return false;
if (parsed.startTime === null) return false;
const observed = _lockProbes.getProcessStartTime(parsed.pid);
if (observed === null) return false;
return observed === parsed.startTime;
}
/**
* Acquire an exclusive capability-mutation lock (a single lockfile created with O_EXCL), stamping
* a JSON body that records a unique owner token, our PID, our HOSTNAME, our process START-TIME, and a
* timestamp. Returns a LockHandle on success, or null if another LIVE operation holds it.
*
* Steal protocol (finding 1 — process-start-time liveness; never deadlocks AND never steals a
* verified-live SAME-host holder). The age is bound to the BODY instance A acts on — `age = now -
* body.ts` for a JSON body (a fresh replacement body carries a fresh ts), falling back to `now -
* mtime` for a legacy/no-`ts` body — and the (dev, ino, ts) identity is re-confirmed immediately
* before the rename so A can never steal a fresh lock B swapped in mid-decision (lock-steal TOCTOU):
* - age <= LOCK_STALE_MS → FRESH: never stolen (genuinely held → blocked).
* - age > LOCK_STALE_MS:
* · SAME host: compute live = pid alive AND recorded startTime present AND observed
* startTime === recorded startTime. If VERIFIED-LIVE → NEVER steal (blocked) — even past the
* deadman; a provably-live holder is sacrosanct. If NOT verified-live (pid dead, start-time
* mismatch = pid-reuse, or start-time unobtainable) → STEAL (fast local recovery).
* · DIFFERENT host, or no parseable pid (legacy/oversized/garbage body) → liveness cannot be
* verified at all → steal ONLY after age > LOCK_DEADMAN_MS (the deadman fallback). Under the
* deadman such a lock is left in place (blocked).
*
* Why this is the convergent design: an age-only rule lost-updates a live holder; a pid-liveness rule
* deadlocks forever on pid-reuse (a reused pid looks alive); a deadman rule can steal a live holder
* before the deadman. The (pid, start-time) pair uniquely identifies a process INSTANCE, so a reused
* pid is detected as a start-time MISMATCH and stolen, while a verified-live holder is never stolen.
*
* The steal itself is atomic (rename-then-recreate, so only ONE racing process can rename the
* inode), and the whole thing is a BOUNDED iterative loop (CONC-2/DOS-1) — no unbounded recursion.
* Acquire the capability-mutation lock (the single `.gsd/capabilities/.lock` under runtimeDir),
* delegating the hardened steal/liveness/deadman protocol to the shared lock primitive. The lockfile
* path is the SAME as before extraction, so all existing #1462 lock tests (which key on a `.lock`
* suffix and call lifecycle.acquireLock(runtimeDir)) keep passing unchanged.
*/
function acquireLock(runtimeDir: string): LockHandle | null {
const root = capabilitiesRoot(runtimeDir);
try { fs.mkdirSync(root, { recursive: true }); } catch { /* best-effort */ }
const lockPath = path.join(root, '.lock');
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
const token = newLockToken();
try {
const fd = fs.openSync(lockPath, 'wx'); // exclusive create — fails if held
// Finding 3 (LOW): once the exclusive create SUCCEEDS, a writeSync/closeSync failure must NOT
// leave the empty `.lock` behind — an orphan body self-blocks every later acquirer until the
// deadman. On any write/close error, best-effort unlink the file we just created and return null.
// Finding 2 (MEDIUM): use fs.writeFileSync(fd, body) — its internal write-all loop flushes the
// WHOLE buffer (no short-write), unlike a bare fs.writeSync(fd, …) which may write fewer bytes
// and leave a malformed body whose token releaseLock can never match (orphan until the deadman).
// Mirrors the writeLedger short-write fix.
try {
fs.writeFileSync(fd, lockFileBody(token));
} catch (writeErr) {
try { fs.closeSync(fd); } catch { /* best-effort */ }
try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ }
throw writeErr;
}
try {
fs.closeSync(fd);
} catch (closeErr) {
try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ }
throw closeErr;
}
// Finding 4 (LOW): capture the lock inode's (dev, ino) so releaseLock can confirm, immediately
// before rmSync, that the path still holds OUR inode (not a successor's) — minimizing the
// check-then-unlink window. Best-effort: a null dev/ino just falls back to the token check.
let dev: number | null = null;
let ino: number | null = null;
try {
const lst = fs.statSync(lockPath);
dev = typeof lst.dev === 'number' ? lst.dev : null;
ino = typeof lst.ino === 'number' ? lst.ino : null;
} catch { /* best-effort — release falls back to the token check alone */ }
return { path: lockPath, token, dev, ino };
} catch (err) {
// EEXIST → held (fall through to the steal decision). Any other error here is either the
// create failing for a real reason OR a write/close failure we already cleaned up → bail out.
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return null;
}
// Held — decide whether to steal.
let st: fs.Stats;
try {
st = fs.statSync(lockPath);
} catch {
// Lock vanished between open and stat — retry the create immediately.
continue;
}
// Finding 1 (HIGH) — bind the age decision to the SAME body instance A acts on. Parse the
// (bounded) body ONCE; derive age from the body's own `ts` (now - ts) for a JSON body so a FRESH
// replacement body (fresh ts) is correctly seen as fresh even if the file `mtime` is stale-old.
// A legacy/garbage/no-`ts` body — AND a FUTURE/implausible `ts` (see lockAgeMs) — falls back to
// the file `mtime` age so a planted/clock-skewed future ts can never deadlock the lock forever.
const parsed = readParsedLockBounded(lockPath);
const age = lockAgeMs(parsed.ts, st.mtimeMs);
if (age <= LOCK_STALE_MS) return null; // genuinely held (fresh) — blocked.
// Capture the identity (dev/ino + body ts) of the EXACT body the steal decision is made against,
// so we can confirm it is UNCHANGED immediately before the rename-steal (finding 1).
const decisionIdentity: LockIdentity = {
dev: typeof st.dev === 'number' ? st.dev : null,
ino: typeof st.ino === 'number' ? st.ino : null,
ts: parsed.ts,
};
if (isSameHost(parsed) && parsed.pid !== null) {
// SAME host with a parseable pid → we CAN verify liveness via the (pid, start-time) pair.
// A VERIFIED-LIVE holder is NEVER stolen — even past the deadman. Otherwise (dead pid,
// start-time mismatch = pid-reuse, or start-time unobtainable) → steal (fast local recovery).
if (holderVerifiedLive(parsed)) return null; // provably-live same-host holder — blocked.
// else fall through to the atomic steal.
} else {
// DIFFERENT host, or no parseable pid (legacy / oversized / garbage body) → liveness cannot be
// verified locally. Only the deadman can reclaim it; under the deadman, leave it (blocked).
if (age <= LOCK_DEADMAN_MS) return null;
// else (age > deadman) → fall through to the atomic steal.
}
// Finding 1 (HIGH): re-stat + re-read the body IMMEDIATELY before the rename and confirm it is the
// SAME instance (dev/ino unchanged AND, for a JSON body, ts unchanged). If B stole+recreated a
// FRESH lock between A's decision and now, the identity differs → do NOT steal B's fresh lock;
// RETRY the bounded loop instead. The rename itself remains the atomic single-winner.
if (!sameLockInstance(decisionIdentity, lockIdentity(lockPath))) {
if (attempt + 1 < LOCK_MAX_ATTEMPTS) lockBackoff();
continue; // the body changed under us — re-evaluate from scratch rather than steal a replacement.
}
// Steal atomically (only one racer can rename the inode).
const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
try { fs.renameSync(lockPath, stolen); } catch { return null; } // another process won the steal
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
// Loop and retry the create (bounded — no recursion, CONC-2). Brief backoff to de-sync racers.
if (attempt + 1 < LOCK_MAX_ATTEMPTS) lockBackoff();
}
return null; // attempt budget exhausted (pathological contention) — never throws/recurses.
try { fs.mkdirSync(root, { recursive: true }); } catch { /* best-effort — lockMod also mkdirs */ }
return lockMod.acquireLock(path.join(root, '.lock'));
}
/**
* Release a lock only if it still carries our owner token (PRIMARY discriminator) — and, as a best-
* effort SECONDARY check, if its inode still matches the (dev, ino) we captured at acquire (finding 4),
* so the common path never deletes a lock that was stale-stolen out from under us.
*
* The TOKEN re-check is what actually protects a successor: a real successor wrote a DIFFERENT owner
* token, so we read a non-matching token and refuse to delete — this holds on every filesystem. The
* dev/ino recheck is only a best-effort secondary guard: it may be DEFEATED by inode reuse on some
* filesystems (e.g. Linux ext4/overlay reusing the freed inode after a successor's unlink+recreate at
* the same path), so correctness does NOT depend on it. We keep it as harmless extra hardening (it can
* catch a same-token reuse edge), but the token check is the load-bearing invariant.
*
* Residual (finding 4 — minimized, honestly stated): a check-then-unlink window remains between the
* final token+inode recheck and the rmSync. This is the IRREDUCIBLE final-instruction window of any
* path-based lock without native OS advisory locking (flock), which GSD avoids (no native deps). The
* token+inode recheck shrinks the window to that last instruction: a successor must replace BOTH the
* token and the inode within it to be wrongly deleted, and that is only REACHABLE when a holder is
* BOTH stale (>LOCK_STALE_MS) AND still alive to call release — a live process frozen >60s mid sub-
* second fs critical section (a crashed holder never calls release; a normal holder finishes in ms).
* A rename-claim variant was tried but merely moves the same window (the restore step can clobber a
* third acquirer — Codex R6). This lock is same-user, same-machine DEFENSE-IN-DEPTH; it is NOT the
* trust barrier (that is consent + integrity + reversibility, see the trust-model doc), and the
* residual crosses no privilege boundary — the same disposition accepted for safeRmUnder's parent TOCTOU.
*/
/** Release a capability-mutation lock (shared primitive — token + inode owner-safe). */
function releaseLock(handle: LockHandle | null): void {
if (!handle) return;
try {
// Finding 2 (HIGH): the lock body is untrusted — read it via the shared fd-based bounded reader
// (regular-file + size cap). A FIFO/device/oversized/non-regular body at handle.path cannot be
// ours (our writes are tiny regular-file JSON), so it is simply not released by us (left for the
// deadman / its real owner) — and a FIFO can never block release. Null means gone/non-regular →
// nothing of ours to release.
let body: string | null;
try {
body = ledgerMod.readSmallRegularFile(handle.path, LOCK_MAX_BODY_BYTES);
} catch {
return; // non-regular / oversized / unreadable → not ours; do not read or delete.
}
if (body === null) return; // gone / missing — nothing of ours to release.
// The body is now JSON `{ token, pid, hostname, startTime, ts }` (finding 1); release only if the
// recorded token is still OURS. A legacy plain-token body (whole body === token) is also honored
// so an in-flight handle written by an older build can still be released.
if (lockBodyToken(body) !== handle.token && body !== handle.token) return; // not our token (PRIMARY).
// Finding 4 (LOW): best-effort SECONDARY guard — re-stat the path IMMEDIATELY before rmSync and, if
// we captured an inode at acquire, confirm it is STILL ours (the dev/ino captured at acquire). A
// successor recreated at the same path MAY have a different inode → then do NOT delete it. This is
// only a window-minimizer, NOT the correctness invariant: inode reuse on some filesystems (Linux
// ext4/overlay after a successor's unlink+recreate) can make the inode match again, so the TOKEN
// check above is the load-bearing protection. When we captured no inode (best-effort null), the
// token check alone gated the delete.
if (handle.dev !== null && handle.ino !== null) {
let cur: fs.Stats;
try {
cur = fs.statSync(handle.path);
} catch {
return; // vanished/unstatable between read and rmSync → nothing of ours to release.
}
if (cur.dev !== handle.dev || cur.ino !== handle.ino) return; // successor inode — not ours.
}
fs.rmSync(handle.path, { force: true });
} catch { /* already gone / stale-stolen / unreadable — nothing of ours to release */ }
}
/** Extract the owner token from a lockfile body (JSON `token` field), or null if not JSON/absent. */
function lockBodyToken(body: string): string | null {
const trimmed = body.trim();
if (!trimmed.startsWith('{')) return null;
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const t = (parsed as Record<string, unknown>)['token'];
return typeof t === 'string' ? t : null;
}
} catch { /* not JSON */ }
return null;
lockMod.releaseLock(handle);
}
function readManifest(dir: string): Record<string, unknown> | null {
@@ -1109,6 +647,91 @@ function checkSharedFileCount(sharedFiles: string[] | undefined): string | null
return null;
}
/**
* #1459: should this operation bind a user consent record? Only a PROJECT-scope op with a consent
* store configured. GLOBAL scope is under the user's own home and is trusted without a record. A
* caller that supplies a consentStoreDir but omits scope is treated as PROJECT (bind unless told
* otherwise) — the conservative default that closes the trust gap.
*/
function shouldBindConsent(opts: LifecycleOptions): boolean {
if (!opts.consentStoreDir) return false;
const scope = opts.scope ?? 'project';
return scope === 'project';
}
/**
* #1459: a non-fatal capability-consent diagnostic on stderr. The lifecycle lib does not own a logger,
* but a consent-binding skip/failure must be OBSERVABLE to the caller (IC-05/WIN-2, IC-07) — a silent
* skip leaves a project cap inactive with no explanation. Best-effort: never throws (stderr can fail).
*/
function warnConsent(message: string): void {
try { process.stderr.write(`capability consent: ${message}\n`); } catch { /* best-effort */ }
}
/**
* #1459 IC-07: a PROJECT-scope op that did NOT supply a consentStoreDir cannot bind a consent record,
* so the freshly-installed/upgraded project cap will be DISCOVERED-BUT-INACTIVE at load. That used to
* be a SILENT skip. Emit a stderr warning so the caller knows consent binding was skipped (and why the
* cap is inactive). Only fires for project scope with NO consent store — GLOBAL scope is trusted and
* intentionally records nothing.
*/
function warnIfConsentSkipped(opts: LifecycleOptions, id: string): void {
const scope = opts.scope ?? 'project';
if (scope === 'project' && !opts.consentStoreDir) {
warnConsent(
`project-scope install of "${id}" did not supply a consent store (consentStoreDir); ` +
`consent binding was SKIPPED, so this capability will be DISCOVERED-BUT-INACTIVE until consented.`,
);
}
}
/**
* Record a project-scope user consent for `id` AFTER its ledger commit (#1459). The consent is bound
* to the RECOMPUTED full-bundle content hash of the INSTALLED bundle (capDir) — the security binding
* (CB-1/CB-2) — plus `integrity` + `disclosureSignature` (kept for the disclosure/re-consent UX). The
* loader recomputes `bundleContentHash(capDir)` at load and re-activates exactly this bundle on THIS
* machine; a forged/cloned project ledger without this record (or whose on-disk bundle differs from
* the consented content) stays inactive.
*
* The content hash MUST be computed from the bundle as it now lives on disk (capDir(runtimeDir, id)),
* NOT the staged dir — the loader hashes the installed capDir, so the two must agree.
*
* Best-effort: a consent-store write failure must not turn a successful install/upgrade into a
* failure (the bundle is already committed) — it is surfaced as a warning, not a throw.
*/
function bindProjectConsent(opts: LifecycleOptions, id: string, integrity: string, manifest: Record<string, unknown>): void {
// #1459 IC-07: a project-scope op WITHOUT a consent store cannot bind — warn (then nothing to do).
if (!shouldBindConsent(opts)) {
warnIfConsentSkipped(opts, id);
return;
}
try {
consentMod.recordProjectConsent({
gsdHome: opts.consentStoreDir,
// #1459 IC-01/CB-4: bind the record's projectRoot through the SINGLE canonical helper so the
// RECORD key matches the loader's LOOKUP key (consentProjectRoot) and `trust revoke`. The bundle
// hash is still taken over the ACTUAL on-disk install location (capDir(opts.runtimeDir, id)).
projectRoot: projectRootMod.consentProjectRoot(opts.runtimeDir),
id,
integrity,
disclosureSignature: trustMod.signatureForManifest(manifest),
contentHash: consentMod.bundleContentHash(capDir(opts.runtimeDir, id)),
});
} catch (err) {
// #1459 IC-05/WIN-2: a consent-store write failure (read-only/UNC/NFS store) must NOT turn an
// otherwise-successful install/upgrade into a failure — the bundle is already committed. Surface a
// non-fatal warning (naming the store path so the operator can fix permissions and re-consent via
// `gsd capability trust`), and let the op SUCCEED. The cap is simply inactive until consent writes.
const storePath = (() => {
try { return consentMod.consentStorePath(opts.consentStoreDir); } catch { return String(opts.consentStoreDir); }
})();
warnConsent(
`could not write the consent record for "${id}" to "${storePath}": ${(err as Error).message}. ` +
`The install succeeded but this capability stays INACTIVE until consent can be recorded.`,
);
}
}
// ---------------------------------------------------------------------------
// Install
// ---------------------------------------------------------------------------
@@ -1282,6 +905,11 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise<
sharedEdits,
});
committed = true;
// #1459: a CONSENTED project install (no consent needed for declarative; granted for
// executable) records a user consent in the user-owned consent store AFTER the ledger commit,
// bound to integrity + disclosure signature. Without this record the loader leaves the project
// overlay inactive — closing the repo-plantable-ledger bypass. Global scope records nothing.
bindProjectConsent(opts, resolved.id, resolved.integrity ?? '', manifest);
} catch (err) {
// Swap/commit failed; the intent remains for reconcile to roll back.
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
@@ -1453,6 +1081,9 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise<
sharedEdits,
});
committed = true;
// #1459: re-record the project consent for the UPGRADED bundle (new integrity + signature) so
// the loader re-activates exactly the new version on THIS machine. Global scope records nothing.
bindProjectConsent(opts, resolved.id, resolved.integrity ?? '', newManifest);
} catch (err) {
// Swap/commit failed mid-flight; the intent remains in the ledger so reconcile can recover.
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
@@ -1481,6 +1112,16 @@ interface RemoveResult {
removedFiles?: string[];
dataPreserved?: boolean;
blockReasons?: string[];
/**
* #1459 finding 3 (round 6): true when the files/ledger were removed but the project-scope consent
* record could NOT be revoked (e.g. the consent-store lock could not be acquired — revokeProjectConsent
* THROWS rather than doing an unlocked delete). The removal is still `removed` (the bundle is gone), but
* a STALE consent record remains that a byte-identical re-drop + forged ledger could reactivate against,
* so the caller must report a NON-CLEAN removal and tell the user to clear it (`gsd capability trust revoke`).
*/
consentRevokeFailed?: boolean;
/** Human-readable detail naming the stale consent record when consentRevokeFailed is true. */
consentRevokeWarning?: string;
}
/**
@@ -1562,7 +1203,37 @@ function removeCapability(id: string, opts: LifecycleOptions): RemoveResult {
};
}
return { status: 'removed', id, strippedEdits, removedFiles, dataPreserved: !removeData };
// #1459: a PROJECT-scope removal fully REVOKES the user consent record so a later repo-dropped
// bundle of the same id cannot silently re-activate against a stale consent. The ledger removal has
// already succeeded, so a revoke failure must NOT fail the removal — but it MUST NOT be silently
// swallowed either (#1459 finding 3, round 6): revokeProjectConsent now THROWS on a consent-lock
// failure (round 3) rather than doing an unlocked delete, and swallowing that throw would report a
// clean `removed` while leaving a STALE consent record a byte-identical re-drop + forged ledger could
// reactivate against (the same stale-redrop class the reconcile path closes). Surface it instead: a
// stderr warning naming the record AND a flag on the result so the CLI reports a non-clean removal.
let consentRevokeFailed = false;
let consentRevokeWarning: string | undefined;
if (shouldBindConsent(opts)) {
try {
// #1459 IC-01/CB-4: revoke under the SAME canonical root the record was written under
// (consentProjectRoot), so a removal actually clears the record the install bound.
consentMod.revokeProjectConsent({ gsdHome: opts.consentStoreDir, projectRoot: projectRootMod.consentProjectRoot(runtimeDir), id });
} catch (err) {
consentRevokeFailed = true;
consentRevokeWarning =
`removed capability "${id}" but could NOT revoke its project consent record: ${(err as Error).message}. ` +
`The consent record is now STALE — a byte-identical re-drop of this bundle could reactivate against it. ` +
`Clear it manually: gsd capability trust revoke ${id}`;
warnConsent(consentRevokeWarning);
}
}
const result: RemoveResult = { status: 'removed', id, strippedEdits, removedFiles, dataPreserved: !removeData };
if (consentRevokeFailed) {
result.consentRevokeFailed = true;
result.consentRevokeWarning = consentRevokeWarning;
}
return result;
} finally {
releaseLock(lock);
}
@@ -1610,11 +1281,29 @@ function backupNameMatchesId(name: unknown, id: string): name is string {
*
* The post-recovery state is always fully-old or fully-new — never a half-state.
*/
function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport {
function reconcileCapabilities(opts: { runtimeDir: string; scope?: 'global' | 'project'; consentStoreDir?: string }): ReconcileReport {
const { runtimeDir } = opts;
const report: ReconcileReport = { rolledBack: [], rolledForward: [], orphansRemoved: [], ledger: null, warnings: [] };
const root = capabilitiesRoot(runtimeDir);
// #1459 IC-03: when a rollback DELETES a committed/half-committed project-scope ledger entry whose
// bundle dir is gone, the user consent record bound to that (projectRoot, id) is now stale. Revoke it
// so a later re-dropped BYTE-IDENTICAL bundle of the same id (whose recomputed content hash would
// still match the stale record) cannot silently re-activate without a fresh user decision. The
// content-hash binding already deactivates a DIFFERENT re-drop; revoking on rollback closes the
// identical-re-drop gap. Best-effort + only when a project consent store is configured.
const revokeStaleConsent = (id: string): void => {
if (!opts.consentStoreDir) return;
if ((opts.scope ?? 'project') !== 'project') return;
try {
consentMod.revokeProjectConsent({
gsdHome: opts.consentStoreDir,
projectRoot: projectRootMod.consentProjectRoot(runtimeDir),
id,
});
} catch { /* best-effort — a consent-store IO error must never abort crash recovery */ }
};
// Finding 2 (HIGH): READ-ONLY corruption preflight BEFORE acquireLock. acquireLock creates
// .gsd/capabilities and a .lock file; doing it before detecting corruption pollutes the scope
// (and takes a lock) on a ledger we will refuse to mutate anyway. A strict read takes no lock and
@@ -1697,6 +1386,7 @@ function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport {
if (!safeRmUnder(runtimeDir, path.relative(runtimeDir, finalDir))) continue;
delete workingLedger.entries[id]; // DOS-2: in-memory drop; single write at end of step 1.
ledgerDirty = true;
revokeStaleConsent(id); // #1459 IC-03: drop the now-stale consent so an identical re-drop stays inactive.
report.rolledBack.push(id);
continue;
}
@@ -1737,6 +1427,7 @@ function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport {
stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) });
delete workingLedger.entries[id]; // DOS-2: in-memory drop.
ledgerDirty = true;
revokeStaleConsent(id); // #1459 IC-03: both backup + live gone → uninstall self-heal also revokes consent.
report.rolledBack.push(id);
continue;
}
@@ -1856,17 +1547,13 @@ export = {
stripCapabilitySharedEdits,
CAP_MARKER,
// Exported for cross-process-lock unit tests (CONC-1/CONC-2/finding-1). Not part of the public CLI
// surface. `_setLockProbes`/`_resetLockProbes` let tests inject deterministic isPidAlive /
// getProcessStartTime so the start-time liveness branches are exercised without real OS pids.
// surface. #1459 finding 4: the lock primitive now lives in the shared capability-lock module; these
// re-export it (acquireLock here still takes a runtimeDir and computes the `.gsd/capabilities/.lock`
// path) and the test seams (`_setLockProbes`/`_resetLockProbes`/`getProcessStartTime`) forward to the
// shared module so the existing #1462 lock tests drive the SAME probe state the primitive reads.
acquireLock,
releaseLock,
getProcessStartTime,
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>): void {
if (typeof probes.isPidAlive === 'function') _lockProbes.isPidAlive = probes.isPidAlive;
if (typeof probes.getProcessStartTime === 'function') _lockProbes.getProcessStartTime = probes.getProcessStartTime;
},
_resetLockProbes(): void {
_lockProbes.isPidAlive = _realIsPidAlive;
_lockProbes.getProcessStartTime = getProcessStartTime;
},
getProcessStartTime: lockMod.getProcessStartTime,
_setLockProbes: lockMod._setLockProbes,
_resetLockProbes: lockMod._resetLockProbes,
};

View File

@@ -64,11 +64,33 @@ interface SemverModule {
}
interface ProjectRootModule {
findProjectRoot: (startDir: string) => string | null;
/** #1459 IC-01/CB-4: the canonical realpath'd consent project root (RECORD/LOOKUP/revoke parity). */
consentProjectRoot: (cwd: string) => string;
}
interface GeneratorModule {
buildRegistry: (capMap: Map<string, unknown>) => Registry;
loadCentralConfigKeys: () => Set<string>;
}
interface LedgerModule {
/** THE single per-entry validator (shared with capability-ledger's readers) — loader parity. */
isValidLedgerEntry: (id: unknown, entry: unknown) => boolean;
/** Shared fd-based bounded reader: content, null for ENOENT, or THROWS (non-regular/oversized/IO). */
readSmallRegularFile: (filePath: string, maxBytes: number) => string | null;
}
interface ConsentModule {
/**
* #1459 CB-1/CB-2: the consent decision is bound to the RECOMPUTED full-bundle content hash, not
* the repo-plantable ledger integrity nor the executable-only disclosure signature.
*/
hasProjectConsent: (args: {
gsdHome?: string;
projectRoot: string;
id: string;
contentHash: string;
}) => boolean;
/** Recompute the full-bundle content hash over capDir (manifest AND artifacts AND identity). */
bundleContentHash: (capDir: string) => string;
}
export interface LoadRegistryOptions {
/** When true, compose the validated installed overlay on top of first-party. */
@@ -85,6 +107,13 @@ export interface OverlaySkip {
id: string;
scope: 'global' | 'project';
reason: string;
/**
* #1459 IC-02: a STRUCTURAL discriminant for the skip so consumers (gsd-tools `list`) classify a
* warning by `kind`, not by matching the human-readable `reason` prose (which is free to change).
* `'unconsented'` is the project-scope no-consent-record case the list command marks INACTIVE; other
* skips carry no `kind` (they are first-party-wins / validation / engines / pending diagnostics).
*/
kind?: 'unconsented';
}
export interface BlockedGate {
@@ -120,6 +149,22 @@ export interface OverlayMeta {
const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/;
const GSD_HOME_DIRNAME = '.gsd';
/**
* GENEROUS DoS backstop for the bounded per-scope ledger read (mirrors capability-ledger's
* LEDGER_MAX_BYTES). The project-scope ledger is repo-plantable untrusted content; reading it via
* the shared fd reader (regular-file + size cap) means a FIFO/device/symlinked ledger can no longer
* BLOCK (the #1459 raw-readFileSync hang) or read unbounded.
*/
const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
/**
* #1459 finding 2 (HIGH): GENEROUS DoS backstop on a project-plantable `capability.json`. The loader
* MUST read the manifest via the shared bounded fd reader (regular-file + size cap, no FIFO hang),
* NOT a raw `fs.readFileSync` — a repo-planted FIFO/device manifest would otherwise BLOCK the loader
* forever and an oversized manifest would read unbounded into memory (OOM). A legitimate manifest is a
* few KiB of declarative JSON; 8 MiB is wildly more than any real capability.json. A null/oversized/
* non-regular read → SKIP the overlay (warning), fail-closed.
*/
const MANIFEST_MAX_BYTES = 8 * 1024 * 1024;
function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
@@ -137,23 +182,105 @@ function readHostVersion(): string {
}
}
/**
* Canonicalize a directory path for dedup/scope-escalation comparison. #1459 finding 1 (HIGH): the dedup
* MUST collapse two DIFFERENT LEXICAL paths that name the SAME PHYSICAL directory (a symlink) to one key,
* else a symlinked GSD_HOME aliasing the project root is scanned once as trusted 'global' BEFORE the
* 'project' scan and the in-repo `.gsd/capabilities` bundle bypasses the CB-3 consent gate via aliasing.
* `fs.realpathSync` resolves symlinks to the physical path; on ENOENT/IO error it falls back to
* `path.resolve` (a not-yet-created overlay dir cannot be realpath'd).
*
* #1459 CONVERGENCE finding 3 (LOW/MED): the realpath FAILURE must be reported to the caller (the
* `realpathFailed` flag), NOT silently swallowed. The old behavior — fall back to `path.resolve` while
* preserving the candidate's ORIGINAL scope — was not strictly fail-safe: a symlinked GSD_HOME whose
* realpath THROWS (a race / odd-FS) would key on its SYMLINK-LEXICAL path, which differs from the
* project candidate's realpath'd key, so the two would NOT merge and the aliased global root would be
* scanned as trusted-'global' (no consent record required) — parking an aliased project tree in the
* trusted-global slot. The caller (`overlayRoots`) uses `realpathFailed` to classify a realpath-failed
* GLOBAL candidate CONSERVATIVELY (consent-required 'project'), so a race/odd-FS can never aliased-upgrade
* an in-repo bundle to trusted-global. The fallback key is still `path.resolve` (best-effort dedup); a
* normal ENOENT (the global capabilities dir simply does not exist yet) still resolves to no scan because
* the later readdir fails — the conservative reclassification is harmless when there is nothing to read.
*/
function canonicalDir(dir: string): { path: string; realpathFailed: boolean; enoent: boolean } {
try {
return { path: fs.realpathSync(dir), realpathFailed: false, enoent: false };
} catch (err) {
// #1459 finding 1 (round 6): distinguish a NON-EXISTENT overlay dir (ENOENT — there is simply nothing
// to scan at that scope, so the fail-safe demotion must NOT fire) from a realpath that fails for ANOTHER
// reason (race / odd-FS / EIO / EACCES — the dir may exist but is uncanonicalizable, so we cannot prove
// physical distinctness and MUST fail safe toward needs-consent).
const code = (err as NodeJS.ErrnoException).code;
const enoent = code === 'ENOENT' || code === 'ENOTDIR';
return { path: path.resolve(dir), realpathFailed: true, enoent };
}
}
/**
* The ordered overlay install roots (global first, then project), deduped by
* resolved absolute path so a single directory is never scanned twice (which
* CANONICAL (realpath'd) absolute path so a single physical directory is never scanned twice (which
* would otherwise self-report a spurious id collision when the project lives
* under the GSD home, or in tests where both resolve to the same fixture).
*
* #1459 CB-3: when the consent-global home resolves EQUAL to (or an ancestor whose .gsd collides with)
* a GENUINE project root, the global overlay dir and the project overlay dir are the SAME directory.
* The dedup must NOT then keep it as 'global' (trusted, no consent record required) — that would let an
* in-repo bundle bypass consent simply because GSD_HOME pointed at the repo. On a collision the
* surviving scope escalates to the MORE RESTRICTIVE 'project' (consent-required), but ONLY when the
* colliding root is a GENUINE marker'd project (a `.planning/` dir or a `.git`). `findProjectRoot` is
* total — it returns `cwd` itself when no marker exists — so a bare GSD_HOME with no project marker
* (the user's own home; also the test-fixture `cwd === home` no-op) must stay 'global' and NOT spuriously
* demand consent.
*
* #1459 finding 1 (HIGH): BOTH the dedup key AND the CB-3 collision comparison are keyed on the
* realpath'd path (canonicalDir), so a symlinked GSD_HOME that physically IS the project root collides
* and escalates to consent-required 'project' — it can no longer be aliased into the trusted-global slot.
*
* #1459 finding 1 (HIGH, ROUND 6): the trusted-global slot is now gated on PROVABLE distinctness from the
* project tree — realpath(global) AND realpath(project) must BOTH succeed AND resolve to DIFFERENT physical
* paths. The earlier one-sided rule (demote only a realpath-FAILED *global* candidate) still allowed the
* symlinked-GSD_HOME bypass: when GSD_HOME aliases the project root, the GLOBAL candidate realpaths fine
* while the PROJECT candidate's realpath fails, so the keys never collide and the in-repo bundle stays in
* the no-consent global slot. If distinctness cannot be proven (either realpath throws, or both resolve
* EQUAL) AND there is a genuine project root, the global is demoted to consent-required 'project'.
*/
function hasGenuineProjectMarker(dir: string): boolean {
try {
const planning = path.join(dir, '.planning');
if (fs.existsSync(planning) && fs.statSync(planning).isDirectory()) return true;
} catch { /* fall through */ }
try {
if (fs.existsSync(path.join(dir, '.git'))) return true;
} catch { /* fall through */ }
return false;
}
function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope: 'global' | 'project' }> {
const roots: Array<{ dir: string; scope: 'global' | 'project' }> = [];
const seen = new Set<string>();
const add = (dir: string, scope: 'global' | 'project'): void => {
const byPath = new Map<string, { dir: string; scope: 'global' | 'project' }>();
const add = (dir: string, scope: 'global' | 'project', canonical: { path: string; realpathFailed: boolean }, genuineProject = false): void => {
const resolved = path.resolve(dir);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push({ dir: resolved, scope });
// #1459 finding 1: the DEDUP KEY (and thus the CB-3 scope-escalation comparison) is the CANONICAL
// (realpath'd) path, so a symlinked GSD_HOME that physically IS the project root collides here (and
// escalates below) instead of being scanned as a distinct trusted 'global' root. The SCANNED path
// (`entry.dir`) stays the lexical `path.resolve` value — the readdir/commandRoots path is unchanged
// for the common (non-symlinked) case; only the dedup/escalation decision is realpath-aware.
const key = canonical.path;
const existing = byPath.get(key);
if (existing) {
// CB-3: a dir already claimed escalates to the more restrictive scope ONLY for a GENUINE project
// root — so a real GSD_HOME == projectRoot (incl. via a symlink) still requires consent, while a
// marker-less home stays trusted-global (and the test-fixture cwd===home no-op is preserved).
if (existing.scope === 'global' && scope === 'project' && genuineProject) existing.scope = 'project';
return;
}
const entry = { dir: resolved, scope };
byPath.set(key, entry);
roots.push(entry);
};
const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
add(path.join(home, GSD_HOME_DIRNAME, 'capabilities'), 'global');
const globalDir = path.join(home, GSD_HOME_DIRNAME, 'capabilities');
const globalCanon = canonicalDir(globalDir);
let projectRoot: string | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
@@ -162,12 +289,63 @@ function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope
} catch {
projectRoot = null;
}
if (projectRoot) {
add(path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities'), 'project');
const projectDir = projectRoot ? path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities') : null;
const projectCanon = projectDir ? canonicalDir(projectDir) : null;
// #1459 finding 1 (HIGH, round 6): the global overlay root is trusted (consent-FREE) ONLY when we can
// PROVE it is a distinct physical directory from the project overlay tree — i.e. realpath(global) AND
// realpath(project) BOTH succeed AND resolve to DIFFERENT physical paths. A one-sided rule (demote only a
// realpath-FAILED *global* candidate) left the symlinked-GSD_HOME bypass open: when GSD_HOME is a symlink
// alias of the project root, the GLOBAL candidate realpaths fine (stays trusted-global) while the PROJECT
// candidate's realpath fails → the two keys never collide → the in-repo bundle stays in the no-consent
// global slot. So the global is demoted to consent-required 'project' (only when there IS a GENUINE
// project root, so a marker-less home / cwd===home stays trusted-global) whenever distinctness cannot be
// proven: EITHER realpath throws, OR both succeed but resolve EQUAL (an alias). When the demoted-global
// and the project candidate physically coincide they then dedup onto one consent-required entry; when
// they are merely unprovable-distinct (e.g. global realpath failed) the global is independently demoted
// so an aliased in-repo tree it would scan still requires a record. A genuinely non-existent global dir
// (ENOENT) realpath-fails too, but its later readdir fails, so this demotion is a harmless no-op there.
let globalScope: 'global' | 'project' = 'global';
if (projectRoot && projectCanon && hasGenuineProjectMarker(projectRoot)) {
// The fail-safe only matters when there IS an in-repo overlay tree to protect. A NON-EXISTENT project
// overlay dir (ENOENT) has nothing to bypass into the trusted-global slot, so the global stays trusted
// (and a genuinely distinct real global cap is not spuriously demoted — the control case). Otherwise,
// demote the global to consent-required 'project' UNLESS we can PROVE physical distinctness:
// - the project overlay actually exists (or can't be proven absent), AND
// - either realpath can't canonicalize one side (race/odd-FS → can't prove distinct), OR
// - both canonicalize EQUAL (an alias — GSD_HOME physically IS the project root).
const projectAbsent = projectCanon.realpathFailed && projectCanon.enoent;
if (!projectAbsent) {
const provablyDistinct =
!globalCanon.realpathFailed &&
!projectCanon.realpathFailed &&
globalCanon.path !== projectCanon.path;
if (!provablyDistinct) globalScope = 'project';
}
}
add(globalDir, globalScope, globalCanon);
if (projectDir && projectCanon) {
add(projectDir, 'project', projectCanon, hasGenuineProjectMarker(projectRoot as string));
}
return roots;
}
/**
* Resolve the PROJECT ROOT for `cwd` used to LOOK UP a project-scope consent record (#1459). Delegates
* to the SINGLE canonical `consentProjectRoot` helper (IC-01/CB-4) so the loader's lookup key always
* matches the install RECORD key and the `trust revoke` key — installing from a subdir then resolves
* to the same realpath'd project root the loader checks (no install-then-inactive). Falls back to
* `cwd` if the project-root module cannot be loaded at all (the consent store realpaths it).
*/
function projectRootFor(cwd: string): string {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const projectRootMod: ProjectRootModule = require('./project-root.cjs');
return projectRootMod.consentProjectRoot(cwd);
} catch { /* fall through */ }
return cwd;
}
/**
* Read the per-scope ledger co-located with an overlay root (the root is `<scope>/.gsd/capabilities`,
* so its ledger is `<scope>/.gsd-capabilities.json`) and classify its ids:
@@ -181,32 +359,32 @@ function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope
* Never throws: a missing/invalid ledger yields empty sets.
*/
/**
* Is `e` a structurally-valid COMMITTED ledger entry for `id`? Mirrors the required shape that
* capability-ledger's readLedger enforces (id/version/source/integrity strings + files/sharedEdits
* arrays), and requires the key to equal `entry.id` and the entry to carry NO `_pending` marker.
* A malformed or tampered entry fails this check and is therefore NOT treated as consent — fail
* closed (Codex R2 low): only a genuine lifecycle-written commit authorizes command dispatch.
* Is `e` a structurally-valid COMMITTED ledger entry for `id`? Delegates the structural shape to
* capability-ledger's SHARED `isValidLedgerEntry` (loader/ledger validator PARITY — #1459 ROOT FIX:
* the loader previously hand-duplicated the shape and could drift), and ADDS the loader-specific
* "committed = valid AND carries NO `_pending` marker" semantic. A malformed/tampered/pending entry
* fails this check and is therefore NOT treated as committed — fail closed.
*/
function isCommittedLedgerEntry(id: string, e: unknown): boolean {
function isCommittedLedgerEntry(ledger: LedgerModule, id: string, e: unknown): boolean {
if (!e || typeof e !== 'object' || Array.isArray(e)) return false;
const r = e as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(r, '_pending')) return false; // committed entries carry no intent
return (
r['id'] === id &&
typeof r['version'] === 'string' &&
typeof r['source'] === 'string' &&
typeof r['integrity'] === 'string' &&
Array.isArray(r['files']) &&
Array.isArray(r['sharedEdits'])
);
if (Object.prototype.hasOwnProperty.call(e as Record<string, unknown>, '_pending')) return false; // intent ⇒ uncommitted.
return ledger.isValidLedgerEntry(id, e);
}
function ledgerOverlayIds(rootDir: string): { pending: Set<string>; committed: Set<string> } {
function ledgerOverlayIds(ledger: LedgerModule, rootDir: string): {
pending: Set<string>;
committed: Set<string>;
} {
const pending = new Set<string>();
const committed = new Set<string>();
try {
const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json');
const parsed: unknown = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
// #1459 (HIGH): read the per-scope ledger via the SHARED fd-based bounded reader (open → fstat →
// require regular file → size cap → read exactly size). The previous raw `fs.readFileSync` BLOCKED
// forever on a repo-planted FIFO ledger (a project-scope DoS) and read an oversized file whole.
const content = ledger.readSmallRegularFile(ledgerPath, LEDGER_MAX_BYTES);
if (content === null) return { pending, committed }; // genuinely missing.
const parsed: unknown = JSON.parse(content);
if (!parsed || typeof parsed !== 'object') return { pending, committed };
const entries = (parsed as Record<string, unknown>)['entries'];
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return { pending, committed };
@@ -214,12 +392,12 @@ function ledgerOverlayIds(rootDir: string): { pending: Set<string>; committed: S
if (!entry || typeof entry !== 'object') continue;
if ((entry as Record<string, unknown>)['_pending']) {
pending.add(id); // a truthy in-flight intent — defer/skip until reconciliation
} else if (isCommittedLedgerEntry(id, entry)) {
committed.add(id); // a genuine, structurally-valid commit — the consent signal
} else if (isCommittedLedgerEntry(ledger, id, entry)) {
committed.add(id); // a genuine, structurally-valid commit
}
// else: malformed / tampered / falsy-_pending → neither (fail closed: declarative-only)
}
} catch { /* missing/invalid ledger — no pending, no committed */ }
} catch { /* missing/invalid/non-regular/oversized ledger — no pending, no committed (fail closed) */ }
return { pending, committed };
}
@@ -245,9 +423,16 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
const validator: ValidatorModule = require('./capability-validator.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const semver: SemverModule = require('./semver-compare.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const ledgerMod: LedgerModule = require('./capability-ledger.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const consentMod: ConsentModule = require('./capability-consent.cjs');
const cwd = options.cwd || process.cwd();
const hostVersion = options.hostVersion || readHostVersion();
// The user-owned consent home — SAME `gsdHome || GSD_HOME || homedir()` rule the CLI uses, so the
// consent the CLI records is the consent the loader checks. The consent store NEVER lives in a repo.
const gsdHome = options.gsdHome || process.env['GSD_HOME'] || os.homedir();
const warnings: OverlaySkip[] = [];
const incompatibleGateCapIds: string[] = [];
@@ -309,7 +494,7 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
// install or upgrade). They are NOT yet committed, so they must not be activated — reconcile
// will roll them forward or back. Fail OPEN (skip without a gate block): an uncommitted gate
// is not a real installed gate. See capability-lifecycle.cts (ADR-1244 Phase 4).
const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(root.dir);
const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(ledgerMod, root.dir);
for (const ent of entries) {
if (!ent.isDirectory()) continue;
const id = ent.name;
@@ -323,7 +508,17 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
let cap: CapManifest;
try {
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CapManifest;
// #1459 finding 2 (HIGH): read the manifest via the SHARED fd-based bounded reader (open → fstat
// → require regular file → size cap → read exactly size). A project-planted FIFO/device manifest
// can no longer BLOCK the loader (the raw readFileSync hang) and an oversized manifest can no
// longer read unbounded. A null read (genuinely missing OR refused as non-regular/oversized) →
// skip the overlay, fail-closed.
const manifestRaw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES);
if (manifestRaw === null) {
warnings.push({ id, scope: root.scope, reason: 'capability.json missing, non-regular (FIFO/device), or exceeds the size cap — skipped' });
continue;
}
cap = JSON.parse(manifestRaw) as CapManifest;
} catch (e) {
warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) });
continue;
@@ -384,9 +579,77 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")');
continue;
}
// 5. Materialize path-based hook fragments (resolved against the overlay dir).
// materializeHookFragments RETURNS errors (e.g. a fragment path escaping the
// capability dir) — capture them; an un-materializable fragment is a skip.
// 5. #1459 — USER-OWNED CONSENT GATE (TRUST-1 + TRUST-3). For a PROJECT-scope overlay the
// authoritative consent signal is NOT the in-repo ledger (repo-plantable: a clone/fork
// activated executable surfaces AND declarative loop surfaces with no user decision) but a
// record in the user-owned consent store on THIS machine, bound to (realpath(projectRoot),
// id, RECOMPUTED full-bundle content hash). If there is NO matching record we do NOT push
// the cap into acceptedMap/overlayCaps and do NOT set a commandRoot → the cap is
// DISCOVERED-BUT-INACTIVE (a warning records why). This single gate closes BOTH
// command-dispatch (TRUST-1) and declarative-surface (TRUST-3) activation. GLOBAL scope is
// under the user's own home and is trusted as before (no consent record required).
//
// CONVERGENCE finding 1 (HIGH): this gate now runs BEFORE the heavy/unbounded pre-activation
// work (materializeHookFragments — which reads each `fragment.path` off disk — and the full
// cross-capability validation). A forged in-repo PROJECT overlay can point a `fragment.path`
// at an in-bundle FIFO/oversized file; materializing it BEFORE the consent check would
// hang/OOM the loader before the unconsented → inactive fail-closed path is reached. Running
// the (already bounded + fail-closed) consent recompute FIRST means an unconsented project
// overlay skips with NO further disk work. The gate's DECISION is identical — only the
// work-ordering moved (consented project overlays + GLOBAL overlays still materialize below).
//
// CONTENT BINDING (#1459 round 2, CB-1/CB-2/TRUST2-5): the binding is the bundle CONTENT
// HASH recomputed HERE over the on-disk capDir (manifest AND artifacts AND identity) — NOT
// the ledger `integrity` (which is `''` for path/git/dir installs and taken verbatim from
// the repo-plantable project ledger → degenerate `'' === ''`) and NOT the executable-only
// disclosure signature (a declarative-only swap leaves it constant). Any tamper — a swapped
// declarative capability.json, an edited hook script, an empty-integrity local install —
// changes the recomputed hash and the cap stays inactive. `bundleContentHash` is itself
// bounded + fail-closed (it refuses non-regular bundle files and reads via the shared bounded
// reader), so it cannot hang on a forged FIFO bundle file. The whole lookup is wrapped so a
// consent-store read / hash-recompute failure fails CLOSED (inactive), never crashing the
// loop (the loader must stay non-throwing end to end).
//
// IRREDUCIBLE TOCTOU LIMIT (#1459 / mirrors the #1462 lock-release residual): the hash
// verified HERE binds the bundle's on-disk content at THIS instant. A local writer racing
// between this verification and the capability's LATER execution (a hook firing, a command
// dispatch) can still mutate the bundle files after the check passes — this is a filesystem
// primitive limit, not a loader bug: short of fd-pinned execution or an atomic content
// snapshot (which needs native support we do not have here), no userspace check can close the
// window between "verify content" and "execute content". This is documented, not dismissed:
// the gate is the strongest defense available at this layer (any persisted tamper is caught on
// the NEXT load), and the residual race requires an attacker already able to write the project
// tree at execution time.
if (root.scope === 'project') {
let consented = false;
try {
consented = consentMod.hasProjectConsent({
gsdHome,
projectRoot: projectRootFor(cwd),
id,
contentHash: consentMod.bundleContentHash(capDir),
});
} catch {
consented = false; // fail closed — a consent-store/hash-recompute failure never activates a cap.
}
if (!consented) {
// DISCOVERED-BUT-INACTIVE: no user consent record on this machine. NOT a gate block (an
// unconsented project gate is not a real installed gate — same fail-open posture as
// `_pending`); it simply does not contribute any surface. #1459 IC-02: tag the skip with the
// structural `kind: 'unconsented'` so gsd-tools `list` marks it INACTIVE by discriminant, not
// by matching the (changeable) reason prose. NOTE (convergence finding 1): we `continue` here
// BEFORE materializeHookFragments, so an unconsented project overlay's fragment files are never
// read — a forged FIFO/oversized fragment cannot hang/OOM the loop.
warnings.push({ id, scope: root.scope, kind: 'unconsented', reason: 'discovered — no user consent record (inactive)' });
continue;
}
}
// 5b. Materialize path-based hook fragments (resolved against the overlay dir). Runs AFTER the
// project consent gate (convergence finding 1) so only a CONSENTED project overlay (or a
// trusted GLOBAL overlay) reaches the fragment reads. materializeHookFragments RETURNS errors
// (e.g. a fragment path escaping the capability dir, OR — convergence finding 1(b) — a fragment
// that is non-regular/oversized and refused by the shared bounded reader) — capture them; an
// un-materializable fragment is a skip, never a hang.
let fragErrs: string[];
try {
fragErrs = validator.materializeHookFragments(cap, capDir) || [];

561
src/capability-lock.cts Normal file
View File

@@ -0,0 +1,561 @@
/**
* Shared cross-process mutual-exclusion lock primitive — #1459 finding 4 + #1462 finding 1.
*
* A SINGLE hardened lockfile protocol shared by BOTH capability-lifecycle (the `.gsd/capabilities/.lock`
* mutation lock) and capability-consent (the consent-store `.consent.lock`). Before this extraction the
* two locks had DIFFERENT, divergent steal policies: the lifecycle lock was hardened (#1462 — pid +
* process-start-time identity + hard deadman, never steals a verified-live same-host holder), while the
* consent lock used a naive mtime-only 60 s steal that would STEAL A LIVE WRITER (a slow/paused holder
* past 60 s is reclaimed; the original writer then resumes and overwrites — a lost update). Sharing one
* primitive makes the consent lock as safe as the lifecycle lock (single source of truth — mirrors the
* shared-validator / shared bounded-reader lessons).
*
* STEAL PROTOCOL (never deadlocks AND never steals a verified-live SAME-host holder). The age is bound
* to the BODY instance the acquirer acts on — `age = now - body.ts` for a JSON body (a fresh replacement
* body carries a fresh ts), falling back to `now - mtime` for a legacy/no-`ts` body — and the
* (dev, ino, ts) identity is re-confirmed immediately before the atomic rename-steal:
* - age <= LOCK_STALE_MS → FRESH: never stolen (genuinely held → blocked).
* - age > LOCK_STALE_MS:
* · SAME host: VERIFIED-LIVE (pid alive AND recorded startTime present AND observed startTime ===
* recorded) → NEVER steal (even past the deadman). NOT verified-live (dead pid, start-time
* MISMATCH = pid-reuse, or start-time unobtainable) → STEAL (fast local recovery).
* · DIFFERENT host / no parseable pid (legacy/oversized/garbage body) → liveness unverifiable →
* steal ONLY after age > LOCK_DEADMAN_MS (the deadman fallback).
*
* The lockfile body is UNTRUSTED: it is read via the shared fd-based bounded reader
* (ledgerMod.readSmallRegularFile) so a FIFO/device/oversized body cannot block or read unbounded.
*
* Test seam: _setLockProbes / _resetLockProbes inject deterministic isPidAlive / getProcessStartTime so
* the start-time liveness branches are exercised without depending on real OS pids beyond the current
* process. capability-lifecycle re-exports these so its existing #1462 lock tests keep driving them.
*
* Imports: node:fs, node:path, node:os, node:crypto, and the ledger's shared bounded readSmallRegularFile
* + execTool (for the rare start-time shell-out on win32/macOS).
*/
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;
};
const { execTool } = require('./shell-command-projection.cjs') as {
execTool: (
program: string,
args: string[],
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number },
) => { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null };
};
/* eslint-enable @typescript-eslint/no-require-imports */
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* A lock older than this is a CANDIDATE for stealing (the holder may have crashed). A same-host
* lock past this age whose recorded pid is DEAD is stolen immediately (fast local recovery).
*/
const LOCK_STALE_MS = 60_000;
/**
* HARD deadman timeout. A lock older than this is stolen REGARDLESS of pid liveness or host. This is
* the only thing that can break a permanent deadlock caused by:
* - PID REUSE: a crashed holder's pid reused by an unrelated long-lived process makes isPidAlive
* return true forever, so the dead-pid fast-recovery branch never fires.
* - CROSS-HOST (NFS): a remote holder's pid is meaningless to local process.kill(pid,0), so liveness
* cannot be judged at all — only the deadman can reclaim such a lock.
* Much larger than LOCK_STALE_MS so a genuinely slow-but-live SAME-host holder is given a wide grace
* window (it is protected by the same-host liveness check until then); 10 minutes is far longer than
* any real sub-second capability fs critical section.
*/
const LOCK_DEADMAN_MS = 600_000;
/**
* The lockfile body is UNTRUSTED content. A well-formed lock body is a tiny JSON object. The body is
* read via the shared fd-based bounded reader (open → fstat → require a REGULAR file → enforce this
* size cap → read exactly size). A non-regular/oversized body is treated as UNPARSEABLE → routed to the
* deadman policy (cannot verify liveness → steal only after the deadman). 64 KiB is orders of magnitude
* larger than any legitimate lock body.
*/
const LOCK_MAX_BODY_BYTES = 64 * 1024;
/**
* DEFAULT bounded steal/retry attempts so a pathological never-acquirable lock cannot recurse forever.
* A caller may raise it (the consent store passes a larger budget — two genuinely-racing same-machine
* consent writers must SERIALIZE, not fail, before the lock-acquire-failure throw kicks in #1459
* finding 3). The lifecycle's sub-second critical section is happy with the small default.
*/
const LOCK_MAX_ATTEMPTS = 8;
const LOCK_RETRY_BACKOFF_MS = 25;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A held lock: the lockfile path, the unique OWNER TOKEN we wrote into it, and the (dev, ino) of the
* lockfile inode captured at acquire. releaseLock re-confirms BOTH the token AND the captured dev/ino
* still match the path on disk immediately before rmSync, so a successor lock that replaced ours at the
* same path (different inode) is never deleted. dev/ino are null when the post-create stat could not be
* taken (best-effort) — then release falls back to the token check alone.
*/
interface LockHandle { path: string; token: string; dev: number | null; ino: number | null; }
/**
* Parsed view of a lockfile body. `hostname` is null for a legacy lock (no hostname recorded) — treated
* as SAME-host (conservative, backward compatible). `startTime` is the holder process's recorded
* start-time; null for a legacy lock or one whose body did not record it — a null recorded start-time
* cannot be matched, so liveness cannot be verified and the holder is treated as NOT verified-live.
*/
interface ParsedLock { pid: number | null; hostname: string | null; startTime: string | null; ts: number | null; }
/** Per-body IDENTITY used to confirm the lock being stolen is still the same instance just before steal. */
interface LockIdentity { dev: number | null; ino: number | null; ts: number | null; }
// ---------------------------------------------------------------------------
// Tokens + backoff
// ---------------------------------------------------------------------------
let _lockSeq = 0;
/**
* A per-acquire unique token so release is owner-safe (never deletes a successor's lock). The FIRST
* `-`-delimited segment is the holder PID — acquireLock parses it back out to check liveness before
* stealing a stale lock.
*/
function newLockToken(): string {
return `${process.pid}-${Date.now()}-${++_lockSeq}`;
}
let _lockSleepBuf: Int32Array | null = null;
function lockBackoff(): void {
// Small jittered backoff between steal attempts (yields the thread via Atomics.wait).
if (_lockSleepBuf === null) _lockSleepBuf = new Int32Array(new SharedArrayBuffer(4));
const jitter = Math.floor(Math.random() * LOCK_RETRY_BACKOFF_MS);
Atomics.wait(_lockSleepBuf, 0, 0, LOCK_RETRY_BACKOFF_MS + jitter);
}
// ---------------------------------------------------------------------------
// Body parse / age / host
// ---------------------------------------------------------------------------
/**
* Parse the holder PID from a legacy plain-token lockfile body (the first `-`-delimited segment).
* Returns null when the body has no numeric leading segment (e.g. JSON content, or legacy no-pid).
*/
function lockHolderPid(body: string): number | null {
const seg = body.split('-')[0];
if (!/^\d+$/.test(seg)) return null;
const pid = Number(seg);
return Number.isInteger(pid) && pid > 0 ? pid : null;
}
/**
* Parse a lockfile body into { pid, hostname, startTime, ts }. The new format is JSON
* `{ token, pid, hostname, startTime, ts }`; a legacy body is a plain `pid-ts-seq` token (or
* non-numeric junk). Never throws — unparseable content yields all-null.
*
* `ts` is the body's OWN recorded timestamp. The age decision is bound to `now - ts` (a FRESH
* replacement body carries a FRESH ts → small age → not stolen), NOT to the file `mtime`. A legacy/
* no-`ts` body yields ts:null and the caller falls back to the file `mtime` age.
*/
function parseLockBody(body: string): ParsedLock {
const trimmed = body.trim();
if (trimmed.startsWith('{')) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const p = parsed as Record<string, unknown>;
const pidVal = p['pid'];
const pid = typeof pidVal === 'number' && Number.isInteger(pidVal) && pidVal > 0 ? pidVal : null;
const hostVal = p['hostname'];
const hostname = typeof hostVal === 'string' && hostVal ? hostVal : null;
const stVal = p['startTime'];
const startTime = typeof stVal === 'string' && stVal ? stVal : null;
const tsVal = p['ts'];
const ts = typeof tsVal === 'number' && Number.isFinite(tsVal) ? tsVal : null;
return { pid, hostname, startTime, ts };
}
} catch { /* fall through to legacy parse */ }
}
// Legacy plain-token body: hostname/startTime/ts were never recorded → null.
return { pid: lockHolderPid(trimmed), hostname: null, startTime: null, ts: null };
}
/**
* Derive the lock AGE (ms) from the body's own `ts` when trustworthy, else fall back to the file
* `mtime`. A `ts` is distrusted when it is in the FUTURE (planted body / clock-skewed writer): a
* trusted future `ts` would keep age <= LOCK_STALE_MS forever → permanent block. A future `mtime` is
* likewise distrusted past a half-stale-window jitter tolerance → MAX_SAFE_INTEGER so the lock routes
* into the normal steal decision tree (verified-live holders are still protected there).
*/
function lockAgeMs(ts: number | null, mtimeMs: number): number {
if (ts !== null) {
const age = Date.now() - ts;
if (age >= 0 && age <= Number.MAX_SAFE_INTEGER) return age;
}
const mtimeAge = Date.now() - mtimeMs;
if (mtimeAge >= 0) return mtimeAge;
return mtimeAge >= -(LOCK_STALE_MS / 2) ? 0 : Number.MAX_SAFE_INTEGER;
}
/** Is the parsed lock from THIS host? A null (legacy) hostname is treated as same-host. */
function isSameHost(parsed: ParsedLock): boolean {
return parsed.hostname === null || parsed.hostname === os.hostname();
}
// ---------------------------------------------------------------------------
// Process start-time (the pid-reuse discriminator)
// ---------------------------------------------------------------------------
/**
* Best-effort process start-time for `pid`, as an OPAQUE platform-specific string used ONLY for
* equality comparison (never parsed as a date). The pair (pid, startTime) uniquely identifies a
* process instance: even if a crashed holder's pid is REUSED, the new process's start-time differs.
* Returns null on ANY error / unobtainable value (liveness cannot be VERIFIED → steal-eligible past
* the deadman). The shell-outs only run on the rare STEAL-decision path, never the happy path.
*/
function getProcessStartTime(pid: number): string | null {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
if (process.platform === 'linux') {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
const rparen = stat.lastIndexOf(')');
if (rparen === -1) return null;
const rest = stat.slice(rparen + 1).trim().split(/\s+/);
const starttime = rest[19]; // overall field 22 → index 19 after comm.
return typeof starttime === 'string' && /^\d+$/.test(starttime) ? starttime : null;
}
if (process.platform === 'win32') {
const res = execTool(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.Ticks`],
{ timeout: 5_000 },
);
if (res.exitCode !== 0 || res.error) return null;
const out = res.stdout.trim();
return /^\d+$/.test(out) ? out : null;
}
const res = execTool('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 5_000 });
if (res.exitCode !== 0 || res.error) return null;
const out = res.stdout.trim();
return out ? out : null;
} catch {
return null;
}
}
/** THIS process's start-time, captured ONCE at module load so we never re-shell on every lock write. */
const _selfStartTime: string | null = getProcessStartTime(process.pid);
/** Serialize the lockfile body: JSON carrying the owner token, pid, hostname, cached start-time, ts. */
function lockFileBody(token: string): string {
return JSON.stringify({ token, pid: process.pid, hostname: os.hostname(), startTime: _selfStartTime, ts: Date.now() });
}
// ---------------------------------------------------------------------------
// Liveness probes (test seam)
// ---------------------------------------------------------------------------
/** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */
function _realIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true; // signalable → alive
} catch (err) {
// EPERM means the process exists but we cannot signal it (still ALIVE). ESRCH means it's gone.
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}
/**
* Test seams: the steal-decision path goes through these indirections so unit tests can mock liveness +
* process start-time DETERMINISTICALLY. The defaults are the real implementations.
*/
const _lockProbes: {
isPidAlive: (pid: number) => boolean;
getProcessStartTime: (pid: number) => string | null;
} = { isPidAlive: _realIsPidAlive, getProcessStartTime };
function isPidAlive(pid: number): boolean {
return _lockProbes.isPidAlive(pid);
}
/**
* Is the recorded SAME-host holder VERIFIED-LIVE? True ONLY when ALL hold: the pid signals alive AND
* the lock recorded a non-null start-time AND the pid's CURRENT observed start-time matches that
* recorded value. Any failure — dead pid, no recorded start-time, unobtainable current start-time, or a
* MISMATCH (= pid-reuse) — means NOT verified-live, so the holder may be stolen. This defeats pid-reuse
* WITHOUT ever stealing a genuinely-live holder.
*/
function holderVerifiedLive(parsed: ParsedLock): boolean {
if (parsed.pid === null) return false;
if (!isPidAlive(parsed.pid)) return false;
if (parsed.startTime === null) return false;
const observed = _lockProbes.getProcessStartTime(parsed.pid);
if (observed === null) return false;
return observed === parsed.startTime;
}
// ---------------------------------------------------------------------------
// Bounded body read + identity recheck
// ---------------------------------------------------------------------------
/**
* Parse the lockfile body via the SHARED fd-based bounded reader. The body is untrusted: a FIFO/device/
* oversized/garbage body returns all-null (routed to the deadman policy). Never throws.
*/
function readParsedLockBounded(lockPath: string): ParsedLock {
const allNull: ParsedLock = { pid: null, hostname: null, startTime: null, ts: null };
try {
const body = ledgerMod.readSmallRegularFile(lockPath, LOCK_MAX_BODY_BYTES);
if (body === null) return allNull; // vanished/missing — cannot verify anything.
return parseLockBody(body);
} catch {
return allNull; // non-regular / oversized / unreadable untrusted body → unparseable.
}
}
/**
* The per-body IDENTITY used to confirm, immediately before the atomic rename-steal, that the lock the
* acquirer decided to steal is STILL the same body instance. Binds (dev, ino) from a fresh stat AND the
* body's own `ts` (when JSON). A null on any field means we could not read it → caller treats it as
* "changed" and retries rather than stealing. Never throws.
*/
function lockIdentity(lockPath: string): LockIdentity {
let dev: number | null = null;
let ino: number | null = null;
try {
const st = fs.statSync(lockPath);
dev = typeof st.dev === 'number' ? st.dev : null;
ino = typeof st.ino === 'number' ? st.ino : null;
} catch {
return { dev: null, ino: null, ts: null }; // vanished/unstatable — treat as changed.
}
const ts = readParsedLockBounded(lockPath).ts;
return { dev, ino, ts };
}
/**
* Two lock identities refer to the SAME body instance only when dev AND ino match AND the `ts` is
* unchanged. A null dev/ino on EITHER side is a CHANGE (fail-safe: do not steal). If the DECISION body
* had a non-null JSON `ts`, the recheck body MUST carry the SAME non-null `ts` (a disappearing ts is a
* CHANGE → do not steal, retry).
*/
function sameLockInstance(a: LockIdentity, b: LockIdentity): boolean {
if (a.dev === null || a.ino === null || b.dev === null || b.ino === null) return false;
if (a.dev !== b.dev || a.ino !== b.ino) return false;
if (a.ts !== null && a.ts !== b.ts) return false;
return true;
}
/** Extract the owner token from a lockfile body (JSON `token` field), or null if not JSON/absent. */
function lockBodyToken(body: string): string | null {
const trimmed = body.trim();
if (!trimmed.startsWith('{')) return null;
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const t = (parsed as Record<string, unknown>)['token'];
return typeof t === 'string' ? t : null;
}
} catch { /* not JSON */ }
return null;
}
// ---------------------------------------------------------------------------
// Acquire / release
// ---------------------------------------------------------------------------
/**
* Acquire an exclusive lock at `lockPath` (a single lockfile created with O_EXCL), stamping a JSON body
* that records a unique owner token, our PID, our HOSTNAME, our process START-TIME, and a timestamp.
* The containing directory is mkdir'd (recursive, best-effort). Returns a LockHandle on success, or
* null if another LIVE operation holds it / the attempt budget is exhausted.
*
* `opts.maxAttempts` raises the bounded steal/retry budget (default LOCK_MAX_ATTEMPTS) so a caller with
* legitimately-contended writers (the consent store) can SERIALIZE rather than fail under brief
* contention. The budget is always bounded — no unbounded recursion.
*
* `opts.waitForFresh` (consent store) changes the BLOCKED-held disposition: when a held lock is NOT
* steal-eligible (fresh under the stale window, a verified-live same-host holder, or an unverifiable
* holder under the deadman), the DEFAULT (lifecycle) returns null IMMEDIATELY (fail-fast — the caller
* does not retry). With waitForFresh the acquirer instead BACKS OFF AND RETRIES (within the bounded
* budget) so two genuinely-racing same-machine writers SERIALIZE — the loser waits for the holder to
* release its sub-ms critical section and then wins the O_EXCL create. It still returns null once the
* budget is exhausted (then #1459 finding 3 turns that into a throw rather than an unlocked write). This
* NEVER steals a non-steal-eligible holder — it only WAITS for it; the steal protocol is unchanged.
*
* The steal itself is atomic (rename-then-recreate, so only ONE racing process can rename the inode),
* and the whole thing is a BOUNDED iterative loop.
*/
function acquireLock(lockPath: string, opts?: { maxAttempts?: number; waitForFresh?: boolean }): LockHandle | null {
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* best-effort */ }
const maxAttempts = (opts && Number.isInteger(opts.maxAttempts) && (opts.maxAttempts as number) > 0)
? (opts.maxAttempts as number)
: LOCK_MAX_ATTEMPTS;
const waitForFresh = !!(opts && opts.waitForFresh);
// A held lock that is NOT steal-eligible: fail-fast (return null) by default, or BACK OFF + RETRY
// (continue) when waitForFresh and a retry budget remains — so a contended consent writer serializes.
const blocked = (attempt: number): LockHandle | null | 'retry' => {
if (waitForFresh && attempt + 1 < maxAttempts) { lockBackoff(); return 'retry'; }
return null;
};
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const token = newLockToken();
try {
const fd = fs.openSync(lockPath, 'wx'); // exclusive create — fails if held
// Once the exclusive create SUCCEEDS, a writeSync/closeSync failure must NOT leave the empty
// lockfile behind — an orphan body self-blocks every later acquirer until the deadman. On any
// write/close error, best-effort unlink the file we just created and bail. fs.writeFileSync(fd, …)
// flushes the WHOLE buffer (no short-write) unlike a bare fs.writeSync.
try {
fs.writeFileSync(fd, lockFileBody(token));
} catch (writeErr) {
try { fs.closeSync(fd); } catch { /* best-effort */ }
try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ }
throw writeErr;
}
try {
fs.closeSync(fd);
} catch (closeErr) {
try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ }
throw closeErr;
}
// Capture the lock inode's (dev, ino) so releaseLock can confirm, immediately before rmSync, that
// the path still holds OUR inode. Best-effort: a null dev/ino just falls back to the token check.
let dev: number | null = null;
let ino: number | null = null;
try {
const lst = fs.statSync(lockPath);
dev = typeof lst.dev === 'number' ? lst.dev : null;
ino = typeof lst.ino === 'number' ? lst.ino : null;
} catch { /* best-effort — release falls back to the token check alone */ }
return { path: lockPath, token, dev, ino };
} catch (err) {
// EEXIST → held (fall through to the steal decision). Any other error here is the create failing
// for a real reason OR a write/close failure we already cleaned up → bail out.
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return null;
}
// Held — decide whether to steal.
let st: fs.Stats;
try {
st = fs.statSync(lockPath);
} catch {
continue; // lock vanished between open and stat — retry the create immediately.
}
// Bind the age decision to the SAME body instance we act on. Parse the (bounded) body ONCE; derive
// age from the body's own `ts` for a JSON body so a FRESH replacement (fresh ts) is seen as fresh
// even if the file `mtime` is stale-old. A legacy/garbage/no-`ts` body — and a FUTURE/implausible
// `ts` — falls back to the file `mtime` age so a planted/clock-skewed future ts can never deadlock.
const parsed = readParsedLockBounded(lockPath);
const age = lockAgeMs(parsed.ts, st.mtimeMs);
if (age <= LOCK_STALE_MS) { // genuinely held (fresh) — blocked.
const b = blocked(attempt);
if (b === 'retry') continue;
return b;
}
const decisionIdentity: LockIdentity = {
dev: typeof st.dev === 'number' ? st.dev : null,
ino: typeof st.ino === 'number' ? st.ino : null,
ts: parsed.ts,
};
if (isSameHost(parsed) && parsed.pid !== null) {
// SAME host with a parseable pid → we CAN verify liveness via the (pid, start-time) pair. A
// VERIFIED-LIVE holder is NEVER stolen — even past the deadman. Otherwise → steal.
if (holderVerifiedLive(parsed)) { // provably-live same-host holder — blocked.
const b = blocked(attempt);
if (b === 'retry') continue;
return b;
}
// else fall through to the atomic steal.
} else {
// DIFFERENT host, or no parseable pid → liveness cannot be verified locally. Only the deadman can
// reclaim it; under the deadman, leave it (blocked).
if (age <= LOCK_DEADMAN_MS) {
const b = blocked(attempt);
if (b === 'retry') continue;
return b;
}
// else (age > deadman) → fall through to the atomic steal.
}
// Re-stat + re-read the body IMMEDIATELY before the rename and confirm it is the SAME instance
// (dev/ino unchanged AND, for a JSON body, ts unchanged). If a racer stole+recreated a FRESH lock
// between our decision and now, the identity differs → do NOT steal; RETRY the bounded loop.
if (!sameLockInstance(decisionIdentity, lockIdentity(lockPath))) {
if (attempt + 1 < maxAttempts) lockBackoff();
continue; // the body changed under us — re-evaluate from scratch rather than steal a replacement.
}
// Steal atomically (only one racer can rename the inode).
const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
try { fs.renameSync(lockPath, stolen); } catch { return null; } // another process won the steal
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
if (attempt + 1 < maxAttempts) lockBackoff();
}
return null; // attempt budget exhausted (pathological contention) — never throws/recurses.
}
/**
* Release a lock only if it still carries our owner token (PRIMARY discriminator) — and, as a best-
* effort SECONDARY check, if its inode still matches the (dev, ino) we captured at acquire, so the
* common path never deletes a lock that was stale-stolen out from under us.
*
* The TOKEN re-check is the load-bearing protection: a real successor wrote a DIFFERENT token, so we
* read a non-matching token and refuse to delete on every filesystem. The dev/ino recheck is best-
* effort secondary hardening (may be defeated by inode reuse on some filesystems). The body is read via
* the bounded reader so a FIFO/oversized body at the path is never read or deleted by us.
*/
function releaseLock(handle: LockHandle | null): void {
if (!handle) return;
try {
let body: string | null;
try {
body = ledgerMod.readSmallRegularFile(handle.path, LOCK_MAX_BODY_BYTES);
} catch {
return; // non-regular / oversized / unreadable → not ours; do not read or delete.
}
if (body === null) return; // gone / missing — nothing of ours to release.
// The body is JSON `{ token, … }`; release only if the recorded token is still OURS. A legacy
// plain-token body (whole body === token) is also honored.
if (lockBodyToken(body) !== handle.token && body !== handle.token) return; // not our token (PRIMARY).
if (handle.dev !== null && handle.ino !== null) {
let cur: fs.Stats;
try {
cur = fs.statSync(handle.path);
} catch {
return; // vanished/unstatable between read and rmSync → nothing of ours to release.
}
if (cur.dev !== handle.dev || cur.ino !== handle.ino) return; // successor inode — not ours.
}
fs.rmSync(handle.path, { force: true });
} catch { /* already gone / stale-stolen / unreadable — nothing of ours to release */ }
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export = {
acquireLock,
releaseLock,
getProcessStartTime,
LOCK_STALE_MS,
LOCK_DEADMAN_MS,
LOCK_MAX_BODY_BYTES,
// Test seams (shared by capability-lifecycle's #1462 lock tests via re-export): inject deterministic
// isPidAlive / getProcessStartTime so the start-time liveness branches are exercised without real pids.
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>): void {
if (typeof probes.isPidAlive === 'function') _lockProbes.isPidAlive = probes.isPidAlive;
if (typeof probes.getProcessStartTime === 'function') _lockProbes.getProcessStartTime = probes.getProcessStartTime;
},
_resetLockProbes(): void {
_lockProbes.isPidAlive = _realIsPidAlive;
_lockProbes.getProcessStartTime = getProcessStartTime;
},
};

View File

@@ -483,7 +483,10 @@ function resolveCapabilityRuntimeState(
// reflected in installed/surfaced state exactly like first-party capabilities.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
const registry = loadRegistry({ includeInstalled: true, cwd });
// #1459 IC-04: thread the consent home (process.env.GSD_HOME) EXPLICITLY so the overlay's global root
// and the project-scope consent lookup resolve to the SAME user-owned home this consumer sees — a
// legitimately-consented project cap then reports ACTIVE here (not falsely inactive at the wrong home).
const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
// ── Resolve installed skills (from install profile) ──────────────────────────
// Distinguish "no profile marker → default full" (legitimate) from a thrown

View File

@@ -63,14 +63,65 @@ interface HookSurface {
interface CommandModuleSurface {
family: string;
module: string;
/**
* TRUST2-3 (#1459): the exported function the host invokes from the module — WHICH code runs. A
* version that keeps family+module but retargets `router` to a different exported function changes
* what executes, so it is part of the disclosed + consent-bound surface. Empty when undeclared.
*/
router: string;
}
interface McpServerSurface {
name: string;
/** The command the server spawns (the actual executable — disclosed for honest consent). */
/**
* The transport TYPE: 'stdio' (spawns command/argv), 'http', or 'sse' (connects to a URL). TRUST2-2
* (#1459): a non-stdio server was previously invisible to the disclosure/signature — its url/headers
* could be swapped with no re-consent. Empty when undeclared (the host default is stdio).
*/
transport: string;
/** The command the server spawns (the actual executable — disclosed for honest consent). stdio only. */
command: string;
/** Arguments passed to the command. */
/**
* Arguments passed to the command. TRUST2-4 (#1459): this is a stringified view for the human
* summary; the consent SIGNATURE encodes the RAW args array (incl non-string members) via
* `rawArgs` so a non-string arg change still forces re-consent (the host receives the raw args).
*/
argv: string[];
/**
* The RAW args array as declared (may contain non-strings). Folded — stable-encoded — into the
* signature so a change to ANY member (incl a number/object/bool the host would still pass) forces
* re-consent (TRUST2-4). Empty array when none declared.
*/
rawArgs: unknown[];
/**
* The URL an http/sse server connects to — TRUST2-2: WHERE the server talks to. A url change is a
* different remote endpoint and must force re-consent. Empty when undeclared (stdio servers).
*/
url: string;
/**
* The HTTP headers an http/sse server is given (string→string, stable-sorted) — TRUST2-2: headers
* carry auth/behavior and a change must force re-consent. Header VALUES are redacted in the human
* summary but INCLUDED in the signature. Empty object when none.
*/
headers: Record<string, string>;
/**
* Environment variables (string→string only) the server is spawned with — disclosed because
* env can change WHAT a command does (e.g. NODE_OPTIONS=--require /tmp/evil.js) without touching
* command/argv. Any add/change forces re-consent (TRUST-2, #1459). Empty object when none.
*/
env: Record<string, string>;
/** The working directory the server is spawned in (if declared) — also affects what runs. */
cwd?: string;
/**
* Finding 5 (MEDIUM, #1459): the FULL declared server config object (prototype-pollution-safe
* shallow-cleaned copy). The writer persists the WHOLE config ({...config}), so the signature must
* bind the WHOLE config — not only the whitelisted fields above — or an upgrade that changes a
* host-honored field NOT in the whitelist (a future `envFile`/`workingDir`/launch option) would be
* written verbatim yet leave the signature constant → no re-consent prompt. This is folded into the
* signature as STABLE (recursively key-sorted) JSON, so any add/change forces re-consent while a
* pure key reorder does not. NOT shown in the human summary (which stays readable via the key fields).
*/
rawConfig: Record<string, unknown>;
}
interface Disclosure {
@@ -184,8 +235,10 @@ function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: st
const rec = c as Record<string, unknown>;
const moduleName = asString(rec['module']);
const family = asString(rec['family']);
// TRUST2-3 (#1459): capture the router (which exported fn runs) so retargeting it forces re-consent.
const router = asString(rec['router']);
if (moduleName) {
commandModules.push({ family, module: moduleName });
commandModules.push({ family, module: moduleName, router });
if (stagedDir && !artifactExists(stagedDir, moduleName)) {
missingArtifacts.push(moduleName);
}
@@ -201,8 +254,51 @@ function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: st
if (!name) return;
const cfg = (typeof config === 'object' && config !== null) ? (config as Record<string, unknown>) : {};
const command = asString(cfg['command']);
const argv = Array.isArray(cfg['args']) ? cfg['args'].filter((a): a is string => typeof a === 'string') : [];
mcpServers.push({ name, command, argv });
// TRUST2-4 (#1459): the RAW args array (incl non-string members) is what the host receives, so it
// is folded — stable-encoded — into the signature. `argv` is the string-filtered view for the
// human summary; `rawArgs` is the full declared array bound into the signature.
const rawArgs = Array.isArray(cfg['args']) ? (cfg['args'] as unknown[]) : [];
const argv = rawArgs.filter((a): a is string => typeof a === 'string');
// TRUST2-2 (#1459): a non-stdio MCP server ({ type|transport, url, headers }) was previously
// invisible to the disclosure/signature. Capture the transport TYPE, the URL, and the HEADERS
// (string→string, prototype-pollution-safe) so a swapped endpoint or header forces re-consent.
const transport = asString(cfg['type']) || asString(cfg['transport']);
const url = asString(cfg['url']);
const headers: Record<string, string> = {};
const rawHeaders = cfg['headers'];
if (rawHeaders && typeof rawHeaders === 'object' && !Array.isArray(rawHeaders)) {
for (const [k, v] of Object.entries(rawHeaders as Record<string, unknown>)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v === 'string') headers[k] = v;
}
}
// TRUST-2 (#1459): env can change WHAT a command does without touching command/argv, so it is
// part of the disclosed (and consent-bound) surface. Filter to string→string entries only —
// a non-string env value cannot be exported as a real environment variable, and including it
// would make the signature depend on un-runnable junk. Prototype-pollution-safe: copy only
// own enumerable string keys, never __proto__/constructor/prototype.
const env: Record<string, string> = {};
const rawEnv = cfg['env'];
if (rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)) {
for (const [k, v] of Object.entries(rawEnv as Record<string, unknown>)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v === 'string') env[k] = v;
}
}
const cwd = asString(cfg['cwd']);
// Finding 5 (MEDIUM, #1459): capture the FULL config (every declared field the writer persists),
// not just the whitelisted ones. Prototype-pollution-safe: copy only own enumerable keys and
// never the dangerous keys. The CAP_MARKER the writer stamps on persist (`_gsdCapability`) is the
// capability id (constant per cap), so it does not perturb the signature; we copy config as
// DECLARED here (pre-stamp) and the writer adds the marker at write time.
const rawConfig: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cfg)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
rawConfig[k] = v;
}
const surface: McpServerSurface = { name, transport, command, argv, rawArgs, url, headers, env, rawConfig };
if (cwd) surface.cwd = cwd;
mcpServers.push(surface);
};
if (Array.isArray(manifest.mcpServers)) {
for (const s of manifest.mcpServers) {
@@ -459,13 +555,58 @@ function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
// Executable-set change detection (auto-update re-prompt trigger)
// ---------------------------------------------------------------------------
/**
* Serialize a value to JSON with object keys RECURSIVELY SORTED, so the result is stable under key
* reordering. Used to fold an MCP server's `env` map into the disclosure signature: ADDING or
* CHANGING any env entry changes the signature (forces re-consent), but merely REORDERING the keys
* does NOT (no false re-prompt). TRUST-2 (#1459).
*/
function stableJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
}
function disclosureSignature(d: Disclosure): string {
const hooks = d.hooks.map((h) => `hook:${h.event}:${h.script}`).sort();
const mods = d.commandModules.map((m) => `mod:${m.family}:${m.module}`).sort();
// Include the command + argv so a version that keeps the server NAME but swaps the executable
// it runs is detected as a changed surface (forces re-consent). argv is JSON-encoded (not
// space-joined) so an argument-boundary change (['a b'] vs ['a','b']) is still a change.
const mcp = d.mcpServers.map((s) => `mcp:${s.name}:${s.command}:${JSON.stringify(s.argv)}`).sort();
// TRUST2-1 (#1459): build EVERY surface line via stableJson of an ARRAY of its components, so each
// component is encoded — a `:`-delimited concatenation let a delimiter inside a component (e.g. an
// mcp name `x:a` vs command `b`) collide with a different decomposition. JSON-encoding every
// component makes each line an injective function of its components (no delimiter injection).
const hooks = d.hooks.map((h) => stableJson(['hook', h.event, h.script])).sort();
// TRUST2-3: include the router (which exported fn runs) so retargeting it forces re-consent.
const mods = d.commandModules.map((m) => stableJson(['mod', m.family, m.module, m.router || ''])).sort();
// Include transport + command + RAW args + url + headers + env + cwd + the FULL declared config so a
// version that:
// - swaps the stdio executable it runs (command/args), OR
// - changes the env it runs with (e.g. NODE_OPTIONS=--require evil.js), OR
// - changes the cwd it runs in, OR
// - (TRUST2-2) swaps the transport/url/headers of a non-stdio (http/sse) server, OR
// - (TRUST2-4) changes a NON-STRING arg the host still receives, OR
// - (finding 5) changes ANY OTHER declared field the writer persists (a future envFile/workingDir/
// launch option NOT in the explicit whitelist above)
// is detected as a changed surface (forces re-consent). The explicit fields are kept FIRST for
// readability/stability; `rawConfig` is the completeness backstop. All are STABLE-encoded (recursively
// key-sorted JSON) so any add/change forces re-consent while a pure key reorder does NOT (no false
// re-prompt).
const mcp = d.mcpServers
.map((s) =>
stableJson([
'mcp',
s.name,
s.transport || '',
s.command,
s.rawArgs || [],
s.url || '',
s.headers || {},
s.env || {},
s.cwd || '',
// Finding 5: the FULL declared config — completeness so any persisted field change re-consents.
s.rawConfig || {},
]),
)
.sort();
return JSON.stringify([hooks, mods, mcp]);
}
@@ -477,10 +618,31 @@ function executableSetChanged(oldD: Disclosure, newD: Disclosure): boolean {
return disclosureSignature(oldD) !== disclosureSignature(newD);
}
/**
* THE single source of truth for the consent-binding signature of a capability manifest: run
* `discloseExecutableSurfaces` then `disclosureSignature`. Both the loader (which checks whether a
* previously-consented project cap still matches) and the lifecycle (which records the consent)
* compute the binding through THIS helper so they can never drift. `stagedDir` is forwarded for
* artifact existence-checking; the signature itself is over the executable SET (hooks/mods/mcp incl.
* env/cwd), not the missingArtifacts list, so it is a stable key regardless of the stagedDir.
*/
function signatureForManifest(manifest: CapabilityManifest, stagedDir?: string): string {
return disclosureSignature(discloseExecutableSurfaces(manifest, stagedDir));
}
// ---------------------------------------------------------------------------
// Human-readable consent prompt
// ---------------------------------------------------------------------------
/** Max characters of an env VALUE shown in the human consent prompt before it is truncated. */
const ENV_VALUE_MAX = 60;
/** Truncate a long env value for the human prompt (the full value is still in the signature). */
function truncateEnvValue(v: string): string {
if (typeof v !== 'string') return '';
return v.length > ENV_VALUE_MAX ? `${v.slice(0, ENV_VALUE_MAX)}… (${v.length} chars)` : v;
}
/**
* Render a disclosure as consent-prompt lines. Returned as an array so the CLI/runtime edge can
* format it; the lib never writes to stdout.
@@ -503,14 +665,37 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
` command modules (${disclosure.commandModules.length}): require()'d into the GSD CLI process`,
);
for (const m of disclosure.commandModules) {
lines.push(` - ${m.family || '(family?)'} -> ${m.module}`);
// TRUST2-3 (#1459): show the router (which exported fn runs) so the user consents to the exact entry point.
const routerSuffix = m.router ? ` [router: ${m.router}]` : '';
lines.push(` - ${m.family || '(family?)'} -> ${m.module}${routerSuffix}`);
}
}
if (disclosure.mcpServers.length > 0) {
lines.push(` MCP servers (${disclosure.mcpServers.length}): spawned by the host runtime`);
lines.push(` MCP servers (${disclosure.mcpServers.length}): spawned/connected by the host runtime`);
for (const s of disclosure.mcpServers) {
const cmd = [s.command, ...s.argv].filter(Boolean).join(' ');
lines.push(` - ${s.name} -> ${cmd || '(no command declared)'}`);
// TRUST2-2 (#1459): a non-stdio (http/sse) server connects to a URL; disclose the endpoint, not
// a (nonexistent) command. A stdio server discloses command + args as before.
const isRemote = (s.transport === 'http' || s.transport === 'sse') || (!s.command && !!s.url);
if (isRemote) {
const t = s.transport || 'http';
lines.push(` - ${s.name} -> [${t}] ${s.url || '(no url declared)'}`);
// Header VALUES are redacted in the human summary (they may carry secrets); only the KEY set
// is shown. The full values ARE in the signature, so a value change forces re-consent.
const hdrKeys = s.headers ? Object.keys(s.headers) : [];
if (hdrKeys.length > 0) {
lines.push(` headers: ${hdrKeys.map((k) => `${k}=<redacted>`).join(', ')}`);
}
} else {
const cmd = [s.command, ...s.argv].filter(Boolean).join(' ');
lines.push(` - ${s.name} -> ${cmd || '(no command declared)'}`);
}
// TRUST-2 (#1459): env can change WHAT runs without touching the command, so show each env key
// and its (truncated) value — the user is consenting to this exact environment.
const envKeys = s.env ? Object.keys(s.env) : [];
if (envKeys.length > 0) {
lines.push(` env: ${envKeys.map((k) => `${k}=${truncateEnvValue(s.env[k])}`).join(', ')}`);
}
if (s.cwd) lines.push(` cwd: ${s.cwd}`);
}
}
if (disclosure.missingArtifacts.length > 0) {
@@ -535,4 +720,7 @@ export = {
evaluateInstallTrust,
executableSetChanged,
summarizeDisclosure,
// #1459: the consent-binding signature (single source of truth for loader + lifecycle consent).
disclosureSignature,
signatureForManifest,
};

View File

@@ -367,7 +367,9 @@ function _federatedConfigSchema(cwd?: string): Record<string, unknown> | undefin
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
// #1459 IC-04: thread the consent home explicitly so a consented project cap's federated config
// key resolves at the SAME user-owned home that gated its activation (never the wrong home).
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
if (schema && typeof schema === 'object') return schema;
} catch { /* fall back to first-party */ }
}

View File

@@ -37,7 +37,9 @@ function _capabilityConfigSchema(cwd?: string): Record<string, unknown> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
// #1459 IC-04: thread the consent home explicitly so a consented project cap's config key
// federates at the SAME user-owned home that gated its activation.
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
if (schema && typeof schema === 'object') return schema;
} catch { /* fall back to first-party */ }
}

View File

@@ -496,7 +496,9 @@ function cmdLoopRenderHooks(
// capabilities are visible to loop rendering exactly like first-party ones.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
const registry = loadRegistry({ includeInstalled: true, cwd });
// #1459 IC-04: thread the consent home (process.env.GSD_HOME) EXPLICITLY so a consented project cap's
// loop surfaces (steps/gates/contributions) render here at the SAME home that gated its activation.
const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
for (const cap of state.capabilities || []) {
capabilityStatesById.set(cap.id, cap);

View File

@@ -139,3 +139,25 @@ export function findProjectRoot(startDir: string): string {
return startDir;
}
/**
* #1459 (IC-01 / CB-4): THE single canonical derivation of the PROJECT ROOT used to bind/lookup a
* project-scope consent record. Install (the CLI/lifecycle RECORD site), the loader (the LOOKUP
* site), and `trust revoke` (CB-4) MUST all derive the consent root through this one helper so the
* recorded key always matches the looked-up key — otherwise installing from a SUBDIR records consent
* at `realpath(subdir)` while the loader looks it up at `realpath(findProjectRoot)` and the freshly
* installed cap is immediately INACTIVE (install-then-inactive).
*
* The rule: `realpath(findProjectRoot(cwd))` (findProjectRoot is total — it returns `cwd` itself when
* no project root is found, so there is no null branch), falling back to `path.resolve(cwd)` when the
* resolved root cannot be realpath'd (e.g. it does not exist yet). The consent store realpaths
* whatever it is given, so passing the SAME logical root from every site is what guarantees the match.
*/
export function consentProjectRoot(cwd: string): string {
const root = findProjectRoot(cwd);
try {
return fs.realpathSync(root);
} catch {
return path.resolve(root);
}
}