fix(#3514): deny internal fetch hosts; disclose unverified integrity (#3516)

* test(#3514): add failing-first denylist and integrity suites

* fix(#3514): deny internal fetch hosts; disclose unverified integrity

* docs(#3514): trust-model, glossary, and changeset entries

* fix(#3514): scope v6 checks to literals; exact pin kinds in prompt

* chore(#3514): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 21:34:29 -04:00
committed by GitHub
parent 268ca7e32d
commit 6badb839a0
18 changed files with 842 additions and 602 deletions

View File

@@ -202,6 +202,31 @@ interface LifecycleOptions {
/** Stamp written onto every capability-owned shared-config entry, for surgical removal. */
const CAP_MARKER = '_gsdCapability';
/**
* #3514 (epic #1900 F21c): what KIND of pin (if any) the source content was pinned with — shared
* by the install and upgrade verdicts so the two cannot drift. Reaching the verdict with a
* supplied `--integrity` pin means the pin VERIFIED (the resolver throws on mismatch). A git
* `#sha:<40-hex-commit>` ref is the git analog of a hash pin — a commit checkout, verified by
* spelling: `/^sha:[0-9a-f]{7,40}$/i` accepts only a hex commit id, so a mutable ref
* (`#sha:main`, `#sha:v1`) is NOT counted as a pin (isolated review finding — a moving ref must
* never render as pinned). Everything else stages with no pin and must say so in the consent
* prompt.
*/
type IntegrityPin = 'sha512' | 'git-commit' | 'none';
const GIT_SHA_PIN_RE = /^sha:[0-9a-f]{7,40}$/i;
function resolveIntegrityPin(
integrity: unknown,
parsed: { kind?: string; ref?: unknown },
): IntegrityPin {
if (typeof integrity === 'string' && integrity.length > 0) return 'sha512';
if (parsed.kind === 'git' && typeof parsed.ref === 'string' && GIT_SHA_PIN_RE.test(parsed.ref)) {
return 'git-commit';
}
return 'none';
}
/** Keys that must never be used as object indices (prototype-pollution guard). */
function isUnsafeKey(k: string): boolean {
return k === '__proto__' || k === 'constructor' || k === 'prototype';
@@ -1023,6 +1048,7 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise<
stagedDir,
strictKnownRegistries,
hostVersion,
integrityPin: resolveIntegrityPin(opts.integrity, parsedPre),
});
if (!verdict.allowed) {
@@ -1225,6 +1251,7 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise<
stagedDir,
strictKnownRegistries,
hostVersion,
integrityPin: resolveIntegrityPin(opts.integrity, parsedPre),
});
if (!verdict.allowed) {
return { status: 'blocked', disclosure: verdict.disclosure, blockReasons: verdict.blockReasons };