Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/lib/actions/sandbox/sandbox-exec-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import {
buildSandboxExecMarkedCommand,
createSandboxExecMarker,
extractSandboxExecCommandStdout,
extractSandboxExecCommandStdoutFromStreams,
SANDBOX_EXEC_STARTED_MARKER,
} from "./sandbox-exec-output";

describe("buildSandboxExecMarkedCommand", () => {
it("prints the sentinel before the command for ordinary scripts", () => {
const command = buildSandboxExecMarkedCommand("echo hi");
expect(command).toBe(`printf '%s\\n' '${SANDBOX_EXEC_STARTED_MARKER}'; echo hi`);
});

it("base64-encodes the hermes secret boundary script instead of inlining it", () => {
const script = "python3 validate-hermes-env-secret-boundary.py --check";
const command = buildSandboxExecMarkedCommand(script);

expect(command).toContain(`printf '%s\\n' '${SANDBOX_EXEC_STARTED_MARKER}'`);
expect(command).not.toContain(script);
const encoded = Buffer.from(script, "utf8").toString("base64");
expect(command).toContain(encoded);
});

it("creates a fresh shell-safe marker for each exec", () => {
const first = createSandboxExecMarker();
const second = createSandboxExecMarker();

expect(first).toMatch(new RegExp(`^${SANDBOX_EXEC_STARTED_MARKER}_[0-9a-f]{32}$`));
expect(second).not.toBe(first);
expect(buildSandboxExecMarkedCommand("echo hi", first)).toContain(`'${first}'`);
});

it("rejects a custom marker containing shell syntax", () => {
expect(() =>
buildSandboxExecMarkedCommand("echo hi", "x'; echo NEMOCLAW_MARKER_INJECTION; '"),
).toThrow("Invalid sandbox exec marker");
});
});

describe("extractSandboxExecCommandStdout", () => {
it("returns null for empty output", () => {
expect(extractSandboxExecCommandStdout("")).toBeNull();
expect(extractSandboxExecCommandStdout(" \n ")).toBeNull();
});

it("returns null when the sentinel never appears", () => {
expect(extractSandboxExecCommandStdout("exec failed\n")).toBeNull();
});

it("extracts stdout after a raw, unframed sentinel", () => {
const output = `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=idle\n`;
expect(extractSandboxExecCommandStdout(output)).toBe("NEMOCLAW_DCODE_PROBE=idle");
});

it("strips the 'stdout: ' frame prefix", () => {
const output = `stdout: ${SANDBOX_EXEC_STARTED_MARKER}\nstdout: NEMOCLAW_DCODE_PROBE=idle\n`;
expect(extractSandboxExecCommandStdout(output)).toBe("NEMOCLAW_DCODE_PROBE=idle");
});

it("strips the '[stdout] ' frame prefix", () => {
const output = `[stdout] ${SANDBOX_EXEC_STARTED_MARKER}\n[stdout] NEMOCLAW_DCODE_PROBE=active\n`;
expect(extractSandboxExecCommandStdout(output)).toBe("NEMOCLAW_DCODE_PROBE=active");
});

it("rejects duplicate sentinel lines as an ambiguous parser boundary", () => {
const output = [
SANDBOX_EXEC_STARTED_MARKER,
"NEMOCLAW_DCODE_PROBE=idle",
SANDBOX_EXEC_STARTED_MARKER,
"NEMOCLAW_DCODE_PROBE=active",
].join("\n");

expect(extractSandboxExecCommandStdout(output)).toBeNull();
});

it("does not match a sentinel embedded in a preamble line as a substring", () => {
const output = `some login banner ${SANDBOX_EXEC_STARTED_MARKER} noise\n${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=idle\n`;
expect(extractSandboxExecCommandStdout(output)).toBe("NEMOCLAW_DCODE_PROBE=idle");
});

it("extracts a framed marker from stderr when stdout does not contain one", () => {
const marker = createSandboxExecMarker();
expect(
extractSandboxExecCommandStdoutFromStreams(
{
stdout: "OpenShell transport preamble\n",
stderr: `stdout: ${marker}\nstdout: NEMOCLAW_DCODE_PROBE=idle\n`,
},
marker,
),
).toBe("NEMOCLAW_DCODE_PROBE=idle");
});

it("rejects the same marker split across stdout and stderr", () => {
const marker = createSandboxExecMarker();
expect(
extractSandboxExecCommandStdoutFromStreams(
{
stdout: `${marker}\nNEMOCLAW_DCODE_PROBE=active\n`,
stderr: `stdout: ${marker}\nstdout: NEMOCLAW_DCODE_PROBE=idle\n`,
},
marker,
),
).toBeNull();
});
});
79 changes: 61 additions & 18 deletions src/lib/actions/sandbox/sandbox-exec-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,38 @@
// SPDX-License-Identifier: Apache-2.0

