Skip to content

Commit 8202fa3

Browse files
fix(cli): reject MCP credential collisions before rebuild (#9401)
<!-- markdownlint-disable MD041 --> ## Summary Sandbox rebuild now rejects an attached provider that supplies a credential key reserved by managed MCP before changing MCP state. Forced host-side recovery repeats the collision check before sandbox deletion. The credential guide documents recovery that preserves the provider and stored credential. ## Related Issue Fixes #9388 ## Changes - Run the attached-provider credential collision check during ordinary live-sandbox rebuild preparation. - Repeat the collision check before forced host-side recovery deletes the sandbox. - Add regression tests for ordinary rebuild, forced recovery, and a collision introduced after preflight. - Verify collision failures do not detach providers or change policy, agent adapter, or registry state. - Document sandbox-scoped provider detach recovery and the destructive scope of `credentials reset`. - Record the completed documentation writer review: `docs-updated`, with no actionable findings. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: The implementation review covered credential custody, attachment ownership, fail-closed metadata errors, rollback boundaries, and secret handling. Regression tests verify collision failures do not mutate managed MCP state. - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: - Station profile/scenario: - Result: - Supporting evidence: ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result: - `npx vitest run --project integration test/mcp-destroy-lifecycle.test.ts test/mcp-add-crash-consistency.test.ts test/mcp-restart-policy-order.test.ts` — 62 tests passed. - `npx vitest run --project cli src/lib/actions/sandbox/mcp-bridge-provider.test.ts` — 18 tests passed. - `npm run test:changed` — 572 tests and 22 growth checks passed. - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) - Documentation validation: `npm run docs:sync-agent-variants` and `npm run docs` passed. Fern reported 0 errors and 2 warnings. --- Signed-off-by: Apurv Kumaria <akumaria@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Prevented sandbox rebuilds, restarts, and provider additions when attached providers have credential-key conflicts. * Prevented partial changes to providers, policies, adapters, or sandboxes while preserving conflicting provider attachments. * Added consistent collision checks across standard, host-side, and recovery flows. * Updated recovery guidance to detach the conflicting provider before retrying; credential resets warn that stored credentials and provider access are removed globally. * **Documentation** * Documented credential-collision handling and recovery steps. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Apurv Kumaria <akumaria@nvidia.com> Signed-off-by: Senthil Ravichandran <senthilr@nvidia.com> Co-authored-by: Senthil Ravichandran <senthilr@nvidia.com>
1 parent b09eece commit 8202fa3

10 files changed

Lines changed: 299 additions & 93 deletions

docs/security/credential-storage.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ Use this precedence to:
121121

122122
Managed MCP is an exception: `$$nemoclaw <name> mcp add` always creates and attaches an OpenShell provider, and `--env KEY` supplies only the transient input value.
123123
For that credential boundary, refer to [About Managed MCP Servers](../manage-sandboxes/mcp-servers/about-managed-mcp-servers).
124+
Before an ordinary live-sandbox rebuild or forced host-side recovery changes managed MCP state, NemoClaw compares the credential keys for every provider attached to the sandbox.
125+
If another provider supplies a credential key that a managed MCP server reserves, rebuild stops before it changes the managed provider attachment, generated policy, or agent adapter.
126+
The collision check does not delete either provider or its stored credential value.
127+
Forced host-side recovery repeats the check before sandbox deletion.
128+
Detach only the conflicting provider from the affected sandbox:
129+
130+
```bash
131+
openshell sandbox provider detach <sandbox-name> <provider-name>
132+
```
133+
134+
This command keeps the provider and stored credential in OpenShell and does not change its attachments to other sandboxes.
135+
Rerun the original rebuild command.
136+
Do not run `$$nemoclaw credentials reset <PROVIDER_NAME>` unless you intend to detach that provider from every sandbox and delete its stored credential from OpenShell.
124137

125138
When the host environment is empty, day-two operations such as `$$nemoclaw <name> rebuild` and remote-provider updates can reuse the credential already registered with the OpenShell gateway.
126139
Export the credential only when you want to create, replace, or rotate the stored provider value.

src/lib/actions/sandbox/mcp-bridge-add-restart.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "./mcp-bridge-policy";
3232
import {
3333
assertMcpProviderRecoverable,
34-
assertNoAttachedProviderCredentialCollision,
34+
assertNoAttachedProviderCredentialCollisions,
3535
attachProvider,
3636
deleteProvider,
3737
detachMissingProviderReference,
@@ -372,7 +372,7 @@ async function addMcpBridgeUnlocked(
372372
// Credential keys are sandbox-global. Prove this key is not already
373373
// supplied by a foreign attachment before opening its MCP route, then check
374374
// again after provider creation to close the intervening race.
375-
assertNoAttachedProviderCredentialCollision(sandboxName, entry);
375+
assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]);
376376
// Loading the real protocol:mcp policy with --wait is the authoritative
377377
// running-supervisor capability check. Do it before any host credential is
378378
// created or updated so unsupported runtimes fail without that side effect.
@@ -407,7 +407,7 @@ async function addMcpBridgeUnlocked(
407407
// adapter mutations. A process death before this write fails closed.
408408
writeBridgeEntry(sandboxName, entry);
409409
}
410-
assertNoAttachedProviderCredentialCollision(sandboxName, entry);
410+
assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]);
411411
if (providerResult.action === "updated" && previousCredentialRevision === undefined) {
412412
throw new McpBridgeError(
413413
`Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`,

src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ replace(adapters, "registerAgentAdapter", () => {});
182182
replace(policy, "applyGeneratedPolicy", (_sandbox, _entry, target) => { admittedTarget = target; });
183183
replace(state, "ensureSandboxGatewaySelected", async () => {});
184184
replace(validation, "assertMcpCredentialBoundaryRuntimeVersion", () => {});
185-
replace(provider, "assertNoAttachedProviderCredentialCollision", () => {});
185+
replace(provider, "assertNoAttachedProviderCredentialCollisions", () => {});
186186
replace(provider, "inspectMcpProvider", () => ({
187187
credentialKeys: null, exists: false, id: null, resourceVersion: null, type: null,
188188
}));

src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,26 +164,29 @@ export function inspectMcpProviderAttachments(
164164
}
165165
}
166166

167-
export function assertNoAttachedProviderCredentialCollision(
167+
export function assertNoAttachedProviderCredentialCollisions(
168168
sandboxName: string,
169-
entry: McpBridgeEntry,
169+
entries: readonly McpBridgeEntry[],
170170
): void {
171+
if (entries.length === 0) return;
171172
const inspection = inspectMcpProviderAttachments(sandboxName);
172173
if (!inspection.attachments) {
173174
throw new McpBridgeError(
174175
inspection.error ?? `Could not inspect providers attached to sandbox '${sandboxName}'.`,
175176
);
176177
}
177-
const credentialKey = entry.env[0];
178-
const collision = inspection.attachments.find(
179-
(attachment) =>
180-
attachment.credentialKeys.includes(credentialKey) &&
181-
!(attachment.name === entry.providerName && attachment.providerId === entry.providerId),
182-
);
183-
if (collision) {
184-
throw new McpBridgeError(
185-
`Credential key '${credentialKey}' is already supplied by attached provider '${collision.name}' with ID '${collision.providerId ?? "missing"}'. Refusing to reserve the key for MCP before provider activation.`,
178+
for (const entry of entries) {
179+
const credentialKey = entry.env[0];
180+
const collision = inspection.attachments.find(
181+
(attachment) =>
182+
attachment.credentialKeys.includes(credentialKey) &&
183+
!(attachment.name === entry.providerName && attachment.providerId === entry.providerId),
186184
);
185+
if (collision) {
186+
throw new McpBridgeError(
187+
`Credential key '${credentialKey}' is already supplied by attached provider '${collision.name}' with ID '${collision.providerId ?? "missing"}'. Refusing to continue managed MCP while this sandbox receives that key from another provider.`,
188+
);
189+
}
187190
}
188191
}
189192

src/lib/actions/sandbox/mcp-bridge-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type {
88
} from "./mcp-bridge-provider-inspection";
99
export {
1010
assertMcpProviderRecoverable,
11-
assertNoAttachedProviderCredentialCollision,
11+
assertNoAttachedProviderCredentialCollisions,
1212
inspectMcpProvider,
1313
inspectMcpProviderAttachments,
1414
parseMcpProviderAttachmentNames,

src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
inspectExactMcpDestroyProvider,
1212
} from "./mcp-bridge-destroy-preflight";
1313
import { assertGeneratedPolicyExactReadOnly } from "./mcp-bridge-policy";
14-
import { preflightMcpEntryTargets } from "./mcp-bridge-provider";
14+
import {
15+
assertNoAttachedProviderCredentialCollisions,
16+
preflightMcpEntryTargets,
17+
} from "./mcp-bridge-provider";
1518
import {
1619
assertMcpDestroyNotPending,
1720
bridgeState,
@@ -162,6 +165,7 @@ async function inspectReadOnlyRecoveryState(
162165
providerByServer.set(entry.server, providerFingerprint(provider));
163166
targetsByServer.set(entry.server, targetFingerprint(target));
164167
}
168+
assertNoAttachedProviderCredentialCollisions(sandboxName, entries);
165169
return { policyByServer, providerByServer, targetsByServer };
166170
}
167171

src/lib/actions/sandbox/mcp-bridge-rebuild.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "./mcp-bridge-policy";
1919
import {
2020
assertMcpProviderRecoverable,
21+
assertNoAttachedProviderCredentialCollisions,
2122
attachProvider,
2223
detachProvider,
2324
preflightMcpEntryTargets,
@@ -129,6 +130,7 @@ export async function prepareMcpBridgesForRebuild(
129130
for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry);
130131
assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries);
131132
for (const entry of entries) assertMcpProviderRecoverable(entry);
133+
assertNoAttachedProviderCredentialCollisions(sandboxName, entries);
132134
const detached: McpBridgeEntry[] = [];
133135
const scrubbedAdapters: McpBridgeEntry[] = [];
134136
try {

src/lib/actions/sandbox/mcp-bridge-restart.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { assertHermesMcpRuntimeIntent } from "./mcp-bridge-hermes-reconciliation
1010
import { applyGeneratedPolicy, assertGeneratedPolicyMutationSafe } from "./mcp-bridge-policy";
1111
import {
1212
assertMcpProviderRecoverable,
13-
assertNoAttachedProviderCredentialCollision,
13+
assertNoAttachedProviderCredentialCollisions,
1414
attachProvider,
1515
detachMissingProviderReference,
1616
type McpCredentialRevisionObservation,
@@ -117,6 +117,10 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P
117117
for (const entry of missingProviderEntries) {
118118
waitForDetachedMcpCredential(sandboxName, entry);
119119
}
120+
// Reject a collision on any target before the first policy/provider/adapter
121+
// mutation. The per-entry checks below still close races at each mutation
122+
// edge without allowing a later target to fail after an earlier update.
123+
assertNoAttachedProviderCredentialCollisions(sandboxName, targetEntries);
120124
for (const [name, storedEntry] of targets) {
121125
// Validated as a complete authenticated entry before gateway side effects.
122126
if (!storedEntry) continue;
@@ -125,7 +129,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P
125129
const adapterEnvValues = resolveCredentialEnv(envRefs);
126130
const target = resolvedTargetPins(resolvedByServer, entry);
127131
let previousCredentialRevision: McpCredentialRevisionObservation | undefined;
128-
assertNoAttachedProviderCredentialCollision(sandboxName, entry);
132+
assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]);
129133
// Revalidate the actual running supervisor before rotating, recreating,
130134
// attaching, or re-registering an authenticated provider.
131135
applyGeneratedPolicy(sandboxName, entry, target);
@@ -152,7 +156,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P
152156
writeBridgeEntry(sandboxName, refreshedEntry);
153157
entry = refreshedEntry;
154158
}
155-
assertNoAttachedProviderCredentialCollision(sandboxName, entry);
159+
assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]);
156160
if (providerResult.action === "updated" && previousCredentialRevision === undefined) {
157161
throw new McpBridgeError(
158162
`Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`,
@@ -213,7 +217,13 @@ export async function restoreExistingMcpBridgeRuntime(
213217
`OpenShell provider '${entry.providerName}' is missing. Runtime restoration refuses to create or rotate credentials; run explicit MCP restart after exporting '${entry.env[0]}'.`,
214218
);
215219
}
216-
assertNoAttachedProviderCredentialCollision(sandboxName, entry);
220+
}
221+
// Prove every restored entry is collision-free before the first mutation.
222+
// The singleton check in the mutation loop still closes the race for each
223+
// entry immediately before its policy and attachment are restored.
224+
assertNoAttachedProviderCredentialCollisions(sandboxName, entries);
225+
for (const entry of entries) {
226+
assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]);
217227
applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry));
218228
attachProvider(sandboxName, entry);
219229
waitForAttachedMcpCredential(sandboxName, entry);

0 commit comments

Comments
 (0)