diff --git a/test/e2e/fixtures/security-posture.ts b/test/e2e/fixtures/security-posture.ts index 00321734065..724facd5f1a 100644 --- a/test/e2e/fixtures/security-posture.ts +++ b/test/e2e/fixtures/security-posture.ts @@ -33,12 +33,13 @@ export interface ProcessSecurityIdentity { } export interface SplitProcessSecurityReport { + childSupervisors: ProcessSecurityIdentity[]; + observedChildSupervisors: ProcessSecurityIdentity[]; observedProcEntries: number; sandboxGid: number; sandboxUid: number; supervisor: ProcessSecurityIdentity; - version: 1; - childSupervisors: ProcessSecurityIdentity[]; + version: 2; } export interface SecurityPostureSummary { @@ -80,6 +81,8 @@ 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; +const MAX_CENSUS_STABILITY_ATTEMPTS = 4; +const MAX_CENSUS_DIAGNOSTIC_IDENTITIES = 16; // 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 @@ -94,6 +97,8 @@ import pwd PROC_ROOT = Path("/proc") MAX_PROC_ENTRIES = ${MAX_PROC_ENTRIES} +MAX_CENSUS_STABILITY_ATTEMPTS = ${MAX_CENSUS_STABILITY_ATTEMPTS} +MAX_CENSUS_DIAGNOSTIC_IDENTITIES = ${MAX_CENSUS_DIAGNOSTIC_IDENTITIES} 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)}) @@ -148,7 +153,7 @@ def selected_status(raw): def stable_process(pid): path = PROC_ROOT / str(pid) - before = path.stat(follow_symlinks=False) + before = os.stat(path, follow_symlinks=False) first_state, first_ppid, first_start_time = stat_identity( (path / "stat").read_text(encoding="utf-8") ) @@ -161,7 +166,7 @@ def stable_process(pid): 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) + after = os.stat(path, 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 ( @@ -195,16 +200,130 @@ def child_supervisor_census(): if observed > MAX_PROC_ENTRIES: raise RuntimeError("process census exceeded its checked bound") try: - argv = argv_for(Path(entry.path)) + selected_path = Path(entry.path) + _, _, selected_start_time = stat_identity( + (selected_path / "stat").read_text(encoding="utf-8") + ) + selected_argv = argv_for(selected_path) except (FileNotFoundError, ProcessLookupError): continue - if is_nemoclaw_start_supervisor(argv): - matches.append(stable_process(entry.name)) + if is_nemoclaw_start_supervisor(selected_argv): + process = stable_process(entry.name) + stable_argv = tuple(item.encode("utf-8") for item in process["argv"]) + if ( + process["startTime"] != selected_start_time + or stable_argv != selected_argv + or not is_nemoclaw_start_supervisor(stable_argv) + or process["executable"] not in SYSTEM_BASH_EXECUTABLES + ): + raise RuntimeError( + "nemoclaw-start process changed after census selection" + ) + matches.append(process) return observed, matches def stable_security_identity(process): return {name: value for name, value in process.items() if name != "state"} +def process_identity_key(process): + return process["pid"], int(process["startTime"]) + +def canonical_security_identities(processes): + return sorted( + (stable_security_identity(process) for process in processes), + key=process_identity_key, + ) + +def diagnostic_census(processes): + identities = sorted( + ( + { + "pid": process["pid"], + "ppid": process["ppid"], + "startTime": process["startTime"], + } + for process in processes + ), + key=process_identity_key, + ) + return { + "count": len(identities), + "identities": identities[:MAX_CENSUS_DIAGNOSTIC_IDENTITIES], + "truncated": len(identities) > MAX_CENSUS_DIAGNOSTIC_IDENTITIES, + } + +def changed_identity_fields(first, second): + return [ + name + for name in ("ppid", "startTime", "argv", "executable", "status") + if first[name] != second[name] + ] + +def remember_observed_processes(observed, observed_start_times, processes): + for process in processes: + key = process_identity_key(process) + previous_start_time = observed_start_times.get(process["pid"]) + if previous_start_time is not None and previous_start_time != process["startTime"]: + raise RuntimeError( + f"nemoclaw-start process PID {process['pid']} was reused during census acquisition" + ) + previous = observed.get(key) + if ( + previous is not None + and stable_security_identity(previous) != stable_security_identity(process) + ): + raise RuntimeError( + "nemoclaw-start process identity changed across census attempts: " + f"pid={process['pid']} " + f"fields={','.join(changed_identity_fields(previous, process))}" + ) + observed[key] = process + observed_start_times[process["pid"]] = process["startTime"] + if len(observed) > MAX_PROC_ENTRIES: + raise RuntimeError("retained process census exceeded its checked bound") + +def require_retained_process_bound(observed, observed_proc_entries): + if len(observed) > observed_proc_entries: + raise RuntimeError("retained process census exceeded the observed process bound") + +def acquire_stable_child_supervisor_census(): + observed_proc_entries, first_processes = child_supervisor_census() + observed_processes = {} + observed_start_times = {} + remember_observed_processes( + observed_processes, + observed_start_times, + first_processes, + ) + require_retained_process_bound(observed_processes, observed_proc_entries) + first_identities = canonical_security_identities(first_processes) + for attempt in range(2, MAX_CENSUS_STABILITY_ATTEMPTS + 1): + next_observed_proc_entries, second_processes = child_supervisor_census() + observed_proc_entries = max(observed_proc_entries, next_observed_proc_entries) + remember_observed_processes( + observed_processes, + observed_start_times, + second_processes, + ) + require_retained_process_bound(observed_processes, observed_proc_entries) + second_identities = canonical_security_identities(second_processes) + if first_identities == second_identities: + return ( + observed_proc_entries, + sorted(second_processes, key=process_identity_key), + sorted(observed_processes.values(), key=process_identity_key), + ) + if attempt == MAX_CENSUS_STABILITY_ATTEMPTS: + raise RuntimeError( + "nemoclaw-start child supervisor census did not stabilize " + f"after {MAX_CENSUS_STABILITY_ATTEMPTS} attempts: " + f"first={json.dumps(diagnostic_census(first_processes), sort_keys=True)} " + f"second={json.dumps(diagnostic_census(second_processes), sort_keys=True)}" + ) + first_processes = second_processes + first_identities = second_identities + raise RuntimeError("nemoclaw-start child supervisor census did not run") + sandbox_user = pwd.getpwnam("sandbox") sandbox_group = grp.getgrnam("sandbox") sandbox_uid = sandbox_user.pw_uid @@ -212,15 +331,14 @@ 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() +( + observed_proc_entries, + child_supervisors, + observed_child_supervisors, +) = acquire_stable_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 @@ -228,16 +346,17 @@ if ( raise RuntimeError("unexpected OpenShell supervisor command") if any( item["executable"] not in SYSTEM_BASH_EXECUTABLES - for item in child_supervisors_first + for item in observed_child_supervisors ): raise RuntimeError("unexpected nemoclaw-start child supervisor executable") print(json.dumps({ - "version": 1, - "observedProcEntries": max(observed_first, observed_second), + "version": 2, + "observedProcEntries": observed_proc_entries, "sandboxUid": sandbox_uid, "sandboxGid": sandbox_gid, "supervisor": supervisor_before, - "childSupervisors": child_supervisors_first, + "childSupervisors": child_supervisors, + "observedChildSupervisors": observed_child_supervisors, }, sort_keys=True))`; function truthy(value: string | undefined): boolean { @@ -503,9 +622,28 @@ function selectNemoclawStartSupervisor( return supervisor; } +function stableProcessIdentityKey(process: ProcessSecurityIdentity): string { + return JSON.stringify({ + argv: process.argv, + executable: process.executable, + pid: process.pid, + ppid: process.ppid, + startTime: process.startTime, + status: process.status, + }); +} + +function processIdentityArray(value: unknown, label: string): ProcessSecurityIdentity[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (value.length > MAX_PROC_ENTRIES) { + throw new Error(`${label} exceeded ${MAX_PROC_ENTRIES} process entries`); + } + return value.map((entry, index) => processIdentity(entry, `${label}[${index}]`)); +} + 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"); + if (report.version !== 2) throw new Error("split-process security report version must be 2"); const observedProcEntries = requiredInteger( report.observedProcEntries, "split-process security report observedProcEntries", @@ -517,24 +655,49 @@ export function validateSplitProcessSecurityReport(value: unknown): SplitProcess 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}]`), + const childSupervisors = processIdentityArray( + report.childSupervisors, + "split-process security report childSupervisors", + ); + const observedChildSupervisors = processIdentityArray( + report.observedChildSupervisors, + "split-process security report observedChildSupervisors", ); + if (observedChildSupervisors.length > observedProcEntries) { + throw new Error( + "split-process security report retained more child supervisors than observed processes", + ); + } validateSupervisor(supervisor, sandboxGid); for (const process of childSupervisors) { validateNemoclawStartProcess(process, sandboxUid, sandboxGid); } selectNemoclawStartSupervisor(childSupervisors); + const observedByPid = new Map(); + const observedIdentityKeys = new Set(); + for (const process of observedChildSupervisors) { + validateNemoclawStartProcess(process, sandboxUid, sandboxGid); + if (observedByPid.has(process.pid)) { + throw new Error(`observed nemoclaw-start process PID ${process.pid} appeared more than once`); + } + observedByPid.set(process.pid, process); + observedIdentityKeys.add(stableProcessIdentityKey(process)); + } + for (const process of childSupervisors) { + if (!observedIdentityKeys.has(stableProcessIdentityKey(process))) { + throw new Error( + `final nemoclaw-start process PID ${process.pid} was absent from the observed census`, + ); + } + } return { childSupervisors, + observedChildSupervisors, observedProcEntries, sandboxGid, sandboxUid, supervisor, - version: 1, + version: 2, }; } diff --git a/test/e2e/support/security-posture.test.ts b/test/e2e/support/security-posture.test.ts index 8ee50fd82de..32d6f092ace 100644 --- a/test/e2e/support/security-posture.test.ts +++ b/test/e2e/support/security-posture.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -33,6 +33,71 @@ 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"; +const CONTROLLED_PROC_HARNESS = String.raw`import contextlib +import grp +import json +import os +import pathlib +import pwd +import sys +import types + +probe, proc_root_arg, censuses_json = sys.argv[1:4] +real_path = type(pathlib.Path()) +real_scandir = os.scandir +real_stat = os.stat +proc_root = real_path(proc_root_arg) +censuses = json.loads(censuses_json) +scan_index = 0 +active_links = set() +stable_targets = {} + +def controlled_path(value="."): + if str(value) == "/proc": + return proc_root + return real_path(value) + +def controlled_scandir(root): + global scan_index + if real_path(root) != proc_root: + return real_scandir(root) + census = censuses[min(scan_index, len(censuses) - 1)] + scan_index += 1 + for link in active_links: + link.unlink(missing_ok=True) + active_links.clear() + stable_targets.clear() + entries = [] + for record in census: + name = str(record["pid"]) + link = proc_root / name + if name != "1": + os.symlink(record["selectedPath"], link, target_is_directory=True) + active_links.add(link) + if record["stablePath"] != record["selectedPath"]: + stable_targets[str(link)] = record["stablePath"] + entries.append(types.SimpleNamespace(name=name, path=str(link))) + return contextlib.nullcontext(iter(entries)) + +def controlled_stat(path, *args, **kwargs): + target = stable_targets.pop(str(path), None) + if target is not None: + link = real_path(path) + link.unlink() + os.symlink(target, link, target_is_directory=True) + return real_stat(path, *args, **kwargs) + +pathlib.Path = controlled_path +os.scandir = controlled_scandir +os.stat = controlled_stat +pwd.getpwnam = lambda _name: types.SimpleNamespace(pw_uid=1000, pw_gid=1000) +grp.getgrnam = lambda _name: types.SimpleNamespace(gr_gid=1000) +exec(compile(probe, "", "exec"), {"__name__": "__main__"})`; + +type ControlledCensus = { + children: ProcessSecurityIdentity[]; + selectedChildren?: ProcessSecurityIdentity[]; +}; type ReportMutationCase = { error: RegExp; @@ -79,7 +144,10 @@ function validNemoclawStartProcess({ } function validReport(): SplitProcessSecurityReport { + const childSupervisor = validNemoclawStartProcess(); return { + childSupervisors: [childSupervisor], + observedChildSupervisors: [structuredClone(childSupervisor)], observedProcEntries: 12, sandboxGid: 1000, sandboxUid: 1000, @@ -102,19 +170,103 @@ function validReport(): SplitProcessSecurityReport { uid: repeatedId(0), }, }, - version: 1, - childSupervisors: [validNemoclawStartProcess()], + version: 2, }; } +function setCurrentChildSupervisors( + report: SplitProcessSecurityReport, + childSupervisors: ProcessSecurityIdentity[], +): void { + report.childSupervisors = childSupervisors; + report.observedChildSupervisors = structuredClone(childSupervisors); +} + +function writeProcProcess(root: string, process: ProcessSecurityIdentity): void { + const processDirectory = path.join(root, String(process.pid)); + mkdirSync(processDirectory, { recursive: true }); + const statFields = [ + process.state, + String(process.ppid), + ...Array.from({ length: 17 }, () => "0"), + process.startTime, + ]; + writeFileSync( + path.join(processDirectory, "stat"), + `${process.pid} (fixture) ${statFields.join(" ")}\n`, + "utf8", + ); + writeFileSync( + path.join(processDirectory, "status"), + [ + `Uid:\t${process.status.uid.join("\t")}`, + `Gid:\t${process.status.gid.join("\t")}`, + `Groups:\t${process.status.groups.join("\t")}`, + `CapInh:\t${process.status.capInh}`, + `CapPrm:\t${process.status.capPrm}`, + `CapEff:\t${process.status.capEff}`, + `CapBnd:\t${process.status.capBnd}`, + `CapAmb:\t${process.status.capAmb}`, + `NoNewPrivs:\t${process.status.noNewPrivs}`, + "", + ].join("\n"), + "utf8", + ); + writeFileSync( + path.join(processDirectory, "cmdline"), + Buffer.from(`${process.argv.join("\0")}\0`, "utf8"), + ); + symlinkSync(process.executable, path.join(processDirectory, "exe")); +} + +function runSplitProcessProbeWithCensuses(censuses: ControlledCensus[]) { + const procRoot = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-security-posture-proc-")); + const report = validReport(); + try { + writeProcProcess(procRoot, report.supervisor); + const controlledCensuses = censuses.map((census, index) => { + const snapshotRoot = path.join(procRoot, "snapshots", String(index)); + const stableRoot = path.join(snapshotRoot, "stable"); + const selectedRoot = path.join(snapshotRoot, "selected"); + const selectedChildren = census.selectedChildren ?? census.children; + const children = census.children.map((stable, childIndex) => { + const selected = selectedChildren[childIndex]!; + writeProcProcess(stableRoot, stable); + writeProcProcess(selectedRoot, selected); + return { + pid: stable.pid, + selectedPath: path.join(selectedRoot, String(stable.pid)), + stablePath: path.join(stableRoot, String(stable.pid)), + }; + }); + const supervisorPath = path.join(procRoot, "1"); + return [{ pid: 1, selectedPath: supervisorPath, stablePath: supervisorPath }, ...children]; + }); + return spawnSync( + "python3", + [ + "-I", + "-c", + CONTROLLED_PROC_HARNESS, + SPLIT_PROCESS_SECURITY_PROBE, + procRoot, + JSON.stringify(controlledCensuses), + ], + { encoding: "utf8", killSignal: "SIGKILL", timeout: 10_000 }, + ); + } finally { + rmSync(procRoot, { force: true, recursive: true }); + } +} + function reportsWithEachNemoclawStartProcessFirst(): SplitProcessSecurityReport[] { const directFirst = validReport(); const descendantFirst = validReport(); const direct = descendantFirst.childSupervisors[0]!; - descendantFirst.childSupervisors = [ + setCurrentChildSupervisors(descendantFirst, [ validNemoclawStartProcess({ pid: 43, ppid: direct.pid, startTime: "203" }), direct, - ]; + ]); return [directFirst, descendantFirst]; } @@ -148,6 +300,176 @@ describe("security posture fixture", () => { expect(compiled.status, compiled.stderr).toBe(0); }); + it("accepts equal split-process censuses with different enumeration order", () => { + const direct = validNemoclawStartProcess(); + const descendant = validNemoclawStartProcess({ pid: 43, ppid: 42, startTime: "203" }); + const result = runSplitProcessProbeWithCensuses([ + { children: [direct, descendant] }, + { children: [descendant, direct] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const report = parseSplitProcessSecurityReport(result.stdout); + expect(report.childSupervisors.map(({ pid }) => pid)).toEqual([42, 43]); + expect(report.observedChildSupervisors.map(({ pid }) => pid)).toEqual([42, 43]); + }); + + it("separates the final stable census from every process observed during acquisition", () => { + const direct = validNemoclawStartProcess(); + const firstDescendant = validNemoclawStartProcess({ + pid: 43, + ppid: 42, + startTime: "203", + }); + const finalDescendant = validNemoclawStartProcess({ + pid: 44, + ppid: 42, + startTime: "204", + }); + const result = runSplitProcessProbeWithCensuses([ + { children: [direct, firstDescendant] }, + { children: [direct, finalDescendant] }, + { children: [finalDescendant, direct] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const report = parseSplitProcessSecurityReport(result.stdout); + expect(report.childSupervisors.map(({ pid }) => pid)).toEqual([42, 44]); + expect(report.observedChildSupervisors.map(({ pid }) => pid)).toEqual([42, 43, 44]); + }); + + it("rejects a privileged process retained from before census stability", () => { + const direct = validNemoclawStartProcess(); + const privileged = validNemoclawStartProcess({ pid: 43, ppid: 42, startTime: "203" }); + privileged.status.capEff = "0000000000000001"; + const finalDescendant = validNemoclawStartProcess({ + pid: 44, + ppid: 42, + startTime: "204", + }); + const result = runSplitProcessProbeWithCensuses([ + { children: [direct, privileged] }, + { children: [direct, finalDescendant] }, + { children: [direct, finalDescendant] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(() => parseSplitProcessSecurityReport(result.stdout)).toThrow( + /nemoclaw-start process\.capEff expected 0/u, + ); + }); + + it("rejects a stable final census with no direct child despite an earlier match", () => { + const result = runSplitProcessProbeWithCensuses([ + { children: [validNemoclawStartProcess()] }, + { children: [] }, + { children: [] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(() => parseSplitProcessSecurityReport(result.stdout)).toThrow( + /expected exactly one direct nemoclaw-start child supervisor, found 0/u, + ); + }); + + it("rejects one process identity that changes across census attempts", () => { + const direct = validNemoclawStartProcess(); + const changed = structuredClone(direct); + changed.status.capEff = "0000000000000001"; + const result = runSplitProcessProbeWithCensuses([ + { children: [direct] }, + { children: [changed] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/identity changed across census attempts.*fields=status/u); + }); + + it("rejects PID reuse during census acquisition", () => { + const result = runSplitProcessProbeWithCensuses([ + { children: [validNemoclawStartProcess({ startTime: "202" })] }, + { children: [validNemoclawStartProcess({ startTime: "303" })] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/PID 42 was reused during census acquisition/u); + }); + + it("rejects a process that changes after census selection without logging its arguments", () => { + const selected = validNemoclawStartProcess(); + const changed = structuredClone(selected); + changed.argv = ["/usr/bin/bash", "credential=DO_NOT_LOG"]; + const result = runSplitProcessProbeWithCensuses([ + { children: [changed], selectedChildren: [selected] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/process changed after census selection/u); + expect(result.stderr).not.toContain("DO_NOT_LOG"); + }); + + it("rejects same-command PID reuse after census selection", () => { + const selected = validNemoclawStartProcess({ startTime: "202" }); + selected.status.capEff = "0000000000000001"; + const reused = validNemoclawStartProcess({ startTime: "303" }); + const result = runSplitProcessProbeWithCensuses([ + { children: [reused], selectedChildren: [selected] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/process changed after census selection/u); + }); + + it("rejects a retained census that grows beyond the observed process bound", () => { + const direct = validNemoclawStartProcess(); + const descendant = (pid: number) => + validNemoclawStartProcess({ pid, ppid: 42, startTime: String(160 + pid) }); + const result = runSplitProcessProbeWithCensuses([ + { children: [direct, descendant(43)] }, + { children: [direct, descendant(44)] }, + { children: [direct, descendant(45)] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/retained process census exceeded the observed process bound/u); + }); + + it("reports both census identities when bounded acquisition does not stabilize", () => { + const direct = validNemoclawStartProcess(); + const firstDescendant = validNemoclawStartProcess({ + pid: 43, + ppid: 42, + startTime: "203", + }); + const secondDescendant = validNemoclawStartProcess({ + pid: 44, + ppid: 42, + startTime: "204", + }); + const result = runSplitProcessProbeWithCensuses([ + { children: [direct, firstDescendant] }, + { children: [direct, secondDescendant] }, + { children: [direct, firstDescendant] }, + { children: [direct, secondDescendant] }, + ]); + + expect(result.error, "python3 is required to run the embedded probe").toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toMatch( + /did not stabilize after 4 attempts: first=.*"pid": 43.*second=.*"pid": 44/su, + ); + expect(result.stderr).not.toMatch(/argv|executable|status/u); + }); + it("keeps isolated Python from importing a sandbox-controlled module", () => { const directory = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-security-posture-python-")); try { @@ -255,8 +577,11 @@ describe("security posture fixture", () => { report.sandboxUid = 998; report.sandboxGid = sandboxGid; report.supervisor.status.groups = ["0", String(sandboxGid)]; - report.childSupervisors = processes.map(({ pid, ppid, startTime }) => - validNemoclawStartProcess({ pid, ppid, sandboxGid, sandboxUid: 998, startTime }), + setCurrentChildSupervisors( + report, + processes.map(({ pid, ppid, startTime }) => + validNemoclawStartProcess({ pid, ppid, sandboxGid, sandboxUid: 998, startTime }), + ), ); expect(validateSplitProcessSecurityReport(report)).toEqual(report); @@ -265,11 +590,11 @@ describe("security posture fixture", () => { it("accepts a nested canonical descendant tree", () => { const report = validReport(); - report.childSupervisors = [ + setCurrentChildSupervisors(report, [ validNemoclawStartProcess({ pid: 44, ppid: 43, startTime: "204" }), report.childSupervisors[0]!, validNemoclawStartProcess({ pid: 43, ppid: 42, startTime: "203" }), - ]; + ]); expect(validateSplitProcessSecurityReport(report)).toEqual(report); }); @@ -558,13 +883,22 @@ describe("security posture fixture", () => { 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, + /version must be 2/u, + ); + expect(() => validateSplitProcessSecurityReport({ ...validReport(), version: 1 })).toThrow( + /version must be 2/u, ); const malformed = { ...validReport(), childSupervisors: "one" }; expect(() => validateSplitProcessSecurityReport(malformed)).toThrow( /childSupervisors must be an array/u, ); + expect(() => + validateSplitProcessSecurityReport({ + ...validReport(), + observedChildSupervisors: "one", + }), + ).toThrow(/observedChildSupervisors must be an array/u); const overflow = validReport(); overflow.observedProcEntries = 32_769; @@ -573,6 +907,45 @@ describe("security posture fixture", () => { ); }); + it("validates historical observations without using them as the final topology", () => { + const report = validReport(); + report.observedChildSupervisors.unshift( + validNemoclawStartProcess({ pid: 41, ppid: 1, startTime: "201" }), + ); + + expect(validateSplitProcessSecurityReport(report)).toEqual(report); + + report.observedChildSupervisors[0]!.status.capEff = "0000000000000001"; + expect(() => validateSplitProcessSecurityReport(report)).toThrow( + /nemoclaw-start process\.capEff expected 0/u, + ); + }); + + it("rejects an incomplete, reused, or over-retained observed census", () => { + const absentFinal = validReport(); + absentFinal.observedChildSupervisors[0]!.ppid = 99; + expect(() => validateSplitProcessSecurityReport(absentFinal)).toThrow( + /final nemoclaw-start process PID 42 was absent from the observed census/u, + ); + + const reusedPid = validReport(); + reusedPid.observedChildSupervisors.push( + validNemoclawStartProcess({ pid: 42, startTime: "303" }), + ); + expect(() => validateSplitProcessSecurityReport(reusedPid)).toThrow( + /observed nemoclaw-start process PID 42 appeared more than once/u, + ); + + const overRetained = validReport(); + overRetained.observedProcEntries = 1; + overRetained.observedChildSupervisors.push( + validNemoclawStartProcess({ pid: 43, ppid: 42, startTime: "203" }), + ); + expect(() => validateSplitProcessSecurityReport(overRetained)).toThrow( + /retained more child supervisors than observed processes/u, + ); + }); + it("selects one exact OpenShell container identity", () => { const row = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; @@ -631,14 +1004,14 @@ describe("security posture fixture", () => { vi.stubEnv("DOCKER_CERT_PATH", "/tmp/untrusted-docker-certs"); const report = validReport(); const directChildSupervisor = report.childSupervisors[0]!; - report.childSupervisors = [ + setCurrentChildSupervisors(report, [ validNemoclawStartProcess({ pid: 43, ppid: directChildSupervisor.pid, startTime: "203", }), directChildSupervisor, - ]; + ]); const containerRow = `${CONTAINER_ID}\t${CONTAINER_NAME}\t${SANDBOX_ID}\tdefault\n`; const command = vi .fn()