fix(#1462): fail closed without data loss on a corrupt capability ledger; atomic ledger write (#1469)
This commit is contained in:
5
.changeset/fix-1462-ledger-corruption.md
Normal file
5
.changeset/fix-1462-ledger-corruption.md
Normal 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.)
|
||||
@@ -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>" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user