fix(#3515): disclose the intentional mcp unconfined posture (#3517)

* test(#3515): add failing-first unconfined-mcp notice suite

* fix(#3515): disclose the intentional mcp unconfined posture

* chore(#3515): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 21:19:04 -04:00
committed by GitHub
parent 411196bc3a
commit e57918a648
7 changed files with 231 additions and 2 deletions

View File

@@ -655,6 +655,14 @@ function applyCapabilitySharedEdits(args: {
}
if (mcpEntries.length > 0) {
// #3515 (epic #1900 F20): the MCP config below is written VERBATIM — command/args/env/cwd
// are NOT confined to the bundle the way hook scripts are (confinedBundleScript, D5 rule 5).
// This asymmetry is INTENTIONAL: most real MCP servers legitimately resolve command/args/cwd
// to global or npx installs outside the capability bundle, so confinement would break them.
// The compensating controls are disclosure + re-consent: the consent prompt renders an
// explicit "not confined to the bundle" notice for every spawned server (summarizeDisclosure,
// capability-trust.cts), and disclosureSignature folds command/args/env/cwd + the FULL
// rawConfig as stable-sorted JSON (#1459 finding 5), so ANY config change forces re-consent.
const mcpObj = (typeof settings['mcpServers'] === 'object' && settings['mcpServers'] !== null && !Array.isArray(settings['mcpServers']))
? (settings['mcpServers'] as Record<string, unknown>)
: {};

View File

@@ -1475,6 +1475,16 @@ function summarizeInstructionSurfaces(disclosure: Disclosure): string[] {
* exists to provide. GSD-authored literals (fallback placeholders, headings, `<redacted>`) are never
* escaped — only manifest-supplied data is.
*/
/**
* #3515: one predicate for "this MCP server is remote (connects to a URL; nothing local is
* spawned)" — shared by the section's confinement notice and the per-server rendering so the
* consent-prompt claim cannot drift from what is actually disclosed per server. Branches on the
* DECLARED SHAPE: an http/sse transport, or a server with no command but a url.
*/
function isRemoteMcpServer(s: McpServerSurface): boolean {
return (s.transport === 'http' || s.transport === 'sse') || (!s.command && !!s.url);
}
function summarizeDisclosure(disclosure: Disclosure): string[] {
const lines: string[] = [];
const instructionLines = summarizeInstructionSurfaces(disclosure);
@@ -1511,10 +1521,26 @@ function summarizeDisclosure(disclosure: Disclosure): string[] {
}
if (disclosure.mcpServers.length > 0) {
lines.push(` MCP servers (${disclosure.mcpServers.length}): spawned/connected by the host runtime`);
// #3515 (epic #1900 F20): the confinement-posture notice. Hook commands are confined to the
// capability bundle (D5 rule 5); an MCP server's command/args/env/cwd are written VERBATIM and
// may point anywhere on the machine — an intentional asymmetry (confining them would break
// global/npx servers), disclosed here so the consent is informed rather than assumed. env is
// named explicitly (isolated review finding): an execution-primitive env value changes WHAT
// runs without touching command or argv — the classic vector — and omitting it would invite
// the inference that env IS confined. Only SPAWNED (stdio) servers earn the line: a remote
// (http/sse) server runs nothing locally, and the claim must be exact in a consent prompt.
// One shared predicate (below) decides spawn-vs-remote for the notice AND the per-server
// rendering, so the two cannot drift into an inexact claim.
if (disclosure.mcpServers.some((s) => !isRemoteMcpServer(s))) {
lines.push(
' intentionally NOT confined to the bundle: a server\'s command, args, env, and cwd are written ' +
'verbatim and may point anywhere on this machine — unlike hooks, which are confined to the capability bundle root'
);
}
for (const s of disclosure.mcpServers) {
// 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 isRemote = isRemoteMcpServer(s);
const name = renderValueForPrompt(s.name);
if (isRemote) {
const t = s.transport ? renderValueForPrompt(s.transport) : 'http';