* 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:
@@ -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 };
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user