Files
msd-core/src/capability-lifecycle.cts
Tom Boucher 9e4f0e99ad fix(#3631): exclude only __pycache__-resident bytecode from the consent digest (#3650)
* test(3631): failing-first coverage for bytecode-cache in the consent hash

bundleContentHash digests a walk with no exclusion, so a routine 'python3 -m unittest'
inside a Python-backed capability bundle writes __pycache__ under the bundle, the
recomputed hash stops matching the consent record, and the capability silently goes
inactive — no error, no warning, and loop render-hooks then omits its step and gate.

Two distinct triggers, and the second is the sharper one: collectBundleEntries pushes a
{kind:'dir'} entry for EVERY directory and the digest emits a TAG_DIR marker for it, so an
EMPTY __pycache__/ flips the hash before a single .pyc is written. A fix filtering only
*.pyc would leave that live. Verified by execution against the built lib: 5 of 7 probe
rows diverge from intent today, including the empty-directory row.

The anti-regression rows are the point of the shape: editing a real scripts/m.py and
adding node_modules/pkg/index.js must BOTH still change the hash. node_modules is
deliberately not excludable — its contents are required at runtime, so dropping it from
the digest would stop consent binding executable content. The symlink row pins ordering:
exclusion must apply after the lstat fail-closed rejection, never before.

Refs #3631

* fix(3631): exclude derived bytecode caches from the consent digest

RED proven at e5ba8f1fe on the remote runner: 8 failures, exactly the rows predicted to
fail, with the four anti-regression rows already green.

collectBundleEntries now skips a hardcoded, gitignore-independent set from the DIGEST:
basenames __pycache__, .pytest_cache, .DS_Store, and any .pyc/.pyo file. Matching is
byte-exact on the raw Buffer name (the walk never utf8-decodes) and case-sensitive, so the
digest does not vary with how a name happens to be spelled on a case-insensitive volume.

Three properties were preserved deliberately, each pinned by a test:

  - The filter runs AFTER the lstat symlink/non-regular fail-closed rejection. Filtering
    first would have turned the exclusion into a way to smuggle a symlink past the check;
    a symlink named x.pyc still throws.
  - Excluded entries still count toward BUNDLE_MAX_FILES and BUNDLE_MAX_TOTAL_BYTES. The
    caps guard the WALK; the digest answers a different question, and exclusion must not
    become an unbounded-bytes hole.
  - An excluded DIRECTORY is neither emitted as a TAG_DIR marker nor recursed into. The
    directory marker was the sharper half of this bug: an empty __pycache__ flipped the
    hash before any .pyc existed, so a *.pyc-only filter would have left it live.

The issue proposed either a gitignore-aware walk or a list including node_modules. Both
are rejected. A consent binding must not delegate its scope to a .gitignore the bundle
author does not control — one line there would drop arbitrary executable content out of
the hash. And node_modules holds code that is required at runtime; excluding it would stop
consent binding executable content, turning a usability bug into a supply-chain hole. What
makes __pycache__ different is that CPython validates each .pyc against its sibling
source, which remains hashed, so a real code change still invalidates consent.

Docs: CONTEXT.md's 'EVERY regular file AND directory' claim is corrected in place.
ADR-2363's residual-gap section said the walk had 'no exclusions' — per
docs/adr/README.md ('ADRs are append-only') that is corrected by a dated amendment rather
than an in-place edit. Its D4 argument is unaffected: skill bodies are .md and stay bound.

Fixes #3631

* fix(3631): narrow the digest exclusion after two isolated security reviews

The first cut of this fix passed the full suite and was still wrong. Both orthogonal
reviews rejected it, and the second one found a hole that has nothing to do with Python.

HIGH — an excluded DIRECTORY was 'continue'd before recursion, so its whole subtree was
permanently outside the digest. Declared hook script paths allow '_', '.' and '/' with no
directory or extension rule, so hooks:[{script:'__pycache__/run.js'}] installed, executed
via node, and its bytes could be rewritten forever without moving the hash. Ship benign
v1, collect consent, then own the machine. No Python involved.

FALSE RATIONALE — the justification I wrote into the code, CONTEXT.md, the ADR amendment
and the changeset claimed CPython validates a cached .pyc against its sibling source, so
the source staying hashed kept consent honest. That is not true, and I proved it by
execution rather than argument: default timestamp invalidation compares only the source's
mtime and size, both settable by anyone who can write the bundle. A forged pyc ran while
the .py was byte-identical.

Also wrong: '*.pyc' matched anywhere, but a legacy sourceless scripts/x.pyc IS importable,
so excluding it was a live vector.

Narrowed to what is actually defensible:
  - a DIRECTORY named __pycache__/.pytest_cache has only its TAG_DIR marker suppressed;
    the walk still recurses and hashes every non-excluded child.
  - .pyc/.pyo are excluded ONLY when the parent basename is exactly __pycache__.
  - a regular FILE named __pycache__, and a DIRECTORY named x.pyc, stay bound.
  - declared hook paths containing a __pycache__/.pytest_cache segment or a .pyc/.pyo
    basename are now rejected in both validator copies — a file named .pyc can contain
    perfectly valid JavaScript, so the exclusion must not be reachable from a declared
    surface.

Accepted residual risk, stated plainly in ADR-2363 and CONTEXT.md instead of explained
away: a forged __pycache__/mod.pyc matching an unmodified, still-hashed mod.py executes
without moving the digest. Before this change that write was detected. It is accepted to
stop routine bytecode caching from silently deactivating capabilities, and it is bounded —
the attacker needs post-consent write access, everything outside __pycache__/*.pyc stays
hashed, and no declared surface can point into the excluded space.

Known limitation, not papered over: .pytest_cache CONTENTS still move the digest. Only the
directory marker is suppressed. Excluding that subtree would reopen the HIGH finding.

Refs #3631

* fix(3631): drop the .DS_Store exclusion and pin what the caps actually bind

Second round of isolated review findings. The hardening closed the two original holes —
both re-reviews confirmed that by execution — but it introduced a new one of the same
shape, and left three claims unbacked.

HIGH, self-inflicted: .DS_Store was excluded from the digest at any depth, but the hook
path validator was hardened only for __pycache__/.pytest_cache/.pyc/.pyo. So
script:'hooks/.DS_Store' was ACCEPTED, runnableHookCommand emits the bare quoted path for
a non-.js name (the branch .sh hooks already use), and capability-source copies it with
its mode bit intact. Ship it +x with a benign shebang, take consent, then rewrite it
forever — the digest never moves. Fixed by DELETING the .DS_Store exclusion rather than
teaching the validator about it: .DS_Store has nothing to do with this issue's Python
bytecode symptom, and an excluded filename is a permanently unhashed name. The narrower
the exclusion, the smaller the hole.

The residual-risk bound in ADR-2363 and CONTEXT.md claimed declared surfaces cannot reach
excluded space. That is false and is now stated correctly: node resolves an unregistered
extension through the default .js handler, so a hashed, consent-covered hooks/run.js that
requires '../__pycache__/mod.pyc' reaches it in one hop. The validator guard raises the
bar for DECLARED surfaces; it does not contain the risk. The two bounds that are real —
post-consent write access required, everything outside __pycache__/*.pyc still hashed —
are kept.

The BUNDLE_MAX_FILES boundary test had gone vacuous: it padded with root-level *.pyc,
which the hardening made non-excluded, so it no longer proved anything about excluded
entries while the ADR claimed the caps were test-pinned. It now pads __pycache__/f{i}.pyc,
with the arithmetic re-derived by execution (capability.json + the still-counted
__pycache__ dir + N). BUNDLE_MAX_TOTAL_BYTES had zero coverage at all and is now pinned by
a sparse 32 MiB __pycache__/big.pyc that must still trip the size cap — the test that
proves exclusion did not become an unbounded-bytes hole.

Added the parity assertion CLAUDE.md's Generative Fix Divergence rule requires for the two
isSafeHookScriptPath copies, and proved it can fail: mutating one BUILT copy to drop .pyo
made the parity check report the divergence. Also pinned semantics that were correct but
untested and would have survived mutation — __pycache__/sub/x.pyc stays hashed (the parent
resets to sub, which is the recursion threading itself), .pytest_cache/y.pyc stays hashed,
and .pyo in both directions, which was a free surviving mutant.

Changeset rewritten: it still described the rejected wholesale-exclusion semantics.

Refs #3631

* chore(3631): backfill changeset PR number (#3650)

---------

Co-authored-by: sim <sim@local>
2026-08-18 22:35:54 -04:00

1907 lines
97 KiB
TypeScript

/**
* Capability lifecycle orchestration — ADR-1244 Phase 4 (D5 trust enforcement + D6 upgrade).
*
* Composes the Phase-3 source resolver + ledger with the Phase-4 trust gate into the three
* mutating operations — install, upgrade, remove — plus a reconciliation sweep that recovers
* from a crash mid-upgrade. The LEDGER WRITE is the commit point for every operation: a crash
* before it leaves the prior state fully intact; a crash after it is a completed operation.
*
* Trust invariants enforced here (see docs/explanation/capability-trust-model.md):
* - install/upgrade never execute capability code (resolver stages copy-only; we only swap
* directories and edit JSON);
* - executable surfaces are disclosed and consent is required before anything is promoted
* (decline => nothing written);
* - integrity + engines.gsd are verified by the resolver BEFORE staging finalizes;
* - remove deletes exactly the ledger-recorded files and surgically strips exactly the
* capability-owned shared-config entries (marker-isolated), touching nothing the user owns.
*
* Imports: node:fs, node:path, ./capability-source.cjs, ./capability-ledger.cjs,
* ./capability-trust.cjs, ./shell-command-projection.cjs (platformWriteSync).
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
/* eslint-disable @typescript-eslint/no-require-imports */
const sourceMod = require('./capability-source.cjs') as {
resolveCapabilitySource: (
spec: string,
opts?: Record<string, unknown>,
) => Promise<{ id: string; version: string; stagedDir: string; integrity: string | null; source: string }>;
parseSpec: (spec: string) => { kind: string; raw: string; target: string; ref?: string };
// #1463 D6 "Update available?" per-source latest-version peek. NEVER throws — returns a status the
// `outdated` aggregation maps onto a record. The exec seam mirrors the resolver's execOverrides.
peekLatestVersion: (
source: string,
opts?: { execOverrides?: Record<string, unknown> },
) => { status: 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown'; version: string | null; reason?: string };
};
const ledgerMod = require('./capability-ledger.cjs') as {
readLedger: (runtimeDir: string) => LedgerFile | null;
readLedgerStrict: (runtimeDir: string) => LedgerFile | null;
writeLedger: (runtimeDir: string, ledger: LedgerFile) => void;
recordInstall: (runtimeDir: string, entry: LedgerEntry, opts?: { baseLedger?: LedgerFile | null }) => void;
removeEntry: (runtimeDir: string, capId: string) => boolean;
reconcile: (runtimeDir: string) => unknown;
isUnsafeCapabilityId: (id: unknown) => boolean;
CorruptLedgerError: new (message: string, ledgerPath: string) => Error & { ledgerPath: string };
LEDGER_FILE_NAME: string;
MAX_SHARED_FILES: number;
// Finding 2 (HIGH): the shared fd-based bounded reader. Returns the content, null for ENOENT, or
// THROWS for a non-regular (FIFO/device/dir) / oversized / IO-error file (fail closed).
readSmallRegularFile: (filePath: string, maxBytes: number) => string | null;
};
const trustMod = require('./capability-trust.cjs') as {
evaluateInstallTrust: (args: Record<string, unknown>) => InstallTrustVerdict;
discloseExecutableSurfaces: (manifest: Record<string, unknown>, stagedDir?: string) => Disclosure;
executableSetChanged: (a: Disclosure, b: Disclosure) => boolean;
evaluateSourceAllowed: (
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 consentMod = require('./capability-consent.cjs') as {
recordProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string; integrity: string; disclosureSignature: string; contentHash: string; reviewerHost?: 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, retryRenameSync } = require('./shell-command-projection.cjs') as {
platformWriteSync: (filePath: string, content: string) => void;
retryRenameSync: (fromPath: string, toPath: string) => void;
};
// #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
// and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
const semverMod = require('./semver-compare.cjs') as {
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
};
/* eslint-enable @typescript-eslint/no-require-imports */
// ---------------------------------------------------------------------------
// Types (mirrors of the Phase-3/4 module shapes we consume)
// ---------------------------------------------------------------------------
interface Disclosure {
hooks: Array<{ event: string; script: 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[];
}
interface InstallTrustVerdict {
allowed: boolean;
requiresConsent: boolean;
disclosure: Disclosure;
engines: { compatible: boolean; range: string | null; satisfiedBy: 'engines' | 'compatVersions' | 'unconstrained' | null; downgradeTo?: string };
blockReasons: string[];
}
interface LedgerEntry {
id: string;
version: string;
source: string;
integrity: string;
files: string[];
sharedEdits: Array<{ file: string; marker: string }>;
/**
* In-flight mutation intent. Written BEFORE the filesystem swap and cleared by the commit. Its
* presence — NOT a version comparison — is the authoritative "operation did not finish" signal
* for reconcileCapabilities (so a same-version malicious bundle cannot be mistaken for committed).
* kind 'install' — fresh install (no prior bundle); rollback REMOVES the half-installed entry.
* kind 'upgrade' — upgrade or reinstall over an existing bundle; rollback RESTORES the backup.
*/
_pending?: { kind: 'install' | 'upgrade'; backupName: string | null; sharedFiles: string[] };
}
interface LedgerFile {
version: string;
updatedAt: string;
entries: Record<string, LedgerEntry>;
}
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). */
consentGranted?: boolean;
/** Expected integrity (sha512-...) to verify against the fetched artifact. */
integrity?: string;
/** Shared config files (relative to runtimeDir) to write capability hooks/mcpServers into. */
sharedFiles?: string[];
/** Injectable exec overrides, threaded to the resolver for tests. */
execOverrides?: Record<string, unknown>;
/** Also delete CAPABILITY_DATA on remove (default false — data is preserved/prompted). */
removeData?: boolean;
/**
* When set, the resolved capability id MUST equal this or the operation is refused with NO writes.
* `gsd capability update <id>` passes the requested id so a source that has been retargeted or
* hand-edited to a different manifest id cannot silently act on (and overwrite) another capability.
*/
expectedId?: string;
/**
* Test seam: override the source resolver. Must honor promote:false semantics — return a
* staged dir (left on disk for the caller to promote/clean). Defaults to the real resolver.
*/
_resolve?: (
spec: string,
opts: Record<string, unknown>,
) => Promise<{ id: string; version: string; stagedDir: string; integrity: string | null; source: string }>;
}
// ---------------------------------------------------------------------------
// Constants + path helpers
// ---------------------------------------------------------------------------
/** Stamp written onto every capability-owned shared-config entry, for surgical removal. */
const CAP_MARKER = '_gsdCapability';
/**
* #3514 (epic #1900 F21c): what KIND of pin (if any) the source content was pinned with — shared
* by the install and upgrade verdicts so the two cannot drift. Reaching the verdict with a
* supplied `--integrity` pin means the pin VERIFIED (the resolver throws on mismatch). A git
* `#sha:<40-hex-commit>` ref is the git analog of a hash pin — a commit checkout, verified by
* spelling: `/^sha:[0-9a-f]{7,40}$/i` accepts only a hex commit id, so a mutable ref
* (`#sha:main`, `#sha:v1`) is NOT counted as a pin (isolated review finding — a moving ref must
* never render as pinned). Everything else stages with no pin and must say so in the consent
* prompt.
*/
type IntegrityPin = 'sha512' | 'git-commit' | 'none';
const GIT_SHA_PIN_RE = /^sha:[0-9a-f]{7,40}$/i;
function resolveIntegrityPin(
integrity: unknown,
parsed: { kind?: string; ref?: unknown },
): IntegrityPin {
if (typeof integrity === 'string' && integrity.length > 0) return 'sha512';
if (parsed.kind === 'git' && typeof parsed.ref === 'string' && GIT_SHA_PIN_RE.test(parsed.ref)) {
return 'git-commit';
}
return 'none';
}
/** Keys that must never be used as object indices (prototype-pollution guard). */
function isUnsafeKey(k: string): boolean {
return k === '__proto__' || k === 'constructor' || k === 'prototype';
}
function capabilitiesRoot(runtimeDir: string): string {
return path.join(runtimeDir, '.gsd', 'capabilities');
}
function capDir(runtimeDir: string, id: string): string {
return path.join(capabilitiesRoot(runtimeDir), id);
}
function capDataDir(runtimeDir: string, id: string): string {
return path.join(runtimeDir, '.gsd', 'capability-data', id);
}
/** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */
const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']);
/**
* fsync a DIRECTORY so a rename inside it is durable across a power loss (DUR-2/DUR-3). Some
* platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) — those are
* tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real storage error)
* is RETHROWN as a clear durability-uncertain error rather than silently swallowed; the rename may
* already be visible, so the caller must NOT claim success when durability could not be confirmed.
* The directory fd is always closed (finally).
*/
function fsyncDir(dirPath: string): void {
let fd: number | null = null;
try {
fd = fs.openSync(dirPath, 'r');
fs.fsyncSync(fd);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
// openSync itself failing (e.g. dir vanished) is also non-fatal best-effort UNLESS it's a real
// storage error; treat tolerated errnos (and a missing code) as best-effort, rethrow the rest.
if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
throw new Error(
`Directory fsync of "${dirPath}" failed (${code}); durability of the preceding rename ` +
`could NOT be confirmed: ${(err as Error).message}`,
);
}
/* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */
} finally {
if (fd !== null) { try { fs.closeSync(fd); } catch { /* best-effort */ } }
}
}
/**
* Build a collision-resistant backup-dir name for `id` (CONC-3). Two processes upgrading the same
* capability in the same millisecond would otherwise produce identical `<id>.upgrading-<pid>-<ts>`
* names; the random nonce eliminates that collision. The name still matches BACKUP_NAME_RE so a
* recorded intent can find the backup after a crash.
*/
function newBackupName(id: string): string {
return `${id}.upgrading-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}
// ---------------------------------------------------------------------------
// Cross-process mutual exclusion
// ---------------------------------------------------------------------------
// 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-]*$/;
type LockHandle = { path: string; token: string; dev: number | null; ino: number | null };
/**
* 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 — lockMod also mkdirs */ }
return lockMod.acquireLock(path.join(root, '.lock'));
}
/** Release a capability-mutation lock (shared primitive — token + inode owner-safe). */
function releaseLock(handle: LockHandle | null): void {
lockMod.releaseLock(handle);
}
function readManifest(dir: string): Record<string, unknown> | null {
try {
const raw = fs.readFileSync(path.join(dir, 'capability.json'), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function readJsonFile(file: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function writeJsonFileAtomic(file: string, obj: unknown): void {
platformWriteSync(file, JSON.stringify(obj, null, 2) + '\n');
}
/**
* Rm a ledger-recorded path only if its REAL location is strictly under runtimeDir's real path.
*
* Lexical containment alone is insufficient: a tampered ledger could record `.gsd/link/victim`
* where `.gsd/link` is a symlink to `/`, and a lexical check would pass while the delete escapes
* (Codex R1 H4). So we realpath the parent chain (defeating symlinked components) and `lstat` the
* final component (a symlinked target is unlinked as a link, never followed into a recursive rm).
*
* Residual: a parent-chain symlink swapped in the window between the realpath check and the rm is a
* classic TOCTOU. It is out of threat model here — both the ledger and runtimeDir are the user's own
* trusted config tree, so an attacker who can tamper the ledger and win that race already has write
* access to delete these files directly (no privilege boundary is crossed). The mutation lock also
* serializes GSD's own operations, and the realpath check defeats the realistic persistent-symlink
* vector.
*/
function safeRmUnder(runtimeDir: string, rel: string): boolean {
if (typeof rel !== 'string' || !rel) return false;
if (path.isAbsolute(rel) || rel.split(/[/\\]/).includes('..')) return false;
let realRoot: string;
try { realRoot = fs.realpathSync(runtimeDir); } catch { return false; }
const target = path.resolve(realRoot, rel);
let realParent: string;
try { realParent = fs.realpathSync(path.dirname(target)); } catch { return false; }
if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) return false;
const realTarget = path.join(realParent, path.basename(target));
let st: fs.Stats;
try { st = fs.lstatSync(realTarget); } catch { return true; /* already gone — idempotent */ }
try {
if (st.isSymbolicLink()) fs.rmSync(realTarget, { force: true }); // unlink the link, don't follow
else fs.rmSync(realTarget, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
/**
* Resolve a shared-config file path RELATIVE to runtimeDir, confined to the scope root by realpath
* (mirrors safeRmUnder). Rejects absolute paths, `..`, and any relFile whose existing parent
* directory is a symlink escaping runtimeDir — so `--shared-file evil/x.json`, where `evil` is a
* pre-planted symlink pointing outside the scope, can never write outside it. Returns the safe
* absolute path, or null when the path is unsafe.
*/
function confinedSharedFile(runtimeDir: string, relFile: unknown): string | null {
if (typeof relFile !== 'string' || !relFile || path.isAbsolute(relFile) || relFile.split(/[/\\]/).includes('..')) {
return null;
}
let realRoot: string;
try { realRoot = fs.realpathSync(runtimeDir); } catch { return null; }
const target = path.resolve(realRoot, relFile);
const parentDir = path.dirname(target);
let realParent: string;
try {
realParent = fs.realpathSync(parentDir);
} catch {
// Parent does not exist yet (created inside the scope on write): a non-existent path cannot be a
// symlink escaping the root, so a lexical containment check is sufficient.
if (parentDir !== realRoot && !parentDir.startsWith(realRoot + path.sep)) return null;
return target;
}
if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) return null;
return path.join(realParent, path.basename(target));
}
// #1460 (R) HIGH — shell-safe hook-script allowlist (mirrors capability-validator.cjs
// isSafeHookScriptPath; see confinedBundleScript for why). Only [A-Za-z0-9._/-], no leading
// `-` segment, no `..`, not absolute.
const SAFE_HOOK_SCRIPT_RE = /^[A-Za-z0-9._/-]+$/;
// #3631 (defense-in-depth, mirrors capability-validator.cjs — KEEP BOTH IN SYNC): a declared script
// path must not point into the space bundleContentHash (capability-consent.cts) excludes from the
// consent-binding digest. A file whose basename ends `.pyc`/`.pyo` can contain perfectly valid
// JavaScript and would be executed by `node` regardless of extension, and a `__pycache__`/
// `.pytest_cache` segment marks a directory whose digest marker is suppressed — so a MANIFEST-DECLARED
// executable surface must never be able to reach either, or the exclusion becomes reachable from a
// path an attacker fully controls at declare-time rather than only via post-consent tamper.
// Regex asymmetry is DELIBERATE, KEEP BOTH RULES IN SYNC WITH capability-validator.cjs (byte-identical
// text, verified by the isSafeHookScriptPath parity test in tests/capability-registry.test.cjs):
// (i) PYCACHE_SUFFIX_RE is case-INSENSITIVE (`/i`) on purpose — a validator should be STRICTER than
// the digest it defends, so it rejects `x.PYC` too even though bundleContentHash's own suffix
// match (hasPycacheFileSuffix, capability-consent.cts) is byte-exact and would still hash it.
// (ii) The __pycache__/.pytest_cache SEGMENT match is case-SENSITIVE to match the digest's own
// byte-exact, case-sensitive directory-basename comparison (CPython always writes a lowercase
// `__pycache__`) — a validator segment match looser than the digest here would reject paths the
// digest would still hash, which is over-strict in the wrong direction for a defense-in-depth
// check layered on top of an already-correct digest.
// (iii) The `[/\\]` backslash alternations in both regexes are defensive/UNREACHABLE in practice:
// SAFE_HOOK_SCRIPT_RE (above) already rejects any backslash character outright, so a script
// string containing `\` never reaches either PYCACHE_*_RE check.
const PYCACHE_SEGMENT_RE = /(?:^|[/\\])(__pycache__|\.pytest_cache)(?:[/\\]|$)/;
const PYCACHE_SUFFIX_RE = /\.(pyc|pyo)$/i;
function isSafeHookScriptPath(script: string): boolean {
if (typeof script !== 'string' || script.length === 0) return false;
if (!SAFE_HOOK_SCRIPT_RE.test(script)) return false;
if (path.isAbsolute(script)) return false;
const segments = script.split(/[/\\]/);
if (segments.includes('..')) return false;
for (const seg of segments) {
if (seg.startsWith('-')) return false;
}
if (PYCACHE_SEGMENT_RE.test(script)) return false;
if (PYCACHE_SUFFIX_RE.test(path.basename(script))) return false;
return true;
}
/**
* #1460 (R) HIGH: POSIX single-quote an arbitrary string for safe inclusion in a shell command.
* The emitted hook `command` is the ABSOLUTE confined script path, which begins with the
* (non-manifest) install-prefix — commonly a home dir containing spaces/special chars (e.g.
* "/Users/Bob Smith/.claude/..."). Written unquoted it would word-split (and, with a hostile
* prefix, could inject). Wrapping in single quotes — with each embedded `'` escaped as `'\''` —
* makes the whole path a single shell token that no metacharacter inside it can break.
*/
function shellSingleQuote(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/**
* #1634: build the emitted hook `command` for an ABSOLUTE confined script path. For `.js`-family
* hooks (`.js`/`.cjs`/`.mjs`) prefix with `node` so the hook runs regardless of the source's
* executable bit — a `git`/tarball source that lost `+x` would otherwise yield
* `/bin/sh: Permission denied` on every matching call (defect #2). This mirrors first-party hooks
* (`node "${CLAUDE_PLUGIN_ROOT}/hooks/x.js"`). The path stays POSIX single-quoted (#1460 (R) HIGH)
* so a space-containing install prefix cannot word-split or inject. Non-JS scripts (e.g. `.sh`)
* keep the bare single-quoted absolute path (unchanged) — they remain responsible for their own
* executability, exactly as before; per-runtime command projection is a separate concern (ADR-857 D8).
*/
const JS_HOOK_EXT_RE = /\.(?:js|cjs|mjs)$/;
function runnableHookCommand(absScript: string): string {
return JS_HOOK_EXT_RE.test(absScript) ? 'node ' + shellSingleQuote(absScript) : shellSingleQuote(absScript);
}
/**
* #1460 CONF-1: resolve a hook `script` (declared RELATIVE to the bundle) against the capability's
* own install dir and CONFINE it via realpath, returning the ABSOLUTE confined path or null when it
* escapes the bundle. Mirrors confinedSharedFile (realpath the FULL existing ancestor chain so an
* ancestor symlink at any depth cannot escape) and capability-validator's materializeHookFragments
* (resolve-against-capDir containment), but rooted at capDir rather than runtimeDir.
*
* Why this matters: the prior code wrote the RAW relative `script` as the hook command. At hook-exec
* time a relative command resolves against the CWD, not the bundle — so it could execute an arbitrary
* file, and a crafted relative path (or a symlinked subdir) could escape the bundle. Writing the
* absolute confined path makes the hook always run the bundle's own file regardless of CWD.
*/
function confinedBundleScript(capDirPath: string, script: string): string | null {
// Absolute paths and `..` segments are invalid script inputs (and rejected by the caller too).
if (path.isAbsolute(script) || script.split(/[/\\]/).includes('..')) return null;
// #1460 (R) HIGH (defense-in-depth): the confined ABSOLUTE path is written verbatim as a hook
// `command` string that a host runtime consumes through a shell. A manifest-controlled script
// name containing a shell metacharacter / whitespace / control char / leading "-" would inject a
// second command — even though the file genuinely exists inside the bundle and so passes the
// realpath confinement below. The validator already rejects such scripts at install/load time
// (capability-validator.cjs isSafeHookScriptPath); we MIRROR the same conservative allowlist here
// so applyCapabilitySharedEdits skips an unsafe script even if validation were somehow bypassed.
if (!isSafeHookScriptPath(script)) return null;
let realCapRoot: string;
try {
realCapRoot = fs.realpathSync(capDirPath);
} catch {
// capDir does not exist yet (e.g. applyCapabilitySharedEdits called before the bundle is on
// disk): a non-existent root cannot be a symlink escaping itself, so confine lexically.
realCapRoot = path.resolve(capDirPath);
const targetLex = path.resolve(realCapRoot, script);
if (targetLex !== realCapRoot && !targetLex.startsWith(realCapRoot + path.sep)) return null;
return targetLex;
}
const target = path.resolve(realCapRoot, script);
const parentDir = path.dirname(target);
let realParent: string;
try {
realParent = fs.realpathSync(parentDir);
} catch {
// Parent does not exist yet (created inside the bundle): lexical containment is sufficient
// because a non-existent path cannot be a symlink escaping the root.
if (parentDir !== realCapRoot && !parentDir.startsWith(realCapRoot + path.sep)) return null;
return target;
}
// The realpath'd parent chain must remain inside the bundle — an ancestor symlink escaping the
// bundle is refused here (the symlink is followed by realpathSync, so its real location is checked).
if (realParent !== realCapRoot && !realParent.startsWith(realCapRoot + path.sep)) return null;
return path.join(realParent, path.basename(target));
}
// ---------------------------------------------------------------------------
// Atomic directory promotion (stage -> swap, backup retained for the caller)
// ---------------------------------------------------------------------------
/**
* Promote a validated staging dir to its final location, setting the old bundle aside (if any)
* into a backup that the CALLER removes only after the ledger commit. When `backupName` is given
* (the upgrade path), the backup uses that exact name so a recorded intent can find it after a
* crash; otherwise a fresh `.upgrading-<pid>-<ts>` name is generated. Returns the backup dir path
* (or null when there was no prior bundle). On a failed swap the old bundle is restored.
*/
function promoteStagingToFinal(
stagingDir: string,
finalDir: string,
backupName?: string,
): { backupDir: string | null } {
// Both finalDir and the backup share this parent; fsyncing it makes each rename durable (DUR-3).
const parent = path.dirname(finalDir);
if (fs.existsSync(finalDir)) {
const backupDir = backupName
? path.join(parent, backupName)
// CONC-3: a random nonce in the unnamed-branch backup name prevents same-ms cross-process collision.
: path.join(parent, newBackupName(path.basename(finalDir)));
retryRenameSync(finalDir, backupDir);
// DUR-3: fsync the parent dir so the old→backup rename is durable BEFORE the second rename —
// a crash here must not lose the backup (the only recovery path for reconcile).
fsyncDir(parent);
try {
retryRenameSync(stagingDir, finalDir);
} catch (err) {
try { retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ }
throw err;
}
// DUR-3: fsync the parent dir again so the staging→final rename is durable too.
fsyncDir(parent);
return { backupDir };
}
fs.mkdirSync(parent, { recursive: true });
retryRenameSync(stagingDir, finalDir);
fsyncDir(parent); // DUR-3: durable fresh-install promotion.
return { backupDir: null };
}
/**
* The canonical shared-edit transition used by install, upgrade, AND reconcile: strip every entry
* stamped with this capability's marker from `stripFiles`, then re-apply the capability's declared
* surfaces (from `manifest`) into `applyFiles`. Centralized so the security-critical strip→apply
* pair cannot diverge across the three callers. Returns the resulting sharedEdits records.
*/
function reapplyCapabilitySharedEdits(args: {
runtimeDir: string;
capId: string;
stripFiles: string[];
applyFiles: string[];
manifest: Record<string, unknown>;
}): Array<{ file: string; marker: string }> {
const { runtimeDir, capId, stripFiles, applyFiles, manifest } = args;
if (stripFiles.length > 0) {
stripCapabilitySharedEdits({ runtimeDir, capId, sharedEdits: stripFiles.map((file) => ({ file, marker: capId })) });
}
return applyCapabilitySharedEdits({ runtimeDir, capId, manifest, sharedFiles: applyFiles });
}
/**
* Re-project a capability's shared-config edits to match its CURRENT on-disk bundle (strip the
* marker across `sharedFiles`, re-apply from the on-disk manifest). Used by reconcile so that after
* a roll-forward/back the shared config is consistent with whichever bundle won (Codex R1 H2).
*/
function resyncCapabilitySharedEdits(args: {
runtimeDir: string;
capId: string;
sharedFiles: string[];
}): Array<{ file: string; marker: string }> {
const { runtimeDir, capId, sharedFiles } = args;
return reapplyCapabilitySharedEdits({
runtimeDir,
capId,
stripFiles: sharedFiles,
applyFiles: sharedFiles,
manifest: readManifest(capDir(runtimeDir, capId)) ?? {},
});
}
// ---------------------------------------------------------------------------
// Shared-config edits (marker-isolated)
// ---------------------------------------------------------------------------
/**
* Write a capability's declared hooks/mcpServers into the given shared config files, stamping
* every added entry with CAP_MARKER === capId so it can later be stripped surgically. Returns
* the ledger `sharedEdits` records (one per file actually touched).
*
* Operates on the settings.json hook shape (`hooks[event][] = { hooks: [...] }`) and the
* mcpServers map (`mcpServers[name] = {...}`), which covers the settings.json-family runtimes;
* runtime-specific command resolution is layered in Phase 5.
*/
function applyCapabilitySharedEdits(args: {
runtimeDir: string;
capId: string;
manifest: Record<string, unknown>;
sharedFiles: string[];
}): Array<{ file: string; marker: string }> {
const { runtimeDir, capId, manifest, sharedFiles } = args;
const records: Array<{ file: string; marker: string }> = [];
const hooks = Array.isArray(manifest['hooks']) ? (manifest['hooks'] as unknown[]) : [];
const mcpRaw = manifest['mcpServers'];
const mcpEntries: Array<{ name: string; config: unknown }> = [];
if (mcpRaw && typeof mcpRaw === 'object') {
if (Array.isArray(mcpRaw)) {
for (const s of mcpRaw) {
if (typeof s === 'object' && s !== null && typeof (s as Record<string, unknown>)['name'] === 'string') {
const rec = s as Record<string, unknown>;
mcpEntries.push({ name: rec['name'] as string, config: rec['config'] ?? rec });
}
}
} else {
for (const [name, config] of Object.entries(mcpRaw as Record<string, unknown>)) {
mcpEntries.push({ name, config });
}
}
}
if (hooks.length === 0 && mcpEntries.length === 0) return records;
for (const relFile of sharedFiles) {
const file = confinedSharedFile(runtimeDir, relFile);
if (file === null) continue; // unsafe path (absolute / .. / symlink escaping the scope root)
const settings = readJsonFile(file) ?? {};
let touched = false;
if (hooks.length > 0) {
const hooksObj = (typeof settings['hooks'] === 'object' && settings['hooks'] !== null && !Array.isArray(settings['hooks']))
? (settings['hooks'] as Record<string, unknown>)
: {};
for (const h of hooks) {
if (typeof h !== 'object' || h === null) continue;
const rec = h as Record<string, unknown>;
const event = typeof rec['event'] === 'string' ? rec['event'] : '';
const script = typeof rec['script'] === 'string' ? rec['script'] : '';
if (!event || !script || isUnsafeKey(event)) continue;
// #1634: optional tool-scoping `matcher` (a settings.json concept — entry-level sibling of
// `hooks`). Absent => match-all (field OMITTED so the existing shipped capabilities' wiring
// is byte-for-byte unchanged, Hyrum's Law). The validator gates this to a non-empty string.
const matcherRaw = rec['matcher'];
const matcher = typeof matcherRaw === 'string' && matcherRaw.length > 0 ? matcherRaw : null;
// #1460 CONF-1: resolve the declared (relative) script against the capability's OWN install
// dir and CONFINE via realpath, then write the ABSOLUTE confined path as the hook command —
// never the raw relative path (which would resolve against the CWD at hook-exec time and could
// execute an arbitrary file). Absolute/`..` inputs and any script escaping the bundle (e.g.
// through a symlinked subdir) return null and are SKIPPED, exactly as before.
const absScript = confinedBundleScript(capDir(runtimeDir, capId), script);
if (absScript === null) continue;
// #1460 (R) HIGH + #1634: the hook `command` is consumed by a shell. `runnableHookCommand`
// emits a `node`-prefixed POSIX-single-quoted absolute path for `.js`-family hooks (runs
// without `+x`; mirrors first-party) and a bare single-quoted path otherwise. Single-quoting
// keeps a space-containing install prefix as one shell token (cannot word-split or inject).
const command = runnableHookCommand(absScript);
const arr = Array.isArray(hooksObj[event]) ? (hooksObj[event] as unknown[]) : [];
// #1634: stamp the marker so the entry is surgically strippable, and carry the declared
// `matcher` (entry-level sibling of `hooks`) only when the author declared one.
const entry: Record<string, unknown> = { [CAP_MARKER]: capId, hooks: [{ type: 'command', command }] };
if (matcher !== null) entry['matcher'] = matcher;
arr.push(entry);
hooksObj[event] = arr;
touched = true;
}
settings['hooks'] = hooksObj;
}
if (mcpEntries.length > 0) {
// #3515 (epic #1900 F20): the MCP config below is written VERBATIM — command/args/env/cwd
// are NOT confined to the bundle the way hook scripts are (confinedBundleScript, D5 rule 5).
// This asymmetry is INTENTIONAL: most real MCP servers legitimately resolve command/args/cwd
// to global or npx installs outside the capability bundle, so confinement would break them.
// The compensating controls are disclosure + re-consent: the consent prompt renders an
// explicit "not confined to the bundle" notice for every spawned server (summarizeDisclosure,
// capability-trust.cts), and disclosureSignature folds command/args/env/cwd + the FULL
// rawConfig as stable-sorted JSON (#1459 finding 5), so ANY config change forces re-consent.
const mcpObj = (typeof settings['mcpServers'] === 'object' && settings['mcpServers'] !== null && !Array.isArray(settings['mcpServers']))
? (settings['mcpServers'] as Record<string, unknown>)
: {};
for (const { name, config } of mcpEntries) {
if (!name || isUnsafeKey(name)) continue;
// Marker isolation for the map-keyed mcpServers shape: only (re)write an entry we already own
// or a brand-new name. A collision with an UNOWNED entry (the user's, or another capability's)
// is SKIPPED so user config is never clobbered — hooks are arrays and append, but mcpServers is
// keyed by name, so a blind overwrite would silently destroy the existing server config.
const existing = mcpObj[name];
const ownedByUs = typeof existing === 'object' && existing !== null
&& (existing as Record<string, unknown>)[CAP_MARKER] === capId;
if (existing !== undefined && !ownedByUs) continue;
const stamped = (typeof config === 'object' && config !== null && !Array.isArray(config))
? { ...(config as Record<string, unknown>), [CAP_MARKER]: capId }
: { value: config, [CAP_MARKER]: capId };
mcpObj[name] = stamped;
touched = true;
}
settings['mcpServers'] = mcpObj;
}
if (touched) {
writeJsonFileAtomic(file, settings);
records.push({ file: relFile, marker: capId });
}
}
return records;
}
/**
* Surgically remove a capability's owned entries (those stamped CAP_MARKER === capId) from each
* recorded shared-config file, leaving everything else — including user hand-edits — untouched.
* Idempotent: tolerates a missing/unparseable file or already-removed entries.
*/
function stripCapabilitySharedEdits(args: {
runtimeDir: string;
capId: string;
sharedEdits: Array<{ file: string; marker: string }>;
}): number {
const { runtimeDir, capId, sharedEdits } = args;
let stripped = 0;
for (const edit of sharedEdits) {
const relFile = edit && typeof edit.file === 'string' ? edit.file : '';
const file = confinedSharedFile(runtimeDir, relFile);
if (file === null) continue; // unsafe path (absolute / .. / symlink escaping the scope root)
const settings = readJsonFile(file);
if (settings === null) continue; // missing/unparseable — nothing to strip
let changed = false;
const hooksObj = settings['hooks'];
if (hooksObj && typeof hooksObj === 'object' && !Array.isArray(hooksObj)) {
const ho = hooksObj as Record<string, unknown>;
for (const event of Object.keys(ho)) {
if (!Array.isArray(ho[event])) continue;
const arr = ho[event] as unknown[];
const kept = arr.filter(
(e) => !(typeof e === 'object' && e !== null && (e as Record<string, unknown>)[CAP_MARKER] === capId),
);
if (kept.length !== arr.length) {
changed = true;
stripped += arr.length - kept.length;
}
if (kept.length === 0) delete ho[event];
else ho[event] = kept;
}
if (Object.keys(ho).length === 0) delete settings['hooks'];
}
const mcpObj = settings['mcpServers'];
if (mcpObj && typeof mcpObj === 'object' && !Array.isArray(mcpObj)) {
const mo = mcpObj as Record<string, unknown>;
for (const name of Object.keys(mo)) {
const v = mo[name];
if (typeof v === 'object' && v !== null && (v as Record<string, unknown>)[CAP_MARKER] === capId) {
delete mo[name];
changed = true;
stripped += 1;
}
}
if (Object.keys(mo).length === 0) delete settings['mcpServers'];
}
if (changed) writeJsonFileAtomic(file, settings);
}
return stripped;
}
/**
* Is `id` a first-party capability id (present in the committed registry)? First-party always wins,
* so an overlay reusing one of these ids — even a non-reserved name like "ui" — must be refused at
* install (the loader would skip it at load anyway; rejecting here avoids writing an inert, shadowing
* bundle). Fail-open to `false` if the registry cannot be read (the reserved-prefix gate still applies).
*/
function isFirstPartyCapabilityId(id: string): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const reg = require('./capability-registry.cjs') as { capabilities?: Record<string, unknown> };
return !!(reg && reg.capabilities && Object.prototype.hasOwnProperty.call(reg.capabilities, id));
} catch {
return false;
}
}
/**
* Finding 5(b): bound the --shared-file COUNT against the same generous DoS cap the ledger applies
* to `_pending.sharedFiles`. Returns an error string when over-cap (so the caller can fail fast
* BEFORE source resolution / staging / shared-config writes), or null when within bounds.
*/
function checkSharedFileCount(sharedFiles: string[] | undefined): string | null {
if (!Array.isArray(sharedFiles)) return null;
if (sharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
return `too many --shared-file entries: ${sharedFiles.length} exceeds the maximum of ` +
`${ledgerMod.MAX_SHARED_FILES}. A capability does not need this many shared-config files; ` +
`reduce the --shared-file count.`;
}
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.
*/
/**
* Resolve an `openai-http` reviewer lane's declared `hostConfigKey` to the destination it currently
* names, or `undefined` when this capability is not such a lane.
*
* Falls back to the lane's declared `defaultHost` when the key is unset, because that is exactly
* what the invocation path will do — binding the config value while the runtime uses the default
* would guarantee a mismatch on the very first review.
*
* Non-throwing: consent binding is best-effort and must never turn a successful install into a
* failure. An unresolvable host simply records nothing, which reads as "not bound" and allows.
*/
function resolveReviewerEgressHost(
opts: LifecycleOptions,
manifest: Record<string, unknown>,
): string | undefined {
try {
const reviewer = manifest['reviewer'];
if (reviewer === null || typeof reviewer !== 'object' || Array.isArray(reviewer)) return undefined;
const r = reviewer as Record<string, unknown>;
if (r['transport'] !== 'openai-http') return undefined;
const invoke = r['invoke'];
if (invoke === null || typeof invoke !== 'object') return undefined;
const inv = invoke as Record<string, unknown>;
const key = typeof inv['hostConfigKey'] === 'string' ? inv['hostConfigKey'] : '';
const fallback = typeof inv['defaultHost'] === 'string' ? inv['defaultHost'] : '';
let configured = '';
if (key) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cfgLoader = require('./config-loader.cjs') as {
loadConfigResolved?: (cwd: string) => { config?: Record<string, unknown> };
};
const root = projectRootMod.consentProjectRoot(opts.runtimeDir);
const cfg = cfgLoader.loadConfigResolved ? (cfgLoader.loadConfigResolved(root).config ?? {}) : {};
let cur: unknown = cfg;
for (const part of key.split('.')) {
if (cur === null || typeof cur !== 'object') { cur = undefined; break; }
cur = Object.prototype.hasOwnProperty.call(cur, part)
? (cur as Record<string, unknown>)[part]
: undefined;
}
if (typeof cur === 'string') configured = cur.trim();
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { normalizeHost } = require('./review-lane-invocation.cjs') as {
normalizeHost: (s: string) => string;
};
const resolved = normalizeHost(configured || fallback);
return resolved || undefined;
} catch {
return undefined;
}
}
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)),
// ADR-2782 D5 rule 1 (#2799): bind the RESOLVED egress destination, not merely the config key
// that names it. The key lives in `.planning/config.json`, outside the SHA-pinned bundle, so
// without this the user consents to "wherever that key points" — a promise the bundle hash
// cannot keep. Phase 5b re-resolves and compares at invocation (rule 4).
reviewerHost: resolveReviewerEgressHost(opts, manifest),
});
} 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
// ---------------------------------------------------------------------------
interface InstallResult {
status: 'installed' | 'aborted' | 'blocked';
id?: string;
version?: string;
disclosure?: Disclosure;
blockReasons?: string[];
requiresConsent?: boolean;
}
/**
* Install a capability from a spec. Resolves (copy-only, integrity+engines verified), evaluates
* the trust gate, and only promotes + records when policy allows and consent (if required) was
* granted. Nothing is written on a blocked or aborted result.
*/
async function installCapability(spec: string, opts: LifecycleOptions): Promise<InstallResult> {
const { runtimeDir, hostVersion, strictKnownRegistries, consentGranted, integrity, sharedFiles, execOverrides } = opts;
// Pre-fetch source gate: never fetch/clone a disallowed source.
const parsedPre = sourceMod.parseSpec(spec);
const srcPre = trustMod.evaluateSourceAllowed(parsedPre, strictKnownRegistries);
if (!srcPre.allowed) {
return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] };
}
// Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution, staging,
// or any shared-config write — so an over-cap install fails fast with a clear count error instead
// of writing files + leaving a `_pending` for reconcile to clean up. The same generous DoS cap as
// the ledger's `_pending.sharedFiles` validation.
const sharedCountError = checkSharedFileCount(sharedFiles);
if (sharedCountError) return { status: 'blocked', blockReasons: [sharedCountError] };
// Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or
// consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption
// reason. The previous order called _resolve first (creating .gsd/capabilities/.staging) and
// only strict-read later, so a corrupt ledger could surface as `aborted` (consent) for an
// executable install without --yes BEFORE the corruption was ever reported, and would leave a
// staging dir behind. A non-throwing read here is a READ-ONLY operation: it touches no lock and
// creates no directory. The later read (re-read under lock before commit) is kept for race-safety.
try {
ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', blockReasons: [(err as Error).message] };
}
// Resolve copy-only into staging (do NOT promote — trust gate decides first).
const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource;
let resolved;
try {
resolved = await resolve(spec, {
hostVersion,
gsdHome: runtimeDir,
integrity,
promote: false,
// The lifecycle owns the engines gate via checkEngines (so it can also surface a
// compatVersions downgrade hint); the resolver must not pre-empt it by throwing.
skipEnginesGate: true,
execOverrides,
});
} catch (err) {
return { status: 'blocked', blockReasons: [(err as Error).message] };
}
const stagedDir = resolved.stagedDir;
// Serialize the fs swap + ledger writes (and reconcile) so a concurrent op can't interleave.
const lock = acquireLock(runtimeDir);
try {
if (!lock) {
return { status: 'blocked', id: resolved.id, blockReasons: ['another capability operation is in progress'] };
}
const manifest = readManifest(stagedDir);
if (manifest === null) {
return { status: 'blocked', blockReasons: ['staged capability.json is missing or invalid'] };
}
if (opts.expectedId && resolved.id !== opts.expectedId) {
return { status: 'blocked', id: resolved.id, blockReasons: [`source resolved to capability id "${resolved.id}" but "${opts.expectedId}" was expected; refusing`] };
}
// ROOT FIX 3: reject unsafe capability ids before any promotion or ledger write.
// A .gsd/capabilities/constructor (or __proto__, prototype) bundle must never be promoted —
// the resolved id is untrusted data from the bundle's capability.json.
if (ledgerMod.isUnsafeCapabilityId(resolved.id)) {
return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to install`] };
}
if (isFirstPartyCapabilityId(resolved.id)) {
return { status: 'blocked', id: resolved.id, blockReasons: [`"${resolved.id}" is a first-party capability id and cannot be overridden by a third-party overlay`] };
}
const verdict = trustMod.evaluateInstallTrust({
parsed: parsedPre,
manifest,
stagedDir,
strictKnownRegistries,
hostVersion,
integrityPin: resolveIntegrityPin(opts.integrity, parsedPre),
});
if (!verdict.allowed) {
return { status: 'blocked', disclosure: verdict.disclosure, blockReasons: verdict.blockReasons };
}
if (verdict.requiresConsent && !consentGranted) {
return { status: 'aborted', disclosure: verdict.disclosure, requiresConsent: true };
}
const finalDir = capDir(runtimeDir, resolved.id);
const relCapDir = path.relative(runtimeDir, finalDir);
const files = sharedFiles ?? [];
// A reinstall over an existing bundle behaves like an upgrade (preserve the old on rollback).
// readLedgerStrict: returns null when MISSING (fresh first install), throws CorruptLedgerError
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
// corrupt-but-present ledger fails closed rather than silently treating it as "no prior entry".
let existingLedger: LedgerFile | null;
try {
existingLedger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
}
const prior = existingLedger && Object.prototype.hasOwnProperty.call(existingLedger.entries, resolved.id)
? existingLedger.entries[resolved.id]
: null;
const hadDir = fs.existsSync(finalDir);
const priorSharedFiles = prior && Array.isArray(prior.sharedEdits) ? prior.sharedEdits.map((e) => e.file) : [];
const candidateFiles = Array.from(new Set([...priorSharedFiles, ...files]));
// CONC-3: nonce'd backup name prevents same-ms cross-process collision.
const backupName = hadDir ? newBackupName(resolved.id) : null;
// INTENT: record BEFORE any filesystem mutation so a crash is recoverable (Codex R2 H1).
// Kind 'upgrade' is used ONLY when BOTH a prior ledger entry AND the on-disk bundle exist (a
// true reinstall-over-existing): the intent then carries the PRIOR metadata + a backup, so a
// rollback restores the old files AND their matching ledger entry (Codex R3 H2/M6). Otherwise
// it is a fresh install (kind 'install', no usable old state) whose rollback removes the
// half-installed entry entirely.
const isUpgradeLike = !!prior && hadDir;
const pendingBase: LedgerEntry = isUpgradeLike
? { ...prior }
: {
id: resolved.id,
version: resolved.version,
source: resolved.source,
integrity: resolved.integrity ?? '',
files: [relCapDir],
sharedEdits: prior?.sharedEdits ?? [],
};
// recordInstall calls readLedgerStrict internally and can throw CorruptLedgerError if the
// ledger is corrupt. Catch it here so the function always returns a typed result, never throws.
// DOS-4: pass the already-strict-read `existingLedger` as the base so recordInstall skips a
// redundant strict re-read (we hold the lock, so the on-disk ledger cannot change underneath it).
try {
ledgerMod.recordInstall(runtimeDir, {
...pendingBase,
_pending: { kind: isUpgradeLike ? 'upgrade' : 'install', backupName, sharedFiles: candidateFiles },
}, { baseLedger: existingLedger });
} catch (err) {
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
}
let committed = false;
let backupDir: string | null = null;
try {
({ backupDir } = promoteStagingToFinal(stagedDir, finalDir, backupName ?? undefined));
const sharedEdits = reapplyCapabilitySharedEdits({ runtimeDir, capId: resolved.id, stripFiles: candidateFiles, applyFiles: files, manifest });
// COMMIT: rewrite WITHOUT _pending. Clearing the intent IS the commit.
ledgerMod.recordInstall(runtimeDir, {
id: resolved.id,
version: resolved.version,
source: resolved.source,
integrity: resolved.integrity ?? '',
files: [relCapDir],
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] };
} finally {
if (committed && backupDir) { try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
}
return { status: 'installed', id: resolved.id, version: resolved.version, disclosure: verdict.disclosure };
} finally {
// If staging survived (blocked/aborted/throw before promotion), clean it up; release the lock.
try { if (fs.existsSync(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true }); } catch { /* best-effort */ }
releaseLock(lock);
}
}
// ---------------------------------------------------------------------------
// Upgrade (atomic stage-then-swap, ledger = commit point)
// ---------------------------------------------------------------------------
interface UpgradeResult {
status: 'upgraded' | 'aborted' | 'blocked' | 'not_installed';
id?: string;
fromVersion?: string;
toVersion?: string;
disclosure?: Disclosure;
blockReasons?: string[];
requiresConsent?: boolean;
}
/**
* Upgrade an installed capability from a (new-version) spec via atomic stage-then-swap. The new
* bundle is fully fetched, verified, and validated into staging; the old bundle is set aside;
* the new is swapped in; THEN the ledger is rewritten (commit point); THEN the backup is dropped.
* A crash anywhere leaves either the old or the new bundle fully intact — see reconcileCapabilities.
*
* Re-prompts for consent (returns 'aborted' when consent not granted) when the executable surface
* set changed between the installed version and the new one.
*/
async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise<UpgradeResult> {
const { runtimeDir, hostVersion, strictKnownRegistries, consentGranted, integrity, sharedFiles, execOverrides } = opts;
const parsedPre = sourceMod.parseSpec(spec);
const srcPre = trustMod.evaluateSourceAllowed(parsedPre, strictKnownRegistries);
if (!srcPre.allowed) {
return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] };
}
// Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution/staging.
const sharedCountError = checkSharedFileCount(sharedFiles);
if (sharedCountError) return { status: 'blocked', blockReasons: [sharedCountError] };
// Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or
// re-consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption
// reason, never fetch/stage the new bundle, and never surface a downstream not_installed/consent
// result that masks the corruption. Read-only — takes no lock, creates no directory. The later
// read (re-read under lock before commit) is kept for race-safety.
try {
ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', blockReasons: [(err as Error).message] };
}
const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource;
let resolved;
try {
resolved = await resolve(spec, {
hostVersion,
gsdHome: runtimeDir,
integrity,
promote: false,
// The lifecycle owns the engines gate via checkEngines (so it can also surface a
// compatVersions downgrade hint); the resolver must not pre-empt it by throwing.
skipEnginesGate: true,
execOverrides,
});
} catch (err) {
return { status: 'blocked', blockReasons: [(err as Error).message] };
}
const stagedDir = resolved.stagedDir;
let committed = false;
const lock = acquireLock(runtimeDir);
try {
if (!lock) {
return { status: 'blocked', id: resolved.id, blockReasons: ['another capability operation is in progress'] };
}
if (opts.expectedId && resolved.id !== opts.expectedId) {
return { status: 'blocked', id: resolved.id, blockReasons: [`source for "${opts.expectedId}" now resolves to a different capability id "${resolved.id}"; refusing to upgrade`] };
}
// ROOT FIX 3: reject unsafe capability ids before any ledger read or promotion.
if (ledgerMod.isUnsafeCapabilityId(resolved.id)) {
return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to upgrade`] };
}
// readLedgerStrict: returns null when MISSING (not installed), throws CorruptLedgerError
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
// corrupt-but-present ledger fails closed rather than silently reporting not_installed.
let existing: LedgerFile | null;
try {
existing = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
}
const prior = existing && Object.prototype.hasOwnProperty.call(existing.entries, resolved.id)
? existing.entries[resolved.id]
: null;
if (!prior) {
return { status: 'not_installed', id: resolved.id, blockReasons: ['capability is not installed; use install'] };
}
const newManifest = readManifest(stagedDir);
if (newManifest === null) {
return { status: 'blocked', blockReasons: ['staged capability.json is missing or invalid'] };
}
const verdict = trustMod.evaluateInstallTrust({
parsed: parsedPre,
manifest: newManifest,
stagedDir,
strictKnownRegistries,
hostVersion,
integrityPin: resolveIntegrityPin(opts.integrity, parsedPre),
});
if (!verdict.allowed) {
return { status: 'blocked', disclosure: verdict.disclosure, blockReasons: verdict.blockReasons };
}
// Re-consent only when the executable surface set changed between versions.
const finalDir = capDir(runtimeDir, resolved.id);
const oldManifest = readManifest(finalDir) ?? {};
const oldDisclosure = trustMod.discloseExecutableSurfaces(oldManifest);
if (trustMod.executableSetChanged(oldDisclosure, verdict.disclosure) && !consentGranted) {
return { status: 'aborted', disclosure: verdict.disclosure, requiresConsent: true };
}
const files = sharedFiles ?? [];
// Every shared file that EITHER the old or the new version touches must be cleaned on a
// rollback, so a crash mid-swap can never strand the new version's executable config.
const candidateFiles = Array.from(new Set([
...(Array.isArray(prior.sharedEdits) ? prior.sharedEdits.map((e) => e.file) : []),
...files,
]));
// INTENT: record the in-flight upgrade BEFORE touching the filesystem. Its presence — not a
// version comparison — is the commit signal reconcile uses (Codex R1 H3).
// Wrap in try/catch so a disk failure (EPERM, ENOSPC, …) at the intent-write stage
// returns a blocked result rather than a raw stack trace (finding 4).
const backupName = newBackupName(resolved.id); // CONC-3: nonce'd, collision-resistant.
try {
ledgerMod.recordInstall(runtimeDir, { ...prior, _pending: { kind: 'upgrade', backupName, sharedFiles: candidateFiles } });
} catch (err) {
return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] };
}
let backupDir: string | null = null;
try {
// Atomic swap: old -> backup(backupName), new -> live.
({ backupDir } = promoteStagingToFinal(stagedDir, finalDir, backupName));
// Re-derive shared edits across ALL candidate files: strip old marker entries, apply new.
const sharedEdits = reapplyCapabilitySharedEdits({ runtimeDir, capId: resolved.id, stripFiles: candidateFiles, applyFiles: files, manifest: newManifest });
// COMMIT: rewrite the entry WITHOUT _pendingUpgrade. Clearing the intent IS the commit.
const relCapDir = path.relative(runtimeDir, finalDir);
ledgerMod.recordInstall(runtimeDir, {
id: resolved.id,
version: resolved.version,
source: resolved.source,
integrity: resolved.integrity ?? '',
files: [relCapDir],
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] };
} finally {
// Drop the backup ONLY after a successful commit; on failure leave it for reconcile.
if (committed && backupDir) {
try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
return { status: 'upgraded', id: resolved.id, fromVersion: prior.version, toVersion: resolved.version, disclosure: verdict.disclosure };
} finally {
try { if (fs.existsSync(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true }); } catch { /* best-effort */ }
releaseLock(lock);
}
}
// ---------------------------------------------------------------------------
// Remove
// ---------------------------------------------------------------------------
interface RemoveResult {
status: 'removed' | 'not_installed' | 'blocked';
id: string;
strippedEdits?: number;
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;
}
/**
* Remove an installed capability: strip exactly its marker-owned shared-config entries, delete
* exactly the ledger-recorded files, then drop the ledger entry (commit point). Idempotent.
* CAPABILITY_DATA is preserved unless opts.removeData is set.
*/
function removeCapability(id: string, opts: LifecycleOptions): RemoveResult {
const { runtimeDir, removeData } = opts;
// 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 anyway. A strict read takes no lock and creates
// no directory, so on a corrupt/IO-error ledger we return blocked with NO lock and NO dir created.
try {
ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', id, blockReasons: [(err as Error).message] };
}
const lock = acquireLock(runtimeDir);
try {
if (!lock) return { status: 'blocked', id, blockReasons: ['another capability operation is in progress'] };
// Re-read under the lock to close the race (the ledger could have gone corrupt between the
// preflight and acquiring the lock). readLedgerStrict: returns null when MISSING (not
// installed), throws CorruptLedgerError when the file exists but is corrupt — fail-closed.
let ledger: LedgerFile | null;
try {
ledger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
return { status: 'blocked', id, blockReasons: [(err as Error).message] };
}
const entry = ledger && Object.prototype.hasOwnProperty.call(ledger.entries, id) ? ledger.entries[id] : null;
if (!entry) return { status: 'not_installed', id };
// 1. Surgically strip capability-owned shared-config entries (user edits untouched).
const strippedEdits = stripCapabilitySharedEdits({
runtimeDir,
capId: id,
sharedEdits: Array.isArray(entry.sharedEdits) ? entry.sharedEdits : [],
});
// 2. Delete exactly the ledger-recorded files (guarded to under runtimeDir).
const removedFiles: string[] = [];
for (const f of Array.isArray(entry.files) ? entry.files : []) {
if (typeof f === 'string' && safeRmUnder(runtimeDir, f)) removedFiles.push(f);
}
// 3. CAPABILITY_DATA: preserved unless explicitly requested.
if (removeData) safeRmUnder(runtimeDir, path.relative(runtimeDir, capDataDir(runtimeDir, id)));
// 4. Ledger commit point — entry no longer referenced.
// Finding 3 (HIGH): commit from the ALREADY-read in-memory ledger (the one we strict-read
// at the top of this function), NOT via removeEntry's non-strict re-read. If the ledger
// goes corrupt between the strict pre-read and the commit, removeEntry would return false
// (it re-reads non-strictly → null → returns false) while removeCapability still returns
// 'removed', leaving a dangling reference in the corrupt file for a capability whose files
// are already gone. Writing from the in-memory snapshot is atomic and coherent.
//
// If the write fails (EPERM, EBUSY, EXDEV, …) after the files are already deleted, we
// return a typed 'blocked' result with recovery info rather than letting an unhandled
// throw propagate as a CLI stack trace. The ledger would still reference files that no
// longer exist — the user can re-run `gsd capability remove <id>` to retry the commit (the
// next install/update/remove also runs the reconcile sweep automatically). There is no
// standalone `reconcile` CLI subcommand (UX-4).
try {
if (ledger !== null) {
delete ledger.entries[id];
ledger.updatedAt = new Date().toISOString();
ledgerMod.writeLedger(runtimeDir, ledger);
}
} catch (err) {
return {
status: 'blocked',
id,
blockReasons: [
`Capability files were deleted but the ledger commit failed: ${(err as Error).message}. ` +
`To recover: run 'gsd capability remove ${id}' again, or manually inspect and restore ` +
`the ledger file to remove the stale entry for "${id}".`,
],
};
}
// #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);
}
}
// ---------------------------------------------------------------------------
// Reconciliation (crash recovery)
// ---------------------------------------------------------------------------
interface ReconcileReport {
rolledBack: string[];
rolledForward: string[];
orphansRemoved: string[];
ledger: unknown;
/** Non-fatal warnings encountered during reconciliation (e.g. a corrupt-present ledger). */
warnings: string[];
}
/**
* Backup-dir name shape; the id segment is kebab-case so no traversal is possible. The trailing
* `-<hex>` nonce (CONC-3) is OPTIONAL so legacy backups written before the nonce was added still
* match (backward compatible).
*/
const BACKUP_NAME_RE = /^[a-z][a-z0-9-]*\.upgrading-\d+-\d+(-[0-9a-f]+)?$/;
/** A backup name is trustworthy for `id` only if it is well-formed AND names that exact id. */
function backupNameMatchesId(name: unknown, id: string): name is string {
return typeof name === 'string' && BACKUP_NAME_RE.test(name) && name.startsWith(id + '.upgrading-');
}
/**
* Recover from a crashed install/upgrade and clean staging orphans. The commit signal is the
* ledger entry's `_pending` INTENT — never a version comparison (a same-version malicious bundle
* must not read as committed; Codex R1 H3). Holds the mutation lock so a concurrent in-flight
* operation's just-written intent is never cleared mid-flight (Codex R2 H2); if the lock is held,
* reconcile defers to that operation and no-ops.
*
* - `_pending.kind === 'upgrade'` (or reinstall): the op did NOT commit -> ROLL BACK by restoring
* the backup over the live (possibly new, uncommitted) dir, re-syncing shared config from the
* restored OLD bundle, and clearing the intent. The intent is cleared ONLY if the restore
* succeeded (Codex R2 M4) so a failed recovery is retried, never silently committed.
* - `_pending.kind === 'install'` (fresh): the install did NOT commit -> remove the half-installed
* dir + its shared edits + the ledger entry entirely.
* - Leftover `<id>.upgrading-*` backups with NO live intent: the op committed -> drop the backup.
*
* The post-recovery state is always fully-old or fully-new — never a half-state.
*/
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
// creates no directory, so on a corrupt/IO-error/broken-symlink ledger we WARN and return WITHOUT
// any filesystem mutation and WITHOUT a lock or directory created. (The in-lock re-read below
// still fires to close the race if the ledger goes corrupt after this preflight.)
try {
ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
report.warnings.push(
`Capability ledger file exists but could not be read: ${(err as Error).message}`,
);
return report; // no lock taken, no directory created, no filesystem mutation (finding 2)
}
const lock = acquireLock(runtimeDir);
if (!lock) return report; // another op is in flight and will reconcile itself.
try {
// --- Step 1: resolve uncommitted operations flagged by the intent. ---
let ledger = ledgerMod.readLedger(runtimeDir);
// Detect corrupt-present or IO-error ledger: readLedger returns null but the file exists.
// Finding 1 (CRITICAL): when the ledger file is present but unreadable/unparseable (or is a
// broken symlink), RETURN IMMEDIATELY with the warning — perform NO filesystem mutations (no
// backup sweep, no staging cleanup, no rmSync/rename). Continuing into step 2 would delete
// `.upgrading-*` backups that may be the only recovery path for the user.
//
// ROOT FIX 4: use lstatSync (not existsSync) — existsSync follows the symlink and returns
// false for a broken/dangling symlink, making reconcile treat a dangling ledger pointer as
// "no ledger yet" and proceed to sweep backups. lstatSync checks the directory ENTRY itself,
// so a broken symlink is detected and treated as an IO problem requiring user intervention.
if (ledger === null) {
const ledgerFilePath = path.join(runtimeDir, '.gsd-capabilities.json');
let ledgerEntryExists = false;
try {
fs.lstatSync(ledgerFilePath);
ledgerEntryExists = true;
} catch (lstatErr) {
// ENOENT means genuinely absent — no ledger, no entry, fresh start is fine.
// Any other error (EACCES, EPERM, …) means an IO problem — also treat as "exists but broken".
if ((lstatErr as NodeJS.ErrnoException).code !== 'ENOENT') {
ledgerEntryExists = true; // IO problem accessing the entry — treat as corrupt/broken.
}
}
if (ledgerEntryExists) {
report.warnings.push(`Capability ledger file exists but could not be parsed: ${ledgerFilePath}`);
return report; // MUST return here — no mutations when ledger is corrupt/broken (finding 1)
}
}
if (ledger) {
// DOS-2: accumulate ALL step-1 ledger mutations in this in-memory copy and write ONCE at the
// end of step 1, instead of a full read+write per pending entry (O(N) reads/writes → O(1)).
// We already hold the lock and the ledger has passed the corruption preflight, so writing the
// validated in-memory copy is coherent. `ledgerDirty` gates whether the single write runs.
const workingLedger = ledger;
let ledgerDirty = false;
for (const id of Object.keys(workingLedger.entries)) {
// W-6: a per-entry mutation can now throw (the strip/restore IO, or a future strict write).
// One bad entry must NOT abort the whole reconcile — wrap it, warn, and continue.
try {
// Reject a tampered ledger key: a non-kebab id (e.g. one containing `../`) must never reach
// capDir()/safeRmUnder() (Codex R3 M5). Leave it in place for ledger.reconcile to report.
if (!KEBAB_ID_RE.test(id)) continue;
const entry = workingLedger.entries[id];
const pending = entry._pending;
if (!pending) continue;
// Candidate shared files: the intent's list UNION the entry's recorded files, so a
// tampered/missing `sharedFiles` still cleans the genuinely-touched files (Codex R2 M5).
const candidateFiles = Array.from(new Set([
...(Array.isArray(pending.sharedFiles) ? pending.sharedFiles : []),
...(Array.isArray(entry.sharedEdits) ? entry.sharedEdits.map((e) => e.file) : []),
]));
const finalDir = capDir(runtimeDir, id);
if (pending.kind === 'install') {
// Uncommitted FRESH install -> remove dir + shared edits + the half-installed entry.
stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) });
// Only drop the entry once the dir is actually gone (safeRmUnder returns true when the
// dir is already absent). If the delete genuinely FAILS (e.g. EPERM), keep `_pending` so
// the next run retries — never orphan the dir with no recovery signal (code-review H).
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;
}
// Uncommitted UPGRADE/reinstall. A kind 'upgrade' intent ALWAYS carries a well-formed
// backupName naming this id; if it does not, the intent is tampered/corrupt — fail CLOSED
// (leave it pending for manual handling) rather than silently accepting the live dir
// (Codex R3 M6).
if (!backupNameMatchesId(pending.backupName, id)) continue;
const backupDir = path.join(root, pending.backupName);
let restored: boolean;
if (fs.existsSync(backupDir)) {
try {
// DUR-6: NEVER rmSync(finalDir) before restoring — a crash between the rm and the
// rename would leave BOTH the new dir AND the backup gone (the old `rmSync` then
// `rename` ordering). Instead, move the uncommitted new dir ASIDE (atomic rename), then
// rename the backup over the now-free finalDir, then drop the aside copy. (`rename`
// cannot atomically replace a non-empty directory on POSIX, so a single rename-over is
// not an option.) At every instant at least one intact copy of the old bundle exists:
// - crash after step (a): backup still present + `_pending` still references it → retry.
// - crash after step (b): old bundle live at finalDir; only the aside copy leaks → swept.
const discard = `${finalDir}.discard-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
if (fs.existsSync(finalDir)) retryRenameSync(finalDir, discard); // (a) set the new dir aside
retryRenameSync(backupDir, finalDir); // (b) restore the old bundle
fsyncDir(root); // make the restore durable
try { fs.rmSync(discard, { recursive: true, force: true }); } catch { /* swept later */ }
restored = true;
} catch {
restored = false; // restore failed — leave the intent for a later retry.
}
} else if (fs.existsSync(finalDir)) {
// Backup absent with a valid pointer: the swap never started, so the OLD bundle is live.
restored = true;
} else {
// BOTH the backup and the live dir are gone (external deletion of both) — the bundle no
// longer exists. Self-heal as a clean uninstall (strip + drop the entry) rather than
// looping on a never-satisfiable restore (code-review M).
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;
}
if (!restored) continue; // keep `_pending` so recovery is retried, never silently committed.
const refreshed = resyncCapabilitySharedEdits({ runtimeDir, capId: id, sharedFiles: candidateFiles });
const cleared: LedgerEntry = { ...entry, sharedEdits: refreshed };
delete cleared._pending;
workingLedger.entries[id] = cleared; // DOS-2: in-memory update; single write at end.
ledgerDirty = true;
report.rolledBack.push(id);
} catch (entryErr) {
// W-6: surface the failed entry as a warning and keep going with the rest.
report.warnings.push(
`Reconcile could not roll back capability "${id}": ${(entryErr as Error).message}`,
);
}
}
// DOS-2: write the accumulated step-1 mutations exactly ONCE.
if (ledgerDirty) {
workingLedger.updatedAt = new Date().toISOString();
try {
ledgerMod.writeLedger(runtimeDir, workingLedger);
} catch (writeErr) {
report.warnings.push(
`Reconcile could not persist rolled-back ledger state: ${(writeErr as Error).message}`,
);
}
}
ledger = ledgerMod.readLedger(runtimeDir);
}
// --- Step 2: sweep leftover backups (committed ops) + staging orphans. ---
let entries: string[] = [];
try {
entries = fs.readdirSync(root);
} catch {
try { report.ledger = ledgerMod.reconcile(runtimeDir); } catch { /* best-effort */ }
return report;
}
for (const name of entries) {
// DUR-6: sweep `.discard-*` dirs left by an interrupted upgrade-rollback (the uncommitted new
// bundle that was moved aside before the backup was renamed back in). They never carry a live
// intent, so they are always safe to drop here.
if (/\.discard-\d+-\d+-[0-9a-f]+$/.test(name)) {
try { fs.rmSync(path.join(root, name), { recursive: true, force: true }); report.orphansRemoved.push(name); } catch { /* best-effort */ }
continue;
}
// Match both the legacy `<id>.upgrading-<pid>-<ts>` and the nonce'd `<id>.upgrading-<pid>-<ts>-<hex>`.
const m = /^(.+)\.upgrading-\d+-\d+(?:-[0-9a-f]+)?$/.exec(name);
if (!m) continue;
const id = m[1];
// If a pending intent still references this backup, step 1 left it (failed restore) — keep it.
const entry = ledger && Object.prototype.hasOwnProperty.call(ledger.entries, id) ? ledger.entries[id] : null;
if (entry && entry._pending && entry._pending.backupName === name) continue;
// No live intent => the op committed (apply ran before commit) — drop the stale backup.
try {
fs.rmSync(path.join(root, name), { recursive: true, force: true });
report.rolledForward.push(id);
} catch { /* best-effort */ }
}
// Clean staging orphans — but spare recently-created dirs, which may belong to an in-flight
// resolve that has not yet acquired this lock (resolve stages BEFORE locking; Codex R3 M7).
const stagingRoot = path.join(root, '.staging');
try {
const now = Date.now();
for (const s of fs.readdirSync(stagingRoot)) {
const p = path.join(stagingRoot, s);
try {
const st = fs.statSync(p);
if (now - st.mtimeMs <= STAGING_ORPHAN_MS) continue; // too fresh — could be live
fs.rmSync(p, { recursive: true, force: true });
report.orphansRemoved.push(s);
} catch { /* best-effort */ }
}
} catch { /* no staging dir */ }
// W-3 / DUR-5: sweep STALE ledger temp orphans (`.gsd-capabilities.json.tmp.<pid>-<nonce>`) from
// the runtime dir. A double-IO-error (or Windows AV lock) during writeLedger's cleanup-unlink can
// leave a temp behind; without this sweep they accumulate forever. Spare recently-created ones,
// which may belong to an in-flight write in another process. Best-effort.
try {
const now = Date.now();
const tmpPrefix = `${ledgerMod.LEDGER_FILE_NAME}.tmp.`;
for (const f of fs.readdirSync(runtimeDir)) {
if (!f.startsWith(tmpPrefix)) continue;
const p = path.join(runtimeDir, f);
try {
const st = fs.statSync(p);
if (now - st.mtimeMs <= LEDGER_TMP_ORPHAN_MS) continue; // too fresh — could be a live write
fs.rmSync(p, { force: true });
report.orphansRemoved.push(f);
} catch { /* best-effort */ }
}
} catch { /* runtimeDir unreadable — nothing to sweep */ }
try { report.ledger = ledgerMod.reconcile(runtimeDir); } catch { /* best-effort */ }
return report;
} finally {
releaseLock(lock);
}
}
// ---------------------------------------------------------------------------
// outdatedCapabilities (ADR-1244 D6 "Update available?"; #1463)
// ---------------------------------------------------------------------------
/** One row of the `outdated` report: the installed capability vs. its source's latest version. */
interface OutdatedRecord {
id: string;
/** Source kind discriminant (git | npm | local | tarball | registry | unknown). */
sourceKind: string;
/** Installed version (from the ledger entry). */
current: string | null;
/** Latest available version at the source, or null when not resolvable. */
latest: string | null;
/**
* outdated (latest > current) | current (latest <= current) | pinned (recorded source pinned to an
* immutable/explicit ref or exact version — update will not move it) | manual (tarball) | unknown
* (peek failed/unsupported).
*/
status: 'outdated' | 'current' | 'pinned' | 'manual' | 'unknown';
}
/**
* #1463 (ADR-1244 D6): for every installed overlay in `runtimeDir`'s ledger, peek its recorded source
* for the latest available version and classify it. This is a LIGHT remote read per entry (the source
* module's metadata-only peek); it NEVER throws on a single bad entry — that entry is reported with
* status 'unknown'. Status rules:
* - peek 'ok' → compare latest vs current (compareSemverCore): latest > current ⇒ 'outdated', else 'current'.
* - peek 'pinned' → 'pinned' (#1463: source pinned to an immutable/explicit git ref or exact npm
* version — `update` re-resolves the SAME ref/version, so it is NEVER outdated; the
* peek's optional `version` is informational only).
* - peek 'manual' → 'manual' (tarball: not auto-detectable per D6).
* - peek 'unsupported'/'unknown' → 'unknown' (registry unimplemented, or the peek failed/timed out).
*
* An empty/missing ledger yields an empty array (non-throwing — readLedger returns null on a missing or
* corrupt-present ledger; the `outdated` report is read-only and degrades to "nothing to report").
*
* @param opts.runtimeDir the scope root holding `.gsd-capabilities.json`.
* @param opts.execOverrides threaded to the source peek (test seam — mock git ls-remote / npm view).
*/
function outdatedCapabilities(opts: {
runtimeDir: string;
execOverrides?: Record<string, unknown>;
}): OutdatedRecord[] {
const { runtimeDir, execOverrides } = opts;
const records: OutdatedRecord[] = [];
const ledger = ledgerMod.readLedger(runtimeDir);
if (!ledger || !ledger.entries) return records;
for (const id of Object.keys(ledger.entries)) {
const entry = ledger.entries[id];
// Defensive: a hostile/partial ledger entry must never crash the sweep — report it 'unknown'.
const current = entry && typeof entry.version === 'string' ? entry.version : null;
const source = entry && typeof entry.source === 'string' ? entry.source : '';
let sourceKind = 'unknown';
try {
sourceKind = sourceMod.parseSpec(source).kind;
} catch { /* unparseable source — leave kind 'unknown' */ }
let peek: { status: string; version: string | null };
try {
peek = sourceMod.peekLatestVersion(source, execOverrides ? { execOverrides } : undefined);
} catch (err) {
// peekLatestVersion is contractually non-throwing, but belt-and-suspenders: a single bad entry
// must never abort the whole report.
records.push({ id, sourceKind, current, latest: null, status: 'unknown' });
void err;
continue;
}
let status: OutdatedRecord['status'];
let latest: string | null = peek.version;
if (peek.status === 'pinned') {
// #1463: the recorded source is pinned (immutable/explicit git ref or exact npm version). `update`
// re-resolves the SAME ref/version, so it can never be outdated. `latest` carries the peek's
// informational version when one is known (exact-pinned npm), else null (a pinned git ref is not
// peeked for a tag).
status = 'pinned';
} else if (peek.status === 'manual') {
status = 'manual';
} else if (peek.status === 'ok' && peek.version && current) {
status = semverMod.compareSemverCore(peek.version, current) > 0 ? 'outdated' : 'current';
} else if (peek.status === 'ok' && peek.version && !current) {
// We have a latest but no recorded current — cannot compare; treat as unknown (no false 'outdated').
status = 'unknown';
} else {
// unsupported / unknown / ok-but-empty → unknown.
status = 'unknown';
latest = peek.version ?? null;
}
records.push({ id, sourceKind, current, latest, status });
}
return records;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export = {
installCapability,
upgradeCapability,
removeCapability,
reconcileCapabilities,
outdatedCapabilities,
applyCapabilitySharedEdits,
stripCapabilitySharedEdits,
// #1460 CONF-2: exported so the ancestor-symlink confinement is locked in by a regression test.
confinedSharedFile,
// #1460 (R) HIGH: exported so the shell-unsafe-script defense-in-depth (returns null for an
// unsafe-char script even when the file exists in the bundle) is locked in by a regression test.
confinedBundleScript,
CAP_MARKER,
// Exported for cross-process-lock unit tests (CONC-1/CONC-2/finding-1). Not part of the public CLI
// 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: lockMod.getProcessStartTime,
_setLockProbes: lockMod._setLockProbes,
_resetLockProbes: lockMod._resetLockProbes,
};