fix(#1460): verify-or-reject capability --integrity per source; confine hook commands to the bundle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-20 08:14:33 -04:00
parent 38e9b83178
commit 08d1c57d6e
9 changed files with 622 additions and 17 deletions

View File

@@ -344,6 +344,42 @@ function readManifestBounded(
return cap as Record<string, unknown>;
}
/**
* #1460 CS-1: read a locally-produced `npm pack` `.tgz` as RAW BYTES via a bounded fd read so a
* supplied `--integrity` can be verified over the tarball (same SRI sha512 domain as the tarball
* adapter) before extraction/staging. `readSmallRegularFile` decodes utf8 (corrupting binary), so
* this reads the Buffer directly while keeping the same fail-closed discipline: open → fstat →
* require a regular file (a FIFO/device cannot BLOCK or be misread) → size-cap (MAX_RESPONSE_BYTES,
* the same ceiling the HTTP fetch enforces) → read exactly fstat.size bytes.
*/
function readPackTarball(tgzPath: string): Buffer {
let fd: number;
try {
fd = fs.openSync(tgzPath, 'r');
} catch (err) {
throw new Error(`Cannot read npm pack tarball: ${tgzPath}: ${(err as Error).message}`);
}
try {
const st = fs.fstatSync(fd);
if (!st.isFile()) {
throw new Error(`Refusing to read non-regular npm pack tarball: ${tgzPath}`);
}
if (st.size > MAX_RESPONSE_BYTES) {
throw new Error(`npm pack tarball exceeds ${MAX_RESPONSE_BYTES} bytes: ${tgzPath}`);
}
const buf = Buffer.allocUnsafe(st.size);
let read = 0;
while (read < st.size) {
const n = fs.readSync(fd, buf, read, st.size - read, read);
if (n === 0) break;
read += n;
}
return read === st.size ? buf : buf.subarray(0, read);
} finally {
try { fs.closeSync(fd); } catch { /* best-effort */ }
}
}
/**
* Reject spec/id values containing path separators or `..`.
* Throws if the id is unsafe.
@@ -813,6 +849,14 @@ function resolveLocal(
gsdHome: string,
hostVersion: string
): ResolveResult {
// #1460 CS-1: a local path is a directory tree, not a single downloadable artifact, so there is
// no stable byte stream to verify a sha512 SRI pin against. A supplied `--integrity` is therefore
// REJECTED with an actionable error rather than being silently dropped (the prior behaviour staged
// with integrity:null, so the user believed content was pinned when it was not).
if (opts.integrity) {
throw new Error('integrity pinning is not supported for local sources');
}
const absPath = path.resolve(parsed.target);
if (!fs.existsSync(absPath)) {
throw new Error(`Local capability path does not exist: ${absPath}`);
@@ -838,6 +882,14 @@ function resolveGit(
): ResolveResult {
const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
// #1460 CS-1: a git working tree has no single downloadable artifact to verify a sha512 SRI pin
// against (a clone is a directory tree, and the digest would vary with pack/checkout details). A
// supplied `--integrity` is therefore REJECTED with an actionable error rather than silently
// dropped (the prior behaviour staged with integrity:null). Pin a git source by COMMIT instead.
if (opts.integrity) {
throw new Error('integrity pinning is not supported for git sources; pin the commit with #sha:<commit>');
}
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-git-'));
try {
// Clone (copy only — no hooks execute on clone, no npm install).
@@ -906,6 +958,17 @@ function resolveNpm(
}
const tgzPath = path.join(tmpPackDir, tarballs[0]);
// #1460 CS-1: a supplied `--integrity` is verified over the `.tgz` BYTES (same SRI sha512
// domain as the tarball adapter) BEFORE anything is staged or promoted — never silently
// dropped. The recorded integrity is always the computed digest of the produced tarball.
// `npm pack --ignore-scripts` (above) ran no capability code, so reading these bytes is
// copy-only. A mismatch throws here, before assertSafeTarMembers / extraction / staging.
const tgzBytes = readPackTarball(tgzPath);
const computedIntegrity = computeIntegrity(tgzBytes);
if (opts.integrity) {
verifyIntegrity(tgzBytes, opts.integrity);
}
// Reject tar-slip member paths before extracting.
assertSafeTarMembers(execTar, tgzPath);
@@ -929,7 +992,7 @@ function resolveNpm(
const id = typeof cap['id'] === 'string' ? cap['id'] : '';
if (!id) throw new Error('capability.json missing "id" field');
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
} finally {
try { fs.rmSync(tmpPackDir, { recursive: true, force: true }); } catch { /* best-effort */ }
try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* best-effort */ }