* test(#3248): failing-first suite for instruction-surface disclosure 28 matrix rows from 50-test-matrix.md. Rows requiring the new Disclosure.instructionSurfaces field fail today; rows 18-20/23-25 (the ADR-2363 D4 signature invariants) pass today by construction because the current code never reads skills/agents at all, and stand as regression guards for the implementation commit. Refs #3248 * feat(#3248): disclose capability skills and agents as an instruction surface ADR-2363 D5. A capability whose only contribution was skills disclosed nothing at install: summarizeDisclosure early-returned "ships no executable surfaces (declarative only)" because hasExecutable was false, while each SKILL.md body landed verbatim in the agent's instruction context. discloseExecutableSurfaces gains a fifth, NON-executable class, instructionSurfaces, collecting declared skills/agents stems through the same safeCollect wrapper as the four existing collectors, so a hostile value degrades only this class and the function stays total for any manifest shape. Nothing existing is edited: the collectors, hasExecutable, disclosureSignature and missingArtifacts are untouched. get_impact rates the symbol CRITICAL at 196 affected, which is why the design is strictly additive. D4 is implemented by omission and pinned rather than left incidental: adding, changing or removing skills/agents leaves disclosureSignature byte-identical, so no stored consent record is perturbed and no spurious re-consent fires. ADR-2782's conditional-append trick is deliberately NOT reused - it worked because no manifest could declare a reviewer body before that class existed, whereas skills predate this one, so a conditional append would re-sign every already-consented skill-bearing capability. The renderer is extracted as summarizeInstructionSurfaces and called from BOTH branches of summarizeDisclosure. Appending only at the end would never render for skill-only capabilities - the ones that need it - since those take the early return. That branch's "declarative only" claim is now conditional on there being no instruction surface either. The renderer iterates rather than spreading into push, so an unbounded stem count cannot throw RangeError, and tolerates the bare {} the CLI edge passes via `res.disclosure || {}`. Scope note: #3248's prose says "skill stems"; ADR-2363 D3 classifies instruction surfaces as "skills, agents". Shipping skills alone would leave an ADR deliverable owned by no phase, and the epic has no Phase 2. Agents are the same shape at no extra cost. Narrowing back is a two-line change. Ratifies ADR-2363 (Proposed -> Accepted) and adds the owed ADR-1244 back-link. Closes #3248 * fix(#3248): escape consent-prompt values and narrow disclosure to skills Two review findings, both of which made the previous commit wrong. BLOCKER (isolated adversarial review). Every manifest-supplied value interpolated into a consent-prompt line was rendered unescaped. Those lines are joined with \n and written RAW to stderr on the needs-consent path (capability-command-router -> cli-exit runMain), so a stem carrying a newline forged additional lines indistinguishable from genuine GSD disclosure text, and an ANSI escape could clear or rewrite lines already printed. That defeats the informed-consent guarantee this change exists to provide, and is a prompt-injection vector against any agent that reads the stderr text to decide whether to retry with --yes. The hole was not unique to the new class - hook event/script, command family/module/router, every MCP field, and every reviewer-lane field were equally unescaped. Fixing only the new one would have created the generative-fix divergence this repo tracks, so renderValueForPrompt is applied to all five classes through one helper, guarded by a parity test that fails if a future class skips it. Escaping is identity for ordinary names, so no well-formed manifest's output changes. The disclosure OBJECT stays verbatim - only the rendered LINE is escaped - because the signature and every consumer reasoning about identity depend on the declared value. NARROWED to skills only. The previous commit also collected agents, arguing ADR-2363 D3 classifies instruction surfaces as "skills, agents". Verified against staging: stageSkillsForRuntimeAsSkills takes a registry and unions third-party skills in via readInstalledCapabilitySkill, while stageAgentsForRuntimeWithConverter takes only a source directory and has no registry-aware path. Third-party agents are never staged into the instruction context, so disclosing them would have put a false claim in a security prompt - worse than the scope creep two reviewers flagged it as. D3's classification stands; D5 now records that Phase 1 implements the skills half and that whether agents should be staged at all is an open maintainer question. Also reverts the premature ADR-2363 ratification. The previous commit flipped it to Accepted and asserted "#3248 merged" while this branch IS #3248 and is unmerged. Status returns to Proposed, and the ADR-1244 back-link - owed only on ratification - is withdrawn. Adds the fast-check property suite CLAUDE.md requires and the direct precedent (reviewer-trust-disclosure) already had: totality, D4 signature invariance, D3 hasExecutable invariance, and renderer totality over adversarial manifests. Refs #3248 * chore(#3248): correct changeset scope claim and backfill pr number The fragment was written against the pre-narrowing commit and still advertised 'skills and agents'. 4d26887e narrowed disclosure to skills only - third-party agents are never staged into the instruction context - but did not touch the fragment, so the release notes would have carried a claim the code does not implement. Also backfills pr:0 -> 3253 and names the prompt-escaping fix, which is user-visible and was absent from the original body. Changeset-only; no code or test changed, so the gsd-test pass recorded for 4d26887e still describes this tree's behavior. Refs #3248 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* by ADR-2782 Phase 3 (#2796) with a FOURTH executable-surface class (the reviewer lane), and by
|
||||
* ADR-2363 Phase 1 (#3248) with a FIFTH, NON-executable class: the instruction surface.
|
||||
*
|
||||
* 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
|
||||
@@ -20,16 +21,33 @@
|
||||
* 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`).
|
||||
*
|
||||
* ADR-2363 D5 (#3248): a capability's declared `skills` are INSTRUCTION surfaces — their bodies are
|
||||
* copied verbatim into the user's agent instruction context, so their reach is bounded only by what
|
||||
* the agent will do when told. They are disclosed BY NAME and never content-scanned (D2 —
|
||||
* Kerckhoffs: a shipped rule set is readable by the adversary who installs it). Unlike the four
|
||||
* executable classes they never set `hasExecutable` (D3) and never enter `disclosureSignature` (D4):
|
||||
* folding them in would perturb the stored signature of every already-consented skill-bearing
|
||||
* capability and fire a spurious re-consent on its next upgrade — the harm ADR-2782 D4 rule 5
|
||||
* already forbids. Any future signature binding arrives as a versioned v2, never an in-place
|
||||
* re-encoding of v1. ADR-2363 D3's class table names "skills, agents", but third-party `agents[]`
|
||||
* are deliberately EXCLUDED here: `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`)
|
||||
* takes only a source directory, with no registry-aware third-party staging path the way
|
||||
* `readInstalledCapabilitySkill` gives skills — so a declared agent is never actually staged into
|
||||
* the instruction context, and disclosing it would name a surface that does not exist. Agents stay
|
||||
* unimplemented pending a maintainer decision.
|
||||
*
|
||||
* Exports:
|
||||
* RESERVED_NAMESPACES — id prefixes third parties may not claim
|
||||
* discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers / reviewer lanes
|
||||
* discloseExecutableSurfaces(...) — enumerate the four executable classes + instruction surfaces
|
||||
* collectReviewerLaneSurfaces(...) — the reviewer-lane collector, independently testable
|
||||
* collectInstructionSurfaces(...) — the instruction-surface 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
|
||||
* summarizeInstructionSurfaces(d) — the instruction-surface section of the consent summary
|
||||
* UNRESOLVED_HOST_MARKER — the non-blank marker for an unresolved openai-http host
|
||||
* EGRESS_PAYLOAD_CLASSES — the named data classes every reviewer lane receives
|
||||
*/
|
||||
@@ -222,6 +240,25 @@ interface ReviewerLaneSurface {
|
||||
egressPayloadClasses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-2363 D3 (#3248): an INSTRUCTION surface — an artifact whose body is copied verbatim into the
|
||||
* user's agent instruction context. Peer to the four executable-surface classes, and deliberately
|
||||
* NOT one of them: a skill body does not execute code, it instructs the thing that does.
|
||||
*
|
||||
* Disclosure NAMES the surface; it never inspects the body. Content scanning is rejected outright
|
||||
* by ADR-2363 D2 (Kerckhoffs — a shipped rule set is readable by the adversary who installs it).
|
||||
*/
|
||||
interface InstructionSurface {
|
||||
/**
|
||||
* Which declaration array the name came from — still a discriminator even with one member: a
|
||||
* future addition (see `INSTRUCTION_SURFACE_FIELDS`) is why this stays a field rather than being
|
||||
* dropped now.
|
||||
*/
|
||||
kind: 'skill';
|
||||
/** The declared stem/name, VERBATIM — never normalized, truncated, or deduped. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Disclosure {
|
||||
/** Hook scripts the capability registers (each runs as a runtime hook command). */
|
||||
hooks: HookSurface[];
|
||||
@@ -235,6 +272,14 @@ interface Disclosure {
|
||||
* above; a standing egress channel to an external reviewer.
|
||||
*/
|
||||
reviewerLanes: ReviewerLaneSurface[];
|
||||
/**
|
||||
* ADR-2363 D5 (#3248): the skills this capability contributes to the agent's instruction context
|
||||
* (agents are excluded — see the module header). A FIFTH disclosed class that is deliberately NOT
|
||||
* executable: it never contributes to `hasExecutable` (D3) and never enters `disclosureSignature`
|
||||
* (D4 — folding it in would perturb the stored signature of every already-consented skill-bearing
|
||||
* capability and fire a spurious re-consent on its next upgrade, which ADR-2782 D4 rule 5 forbids).
|
||||
*/
|
||||
instructionSurfaces: InstructionSurface[];
|
||||
/** True when the capability ships ANY executable surface (=> consent required). */
|
||||
hasExecutable: boolean;
|
||||
/**
|
||||
@@ -688,6 +733,54 @@ function collectReviewerLaneSurfaces(
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest fields whose declared names become instruction surfaces. Ordered data rather than a
|
||||
* hand-rolled loop per field, so a future second member of the class is one row, not a second copy
|
||||
* of the same filter. Deliberately a ONE-row table today: third-party `agents[]` are never staged
|
||||
* into the instruction context — `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`)
|
||||
* takes only a source directory, with no registry-aware staging path the way
|
||||
* `readInstalledCapabilitySkill` gives skills — so disclosing them would name a surface that does
|
||||
* not exist. ADR-2363 D3's class table says "skills, agents"; the agents half is therefore
|
||||
* deliberately unimplemented pending a maintainer decision.
|
||||
*/
|
||||
const INSTRUCTION_SURFACE_FIELDS: ReadonlyArray<{ field: string; kind: InstructionSurface['kind'] }> = [
|
||||
{ field: 'skills', kind: 'skill' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Collect the instruction surfaces a manifest declares (ADR-2363 D5, #3248) — peer to the four
|
||||
* executable-surface collectors, and invoked through the same `safeCollect` wrapper so a hostile
|
||||
* value here degrades ONLY this class to empty rather than losing the other four.
|
||||
*
|
||||
* Liberal in what it accepts, exactly like the existing collectors: a non-object manifest, an
|
||||
* absent field, a non-array field, and a non-string/blank member each degrade quietly. A non-array
|
||||
* `skills` is NOT a partial success — it yields nothing, because a scalar declares no set.
|
||||
*
|
||||
* Deliberately does NOT:
|
||||
* - dedup (a manifest declaring a stem twice discloses it twice — disclosure reports what the
|
||||
* manifest SAYS; collapsing would misreport it, and dedup is the registry's job);
|
||||
* - normalize or truncate a name (it is disclosed verbatim so the user sees what was declared);
|
||||
* - existence-check the stem against `stagedDir`. A stem is a REGISTRY name, not a bundle-relative
|
||||
* artifact path — checking it would repeat the reviewer-lane `binary` mistake (matrix C6) and
|
||||
* put registry names into `missingArtifacts`, which is for declared bundle FILES only.
|
||||
*/
|
||||
function collectInstructionSurfaces(manifest: CapabilityManifest): InstructionSurface[] {
|
||||
const surfaces: InstructionSurface[] = [];
|
||||
if (typeof manifest !== 'object' || manifest === null) return surfaces;
|
||||
for (const { field, kind } of INSTRUCTION_SURFACE_FIELDS) {
|
||||
const declared = (manifest as Record<string, unknown>)[field];
|
||||
if (!Array.isArray(declared)) continue;
|
||||
for (const entry of declared) {
|
||||
// A non-string or blank member is dropped INDIVIDUALLY — the valid siblings around it still
|
||||
// disclose, mirroring how collectHookSurfaces skips a malformed entry rather than the array.
|
||||
if (typeof entry !== 'string') continue;
|
||||
if (entry.trim() === '') continue;
|
||||
surfaces.push({ kind, name: entry });
|
||||
}
|
||||
}
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every executable surface a capability manifest declares.
|
||||
*
|
||||
@@ -697,6 +790,13 @@ function collectReviewerLaneSurfaces(
|
||||
* - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
|
||||
* - `reviewer`: { slug, transport, invoke, ... } — an external reviewer lane (ADR-2782 D5, #2796)
|
||||
*
|
||||
* plus ONE non-executable class (ADR-2363 D5, #3248):
|
||||
* - `skills`: string[] of owned stems — INSTRUCTION surfaces, whose bodies land in the agent's
|
||||
* instruction context. Disclosed by name; they never set `hasExecutable` (D3) and never enter
|
||||
* `disclosureSignature` (D4). Stems are registry names, so they are never existence-checked
|
||||
* against `stagedDir` and never appear in `missingArtifacts`. `agents` is excluded — see the
|
||||
* module header.
|
||||
*
|
||||
* `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
|
||||
@@ -709,7 +809,7 @@ function collectReviewerLaneSurfaces(
|
||||
* 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.
|
||||
* value in ONE class degrades only that class to empty rather than losing the others.
|
||||
*
|
||||
* `resolveHost` (optional, #2796) is forwarded to `collectReviewerLaneSurfaces` so a caller with
|
||||
* config access (the lifecycle, never the loader — see `signatureForManifest`) can disclose the REAL
|
||||
@@ -731,10 +831,16 @@ function discloseExecutableSurfaces(
|
||||
() => collectReviewerLaneSurfaces(manifest, resolveHost),
|
||||
[] as ReviewerLaneSurface[],
|
||||
);
|
||||
const instructionSurfaces = safeCollect(
|
||||
() => collectInstructionSurfaces(manifest),
|
||||
[] as InstructionSurface[],
|
||||
);
|
||||
|
||||
// ADR-2363 D3: instruction surfaces are deliberately ABSENT from this expression. Adding them
|
||||
// would silently change `executableSetChanged` and the auto-update re-consent trigger.
|
||||
const hasExecutable =
|
||||
hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0 || reviewerLanes.length > 0;
|
||||
return { hooks, commandModules, mcpServers, reviewerLanes, hasExecutable, missingArtifacts };
|
||||
return { hooks, commandModules, mcpServers, reviewerLanes, instructionSurfaces, hasExecutable, missingArtifacts };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1126,21 +1232,127 @@ function truncateEnvValue(v: string): string {
|
||||
return v.length > ENV_VALUE_MAX ? `${v.slice(0, ENV_VALUE_MAX)}… (${v.length} chars)` : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that must never reach the consent prompt unescaped. `summarizeDisclosure`'s lines are
|
||||
* joined with `\n` and written RAW to stderr on the needs-consent path, and every value in them is
|
||||
* attacker-controlled manifest data. A raw newline forges a line indistinguishable from genuine
|
||||
* disclosure text; a raw ESC lets a value rewrite or clear lines already printed; a bidi override
|
||||
* visually reorders one. C0, DEL, C1, the bidi/isolate controls, and the line/paragraph separators
|
||||
* are all escaped to a visible `\uXXXX`, so the value stays identifiable and cannot forge output.
|
||||
*/
|
||||
const UNSAFE_PROMPT_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
/** Max characters of any single manifest-supplied value rendered into the consent prompt. */
|
||||
const PROMPT_VALUE_MAX = 200;
|
||||
|
||||
/**
|
||||
* Render one manifest-supplied value safely into a consent-prompt line: escape every character that
|
||||
* could forge or rewrite output, then bound the length so one oversized value cannot flood the
|
||||
* prompt and push the rest off screen. Escaping is IDENTITY for ordinary names, so this changes no
|
||||
* existing rendered output for any well-formed manifest — only the disclosure OBJECT is verbatim;
|
||||
* the rendered LINE is always escaped.
|
||||
*/
|
||||
function renderValueForPrompt(v: unknown): string {
|
||||
// `String(v)` on an arbitrary `unknown` risks Object's default `[object Object]` stringification
|
||||
// (@typescript-eslint/no-base-to-string) for a non-primitive; every call site here passes a string
|
||||
// in practice, but the parameter stays `unknown` for the same total-collector discipline as
|
||||
// `renderArgForHuman`, so a non-primitive is JSON-stringified instead of coerced.
|
||||
let s: string;
|
||||
if (typeof v === 'string') {
|
||||
s = v;
|
||||
} else if (v === null || v === undefined) {
|
||||
s = '';
|
||||
} else if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint') {
|
||||
s = String(v);
|
||||
} else {
|
||||
try {
|
||||
s = JSON.stringify(v) ?? '';
|
||||
} catch {
|
||||
s = '';
|
||||
}
|
||||
}
|
||||
const escaped = s.replace(UNSAFE_PROMPT_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
return escaped.length > PROMPT_VALUE_MAX
|
||||
? `${escaped.slice(0, PROMPT_VALUE_MAX)}… (${escaped.length} chars)`
|
||||
: escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the instruction-surface section of a consent summary (ADR-2363 D3/D5, #3248).
|
||||
*
|
||||
* Extracted as its own exported function for two reasons. It is called from BOTH branches of
|
||||
* `summarizeDisclosure` — a skill-only capability has `hasExecutable === false` and takes the early
|
||||
* return, so a section appended only at the end would never render for exactly the capabilities
|
||||
* that need it. And it gives tests a typed surface to assert on, instead of regex-matching prose out
|
||||
* of `summarizeDisclosure` (CONTRIBUTING — "Prohibited: Raw Text Matching on Test Outputs").
|
||||
*
|
||||
* Returns `[]` when nothing is declared, so either caller can append unconditionally without
|
||||
* emitting an empty header.
|
||||
*
|
||||
* TOTAL for a partial disclosure object: the CLI edge calls `summarizeDisclosure(res.disclosure || {})`
|
||||
* (`capability-command-router.cjs`), so a bare `{}` — carrying no `instructionSurfaces` at all —
|
||||
* reaches this function whenever a lifecycle result has no disclosure.
|
||||
*
|
||||
* #3248: every manifest-supplied value rendered here (`kind`, `name`) goes through
|
||||
* `renderValueForPrompt` first — the CLI edge writes these lines RAW to stderr on the needs-consent
|
||||
* path, and an unescaped name could forge a line or rewrite output already printed (see that
|
||||
* function's comment).
|
||||
*/
|
||||
function summarizeInstructionSurfaces(disclosure: Disclosure): string[] {
|
||||
const declared = (disclosure as Partial<Disclosure> | null | undefined)?.instructionSurfaces;
|
||||
const surfaces = Array.isArray(declared) ? declared : [];
|
||||
if (surfaces.length === 0) return [];
|
||||
const lines: string[] = [
|
||||
` instruction surfaces (${surfaces.length}): installed into your agent's instruction context`,
|
||||
];
|
||||
for (const s of surfaces) {
|
||||
// #3248: kind/name are manifest-supplied — escape+bound before rendering (see `renderValueForPrompt`).
|
||||
const kind = s?.kind ? renderValueForPrompt(s.kind) : '(kind?)';
|
||||
const name = s?.name ? renderValueForPrompt(s.name) : '(name?)';
|
||||
lines.push(` - ${kind}: ${name}`);
|
||||
}
|
||||
// ADR-2363 D1/D2, and Kerckhoffs: say plainly that nothing inspected these bodies. A summary that
|
||||
// named the surface while implying review would be worse than silence — a "looks checked" line
|
||||
// displaces the judgement this prompt exists to provoke (Goodhart, D2).
|
||||
lines.push(' these bodies are installed verbatim and are NOT content-scanned');
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a disclosure as consent-prompt lines. Returned as an array so the CLI/runtime edge can
|
||||
* format it; the lib never writes to stdout.
|
||||
*
|
||||
* #3248: every manifest-supplied value interpolated into a line (hook event/script, command
|
||||
* family/module/router, MCP name/transport/url/command/argv/header-keys/env-keys+values/cwd,
|
||||
* reviewer-lane slug/hostConfigKey/resolvedHost/binary/rawArgs/handler, missingArtifacts entries)
|
||||
* goes through `renderValueForPrompt` first, which escapes forging/rewriting control characters and
|
||||
* bounds the length. These lines are joined with `\n` and written RAW to stderr on the
|
||||
* needs-consent path (`capability-command-router.cjs`), so an unescaped value could forge a line or
|
||||
* rewrite/clear output already printed — defeating the informed-consent guarantee this function
|
||||
* exists to provide. GSD-authored literals (fallback placeholders, headings, `<redacted>`) are never
|
||||
* escaped — only manifest-supplied data is.
|
||||
*/
|
||||
function summarizeDisclosure(disclosure: Disclosure): string[] {
|
||||
const lines: string[] = [];
|
||||
const instructionLines = summarizeInstructionSurfaces(disclosure);
|
||||
if (!disclosure.hasExecutable) {
|
||||
lines.push('This capability ships no executable surfaces (declarative only).');
|
||||
// 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).');
|
||||
return lines;
|
||||
}
|
||||
lines.push('This capability ships no executable surfaces, but contributes agent instructions:');
|
||||
for (const line of instructionLines) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
lines.push('This capability ships executable surfaces that will run in your agent runtime:');
|
||||
if (disclosure.hooks.length > 0) {
|
||||
lines.push(` hooks (${disclosure.hooks.length}): run as runtime hook commands`);
|
||||
for (const h of disclosure.hooks) {
|
||||
lines.push(` - ${h.event || '(event?)'} -> ${h.script}`);
|
||||
const event = h.event ? renderValueForPrompt(h.event) : '(event?)';
|
||||
lines.push(` - ${event} -> ${renderValueForPrompt(h.script)}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.commandModules.length > 0) {
|
||||
@@ -1149,8 +1361,9 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
|
||||
);
|
||||
for (const m of disclosure.commandModules) {
|
||||
// TRUST2-3 (#1459): show the router (which exported fn runs) so the user consents to the exact entry point.
|
||||
const routerSuffix = m.router ? ` [router: ${m.router}]` : '';
|
||||
lines.push(` - ${m.family || '(family?)'} -> ${m.module}${routerSuffix}`);
|
||||
const routerSuffix = m.router ? ` [router: ${renderValueForPrompt(m.router)}]` : '';
|
||||
const family = m.family ? renderValueForPrompt(m.family) : '(family?)';
|
||||
lines.push(` - ${family} -> ${renderValueForPrompt(m.module)}${routerSuffix}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.mcpServers.length > 0) {
|
||||
@@ -1159,26 +1372,36 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
|
||||
// TRUST2-2 (#1459): a non-stdio (http/sse) server connects to a URL; disclose the endpoint, not
|
||||
// a (nonexistent) command. A stdio server discloses command + args as before.
|
||||
const isRemote = (s.transport === 'http' || s.transport === 'sse') || (!s.command && !!s.url);
|
||||
const name = renderValueForPrompt(s.name);
|
||||
if (isRemote) {
|
||||
const t = s.transport || 'http';
|
||||
lines.push(` - ${s.name} -> [${t}] ${s.url || '(no url declared)'}`);
|
||||
const t = s.transport ? renderValueForPrompt(s.transport) : 'http';
|
||||
const url = s.url ? renderValueForPrompt(s.url) : '(no url declared)';
|
||||
lines.push(` - ${name} -> [${t}] ${url}`);
|
||||
// Header VALUES are redacted in the human summary (they may carry secrets); only the KEY set
|
||||
// is shown. The full values ARE in the signature, so a value change forces re-consent.
|
||||
const hdrKeys = s.headers ? Object.keys(s.headers) : [];
|
||||
if (hdrKeys.length > 0) {
|
||||
lines.push(` headers: ${hdrKeys.map((k) => `${k}=<redacted>`).join(', ')}`);
|
||||
lines.push(` headers: ${hdrKeys.map((k) => `${renderValueForPrompt(k)}=<redacted>`).join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
const cmd = [s.command, ...s.argv].filter(Boolean).join(' ');
|
||||
lines.push(` - ${s.name} -> ${cmd || '(no command declared)'}`);
|
||||
// Command + args are each escaped individually (not the joined string) so a value that
|
||||
// embeds a newline cannot forge a line even when it lands mid-argv.
|
||||
const cmd = [s.command, ...s.argv].filter(Boolean).map(renderValueForPrompt).join(' ');
|
||||
lines.push(` - ${name} -> ${cmd || '(no command declared)'}`);
|
||||
}
|
||||
// TRUST-2 (#1459): env can change WHAT runs without touching the command, so show each env key
|
||||
// and its (truncated) value — the user is consenting to this exact environment.
|
||||
// and its (truncated) value — the user is consenting to this exact environment. Truncate first
|
||||
// (keeps the prompt readable at the existing 60-char bound), then escape the result (#3248) so
|
||||
// the truncated value can still not forge or rewrite output.
|
||||
const envKeys = s.env ? Object.keys(s.env) : [];
|
||||
if (envKeys.length > 0) {
|
||||
lines.push(` env: ${envKeys.map((k) => `${k}=${truncateEnvValue(s.env[k])}`).join(', ')}`);
|
||||
lines.push(
|
||||
` env: ${envKeys
|
||||
.map((k) => `${renderValueForPrompt(k)}=${renderValueForPrompt(truncateEnvValue(s.env[k]))}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (s.cwd) lines.push(` cwd: ${s.cwd}`);
|
||||
if (s.cwd) lines.push(` cwd: ${renderValueForPrompt(s.cwd)}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.reviewerLanes.length > 0) {
|
||||
@@ -1193,26 +1416,33 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
|
||||
// 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.
|
||||
const slug = l.slug ? renderValueForPrompt(l.slug) : '(slug?)';
|
||||
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}`);
|
||||
const hostConfigKey = l.hostConfigKey ? renderValueForPrompt(l.hostConfigKey) : '(hostConfigKey?)';
|
||||
lines.push(` - ${slug} -> [openai-http] ${hostConfigKey} => ${renderValueForPrompt(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)'}`);
|
||||
// whole purpose. `renderArgForHuman` stringifies a non-string member; that
|
||||
// string is equally attacker-controlled, so it is escaped too (#3248).
|
||||
const cmd = [l.binary, ...l.rawArgs.map(renderArgForHuman)]
|
||||
.filter(Boolean)
|
||||
.map(renderValueForPrompt)
|
||||
.join(' ');
|
||||
lines.push(` - ${slug} -> ${cmd || '(no binary declared)'}`);
|
||||
}
|
||||
if (l.handler) lines.push(` handler: ${l.handler}`);
|
||||
if (l.handler) lines.push(` handler: ${renderValueForPrompt(l.handler)}`);
|
||||
lines.push(` sends: ${l.egressPayloadClasses.join(', ')}`);
|
||||
}
|
||||
}
|
||||
for (const line of instructionLines) lines.push(line);
|
||||
if (disclosure.missingArtifacts.length > 0) {
|
||||
lines.push(' WARNING — declared artifacts not found in the staged bundle:');
|
||||
for (const a of disclosure.missingArtifacts) {
|
||||
lines.push(` - ${a}`);
|
||||
lines.push(` - ${renderValueForPrompt(a)}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
@@ -1228,12 +1458,21 @@ export = {
|
||||
// #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,
|
||||
// ADR-2363 D5 (#3248): the instruction-surface collector, exported for independent testability —
|
||||
// same rationale as `collectReviewerLaneSurfaces` above.
|
||||
collectInstructionSurfaces,
|
||||
checkReservedNamespace,
|
||||
evaluateSourceAllowed,
|
||||
checkEngines,
|
||||
evaluateInstallTrust,
|
||||
executableSetChanged,
|
||||
summarizeDisclosure,
|
||||
// ADR-2363 D3/D5 (#3248): the instruction-surface section of the consent summary, exported so
|
||||
// callers/tests can assert on it directly — see `summarizeInstructionSurfaces`'s own JSDoc.
|
||||
summarizeInstructionSurfaces,
|
||||
// #3248: the consent-prompt escaping/bounding helper, exported so tests can assert directly that
|
||||
// control characters (newline, ESC, bidi overrides, etc.) never reach a rendered prompt line.
|
||||
renderValueForPrompt,
|
||||
// #1459: the consent-binding signature (single source of truth for loader + lifecycle consent).
|
||||
disclosureSignature,
|
||||
signatureForManifest,
|
||||
|
||||
Reference in New Issue
Block a user