Skip to content

Commit c8ae4b5

Browse files
authored
fix(e2e): minimize guard-chain evidence export (#9496)
<!-- markdownlint-disable MD041 --> ## Summary Guard-chain recovery evidence previously returned the entire sandbox proxy environment file. Pattern-based redaction did not recognize an opaque runtime-minted gateway credential, which reached persisted evidence artifacts. The fixture now validates every required marker inside the sandbox and returns only a fixed credential-free sentinel, while rejecting every incomplete or malformed result. ## Changes - Check that the proxy environment file is readable and nonempty, then pass every expected marker as a positional shell argument and match it as a fixed string without returning the file contents. - Reject an empty marker list, an empty marker, or a marker containing a carriage return or line feed before starting a sandbox command. - Accept only exit status 0, no signal, no timeout, the exact sentinel on standard output, and empty standard error; report failures with fixed diagnostics that do not include sandbox output. - Exercise the generated shell command through the real `ShellProbe` and `ArtifactSink`, confirming that an unregistered opaque synthetic credential appears in neither the result nor any of the three evidence artifacts. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] 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: [independent nine-category security review PASS](#9496 (comment)) at commit under review `a51470f550f44d798cd3e145788b542e7d91560e`; no findings. - [ ] 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: Not applicable. - Station profile/scenario: Not applicable. - Result: Not applicable. - Supporting evidence: Not applicable. ## 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 — results: - Fail-first commit `2e02a68b5`: `npm exec -- vitest run --project e2e-support test/e2e/support/e2e-recovery-helpers.test.ts` produced the intended single security-regression failure; 38 tests passed. - Commit under review `a51470f55`: `npm exec -- vitest run --project e2e-support test/e2e/support/e2e-recovery-helpers.test.ts` passed 49 tests. - `npm exec -- vitest run --project integration test/growth-guardrails.test.ts` passed 32 tests. - `npm --prefix nemoclaw run build` passed. - `npm --prefix nemoclaw run typecheck` passed. - `npm run typecheck:cli` passed. - `npm run validate:pr` passed. - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: not applicable; the change is limited to one E2E fixture method and its E2E-support tests. - [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) --- Signed-off-by: Julie Yaunches <jyaunches@nvidia.com> --------- Signed-off-by: Julie Yaunches <jyaunches@nvidia.com>
1 parent 58b17d7 commit c8ae4b5

2 files changed

Lines changed: 249 additions & 30 deletions

File tree

test/e2e/fixtures/clients/gateway.ts

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ const DEFAULT_GUARD_MARKERS: ReadonlyArray<string> = [
4242
"nemoclaw-sandbox-safety-net",
4343
"nemoclaw-ciao-network-guard",
4444
];
45+
const GUARD_CHAIN_PROXY_ENV_PATH = "/tmp/nemoclaw-proxy-env.sh";
46+
const GUARD_CHAIN_ACTIVE_SENTINEL = "NEMOCLAW_GUARD_CHAIN_ACTIVE";
47+
const GUARD_CHAIN_FILE_UNAVAILABLE_EXIT_CODE = 20;
48+
const GUARD_CHAIN_MARKER_MISSING_EXIT_CODE = 21;
4549

4650
/** Default gateway log path inside the sandbox. */
4751
const GATEWAY_LOG_PATH = "/tmp/gateway.log";
@@ -302,46 +306,82 @@ export class GatewayClient {
302306
}
303307

304308
/**
305-
* Assert that the NODE_OPTIONS guard chain is active for the gateway by
306-
* reading `/tmp/nemoclaw-proxy-env.sh` and verifying it contains the
307-
* expected preload markers (`--require` paths). The proxy-env file is
308-
* the single source of truth — when recovery sources it, the gateway
309-
* inherits the chain.
309+
* Assert that the NODE_OPTIONS guard chain is active for the gateway. The
310+
* sandbox command checks `/tmp/nemoclaw-proxy-env.sh` for every expected
311+
* preload marker and returns only a fixed credential-free sentinel. It does
312+
* not return the proxy environment file contents to the host or store them
313+
* in evidence artifacts.
310314
*
311315
* We deliberately read the file rather than `/proc/<pid>/environ`:
312316
* `kernel.yama.ptrace_scope=1` blocks reads of /proc/.../environ across
313317
* non-ancestor process trees. This matches the legacy 2478 bash test's
314318
* approach (`gateway_guards_active` -> `proxy_env_contents`).
315319
*
316-
* @throws if the file is missing or any expected marker is absent.
320+
* @throws if the expected marker list is empty or a marker is empty or
321+
* contains a carriage return or line feed; the file is missing, unreadable,
322+
* or empty; an expected marker is absent; or the sentinel response is invalid.
317323
*/
318324
async expectGuardChainActive(
319325
instance: NemoClawInstance,
320326
options: ExpectGuardChainOptions = {},
321327
): Promise<void> {
322328
const expected = options.expectedMarkers ?? DEFAULT_GUARD_MARKERS;
329+
if (
330+
expected.length === 0 ||
331+
expected.some((marker) => marker.length === 0 || /[\r\n]/u.test(marker))
332+
) {
333+
throw new Error(
334+
"expectGuardChainActive: expectedMarkers must be a non-empty list of non-empty single-line markers",
335+
);
336+
}
337+
const script =
338+
'set -eu; proxy_env="$1"; sentinel="$2"; shift 2; ' +
339+
'[ -r "$proxy_env" ] && [ -s "$proxy_env" ] || exit 20; ' +
340+
'for marker do grep -Fq -- "$marker" "$proxy_env" 2>/dev/null || exit 21; done; ' +
341+
'printf "%s\\n" "$sentinel"';
323342
const result = await this.sandbox.exec(
324343
instance.sandboxName,
325-
["sh", "-c", "cat /tmp/nemoclaw-proxy-env.sh 2>/dev/null"],
344+
[
345+
"sh",
346+
"-c",
347+
script,
348+
"nemoclaw-guard-chain-proof",
349+
GUARD_CHAIN_PROXY_ENV_PATH,
350+
GUARD_CHAIN_ACTIVE_SENTINEL,
351+
...expected,
352+
],
326353
{
327354
artifactName: `gateway-guard-chain-${instance.sandboxName}`,
328355
env: probeEnv(),
329356
...options,
330357
},
331358
);
332359

333-
if (result.exitCode !== 0 || result.stdout.trim() === "") {
360+
if (
361+
result.exitCode === 0 &&
362+
result.signal === null &&
363+
!result.timedOut &&
364+
result.stdout === `${GUARD_CHAIN_ACTIVE_SENTINEL}\n` &&
365+
result.stderr === ""
366+
) {
367+
return;
368+
}
369+
370+
const quietFailure =
371+
result.signal === null && !result.timedOut && result.stdout === "" && result.stderr === "";
372+
if (quietFailure && result.exitCode === GUARD_CHAIN_FILE_UNAVAILABLE_EXIT_CODE) {
334373
throw new Error(
335-
`expectGuardChainActive: /tmp/nemoclaw-proxy-env.sh missing or empty in ${instance.sandboxName}`,
374+
`expectGuardChainActive: /tmp/nemoclaw-proxy-env.sh missing, unreadable, or empty in ${instance.sandboxName}`,
336375
);
337376
}
338-
339-
const missing = expected.filter((marker) => !result.stdout.includes(marker));
340-
if (missing.length > 0) {
377+
if (quietFailure && result.exitCode === GUARD_CHAIN_MARKER_MISSING_EXIT_CODE) {
341378
throw new Error(
342-
`expectGuardChainActive: /tmp/nemoclaw-proxy-env.sh missing markers ${JSON.stringify(missing)} in ${instance.sandboxName}`,
379+
`expectGuardChainActive: /tmp/nemoclaw-proxy-env.sh missing an expected marker in ${instance.sandboxName}`,
343380
);
344381
}
382+
throw new Error(
383+
`expectGuardChainActive: guard-chain check was invalid in ${instance.sandboxName}`,
384+
);
345385
}
346386

347387
/**

test/e2e/support/e2e-recovery-helpers.test.ts

Lines changed: 196 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
48
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
10+
import { ArtifactSink } from "../fixtures/artifacts.ts";
511
import type { CommandRunner } from "../fixtures/clients/index.ts";
612
import { GatewayClient, HostCliClient, SandboxClient } from "../fixtures/clients/index.ts";
713
import type { NemoClawInstance } from "../fixtures/phases/onboarding.ts";
14+
import { startTestProgress, type TestProgress } from "../fixtures/progress.ts";
15+
import { redactString } from "../fixtures/redaction.ts";
816
import type {
917
ShellProbeResult,
1018
ShellProbeRunOptions,
1119
TrustedShellCommand,
1220
} from "../fixtures/shell-probe.ts";
21+
import { ShellProbe, trustedShellCommand } from "../fixtures/shell-probe.ts";
1322

1423
interface RunnerCall {
1524
command: string;
@@ -67,6 +76,59 @@ class ScriptedRunner implements CommandRunner {
6776
}
6877
}
6978

79+
class LocalGuardChainRunner implements CommandRunner {
80+
readonly calls: RunnerCall[] = [];
81+
readonly results: ShellProbeResult[] = [];
82+
private readonly probe: ShellProbe;
83+
private readonly progress: TestProgress;
84+
85+
constructor(
86+
private readonly proxyEnvPath: string,
87+
artifactRoot: string,
88+
) {
89+
this.progress = startTestProgress(
90+
"Guard-chain extraction support",
91+
["run guard-chain marker check", "verify guard-chain sentinel"],
92+
{ logLine: () => undefined },
93+
);
94+
this.probe = new ShellProbe({
95+
artifacts: new ArtifactSink(artifactRoot),
96+
progress: this.progress,
97+
redact: redactString,
98+
signal: new AbortController().signal,
99+
});
100+
}
101+
102+
async run(
103+
command: TrustedShellCommand,
104+
options?: ShellProbeRunOptions,
105+
): Promise<ShellProbeResult> {
106+
const call = { command: command.command, args: [...command.args], options };
107+
this.calls.push(call);
108+
const separator = command.args.indexOf("--");
109+
expect(separator).toBeGreaterThanOrEqual(0);
110+
const [innerCommand, ...innerArgs] = command.args.slice(separator + 1);
111+
expect(innerCommand).toBeTruthy();
112+
const localArgs = innerArgs.map((argument) =>
113+
argument.replaceAll("/tmp/nemoclaw-proxy-env.sh", this.proxyEnvPath),
114+
);
115+
const result = await this.probe.run(
116+
trustedShellCommand({
117+
command: innerCommand!,
118+
args: localArgs,
119+
reason: "exercise the generated guard-chain marker check",
120+
}),
121+
options,
122+
);
123+
this.results.push(result);
124+
return result;
125+
}
126+
127+
stop(): void {
128+
this.progress.stop();
129+
}
130+
}
131+
70132
function fakeInstance(sandboxName = "e2e-2701"): NemoClawInstance {
71133
return {
72134
onboarding: "openclaw-nvidia",
@@ -88,7 +150,7 @@ function fakeInstance(sandboxName = "e2e-2701"): NemoClawInstance {
88150
};
89151
}
90152

91-
function buildGateway(runner: ScriptedRunner): GatewayClient {
153+
function buildGateway(runner: CommandRunner): GatewayClient {
92154
const host = new HostCliClient(runner, { cliPath: "nemoclaw" });
93155
const sandbox = new SandboxClient(runner);
94156
return new GatewayClient(host, sandbox);
@@ -235,52 +297,169 @@ describe("GatewayClient recovery helpers (#2701)", () => {
235297
});
236298

237299
describe("expectGuardChainActive", () => {
238-
it("passes when proxy-env.sh contains the default safety-net + ciao markers", async () => {
300+
it("returns only the fixed sentinel and excludes an opaque credential from results and artifacts", async () => {
301+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-guard-chain-proof-"));
302+
const proxyEnvPath = path.join(tmp, "proxy-env.sh");
303+
const opaqueValue = "opaqueMintedGatewayMaterial_7qR2v9XcL4n8";
304+
const expectedMarkers = [
305+
"nemoclaw-sandbox-safety-net",
306+
"nemoclaw-ciao-network-guard",
307+
"-leading",
308+
"literal;$(false)",
309+
];
310+
const proxyEnv =
311+
'export NODE_OPTIONS="--require /tmp/nemoclaw-sandbox-safety-net.js ' +
312+
'--require /tmp/nemoclaw-ciao-network-guard.js -leading literal;$(false)"\n' +
313+
`export HTTPS_PROXY="http://gateway-user:${opaqueValue}@127.0.0.1:3128"\n`;
314+
expect(redactString(proxyEnv)).toBe(proxyEnv);
315+
fs.writeFileSync(proxyEnvPath, proxyEnv, { mode: 0o600 });
316+
const runner = new LocalGuardChainRunner(proxyEnvPath, path.join(tmp, "artifacts"));
317+
const gateway = buildGateway(runner);
318+
319+
try {
320+
await gateway.expectGuardChainActive(fakeInstance(), { expectedMarkers });
321+
322+
const result = runner.results.at(-1);
323+
expect(result).toMatchObject({
324+
exitCode: 0,
325+
signal: null,
326+
timedOut: false,
327+
stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\n",
328+
stderr: "",
329+
});
330+
const call = runner.calls.at(-1);
331+
const separator = call?.args.indexOf("--") ?? -1;
332+
const innerArgs = call?.args.slice(separator + 1) ?? [];
333+
expect(innerArgs.slice(0, 2)).toEqual(["sh", "-c"]);
334+
expect(innerArgs.slice(4)).toEqual([
335+
"/tmp/nemoclaw-proxy-env.sh",
336+
"NEMOCLAW_GUARD_CHAIN_ACTIVE",
337+
...expectedMarkers,
338+
]);
339+
expect(JSON.stringify(result)).not.toContain(opaqueValue);
340+
expect(JSON.stringify(result)).not.toMatch(/<REDACTED>|\[REDACTED\]/u);
341+
const artifacts = result!.artifacts;
342+
const artifactContents =
343+
fs.readFileSync(artifacts.stdout, "utf8") +
344+
fs.readFileSync(artifacts.stderr, "utf8") +
345+
fs.readFileSync(artifacts.result, "utf8");
346+
expect(artifactContents).not.toContain(opaqueValue);
347+
expect(artifactContents).not.toMatch(/<REDACTED>|\[REDACTED\]/u);
348+
349+
fs.writeFileSync(
350+
proxyEnvPath,
351+
proxyEnv.replace("literal;$(false)", "missing-custom-marker"),
352+
);
353+
await expect(
354+
gateway.expectGuardChainActive(fakeInstance(), { expectedMarkers }),
355+
).rejects.toThrow(/missing an expected marker/);
356+
const failedResult = runner.results.at(-1);
357+
expect(failedResult).toMatchObject({ stdout: "", stderr: "" });
358+
expect(JSON.stringify(failedResult)).not.toContain(opaqueValue);
359+
expect(JSON.stringify(failedResult)).not.toMatch(/<REDACTED>|\[REDACTED\]/u);
360+
const failedArtifacts = failedResult!.artifacts;
361+
const failedArtifactContents =
362+
fs.readFileSync(failedArtifacts.stdout, "utf8") +
363+
fs.readFileSync(failedArtifacts.stderr, "utf8") +
364+
fs.readFileSync(failedArtifacts.result, "utf8");
365+
expect(failedArtifactContents).not.toContain(opaqueValue);
366+
expect(failedArtifactContents).not.toMatch(/<REDACTED>|\[REDACTED\]/u);
367+
} finally {
368+
runner.stop();
369+
fs.rmSync(tmp, { recursive: true, force: true });
370+
}
371+
});
372+
373+
it("passes only when the sandbox returns the fixed guard-chain sentinel", async () => {
239374
const runner = new ScriptedRunner();
240-
runner.queue({
241-
stdout:
242-
'export NODE_OPTIONS="--require /tmp/nemoclaw-sandbox-safety-net.js ' +
243-
'--require /tmp/nemoclaw-ciao-network-guard.js"\n',
244-
});
375+
runner.queue({ stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\n" });
245376
const gateway = buildGateway(runner);
246377

247378
await gateway.expectGuardChainActive(fakeInstance());
248379

249-
expect(runner.calls[0]?.args.slice(-1)[0]).toContain("cat /tmp/nemoclaw-proxy-env.sh");
380+
const separator = runner.calls[0]?.args.indexOf("--") ?? -1;
381+
expect(runner.calls[0]?.args.slice(separator + 4)).toEqual([
382+
"nemoclaw-guard-chain-proof",
383+
"/tmp/nemoclaw-proxy-env.sh",
384+
"NEMOCLAW_GUARD_CHAIN_ACTIVE",
385+
"nemoclaw-sandbox-safety-net",
386+
"nemoclaw-ciao-network-guard",
387+
]);
250388
});
251389

252390
it("fails when proxy-env.sh is empty (post pod-recreate target)", async () => {
253391
const runner = new ScriptedRunner();
254-
runner.queue({ stdout: "" });
392+
runner.queue({ exitCode: 20 });
255393
const gateway = buildGateway(runner);
256394

257395
await expect(gateway.expectGuardChainActive(fakeInstance())).rejects.toThrow(
258-
/missing or empty/,
396+
/missing, unreadable, or empty/,
259397
);
260398
});
261399

262400
it("fails when proxy-env.sh exists but a marker is absent", async () => {
263401
const runner = new ScriptedRunner();
264-
runner.queue({
265-
stdout: 'export NODE_OPTIONS="--require /tmp/nemoclaw-sandbox-safety-net.js"\n',
266-
});
402+
runner.queue({ exitCode: 21 });
267403
const gateway = buildGateway(runner);
268404

269405
await expect(gateway.expectGuardChainActive(fakeInstance())).rejects.toThrow(
270-
/missing markers.*nemoclaw-ciao-network-guard/,
406+
/missing an expected marker/,
271407
);
272408
});
273409

274410
it("honors a caller-supplied marker list", async () => {
275411
const runner = new ScriptedRunner();
276-
runner.queue({
277-
stdout: 'export NODE_OPTIONS="--require /tmp/nemoclaw-slack-channel-guard.js"\n',
278-
});
412+
runner.queue({ stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\n" });
279413
const gateway = buildGateway(runner);
280414

281415
await gateway.expectGuardChainActive(fakeInstance(), {
282416
expectedMarkers: ["nemoclaw-slack-channel-guard"],
283417
});
418+
419+
expect(runner.calls[0]?.args.at(-1)).toBe("nemoclaw-slack-channel-guard");
420+
});
421+
422+
it.each([
423+
{ condition: "an empty marker list", expectedMarkers: [] },
424+
{ condition: "an empty marker value", expectedMarkers: [""] },
425+
{
426+
condition: "a marker with a line feed",
427+
expectedMarkers: ["nemoclaw-sandbox-safety-net\n"],
428+
},
429+
{
430+
condition: "a marker with a carriage return",
431+
expectedMarkers: ["nemoclaw-sandbox-safety-net\r"],
432+
},
433+
])("rejects $condition before running a sandbox command", async ({ expectedMarkers }) => {
434+
const runner = new ScriptedRunner();
435+
const gateway = buildGateway(runner);
436+
437+
await expect(
438+
gateway.expectGuardChainActive(fakeInstance(), { expectedMarkers }),
439+
).rejects.toThrow(
440+
/expectedMarkers must be a non-empty list of non-empty single-line markers/,
441+
);
442+
expect(runner.calls).toHaveLength(0);
443+
});
444+
445+
it.each([
446+
{ condition: "returns a nonzero exit", reply: { exitCode: 1 } },
447+
{ condition: "times out", reply: { timedOut: true } },
448+
{ condition: "is terminated", reply: { signal: "SIGTERM" as const } },
449+
{ condition: "omits stdout", reply: { stdout: "" } },
450+
{ condition: "adds stdout", reply: { stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\nextra\n" } },
451+
{
452+
condition: "adds stderr",
453+
reply: { stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\n", stderr: "unexpected\n" },
454+
},
455+
])("rejects a guard-chain check that $condition", async ({ reply }) => {
456+
const runner = new ScriptedRunner();
457+
runner.queue({ stdout: "NEMOCLAW_GUARD_CHAIN_ACTIVE\n", ...reply });
458+
const gateway = buildGateway(runner);
459+
460+
await expect(gateway.expectGuardChainActive(fakeInstance())).rejects.toThrow(
461+
/guard-chain check was invalid/,
462+
);
284463
});
285464
});
286465

0 commit comments

Comments
 (0)