fix(#1459): user-owned consent store gates third-party capability activation; env/cwd in disclosure; loader validator parity (#1473)
This commit is contained in:
@@ -1503,14 +1503,31 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
return '0.0.0';
|
||||
}
|
||||
};
|
||||
// #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
|
||||
// live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
|
||||
// written here is the record the loader checks.
|
||||
const capConsentHome = () => {
|
||||
const osMod = require('node:os');
|
||||
return process.env.GSD_HOME || osMod.homedir();
|
||||
};
|
||||
// #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
|
||||
// record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
|
||||
// path cannot be realpath'd (e.g. it does not exist yet).
|
||||
const capProjectRoot = () => {
|
||||
try { return fs.realpathSync(cwd); } catch { return cwd; }
|
||||
};
|
||||
// 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) => {
|
||||
// #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
|
||||
// half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
|
||||
// record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
|
||||
// reconcile revokes nothing.
|
||||
const capRunReconcile = (runtimeDir, lifecycle, scope) => {
|
||||
try {
|
||||
const report = lifecycle.reconcileCapabilities({ runtimeDir });
|
||||
const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
|
||||
if (report && Array.isArray(report.warnings)) {
|
||||
for (const w of report.warnings) {
|
||||
try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
|
||||
@@ -1629,7 +1646,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
||||
);
|
||||
}
|
||||
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
|
||||
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
||||
const res = await lifecycle.installCapability(spec, {
|
||||
runtimeDir,
|
||||
hostVersion: capHostVersion(),
|
||||
@@ -1637,6 +1654,10 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
integrity: capFlagValue('--integrity'),
|
||||
sharedFiles: installSharedFiles,
|
||||
strictKnownRegistries: capReadStrict(),
|
||||
// #1459: bind a user consent record for a CONSENTED project install (under the user-owned
|
||||
// consent home, NOT in the repo). The lifecycle records nothing for global scope.
|
||||
scope,
|
||||
consentStoreDir: capConsentHome(),
|
||||
});
|
||||
if (res.status === 'installed') {
|
||||
output({
|
||||
@@ -1695,7 +1716,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
||||
);
|
||||
}
|
||||
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
|
||||
capRunReconcile(runtimeDir, lifecycle, scope); // 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>)
|
||||
@@ -1719,6 +1740,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
|
||||
strictKnownRegistries: capReadStrict(),
|
||||
expectedId: capId,
|
||||
// #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
|
||||
scope,
|
||||
consentStoreDir: capConsentHome(),
|
||||
});
|
||||
// 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
|
||||
@@ -1785,7 +1809,7 @@ 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');
|
||||
capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr
|
||||
capRunReconcile(runtimeDir, lifecycle, scope); // 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).
|
||||
// Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
|
||||
@@ -1803,8 +1827,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
}
|
||||
const res = lifecycle.removeCapability(id, { runtimeDir, removeData: capHasFlag('--purge-data') });
|
||||
const res = lifecycle.removeCapability(id, {
|
||||
runtimeDir,
|
||||
removeData: capHasFlag('--purge-data'),
|
||||
// #1459: a project-scope removal revokes the user consent record so a later repo-dropped
|
||||
// bundle of the same id cannot silently re-activate against a stale consent.
|
||||
scope,
|
||||
consentStoreDir: capConsentHome(),
|
||||
});
|
||||
if (res.status === 'removed') {
|
||||
// #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
|
||||
// could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
|
||||
// record remains. Surface it on stderr + in the JSON so the user knows to clear it.
|
||||
if (res.consentRevokeFailed) {
|
||||
process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
|
||||
}
|
||||
output({
|
||||
status: 'removed',
|
||||
id,
|
||||
@@ -1812,6 +1849,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
removedFiles: res.removedFiles,
|
||||
strippedEdits: res.strippedEdits,
|
||||
dataPreserved: res.dataPreserved,
|
||||
consentRevokeFailed: res.consentRevokeFailed || undefined,
|
||||
consentRevokeWarning: res.consentRevokeWarning || undefined,
|
||||
}, raw);
|
||||
} else if (res.status === 'not_installed') {
|
||||
error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
||||
@@ -1835,6 +1874,22 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
// First-party capabilities are always included (they have no scope concept).
|
||||
const base = loader.loadRegistry();
|
||||
const fp = (base && base.capabilities) || {};
|
||||
// #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
|
||||
// (a bundle whose project ledger looks committed but has no user consent record on THIS
|
||||
// machine) is marked status:'inactive' with a reason, instead of silently appearing active.
|
||||
// loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
|
||||
const inactiveById = {};
|
||||
try {
|
||||
const composed = loader.loadRegistry({ includeInstalled: true, cwd });
|
||||
const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
|
||||
for (const w of overlayWarnings) {
|
||||
// #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
|
||||
// human-readable reason prose (which is free to change without breaking this filter).
|
||||
if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
|
||||
inactiveById[`${w.scope} ${w.id}`] = w.reason;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort — list still works without the inactive annotation */ }
|
||||
for (const capId of Object.keys(fp)) {
|
||||
const cap = fp[capId] || {};
|
||||
rows.push({
|
||||
@@ -1868,11 +1923,23 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
const entry = ledger.entries[capId];
|
||||
let manifest = {};
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 'utf8'));
|
||||
// #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
|
||||
// bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
|
||||
// a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
|
||||
// an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
|
||||
// declarative capability.json. A null (genuinely missing) or a bounded-reader throw
|
||||
// (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
|
||||
// (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
|
||||
const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
|
||||
manifest = raw === null ? {} : JSON.parse(raw);
|
||||
} catch { manifest = {}; }
|
||||
let status = 'active';
|
||||
let reason = null;
|
||||
const range = manifest.engines && manifest.engines.gsd;
|
||||
if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
|
||||
// #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
|
||||
const inactiveReason = inactiveById[`${sc} ${capId}`];
|
||||
if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
|
||||
rows.push({
|
||||
id: capId,
|
||||
role: manifest.role || null,
|
||||
@@ -1881,6 +1948,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
source: entry.source || null,
|
||||
scope: sc,
|
||||
status,
|
||||
reason,
|
||||
title: manifest.title || null,
|
||||
});
|
||||
}
|
||||
@@ -1900,9 +1968,65 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
|
||||
raw,
|
||||
);
|
||||
} else if (capSubcommand === 'trust') {
|
||||
// capability trust list [--scope project] [--json]
|
||||
// capability trust revoke <id> [--project <path>]
|
||||
// The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
|
||||
const consentMod = require('./lib/capability-consent.cjs');
|
||||
const trustSub = args[2];
|
||||
if (trustSub === 'list') {
|
||||
// --scope is accepted for symmetry; only 'project' records exist today.
|
||||
const listScope = capFlagValue('--scope');
|
||||
if (listScope && listScope !== 'project') {
|
||||
error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
const store = consentMod.readConsentStore(capConsentHome());
|
||||
const rows = Object.keys(store.records).map((k) => {
|
||||
const r = store.records[k];
|
||||
// #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
|
||||
// binding against the current bundle (e.g. `gsd capability list` showing inactive after a
|
||||
// tamper) and understand why a consented cap deactivated. The contentHash is THE security
|
||||
// binding the loader checks; disclosureSignature is the executable-surface re-consent key.
|
||||
return {
|
||||
id: r.id, scope: r.scope, projectRoot: r.projectRoot,
|
||||
integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
|
||||
consentedAt: r.consentedAt,
|
||||
};
|
||||
});
|
||||
output(rows, raw || capHasFlag('--json'));
|
||||
} else if (trustSub === 'revoke') {
|
||||
const id = args[3];
|
||||
if (!id || id.startsWith('--')) {
|
||||
error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
// --project pins the project root whose consent is revoked; defaults to realpath(cwd).
|
||||
const projFlag = capFlagValue('--project');
|
||||
let projectRoot;
|
||||
try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
|
||||
catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
|
||||
// #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
|
||||
// (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
|
||||
// error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
|
||||
// consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
|
||||
try {
|
||||
consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
|
||||
} catch (err) {
|
||||
error(
|
||||
`capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
|
||||
`(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
|
||||
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
|
||||
);
|
||||
}
|
||||
output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
|
||||
} else {
|
||||
error(
|
||||
`Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
|
||||
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error(
|
||||
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, disable, enable, state, set`,
|
||||
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, trust, disable, enable, state, set`,
|
||||
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
* fresh worktree before `npm run build:lib` has run.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { LOOP_HOST_CONTRACT } = require('./loop-host-contract.cjs');
|
||||
@@ -1016,6 +1015,13 @@ function validateRuntimeBody(cap) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// #1459 CONVERGENCE finding 1(b) — GENEROUS DoS backstop on a (possibly project-plantable) hook
|
||||
// fragment file. A real fragment is a few KiB of markdown; 8 MiB is wildly more than any legitimate
|
||||
// fragment. The bounded reader refuses a non-regular (FIFO/device/symlink-to-nonregular) or oversized
|
||||
// fragment WITHOUT a raw blocking read, so a forged in-bundle FIFO/oversized fragment.path becomes an
|
||||
// un-materializable fragment (a validation error / skip) instead of hanging or OOM-ing the loop.
|
||||
const FRAGMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function materializeHookFragments(cap, capDir) {
|
||||
const errors = [];
|
||||
const hookGroups = [
|
||||
@@ -1023,6 +1029,22 @@ function materializeHookFragments(cap, capDir) {
|
||||
['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
|
||||
];
|
||||
|
||||
// #1459 CONVERGENCE finding 1(b): the fragment body is read via the SHARED bounded fd reader (open →
|
||||
// fstat → require regular file → size cap → read exactly size), NOT a raw fs.readFileSync(abs,'utf8')
|
||||
// which BLOCKS forever on a forged in-bundle FIFO and reads an oversized fragment unbounded into memory.
|
||||
// Required lazily so the committed plain-.cjs validator does not hard-depend on the built ledger artifact
|
||||
// at module-load time (materialize is a runtime path, reached only after build:lib). A bounded-reader
|
||||
// throw (non-regular/oversized/IO) → an un-materializable-fragment validation error, not a hang.
|
||||
let readSmallRegularFile;
|
||||
try {
|
||||
({ readSmallRegularFile } = require('./capability-ledger.cjs'));
|
||||
} catch {
|
||||
// Defensive: if the bounded reader is unavailable, fall back to a fail-CLOSED stub so we never
|
||||
// silently revert to an unbounded raw read. A null-returning stub turns every path fragment into an
|
||||
// "could not be read" error rather than a hang (declarative-only fragments use `inline` and skip this).
|
||||
readSmallRegularFile = () => null;
|
||||
}
|
||||
|
||||
for (const [groupName, hooks] of hookGroups) {
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const hook = hooks[i];
|
||||
@@ -1043,8 +1065,18 @@ function materializeHookFragments(cap, capDir) {
|
||||
}
|
||||
|
||||
try {
|
||||
fragment.inline = fs.readFileSync(abs, 'utf8');
|
||||
const body = readSmallRegularFile(abs, FRAGMENT_MAX_BYTES);
|
||||
if (body === null) {
|
||||
// null = genuinely missing (ENOENT) OR refused as non-regular/oversized via the stub fallback.
|
||||
errors.push(
|
||||
cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read (missing, non-regular ' +
|
||||
'(FIFO/device), or exceeds the size cap): ' + fragment.path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
fragment.inline = body;
|
||||
} catch (err) {
|
||||
// Bounded-reader fail-closed throw (non-regular/oversized/IO) — an un-materializable fragment.
|
||||
errors.push(
|
||||
cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
|
||||
fragment.path + ' (' + err.message + ')',
|
||||
|
||||
Reference in New Issue
Block a user