fix(#1459): user-owned consent store gates third-party capability activation; env/cwd in disclosure; loader validator parity (#1473)
This commit is contained in:
824
src/capability-consent.cts
Normal file
824
src/capability-consent.cts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
561
src/capability-lock.cts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user