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

@@ -0,0 +1,5 @@
---
type: Security
pr: 3517
---
**MCP server configs are now explicitly flagged as unconfined in the capability consent prompt** — a capability's MCP servers can legitimately point at commands, args, env, and working directories anywhere on the machine (unlike its hooks, which are confined to the installed bundle), and the consent disclosure now says so plainly for every spawned server instead of leaving the asymmetry unstated. (#3515)

71
.pr-body-3515.md Normal file
View File

@@ -0,0 +1,71 @@
## Fix PR
> **Using the wrong template?**
> — Enhancement: use [enhancement.md](?template=enhancement.md)
> — Feature: use [feature.md](?template=feature.md)
---
## Linked Issue
> **Required.** This PR will be auto-closed if no valid issue link is found.
Fixes #3515
> The linked issue must have the `confirmed-bug` label. If it doesn't, ask a maintainer to confirm the bug before continuing.
---
## What was broken
The capability consent prompt never stated the hooks-vs-MCP confinement asymmetry: hook commands are confined to the capability bundle (ADR-1244 D5 rule 5), but an MCP server's `command`/`args`/`env`/`cwd` are written verbatim and may point anywhere on the machine. The asymmetry is intentional (per the maintainer decision on the epic — most real MCP servers legitimately resolve to global/npx installs, so confinement would break them), but unstated, so the consent was not informed about it.
## What this fix does
The document+disclose arm of the decision (no confinement machinery): the consent disclosure's MCP section now renders one explicit notice for every **spawned** (stdio) server — *"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"*. Remote-only (http/sse) servers render no notice (nothing local is spawned — the claim stays exact), decided by one shared `isRemoteMcpServer` predicate also used for the per-server rendering so the two cannot drift. The lifecycle's MCP write path documents the intentional asymmetry in code, cross-referencing the notice and the existing re-consent binding (`disclosureSignature` already folds command/args/env/cwd + full `rawConfig`, #1459 — any change forces re-consent).
## Root cause
The D5 hook confinement (rule 5) postdates the MCP write path; the asymmetry was deliberate but was never carried into the human disclosure, so the prompt showed MCP servers without saying their posture differs from the hooks listed right above them.
## Testing
### How I verified the fix
- Failing-first: `gsd-test` at the tests-only commit — verdict below.
- GREEN: full `gsd-test` matrix at the final HEAD — verdict below.
- A GOLDEN signature test locks the consent signature's exact bytes for a spawned-server manifest — the notice provably introduces no new disclosure state, so no already-consented install can be spuriously re-prompted.
### Regression test added?
- [x] Yes — added a test that would have caught this bug
### Platforms tested
- [x] macOS
- [ ] Windows (including backslash path handling)
- [x] Linux
### Runtimes tested
- [ ] Claude Code
- [ ] Gemini CLI
- [ ] OpenCode
- [ ] Other: ___
- [x] N/A (not runtime-specific — trust-gate prompt renderer)
---
## Checklist
- [x] Issue linked above with `Fixes #3515` — **PR will be auto-closed if missing**
- [x] Linked issue has the `confirmed-bug` label
- [x] Fix is scoped to the reported bug — no unrelated changes included
- [x] Regression test added (or explained why not)
- [x] All existing tests pass (`npm test`) — full `gsd-test` matrix at final HEAD
- [x] `.changeset/` fragment added — `Security` type
- [x] No unnecessary dependencies added
## Breaking changes
None — no behavior change anywhere; one added prompt line (spawned MCP servers only), comments, and documentation.

View File

@@ -315,7 +315,7 @@ Issue #1459 user-owned consent seam (`gsd-core/bin/lib/capability-consent.cjs`).
Issue #1459 finding 4 shared cross-process lock primitive (`gsd-core/bin/lib/capability-lock.cjs`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's bounded `readSmallRegularFile` + `shell-command-projection`'s `execTool` for the rare start-time shell-out). THE single hardened lockfile protocol shared by BOTH `capability-lifecycle` (the `.gsd/capabilities/.lock` mutation lock) and `capability-consent` (the consent-store `.consent.lock`) — extracted so the two locks cannot diverge (mirrors the shared-validator / shared bounded-reader lessons). Exports: `acquireLock(lockPath, opts?)` (O_EXCL create with a JSON `{token,pid,hostname,startTime,ts}` body; steal protocol binds age to the body's own `ts`, never stale-steals a VERIFIED-LIVE same-host holder — pid alive AND recorded start-time matches the pid's current start-time, defeating pid-reuse without ever stealing a live holder — and reclaims only a dead/unverifiable holder via the dead-pid fast path or the hard `LOCK_DEADMAN_MS` deadman; `opts.maxAttempts` raises the bounded retry budget and `opts.waitForFresh` makes a contended fresh/live holder be WAITED FOR rather than failed-fast so genuinely-racing consent writers serialize), `releaseLock(handle)` (token + inode owner-safe — never deletes a successor's lock), `getProcessStartTime`, and the `_setLockProbes`/`_resetLockProbes` test seams. Carries the #1462 lifecycle-lock invariants (process-start-time liveness, TOCTOU-safe pre-rename identity recheck, bounded iterative loop).
### Capability Trust Gate
ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?, resolveHost?)` (enumerates the four executable surfaces — `hooks`, command modules, `mcpServers`, and reviewer lanes (ADR-2782 D5) — plus a fifth, non-executable class, instruction surfaces (`skills` stems only, ADR-2363 D5, #3248), returned as `instructionSurfaces`; flags `hasExecutable` from the four executable classes only — instruction surfaces deliberately do NOT contribute to it; a reviewer lane is the one class that *receives* data, so it discloses its binary + full args (spawn) or destination host + `hostConfigKey` (openai-http) together with the egress payload classes); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`); `collectInstructionSurfaces(manifest)` (the instruction-surface collector — `skills` stems only, independently testable, same total/`safeCollect` contract as the four executable collectors; ADR-2363 D3 classifies `agents` as an instruction surface too, but a third-party capability's declared `agents[]` are never staged into the agent's instruction context — `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`) has no registry-aware third-party path the way `readInstalledCapabilitySkill` gives skills — so disclosing them would name a surface that does not exist; agents stay unimplemented pending a maintainer decision, and are NOT thereby safe or inert, only undisclosed); `summarizeInstructionSurfaces(disclosure)` (renders the instruction-surface section of the consent summary; called from BOTH branches of `summarizeDisclosure` because 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). The MCP disclosure also captures each server's `env` (string→string, filtered) and `cwd` (#1459) — `disclosureSignature` folds them in as STABLE SORTED JSON so any env/cwd add/change forces re-consent while a key reorder does not; `signatureForManifest(manifest, stagedDir?)` is the single source of truth for that signature (consumed by the loader's consent check and the lifecycle's consent binding). #1459 finding 5: each MCP surface also carries `rawConfig` — the FULL declared server config the writer persists (`{...config}`), prototype-pollution-cleaned — folded into the signature as STABLE SORTED JSON so a change to ANY persisted field (not just the explicit whitelist — a future `envFile`/`workingDir`/launch option) forces re-consent, while a pure key reorder does not; the human summary stays readable via the key fields only. Instruction surfaces are deliberately EXCLUDED from `disclosureSignature` (ADR-2363 D4, #3248) — a manifest gaining, losing, or changing `skills` produces a byte-identical signature and disturbs no stored consent record; any future binding arrives as a versioned v2, never an in-place re-encoding of v1. The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/capability-trust-model.md`.
ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?, resolveHost?)` (enumerates the four executable surfaces — `hooks`, command modules, `mcpServers`, and reviewer lanes (ADR-2782 D5) — plus a fifth, non-executable class, instruction surfaces (`skills` stems only, ADR-2363 D5, #3248), returned as `instructionSurfaces`; flags `hasExecutable` from the four executable classes only — instruction surfaces deliberately do NOT contribute to it; a reviewer lane is the one class that *receives* data, so it discloses its binary + full args (spawn) or destination host + `hostConfigKey` (openai-http) together with the egress payload classes); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`); `collectInstructionSurfaces(manifest)` (the instruction-surface collector — `skills` stems only, independently testable, same total/`safeCollect` contract as the four executable collectors; ADR-2363 D3 classifies `agents` as an instruction surface too, but a third-party capability's declared `agents[]` are never staged into the agent's instruction context — `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`) has no registry-aware third-party path the way `readInstalledCapabilitySkill` gives skills — so disclosing them would name a surface that does not exist; agents stay unimplemented pending a maintainer decision, and are NOT thereby safe or inert, only undisclosed); `summarizeInstructionSurfaces(disclosure)` (renders the instruction-surface section of the consent summary; called from BOTH branches of `summarizeDisclosure` because 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). The MCP disclosure also captures each server's `env` (string→string, filtered) and `cwd` (#1459) — `disclosureSignature` folds them in as STABLE SORTED JSON so any env/cwd add/change forces re-consent while a key reorder does not; `signatureForManifest(manifest, stagedDir?)` is the single source of truth for that signature (consumed by the loader's consent check and the lifecycle's consent binding). #1459 finding 5: each MCP surface also carries `rawConfig` — the FULL declared server config the writer persists (`{...config}`), prototype-pollution-cleaned — folded into the signature as STABLE SORTED JSON so a change to ANY persisted field (not just the explicit whitelist — a future `envFile`/`workingDir`/launch option) forces re-consent, while a pure key reorder does not; the human summary stays readable via the key fields only. #3515 (epic #1900 F20): the MCP disclosure section additionally renders an INTENTIONALLY-NOT-CONFINED notice for every SPAWNED (stdio) server — command/args/cwd are written verbatim and may point anywhere on the machine, unlike the confined hook path (capability-lifecycle's MCP write documents the same asymmetry in code); remote-only (http/sse) servers render no notice (nothing local is spawned), and the line introduces NO new Disclosure field so disclosureSignature is untouched. Instruction surfaces are deliberately EXCLUDED from `disclosureSignature` (ADR-2363 D4, #3248) — a manifest gaining, losing, or changing `skills` produces a byte-identical signature and disturbs no stored consent record; any future binding arrives as a versioned v2, never an in-place re-encoding of v1. The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/capability-trust-model.md`.
### Capability Lifecycle
ADR-1244 Phase 4 (D5+D6) orchestration seam (`gsd-core/bin/lib/capability-lifecycle.cjs`) composing the source resolver, ledger, and trust gate into the mutating operations. Exports: `installCapability` (pre-fetch source gate → resolve copy-only with `promote:false` → trust verdict → promote + apply marker-stamped shared edits → **ledger commit**; nothing written on block/abort), `upgradeCapability` (atomic stage-then-swap: old set aside, new swapped in, shared edits re-derived, **ledger committed**, backup dropped; re-prompts when the executable set changed), `removeCapability` (strip only `_gsdCapability`-marked shared-config entries — user hand-edits preserved — delete exactly the ledger-recorded files, then drop the entry; `CAPABILITY_DATA` preserved unless `removeData`), `reconcileCapabilities` (crash recovery driven by the ledger's `_pending {kind,backupName,sharedFiles}` INTENT — not a version comparison: roll an uncommitted upgrade back by restoring the backup, an uncommitted fresh install away entirely, and re-sync shared config from the winning bundle, guaranteeing no half-state), plus `applyCapabilitySharedEdits`/`stripCapabilitySharedEdits` (marker-isolated JSON edits, prototype-pollution-guarded). All four mutating ops + reconcile take a cross-process lock (`.gsd/capabilities/.lock`, atomic stale-steal) so a concurrent reconcile can't clear a live intent. Capability code never executes during any operation. The source resolver's `promote:false`/`skipEnginesGate` options are the seams that let this module own the swap/commit ordering and the engines gate (with `compatVersions` downgrade hint).

View File

@@ -209,6 +209,18 @@ signature is a stable, key-order-independent encoding, so any later add or chang
to a surface — including an env or cwd change — deactivates the capability until
the user re-consents, while a harmless key reorder does not.
One asymmetry the summary now names explicitly
([#3515](https://github.com/open-gsd/gsd-core/issues/3515)): hook commands are
*confined to the capability bundle*, but an MCP server's `command`, `args`,
`env`, and `cwd` are written **verbatim** and may point anywhere on the machine.
That is intentional — most real MCP servers legitimately resolve to global
or `npx` installs outside the bundle, and confining them would break every
such server — so the prompt says "intentionally NOT confined to the bundle"
for every spawned server rather than letting the asymmetry go unstated. The
re-consent signature covers this surface completely: any change to a
server's command, argv, env, cwd, or any other declared field forces
re-consent (above).
For everything else the bundle carries, the disclosure note explains what the
artifact does and consent is lighter. But "everything else" is not one class, and
treating it as one was a mistake this document made until ADR-2363 — see the next

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';

View File

@@ -615,3 +615,110 @@ test('TV-09: signatureForManifest does NOT vary with missingArtifacts (MISSING a
cleanup(missing);
}
});
// ---------------------------------------------------------------------------
// #3515 (epic #1900 F20) — MCP server config is INTENTIONALLY unconfined
// (unlike hooks); the consent disclosure must say so. Adopted decision: the
// document+disclose arm — confining args/cwd would break global/npx MCP
// servers (Hyrum); honest disclosure is the defense-in-depth.
// ---------------------------------------------------------------------------
test('#3515: stdio MCP disclosure carries the intentionally-unconfined notice', () => {
const d = trust.discloseExecutableSurfaces({
id: 'mcp-cap',
hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }],
mcpServers: {
'cap-srv': { command: 'node', args: ['/opt/global/server.js'], cwd: '/opt/somewhere-else' },
},
});
const joined = trust.summarizeDisclosure(d).join('\n');
assert.match(joined, /MCP servers \(1\)/);
assert.match(
joined,
/not confined to the bundle.*anywhere on this machine/i,
'the notice must state the unconfined posture and its scope'
);
assert.match(joined, /command, args, env, and cwd/i,
'isolated-review finding: env is the classic execution-primitive channel — omitting it invites the inference that env IS confined');
assert.match(joined, /unlike hooks/i, 'the hooks contrast is the load-bearing part of the claim');
});
test('#3515: the notice is unconditional for spawned servers (no per-value path heuristics)', () => {
const d = trust.discloseExecutableSurfaces({
id: 'mcp-cap-min',
mcpServers: { 'min-srv': { command: 'npx' } },
});
const joined = trust.summarizeDisclosure(d).join('\n');
assert.match(joined, /not confined to the bundle/i);
});
test('#3515: remote-only MCP does not claim a local spawn', () => {
const d = trust.discloseExecutableSurfaces({
id: 'mcp-cap-remote',
mcpServers: { 'remote-srv': { transport: 'http', url: 'https://example.com/mcp' } },
});
const joined = trust.summarizeDisclosure(d).join('\n');
assert.match(joined, /MCP servers \(1\)/);
assert.doesNotMatch(joined, /not confined to the bundle/i, 'nothing local is spawned — no unconfined claim');
});
test('#3515: no MCP section, no notice', () => {
const d = trust.discloseExecutableSurfaces({
id: 'hooks-only',
hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }],
});
const joined = trust.summarizeDisclosure(d).join('\n');
assert.doesNotMatch(joined, /not confined to the bundle/i);
});
test('#3515: the notice changes no consent signature — golden value for a spawned-server manifest', () => {
const manifest = {
id: 'sig-cap',
hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }],
mcpServers: { 'sig-srv': { command: 'node', args: ['x'], env: { K: 'V' }, cwd: '/tmp' } },
};
// GOLDEN (isolated-review finding m1: a self-comparison can never fail). Base64 of the exact
// signature so the literal carries no escaping. If you intentionally extend disclosureSignature,
// these bytes change and this fails — update the literal in the same, conscious change.
const GOLDEN_B64 = 'W1siW1wiaG9va1wiLFwiUG9zdFRvb2xVc2VcIixcImhvb2tzL3J1bi5qc1wiXSJdLFtdLFsiW1wibWNwXCIsXCJzaWctc3J2XCIsXCJcIixcIm5vZGVcIixbXCJ4XCJdLFwiXCIse30se1wiS1wiOlwiVlwifSxcIi90bXBcIix7XCJhcmdzXCI6W1wieFwiXSxcImNvbW1hbmRcIjpcIm5vZGVcIixcImN3ZFwiOlwiL3RtcFwiLFwiZW52XCI6e1wiS1wiOlwiVlwifX1dIl1d';
assert.strictEqual(
Buffer.from(trust.signatureForManifest(manifest), 'utf8').toString('base64'),
GOLDEN_B64
);
const d1 = trust.discloseExecutableSurfaces(manifest);
trust.summarizeDisclosure(d1); // rendering reads only — assert it cannot mutate disclosure state
assert.strictEqual(trust.executableSetChanged(d1, trust.discloseExecutableSurfaces(manifest)), false);
});
test('#3515: mixed transports render exactly one notice and still disclose the remote endpoint', () => {
const d = trust.discloseExecutableSurfaces({
id: 'mixed-cap',
mcpServers: {
'local-srv': { command: 'node', args: ['server.js'] },
'remote-srv': { transport: 'http', url: 'https://example.com/mcp' },
},
});
const lines = trust.summarizeDisclosure(d);
assert.strictEqual(
lines.filter((l) => /not confined to the bundle/i.test(l)).length,
1,
'one section-level notice, not one per server'
);
assert.ok(lines.some((l) => /\[http\] https:\/\/example\.com\/mcp/.test(l)), 'the remote endpoint is still disclosed');
});
test('#3515: boundary server shapes — no command and no url renders as spawned (exact-claim edges)', () => {
const d = trust.discloseExecutableSurfaces({
id: 'edge-cap',
mcpServers: {
'empty-srv': {},
'http-with-command': { transport: 'http', command: 'node', url: 'https://example.com/mcp' },
},
});
const joined = trust.summarizeDisclosure(d).join('\n');
// `{}` has neither transport nor url nor command — the shared predicate classifies it SPAWNED
// (nothing proves it is remote), so the notice fires.
assert.match(joined, /not confined to the bundle/i);
// An http-transport server WITH a command is remote by declared shape (transport wins).
assert.ok(/\[http\]/.test(joined));
});