import { Buffer } from "node:buffer";
import { randomBytes } from "node:crypto";

export const SANDBOX_EXEC_STARTED_MARKER = "__NEMOCLAW_SANDBOX_EXEC_STARTED__";
const GENERATED_SANDBOX_EXEC_MARKER_PATTERN = new RegExp(
`^${SANDBOX_EXEC_STARTED_MARKER}_[0-9a-f]{32}$`,
);

export function buildSandboxExecMarkedCommand(command: string): string {
function assertSandboxExecMarker(marker: string): void {
if (
marker === SANDBOX_EXEC_STARTED_MARKER ||
GENERATED_SANDBOX_EXEC_MARKER_PATTERN.test(marker)
) {
return;
}
throw new Error("Invalid sandbox exec marker");
}

export function createSandboxExecMarker(): string {
return `${SANDBOX_EXEC_STARTED_MARKER}_${randomBytes(16).toString("hex")}`;
}

export function buildSandboxExecMarkedCommand(
command: string,
marker = SANDBOX_EXEC_STARTED_MARKER,
): string {
assertSandboxExecMarker(marker);
if (!command.includes("validate-hermes-env-secret-boundary.py")) {
return `printf '%s\\n' '${SANDBOX_EXEC_STARTED_MARKER}'; ${command}`;
return `printf '%s\\n' '${marker}'; ${command}`;
}
const encodedCommand = Buffer.from(command, "utf8").toString("base64");
return [
`printf '%s\\n' '${SANDBOX_EXEC_STARTED_MARKER}'`,
`printf '%s\\n' '${marker}'`,
"command -v base64 >/dev/null 2>&1 || { echo NEMOCLAW_BASE64_MISSING >&2; exit 127; }",
`printf '%s' '${encodedCommand}' | base64 -d | sh`,
].join("; ");
Expand All @@ -32,26 +54,47 @@ function parseSandboxExecStdoutFrame(line: string): { text: string; framed: bool
* stdout frame prefixes at this transport boundary so recovery, status, and
* Hermes boundary callers keep consuming plain command stdout.
*
* Security boundary: the sentinel must occupy its own stdout line after optional
* frame-prefix stripping. A preamble that merely contains the sentinel string is
* rejected so sandbox output cannot move the parser boundary forward. Remove
* this compatibility shim once OpenShell exposes a stable machine-readable exec
* output mode that preserves child stdout/stderr without human framing.
* Security boundary: accept exactly one marker across the captured stdout and
* stderr streams. A duplicate before or after the authentic boundary is
* ambiguous and must fail closed. A fresh marker for each exec also prevents
* fixed preamble text from being mistaken for the current boundary.
*
* Remove this compatibility shim once OpenShell exposes a stable
* machine-readable exec output mode that preserves child stdout/stderr
* without human framing.
*/
export function extractSandboxExecCommandStdout(output: string): string | null {
const stdout = output.trim();
if (!stdout) return null;
const lines = stdout.split(/\r?\n/).map(parseSandboxExecStdoutFrame);
const exactMarkerIndex = lines.findIndex(
(line) => line.text.trim() === SANDBOX_EXEC_STARTED_MARKER,
);
if (exactMarkerIndex >= 0) {
return lines
.slice(exactMarkerIndex + 1)
export function extractSandboxExecCommandStdoutFromStreams(
streams: { stdout?: string; stderr?: string },
marker = SANDBOX_EXEC_STARTED_MARKER,
): string | null {
let markerLocation: { lines: Array<{ text: string; framed: boolean }>; index: number } | null =
null;

for (const output of [streams.stdout ?? "", streams.stderr ?? ""]) {
const normalized = output.trim();
if (!normalized) continue;
const lines = normalized.split(/\r?\n/).map(parseSandboxExecStdoutFrame);
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].text.trim() !== marker) continue;
if (markerLocation !== null) return null;
markerLocation = { lines, index };
}
}

if (markerLocation !== null) {
return markerLocation.lines
.slice(markerLocation.index + 1)
.map((line) => line.text)
.join("\n")
.trim();
}

return null;
}

export function extractSandboxExecCommandStdout(
output: string,
marker = SANDBOX_EXEC_STARTED_MARKER,
): string | null {
return extractSandboxExecCommandStdoutFromStreams({ stdout: output }, marker);
}
Loading
Loading