fix(#1462): fail closed without data loss on a corrupt capability ledger; atomic ledger write (#1469)

This commit is contained in:
Tom Boucher
2026-06-19 19:55:10 -04:00
committed by GitHub
parent 7195c2a90b
commit c866ac1b24
9 changed files with 6039 additions and 240 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1469
---
**Capability ledger: fail closed on corruption, with durable atomic writes and a race-safe install lock.** A corrupt or unreadable `.gsd-capabilities.json` is now left in place and surfaced (not silently overwritten) — `install`/`update`/`remove`/`list`/`reconcile` fail closed and report it, so a corrupt ledger can no longer wipe prior capabilities' tracked files and shared-config fragments (which previously left unremovable orphans in `settings.json`/`hooks.json`). Ledger writes are atomic and crash-durable (exclusive temp file + `fsync` of file and directory + rename, with temp cleanup on failure). The per-capability lock is race-safe: a holder is identified by `(pid, process start-time, hostname)`, so a reused PID cannot deadlock recovery and a verifiably-live holder is never stolen, with a hard deadman timeout for unverifiable or cross-host holders. Untrusted ledger and lock reads are bounded (regular-file + size caps; FIFOs/devices rejected) and validated through a single shared entry validator (prototype-safe ids, DoS length caps). (#1462, ADR-1244.)

View File

@@ -83,7 +83,7 @@ A per-runtime install manifest, e.g. `~/.claude/.gsd-capabilities.json`, recordi
"source": "https://github.com/org/cap.git#sha:…",
"integrity": "sha512-…",
"files": ["skills/…", "agents/…"], // owned files written
"sharedEdits": [{ "file": "settings.json", "path": "hooks.PostToolUse[…]" }]
"sharedEdits": [{ "file": "settings.json", "marker": "<id>" }]
}
}
```

View File

@@ -158,7 +158,7 @@ gsd capability list [--json]
| Flag | Description |
|---|---|
| `--json` | Emit the JSON array explicitly. (In 1.6.0 `list` always emits JSON; a formatted table is planned.) |
| `--json` | Currently a **no-op**: `list` always emits the JSON array regardless of this flag. The flag is accepted for forward compatibility — a formatted human-readable table is planned, at which point `--json` will select the JSON form. Do not rely on omitting `--json` to get non-JSON output today. |
**Behaviour**

View File

@@ -1503,6 +1503,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
return '0.0.0';
}
};
// UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
// (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
// bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
// reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
// follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
const capRunReconcile = (runtimeDir, lifecycle) => {
try {
const report = lifecycle.reconcileCapabilities({ runtimeDir });
if (report && Array.isArray(report.warnings)) {
for (const w of report.warnings) {
try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
}
}
} catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
};
if (capSubcommand === 'state') {
const configDirIdx = args.indexOf('--config-dir');
let configDir = null;
@@ -1601,13 +1616,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./lib/capability-lifecycle.cjs');
const trust = require('./lib/capability-trust.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
// Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
// staging, or any shared-config write — so an over-cap install fails fast with a clear count
// error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
// depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
const installSharedFiles = capRepeatedFlag('--shared-file');
const ledgerModInstall = require('./lib/capability-ledger.cjs');
if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
error(
`capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
`exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
}
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
const res = await lifecycle.installCapability(spec, {
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
integrity: capFlagValue('--integrity'),
sharedFiles: capRepeatedFlag('--shared-file'),
sharedFiles: installSharedFiles,
strictKnownRegistries: capReadStrict(),
});
if (res.status === 'installed') {
@@ -1622,12 +1650,18 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// 'aborted' always means "executable surface needs consent" in the lifecycle contract —
// match it regardless of the requiresConsent flag so a future aborted path can't fall
// through to the generic "blocked: unknown reason" arm with a misleading message.
error(
['This capability declares executable surfaces and needs your consent before install:']
.concat(trust.summarizeDisclosure(res.disclosure || {}).map((l) => ' ' + l))
const disclosure = trust.summarizeDisclosure(res.disclosure || {});
// UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
// can detect the consent requirement programmatically. We throw ExitError (not error(),
// which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
// is flushed before exit; the human-readable guidance still lands on stderr.
output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
throw new ExitError(
1,
['Error: This capability declares executable surfaces and needs your consent before install:']
.concat(disclosure.map((l) => ' ' + l))
.concat(['Re-run with --yes to grant consent and install.'])
.join('\n'),
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
} else {
error(
@@ -1649,8 +1683,29 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const lifecycle = require('./lib/capability-lifecycle.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
const trust = require('./lib/capability-trust.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
const ledger = ledgerMod.readLedger(runtimeDir);
// Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
// the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
// re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
// a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
const updateSharedFiles = capRepeatedFlag('--shared-file');
if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
error(
`capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
`exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
}
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
// readLedgerStrict: returns null when MISSING (no installs yet), 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 (<id>)
// or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
let ledger;
try {
ledger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
const entries = (ledger && ledger.entries) || {};
const upgradeOne = async (capId) => {
const entry = entries[capId];
@@ -1661,18 +1716,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
sharedFiles: capRepeatedFlag('--shared-file'),
sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
strictKnownRegistries: capReadStrict(),
expectedId: capId,
});
// UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
// them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
// stable per-entry shape for `--all` consumers.
return {
id: capId,
status: r.status,
fromVersion: r.fromVersion,
toVersion: r.toVersion,
requiresConsent: r.requiresConsent,
blockReasons: r.blockReasons,
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : undefined,
fromVersion: r.fromVersion ?? null,
toVersion: r.toVersion ?? null,
requiresConsent: r.requiresConsent ?? null,
blockReasons: r.blockReasons ?? null,
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
};
};
if (all) {
@@ -1684,12 +1742,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
const failed = results.filter((x) => x.status !== 'upgraded');
if (failed.length > 0) {
// Some entries did not upgrade (aborted / blocked) — surface as a non-zero exit so
// automation never reads a partial `--all` run as a clean success.
error(
`capability update --all: ${failed.length} of ${results.length} did not upgrade.\n` +
JSON.stringify({ scope, updated: results }, null, 2),
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
// UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
// alike), then set a non-zero exit. Previously the results JSON was embedded inside the
// error STRING on stderr, so automation could not parse a partial-failure run as
// structured data. We throw ExitError (not error(), which calls process.exit and would
// bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
// concise reason still lands on stderr.
output({ scope, updated: results }, raw);
throw new ExitError(
1,
`Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
`(see the JSON result on stdout for per-capability status).`,
);
}
output({ scope, updated: results }, raw);
@@ -1722,10 +1785,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./lib/capability-lifecycle.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
// Ledger first: an installed overlay is removable even if its id shadows a first-party name.
// Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
const removeLedger = ledgerMod.readLedger(runtimeDir);
// Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
// silently reporting "first-party cannot be removed" for any id (finding 7).
let removeLedger;
try {
removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
if (!inLedger) {
const base = require('./lib/capability-loader.cjs').loadRegistry();
@@ -1749,12 +1819,20 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
} else if (capSubcommand === 'list') {
// capability list [--json] — emits a JSON array of capability descriptors (first-party + overlay).
// capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
// When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
// corrupt unrelated ledger in another scope does not block a scoped list).
const loader = require('./lib/capability-loader.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
const semver = require('./lib/semver-compare.cjs');
const host = capHostVersion();
const rows = [];
const listScopeArg = capFlagValue('--scope');
// Validate --scope if provided.
if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// First-party capabilities are always included (they have no scope concept).
const base = loader.loadRegistry();
const fp = (base && base.capabilities) || {};
for (const capId of Object.keys(fp)) {
@@ -1770,9 +1848,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
title: cap.title || null,
});
}
for (const sc of ['global', 'project']) {
// Overlay scopes: honor --scope to read only the requested scope (finding 8).
const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
for (const sc of overlayScopes) {
const { runtimeDir } = capResolveScope(sc);
const ledger = ledgerMod.readLedger(runtimeDir);
// readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
// corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
// dropping overlay entries and returning a first-party-only list (site A fix, #1462).
let ledger;
try {
ledger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
// UX-3: name the offending scope so the user knows WHICH ledger to fix.
error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
if (!ledger || !ledger.entries) continue;
for (const capId of Object.keys(ledger.entries)) {
const entry = ledger.entries[capId];

View File

@@ -5,24 +5,25 @@
* what each capability install wrote. Serves as the atomic commit point and
* reconciliation basis for Phase 4 upgrade/remove operations.
*
* LEAF MODULE — imports ONLY: node:fs, node:path, node:os, and
* ./shell-command-projection.cjs (for platformWriteSync). No other src/ imports.
* LEAF MODULE — imports ONLY: node:fs, node:path, node:crypto. No other src/ imports.
*
* Exports:
* readLedger(runtimeDir) — structural-validated read, never throws
* writeLedger(runtimeDir, ledger) — atomic write via platformWriteSync
* readLedgerStrict(runtimeDir) — like readLedger but throws CorruptLedgerError when
* the file exists but is unparseable/invalid. The
* corrupt file is LEFT IN PLACE (not moved/quarantined)
* so every subsequent op also blocks until the user
* inspects and resolves it.
* writeLedger(runtimeDir, ledger) — atomic write (tmp + rename, crash-safe)
* recordInstall(runtimeDir, entry) — idempotent upsert of a ledger entry
* removeEntry(runtimeDir, capId) — remove a single entry by id
* reconcile(runtimeDir) — report orphans / stale entries (read-only)
* CorruptLedgerError — thrown by readLedgerStrict on corruption
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { platformWriteSync } = require('./shell-command-projection.cjs') as {
platformWriteSync: (filePath: string, content: string) => void;
};
import crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// Constants
@@ -31,6 +32,26 @@ const { platformWriteSync } = require('./shell-command-projection.cjs') as {
const LEDGER_FILE_NAME = '.gsd-capabilities.json';
const LEDGER_SCHEMA_VERSION = '1';
// ---------------------------------------------------------------------------
// CorruptLedgerError
// ---------------------------------------------------------------------------
/**
* Thrown by `readLedgerStrict` when the ledger file is present but cannot be
* parsed or is structurally invalid. The corrupt file is LEFT IN PLACE so that
* every subsequent operation also blocks until the user resolves it manually.
* Recovery: inspect the file, restore a backup, or move it aside to start fresh.
*/
class CorruptLedgerError extends Error {
/** Absolute path of the corrupt ledger file. */
ledgerPath: string;
constructor(message: string, ledgerPath: string) {
super(message);
this.name = 'CorruptLedgerError';
this.ledgerPath = ledgerPath;
}
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -57,34 +78,180 @@ interface LedgerFile {
// IO helpers
// ---------------------------------------------------------------------------
/** Pattern for valid capability IDs (must match this to be accepted as ledger keys). */
const VALID_ID_RE = /^[a-z][a-z0-9-]*$/;
/**
* Read and structurally validate the ledger file.
*
* Returns null if the file is missing, unreadable, or structurally invalid.
* Never throws.
* DOS-3 / finding 5(a): GENEROUS DoS backstop bounds — NOT product limits. No legitimate capability
* declares this many files or shared-config edits, but a hostile ledger with a 100k+-element array
* is rejected before it can be iterated/spread into a Set (memory/CPU DoS). Raised from the prior
* 256/64 (which risked false-rejecting large-but-legitimate installs) to clearly-generous bounds.
*/
function readLedger(runtimeDir: string): LedgerFile | null {
const filePath = path.join(runtimeDir, LEDGER_FILE_NAME);
const MAX_FILES = 10_000;
const MAX_SHARED_EDITS = 256;
/** Cap for `_pending.sharedFiles` (finding 3) — same generous bound as `sharedEdits`. */
const MAX_SHARED_FILES = 256;
/**
* Finding 3 (MEDIUM): GENEROUS DoS backstops on the ledger FILE itself, NOT product limits. The
* ledger is untrusted on-disk content; readLedgerRaw must not read+parse+materialize an unbounded
* file. Before reading, `statSync` and reject (fail-closed via the corrupt path) if `size` exceeds
* LEDGER_MAX_BYTES. And enforce MAX_ENTRIES during validation so a hostile ledger with millions of
* keys cannot weaponize Object.keys iteration. 8 MiB / 4096 entries are far beyond any real install
* (a typical entry is a few hundred bytes; 4096 capabilities is wildly more than any user installs).
*/
const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
const MAX_ENTRIES = 4096;
/**
* Returns true when `id` must never be used as an object key or ledger entry id — either
* because it would cause prototype pollution or because it fails the kebab-case constraint.
*
* Security note: uses INLINE LITERAL key comparisons (do NOT use a Set or computed lookup)
* as required by the CodeQL prototype-pollution barrier — a Set.has call could itself be
* attacked via a poisoned prototype.
*/
function isUnsafeCapabilityId(id: unknown): boolean {
if (typeof id !== 'string') return true;
if (id === '__proto__') return true;
if (id === 'constructor') return true;
if (id === 'prototype') return true;
if (!VALID_ID_RE.test(id)) return true;
return false;
}
/**
* Sentinel for distinguishing IO errors (EACCES, EISDIR, EPERM, …) from
* parse/validation failures. Thrown internally by readLedgerRaw; caught by the
* two public readers to produce the right error type or return value.
*/
class LedgerIOError extends Error {
code: string | undefined;
constructor(message: string, code?: string) {
super(message);
this.name = 'LedgerIOError';
this.code = code;
}
}
/**
* Finding 2 (HIGH): the SINGLE shared robust bounded reader for every untrusted on-disk file the
* capability stack reads (the ledger here AND the .lock body in capability-lifecycle, which imports
* this). A path-`stat`(path)+`readFileSync`(path) pair is NOT safe: a FIFO, a symlink to a character
* device like /dev/zero, or a regular file SWAPPED/GROWN between the stat and the read defeats the
* size cap and can BLOCK (FIFO with no writer) or read UNBOUNDED (infinite device). Project-scope
* ledgers are repo-plantable, so this is a repo-borne DoS.
*
* The fix binds the type+size decision to the SAME open fd we read from:
* 1. openSync(path, O_RDONLY|O_NONBLOCK) — open ONCE, NON-BLOCKING. The O_NONBLOCK is essential:
* a plain openSync of a FIFO BLOCKS until a writer appears (the
* very hang we are defending against); O_NONBLOCK returns the fd
* immediately so fstat can reject it. (Symlinks are still followed
* to their target, as a read would; O_NONBLOCK is ignored for a
* regular file.)
* 2. fstatSync(fd) — stat the OPENED fd (not the path) — defeats the stat-then-read
* swap and reads the REAL target's type/size.
* 3. require stat.isFile() — reject FIFO / device / directory / symlink-to-nonregular. A
* directory keeps the legacy `EISDIR` code so existing callers
* that branch on it are unchanged.
* 4. require stat.size <= maxBytes — refuse an oversized regular file WITHOUT reading it whole.
* 5. read EXACTLY stat.size bytes from the fd — never an unbounded streaming read.
* 6. closeSync(fd) in finally.
*
* Returns the file content as a string, or null for ENOENT (genuinely missing). Throws LedgerIOError
* for every other condition (non-regular, oversized, IO error) so callers fail closed. Behavior for a
* normal small regular file is identical to the prior readFileSync(path,'utf8').
*/
function readSmallRegularFile(filePath: string, maxBytes: number): string | null {
// O_RDONLY | O_NONBLOCK: never block on opening a FIFO/device — return the fd so fstat can reject it.
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK;
let fd: number;
try {
fd = fs.openSync(filePath, openFlags);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return null; // genuinely missing — not a corruption.
throw new LedgerIOError(`Cannot open ${filePath}: ${(err as Error).message}`, code);
}
try {
const st = fs.fstatSync(fd);
if (!st.isFile()) {
// FIFO / device / directory / symlink-to-nonregular. Preserve EISDIR for a directory so callers
// that distinguish it (and existing tests) still see that code; other non-regular kinds get a
// synthetic ENXIO. Either way it is an unreadable, fail-closed condition (not content parsing).
const code = st.isDirectory() ? 'EISDIR' : 'ENXIO';
throw new LedgerIOError(
`Cannot read ${filePath}: not a regular file (unreadable; FIFO/device/directory) — refusing.`,
code,
);
}
if (st.size > maxBytes) {
throw new LedgerIOError(
`Cannot read ${filePath}: file size ${st.size} bytes exceeds the maximum of ${maxBytes} ` +
`bytes (refusing to read an oversized file). Inspect or move it aside.`,
'EFBIG',
);
}
if (st.size === 0) return '';
const buf = Buffer.allocUnsafe(st.size);
let off = 0;
// Read EXACTLY st.size bytes from the fd (never a streaming/unbounded read).
while (off < st.size) {
const n = fs.readSync(fd, buf, off, st.size - off, off);
if (n <= 0) break; // EOF earlier than fstat reported (truncated under us) — return what we got.
off += n;
}
return buf.toString('utf8', 0, off);
} catch (err) {
if (err instanceof LedgerIOError) throw err;
throw new LedgerIOError(`Cannot read ${filePath}: ${(err as Error).message}`, (err as NodeJS.ErrnoException).code);
} finally {
try { fs.closeSync(fd); } catch { /* best-effort */ }
}
}
/**
* Read and structurally validate the ledger file. Throws LedgerIOError when the
* file cannot be read due to an OS error (EACCES, EISDIR, EPERM, …). Returns
* null when the file is missing (ENOENT) or when its content fails validation.
* Never throws for parse or validation failures — those become null.
*/
function readLedgerRaw(runtimeDir: string): LedgerFile | null {
const filePath = path.join(runtimeDir, LEDGER_FILE_NAME);
// Finding 3 (MEDIUM) + Finding 2 (HIGH): the ledger file is untrusted. Read it via the shared
// fd-based bounded reader (open → fstat → require regular file → size cap → read exactly size). A
// FIFO/device/symlink-to-device or a stat-then-read swap can no longer block or bypass the cap; an
// oversized/non-regular file is surfaced as a LedgerIOError (a "cannot read" condition, not a
// content-parse failure) so readLedger returns null and readLedgerStrict rethrows it — every
// subsequent op then fails closed until the user resolves it, exactly like the corrupt path.
let raw: string;
try {
const content = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
if (content === null) return null; // genuinely missing — not a corruption.
raw = content;
} catch (err) {
if (err instanceof LedgerIOError) throw err; // non-regular / oversized / IO — fail closed.
throw new LedgerIOError(`Cannot read ledger at ${filePath}: ${(err as Error).message}`, (err as NodeJS.ErrnoException).code);
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
const p = parsed as Record<string, unknown>;
if (typeof p['version'] !== 'string') return null;
if (typeof p['updatedAt'] !== 'string') return null;
// Schema version must be the expected value (not any string) — finding 11.
if (p['version'] !== LEDGER_SCHEMA_VERSION) return null;
// updatedAt must be a non-empty string — finding 11.
if (typeof p['updatedAt'] !== 'string' || !p['updatedAt']) return null;
if (typeof p['entries'] !== 'object' || p['entries'] === null || Array.isArray(p['entries'])) return null;
// Shallow-validate each entry
// Validate each entry via isValidLedgerEntry — THE single validator (ROOT FIX 1).
// This eliminates the previous inline duplication and guarantees readLedger and
// isValidLedgerEntry can never diverge.
const entries = p['entries'] as Record<string, unknown>;
for (const key of Object.keys(entries)) {
const e = entries[key];
if (typeof e !== 'object' || e === null) return null;
const entry = e as Record<string, unknown>;
if (typeof entry['id'] !== 'string') return null;
if (typeof entry['version'] !== 'string') return null;
if (typeof entry['source'] !== 'string') return null;
if (typeof entry['integrity'] !== 'string') return null;
if (!Array.isArray(entry['files'])) return null;
if (!Array.isArray(entry['sharedEdits'])) return null;
const keys = Object.keys(entries);
// Finding 3 (MEDIUM): cap the entry COUNT so a hostile ledger with millions of keys cannot
// weaponize per-entry validation/iteration (the size cap above already bounds the parse; this
// bounds the post-parse key count). Generous DoS backstop, not a product limit.
if (keys.length > MAX_ENTRIES) return null;
for (const key of keys) {
if (!isValidLedgerEntry(key, entries[key])) return null;
}
return {
version: p['version'],
@@ -97,13 +264,358 @@ function readLedger(runtimeDir: string): LedgerFile | null {
}
/**
* Write the ledger atomically via platformWriteSync (mkdirSync + tmp+rename).
* Validate a single ledger entry object against the per-entry shape that readLedger enforces.
* This is THE single validator — readLedger/readLedgerRaw call it per-entry instead of
* duplicating inline checks (ROOT FIX 1 — single source of truth; #1459 will also consume this).
*
* Returns true when the entry is structurally valid for the given `id` key.
* Returns false for any structural violation:
* - id is an unsafe prototype-pollution key (__proto__, constructor, prototype)
* - id fails the kebab-case constraint (VALID_ID_RE)
* - entry.id field missing or not matching the key
* - missing/wrong-type required fields (version, source, integrity)
* - files[] with non-string members
* - sharedEdits[] with missing / non-string file or marker fields
* - _pending present but wrong shape (kind not 'install'/'upgrade', bad backupName, missing sharedFiles[])
*/
function isValidLedgerEntry(id: unknown, entry: unknown): boolean {
// ROOT FIX 3: reject unsafe ids using inline literal checks (CodeQL-safe pattern).
if (isUnsafeCapabilityId(id)) return false;
if (typeof entry !== 'object' || entry === null) return false;
const e = entry as Record<string, unknown>;
if (typeof e['id'] !== 'string' || e['id'] !== id) return false;
if (typeof e['version'] !== 'string') return false;
if (typeof e['source'] !== 'string') return false;
if (typeof e['integrity'] !== 'string') return false;
if (!Array.isArray(e['files'])) return false;
// DOS-3 / finding 5(a): cap array sizes so a hostile ledger cannot weaponize a 100k+-element
// files[] (or sharedEdits[]/_pending.sharedFiles[]) into a memory/CPU DoS at validation/reconcile
// time. These are GENEROUS DoS backstops, NOT product limits — no legitimate capability declares
// 10k files or 256 shared-config edits, but a 100k+ hostile array is rejected (not iterated).
if (e['files'].length > MAX_FILES) return false;
for (const f of e['files'] as unknown[]) {
if (typeof f !== 'string') return false;
}
if (!Array.isArray(e['sharedEdits'])) return false;
if (e['sharedEdits'].length > MAX_SHARED_EDITS) return false; // DOS-3 (see above)
for (const se of e['sharedEdits'] as unknown[]) {
if (se === null || typeof se !== 'object') return false;
const seObj = se as Record<string, unknown>;
if (typeof seObj['file'] !== 'string' || !seObj['file']) return false;
if (typeof seObj['marker'] !== 'string' || !seObj['marker']) return false;
}
// Validate _pending shape if present (ROOT FIX 1 — previously only in readLedgerRaw).
if (Object.prototype.hasOwnProperty.call(e, '_pending')) {
const pending = e['_pending'];
if (pending !== undefined) {
if (typeof pending !== 'object' || pending === null) return false;
const p = pending as Record<string, unknown>;
if (p['kind'] !== 'install' && p['kind'] !== 'upgrade') return false;
// backupName must be string or null — not a number or object.
if (p['backupName'] !== null && typeof p['backupName'] !== 'string') return false;
if (!Array.isArray(p['sharedFiles'])) return false;
// Finding 3: _pending.sharedFiles was previously ONLY Array.isArray-checked, so a hostile
// ledger with a 500k-element (or non-string) _pending.sharedFiles was accepted and later
// spread into a Set + iterated in reconcileCapabilities (DoS bypass). Cap its length with the
// same generous bound as sharedFiles and require every member to be a string.
if ((p['sharedFiles'] as unknown[]).length > MAX_SHARED_FILES) return false;
for (const sf of p['sharedFiles'] as unknown[]) {
if (typeof sf !== 'string') return false;
}
}
}
return true;
}
/**
* Validate a WHOLE ledger-file object against the SAME structural rules a strict read enforces
* (finding 5 — LOW): the schema version, a non-empty `updatedAt`, an entries map within MAX_ENTRIES,
* and every entry valid via isValidLedgerEntry. Used by recordInstall to gate the in-lock
* `baseLedger` fast-path so an invalid caller-supplied base can never be written verbatim. Never
* throws; returns false for any structural violation.
*/
function isValidLedgerFile(base: unknown): base is LedgerFile {
if (typeof base !== 'object' || base === null || Array.isArray(base)) return false;
const b = base as Record<string, unknown>;
if (b['version'] !== LEDGER_SCHEMA_VERSION) return false;
if (typeof b['updatedAt'] !== 'string' || !b['updatedAt']) return false;
const entriesVal = b['entries'];
if (typeof entriesVal !== 'object' || entriesVal === null || Array.isArray(entriesVal)) return false;
const entries = entriesVal as Record<string, unknown>;
const keys = Object.keys(entries);
if (keys.length > MAX_ENTRIES) return false;
for (const key of keys) {
if (!isValidLedgerEntry(key, entries[key])) return false;
}
return true;
}
/**
* Read and structurally validate the ledger file.
*
* Returns null if the file is missing or structurally invalid.
* Returns the parsed ledger when the file is valid.
* On IO errors (EACCES, EISDIR, EPERM), returns null (non-throwing, compatible with old API).
* Never throws.
*/
function readLedger(runtimeDir: string): LedgerFile | null {
try {
return readLedgerRaw(runtimeDir);
} catch (err) {
if (err instanceof LedgerIOError) {
// IO error — treat as unreadable (return null) so callers are not broken.
// readLedgerStrict will surface the real error.
return null;
}
return null;
}
}
/**
* Like `readLedger` but distinguishes missing-vs-corrupt, and surfaces IO errors distinctly:
* - File missing → returns null (no ledger yet, fresh start is fine).
* - File present and valid → returns the parsed LedgerFile.
* - File present but unparseable/invalid CONTENT → throws CorruptLedgerError. The file is
* LEFT IN PLACE (not moved, renamed, or deleted) so every subsequent operation also
* blocks until the user resolves it. Recovery: inspect the file, restore a backup,
* or move it aside yourself to start fresh.
* - File present but unreadable (EACCES, EPERM, EISDIR, …) → throws LedgerIOError with
* the original OS errno/code preserved. This is an IO/permission problem — NOT a content
* corruption — and callers should surface it as such (finding 4).
*
* Callers that must fail-closed on corruption (upgrade, remove, install) should use this
* instead of `readLedger` so they never mistake a corrupt file for "not installed".
*/
function readLedgerStrict(runtimeDir: string): LedgerFile | null {
const filePath = path.join(runtimeDir, LEDGER_FILE_NAME);
let raw: LedgerFile | null;
try {
raw = readLedgerRaw(runtimeDir);
} catch (err) {
if (err instanceof LedgerIOError) {
// IO error (EACCES, EPERM, EISDIR, …) — rethrow as-is so callers see it as an IO
// problem with the original errno, not as content corruption (finding 4).
throw err;
}
throw err; // unexpected — propagate
}
if (raw !== null) return raw;
// readLedgerRaw returned null: either genuinely missing or present-but-invalid (or unreadable).
// ROOT FIX 4: use lstatSync (not existsSync) to detect dangling/broken symlinks.
// existsSync follows the symlink and returns false for a broken symlink, making the ledger
// appear "missing" when it is actually an IO problem — so a broken symlink would silently
// allow a "fresh install" over a dangling ledger pointer, losing all prior records.
// lstatSync checks the directory entry itself (not the target) — if it exists (even as a
// broken symlink), that is NOT "missing": surface it as an IO error so every subsequent op
// also fails closed until the user resolves it.
let lstatResult: fs.Stats | null = null;
try {
lstatResult = fs.lstatSync(filePath);
} catch (lstatErr) {
const lstatCode = (lstatErr as NodeJS.ErrnoException).code;
if (lstatCode === 'ENOENT') return null; // genuinely missing directory entry — fresh start is fine.
// Any other lstat error (EACCES, EPERM, …) — treat as IO failure.
throw new LedgerIOError(
`Cannot stat ledger at ${filePath}: ${(lstatErr as Error).message}`,
lstatCode,
);
}
// lstat succeeded — the path exists in the directory (could be a broken symlink, dir, etc.).
if (lstatResult.isSymbolicLink()) {
// Broken symlink: the entry exists but the target is unreadable. This is an IO problem,
// not content corruption — surface as LedgerIOError (not CorruptLedgerError) so callers
// distinguish "I/O problem" from "corrupt content" (ROOT FIX 4).
throw new LedgerIOError(
`Ledger path ${filePath} is a broken or dangling symlink. ` +
`Remove or fix the symlink so the ledger can be read normally.`,
'ENOENT',
);
}
// BC-1: distinguish a future/unsupported SCHEMA VERSION from genuine corruption. readLedgerRaw
// returns null both when the JSON is unparseable AND when it parses cleanly but carries a
// version string we do not support (currently only '1' exists). A version bump should surface a
// clear "unsupported schema version X" message, not a misleading "corrupt or invalid". This is a
// best-effort re-parse for the message only — the file is still LEFT IN PLACE.
//
// FIRST SCHEMA BUMP: when a v2 schema is introduced, ADD A MIGRATION BRANCH here (and in
// readLedgerRaw) — read the old shape, migrate it forward, and write the upgraded ledger — rather
// than throwing. Until then there are no v0/v2 ledgers in the wild (no released version wrote one),
// so blocking on an unknown version is the safe fail-closed behavior.
try {
// Finding 2 (HIGH): the reparse is ALSO a read of the untrusted ledger path — a FIFO/device or a
// file swapped after the first read must not block/bypass the cap here. Route it through the same
// bounded fd reader (a null/throw means there's nothing safely reparseable → fall through to the
// generic corrupt message).
const reparsedRaw = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
const reparsed: unknown = reparsedRaw === null ? null : JSON.parse(reparsedRaw);
if (typeof reparsed === 'object' && reparsed !== null) {
const ver = (reparsed as Record<string, unknown>)['version'];
if (typeof ver === 'string' && ver !== LEDGER_SCHEMA_VERSION) {
throw new CorruptLedgerError(
`Capability ledger at ${filePath} uses unsupported ledger schema version "${ver}" ` +
`(this build supports version "${LEDGER_SCHEMA_VERSION}"). Upgrade GSD to a build that ` +
`understands this ledger, or move the file aside to start fresh.`,
filePath,
);
}
}
} catch (reparseErr) {
// A CorruptLedgerError from the unsupported-version branch must propagate; any other error
// (re-read/parse failure) means it is genuinely corrupt — fall through to the generic message.
if (reparseErr instanceof CorruptLedgerError) throw reparseErr;
}
// File exists (not a symlink, not missing) but failed validation — throw. The file is
// intentionally LEFT IN PLACE so that every subsequent op is also blocked until the user
// resolves it (finding 1): auto-moving it would let the NEXT op proceed as fresh state
// → data-loss/orphan outcome.
// W-2: the recovery hint must be platform-aware — a POSIX `mv` with a forward-slash path is wrong
// on Windows (backslash paths, no `mv`). Show the native rename command for the running platform.
const moveHint = process.platform === 'win32'
? `ren "${filePath}" "${path.basename(filePath)}.bak" (or PowerShell: Move-Item "${filePath}" "${filePath}.bak")`
: `mv "${filePath}" "${filePath}.bak"`;
throw new CorruptLedgerError(
`Capability ledger at ${filePath} is present but corrupt or invalid. ` +
`Inspect the file to recover your capability records, restore a known-good backup, ` +
`or move it aside to start fresh (e.g. ${moveHint}).`,
filePath,
);
}
/** W-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const RENAME_MAX_ATTEMPTS = 3;
const RENAME_RETRY_BACKOFF_MS = 50;
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
let _renameSleepBuf: Int32Array | null = null;
function renameBackoff(): void {
if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
}
/** 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 the directory CONTAINING `dest` so the just-completed rename is durable across a power loss
* (DUR-2). 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 fsyncContainingDir(dest: string): void {
let dirFd: number | null = null;
try {
dirFd = fs.openSync(path.dirname(dest), 'r');
fs.fsyncSync(dirFd);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
// Real storage error (e.g. EIO): the rename may already be visible but its durability could
// NOT be confirmed. Rethrow rather than silently claim success (finding 4).
throw new Error(
`Directory fsync of "${path.dirname(dest)}" failed (${code}); durability of the ledger ` +
`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 (dirFd !== null) { try { fs.closeSync(dirFd); } catch { /* best-effort */ } }
}
}
/**
* Write the ledger atomically AND durably (tmp file in the same dir → fsync → close → rename →
* dir fsync, no truncating fallback). Using a local implementation rather than platformWriteSync
* so that a crash or power-loss mid-write cannot produce a zero-byte / truncated ledger — the
* corrupt file that LEDGER-1 mishandled (ADR-1244 D4 fix).
*
* Durability sequence (DUR-1 / DUR-2):
* 1. writeFileSync(fd, content) — full-buffer write (no short-writes).
* 2. fsyncSync(fd) — flush the file's bytes to stable storage BEFORE the rename;
* otherwise a power-loss AFTER a successful rename can leave a
* zero/partial ledger (total loss). If fsync throws, the temp is
* unlinked and the error rethrown (treated as a write failure) —
* we NEVER rename a possibly-unflushed file live.
* 3. closeSync(fd) — a close error can also signal delayed-writeback failure;
* unlink the temp and rethrow before the rename.
* 4. renameSync(tmp, dest) — atomic install (retried on transient Windows AV locks, W-1).
* 5. fsyncSync(dirname fd) — make the rename itself durable (DUR-2).
*
* Security hardening (adversarial re-review):
* - Temp path includes a random nonce (not just pid) to avoid predictable names and resist
* collision between concurrent processes.
* - Temp file is created with the exclusive `wx` flag (O_EXCL) so a pre-planted symlink at the
* same path cannot redirect the write to another file.
* - On any failure (write, fsync, close, or rename) the temp file is cleaned up before
* rethrowing, and the primary error is always preserved (finding 13).
*/
function writeLedger(runtimeDir: string, ledger: LedgerFile): void {
platformWriteSync(
path.join(runtimeDir, LEDGER_FILE_NAME),
JSON.stringify(ledger, null, 2) + '\n',
);
const filePath = path.join(runtimeDir, LEDGER_FILE_NAME);
const content = JSON.stringify(ledger, null, 2) + '\n';
fs.mkdirSync(runtimeDir, { recursive: true });
// Unique nonce in the name prevents predictable-path attacks; wx (O_EXCL) prevents
// a pre-existing symlink from silently redirecting the write.
const nonce = crypto.randomBytes(4).toString('hex');
const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`;
const fd = fs.openSync(tmpPath, 'wx'); // exclusive create — throws if already exists
let primaryErr: Error | null = null;
try {
// Write as a Buffer in one call to prevent short-writes (finding 6).
// fs.writeFileSync(fd, …) internally uses a write-all loop that flushes the
// entire buffer before returning, unlike a bare writeSync which may short-write.
fs.writeFileSync(fd, content);
// DUR-1: fsync the file's contents to stable storage BEFORE closing/renaming. Without this a
// power-loss after a successful rename can leave a zero/partial ledger → total loss.
fs.fsyncSync(fd);
} catch (err) {
primaryErr = err instanceof Error ? err : new Error(String(err));
} finally {
// closeSync can also throw (finding 2): a close error on the write fd can signal
// delayed-writeback failure, meaning the data may not have been durably committed
// to storage. In that case we must NOT install the possibly-unflushed temp as the
// live ledger — unlink it and rethrow the close error before the rename.
let closeErr: Error | null = null;
try { fs.closeSync(fd); } catch (err) { closeErr = err instanceof Error ? err : new Error(String(err)); }
// If the write OR fsync failed, always clean up and rethrow that error (DUR-1).
if (primaryErr !== null) {
try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ }
throw primaryErr;
}
// Write+fsync succeeded but close threw — unlink the possibly-unflushed temp and rethrow
// the close error. NEVER proceed to rename a potentially unflushed file (finding 2).
if (closeErr !== null) {
try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ }
throw closeErr;
}
// Write, fsync, and close all succeeded — fall through to rename.
}
// W-1: renameSync can transiently fail on Windows when an AV scanner / file indexer holds a
// brief lock (EPERM/EBUSY/EACCES). Retry a few times with a short backoff before giving up.
let renameErr: Error | null = null;
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
try {
fs.renameSync(tmpPath, filePath);
renameErr = null;
break;
} catch (err) {
renameErr = err instanceof Error ? err : new Error(String(err));
const code = (err as NodeJS.ErrnoException).code ?? '';
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
renameBackoff();
continue;
}
break;
}
}
if (renameErr !== null) {
// Clean up the orphaned temp file before rethrowing.
try { fs.unlinkSync(tmpPath); } catch { /* best-effort */ }
throw renameErr;
}
// DUR-2: make the rename durable by fsyncing the containing directory (best-effort).
fsyncContainingDir(filePath);
}
// ---------------------------------------------------------------------------
@@ -116,15 +628,63 @@ function writeLedger(runtimeDir: string, ledger: LedgerFile): void {
* If an entry with the same id already exists it is replaced. The `updatedAt`
* timestamp is refreshed on every call. Rejects ids that would cause prototype
* pollution (__proto__, constructor, prototype).
*
* Uses `readLedgerStrict` so that a corrupt-but-present ledger fails closed (throws
* CorruptLedgerError, leaving the file in place) rather than silently overwriting it.
*
* DOS-4: `opts.baseLedger` lets an IN-LOCK caller pass the ledger it has ALREADY strict-read this
* critical section so recordInstall does not redundantly re-read+re-validate it (install does up to
* three strict reads per op). It is ONLY safe when the caller holds the mutation lock (so the
* on-disk ledger cannot change underneath the passed snapshot) AND obtained it via readLedgerStrict
* (so corruption was already fail-closed). The standalone strict read remains the DEFAULT — omit
* `baseLedger` and the strict guarantee is unchanged. A null/missing baseLedger falls back to the
* strict read; a non-object baseLedger is rejected.
*/
function recordInstall(runtimeDir: string, entry: LedgerEntry): void {
// Prototype-pollution guard — inline literal checks (CodeQL-safe pattern).
if (entry.id === '__proto__' || entry.id === 'constructor' || entry.id === 'prototype') {
// Silently ignore — the id is invalid and must never reach the ledger.
return;
function recordInstall(
runtimeDir: string,
entry: LedgerEntry,
opts?: { baseLedger?: LedgerFile | null },
): void {
// ROOT FIX 3: reject ALL unsafe ids with a throw (not silent return) — this includes
// prototype-pollution keys AND non-kebab ids. Using isUnsafeCapabilityId (which uses
// inline literal === checks — CodeQL-safe pattern) as the single gate.
if (isUnsafeCapabilityId(entry.id)) {
throw new Error(
`Invalid capability id "${entry.id}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
`Unsafe or non-kebab ids are rejected to prevent prototype pollution and ledger corruption.`,
);
}
// ROOT FIX 3 (finding 3): validate the WHOLE entry — not just entry.id — against the single
// per-entry validator. Otherwise recordInstall could write a structurally-invalid entry (e.g.
// files:[123] or a malformed sharedEdits member) that every subsequent readLedger/readLedgerStrict
// would then reject as corrupt — turning a bad write into a persistent self-inflicted lockout.
// Validating here makes recordInstall fail FAST (throw, write nothing) on a malformed entry.
if (!isValidLedgerEntry(entry.id, entry)) {
throw new Error(
`Refusing to record a structurally-invalid ledger entry for "${entry.id}": the entry fails ` +
`the ledger schema (check files[]/sharedEdits[]/version/source/integrity types). ` +
`Writing it would corrupt the ledger so every later read rejects it.`,
);
}
// DOS-4 + finding 5 (LOW): use the caller-supplied in-lock base ONLY when it passes the SAME
// validation a strict read would (version, updatedAt, entry-count cap, and every entry via
// isValidLedgerEntry). Previously the base was accepted on a shallow `entries is an object` check
// and written VERBATIM — so a caller passing an invalid base (bad version/updatedAt, or a malformed
// entry) would write a self-corrupting ledger that every later read rejects. Now an INVALID base is
// ignored and we fall back to the strict read (the default, unchanged strict guarantee), so the
// ledger is only ever derived from validated state.
let existing: LedgerFile | null;
const base = opts?.baseLedger;
if (base !== undefined && base !== null && isValidLedgerFile(base)) {
existing = base;
} else {
// readLedgerStrict: returns null when missing, parsed ledger when valid,
// throws CorruptLedgerError (leaving file in place) when present-but-corrupt.
existing = readLedgerStrict(runtimeDir);
}
const existing = readLedger(runtimeDir);
const ledger: LedgerFile = existing ?? {
version: LEDGER_SCHEMA_VERSION,
updatedAt: new Date().toISOString(),
@@ -140,11 +700,17 @@ function recordInstall(runtimeDir: string, entry: LedgerEntry): void {
/**
* Remove a single capability entry from the ledger by id.
*
* Returns true if the entry was present and removed, false if not found.
* Returns true if the entry was present and removed, false if GENUINELY not found.
*
* Finding 4 (fail-closed): uses `readLedgerStrict` (not the non-throwing `readLedger`) so a
* corrupt-but-present ledger THROWS (CorruptLedgerError / LedgerIOError, file left in place)
* rather than returning false. Returning false on corruption would let a corrupt ledger
* masquerade as "entry not installed" — a silent no-op that hides recorded state. `false` is
* now reserved exclusively for a genuinely-missing ledger or a genuinely-absent entry.
*/
function removeEntry(runtimeDir: string, capId: string): boolean {
const ledger = readLedger(runtimeDir);
if (ledger === null) return false;
const ledger = readLedgerStrict(runtimeDir); // throws on corrupt-present / IO error (fail-closed)
if (ledger === null) return false; // genuinely missing ledger — nothing installed
if (!Object.prototype.hasOwnProperty.call(ledger.entries, capId)) return false;
delete ledger.entries[capId];
ledger.updatedAt = new Date().toISOString();
@@ -179,7 +745,21 @@ function reconcile(runtimeDir: string): ReconcileResult {
const ledger = readLedger(runtimeDir);
if (ledger === null) {
const filePath = path.join(runtimeDir, LEDGER_FILE_NAME);
if (fs.existsSync(filePath)) {
// Finding 5: use lstatSync (not existsSync) to detect the directory ENTRY itself. existsSync
// FOLLOWS the symlink and returns false for a dangling/broken symlink — so a ledger that is a
// broken symlink would be reported "missing" (no warning) when it is actually an unreadable IO
// problem. lstatSync stats the entry without following it: any entry present (even a broken
// symlink) is NOT "missing" and must surface a warning.
let entryExists = false;
try {
fs.lstatSync(filePath);
entryExists = true;
} catch (lstatErr) {
// ENOENT — genuinely absent: nothing installed, not a warning. Any other error (EACCES,
// EPERM, …) means the entry is present-but-unreadable → treat as a parse/IO warning.
if ((lstatErr as NodeJS.ErrnoException).code !== 'ENOENT') entryExists = true;
}
if (entryExists) {
result.warnings.push(`Ledger file exists but could not be parsed: ${filePath}`);
}
// Missing ledger is not a warning — it simply means nothing has been installed.
@@ -218,10 +798,20 @@ function reconcile(runtimeDir: string): ReconcileResult {
export = {
readLedger,
readLedgerStrict,
writeLedger,
recordInstall,
removeEntry,
reconcile,
isValidLedgerEntry,
isUnsafeCapabilityId,
// Finding 2 (HIGH): the SINGLE shared bounded fd reader — also consumed by capability-lifecycle's
// lock-body reads so every untrusted file read goes through the regular-file + size-capped fd path.
readSmallRegularFile,
// Exported for testing / introspection
LEDGER_FILE_NAME,
CorruptLedgerError,
LedgerIOError,
// DoS backstop bounds — shared with the lifecycle/CLI early count check (finding 5).
MAX_SHARED_FILES,
};

File diff suppressed because it is too large Load Diff

View File

@@ -231,6 +231,36 @@ describe('capability update', () => {
assert.equal(o.toVersion, '2.0.0');
assert.equal(readLedgerEntry(home, 'upcap').version, '2.0.0');
});
// Finding 4 (MEDIUM): `capability update --shared-file` over-cap previously ran the pre-op
// reconcile (and re-parsed --shared-file per entry) BEFORE rejecting; install already had the early
// guard, update did not. The count must now be enforced BEFORE capRunReconcile.
//
// To PROVE reconcile did not run, the ledger is intentionally CORRUPT: a reconcile sweep would
// surface a "capability reconcile:" warning on stderr. The over-cap update must be rejected with a
// count error and that reconcile prefix must be ABSENT (reconcile never executed).
// Revert-fails: move the count check back below capRunReconcile (or drop it) → the corrupt-ledger
// reconcile runs first and emits "capability reconcile:" on stderr, so the "prefix absent"
// assertion fails (and/or the count error is missing).
test('finding-4: an OVER-CAP --shared-file update is rejected BEFORE the pre-op reconcile runs', () => {
const home = tmpDir('cap-cli-home-f4-');
fs.mkdirSync(home, { recursive: true });
// A corrupt ledger: if the pre-op reconcile RAN, it would emit a "capability reconcile:" warning.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const sharedArgs = [];
for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); } // over the 256 cap
const r = runGsdTools(
['capability', 'update', '--all', '--scope', 'global', ...sharedArgs, '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, false, 'an over-cap --shared-file update must be rejected');
const combined = `${r.error}\n${r.output}`;
assert.match(combined, /shared.?file|count|too many|256/i,
'the failure must clearly name the shared-file count problem');
assert.doesNotMatch(combined, /capability reconcile:/i,
'the pre-op reconcile must NOT have run — the count check precedes it (finding 4)');
});
});
// ─── remove ─────────────────────────────────────────────────────────────────
@@ -425,6 +455,36 @@ describe('capability install (--shared-file confinement)', () => {
assert.ok(!fs.existsSync(path.join(outside, 'settings.json')), 'must NOT write through the escaping symlink');
});
// Finding 5(b) (MEDIUM): the --shared-file COUNT must be bounded EARLY — at the CLI/lifecycle
// entry, BEFORE source resolution / staging / shared-config writes — so an over-cap install fails
// fast with a clear count error instead of writing files + leaving a _pending to reconcile.
// Revert-fails: remove the early count check in installCapability/gsd-tools → the install proceeds
// to staging (a .gsd/capabilities/.staging dir is created) before any cap is enforced, so the
// "no staging created" assertion fails (and there is no clear count error).
test('finding-5b: an install with OVER-CAP --shared-file count is rejected BEFORE any staging dir is created', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
const src = writeCapSource('overcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
// Build 300 --shared-file args (over the 256 generous cap).
const sharedArgs = [];
for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); }
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', ...sharedArgs, '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, false, 'an over-cap --shared-file install must be rejected');
assert.match(`${r.error}\n${r.output}`, /shared.?file|count|too many|256/i,
'the failure must clearly name the shared-file count problem');
// NO staging dir may have been created — the bound is enforced before resolution/staging.
const staging = path.join(home, '.gsd', 'capabilities', '.staging');
assert.equal(fs.existsSync(staging) && fs.readdirSync(staging).length > 0, false,
'no staging dir may be created when the over-cap install is rejected early');
// NO ledger entry / _pending must be left behind.
assert.equal(readLedgerEntry(home, 'overcap'), null, 'no ledger entry / _pending may be left');
// NO shared-config file may have been written.
assert.equal(fs.existsSync(path.join(home, 'f0.json')), false, 'no shared-config file may be written');
});
test('install does not clobber a user mcpServers entry whose name collides with the capability', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
@@ -473,3 +533,226 @@ describe('capability (argument + empty-state handling)', () => {
assert.match(`${r.error}\n${r.output}`, /Missing value for --integrity/i);
});
});
// ─── corrupt-ledger fail-closed — list + remove (sites A and C) ─────────────
describe('capability list (corrupt ledger fail-closed — site A)', () => {
test('capability list on a corrupt ledger exits non-zero with a blocked/corrupt error (finding-19)', () => {
const home = tmpDir('cap-cli-home-list-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file in the global scope location.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], makeCwd(), scopeEnv(home));
// Must exit non-zero (fail-closed — finding 19). A silent exit-0 is not acceptable.
assert.equal(r.success, false, 'capability list must exit non-zero when the ledger is corrupt (fail-closed)');
const combined = `${r.error}\n${r.output}`;
assert.match(combined, /corrupt|blocked/i,
'must mention corruption or blocked, not silently fail');
});
test('capability list --scope global: healthy global + corrupt project → exits zero (finding-8)', () => {
// When --scope global is given, only the global ledger is read.
// A corrupt project ledger must not block a global-only list.
// Project scope runtimeDir = cwd (where .gsd-capabilities.json would live).
const home = tmpDir('cap-cli-home-list-scoped-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Write a corrupt ledger at the project scope location (cwd/.gsd-capabilities.json).
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], cwd, scopeEnv(home));
// Global scope is healthy (no ledger = null = fine). Only the global scope is read.
assert.equal(r.success, true, `list --scope global must succeed when only the project ledger is corrupt; got: ${r.error || r.output}`);
const rows = parse(r.output);
assert.ok(Array.isArray(rows), 'output must be a JSON array');
// First-party capabilities must appear (they are always included).
assert.ok(rows.some((x) => x.source === 'first-party'), 'first-party entries must appear');
});
test('capability list --scope project: corrupt project ledger exits non-zero (finding-8)', () => {
const home = tmpDir('cap-cli-home-list-proj-corrupt-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json.
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption');
});
});
describe('capability remove (corrupt ledger fail-closed — site C)', () => {
test('capability remove on a corrupt global ledger exits non-zero with a blocked/corrupt error, NOT not_installed or silent success', () => {
const home = tmpDir('cap-cli-home-remove-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file so the scope has one.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT silently report "not installed" — that would hide the corruption.
assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i,
'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability remove first-party id on corrupt ledger surfaces corruption, not first-party error (finding-7)', () => {
// Finding 7: with readLedger (old), a corrupt ledger + first-party id reports "first-party cannot be removed"
// (hiding the corruption). With readLedgerStrict, corruption is surfaced first.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const home = tmpDir('cap-cli-home-f7-');
fs.mkdirSync(home, { recursive: true });
// Corrupt the ledger.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'remove', firstParty, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT report "first-party" (which would hide the corruption).
assert.doesNotMatch(`${r.error}\n${r.output}`, /first-party/i,
'corrupt ledger must surface corruption, not first-party gate');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability remove on a corrupt project-scope ledger exits non-zero (finding-20)', () => {
const home = tmpDir('cap-cli-home-remove-proj-corrupt-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json.
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project json ---');
const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt project ledger');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption or blocked');
});
});
// ─── corrupt-ledger fail-closed (Codex pass 3 — medium #2) ──────────────────
describe('capability update (corrupt ledger fail-closed)', () => {
test('capability update <id> on a corrupt ledger exits non-zero with a blocked/corrupt error, NOT not_installed', () => {
const home = tmpDir('cap-cli-home-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'update', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT report "not installed" — that would hide the corruption silently.
assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i,
'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability update --all on a corrupt ledger exits non-zero, does NOT silently succeed with an empty list', () => {
const home = tmpDir('cap-cli-home-corrupt-all-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger for --all');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked; not silently succeed');
});
});
// ─── orthogonal adversarial review (#1462): UX / observability ──────────────
describe('capability update --all (UX-1: structured stdout on partial failure)', () => {
test('UX-1: a partial --all failure emits {scope, updated:[...]} JSON on STDOUT and exits non-zero', () => {
const home = tmpDir('cap-cli-home-ux1-');
// Install an executable capability, then change its exec surface so the update needs re-consent
// and (without --yes) ABORTS — a partial-failure --all run.
const src = writeCapSource('ux1cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/a.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
cap.hooks = [{ event: 'PostToolUse', script: 'hooks/b.js' }];
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
fs.writeFileSync(path.join(src, 'hooks', 'b.js'), '// artifact');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
// Non-zero exit (partial failure).
assert.equal(r.success, false, 'a partial --all failure must exit non-zero');
// STRUCTURED data on STDOUT (not embedded in the error string) — UX-1.
assert.ok(r.output && r.output.length > 0, 'structured result must be emitted on stdout');
const parsed = JSON.parse(r.output);
assert.equal(parsed.scope, 'global', 'stdout JSON must carry the scope');
assert.ok(Array.isArray(parsed.updated), 'stdout JSON must carry the updated[] array');
assert.ok(parsed.updated.some((x) => x.id === 'ux1cap' && x.status !== 'upgraded'),
`updated[] must include the failed entry; got: ${JSON.stringify(parsed.updated)}`);
});
});
describe('capability list (UX-3: corrupt-scope error names the scope)', () => {
test('UX-3: a corrupt project-scope ledger error names the scope', () => {
const home = tmpDir('cap-cli-home-ux3-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt');
assert.match(`${r.error}\n${r.output}`, /\bproject\b/,
`the corrupt-scope error must name the scope ("project"); got: ${r.error}\n${r.output}`);
});
});
describe('capability install (UX-5: structured aborted/requiresConsent on stdout)', () => {
test('UX-5: an executable install WITHOUT --yes in --raw mode emits a structured aborted envelope on stdout', () => {
const home = tmpDir('cap-cli-home-ux5-');
const src = writeCapSource('ux5cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'an executable install without --yes must exit non-zero');
assert.ok(r.output && r.output.length > 0, 'stdout must NOT be empty in raw aborted mode (UX-5)');
const out = JSON.parse(r.output);
assert.equal(out.status, 'aborted', 'structured stdout must carry status=aborted');
assert.equal(out.requiresConsent, true, 'structured stdout must carry requiresConsent=true');
assert.ok(Array.isArray(out.disclosure), 'structured stdout must carry the disclosure list');
});
});
describe('capability update (UX-6: normalized per-entry fields)', () => {
test('UX-6: a not_installed entry in --all output has explicit null fields (not undefined)', () => {
// Seed a ledger with an entry whose recorded source resolves to a DIFFERENT id, so upgradeOne
// reports a non-upgraded status with no fromVersion/toVersion — those must serialize as null.
const home = tmpDir('cap-cli-home-ux6-');
fs.mkdirSync(home, { recursive: true });
// Hand-write a ledger entry pointing at a non-existent source so the update blocks.
const ledger = {
version: '1', updatedAt: new Date().toISOString(),
entries: {
'ux6cap': { id: 'ux6cap', version: '1.0.0', source: '/nonexistent/path/that/does/not/resolve', integrity: '', files: [], sharedEdits: [] },
},
};
fs.writeFileSync(ledgerPath(home), JSON.stringify(ledger, null, 2));
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
// Partial failure (the blocked entry) → non-zero, structured stdout.
assert.equal(r.success, false, 'a blocked --all entry must exit non-zero');
const parsed = JSON.parse(r.output);
const row = parsed.updated.find((x) => x.id === 'ux6cap');
assert.ok(row, `updated[] must include ux6cap; got: ${JSON.stringify(parsed.updated)}`);
// JSON.stringify omits undefined keys; explicit null is preserved. The fields must be present
// as null (normalized), not absent.
assert.ok('fromVersion' in row, 'fromVersion must be an explicit field (null), not omitted (UX-6)');
assert.strictEqual(row.fromVersion, null, 'fromVersion must be null for a blocked entry (UX-6)');
assert.ok('toVersion' in row, 'toVersion must be an explicit field (null), not omitted (UX-6)');
assert.strictEqual(row.toVersion, null, 'toVersion must be null for a blocked entry (UX-6)');
});
});
describe('capability install (UX-2: reconcile warnings surfaced on stderr)', () => {
// Revert-fails: restore the bare `try{reconcile}catch{}` that discards the report → the distinctive
// "capability reconcile:" warning prefix is never emitted to stderr, so this assertion fails. (The
// install block reason references "corrupt" but NOT the reconcile-warning prefix, so the prefix
// assertion is non-vacuous.)
test('UX-2: a corrupt ledger detected by the pre-op reconcile is surfaced on stderr (not swallowed)', () => {
const home = tmpDir('cap-cli-home-ux2-');
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const src = writeCapSource('ux2cap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'install on a corrupt ledger must exit non-zero');
// The reconcile report's warning must be surfaced with its distinctive prefix on stderr — proving
// the report was captured and emitted, not discarded in a bare try/catch.
assert.match(`${r.error}\n${r.output}`, /capability reconcile:/i,
'the pre-op reconcile warning must be surfaced on stderr with its prefix (UX-2)');
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff