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

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

View File

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