feat(#2796): reviewer lane as a fourth trust-disclosure class (#2826)

* feat(#2796): reviewer lane as a fourth trust-disclosure class

Phase 3 of epic #2782, delivering ADR-2782 D5. A reviewer lane is piped the plan
text, requirements, research findings and CONTEXT.md decisions, and its output is
read back into REVIEWS.md -- an egress channel for the most sensitive artifacts
GSD produces. Making lanes pluggable WITHOUT a disclosure class would open a
data-exfiltration path behind a manifest field, which is why this gates the
feature rather than following it.

- discloseExecutableSurfaces was cyclomatic 51 / cognitive 99 / 110 lines with
  risk_level critical. Rather than grow it, it is now a short orchestrator over
  four extracted collectors (hooks, commands, mcp -- behaviour-preserving -- plus
  the new lane collector), each independently testable. That is also what makes
  the 80% mutation threshold survivable: 51 branches in one function cannot be
  mutation-covered by whole-function tests.

- A spawn lane discloses its binary AND its full declared args, in rendered and
  raw form. Binary-only disclosure would be insufficient and not hypothetically:
  a lane declaring python3 with innocuous args could later change them to
  ['-c', '<program>'] without the binary changing. That is the bug class #1459
  already fixed for MCP servers.

- An openai-http lane has no binary, so it discloses the destination host and the
  config key naming it. A localhost destination is disclosed and distinguished
  from a remote one. Both forms name the egress payload classes.

THE CONSTRAINT THAT SHAPED THE DESIGN: the lane element is appended to the
disclosure signature ONLY when at least one lane is declared. signatureForManifest
is the consent key both the loader and the lifecycle compare, so appending
unconditionally would have changed every installed capability's signature and
re-prompted every user for every capability on their next upgrade -- for a feature
they do not use. Two pre-change goldens are asserted byte-for-byte as the tripwire.

The resolved host is deliberately NOT in the signature. The loader has no config
resolver, so including it would make the loader and the lifecycle compute
different signatures for the same manifest and produce a permanent false-mismatch
loop. It is disclosed and recorded instead; Phase 5b re-resolves and compares at
invocation, which is D5 rule 4's own placement.

reviewsSection and timeoutFloorMs are also excluded from the signature: a cosmetic
change must not force re-consent, because a prompt carrying no security
information is how users learn to click through.

A lane's binary is NOT existence-checked against the staged bundle. It is a PATH
tool, never a bundle artifact; treating it like a hook script would add every lane
to missingArtifacts and block every lane install.

Two defects fixed beyond the fourth class:
- isLocalHostValue mis-parsed a scheme-less host: new URL('localhost:1234') does
  NOT throw, it reads 'localhost' as the URL scheme and yields an empty hostname,
  so a bare host:port would have been reported as non-local. Now falls back on an
  empty hostname rather than only on a caught throw.
- The orchestrator's safeCollect closes a PRE-EXISTING totality gap in the other
  three classes: a null manifest, or one with a throwing getter or Proxy trap,
  previously threw out of disclosure -- which runs on an UNVALIDATED manifest at
  install time. No well-formed input changes; all 51 existing trust tests pass.

Closes #2796

* fix(#2796): close four disclosure gaps found by the isolated security review

All four were REPRODUCED by execution against the shipped module, and all four
passed the existing 41-test suite while live -- each exists because the matrix
did not think to ask.

B (MEDIUM, reachable via plain JSON). Non-string argv members were folded into
the consent SIGNATURE but dropped from the human-facing text, because the summary
rendered the string-filtered args rather than the raw declared array. A manifest
declaring args ['--json', 7, {mode:'exfiltrate-everything'}, true] printed as
'--json' alone -- the host still receives the rest, so the user consented to a
surface never shown. That directly contradicts this design's own Kerckhoffs claim
that nothing about a lane is hidden. The summary now renders the raw array, with
non-strings shown in a visible form, and never throws on a circular or BigInt
member.

F (MEDIUM, reachable). The [local] flag is design-load-bearing, and it was
dropped for every loopback form except the dotted quad and the bare hostname.
Bracketed IPv6 was mangled by splitting on the address's own colons ([::1]:8080
became '['), and legacy IPv4 encodings were not recognised at all. A browser,
curl and the OS resolver all treat 127.1, 2130706433, 0x7f000001 and 0177.0.0.1
as loopback. isLocalHostValue now handles bracketed and bare IPv6, IPv4-mapped
loopback, and inet_aton shorthand/decimal/hex/octal. The dangerous direction was
already clean and is now pinned by tests: localhost.evil.com,
http://user@localhost@evil.com and friends stay REMOTE.

C (LOW). An empty reviewer body flipped hasExecutable true and perturbed the
disclosure signature, producing a re-consent prompt whose only content was
'(no binary declared)'. A prompt carrying no security information is the
click-through-training harm this design explicitly refuses for reviewsSection and
timeoutFloorMs; refusing it there and permitting it here was inconsistent. A body
declaring nothing recognised is no longer a lane. The test is deliberately broad
-- any ONE recognised field suffices -- because requiring specifically a binary,
or specifically a slug, would let a lane declaring only the other slip through
unconsented, which is the far worse failure. Pinned in both directions.

D (LOW-MEDIUM). Disclosure runs BEFORE validation, so a mis-cased or unrecognised
transport reaches this code. Keying on an exact string sent a lane that plainly
declares a hostConfigKey down the spawn branch, printing '(no binary declared)'
for a lane egressing to a live remote host, and left its resolvedHost blank --
which reads as 'no destination', the precise thing the design forbids. Both the
collector and the summary now branch on the declared SHAPE, so such a lane
discloses its key and either a resolved host or the explicit unresolved marker.

Two further findings were reproduced but confirmed NOT reachable through the real
pipeline and are recorded as known limits rather than fixed: a selective-throw
Proxy blanking a whole lane, and NaN/Infinity/undefined colliding to 'null' in a
signature. Every production manifest reaches disclosure through
readManifestBounded's strict JSON.parse, which cannot produce a Proxy, a getter,
a BigInt, a circular reference, NaN or Infinity. The 0/-0 sub-case IS reachable
via valid JSON but is inert -- String(0) === String(-0), so a spawned process
receives identical argv.

9 regression tests added (50 total in this file, up from 41).

* chore(#2796): backfill changeset pr number to 2826
This commit is contained in:
Tom Boucher
2026-07-29 11:21:49 -04:00
committed by GitHub
parent ddcf459c81
commit 69dbf28ca7
4 changed files with 1630 additions and 44 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2826
---
**Reviewer lanes are disclosed and consent-gated before install** — a capability that declares a reviewer lane now discloses what it will run and what it will be sent, and blocks on consent before any file is promoted. A spawned lane discloses its binary and its full arguments; an OpenAI-compatible lane discloses its destination host and the config key naming it, including a localhost destination. Both name the egress payload classes — plan text, requirements, research findings, and CONTEXT.md decisions. Changing a lane's binary, arguments, destination, prompt channel, or handler forces re-consent on update; a capability with no reviewer lane is unaffected and its consent record is unchanged. (#2796)

View File

@@ -15,8 +15,9 @@
GSD 1.6.0 opens the capability platform to third-party authors with **full
artifact parity**: a third-party capability may ship the same executable
surfaces that GSD Core ships — hooks, MCP servers, command modules. This is a
deliberate product choice, and it carries real security weight.
surfaces that GSD Core ships — hooks, MCP servers, command modules, and
reviewer lanes. This is a deliberate product choice, and it carries real
security weight.
Full parity means a third-party capability, once installed, can execute code
the next time a relevant loop event fires. There is no "first use" gate.
@@ -58,9 +59,9 @@ contains.
GSD's response: auto-update is **off by default** for third-party capabilities.
When it is enabled, a change to the *executable set* (the set of hooks, MCP
servers, or command modules the capability declares) triggers a re-consent
prompt before the update applies. Updating a non-executable capability
(documentation, agents, skills) does not require re-consent.
servers, command modules, or reviewer lanes the capability declares) triggers a
re-consent prompt before the update applies. Updating a non-executable
capability (documentation, agents, skills) does not require re-consent.
VS Code also has no signature check on VSIX packages. GSD requires an
`integrity` SHA-512 pin in the ledger, verified before extraction.
@@ -75,11 +76,57 @@ recommendation for sensitive environments is `--ignore-scripts`.
GSD takes a stronger position: **install never executes capability code**,
full stop. Installation is a copy-only staging operation. There is no
`postinstall`-equivalent. A capability's hooks, MCP server, and command
modules are not invoked during install; they are first invoked when the loop
fires after install. This means a malicious payload in an executable surface
cannot be triggered by the act of downloading it — the user has a window
between install and first use to verify what they consented to.
`postinstall`-equivalent. A capability's hooks, MCP server, command
modules, and reviewer lanes are not invoked during install; they are first
invoked when the loop fires after install. This means a malicious payload in an
executable surface cannot be triggered by the act of downloading it — the user
has a window between install and first use to verify what they consented to.
### The reviewer lane: the one surface that *receives* data
Three of the four disclosure classes are about code the capability gets to
**run**. A reviewer lane — one external CLI or model endpoint that `/gsd:review`
hands a plan to — is different in kind, and the difference is the reason it is
disclosed at all.
A lane is piped the plan text, the requirements, the research findings, and the
`CONTEXT.md` decisions, and its output is read back into `REVIEWS.md`. That is an
**egress channel for the most sensitive artifacts GSD produces**. Making lanes
pluggable without a disclosure class would have opened a data-exfiltration path
behind a manifest field, which is why the trust work gates the feature rather
than following it.
What is disclosed depends on how the lane is reached:
- A **spawned** lane discloses its binary **and its full declared arguments**, in
both rendered and raw form. Disclosing the binary alone would be insufficient,
and not hypothetically: a lane declaring `python3` with innocuous arguments
could later change them to `["-c", "<arbitrary program>"]` without the binary
changing at all. Arguments are therefore signature-bound, exactly as they are
for MCP servers.
- An **OpenAI-compatible HTTP** lane has no binary, so it discloses the
**destination host** and the config key that names it. Disclosing `curl` would
be technically true and practically meaningless; the destination is the
disclosure that matters. A `localhost` destination is still disclosed, and is
distinguished from a remote one — a lane pointed at a local port is an egress
channel too, and the port may not be what the user assumes.
Both forms additionally name the **egress payload classes**, rather than an
unhelpful "sends data to the tool".
**Stated honestly:** consent-at-install is a weaker gate for a *standing* egress
channel than it is for a hook. A user consents once; the lane thereafter receives
every plan on every review run. Disclosure makes the channel visible, pinned and
revocable — it does not make it safe. A per-run egress prompt was considered and
rejected as consent fatigue that trains users to approve blindly.
One consequence is worth naming because it does not follow the pattern of the
other three classes. A lane's destination host lives in `.planning/config.json`,
which is user- and CI-editable at any time with no re-install and no integrity
check — unlike every other consent-bound value, all of which come from the
SHA-pinned manifest. Consent therefore binds the **resolved host**, not merely
the config key, so that a later edit redirecting a consented lane to a different
destination is detectable rather than silent.
SLSA provenance (the `provenance` field in `capability.json`) provides a
machine-checkable link from a capability bundle back to a specific commit in a

View File

@@ -1,5 +1,6 @@
/**
* Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6).
* Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6), extended
* by ADR-2782 Phase 3 (#2796) with a FOURTH executable-surface class: the reviewer lane.
*
* PURE module. It computes *what* a capability would do and *whether* policy allows it; it
* never mutates the filesystem and never performs I/O beyond reading staged files to confirm
@@ -9,15 +10,28 @@
*
* LEAF MODULE — imports ONLY: node:fs, node:path, and ./semver-compare.cjs.
*
* ADR-2782 D5 (#2796): a `reviewer` lane is piped the plan text, requirements, research findings
* and CONTEXT.md decisions, then its output is read back into REVIEWS.md — making it an executable
* surface exactly like a hook, command module, or MCP server, and it is disclosed and consent-bound
* the same way. `disclosureSignature` appends the lane element to its output ONLY when at least one
* lane is declared (D4.5) — a lane-free manifest's signature stays byte-identical to before this
* class existed, so no already-consented capability re-prompts on upgrade. The RESOLVED host (as
* opposed to the declared `hostConfigKey`) is disclosed to a human but deliberately EXCLUDED from
* the signature — the loader has no config resolver and must compute the same signature as the
* lifecycle (constraint 2, `.gsd/phase/chore-2796-reviewer-trust-disclosure/40-design.md`).
*
* Exports:
* RESERVED_NAMESPACES — id prefixes third parties may not claim
* discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers
* discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers / reviewer lanes
* collectReviewerLaneSurfaces(...) — the reviewer-lane collector, independently testable
* checkReservedNamespace(id) — is this id in a reserved namespace?
* evaluateSourceAllowed(parsed,...) — strictKnownRegistries enforcement
* checkEngines(manifest, host) — engines.gsd hard gate + compatVersions downgrade
* evaluateInstallTrust(args) — compose: source + namespace + engines + disclosure
* executableSetChanged(old, new) — did the executable surface set change between versions?
* summarizeDisclosure(disclosure) — human-readable consent-prompt lines
* UNRESOLVED_HOST_MARKER — the non-blank marker for an unresolved openai-http host
* EGRESS_PAYLOAD_CLASSES — the named data classes every reviewer lane receives
*/
import fs from 'node:fs';
@@ -40,6 +54,29 @@ const semverMod = require('./semver-compare.cjs') as {
*/
const RESERVED_NAMESPACES = ['gsd-', 'gsd-core-', 'anthropic-'];
/**
* ADR-2782 D5's gating requirement (#2796): every reviewer lane is piped the plan text,
* requirements, research findings and CONTEXT.md decisions. Named explicitly here so disclosure
* says exactly this — never the unhelpful "sends data to the tool" (design section B5).
*/
const EGRESS_PAYLOAD_CLASSES = ['plan text', 'requirements', 'research findings', 'CONTEXT.md decisions'];
/**
* B3 (#2796 matrix): `resolvedHost` must never be a blank string — a blank reads as "no
* destination" rather than "not resolved". This marker is disclosed for an `openai-http` lane
* when no resolver was supplied to `collectReviewerLaneSurfaces`, or the supplied resolver could
* not resolve the declared `hostConfigKey`. Deliberately NOT part of `disclosureSignature`'s input
* (see the lane signature line) — only the human-facing surface carries it.
*/
const UNRESOLVED_HOST_MARKER = '(unresolved — no host resolver was supplied at disclosure time)';
/**
* Loopback hostnames recognized LITERALLY, never by substring (an evil host must not spoof this,
* e.g. `notlocalhost.example`). D5: localhost is not "safe by default" — it is disclosed and
* FLAGGED, never omitted (matrix B4).
*/
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -52,6 +89,8 @@ interface CapabilityManifest {
hooks?: unknown;
commands?: unknown;
mcpServers?: unknown;
/** ADR-2782 (#2796): a single reviewer-lane body — never an array (Phase 2's own validator rejects that shape). */
reviewer?: unknown;
[k: string]: unknown;
}
@@ -124,6 +163,65 @@ interface McpServerSurface {
rawConfig: Record<string, unknown>;
}
/**
* Resolves a reviewer lane's `hostConfigKey` (a dotted key into `.planning/config.json`) to its
* configured destination host, for HUMAN disclosure only. Optional — the loader has no config
* access and calls `discloseExecutableSurfaces`/`signatureForManifest` without one; the lifecycle
* MAY supply one so the consent prompt shows a real destination instead of `UNRESOLVED_HOST_MARKER`.
* MUST NOT throw is not required of the caller — `collectReviewerLaneSurfaces` treats any thrown
* error or non-string/empty return as "could not resolve" and falls back to the marker.
*/
type ReviewerHostResolver = (hostConfigKey: string) => string | undefined;
/**
* ADR-2782 D5 (#2796): the reviewer-lane executable surface. A capability manifest carries AT MOST
* ONE `reviewer` body (Phase 2's validator rejects an array), so this collector returns 0 or 1
* entries per manifest — the array return shape matches the other three collectors for a uniform
* `Disclosure` and lets `disclosureSignature` sort/fold it the same way.
*/
interface ReviewerLaneSurface {
/** The lane's declared identity (also its config/flag namer). Empty when undeclared/malformed. */
slug: string;
/** `'spawn'` | `'openai-http'` as declared, or '' when absent/malformed — disclosure never validates. */
transport: string;
/** spawn: the executable name/path as declared. Empty for an openai-http lane or when undeclared. */
binary: string;
/** spawn: the declared args, RENDERED (string-filtered) for the human summary — mirrors MCP's argv. */
args: string[];
/**
* spawn: the FULL declared args array (may contain non-strings the host still receives) — folded
* into the signature so ANY member change forces re-consent (matrix A6, the #1459 bug class:
* `python3` with innocuous args later becoming `['-c', '<program>']`). Empty when undeclared.
*/
rawArgs: unknown[];
/** openai-http: the dotted config key naming the destination host. Empty for a spawn lane. */
hostConfigKey: string;
/**
* openai-http: the resolved destination host when a resolver was supplied and could resolve
* `hostConfigKey`; `UNRESOLVED_HOST_MARKER` when a resolver was not supplied or could not resolve
* it. NEVER '' for an openai-http lane (matrix B3) — a blank reads as "no destination". '' for a
* spawn lane (no destination concept — mirrors McpServerSurface's empty-when-inapplicable
* convention). Deliberately EXCLUDED from `disclosureSignature`'s input (design constraint 2): the
* loader has no resolver and must compute the SAME signature as the lifecycle.
*/
resolvedHost: string;
/**
* True when `resolvedHost` is a loopback/local destination. D5: localhost is disclosed like any
* other destination, never treated as "safe by default" (matrix B4). Always false for a spawn
* lane and for an unresolved openai-http host.
*/
isLocalDestination: boolean;
/** How the review prompt reaches the lane (`'stdin'|'argv'|'argv-file-ref'|'none'`, or ''). */
promptChannel: string;
/** The first-party handler module name that post-processes this lane's output, or '' when undeclared. */
handler: string;
/**
* The data classes that egress to this lane on every run (`EGRESS_PAYLOAD_CLASSES`) — named
* honestly (Kerckhoffs's Principle) rather than disclosed as an unhelpful "sends data to the tool".
*/
egressPayloadClasses: string[];
}
interface Disclosure {
/** Hook scripts the capability registers (each runs as a runtime hook command). */
hooks: HookSurface[];
@@ -131,6 +229,12 @@ interface Disclosure {
commandModules: CommandModuleSurface[];
/** MCP servers the capability declares (each spawned by the host runtime) — name AND command. */
mcpServers: McpServerSurface[];
/**
* ADR-2782 (#2796): the reviewer lane this capability declares — 0 or 1 entries (a manifest
* carries at most one `reviewer` body). A fourth executable-surface class alongside the three
* above; a standing egress channel to an external reviewer.
*/
reviewerLanes: ReviewerLaneSurface[];
/** True when the capability ships ANY executable surface (=> consent required). */
hasExecutable: boolean;
/**
@@ -172,6 +276,12 @@ interface InstallTrustArgs {
stagedDir?: string;
strictKnownRegistries?: StrictKnownRegistries;
hostVersion: string;
/**
* Optional (#2796): resolves a reviewer lane's `hostConfigKey` to its configured destination, for
* HUMAN disclosure at install time only — never folds into the consent signature (design
* constraint 2; see `ReviewerHostResolver`).
*/
resolveHost?: ReviewerHostResolver;
}
interface InstallTrustVerdict {
@@ -194,25 +304,147 @@ function asString(v: unknown): string {
}
/**
* Enumerate every executable surface a capability manifest declares.
*
* Recognizes the three executable surface kinds a capability can ship:
* - `hooks`: [{ event, script }] — scripts run as runtime hook commands
* - `commands`:[{ family, module, router? }] — modules require()'d into the CLI process
* - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
*
* `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
* declare it, so the trust gate discloses it whenever present (honest disclosure over the
* narrower first-party schema). Pure: when `stagedDir` is provided, declared script/module
* files are existence-checked and any missing ones reported, but nothing is mutated.
* Run `fn`, returning `fallback` instead of throwing. Makes each per-class collector total: a
* hostile manifest (a Proxy with a throwing trap, a throwing getter, or a non-object/null root)
* degrades ONE surface class to empty rather than crashing disclosure for the other three classes
* behind it in the same manifest (ADR-2782 #2796 — disclosure runs before validation and must never
* throw; matrix C5/E2).
*/
function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: string): Disclosure {
const hooks: HookSurface[] = [];
const commandModules: CommandModuleSurface[] = [];
const mcpServers: McpServerSurface[] = [];
const missingArtifacts: string[] = [];
function safeCollect<T>(fn: () => T, fallback: T): T {
try {
return fn();
} catch {
return fallback;
}
}
// hooks: [{ event, script }]
/**
* Recognize a loopback/local destination from a RESOLVED openai-http host value (matrix B4). Matches
* literally, never by substring — an evil host must not spoof `localhost` via e.g.
* `notlocalhost.example`. Falls back to a scheme-less leading-segment match so a bare config value
* like `localhost:1234` or `192.168.1.5:8080` (no `http://` prefix) is still recognized.
*
* The fallback triggers on EITHER `new URL()` throwing (a value that is not parseable as an absolute
* URL at all, e.g. `192.168.1.5:8080` — WHATWG scheme names cannot start with a digit) OR it
* succeeding with an EMPTY hostname: `new URL('localhost:1234')` does NOT throw — it mis-parses the
* scheme-less `host:port` shape as an opaque URL whose "scheme" IS the hostname text
* (`protocol: "localhost:"`, `hostname: ""`), which would otherwise silently fail to recognize a
* bare local config value as local.
*/
function isLocalHostValue(hostValue: string): boolean {
let hostname = '';
try {
hostname = new URL(hostValue).hostname;
} catch {
hostname = '';
}
if (!hostname) {
hostname = extractBareHost(hostValue);
}
// WHATWG returns an IPv6 hostname bracketed; a bare config value may not be.
const lower = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
if (LOOPBACK_HOSTNAMES.has(lower)) return true;
if (isLoopbackIpv6(lower)) return true;
return isLoopbackIpv4(lower);
}
/**
* Pull the host out of a value `new URL()` could not parse — a scheme-less
* `host:port`, or one carrying a path/query/fragment.
*
* IPv6 needs explicit handling: splitting on `:` mangles `[::1]:8080` to `[`,
* which then matches nothing and silently reports a loopback destination as
* remote. A bracketed literal is taken through its closing bracket; an unbracketed
* value with two or more colons is treated as a bare IPv6 address rather than
* `host:port`, since a host:port has exactly one.
*/
function extractBareHost(hostValue: string): string {
let s = String(hostValue).trim();
const schemeEnd = s.indexOf('://');
if (schemeEnd >= 0) s = s.slice(schemeEnd + 3);
s = s.split(/[/?#]/)[0] || '';
if (s.startsWith('[')) {
const close = s.indexOf(']');
return close > 0 ? s.slice(1, close) : s;
}
const colons = (s.match(/:/g) || []).length;
if (colons >= 2) return s;
return colons === 1 ? s.slice(0, s.indexOf(':')) : s;
}
/**
* Render one declared argv member for the human consent prompt.
*
* A string prints as itself. Anything else prints in a form that makes its
* presence and shape visible rather than vanishing: an argv member the host
* still receives, but which the user was never shown, is a surface consented to
* unseen. Never throws — a circular or BigInt member must not break the prompt.
*/
function renderArgForHuman(arg: unknown): string {
if (typeof arg === 'string') return arg;
if (typeof arg === 'bigint') return `<${String(arg)}n>`;
try {
const json = JSON.stringify(arg);
return json === undefined ? `<${typeof arg}>` : `<${json}>`;
} catch {
return `<${typeof arg}>`;
}
}
/** `::1`, its expanded forms, and IPv4-mapped loopback (`::ffff:127.0.0.1`). */
function isLoopbackIpv6(host: string): boolean {
if (!host.includes(':')) return false;
if (host === '::1') return true;
const mapped = /^::ffff:(.+)$/i.exec(host);
if (mapped) return isLoopbackIpv4(mapped[1]);
const groups = host.split(':').filter((g) => g !== '');
if (groups.length === 0) return false;
return groups.every((g, i) => (i === groups.length - 1 ? /^0*1$/.test(g) : /^0*$/.test(g)));
}
/**
* 127.0.0.0/8 under inet_aton semantics, which is what a browser, curl and the
* OS resolver all accept. `127.1`, `2130706433`, `0x7f000001` and `0177.0.0.1`
* are every bit as loopback as `127.0.0.1`; a disclosure that flags only the
* dotted-quad form understates a local destination for the other four.
*/
function isLoopbackIpv4(host: string): boolean {
const parts = host.split('.');
if (parts.length < 1 || parts.length > 4) return false;
const nums: number[] = [];
for (const part of parts) {
let n: number;
if (/^0[xX][0-9a-fA-F]+$/.test(part)) n = parseInt(part, 16);
else if (/^0[0-7]+$/.test(part)) n = parseInt(part, 8);
else if (/^\d+$/.test(part)) n = parseInt(part, 10);
else return false;
if (!Number.isFinite(n) || n < 0) return false;
nums.push(n);
}
// inet_aton: the final part absorbs every remaining octet.
let addr: number;
if (nums.length === 1) addr = nums[0];
else if (nums.length === 2) addr = ((nums[0] & 0xff) * 0x1000000) + (nums[1] & 0xffffff);
else if (nums.length === 3) addr = ((nums[0] & 0xff) * 0x1000000) + ((nums[1] & 0xff) * 0x10000) + (nums[2] & 0xffff);
else addr = ((nums[0] & 0xff) * 0x1000000) + ((nums[1] & 0xff) * 0x10000) + ((nums[2] & 0xff) * 0x100) + (nums[3] & 0xff);
if (!Number.isFinite(addr) || addr < 0 || addr > 0xffffffff) return false;
return Math.floor(addr / 0x1000000) === 127;
}
/**
* Collect the `hooks` executable-surface class: [{ event, script }] — scripts run as runtime hook
* commands. Extracted from the former monolithic `discloseExecutableSurfaces` (ADR-2782 #2796,
* cyclomatic 51 / cognitive 99 / 110 lines / `risk_level: critical`) — BEHAVIOR UNCHANGED, only
* isolated so it is independently testable and the orchestrator shrinks instead of growing a fourth
* class inline. `missingArtifacts` is a shared accumulator the orchestrator passes to every collector
* that can populate it.
*/
function collectHookSurfaces(
manifest: CapabilityManifest,
stagedDir: string | undefined,
missingArtifacts: string[],
): HookSurface[] {
const hooks: HookSurface[] = [];
if (Array.isArray(manifest.hooks)) {
for (const h of manifest.hooks) {
if (typeof h !== 'object' || h === null) continue;
@@ -227,8 +459,19 @@ function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: st
}
}
}
return hooks;
}
// commands: [{ family, module, router? }]
/**
* Collect the `commands` executable-surface class: [{ family, module, router? }] — modules
* require()'d into the GSD CLI process. Extracted, BEHAVIOR UNCHANGED — see `collectHookSurfaces`.
*/
function collectCommandSurfaces(
manifest: CapabilityManifest,
stagedDir: string | undefined,
missingArtifacts: string[],
): CommandModuleSurface[] {
const commandModules: CommandModuleSurface[] = [];
if (Array.isArray(manifest.commands)) {
for (const c of manifest.commands) {
if (typeof c !== 'object' || c === null) continue;
@@ -245,10 +488,21 @@ function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: st
}
}
}
return commandModules;
}
// mcpServers: object map { name: { command, args } } OR array [{ name, command, args }]
// (or array [{ name, config: { command, args } }]). Capture the COMMAND, not just the name —
// the command is the executable that actually runs, and consent must disclose it (Codex R1 H1).
/**
* Collect the `mcpServers` executable-surface class: object map { name: { command, args } } OR
* array [{ name, command, args }] (or array [{ name, config: { command, args } }]). Captures the
* COMMAND, not just the name — the command is the executable that actually runs, and consent must
* disclose it (Codex R1 H1). Extracted, BEHAVIOR UNCHANGED — see `collectHookSurfaces`. Unlike
* hooks/commands, an MCP server's command is never existence-checked against `stagedDir` (exactly
* like a reviewer lane's `binary` — see `collectReviewerLaneSurfaces` — it may be any PATH
* executable, not necessarily a bundle artifact), so this collector takes no `missingArtifacts`
* accumulator.
*/
function collectMcpSurfaces(manifest: CapabilityManifest): McpServerSurface[] {
const mcpServers: McpServerSurface[] = [];
if (manifest.mcpServers && typeof manifest.mcpServers === 'object') {
const pushServer = (name: string, config: unknown): void => {
if (!name) return;
@@ -313,9 +567,174 @@ function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: st
}
}
}
return mcpServers;
}
const hasExecutable = hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0;
return { hooks, commandModules, mcpServers, hasExecutable, missingArtifacts };
/**
* Collect the reviewer-lane executable-surface class (ADR-2782 D5, #2796): 0 or 1 entries, since a
* capability manifest carries AT MOST ONE `reviewer` body (Phase 2's validator rejects an array
* shape outright — matrix C2b). The array return shape matches the other three collectors so
* `Disclosure`/`disclosureSignature` treat it uniformly (sort-then-fold), even though today it can
* never hold more than one entry.
*
* TOTAL and absent-safe (matrix C1–C5): no `reviewer` key, `reviewer: null`, a non-object body
* (array/boolean/number), a malformed `invoke`, non-array `flags`, or the whole manifest being a
* throwing Proxy/getter all degrade to "no lane" rather than throwing — disclosure runs BEFORE
* Phase 2's validation, on a manifest validation would reject outright.
*
* `resolveHost` is optional — supplied by the lifecycle (never the loader, which has no config
* access) to disclose the REAL destination of an `openai-http` lane to a human at install/upgrade
* time. Its return value is NEVER folded into `disclosureSignature` (design constraint 2: the
* signature must stay a pure function of the manifest, or the loader and lifecycle would compute
* different signatures for the same manifest and produce a permanent false re-consent loop).
*/
function collectReviewerLaneSurfaces(
manifest: CapabilityManifest,
resolveHost?: ReviewerHostResolver,
): ReviewerLaneSurface[] {
return safeCollect(() => {
const r = manifest.reviewer;
// C1 (no reviewer key) / C2a (null) / C2b (non-object: array, boolean, number) all disclose no
// lane — never an error at this layer. Validation of a malformed body is Phase 2's job.
if (typeof r !== 'object' || r === null || Array.isArray(r)) return [];
const rec = r as Record<string, unknown>;
const slug = asString(rec['slug']);
const transport = asString(rec['transport']);
const handler = asString(rec['handler']);
// C3: `invoke` absent/malformed still discloses a lane, with empty binary/args/rawArgs rather
// than crashing — validating `invoke`'s shape is Phase 2's job, not disclosure's.
const invokeRaw = rec['invoke'];
const invoke = (typeof invokeRaw === 'object' && invokeRaw !== null && !Array.isArray(invokeRaw))
? (invokeRaw as Record<string, unknown>)
: {};
const binary = asString(invoke['binary']);
// B1b: the RAW declared args (may contain non-strings the host still receives) is what the
// signature binds; `args` is the string-filtered RENDERED view for a human summary — the exact
// argv/rawArgs split MCP servers already use for the same reason (TRUST2-4, #1459).
const rawArgsDeclared = Array.isArray(invoke['args']) ? (invoke['args'] as unknown[]) : [];
const args = rawArgsDeclared.filter((a): a is string => typeof a === 'string');
const hostConfigKey = asString(invoke['hostConfigKey']);
const promptChannel = asString(invoke['promptChannel']);
// An EMPTY (or wholly unrecognised) reviewer body declares no lane and must
// not be treated as one. Without this, `reviewer: {}` alone flips
// hasExecutable true and perturbs the disclosure signature — producing a
// re-consent prompt whose only content is "(no binary declared)". That is a
// prompt carrying no security information, which is exactly the
// click-through-training harm this design refuses for reviewsSection and
// timeoutFloorMs; refusing it there and permitting it here would be
// inconsistent.
//
// The test is deliberately BROAD — any one recognised field with a value is
// enough. Requiring specifically a binary, or specifically a slug, would let
// a lane declaring only the other slip through unconsented, which is the far
// worse failure.
const declaresSomething = Boolean(
slug || transport || handler || binary || hostConfigKey || promptChannel
|| rawArgsDeclared.length > 0,
);
if (!declaresSomething) return [];
// B2/B3/B4: resolvedHost/isLocalDestination are only meaningful for an openai-http lane — a
// spawn lane has no destination concept, so both stay at their inapplicable defaults ('' /
// false), mirroring McpServerSurface's existing empty-when-inapplicable convention (e.g.
// `url: ''` for a stdio server). For openai-http, resolvedHost never ends up '' — it is either a
// real resolved value or the explicit UNRESOLVED_HOST_MARKER (never a blank read as "no
// destination").
// The shape test is deliberately WIDER than an exact transport match, and the
// human summary uses the same one. Disclosure runs BEFORE validation, so a
// mis-cased or unrecognised `transport` reaches here; keying only on the exact
// string would leave a lane that plainly declares a hostConfigKey with a BLANK
// destination, which reads as "no destination" — the precise thing B3 forbids.
const hasHttpShape = transport === 'openai-http' || (!binary && Boolean(hostConfigKey));
let resolvedHost = '';
let isLocalDestination = false;
if (hasHttpShape) {
resolvedHost = UNRESOLVED_HOST_MARKER;
if (typeof resolveHost === 'function') {
let resolved: string | undefined;
try {
resolved = resolveHost(hostConfigKey);
} catch {
resolved = undefined;
}
if (typeof resolved === 'string' && resolved) resolvedHost = resolved;
}
if (resolvedHost !== UNRESOLVED_HOST_MARKER) {
isLocalDestination = isLocalHostValue(resolvedHost);
}
}
const surface: ReviewerLaneSurface = {
slug,
transport,
binary,
args,
rawArgs: rawArgsDeclared,
hostConfigKey,
resolvedHost,
isLocalDestination,
promptChannel,
handler,
// B5: every lane receives the same named egress payload classes — a fresh copy per surface so
// no caller can mutate the shared constant through a returned surface.
egressPayloadClasses: [...EGRESS_PAYLOAD_CLASSES],
};
return [surface];
}, []);
}
/**
* Enumerate every executable surface a capability manifest declares.
*
* Recognizes the FOUR executable surface kinds a capability can ship:
* - `hooks`: [{ event, script }] — scripts run as runtime hook commands
* - `commands`: [{ family, module, router? }] — modules require()'d into the CLI process
* - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
* - `reviewer`: { slug, transport, invoke, ... } — an external reviewer lane (ADR-2782 D5, #2796)
*
* `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
* declare it, so the trust gate discloses it whenever present (honest disclosure over the
* narrower first-party schema). Pure: when `stagedDir` is provided, declared hook/command-module
* files are existence-checked and any missing ones reported, but nothing is mutated. A reviewer
* lane's `binary` is NEVER existence-checked against `stagedDir` (matrix C6) — like an MCP server's
* command, it is a PATH lookup on the user's machine, never a bundle artifact; existence-checking it
* would block every lane install.
*
* TOTAL: never throws, for any manifest shape — including a non-object manifest, a Proxy with
* throwing traps, or a property with a throwing getter (matrix C5, E2). Disclosure runs BEFORE
* Phase 2's validation, on a manifest validation would reject outright, so it must tolerate what
* validation does not. Each surface class is collected independently (`safeCollect`) so a hostile
* value in ONE class degrades only that class to empty rather than losing the other three.
*
* `resolveHost` (optional, #2796) is forwarded to `collectReviewerLaneSurfaces` so a caller with
* config access (the lifecycle, never the loader — see `signatureForManifest`) can disclose the REAL
* destination of an `openai-http` lane. It never affects the returned signature.
*/
function discloseExecutableSurfaces(
manifest: CapabilityManifest,
stagedDir?: string,
resolveHost?: ReviewerHostResolver,
): Disclosure {
const missingArtifacts: string[] = [];
const hooks = safeCollect(() => collectHookSurfaces(manifest, stagedDir, missingArtifacts), [] as HookSurface[]);
const commandModules = safeCollect(
() => collectCommandSurfaces(manifest, stagedDir, missingArtifacts),
[] as CommandModuleSurface[],
);
const mcpServers = safeCollect(() => collectMcpSurfaces(manifest), [] as McpServerSurface[]);
const reviewerLanes = safeCollect(
() => collectReviewerLaneSurfaces(manifest, resolveHost),
[] as ReviewerLaneSurface[],
);
const hasExecutable =
hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0 || reviewerLanes.length > 0;
return { hooks, commandModules, mcpServers, reviewerLanes, hasExecutable, missingArtifacts };
}
/**
@@ -510,7 +929,7 @@ function checkEngines(manifest: CapabilityManifest, hostVersion: string): Engine
* is defense-in-depth and lets callers surface a compatVersions downgrade hint.
*/
function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion } = args;
const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion, resolveHost } = args;
const blockReasons: string[] = [];
const src = evaluateSourceAllowed(parsed, strictKnownRegistries);
@@ -534,7 +953,10 @@ function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
);
}
const disclosure = discloseExecutableSurfaces(manifest, stagedDir);
// #2796: resolveHost is optional and, when supplied, discloses the REAL destination of an
// 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);
// 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
@@ -560,13 +982,42 @@ function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
* reordering. Used to fold an MCP server's `env` map into the disclosure signature: ADDING or
* CHANGING any env entry changes the signature (forces re-consent), but merely REORDERING the keys
* does NOT (no false re-prompt). TRUST-2 (#1459).
*
* TOTAL (#2796, matrix C5c/E2): a value declared inside an unvalidated manifest — e.g. a reviewer
* lane's `invoke.args` — may contain a BigInt (which `JSON.stringify` throws on) or a circular
* reference (which unguarded recursion stack-overflows on). Both are handled without throwing:
* a BigInt renders as its decimal string; a cycle (an object that is its OWN ancestor in the current
* recursion path — tracked via `seen`, added before recursing into children and removed once fully
* processed) renders as the literal string `"[Circular]"`. Neither case is reachable for the golden
* hooks/mods/mcp fixtures this phase's byte-identity tests pin down, so their output is unaffected.
*/
function stableJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
function stableJson(value: unknown, seen?: Set<unknown>): string {
if (typeof value === 'bigint') return JSON.stringify(`${value.toString()}n`);
if (value === null || typeof value !== 'object') {
try {
return JSON.stringify(value) ?? 'null';
} catch {
// A non-object value whose serialization still throws (defensive; JSON.stringify does not
// throw for any other typeof today, but this keeps the contract TOTAL against future engines).
return 'null';
}
}
const seenSet = seen ?? new Set<unknown>();
if (seenSet.has(value)) return '"[Circular]"';
try {
seenSet.add(value);
if (Array.isArray(value)) {
return `[${value.map((v) => stableJson(v, seenSet)).join(',')}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k], seenSet)}`).join(',')}}`;
} catch {
// A Proxy with a throwing trap, or a getter that throws on read — never propagate (matrix C5).
return '"[unserializable]"';
} finally {
seenSet.delete(value);
}
}
function disclosureSignature(d: Disclosure): string {
@@ -607,7 +1058,25 @@ function disclosureSignature(d: Disclosure): string {
]),
)
.sort();
return JSON.stringify([hooks, mods, mcp]);
// ADR-2782 D5 (#2796): fold in slug/transport/binary/rawArgs/hostConfigKey/promptChannel/handler —
// every field that changes WHAT runs, WHERE it sends data, or WHAT CODE post-processes its output
// (matrix A3–A9). Deliberately ABSENT from this line: `reviewsSection` and `timeoutFloorMs` (matrix
// A10/A13 — cosmetic fields; folding them in would force a re-consent prompt that carries no
// security information, training users to click through) and the RESOLVED host (design constraint
// 2 — the loader has no config resolver and must compute the SAME signature as the lifecycle, or a
// resolver-bearing caller and a resolver-less caller would permanently disagree on one manifest's
// signature).
const lanes = d.reviewerLanes
.map((l) =>
stableJson(['lane', l.slug, l.transport, l.binary, l.rawArgs || [], l.hostConfigKey, l.promptChannel, l.handler]),
)
.sort();
// D4.5 (the highest-consequence line in this phase): the lane element is appended ONLY when at
// least one lane is declared. A lane-free manifest's signature stays BYTE-IDENTICAL to before this
// class existed (matrix A1a/A1b/A1c) — appending unconditionally would change every already-
// installed capability's signature and re-prompt every user for every capability on next upgrade,
// whether or not they use any reviewer lane at all.
return lanes.length > 0 ? JSON.stringify([hooks, mods, mcp, lanes]) : JSON.stringify([hooks, mods, mcp]);
}
/**
@@ -698,6 +1167,34 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
if (s.cwd) lines.push(` cwd: ${s.cwd}`);
}
}
if (disclosure.reviewerLanes.length > 0) {
lines.push(` reviewer lane (${disclosure.reviewerLanes.length}): an external reviewer receives plan/review data on every run`);
for (const l of disclosure.reviewerLanes) {
// B1/B2/B3/B4: disclose binary+args for a spawn lane, or hostConfigKey+resolved destination
// (flagged local when applicable, never omitted as "safe") for an openai-http lane — never
// curl/the transport name alone, which would be true and useless (design B2).
// Branch on the DECLARED SHAPE, not on an exact transport string. A lane
// whose transport is mis-cased or unrecognised still has a hostConfigKey,
// and falling through to the spawn branch would print "(no binary
// declared)" for a lane that in fact egresses to a live remote host —
// understating the disclosure precisely when it matters. Disclosure runs
// BEFORE validation, so a non-canonical transport does reach this code.
if (l.transport === 'openai-http' || (!l.binary && l.hostConfigKey)) {
const localTag = l.isLocalDestination ? ' [local]' : '';
lines.push(` - ${l.slug || '(slug?)'} -> [openai-http] ${l.hostConfigKey || '(hostConfigKey?)'} => ${l.resolvedHost}${localTag}`);
} else {
// Render the RAW declared args, not the string-filtered view. The raw
// array is what the host receives and what the consent signature binds,
// so a non-string member that is invisible here is a surface the user
// consented to without being shown — the opposite of the disclosure's
// whole purpose.
const cmd = [l.binary, ...l.rawArgs.map(renderArgForHuman)].filter(Boolean).join(' ');
lines.push(` - ${l.slug || '(slug?)'} -> ${cmd || '(no binary declared)'}`);
}
if (l.handler) lines.push(` handler: ${l.handler}`);
lines.push(` sends: ${l.egressPayloadClasses.join(', ')}`);
}
}
if (disclosure.missingArtifacts.length > 0) {
lines.push(' WARNING — declared artifacts not found in the staged bundle:');
for (const a of disclosure.missingArtifacts) {
@@ -714,6 +1211,9 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
export = {
RESERVED_NAMESPACES,
discloseExecutableSurfaces,
// #2796: the reviewer-lane collector, exported for independent testability (ADR-2782's own
// argument for extracting per-class collectors rather than growing the switch inline).
collectReviewerLaneSurfaces,
checkReservedNamespace,
evaluateSourceAllowed,
checkEngines,
@@ -723,4 +1223,8 @@ export = {
// #1459: the consent-binding signature (single source of truth for loader + lifecycle consent).
disclosureSignature,
signatureForManifest,
// #2796: the non-blank unresolved-host marker and the named egress payload classes, exported so
// tests can assert exact equality rather than a loose substring match.
UNRESOLVED_HOST_MARKER,
EGRESS_PAYLOAD_CLASSES,
};

File diff suppressed because it is too large Load Diff