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 };

View File

@@ -212,8 +212,102 @@ function _setHttpsGetImpl(fn: HttpsGetImpl | null): void {
// Injectable HTTP transport (test seam)
// ---------------------------------------------------------------------------
/**
* #3514 (epic #1900 F21): pre-transport fetch gate. Runs BEFORE any bytes leave and before the
* (possibly injected) transport is invoked:
*
* - Non-`https` URLs are refused with a NAMED, actionable reason. parseSpec still CLASSIFIES an
* `http://` tarball URL as tarball (no parse-time hard reject — internal-mirror classification
* flows stay intact, the adopted split decision on the epic); the https-only transport then
* fails clearly instead of with a raw ERR_INVALID_PROTOCOL.
* - Loopback / link-local / metadata / unspecified hosts and `localhost`-form names are denied
* unconditionally — no legitimate capability install fetches them. RFC1918 private ranges are
* deliberately ALLOWED (internal mirrors are the legitimate use this leaves room for); the
* denylist is not an allowlist.
*
* Known limit (disclosed): the check is on the URL's HOST LITERAL. A public hostname that
* RESOLVES via DNS to a denied range (rebinding) is out of scope — `https.get` resolves
* internally and this gate does not double-resolve.
*/
function assertFetchableUrl(rawUrl: string): void {
let u: URL;
try {
u = new URL(rawUrl);
} catch {
throw new Error(`invalid capability source URL: "${rawUrl}"`);
}
if (u.protocol !== 'https:') {
throw new Error(
`capability source fetch requires https — refusing "${u.protocol}" URL (host "${u.hostname}"). ` +
'The transport never fetches plaintext http; for an internal mirror use an https URL or a local path source.'
);
}
// Node's URL.hostname KEEPS the brackets on IPv6 literals ('[::1]') — strip them so the
// v6 checks below see the bare address. A single trailing dot is FQDN syntax ('localhost.'
// is the same name as 'localhost'; some resolvers synthesize loopback for it) — strip it.
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
const deniedHost = () =>
new Error(
`refusing to fetch capability source from internal host "${host}" (loopback/link-local/metadata denylist)`
);
if (host === 'localhost' || host.endsWith('.localhost')) {
throw deniedHost();
}
// IPv4 literal: 127/8 loopback, 0/8 unspecified, 169.254/16 link-local (contains the cloud
// metadata IPs). Malformed literals that match no range simply fall through to a failed
// resolution downstream — denying is not needed for safety there.
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 127 || a === 0 || (a === 169 && b === 254)) throw deniedHost();
return;
}
// IPv6-literal checks. A registered domain NEVER contains ':' after bracket-strip, while every
// IPv6 literal does — that colon is the guard. Without it, the fe80::/10 prefix test below
// would deny legitimate domains like 'feather.internal' or 'february.example.com' (isolated
// review finding — a domain-shaped host must never reach the v6 branch).
if (!host.includes(':')) {
return;
}
// An IPv4-mapped address re-checks as IPv4 — in EITHER spelling, because the URL parser may
// preserve the dotted form ('::ffff:127.0.0.1') or normalize to hex hextets ('::ffff:7f00:1').
// Otherwise deny ::1 (loopback), :: (unspecified), and fe80::/10 (link-local, hex prefix range
// fe80–febf).
if (host.startsWith('::ffff:')) {
const suffix = host.slice('::ffff:'.length);
let v4str: string | null = null;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(suffix)) {
v4str = suffix;
} else {
const hextets = suffix.split(':');
if (hextets.length === 2 && /^[\da-f]{1,4}$/.test(hextets[0]) && /^[\da-f]{1,4}$/.test(hextets[1])) {
const hi = parseInt(hextets[0], 16);
const lo = parseInt(hextets[1], 16);
v4str = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
}
}
if (v4str) {
assertFetchableUrl(`https://${v4str}/`);
return;
}
}
// fe80::/10 = hex prefix fe80..febf, i.e. /^fe[89ab]/ (third nibble 8-b has top two bits set).
if (host === '::1' || host === '::' || /^fe[89ab]/.test(host)) {
throw deniedHost();
}
}
function realHttpsGet(url: string): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
// #3514: gate BEFORE the transport — scheme + host denylist refuse with named errors and
// never invoke the (possibly injected) transport. A throw inside the executor rejects.
try {
assertFetchableUrl(url);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
const req = _httpsGetImpl(
url,
{ headers: { 'User-Agent': 'gsd-core-capability-source/1.0' } },

View File

@@ -338,6 +338,17 @@ interface Disclosure {
* Empty when no stagedDir was supplied.
*/
missingArtifacts: string[];
/**
* #3514 (epic #1900 F21c): what pinned the source content before staging — 'pinned' (a verified
* sha512 `--integrity` pin), 'commit-pinned' (a git source checked out at a `#sha:<40-hex>`
* commit), or 'unverified' (no pin). PROMPT-ONLY: set by evaluateInstallTrust when the caller
* supplies `integrityPin`, rendered by summarizeDisclosure, and deliberately EXCLUDED from
* `disclosureSignature` — consent's content binding is `bundleContentHash` (#1459), and a
* rendered line must never read as a changed executable set (which would fire a spurious
* re-consent on every upgrade). Mirrors the instructionSurfaces exclusion precedent (ADR-2363
* D4) one field over.
*/
integrityStatus?: 'pinned' | 'commit-pinned' | 'unverified';
}
type StrictKnownRegistries = string[] | null | undefined;
@@ -377,6 +388,14 @@ interface InstallTrustArgs {
* constraint 2; see `ReviewerHostResolver`).
*/
resolveHost?: ReviewerHostResolver;
/**
* #3514 (epic #1900 F21c): what kind of pin the source content carries — 'sha512' (a supplied
* `--integrity` pin, verified by the resolver before the verdict runs), 'git-commit' (a git
* source pinned by `#sha:<40-hex-commit>`), or 'none'. Optional: absent ⇒ the disclosure
* carries no `integrityStatus` and the prompt renders no integrity line (legacy callers see
* byte-identical output).
*/
integrityPin?: 'sha512' | 'git-commit' | 'none';
}
interface InstallTrustVerdict {
@@ -1159,6 +1178,12 @@ function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
// openai-http reviewer lane to the human at install/upgrade time — it never affects the
// consent-binding signature (disclosureSignature never reads resolvedHost; design constraint 2).
const disclosure = discloseExecutableSurfaces(manifest, stagedDir, resolveHost);
// #3514 (F21c): prompt-only integrity status. Set AFTER discloseExecutableSurfaces so the
// surface builder (and every signature computed from it) is untouched — see the field's
// disclosure-interface comment for why this must never reach disclosureSignature.
if (args.integrityPin === 'sha512') disclosure.integrityStatus = 'pinned';
else if (args.integrityPin === 'git-commit') disclosure.integrityStatus = 'commit-pinned';
else if (args.integrityPin === 'none') disclosure.integrityStatus = 'unverified';
// A manifest that declares a hook script or command module NOT present in the staged bundle
// (missing, or escaping the bundle via an absolute/`..` path) is rejected: such an artifact
@@ -1488,16 +1513,20 @@ function isRemoteMcpServer(s: McpServerSurface): boolean {
function summarizeDisclosure(disclosure: Disclosure): string[] {
const lines: string[] = [];
const instructionLines = summarizeInstructionSurfaces(disclosure);
// #3514 (F21c): computed once, appended before every return path so a future path cannot miss it.
const integrity = integrityStatusLine(disclosure);
if (!disclosure.hasExecutable) {
// ADR-2363 D3: "declarative only" is true ONLY when there is no instruction surface either.
// Claiming it unconditionally told a user their capability contributes nothing to weigh while
// it was contributing agent instructions — the exact category error ADR-2363 was written to end.
if (instructionLines.length === 0) {
lines.push('This capability ships no executable surfaces (declarative only).');
if (integrity) lines.push(integrity);
return lines;
}
lines.push('This capability ships no executable surfaces, but contributes agent instructions:');
for (const line of instructionLines) lines.push(line);
if (integrity) lines.push(integrity);
return lines;
}
lines.push('This capability ships executable surfaces that will run in your agent runtime:');
@@ -1661,9 +1690,30 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
lines.push(` - ${renderValueForPrompt(a)}`);
}
}
if (integrity) lines.push(integrity);
return lines;
}
/**
* #3514 (F21c): the one-line integrity status for the consent prompt, or null when the caller
* supplied no `integrityPin` (legacy — no line, byte-identical output). GSD-authored literals,
* never manifest data — no escaping needed. A consent-prompt claim must be EXACT: a git
* commit-pinned source renders its own line, never "sha512 pin" — no sha512 was supplied
* (isolated review finding).
*/
function integrityStatusLine(disclosure: Disclosure): string | null {
if (disclosure.integrityStatus === 'pinned') {
return ' content: sha512 pin supplied and verified before staging';
}
if (disclosure.integrityStatus === 'commit-pinned') {
return ' content: pinned to a git commit, checked out before staging';
}
if (disclosure.integrityStatus === 'unverified') {
return ' content: NO PINNED HASH — staged unverified (a computed sha512 is recorded in the ledger at install)';
}
return null;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------

View File

@@ -378,11 +378,6 @@ function isInsideRoot(candidatePath: string, rootDir: string): boolean {
return target === root || target.startsWith(`${root}${path.sep}`);
}
// #3484: restored from the retired SDK lineage (dropped as a bare 256 * 1024 literal
// in the ADR-0174 collapse). Counts String#length (UTF-16 code units), not bytes —
// semantics preserved from the deleted source.
const MAX_MODIFIED_FILE_BYTES = 256 * 1024;
function readModifiedFilesContent(projectDir: string, summaries: string[]): string {
const out: string[] = [];
let total = 0;
@@ -395,7 +390,7 @@ function readModifiedFilesContent(projectDir: string, summaries: string[]): stri
if (total >= 50) break;
if (!file || !isInsideRoot(file, projectDir)) continue;
const raw = readIfExists(resolvePath(file, projectDir));
out.push(raw.length > MAX_MODIFIED_FILE_BYTES ? raw.slice(0, MAX_MODIFIED_FILE_BYTES) : raw);
out.push(raw.length > 256 * 1024 ? raw.slice(0, 256 * 1024) : raw);
total++;
}
if (total >= 50) break;