Files
msd-core/src/capability-lifecycle.cts
Tom Boucher 871621c3c8 feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6) (#1742)
* feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6)

ADR-1703 Phase 6 of the cross-platform portability epic (#1702). Adds the
second production-code portability AST rule + the ADR-mandated glob expansion
to bin/install.js and scripts/build-hooks.js.

- eslint-rules/require-fs-op-fallback.cjs: flags an unguarded fs.rename /
  fs.renameSync (the atomic-publish primitive named first in
  DEFECT.WINDOWS-FS-OPS.symptom) that is NOT inside a try/catch whose handler
  references a transient errno ('EPERM'/'EBUSY'/'EACCES' or a *RETRY_ERRNOS
  set) AND NOT behind a Windows platform guard. A catch that silently swallows
  or cleans-up-and-rethrows without an errno check does NOT satisfy the
  defect's 'never silently swallow' clause. copyFile/unlink are deliberately
  not flagged (they are the fallback primitives per the defect's own
  fix-forward). Scope narrowed to rename per Phase 5's precision discipline;
  documented on #1740.

- src/shell-command-projection.cts: export retryRenameSync(from, to) — the
  drop-in bounded-retry helper over the existing atomicRenameWithRetry.

- 27 bare fs.renameSync sites across 11 modules routed through retryRenameSync
  (capability-lifecycle/lock/source, installer-migrations, milestone, phase,
  planning-workspace, roadmap-upgrade, runtime-hooks-surface, state,
  workstream). Idempotent on POSIX; resilient to AV/indexer transient locks
  on Windows.

- eslint.config.mjs: register rule at error on src/**/*.cts; new focused
  portability-rules block covering bin/install.js + scripts/build-hooks.js
  (ADR-1703 L124-126 glob expansion — both files are compliant: zero
  rename violations).

- tests: 15-case RuleTester suite; portability-rule-disable-ban extended
  (PROTECTED_RULES + scans bin/install.js/build-hooks.js with shebang
  handling); ci-test-scope portability-lint selection rule.

- CONTEXT.md DEFECT.WINDOWS-FS-OPS predicate rewritten to point at the rule;
  docs/contributing/cross-platform-portability-rules.md reference + how-to.

Closes #1740

* chore(#1740): backfill changeset pr:1742

* fix(#1740): tighten require-fs-op-fallback precision (codex review HIGH-1/HIGH-2)

Addresses two false-negative findings from the codex (gpt-5.5/high)
adversarial review of PR #1742:

HIGH-1 — a catch that REFERENCES a transient errno but only rethrows (no
retry/fallback) was marked compliant. The DEFECT.WINDOWS-FS-OPS fix-forward
requires retry, not just recognition. Fix: catchHandlerHasRetrySignal now
requires a loop `continue` backedge OR a `return <call>` delegation; a bare
rethrow is flagged. The misleading `/* retry logic */` valid test is replaced
with a real retry loop, and the rethrow-only shape is added as invalid.

HIGH-2 — the nested-try ancestor walk treated an OUTER errno-catch as
protecting the rename even when an INNER catch intercepted/swallowed the error
(the outer catch is unreachable). Fix: isInsideTransientErrnoTryCatch now stops
at the NEAREST enclosing TryStatement WITH A CATCH HANDLER whose block contains
the rename (try-finally is skipped — it doesn't catch); outer catches are no
longer consulted. The unsound nested-try valid test is converted to invalid,
and a try-finally-skipped valid case is added.

Verified: 17 RuleTester cases pass; zero new production violations (the 27
fixed sites use retryRenameSync; the real retry loops — atomicRenameWithRetry,
capability-ledger/consent, build-hooks — remain compliant via continue/errno);
lint:ci green; disable-ban + vocab-drift green.

---------

Co-authored-by: review-bot <review-bot@gsd>
2026-06-25 23:55:58 -04:00

1788 lines
90 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 }) => 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';
/** 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._/-]+$/;
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;
}
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) {
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.
*/
function bindProjectConsent(opts: LifecycleOptions, id: string, integrity: string, manifest: Record<string, unknown>): void {
// #1459 IC-07: a project-scope op WITHOUT a consent store cannot bind — warn (then nothing to do).
if (!shouldBindConsent(opts)) {
warnIfConsentSkipped(opts, id);
return;
}
try {
consentMod.recordProjectConsent({
gsdHome: opts.consentStoreDir,
// #1459 IC-01/CB-4: bind the record's projectRoot through the SINGLE canonical helper so the
// RECORD key matches the loader's LOOKUP key (consentProjectRoot) and `trust revoke`. The bundle
// hash is still taken over the ACTUAL on-disk install location (capDir(opts.runtimeDir, id)).
projectRoot: projectRootMod.consentProjectRoot(opts.runtimeDir),
id,
integrity,
disclosureSignature: trustMod.signatureForManifest(manifest),
contentHash: consentMod.bundleContentHash(capDir(opts.runtimeDir, id)),
});
} catch (err) {
// #1459 IC-05/WIN-2: a consent-store write failure (read-only/UNC/NFS store) must NOT turn an
// otherwise-successful install/upgrade into a failure — the bundle is already committed. Surface a
// non-fatal warning (naming the store path so the operator can fix permissions and re-consent via
// `gsd capability trust`), and let the op SUCCEED. The cap is simply inactive until consent writes.
const storePath = (() => {
try { return consentMod.consentStorePath(opts.consentStoreDir); } catch { return String(opts.consentStoreDir); }
})();
warnConsent(
`could not write the consent record for "${id}" to "${storePath}": ${(err as Error).message}. ` +
`The install succeeded but this capability stays INACTIVE until consent can be recorded.`,
);
}
}
// ---------------------------------------------------------------------------
// Install
// ---------------------------------------------------------------------------
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,
});
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,
});
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,
};