From a8488a0364d2ad9780788e606b97aa966756a058 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sat, 8 Aug 2026 15:01:04 -0700 Subject: [PATCH 1/2] fix(e2e): enforce split process security posture Signed-off-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 7 +- test/e2e/fixtures/security-posture.ts | 567 ++++++++++++++++-- ...security-posture-workflow-boundary.test.ts | 20 +- test/e2e/support/security-posture.test.ts | 539 ++++++++++++++++- tools/e2e/cli-artifact-workflow-boundary.mts | 2 +- .../security-posture-workflow-boundary.mts | 7 +- 6 files changed, 1037 insertions(+), 105 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 201a0813b0c..094560b4771 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5177,11 +5177,9 @@ jobs: matrix: include: - agent: openclaw - expect_non_root_entrypoint: "1" sandbox_name: e2e-oc-security test_file: test/e2e/live/full-e2e.test.ts - agent: hermes - expect_non_root_entrypoint: "0" sandbox_name: e2e-hm-security test_file: test/e2e/live/hermes-e2e.test.ts env: @@ -5193,10 +5191,7 @@ jobs: NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_AGENT: ${{ matrix.agent }} - NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT: ${{ matrix.expect_non_root_entrypoint }} - # Legacy-parity contract: enforce a non-root host user. PID 1 must stay - # root long enough to step down child processes, so its uid, bounding - # set, and NoNewPrivs remain evidence unless an opt-in expectation is set. + NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS: "1" NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST: "1" NEMOCLAW_E2E_SECURITY_POSTURE: "1" NEMOCLAW_E2E_SHARD: ${{ matrix.agent }} diff --git a/test/e2e/fixtures/security-posture.ts b/test/e2e/fixtures/security-posture.ts index 114cbf16052..cf677d3f5c1 100644 --- a/test/e2e/fixtures/security-posture.ts +++ b/test/e2e/fixtures/security-posture.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { privilegedSandboxExecArgv } from "../../../src/lib/sandbox/privileged-exec.ts"; import { buildAvailabilityProbeEnv } from "./availability-env.ts"; import type { HostCliClient } from "./clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "./clients/sandbox.ts"; @@ -8,36 +9,230 @@ import type { ShellProbeResult } from "./shell-probe.ts"; export type SecurityPostureAgent = "hermes" | "openclaw"; +export interface ProcessSecurityStatus { + capAmb: string; + capBnd: string; + capEff: string; + capInh: string; + capPrm: string; + gid: string[]; + groups: string[]; + noNewPrivs: string; + uid: string[]; +} + +export interface ProcessSecurityIdentity { + argv: string[]; + executable: string; + pid: number; + ppid: number; + state: string; + startTime: string; + status: ProcessSecurityStatus; +} + +export interface SplitProcessSecurityReport { + observedProcEntries: number; + sandboxGid: number; + sandboxUid: number; + supervisor: ProcessSecurityIdentity; + version: 1; + childSupervisors: ProcessSecurityIdentity[]; +} + export interface SecurityPostureSummary { configureGuard: true; - entrypoint: { - capBnd: string; - capEff: string | null; - dangerousBoundingCapabilities: string[]; - dangerousEffectiveCapabilities: string[]; - noNewPrivs: string | null; - uid: string; - }; hostNonRoot: true; rcFilesLocked: true; runtimeProxyEnvLocked: true; + splitProcess: { + childSupervisor: ProcessSecurityIdentity; + supervisor: ProcessSecurityIdentity; + }; startupLogClean: true; } export interface SecurityPostureExpectations { - droppedBoundingCapabilities: boolean; enabled: boolean; - noNewPrivileges: boolean; - nonRootEntrypoint: boolean; + openshellSplitProcess: boolean; +} + +export interface SecurityPostureDependencies { + privilegedExecArgv?: typeof privilegedSandboxExecArgv; } -const DANGEROUS_CAPABILITIES = [ - [21, "CAP_SYS_ADMIN"], - [19, "CAP_SYS_PTRACE"], - [13, "CAP_NET_RAW"], - [10, "CAP_NET_BIND_SERVICE"], - [1, "CAP_DAC_OVERRIDE"], +const OPENSHELL_DEFAULT_WORKSPACE = "default"; +const OPENSHELL_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; +const OPENSHELL_SANDBOX_WORKSPACE_LABEL = "openshell.ai/sandbox-workspace"; +const OPENSHELL_SUPERVISOR_EXECUTABLE = "/opt/openshell/bin/openshell-sandbox"; +const OPENSHELL_SUPERVISOR_ARGV = [ + OPENSHELL_SUPERVISOR_EXECUTABLE, + "--workdir", + "/sandbox", ] as const; +const SYSTEM_BASH_EXECUTABLES = ["/bin/bash", "/usr/bin/bash"] as const; +const LIVE_PROCESS_STATES = ["D", "R", "S"] as const; +const SAFE_OPENSHELL_IDENTITY_COMPONENT = /^[a-z0-9][a-z0-9_.-]*$/u; +const MAX_PROC_ENTRIES = 32_768; +// OpenShell 0.0.99 and 0.0.101 grant the OpenShell supervisor the Docker default +// capabilities plus NET_ADMIN, SYS_ADMIN, SYS_PTRACE, and SYSLOG. Freeze the +// resulting Linux capability mask so additions and removals both require an +// explicit security review. +export const OPENSHELL_SUPERVISOR_CAPABILITY_MASK = "00000004a82c35fb"; + +export const SPLIT_PROCESS_SECURITY_PROBE = String.raw`import grp +import json +import os +from pathlib import Path +import pwd + +PROC_ROOT = Path("/proc") +MAX_PROC_ENTRIES = ${MAX_PROC_ENTRIES} +OPENSHELL_SUPERVISOR_ARGV = (b"/opt/openshell/bin/openshell-sandbox", b"--workdir", b"/sandbox") +OPENSHELL_SUPERVISOR_EXECUTABLE = "/opt/openshell/bin/openshell-sandbox" +NEMOCLAW_START_SUPERVISOR = (b"nemoclaw-start", b"/usr/local/bin/nemoclaw-start") +BASH = (b"bash", b"/bin/bash", b"/usr/bin/bash") +SYSTEM_BASH_EXECUTABLES = {"/bin/bash", "/usr/bin/bash"} +LIVE_PROCESS_STATES = {"D", "R", "S"} + +def argv_for(path): + raw = (path / "cmdline").read_bytes() + if not raw: + return () + if not raw.endswith(b"\0"): + raise RuntimeError("process command line is not terminated") + return tuple(raw[:-1].split(b"\0")) + +def is_nemoclaw_start_supervisor(argv): + return ( + argv in ((NEMOCLAW_START_SUPERVISOR[0],), (NEMOCLAW_START_SUPERVISOR[1],)) + or ( + len(argv) == 2 + and argv[0] in BASH + and argv[1] in NEMOCLAW_START_SUPERVISOR + ) + ) + +def stat_identity(raw): + suffix = raw.rsplit(") ", 1) + if len(suffix) != 2: + raise RuntimeError("malformed proc stat record") + fields = suffix[1].split() + if len(fields) < 20: + raise RuntimeError("incomplete proc stat record") + return fields[0], int(fields[1], 10), fields[19] + +def selected_status(raw): + values = {} + for line in raw.splitlines(): + name, separator, value = line.partition(":") + if separator: + values[name] = value.strip().split() + return { + "uid": values.get("Uid", []), + "gid": values.get("Gid", []), + "groups": values.get("Groups", []), + "capInh": (values.get("CapInh") or [""])[0], + "capPrm": (values.get("CapPrm") or [""])[0], + "capEff": (values.get("CapEff") or [""])[0], + "capBnd": (values.get("CapBnd") or [""])[0], + "capAmb": (values.get("CapAmb") or [""])[0], + "noNewPrivs": (values.get("NoNewPrivs") or [""])[0], + } + +def stable_process(pid): + path = PROC_ROOT / str(pid) + before = path.stat(follow_symlinks=False) + first_state, first_ppid, first_start_time = stat_identity( + (path / "stat").read_text(encoding="utf-8") + ) + first_status = selected_status((path / "status").read_text(encoding="utf-8")) + first_argv = argv_for(path) + first_executable = os.readlink(path / "exe") + second_state, second_ppid, second_start_time = stat_identity( + (path / "stat").read_text(encoding="utf-8") + ) + second_status = selected_status((path / "status").read_text(encoding="utf-8")) + second_argv = argv_for(path) + second_executable = os.readlink(path / "exe") + after = path.stat(follow_symlinks=False) + if first_state not in LIVE_PROCESS_STATES or second_state not in LIVE_PROCESS_STATES: + raise RuntimeError(f"process {pid} is not live") + if ( + before.st_dev != after.st_dev + or before.st_ino != after.st_ino + or first_ppid != second_ppid + or first_start_time != second_start_time + or first_status != second_status + or first_argv != second_argv + or first_executable != second_executable + ): + raise RuntimeError(f"process {pid} changed during inspection") + return { + "pid": int(pid), + "ppid": second_ppid, + "state": second_state, + "startTime": second_start_time, + "argv": [item.decode("utf-8", "strict") for item in first_argv], + "executable": first_executable, + "status": first_status, + } + +def child_supervisor_census(): + observed = 0 + matches = [] + with os.scandir(PROC_ROOT) as entries: + for entry in entries: + if not entry.name.isascii() or not entry.name.isdigit(): + continue + observed += 1 + if observed > MAX_PROC_ENTRIES: + raise RuntimeError("process census exceeded its checked bound") + try: + argv = argv_for(Path(entry.path)) + except (FileNotFoundError, ProcessLookupError): + continue + if is_nemoclaw_start_supervisor(argv): + matches.append(stable_process(entry.name)) + return observed, matches + +def stable_security_identity(process): + return {name: value for name, value in process.items() if name != "state"} + +sandbox_user = pwd.getpwnam("sandbox") +sandbox_group = grp.getgrnam("sandbox") +sandbox_uid = sandbox_user.pw_uid +sandbox_gid = sandbox_group.gr_gid +if sandbox_user.pw_gid != sandbox_gid: + raise RuntimeError("sandbox user and group identities disagree") +supervisor_before = stable_process(1) +observed_first, child_supervisors_first = child_supervisor_census() +observed_second, child_supervisors_second = child_supervisor_census() +supervisor_after = stable_process(1) +if stable_security_identity(supervisor_before) != stable_security_identity(supervisor_after): + raise RuntimeError("OpenShell supervisor changed during inspection") +if [stable_security_identity(item) for item in child_supervisors_first] != [ + stable_security_identity(item) for item in child_supervisors_second +]: + raise RuntimeError("nemoclaw-start child supervisor census changed during inspection") +if ( + tuple(supervisor_before["argv"]) != tuple(item.decode("ascii") for item in OPENSHELL_SUPERVISOR_ARGV) + or supervisor_before["executable"] != OPENSHELL_SUPERVISOR_EXECUTABLE +): + raise RuntimeError("unexpected OpenShell supervisor command") +if any( + item["executable"] not in SYSTEM_BASH_EXECUTABLES + for item in child_supervisors_first +): + raise RuntimeError("unexpected nemoclaw-start child supervisor executable") +print(json.dumps({ + "version": 1, + "observedProcEntries": max(observed_first, observed_second), + "sandboxUid": sandbox_uid, + "sandboxGid": sandbox_gid, + "supervisor": supervisor_before, + "childSupervisors": child_supervisors_first, +}, sort_keys=True))`; function truthy(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); @@ -60,17 +255,254 @@ function requireSuccess(label: string, result: ShellProbeResult): void { } } -function statusField(status: string, field: string): string | null { - const match = status.match(new RegExp(`^${field}:\\s+([^\\s]+)`, "m")); - return match?.[1] ?? null; +function requiredRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a nonempty string`); + } + return value; +} + +function requiredInteger(value: unknown, label: string, minimum: number): number { + if (!Number.isSafeInteger(value) || Number(value) < minimum) { + throw new Error(`${label} must be a safe integer greater than or equal to ${minimum}`); + } + return Number(value); +} + +function requiredStringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new Error(`${label} must be an array of strings`); + } + return value as string[]; +} + +function processStatus(value: unknown, label: string): ProcessSecurityStatus { + const status = requiredRecord(value, label); + return { + capAmb: requiredString(status.capAmb, `${label}.capAmb`), + capBnd: requiredString(status.capBnd, `${label}.capBnd`), + capEff: requiredString(status.capEff, `${label}.capEff`), + capInh: requiredString(status.capInh, `${label}.capInh`), + capPrm: requiredString(status.capPrm, `${label}.capPrm`), + gid: requiredStringArray(status.gid, `${label}.gid`), + groups: requiredStringArray(status.groups, `${label}.groups`), + noNewPrivs: requiredString(status.noNewPrivs, `${label}.noNewPrivs`), + uid: requiredStringArray(status.uid, `${label}.uid`), + }; +} + +function processIdentity(value: unknown, label: string): ProcessSecurityIdentity { + const process = requiredRecord(value, label); + const identity = { + argv: requiredStringArray(process.argv, `${label}.argv`), + executable: requiredString(process.executable, `${label}.executable`), + pid: requiredInteger(process.pid, `${label}.pid`, 1), + ppid: requiredInteger(process.ppid, `${label}.ppid`, 0), + state: requiredString(process.state, `${label}.state`), + startTime: requiredString(process.startTime, `${label}.startTime`), + status: processStatus(process.status, `${label}.status`), + }; + if (!/^\d+$/u.test(identity.startTime)) throw new Error(`${label}.startTime must be numeric`); + if (identity.argv.length === 0 || identity.argv.some((argument) => argument.length === 0)) { + throw new Error(`${label}.argv must contain only nonempty arguments`); + } + if (!(LIVE_PROCESS_STATES as readonly string[]).includes(identity.state)) { + throw new Error(`${label}.state must be one of ${LIVE_PROCESS_STATES.join(", ")}`); + } + return identity; +} + +function requireCapabilityHex(value: string, label: string): void { + if (!/^[0-9a-f]{16}$/u.test(value)) { + throw new Error(`${label} must be a 16-digit lowercase capability mask`); + } +} + +function requireZeroCapabilities(status: ProcessSecurityStatus, label: string): void { + for (const field of ["capInh", "capPrm", "capEff", "capBnd", "capAmb"] as const) { + const value = status[field]; + requireCapabilityHex(value, `${label}.${field}`); + if (!/^[0]+$/u.test(value)) { + throw new Error(`${label}.${field} expected 0, got ${value}`); + } + } +} + +function requireExactIds(values: string[], expected: number, label: string): void { + const exact = String(expected); + if (values.length !== 4 || values.some((value) => value !== exact)) { + throw new Error( + `${label} expected ${exact} in all four identity slots, got ${values.join(" ")}`, + ); + } +} + +function requireExactSupplementaryGroups(values: string[], expected: number, label: string): void { + const exact = String(expected); + if (values.length !== 1 || values[0] !== exact) { + throw new Error(`${label} expected only ${exact}, got ${values.join(" ")}`); + } +} + +function canonicalNemoclawStartSupervisorArgv(argv: string[]): boolean { + const starts = ["nemoclaw-start", "/usr/local/bin/nemoclaw-start"]; + if (argv.length === 1 && starts.includes(argv[0] ?? "")) return true; + return ( + argv.length === 2 && + ["bash", "/bin/bash", "/usr/bin/bash"].includes(argv[0] ?? "") && + starts.includes(argv[1] ?? "") + ); +} + +function validateSupervisor(process: ProcessSecurityIdentity): void { + if (process.pid !== 1 || process.ppid !== 0) { + throw new Error( + `OpenShell supervisor expected pid=1 ppid=0, got ${process.pid}/${process.ppid}`, + ); + } + if ( + process.executable !== OPENSHELL_SUPERVISOR_EXECUTABLE || + process.argv.length !== OPENSHELL_SUPERVISOR_ARGV.length || + process.argv.some((argument, index) => argument !== OPENSHELL_SUPERVISOR_ARGV[index]) + ) { + throw new Error("PID 1 does not have the expected OpenShell supervisor command"); + } + requireExactIds(process.status.uid, 0, "OpenShell supervisor Uid"); + requireExactIds(process.status.gid, 0, "OpenShell supervisor Gid"); + requireExactSupplementaryGroups(process.status.groups, 0, "OpenShell supervisor Groups"); + for (const field of ["capInh", "capPrm", "capEff", "capBnd", "capAmb"] as const) { + requireCapabilityHex(process.status[field], `OpenShell supervisor ${field}`); + } + if (process.status.capInh !== "0000000000000000") { + throw new Error(`OpenShell supervisor CapInh drifted to ${process.status.capInh}`); + } + for (const field of ["capPrm", "capEff", "capBnd"] as const) { + if (process.status[field] !== OPENSHELL_SUPERVISOR_CAPABILITY_MASK) { + throw new Error( + `OpenShell supervisor ${field} expected ${OPENSHELL_SUPERVISOR_CAPABILITY_MASK}, got ${process.status[field]}`, + ); + } + } + if (process.status.capAmb !== "0000000000000000") { + throw new Error(`OpenShell supervisor CapAmb drifted to ${process.status.capAmb}`); + } + if (process.status.noNewPrivs !== "1") { + throw new Error(`OpenShell supervisor expected NoNewPrivs=1, got ${process.status.noNewPrivs}`); + } +} + +function validateNemoclawStartSupervisor( + process: ProcessSecurityIdentity, + sandboxUid: number, + sandboxGid: number, +): void { + if (process.pid === 1 || process.ppid !== 1) { + throw new Error( + `nemoclaw-start child supervisor expected a direct PID 1 child, got pid=${process.pid} ppid=${process.ppid}`, + ); + } + if (!canonicalNemoclawStartSupervisorArgv(process.argv)) { + throw new Error("nemoclaw-start child supervisor does not have the expected argv"); + } + if (!(SYSTEM_BASH_EXECUTABLES as readonly string[]).includes(process.executable)) { + throw new Error( + `nemoclaw-start child supervisor expected the system Bash executable, got ${process.executable}`, + ); + } + requireExactIds(process.status.uid, sandboxUid, "nemoclaw-start child supervisor Uid"); + requireExactIds(process.status.gid, sandboxGid, "nemoclaw-start child supervisor Gid"); + requireExactSupplementaryGroups( + process.status.groups, + sandboxGid, + "nemoclaw-start child supervisor Groups", + ); + requireZeroCapabilities(process.status, "nemoclaw-start child supervisor"); + if (process.status.noNewPrivs !== "1") { + throw new Error( + `nemoclaw-start child supervisor expected NoNewPrivs=1, got ${process.status.noNewPrivs}`, + ); + } } -export function dangerousCapabilities(capabilityHex: string | null): string[] { - if (!capabilityHex || !/^[0-9a-f]+$/iu.test(capabilityHex)) return []; - const value = BigInt(`0x${capabilityHex}`); - return DANGEROUS_CAPABILITIES.filter(([bit]) => (value & (1n << BigInt(bit))) !== 0n).map( - ([, name]) => name, +export function validateSplitProcessSecurityReport(value: unknown): SplitProcessSecurityReport { + const report = requiredRecord(value, "split-process security report"); + if (report.version !== 1) throw new Error("split-process security report version must be 1"); + const observedProcEntries = requiredInteger( + report.observedProcEntries, + "split-process security report observedProcEntries", + 1, ); + if (observedProcEntries > MAX_PROC_ENTRIES) { + throw new Error(`split-process security report exceeded ${MAX_PROC_ENTRIES} process entries`); + } + const sandboxUid = requiredInteger(report.sandboxUid, "sandbox uid", 1); + const sandboxGid = requiredInteger(report.sandboxGid, "sandbox gid", 1); + const supervisor = processIdentity(report.supervisor, "supervisor"); + if (!Array.isArray(report.childSupervisors)) { + throw new Error("split-process security report childSupervisors must be an array"); + } + const childSupervisors = report.childSupervisors.map((entry, index) => + processIdentity(entry, `childSupervisors[${index}]`), + ); + if (childSupervisors.length !== 1) { + throw new Error( + `expected exactly one nemoclaw-start child supervisor, found ${childSupervisors.length}`, + ); + } + validateSupervisor(supervisor); + validateNemoclawStartSupervisor(childSupervisors[0]!, sandboxUid, sandboxGid); + return { + childSupervisors, + observedProcEntries, + sandboxGid, + sandboxUid, + supervisor, + version: 1, + }; +} + +export function parseSplitProcessSecurityReport(output: string): SplitProcessSecurityReport { + let parsed: unknown; + try { + parsed = JSON.parse(output.trim()); + } catch (error) { + throw new Error("split-process security probe emitted invalid JSON", { cause: error }); + } + return validateSplitProcessSecurityReport(parsed); +} + +export function parseOpenShellContainerId(output: string, sandboxName: string): string { + const rows = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + if (rows.length !== 1) { + throw new Error( + `expected exactly one running OpenShell Docker container for ${sandboxName}, found ${rows.length}`, + ); + } + const [id, name, sandboxId, sandboxWorkspace, ...unexpected] = rows[0]!.split("\t"); + const expectedName = `openshell-${OPENSHELL_DEFAULT_WORKSPACE}--${sandboxName}-${sandboxId}`; + if ( + !id || + !/^[0-9a-f]{64}$/u.test(id) || + !name || + !sandboxId || + !SAFE_OPENSHELL_IDENTITY_COMPONENT.test(sandboxId) || + sandboxWorkspace !== OPENSHELL_DEFAULT_WORKSPACE || + unexpected.length > 0 || + name !== expectedName + ) { + throw new Error(`unexpected OpenShell Docker container identity for ${sandboxName}`); + } + return id; } export function securityPostureEnabled(): boolean { @@ -82,10 +514,8 @@ export function securityPostureExpectations( ): SecurityPostureExpectations { const enabled = truthy(env.NEMOCLAW_E2E_SECURITY_POSTURE); return { - droppedBoundingCapabilities: enabled && truthy(env.NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS), enabled, - noNewPrivileges: enabled && truthy(env.NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS), - nonRootEntrypoint: enabled && truthy(env.NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT), + openshellSplitProcess: enabled && truthy(env.NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS), }; } @@ -93,10 +523,8 @@ export function securityPostureModeEnv(): NodeJS.ProcessEnv { const expectations = securityPostureExpectations(); if (!expectations.enabled) return {}; return { - NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS: expectations.droppedBoundingCapabilities ? "1" : "0", - NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT: expectations.nonRootEntrypoint ? "1" : "0", NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST: "1", - NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS: expectations.noNewPrivileges ? "1" : "0", + NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS: expectations.openshellSplitProcess ? "1" : "0", NEMOCLAW_E2E_SECURITY_POSTURE: "1", }; } @@ -106,8 +534,12 @@ export async function assertSecurityPosture( sandbox: SandboxClient, sandboxName: string, agent: SecurityPostureAgent, + dependencies: SecurityPostureDependencies = {}, ): Promise { const expectations = securityPostureExpectations(); + if (!expectations.openshellSplitProcess) { + throw new Error("security-posture mode requires NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS=1"); + } const hostUser = await host.command( "sh", ["-lc", 'uid="$(id -u)"; gid="$(id -g)"; echo "uid=$uid gid=$gid"; test "$uid" -ne 0'], @@ -119,38 +551,47 @@ export async function assertSecurityPosture( ); requireSuccess("non-root host user", hostUser); - const entrypoint = await sandbox.execShell( - sandboxName, - trustedSandboxShellScript( - 'grep -E "^(Uid|Gid|CapBnd|CapEff|NoNewPrivs):" /proc/1/status; ' + - "test -n \"$(awk '/^Uid:/ { print $2; exit }' /proc/1/status)\"; " + - "test -n \"$(awk '/^CapBnd:/ { print $2; exit }' /proc/1/status)\"", + const containers = await host.command( + "docker", + [ + "ps", + "--no-trunc", + "--filter", + "label=openshell.ai/managed-by=openshell", + "--filter", + `label=openshell.ai/sandbox-name=${sandboxName}`, + "--format", + `{{.ID}}\t{{.Names}}\t{{.Label "${OPENSHELL_SANDBOX_ID_LABEL}"}}\t{{.Label "${OPENSHELL_SANDBOX_WORKSPACE_LABEL}"}}`, + ], + { + artifactName: "security-posture-container-identity", + env: probeEnv(), + timeoutMs: 30_000, + }, + ); + requireSuccess("OpenShell Docker container discovery", containers); + const containerId = parseOpenShellContainerId(containers.stdout, sandboxName); + const privilegedExecArgv = dependencies.privilegedExecArgv ?? privilegedSandboxExecArgv; + const splitProcessProbe = await host.command( + "docker", + privilegedExecArgv( + sandboxName, + ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE], + false, + true, + containerId, ), { - artifactName: "security-posture-entrypoint-status", + artifactName: "security-posture-split-processes", env: probeEnv(), timeoutMs: 30_000, }, ); - requireSuccess("entrypoint security status", entrypoint); - const uid = statusField(entrypoint.stdout, "Uid"); - const capBnd = statusField(entrypoint.stdout, "CapBnd"); - const capEff = statusField(entrypoint.stdout, "CapEff"); - const noNewPrivs = statusField(entrypoint.stdout, "NoNewPrivs"); - if (!uid || !capBnd) throw new Error(`entrypoint status is incomplete:\n${entrypoint.stdout}`); - const dangerousBoundingCapabilities = dangerousCapabilities(capBnd); - const dangerousEffectiveCapabilities = dangerousCapabilities(capEff); - if (expectations.nonRootEntrypoint && uid === "0") { - throw new Error(`entrypoint PID 1 expected a non-root uid, got ${uid}`); - } - if (expectations.droppedBoundingCapabilities && dangerousBoundingCapabilities.length > 0) { - throw new Error( - `entrypoint PID 1 retained bounding capabilities: ${dangerousBoundingCapabilities.join(", ")}`, - ); - } - if (expectations.noNewPrivileges && noNewPrivs !== "1") { - throw new Error(`entrypoint PID 1 expected NoNewPrivs=1, got ${noNewPrivs ?? ""}`); - } + requireSuccess( + "OpenShell and nemoclaw-start child supervisor security posture", + splitProcessProbe, + ); + const splitProcess = parseSplitProcessSecurityReport(splitProcessProbe.stdout); const rcFiles = await sandbox.execShell( sandboxName, @@ -257,17 +698,13 @@ tail -n 20 "$log" return { configureGuard: true, - entrypoint: { - capBnd, - capEff, - dangerousBoundingCapabilities, - dangerousEffectiveCapabilities, - noNewPrivs, - uid, - }, hostNonRoot: true, rcFilesLocked: true, runtimeProxyEnvLocked: true, + splitProcess: { + childSupervisor: splitProcess.childSupervisors[0]!, + supervisor: splitProcess.supervisor, + }, startupLogClean: true, }; } diff --git a/test/e2e/support/security-posture-workflow-boundary.test.ts b/test/e2e/support/security-posture-workflow-boundary.test.ts index 3548897a731..29d84f7294c 100644 --- a/test/e2e/support/security-posture-workflow-boundary.test.ts +++ b/test/e2e/support/security-posture-workflow-boundary.test.ts @@ -50,7 +50,6 @@ describe("security posture workflow boundary", () => { it("rejects missing agent coverage, mode drift, and broadly scoped credentials", () => { const hermesMatrixEntry = [ " - agent: hermes", - ' expect_non_root_entrypoint: "0"', " sandbox_name: e2e-hm-security", " test_file: test/e2e/live/hermes-e2e.test.ts", "", @@ -70,6 +69,8 @@ describe("security posture workflow boundary", () => { job["timeout-minutes"] = 30; (job.strategy as Record)["fail-fast"] = true; delete env.NEMOCLAW_E2E_SECURITY_POSTURE; + delete env.NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS; + env.NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT = "1"; env.E2E_ARTIFACT_DIR = "/tmp/security-posture"; job.permissions = { contents: "write" }; env.NVIDIA_INFERENCE_API_KEY = "${{ secrets.NVIDIA_INFERENCE_API_KEY }}"; @@ -91,6 +92,12 @@ describe("security posture workflow boundary", () => { expect(errors).toContain("security-posture must retain its 75 minute two-agent budget"); expect(errors).toContain("security-posture matrix must keep fail-fast disabled"); expect(errors).toContain("security-posture must set NEMOCLAW_E2E_SECURITY_POSTURE=1"); + expect(errors).toContain( + "security-posture must set NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS=1", + ); + expect(errors).toContain( + "security-posture must not set retired NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT", + ); expect(errors).toContain( "security-posture must set E2E_ARTIFACT_DIR=${{ github.workspace }}/e2e-artifacts/live/security-posture-${{ matrix.agent }}", ); @@ -105,4 +112,15 @@ describe("security posture workflow boundary", () => { "security-posture step 'Install OpenShell CLI' must run: -u DOCKER_CONFIG", ); }); + + it("rejects split-process posture flag drift", () => { + const workflow = readSecurityPostureWorkflow(); + const job = (workflow.jobs as Record>)["security-posture"]; + const env = job.env as Record; + env.NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS = "0"; + + expect(validateSecurityPostureWorkflow(workflow)).toContain( + "security-posture must set NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS=1", + ); + }); }); diff --git a/test/e2e/support/security-posture.test.ts b/test/e2e/support/security-posture.test.ts index e442885bb7b..fc56d926533 100644 --- a/test/e2e/support/security-posture.test.ts +++ b/test/e2e/support/security-posture.test.ts @@ -1,69 +1,550 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { - dangerousCapabilities, + assertSecurityPosture, + OPENSHELL_SUPERVISOR_CAPABILITY_MASK, + parseOpenShellContainerId, + parseSplitProcessSecurityReport, + SPLIT_PROCESS_SECURITY_PROBE, + type SplitProcessSecurityReport, securityPostureEnabled, securityPostureExpectations, securityPostureModeEnv, + validateSplitProcessSecurityReport, } from "../fixtures/security-posture.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; + +const ZERO_CAPABILITIES = "0000000000000000"; +const SUPERVISOR_EXECUTABLE = "/opt/openshell/bin/openshell-sandbox"; +const CONTAINER_ID = "a".repeat(64); +const SANDBOX_NAME = "secure-sandbox"; +const SANDBOX_ID = "sandbox-id"; +const CONTAINER_NAME = `openshell-default--${SANDBOX_NAME}-${SANDBOX_ID}`; + +type ReportMutationCase = { + error: RegExp; + mutate: (report: SplitProcessSecurityReport) => void; + name: string; +}; + +function repeatedId(id: number): string[] { + return Array.from({ length: 4 }, () => String(id)); +} + +function validReport(): SplitProcessSecurityReport { + return { + observedProcEntries: 12, + sandboxGid: 1000, + sandboxUid: 1000, + supervisor: { + argv: [SUPERVISOR_EXECUTABLE, "--workdir", "/sandbox"], + executable: SUPERVISOR_EXECUTABLE, + pid: 1, + ppid: 0, + state: "S", + startTime: "101", + status: { + capAmb: ZERO_CAPABILITIES, + capBnd: OPENSHELL_SUPERVISOR_CAPABILITY_MASK, + capEff: OPENSHELL_SUPERVISOR_CAPABILITY_MASK, + capInh: ZERO_CAPABILITIES, + capPrm: OPENSHELL_SUPERVISOR_CAPABILITY_MASK, + gid: repeatedId(0), + groups: ["0"], + noNewPrivs: "1", + uid: repeatedId(0), + }, + }, + version: 1, + childSupervisors: [ + { + argv: ["/usr/bin/bash", "/usr/local/bin/nemoclaw-start"], + executable: "/usr/bin/bash", + pid: 42, + ppid: 1, + state: "S", + startTime: "202", + status: { + capAmb: ZERO_CAPABILITIES, + capBnd: ZERO_CAPABILITIES, + capEff: ZERO_CAPABILITIES, + capInh: ZERO_CAPABILITIES, + capPrm: ZERO_CAPABILITIES, + gid: repeatedId(1000), + groups: ["1000"], + noNewPrivs: "1", + uid: repeatedId(1000), + }, + }, + ], + }; +} + +function successfulProbe(stdout = ""): ShellProbeResult { + return { + artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, + command: [], + exitCode: 0, + signal: null, + stderr: "", + stdout, + timedOut: false, + }; +} afterEach(() => vi.unstubAllEnvs()); describe("security posture fixture", () => { - it("decodes the dangerous Linux capability bits", () => { - expect(dangerousCapabilities("00200000")).toEqual(["CAP_SYS_ADMIN"]); - expect(dangerousCapabilities("00002402")).toEqual([ - "CAP_NET_RAW", - "CAP_NET_BIND_SERVICE", - "CAP_DAC_OVERRIDE", - ]); - expect(dangerousCapabilities("00000000")).toEqual([]); - expect(dangerousCapabilities("not-hex")).toEqual([]); + it("compiles the embedded split-process probe as Python", () => { + const compiled = spawnSync( + "python3", + [ + "-c", + "import sys; compile(sys.argv[1], '', 'exec')", + SPLIT_PROCESS_SECURITY_PROBE, + ], + { encoding: "utf8" }, + ); + + expect(compiled.status, compiled.stderr).toBe(0); }); - it("only forwards the explicit security-posture mode", () => { + it("keeps isolated Python from importing a sandbox-controlled module", () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-security-posture-python-")); + try { + writeFileSync(path.join(directory, "json.py"), "raise SystemExit(73)\n", "utf8"); + const isolated = spawnSync( + "python3", + ["-I", "-c", 'import json; print(json.dumps({"isolated": True}))'], + { + cwd: directory, + encoding: "utf8", + env: { ...process.env, PYTHONPATH: directory }, + }, + ); + + expect(isolated.status, isolated.stderr).toBe(0); + expect(isolated.stdout.trim()).toBe('{"isolated": true}'); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + + it("forwards only an enabled split-process expectation", () => { vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", undefined); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "1"); expect(securityPostureEnabled()).toBe(false); expect(securityPostureExpectations()).toEqual({ - droppedBoundingCapabilities: false, enabled: false, - noNewPrivileges: false, - nonRootEntrypoint: false, + openshellSplitProcess: false, }); expect(securityPostureModeEnv()).toEqual({}); - vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "yes"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "on"); expect(securityPostureEnabled()).toBe(true); expect(securityPostureExpectations()).toEqual({ - droppedBoundingCapabilities: false, enabled: true, - noNewPrivileges: false, - nonRootEntrypoint: false, + openshellSplitProcess: true, }); expect(securityPostureModeEnv()).toEqual({ - NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS: "0", - NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT: "0", NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST: "1", - NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS: "0", + NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS: "1", NEMOCLAW_E2E_SECURITY_POSTURE: "1", }); }); - it("normalizes opt-in PID 1 hardening expectations", () => { - vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "yes"); - vi.stubEnv("NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS", "true"); - vi.stubEnv("NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT", "on"); - vi.stubEnv("NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS", "1"); + it("normalizes a disabled split-process expectation", () => { + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "0"); + expect(securityPostureExpectations()).toEqual({ + enabled: true, + openshellSplitProcess: false, + }); expect(securityPostureModeEnv()).toEqual({ - NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS: "1", - NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT: "1", NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST: "1", - NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS: "1", + NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS: "0", NEMOCLAW_E2E_SECURITY_POSTURE: "1", }); }); + + it("accepts the expected OpenShell supervisor and non-root nemoclaw-start child supervisor", () => { + const report = validReport(); + + expect(OPENSHELL_SUPERVISOR_CAPABILITY_MASK).toBe("00000004a82c35fb"); + expect(validateSplitProcessSecurityReport(report)).toEqual(report); + expect(parseSplitProcessSecurityReport(JSON.stringify(report))).toEqual(report); + }); + + it.each([ + { + error: /expected pid=1 ppid=0/u, + mutate: (report) => { + report.supervisor.ppid = 2; + }, + name: "parent process", + }, + { + error: /does not have the expected OpenShell supervisor command/u, + mutate: (report) => { + report.supervisor.executable = "/usr/bin/bash"; + }, + name: "executable", + }, + { + error: /does not have the expected OpenShell supervisor command/u, + mutate: (report) => { + report.supervisor.argv.push("--unexpected"); + }, + name: "command arguments", + }, + { + error: /supervisor Uid expected 0/u, + mutate: (report) => { + report.supervisor.status.uid = repeatedId(1000); + }, + name: "user identity", + }, + { + error: /supervisor Gid expected 0/u, + mutate: (report) => { + report.supervisor.status.gid = repeatedId(1000); + }, + name: "group identity", + }, + { + error: /supervisor Groups expected only 0/u, + mutate: (report) => { + report.supervisor.status.groups = ["0", "44"]; + }, + name: "supplementary groups", + }, + { + error: /supervisor\.state must be one of D, R, S/u, + mutate: (report) => { + report.supervisor.state = "T"; + }, + name: "process state", + }, + { + error: /supervisor CapInh drifted/u, + mutate: (report) => { + report.supervisor.status.capInh = "0000000000000001"; + }, + name: "inheritable capability", + }, + { + error: /supervisor capPrm expected/u, + mutate: (report) => { + report.supervisor.status.capPrm = ZERO_CAPABILITIES; + }, + name: "permitted capability", + }, + { + error: /supervisor capEff expected/u, + mutate: (report) => { + report.supervisor.status.capEff = ZERO_CAPABILITIES; + }, + name: "effective capability", + }, + { + error: /supervisor capBnd expected/u, + mutate: (report) => { + report.supervisor.status.capBnd = ZERO_CAPABILITIES; + }, + name: "bounding capability", + }, + { + error: /supervisor CapAmb drifted/u, + mutate: (report) => { + report.supervisor.status.capAmb = "0000000000000001"; + }, + name: "ambient capability", + }, + { + error: /supervisor expected NoNewPrivs=1/u, + mutate: (report) => { + report.supervisor.status.noNewPrivs = "0"; + }, + name: "NoNewPrivs", + }, + ])("rejects OpenShell supervisor $name drift", ({ error, mutate }) => { + const report = validReport(); + mutate(report); + + expect(() => validateSplitProcessSecurityReport(report)).toThrow(error); + }); + + it.each([ + [0, /found 0/u], + [2, /found 2/u], + ])("rejects a census with %i nemoclaw-start child supervisors", (count, error) => { + const report = validReport(); + report.childSupervisors = Array.from( + { length: count }, + () => validReport().childSupervisors[0]!, + ); + + expect(() => validateSplitProcessSecurityReport(report)).toThrow(error); + }); + + it.each([ + { + error: /direct PID 1 child/u, + mutate: (report) => { + report.childSupervisors[0]!.ppid = 10; + }, + name: "that is not a direct child of PID 1", + }, + { + error: /does not have the expected argv/u, + mutate: (report) => { + report.childSupervisors[0]!.argv = [ + "/usr/bin/bash", + "/usr/local/bin/nemoclaw-start", + "--unexpected", + ]; + }, + name: "with extra command arguments", + }, + { + error: /argv must contain only nonempty arguments/u, + mutate: (report) => { + report.childSupervisors[0]!.argv.push(""); + }, + name: "with a trailing empty command argument", + }, + { + error: /expected the system Bash executable/u, + mutate: (report) => { + report.childSupervisors[0]!.executable = "/usr/bin/python3"; + }, + name: "with a different executable", + }, + { + error: /childSupervisors\[0\]\.state must be one of D, R, S/u, + mutate: (report) => { + report.childSupervisors[0]!.state = "T"; + }, + name: "with a stopped or traced process state", + }, + { + error: /child supervisor Uid expected 1000/u, + mutate: (report) => { + report.childSupervisors[0]!.status.uid = repeatedId(0); + }, + name: "that runs as root", + }, + { + error: /child supervisor Uid expected 1000/u, + mutate: (report) => { + report.childSupervisors[0]!.status.uid = repeatedId(1001); + }, + name: "with a different non-root user", + }, + { + error: /child supervisor Gid expected 1000/u, + mutate: (report) => { + report.childSupervisors[0]!.status.gid = repeatedId(1001); + }, + name: "with a different non-root group", + }, + { + error: /child supervisor Groups expected only 1000/u, + mutate: (report) => { + report.childSupervisors[0]!.status.groups = ["0", "1000"]; + }, + name: "with a privileged supplementary group", + }, + { + error: /child supervisor expected NoNewPrivs=1/u, + mutate: (report) => { + report.childSupervisors[0]!.status.noNewPrivs = "0"; + }, + name: "without NoNewPrivs", + }, + ])("rejects a nemoclaw-start child supervisor $name", ({ error, mutate }) => { + const report = validReport(); + mutate(report); + + expect(() => validateSplitProcessSecurityReport(report)).toThrow(error); + }); + + it.each([ + "capInh", + "capPrm", + "capEff", + "capBnd", + "capAmb", + ] as const)("rejects a nemoclaw-start child supervisor with a nonzero %s set", (field) => { + const report = validReport(); + report.childSupervisors[0]!.status[field] = "0000000000000001"; + + expect(() => validateSplitProcessSecurityReport(report)).toThrow( + new RegExp(`child supervisor\\.${field} expected 0`, "u"), + ); + }); + + it("rejects malformed and overflowing split-process reports", () => { + expect(() => parseSplitProcessSecurityReport("not-json")).toThrow(/emitted invalid JSON/u); + expect(() => validateSplitProcessSecurityReport({ childSupervisors: [] })).toThrow( + /version must be 1/u, + ); + + const malformed = { ...validReport(), childSupervisors: "one" }; + expect(() => validateSplitProcessSecurityReport(malformed)).toThrow( + /childSupervisors must be an array/u, + ); + + const overflow = validReport(); + overflow.observedProcEntries = 32_769; + expect(() => validateSplitProcessSecurityReport(overflow)).toThrow( + /exceeded 32768 process entries/u, + ); + }); + + it("selects one exact OpenShell container identity", () => { + const row = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; + + expect(parseOpenShellContainerId(row, SANDBOX_NAME)).toBe(CONTAINER_ID); + }); + + it.each([ + ["", /found 0/u], + [ + `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n${"b".repeat(64)}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault`, + /found 2/u, + ], + [ + `abc\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault`, + /unexpected OpenShell Docker container identity/u, + ], + [ + `${CONTAINER_ID}\twrong-name\t${SANDBOX_ID}\tdefault`, + /unexpected OpenShell Docker container identity/u, + ], + [ + `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tother`, + /unexpected OpenShell Docker container identity/u, + ], + [ + `${CONTAINER_ID}\t${CONTAINER_NAME}\tunsafe/id\tdefault`, + /unexpected OpenShell Docker container identity/u, + ], + ])("rejects a container selection that is absent, ambiguous, or inexact", (output, error) => { + expect(() => parseOpenShellContainerId(output, SANDBOX_NAME)).toThrow(error); + }); + + it("checks the split-process report before the remaining sandbox posture", async () => { + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "1"); + const report = validReport(); + const containerRow = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; + const command = vi + .fn() + .mockResolvedValueOnce(successfulProbe("uid=1000 gid=1000\n")) + .mockResolvedValueOnce(successfulProbe(containerRow)) + .mockResolvedValueOnce(successfulProbe(JSON.stringify(report))); + const execShell = vi.fn(async () => successfulProbe()); + const host = { command } as unknown as HostCliClient; + const sandbox = { execShell } as unknown as SandboxClient; + const privilegedProbeArgs = [ + "exec", + "--env", + "LD_PRELOAD=", + "--env", + "PYTHONPATH=", + "--user", + "root", + CONTAINER_ID, + "/usr/bin/python3", + "-I", + "-c", + SPLIT_PROCESS_SECURITY_PROBE, + ]; + const privilegedExecArgv = vi.fn( + ( + _sandboxName: string, + _command: string[], + _stdin?: boolean, + _sanitizeEnvironment?: boolean, + _expectedContainerId?: string, + ) => privilegedProbeArgs, + ); + + const summary = await assertSecurityPosture(host, sandbox, SANDBOX_NAME, "openclaw", { + privilegedExecArgv, + }); + + expect(summary).toEqual({ + configureGuard: true, + hostNonRoot: true, + rcFilesLocked: true, + runtimeProxyEnvLocked: true, + splitProcess: { + childSupervisor: report.childSupervisors[0], + supervisor: report.supervisor, + }, + startupLogClean: true, + }); + expect(command).toHaveBeenCalledTimes(3); + expect(command).toHaveBeenNthCalledWith( + 2, + "docker", + [ + "ps", + "--no-trunc", + "--filter", + "label=openshell.ai/managed-by=openshell", + "--filter", + `label=openshell.ai/sandbox-name=${SANDBOX_NAME}`, + "--format", + '{{.ID}}\t{{.Names}}\t{{.Label "openshell.ai/sandbox-id"}}\t{{.Label "openshell.ai/sandbox-workspace"}}', + ], + expect.objectContaining({ artifactName: "security-posture-container-identity" }), + ); + expect(command).toHaveBeenNthCalledWith( + 3, + "docker", + privilegedProbeArgs, + expect.objectContaining({ artifactName: "security-posture-split-processes" }), + ); + expect(privilegedExecArgv).toHaveBeenCalledWith( + SANDBOX_NAME, + ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE], + false, + true, + CONTAINER_ID, + ); + expect(execShell).toHaveBeenCalledTimes(4); + }); + + it("rejects a disabled split-process expectation before running a command", async () => { + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "0"); + const command = vi.fn(); + const execShell = vi.fn(); + + await expect( + assertSecurityPosture( + { command } as unknown as HostCliClient, + { execShell } as unknown as SandboxClient, + SANDBOX_NAME, + "openclaw", + ), + ).rejects.toThrow(/requires NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS=1/u); + expect(command).not.toHaveBeenCalled(); + expect(execShell).not.toHaveBeenCalled(); + }); }); diff --git a/tools/e2e/cli-artifact-workflow-boundary.mts b/tools/e2e/cli-artifact-workflow-boundary.mts index f2e1aa07a11..431dca19451 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -40,7 +40,7 @@ const CLI_ARTIFACT_PROVENANCE_STEP = "Record CLI artifact provenance"; const CANDIDATE_CHECKOUT_STEP_CONTENT_SHA256 = "3578a053cede863f7aa4814d8399b4ca21ea0b77cee712e6d549c684818f11dd"; const CLI_ARTIFACT_WORKFLOW_CONTRACT_SHA256 = - "14fb4de8dffd0cfd3f0dd3177f03e806c8912247bac4f5919a570c0f6b4c0ca2"; + "604afc60e21ba46c2099f23577bbc0dda69e03ee09a12ed94db0713237def237"; const CLI_ARTIFACT_CONSUMER_JOB_NAMES = [ "agent-turn-latency", "bedrock-runtime-compatible-anthropic", diff --git a/tools/e2e/security-posture-workflow-boundary.mts b/tools/e2e/security-posture-workflow-boundary.mts index 764123ee270..32a82c47778 100644 --- a/tools/e2e/security-posture-workflow-boundary.mts +++ b/tools/e2e/security-posture-workflow-boundary.mts @@ -73,13 +73,11 @@ export function validateSecurityPostureWorkflow(workflow: WorkflowRecord): strin const expectedMatrix = [ { agent: "openclaw", - expect_non_root_entrypoint: "1", sandbox_name: "e2e-oc-security", test_file: "test/e2e/live/full-e2e.test.ts", }, { agent: "hermes", - expect_non_root_entrypoint: "0", sandbox_name: "e2e-hm-security", test_file: "test/e2e/live/hermes-e2e.test.ts", }, @@ -96,8 +94,8 @@ export function validateSecurityPostureWorkflow(workflow: WorkflowRecord): strin NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_AGENT: "${{ matrix.agent }}", NEMOCLAW_CLI_BIN: "${{ github.workspace }}/bin/nemoclaw.js", - NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT: "${{ matrix.expect_non_root_entrypoint }}", NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST: "1", + NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS: "1", NEMOCLAW_E2E_SECURITY_POSTURE: "1", NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1", NEMOCLAW_NON_INTERACTIVE: "1", @@ -110,6 +108,9 @@ export function validateSecurityPostureWorkflow(workflow: WorkflowRecord): strin for (const [name, value] of Object.entries(expectedEnv)) { if (jobEnv[name] !== value) errors.push(`${JOB_NAME} must set ${name}=${value}`); } + if (Object.hasOwn(jobEnv, "NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT")) { + errors.push(`${JOB_NAME} must not set retired NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT`); + } if (Object.hasOwn(jobEnv, "NVIDIA_INFERENCE_API_KEY")) { errors.push(`${JOB_NAME} must not expose the inference key at job scope`); } From ce36aadc0cda593b63f8c5e8a5092ad4c55d9f33 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sat, 8 Aug 2026 15:51:32 -0700 Subject: [PATCH 2/2] fix(e2e): bind posture inspection runtime Signed-off-by: Apurv Kumaria --- test/e2e/fixtures/security-posture.ts | 111 ++++++++++++++----- test/e2e/support/security-posture.test.ts | 123 +++++++++++++++++++++- 2 files changed, 205 insertions(+), 29 deletions(-) diff --git a/test/e2e/fixtures/security-posture.ts b/test/e2e/fixtures/security-posture.ts index cf677d3f5c1..b9eb6c5f8bc 100644 --- a/test/e2e/fixtures/security-posture.ts +++ b/test/e2e/fixtures/security-posture.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { privilegedSandboxExecArgv } from "../../../src/lib/sandbox/privileged-exec.ts"; +import { buildSubprocessEnv } from "../../../src/lib/subprocess-env.ts"; import { buildAvailabilityProbeEnv } from "./availability-env.ts"; import type { HostCliClient } from "./clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "./clients/sandbox.ts"; @@ -71,6 +72,11 @@ const OPENSHELL_SUPERVISOR_ARGV = [ "/sandbox", ] as const; const SYSTEM_BASH_EXECUTABLES = ["/bin/bash", "/usr/bin/bash"] as const; +const NEMOCLAW_START_SUPERVISOR_PATHS = [ + "nemoclaw-start", + "/usr/local/bin/nemoclaw-start", +] as const; +const BASH_ARGV0 = ["bash", ...SYSTEM_BASH_EXECUTABLES] as const; const LIVE_PROCESS_STATES = ["D", "R", "S"] as const; const SAFE_OPENSHELL_IDENTITY_COMPONENT = /^[a-z0-9][a-z0-9_.-]*$/u; const MAX_PROC_ENTRIES = 32_768; @@ -88,12 +94,12 @@ import pwd PROC_ROOT = Path("/proc") MAX_PROC_ENTRIES = ${MAX_PROC_ENTRIES} -OPENSHELL_SUPERVISOR_ARGV = (b"/opt/openshell/bin/openshell-sandbox", b"--workdir", b"/sandbox") -OPENSHELL_SUPERVISOR_EXECUTABLE = "/opt/openshell/bin/openshell-sandbox" -NEMOCLAW_START_SUPERVISOR = (b"nemoclaw-start", b"/usr/local/bin/nemoclaw-start") -BASH = (b"bash", b"/bin/bash", b"/usr/bin/bash") -SYSTEM_BASH_EXECUTABLES = {"/bin/bash", "/usr/bin/bash"} -LIVE_PROCESS_STATES = {"D", "R", "S"} +OPENSHELL_SUPERVISOR_ARGV = tuple(item.encode("utf-8") for item in ${JSON.stringify(OPENSHELL_SUPERVISOR_ARGV)}) +OPENSHELL_SUPERVISOR_EXECUTABLE = ${JSON.stringify(OPENSHELL_SUPERVISOR_EXECUTABLE)} +NEMOCLAW_START_SUPERVISOR = tuple(item.encode("utf-8") for item in ${JSON.stringify(NEMOCLAW_START_SUPERVISOR_PATHS)}) +BASH = tuple(item.encode("utf-8") for item in ${JSON.stringify(BASH_ARGV0)}) +SYSTEM_BASH_EXECUTABLES = set(${JSON.stringify(SYSTEM_BASH_EXECUTABLES)}) +LIVE_PROCESS_STATES = set(${JSON.stringify(LIVE_PROCESS_STATES)}) def argv_for(path): raw = (path / "cmdline").read_bytes() @@ -105,7 +111,7 @@ def argv_for(path): def is_nemoclaw_start_supervisor(argv): return ( - argv in ((NEMOCLAW_START_SUPERVISOR[0],), (NEMOCLAW_START_SUPERVISOR[1],)) + (len(argv) == 1 and argv[0] in NEMOCLAW_START_SUPERVISOR) or ( len(argv) == 2 and argv[0] in BASH @@ -245,6 +251,20 @@ function probeEnv(): NodeJS.ProcessEnv { }; } +function subprocessEnvironmentIdentity(env: NodeJS.ProcessEnv): string { + return JSON.stringify( + Object.entries(env) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function requireStablePrivilegedDockerEnvironment(expectedIdentity: string): void { + if (subprocessEnvironmentIdentity(buildSubprocessEnv()) !== expectedIdentity) { + throw new Error("privileged Docker environment changed during security posture inspection"); + } +} + function resultText(result: Pick): string { return [result.stdout, result.stderr].filter(Boolean).join("\n"); } @@ -352,12 +372,16 @@ function requireExactSupplementaryGroups(values: string[], expected: number, lab } function canonicalNemoclawStartSupervisorArgv(argv: string[]): boolean { - const starts = ["nemoclaw-start", "/usr/local/bin/nemoclaw-start"]; - if (argv.length === 1 && starts.includes(argv[0] ?? "")) return true; + if ( + argv.length === 1 && + (NEMOCLAW_START_SUPERVISOR_PATHS as readonly string[]).includes(argv[0] ?? "") + ) { + return true; + } return ( argv.length === 2 && - ["bash", "/bin/bash", "/usr/bin/bash"].includes(argv[0] ?? "") && - starts.includes(argv[1] ?? "") + (BASH_ARGV0 as readonly string[]).includes(argv[0] ?? "") && + (NEMOCLAW_START_SUPERVISOR_PATHS as readonly string[]).includes(argv[1] ?? "") ); } @@ -505,6 +529,20 @@ export function parseOpenShellContainerId(output: string, sandboxName: string): return id; } +export function dockerRuntimeEndpointArgs(privilegedExecArgs: readonly string[]): string[] { + if (privilegedExecArgs[0] === "exec") return []; + const dockerHost = privilegedExecArgs[1]; + if ( + privilegedExecArgs[0] !== "--host" || + !dockerHost || + /[\u0000-\u001f\u007f-\u009f]/u.test(dockerHost) || + privilegedExecArgs[2] !== "exec" + ) { + throw new Error("privileged Docker execution did not identify a supported runtime endpoint"); + } + return ["--host", dockerHost]; +} + export function securityPostureEnabled(): boolean { return securityPostureExpectations().enabled; } @@ -551,9 +589,23 @@ export async function assertSecurityPosture( ); requireSuccess("non-root host user", hostUser); + const privilegedExecArgv = dependencies.privilegedExecArgv ?? privilegedSandboxExecArgv; + const splitProcessProbeCommand = ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE]; + const privilegedDockerEnv = buildSubprocessEnv(); + const privilegedDockerEnvironmentIdentity = subprocessEnvironmentIdentity(privilegedDockerEnv); + const initialPrivilegedExecArgs = privilegedExecArgv( + sandboxName, + splitProcessProbeCommand, + false, + true, + ); + requireStablePrivilegedDockerEnvironment(privilegedDockerEnvironmentIdentity); + const dockerEndpointArgs = dockerRuntimeEndpointArgs(initialPrivilegedExecArgs); + const containers = await host.command( "docker", [ + ...dockerEndpointArgs, "ps", "--no-trunc", "--filter", @@ -565,28 +617,33 @@ export async function assertSecurityPosture( ], { artifactName: "security-posture-container-identity", - env: probeEnv(), + env: privilegedDockerEnv, timeoutMs: 30_000, }, ); requireSuccess("OpenShell Docker container discovery", containers); const containerId = parseOpenShellContainerId(containers.stdout, sandboxName); - const privilegedExecArgv = dependencies.privilegedExecArgv ?? privilegedSandboxExecArgv; - const splitProcessProbe = await host.command( - "docker", - privilegedExecArgv( - sandboxName, - ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE], - false, - true, - containerId, - ), - { - artifactName: "security-posture-split-processes", - env: probeEnv(), - timeoutMs: 30_000, - }, + requireStablePrivilegedDockerEnvironment(privilegedDockerEnvironmentIdentity); + const finalPrivilegedExecArgs = privilegedExecArgv( + sandboxName, + splitProcessProbeCommand, + false, + true, + containerId, ); + requireStablePrivilegedDockerEnvironment(privilegedDockerEnvironmentIdentity); + const finalDockerEndpointArgs = dockerRuntimeEndpointArgs(finalPrivilegedExecArgs); + if ( + finalDockerEndpointArgs.length !== dockerEndpointArgs.length || + finalDockerEndpointArgs.some((argument, index) => argument !== dockerEndpointArgs[index]) + ) { + throw new Error("container runtime endpoint changed before privileged inspection"); + } + const splitProcessProbe = await host.command("docker", finalPrivilegedExecArgs, { + artifactName: "security-posture-split-processes", + env: privilegedDockerEnv, + timeoutMs: 30_000, + }); requireSuccess( "OpenShell and nemoclaw-start child supervisor security posture", splitProcessProbe, diff --git a/test/e2e/support/security-posture.test.ts b/test/e2e/support/security-posture.test.ts index fc56d926533..8a6dc5afcdb 100644 --- a/test/e2e/support/security-posture.test.ts +++ b/test/e2e/support/security-posture.test.ts @@ -12,6 +12,7 @@ import type { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { assertSecurityPosture, + dockerRuntimeEndpointArgs, OPENSHELL_SUPERVISOR_CAPABILITY_MASK, parseOpenShellContainerId, parseSplitProcessSecurityReport, @@ -30,6 +31,7 @@ const CONTAINER_ID = "a".repeat(64); const SANDBOX_NAME = "secure-sandbox"; const SANDBOX_ID = "sandbox-id"; const CONTAINER_NAME = `openshell-default--${SANDBOX_NAME}-${SANDBOX_ID}`; +const PORTABLE_DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock"; type ReportMutationCase = { error: RegExp; @@ -116,6 +118,7 @@ describe("security posture fixture", () => { { encoding: "utf8" }, ); + expect(compiled.error, "python3 is required to compile the embedded probe").toBeUndefined(); expect(compiled.status, compiled.stderr).toBe(0); }); @@ -133,6 +136,7 @@ describe("security posture fixture", () => { }, ); + expect(isolated.error, "python3 is required to verify isolated mode").toBeUndefined(); expect(isolated.status, isolated.stderr).toBe(0); expect(isolated.stdout.trim()).toBe('{"isolated": true}'); } finally { @@ -420,6 +424,19 @@ describe("security posture fixture", () => { expect(parseOpenShellContainerId(row, SANDBOX_NAME)).toBe(CONTAINER_ID); }); + it("derives Docker discovery from the privileged execution endpoint", () => { + expect(dockerRuntimeEndpointArgs(["exec", "--user", "root"])).toEqual([]); + expect( + dockerRuntimeEndpointArgs(["--host", PORTABLE_DOCKER_HOST, "exec", "--user", "root"]), + ).toEqual(["--host", PORTABLE_DOCKER_HOST]); + expect(() => dockerRuntimeEndpointArgs(["--host", "", "exec"])).toThrow( + /supported runtime endpoint/u, + ); + expect(() => dockerRuntimeEndpointArgs(["--context", "remote", "exec"])).toThrow( + /supported runtime endpoint/u, + ); + }); + it.each([ ["", /found 0/u], [ @@ -446,9 +463,17 @@ describe("security posture fixture", () => { expect(() => parseOpenShellContainerId(output, SANDBOX_NAME)).toThrow(error); }); - it("checks the split-process report before the remaining sandbox posture", async () => { + it.each([ + ["direct Docker", []], + ["portable container runtime", ["--host", PORTABLE_DOCKER_HOST]], + ])("checks the split-process report through %s before the remaining posture", async (_runtime, dockerEndpointArgs) => { vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "1"); + vi.stubEnv("DOCKER_HOST", "unix:///run/trusted-docker.sock"); + vi.stubEnv("DOCKER_CONTEXT", "untrusted-context"); + vi.stubEnv("DOCKER_CONFIG", "/tmp/untrusted-docker-config"); + vi.stubEnv("DOCKER_TLS_VERIFY", "1"); + vi.stubEnv("DOCKER_CERT_PATH", "/tmp/untrusted-docker-certs"); const report = validReport(); const containerRow = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; const command = vi @@ -460,6 +485,7 @@ describe("security posture fixture", () => { const host = { command } as unknown as HostCliClient; const sandbox = { execShell } as unknown as SandboxClient; const privilegedProbeArgs = [ + ...dockerEndpointArgs, "exec", "--env", "LD_PRELOAD=", @@ -503,6 +529,7 @@ describe("security posture fixture", () => { 2, "docker", [ + ...dockerEndpointArgs, "ps", "--no-trunc", "--filter", @@ -520,16 +547,108 @@ describe("security posture fixture", () => { privilegedProbeArgs, expect.objectContaining({ artifactName: "security-posture-split-processes" }), ); - expect(privilegedExecArgv).toHaveBeenCalledWith( + expect(privilegedExecArgv).toHaveBeenNthCalledWith( + 1, + SANDBOX_NAME, + ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE], + false, + true, + ); + expect(privilegedExecArgv).toHaveBeenNthCalledWith( + 2, SANDBOX_NAME, ["/usr/bin/python3", "-I", "-c", SPLIT_PROCESS_SECURITY_PROBE], false, true, CONTAINER_ID, ); + for (const callIndex of [1, 2]) { + const dockerEnv = command.mock.calls[callIndex]?.[2]?.env; + expect(dockerEnv).toMatchObject({ DOCKER_HOST: "unix:///run/trusted-docker.sock" }); + expect(dockerEnv).not.toHaveProperty("DOCKER_CONTEXT"); + expect(dockerEnv).not.toHaveProperty("DOCKER_CONFIG"); + expect(dockerEnv).not.toHaveProperty("DOCKER_TLS_VERIFY"); + expect(dockerEnv).not.toHaveProperty("DOCKER_CERT_PATH"); + } expect(execShell).toHaveBeenCalledTimes(4); }); + it("rejects container runtime endpoint drift before privileged inspection", async () => { + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "1"); + const containerRow = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; + const command = vi + .fn() + .mockResolvedValueOnce(successfulProbe("uid=1000 gid=1000\n")) + .mockResolvedValueOnce(successfulProbe(containerRow)); + const execShell = vi.fn(); + let invocation = 0; + const privilegedExecArgv = vi.fn( + ( + _sandboxName: string, + _command: string[], + _stdin?: boolean, + _sanitizeEnvironment?: boolean, + _expectedContainerId?: string, + ) => [ + "--host", + invocation++ === 0 ? "unix:///run/podman-a.sock" : "unix:///run/podman-b.sock", + "exec", + ], + ); + + await expect( + assertSecurityPosture( + { command } as unknown as HostCliClient, + { execShell } as unknown as SandboxClient, + SANDBOX_NAME, + "openclaw", + { privilegedExecArgv }, + ), + ).rejects.toThrow(/runtime endpoint changed/u); + + expect(command).toHaveBeenCalledTimes(2); + expect(execShell).not.toHaveBeenCalled(); + }); + + it("rejects Docker environment drift before privileged inspection", async () => { + vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); + vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "1"); + vi.stubEnv("DOCKER_HOST", "unix:///run/docker-a.sock"); + const containerRow = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; + const command = vi + .fn() + .mockResolvedValueOnce(successfulProbe("uid=1000 gid=1000\n")) + .mockImplementationOnce(async () => { + vi.stubEnv("DOCKER_HOST", "unix:///run/docker-b.sock"); + return successfulProbe(containerRow); + }); + const execShell = vi.fn(); + const privilegedExecArgv = vi.fn( + ( + _sandboxName: string, + _command: string[], + _stdin?: boolean, + _sanitizeEnvironment?: boolean, + _expectedContainerId?: string, + ) => ["exec"], + ); + + await expect( + assertSecurityPosture( + { command } as unknown as HostCliClient, + { execShell } as unknown as SandboxClient, + SANDBOX_NAME, + "openclaw", + { privilegedExecArgv }, + ), + ).rejects.toThrow(/privileged Docker environment changed/u); + + expect(privilegedExecArgv).toHaveBeenCalledTimes(1); + expect(command).toHaveBeenCalledTimes(2); + expect(execShell).not.toHaveBeenCalled(); + }); + it("rejects a disabled split-process expectation before running a command", async () => { vi.stubEnv("NEMOCLAW_E2E_SECURITY_POSTURE", "1"); vi.stubEnv("NEMOCLAW_E2E_EXPECT_OPENSHELL_SPLIT_PROCESS", "0");