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

@@ -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
// ---------------------------------------------------------------------------