diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index f3febfd1a94..64fc57c6d2a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1385,6 +1385,10 @@ If that record cannot be cleared and NemoClaw writes the rejection marker, `shie The auto-restore timer and transition remain the recovery authority. If the rejection marker also cannot be written, `shields status` reports the incomplete transition as an error. +If a config path is unsafe, for example a symlink at the Hermes `config.yaml` path, `shields down` refuses that path before it weakens policy, writes a provisional Shields down record, or starts a timer. +The command returns an error and `shields status` remains `UP`. +If an unsafe path appears after the preflight and a provisional Shields down record already exists, the command restores the restrictive policy when it can but keeps the Shields down record until config protection is positively re-verified. This fail-closed behavior also applies when unlock fails after a partial mutation, and requires manual intervention if re-lock cannot be confirmed. + If `shields up` reports that the config remains unlocked or drifted, confirm that the sandbox is running and ready, then retry `$$nemoclaw shields up`. If the retry still fails, rebuild a known-good baseline with `$$nemoclaw rebuild --yes`. diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index c60d36f0811..50743597c1b 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -990,6 +990,12 @@ describe("shields command flow", () => { }, dockerExecFileSync: (argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; + switch (true) { + case args.some((arg) => arg.includes("nemoclaw-shields-down-path-preflight")): + return ""; + default: + break; + } observedPreparingDuringUnlock ||= readOnlyTransition().phase === "preparing"; switch (true) { case args.includes("sha256sum"): diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 3e60ee675f7..c15ce1d8e49 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -800,6 +800,131 @@ function privilegedSandboxExecCapture(sandboxName: string, cmd: string[], timeou ); } +// Reject unsafe config paths before Shields down weakens policy or persists a +// provisional DOWN record. A symlink planted after shields up is refused by the +// unlock path, but without this preflight the provisional DOWN/permissive +// status survives when re-lock also fails on the same path (#8804). +const SHIELDS_DOWN_CONFIG_PATH_PREFLIGHT_SCRIPT = String.raw` +# nemoclaw-shields-down-path-preflight +import errno +import os +import stat +import sys + +def die(message): + sys.stderr.write(message + "\n") + raise SystemExit(1) + +def open_flags(want_dir): + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + if want_dir: + flags |= getattr(os, "O_DIRECTORY", 0) + else: + flags |= getattr(os, "O_NONBLOCK", 0) + return flags + +def open_nofollow(path, want_dir, dir_fd=None): + try: + if dir_fd is None: + return os.open(path, open_flags(want_dir)) + return os.open(path, open_flags(want_dir), dir_fd=dir_fd) + except OSError as exc: + label = path if dir_fd is None else "%s/%s" % (sys.argv[1].rstrip("/"), path) + if exc.errno in (errno.ELOOP, getattr(errno, "ENOTDIR", errno.EINVAL)): + die("refusing symlink path: " + label) + if exc.errno == errno.ENOENT: + die("missing config path: " + label) + die("open failed for %s: %s" % (label, exc)) + +def child_name(config_dir, path): + prefix = config_dir.rstrip("/") + "/" + if not path.startswith(prefix): + die("refusing config path outside config dir: " + path) + name = path[len(prefix):] + if not name or "/" in name or name in (".", ".."): + die("refusing nested or unsafe config path: " + path) + return name + +config_dir = sys.argv[1] +files = sys.argv[2:] +dir_fd = open_nofollow(config_dir, True) +try: + mode = os.fstat(dir_fd).st_mode + if not stat.S_ISDIR(mode): + die("refusing non-directory config path: " + config_dir) + for path in files: + name = child_name(config_dir, path) + fd = open_nofollow(name, False, dir_fd=dir_fd) + try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + die("refusing non-regular config path: " + path) + finally: + os.close(fd) +finally: + os.close(dir_fd) +`; + +function errorStderr(error: unknown): string { + if (!(error instanceof Error) || !("stderr" in error) || error.stderr == null) return ""; + return Buffer.isBuffer(error.stderr) ? error.stderr.toString("utf8") : String(error.stderr); +} + +function errorText(error: unknown): string { + if (!(error instanceof Error)) return String(error); + return `${errorStderr(error)}\n${error.message}`.trim(); +} + +function isUnsafeShieldsConfigPathError(error: unknown): boolean { + const message = errorText(error); + return ( + /refusing (?:to follow )?symlink(?: path)?/i.test(message) || + /refusing non-(?:regular|directory) config path/i.test(message) || + /refusing unsafe(?:-| )(?:config|sealed|Hermes).*path/i.test(message) || + /canonical config path is not a safe regular file/i.test(message) || + /missing config path:/i.test(message) + ); +} + +function assertShieldsDownConfigPathsSafe(sandboxName: string, target: AgentConfigTarget): void { + const files = [target.configPath, ...(target.sensitiveFiles || [])]; + try { + privilegedSandboxExecCapture(sandboxName, [ + "python3", + "-I", + "-c", + SHIELDS_DOWN_CONFIG_PATH_PREFLIGHT_SCRIPT, + target.configDir, + ...files, + ]); + } catch (error) { + const message = errorText(error); + const refusal = message + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => isUnsafeShieldsConfigPathError(line)); + if (refusal) { + throw new Error(refusal); + } + throw new Error(`Unsafe Shields config path check failed: ${message}`); + } +} + +function requireShieldsDownConfigPathsSafe( + sandboxName: string, + target: AgentConfigTarget, + throwOnError?: boolean, +): void { + try { + assertShieldsDownConfigPathsSafe(sandboxName, target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` ERROR: ${message}`); + console.error(" Shields down did not take effect. `shields status` continues to report `UP`."); + failShieldsCommand(message, throwOnError); + } +} + function hermesShieldsGuardArgs( action: string, target: AgentConfigTarget, @@ -4686,6 +4811,12 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = ? "provider-state-mutation-v2" : requireHermesShieldsProtocol(sandboxName, target, opts.allowLegacyHermesProtocol === true); + // Refuse an unsafe config path before timer, host state, or policy mutation. + // Otherwise unlock rejects the path after the provisional DOWN/permissive + // record is already live, and status integrity fails when re-lock cannot + // reseal the same unsafe path (#8804). + requireShieldsDownConfigPathsSafe(sandboxName, target, opts.throwOnError); + // Kill stale auto-restore markers only when this command will actually // transition into shields-down. A repeated shields-down must not cancel the // active timer and leave the sandbox unlocked indefinitely. @@ -4978,25 +5109,44 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = opts.allowLegacyHermesProtocol === true, protocol, ); + if ( + transition && + timerAuthority && + rollback.outcome === "manual_intervention_required" && + !rollback.timerAuthorityRevoked + ) { + try { + assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); + transition = { ...transition, phase: "active" }; + writeShieldsDownTransition(transition, "preparing"); + assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "active"); + } catch (transitionError) { + const transitionMessage = + transitionError instanceof Error ? transitionError.message : String(transitionError); + console.error( + ` CRITICAL: Could not persist the incomplete Shields down posture. Treat the config as mutable and recover it manually. ${transitionMessage}`, + ); + } + } if (transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); } console.error(` ERROR: ${message}`); - const timerAuthority = describeRollbackTimerAuthority( + const timerAuthorityDescription = describeRollbackTimerAuthority( transition !== null, rollback.timerAuthorityRevoked, ); if (rollback.outcome === "mutable_default_restored") { console.error( - ` Config mutation failed; the original mutable-default posture was restored.${timerAuthority}`, + ` Config mutation failed; the original mutable-default posture was restored.${timerAuthorityDescription}`, ); } else if (rollback.outcome === "lockdown_restored") { console.error( - ` Config did not reach the mutable-default state; fail-closed lockdown was restored.${timerAuthority}`, + ` Config did not reach the mutable-default state; fail-closed lockdown was restored.${timerAuthorityDescription}`, ); } else { console.error( - ` Config rollback is incomplete.${timerAuthority} Manual intervention is required.`, + ` Config rollback is incomplete.${timerAuthorityDescription} Manual intervention is required.`, ); } if (inferenceRouteConvergenceFailed) { diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 25fdf1bb168..69d243379ca 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -7,6 +7,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { + createHermesUnsafeConfigHarness, + expectHermesShieldsUpRecord, + failHermesInferenceConvergence, + type HermesUnsafeConfigHarness, +} from "../../../test/helpers/hermes-unsafe-config-shields-harness"; import { createShieldsFlowHarness, type ShieldsFlowHarnessOptions, @@ -767,3 +773,151 @@ describe("OpenClaw shields flow rollback and recovery", () => { ).toBe(true); }); }); + +describe("Hermes Shields down unsafe config path (#8804)", () => { + const harnessFactory = createHermesUnsafeConfigHarness(requireSource, INDEX_MODULE); + let harness: HermesUnsafeConfigHarness; + + beforeEach(() => { + harness = harnessFactory.beforeEachHook(); + }); + + afterEach(() => { + harnessFactory.afterEachHook(); + }); + + it("rejects a Hermes config symlink before Shields down weakens posture (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("preflight-symlink"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), + ).toThrow(/refusing symlink path: .*config\.yaml/); + + expect(harness.runSpy).not.toHaveBeenCalled(); + expect(harness.auditSpy).not.toHaveBeenCalled(); + expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); + }); + + it("rejects a replaced Hermes config directory before Shields down weakens posture (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("preflight-dir-symlink"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), + ).toThrow(/refusing symlink path: .*\.hermes/); + + expect(harness.runSpy).not.toHaveBeenCalled(); + expect(harness.auditSpy).not.toHaveBeenCalled(); + expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); + }); + + it("rejects a missing Hermes config before Shields down weakens posture (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("preflight-missing-config"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { + reason: "missing-config", + throwOnError: true, + }), + ).toThrow(/missing config path: .*config\.yaml/); + + expect(harness.runSpy).not.toHaveBeenCalled(); + expect(harness.auditSpy).not.toHaveBeenCalled(); + expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); + }); + + it("rejects a Hermes sensitive-file symlink before Shields down weakens posture (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("preflight-sensitive-file-symlink"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), + ).toThrow(/refusing symlink path: .*\.env/); + + expect(harness.runSpy).not.toHaveBeenCalled(); + expect(harness.auditSpy).not.toHaveBeenCalled(); + expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); + }); + + it("keeps DOWN when unlock fails and unsafe re-lock cannot verify protection (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("unlock-symlink"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { + reason: "unsafe-path", + timeout: "15m", + throwOnError: true, + }), + ).toThrow(/refusing to follow symlink: \/sandbox\/\.hermes\/config\.yaml/); + + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), + ).toMatchObject({ shieldsDown: true }); + expect(harness.auditSpy).not.toHaveBeenCalled(); + const errors = harness.errorSpy.mock.calls.flat().map(String).join("\n"); + expect(errors).toContain("Manual intervention is required"); + expect(errors).not.toContain("provisional Shields down cleared"); + }); + + it("keeps DOWN when unsafe replacement breaks rollback after mutation begins (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("unlock-partial-rollback-symlink"); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { + reason: "unsafe-path-during-unlock", + timeout: "15m", + throwOnError: true, + }), + ).toThrow(/refusing to follow symlink: \/sandbox\/\.hermes\/config\.yaml/); + + const errors = harness.errorSpy.mock.calls.flat().map(String).join("\n"); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), + ).toMatchObject({ shieldsDown: true }); + expect(harness.shields.isShieldsDown("hermes-shields")).toBe(true); + expect(errors).toContain("Hermes shields rollback preparation failed"); + expect(errors).toContain("Manual intervention is required"); + expect(errors).not.toContain("provisional Shields down cleared"); + }); + + it("keeps DOWN when unlock succeeded and unsafe re-lock cannot verify protection (#8804)", () => { + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("unlock-ok-relock-symlink"); + failHermesInferenceConvergence(requireSource); + + expect(() => + harness.shields.shieldsDown("hermes-shields", { + reason: "unsafe-path-after-unlock", + timeout: "15m", + throwOnError: true, + }), + ).toThrow(/Hermes inference route did not converge/); + + const errors = harness.errorSpy.mock.calls.flat().map(String).join("\n"); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), + ).toMatchObject({ shieldsDown: true }); + expect(errors).toContain("Manual intervention is required"); + expect(errors).not.toContain("provisional Shields down cleared"); + }); +}); diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index efde176a806..ac1033c2dea 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -45,6 +45,8 @@ describe("shields policy transition", () => { const runner = requireSource("../runner.js"); const agentConfig = requireSource("../sandbox/agent-config.js"); + const privilegedExec = requireSource("../sandbox/privileged-exec.js"); + const dockerExec = requireSource("../adapters/docker/exec.js"); vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); runSpy = vi.spyOn(runner, "run").mockReturnValue({ status: 0 }); runCaptureSpy = vi.spyOn(runner, "runCapture").mockImplementation(() => { @@ -66,6 +68,10 @@ describe("shields policy transition", () => { }, stateLockPlanInImage: false, }); + vi.spyOn(privilegedExec, "privilegedSandboxExecArgv").mockImplementation( + (_sandboxName: unknown, cmd: unknown) => cmd as string[], + ); + vi.spyOn(dockerExec, "dockerExecFileSync").mockReturnValue(""); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts new file mode 100644 index 00000000000..3e8a013ad1b --- /dev/null +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { expect, type MockInstance, vi } from "vitest"; + +type RequireSource = NodeJS.Require; + +const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; +const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; +const PATH_PREFLIGHT_MARKER = "nemoclaw-shields-down-path-preflight"; +const LOCK_TOKEN = "a".repeat(64); +const CURRENT_GUARD_HELP = [ + "begin-shields-transition", + "run-state-dir-transition", + "apply-shields-transition", + "finish-shields-transition", + "prepare-shields-abort", + "abort-shields-transition", + "--rollback-shields-mode", + "--state-lock-plan-json", +].join(" "); + +const hermesTarget = { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", + format: "yaml", + configFile: "config.yaml", + sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + stateLockPlan: { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["pairing"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + stateLockPlanInImage: true, +}; + +export type HermesUnsafeConfigScenario = + | "preflight-symlink" + | "preflight-dir-symlink" + | "preflight-missing-config" + | "preflight-sensitive-file-symlink" + | "unlock-symlink" + | "unlock-partial-rollback-symlink" + | "unlock-ok-relock-symlink"; + +type DockerExecImpl = (cmd: string[]) => string; + +function isGuardAction(cmd: string[], action: string): boolean { + const guardIndex = cmd.indexOf(HERMES_GUARD); + return guardIndex >= 0 && cmd[guardIndex + 1] === action; +} + +function isPathPreflight(cmd: string[]): boolean { + const matchesCommand = + cmd[0] === "python3" && + cmd[1] === "-I" && + cmd[2] === "-c" && + cmd[4] === hermesTarget.configDir && + cmd[5] === hermesTarget.configPath; + if (!matchesCommand) return false; + if (typeof cmd[3] !== "string" || !cmd[3].includes(PATH_PREFLIGHT_MARKER)) { + throw new Error(`Expected ${PATH_PREFLIGHT_MARKER} in the Shields path preflight command`); + } + return true; +} + +function shieldsMode(cmd: string[]): string | undefined { + const index = cmd.indexOf("--shields-mode"); + return index >= 0 ? cmd[index + 1] : undefined; +} + +function defaultDockerExec(cmd: string[]): string { + if ( + cmd[0] === HERMES_PYTHON && + cmd.includes("-c") && + cmd.at(-1)?.includes("runtime-state-mutation-publisher") + ) { + return "absent"; + } + if (cmd.includes(HERMES_GUARD) && cmd.includes("--help")) return CURRENT_GUARD_HELP; + if (isGuardAction(cmd, "begin-shields-transition")) { + return `lock_token=${LOCK_TOKEN} original_locked=1`; + } + if (isGuardAction(cmd, "apply-shields-transition")) { + return "shields_mode=mutable chattr_applied=0"; + } + if (cmd[0] === "stat") { + return cmd.at(-1) === "/sandbox/.hermes" ? "3770 sandbox:sandbox" : "640 sandbox:sandbox"; + } + if (cmd[0] === "sha256sum") return `${"b".repeat(64)} ${cmd.at(-1)}`; + if (cmd[0] === "lsattr") return `---------------- ${cmd.at(-1)}`; + return ""; +} + +function runPathPreflight(cmd: string[], configFixtureDir: string): string { + const fixtureArgs = cmd.slice(1, 4).concat( + configFixtureDir, + cmd.slice(5).map((file) => path.join(configFixtureDir, path.basename(file))), + ); + return execFileSync(cmd[0], fixtureArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function wrapScenario( + scenario: HermesUnsafeConfigScenario, + prior: DockerExecImpl, + configFixtureDir: string, +): DockerExecImpl { + const unsafePathError = new Error("refusing to follow symlink: /sandbox/.hermes/config.yaml"); + let stateDirMutationStarted = false; + + return (cmd: string[]) => { + if (isPathPreflight(cmd)) return runPathPreflight(cmd, configFixtureDir); + if (scenario === "unlock-symlink" && isGuardAction(cmd, "begin-shields-transition")) { + throw unsafePathError; + } + if (scenario === "unlock-partial-rollback-symlink") { + if ( + isGuardAction(cmd, "run-state-dir-transition") && + cmd[cmd.indexOf("--state-action") + 1] === "unlock" + ) { + stateDirMutationStarted = true; + } else if ( + stateDirMutationStarted && + (isGuardAction(cmd, "apply-shields-transition") || + (isGuardAction(cmd, "run-state-dir-transition") && + cmd[cmd.indexOf("--state-action") + 1] === "lock") || + (isGuardAction(cmd, "begin-shields-transition") && shieldsMode(cmd) === "locked")) + ) { + throw unsafePathError; + } + } + if ( + scenario === "unlock-ok-relock-symlink" && + isGuardAction(cmd, "begin-shields-transition") && + shieldsMode(cmd) === "locked" + ) { + throw unsafePathError; + } + return prior(cmd); + }; +} + +export type HermesUnsafeConfigHarness = { + auditSpy: MockInstance; + dockerExecSpy: MockInstance; + errorSpy: MockInstance; + homeDir: string; + runSpy: MockInstance; + seedLockedState: (sandboxName: string) => string; + setScenario: (scenario: HermesUnsafeConfigScenario) => void; + shields: typeof import("../../src/lib/shields/index.js"); +}; + +/** Paths are relative to the calling shields `*.test.ts` createRequire root. */ +export function createHermesUnsafeConfigHarness( + requireSource: RequireSource, + indexModule: string, +): { + afterEachHook: () => void; + beforeEachHook: () => HermesUnsafeConfigHarness; +} { + let homeDir = ""; + let shields: HermesUnsafeConfigHarness["shields"]; + let runSpy: MockInstance; + let dockerExecSpy: MockInstance; + let auditSpy: MockInstance; + let errorSpy: MockInstance; + let baseDockerExec: DockerExecImpl = defaultDockerExec; + let configFixtureDir = ""; + + const resetConfigFixture = () => { + configFixtureDir = path.join(homeDir, "sandbox", ".hermes"); + fs.rmSync(configFixtureDir, { recursive: true, force: true }); + fs.mkdirSync(configFixtureDir, { recursive: true }); + fs.writeFileSync(path.join(configFixtureDir, "config.yaml"), "model: test\n"); + fs.writeFileSync(path.join(configFixtureDir, ".env"), "TEST_VALUE=1\n"); + fs.writeFileSync(path.join(configFixtureDir, ".config-hash"), `${"b".repeat(64)}\n`); + }; + + const prepareConfigFixture = (scenario: HermesUnsafeConfigScenario) => { + resetConfigFixture(); + if (scenario === "preflight-symlink") { + const target = path.join(homeDir, "real-config.yaml"); + fs.writeFileSync(target, "model: test\n"); + fs.rmSync(path.join(configFixtureDir, "config.yaml")); + fs.symlinkSync(target, path.join(configFixtureDir, "config.yaml")); + } + if (scenario === "preflight-dir-symlink") { + const replacementDir = path.join(homeDir, "replacement-hermes"); + fs.renameSync(configFixtureDir, replacementDir); + fs.symlinkSync(replacementDir, configFixtureDir, "dir"); + } + if (scenario === "preflight-missing-config") { + fs.rmSync(path.join(configFixtureDir, "config.yaml")); + } + if (scenario === "preflight-sensitive-file-symlink") { + const target = path.join(homeDir, "real-env"); + fs.writeFileSync(target, "TEST_VALUE=1\n"); + fs.rmSync(path.join(configFixtureDir, ".env")); + fs.symlinkSync(target, path.join(configFixtureDir, ".env")); + } + }; + + const seedLockedState = (sandboxName: string): string => { + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ + shieldsDown: false, + chattrApplied: true, + fileHashes: { + "/sandbox/.hermes/config.yaml": "b".repeat(64), + "/sandbox/.hermes/.env": "b".repeat(64), + "/sandbox/.hermes/.config-hash": "b".repeat(64), + }, + updatedAt: "2026-08-11T00:00:00.000Z", + }), + ); + return stateDir; + }; + + const setScenario = (scenario: HermesUnsafeConfigScenario) => { + prepareConfigFixture(scenario); + dockerExecSpy.mockImplementation(wrapScenario(scenario, baseDockerExec, configFixtureDir)); + }; + + return { + beforeEachHook: () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-unsafe-")); + vi.stubEnv("HOME", homeDir); + delete requireSource.cache[requireSource.resolve(indexModule)]; + delete requireSource.cache[requireSource.resolve("./timer-bound-lock.js")]; + delete requireSource.cache[requireSource.resolve("./transition-lock.js")]; + resetConfigFixture(); + + const runner = requireSource("../runner.js"); + const policy = requireSource("../policy/index.js"); + const agentConfig = requireSource("../sandbox/agent-config.js"); + const registry = requireSource("../state/registry.js"); + const privilegedExec = requireSource("../sandbox/privileged-exec.js"); + const dockerExec = requireSource("../adapters/docker/exec.js"); + const stateDirLock = requireSource("./state-dir-lock.js"); + const relockReconfirm = requireSource("./relock-reconfirm.js"); + const audit = requireSource("./audit.js"); + const permissiveRuntime = requireSource("./permissive-runtime.js"); + const tempFiles = requireSource("../onboard/temp-files.js"); + const childProcess = requireSource("node:child_process"); + const timerControl = requireSource("./timer-control.js"); + const fakeTimerPid = 4242; + const permissivePolicyPath = path.join(homeDir, "permissive.yaml"); + fs.writeFileSync(permissivePolicyPath, "version: 1\nnetwork_policies: {}\n", { + mode: 0o600, + }); + + runSpy = vi.spyOn(runner, "run").mockReturnValue({ status: 0 }); + dockerExecSpy = vi.spyOn(dockerExec, "dockerExecFileSync"); + auditSpy = vi.spyOn(audit, "appendAuditEntry").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies:\n test: {}\n"); + vi.spyOn(policy, "buildPolicyGetCommand").mockImplementation((name: unknown) => [ + "policy", + "get", + String(name), + ]); + vi.spyOn(policy, "buildPolicySetCommand").mockImplementation( + (file: unknown, name: unknown) => ["policy", "set", String(file), String(name)], + ); + vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); + vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue(permissivePolicyPath); + vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue(hermesTarget); + vi.spyOn(registry, "getSandbox").mockImplementation((name: unknown) => ({ + name: String(name), + agent: "hermes", + openshellDriver: "docker", + lifecycleGeneration: "legacy-generation", + workload: { kind: "managed-image" }, + })); + vi.spyOn(privilegedExec, "privilegedSandboxExecArgv").mockImplementation( + (_sandboxName: unknown, cmd: unknown) => cmd as string[], + ); + vi.spyOn(stateDirLock, "applyStateDirLockMode").mockReturnValue([]); + vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]); + vi.spyOn(stateDirLock, "restoreStateDirLockPosture").mockReturnValue([]); + vi.spyOn(stateDirLock, "stateLockPlanCompatibilityIssues").mockReturnValue([]); + vi.spyOn(relockReconfirm, "waitForHermesInferenceRouteConvergence").mockReturnValue({ + ok: true, + attempts: 1, + httpStatus: 200, + }); + vi.spyOn(permissiveRuntime, "buildRuntimePermissivePolicy").mockImplementation( + (basePath: unknown) => String(basePath), + ); + vi.spyOn(tempFiles, "cleanupTempDir").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(timerControl, "readProcessStartIdentity").mockImplementation((pid: unknown) => + Number(pid) === fakeTimerPid || Number(pid) === process.pid ? "test-start" : null, + ); + vi.spyOn(timerControl, "isProcessAlive").mockReturnValue(true); + vi.spyOn(timerControl, "verifyTimerMarkerIdentity").mockReturnValue({ verified: true }); + vi.spyOn(childProcess, "fork").mockImplementation((_module: unknown, args: unknown) => { + const sandboxName = String((args as unknown[])[0]); + return { + pid: fakeTimerPid, + disconnect: vi.fn(), + unref: vi.fn(), + kill: vi.fn(() => true), + send: vi.fn((message: unknown) => { + const request = message as { type?: unknown; processToken?: unknown }; + if (request.type === "authorize" && typeof request.processToken === "string") { + const marker = timerControl.readTimerMarker(sandboxName); + if (marker?.timerProcessStartIdentity) { + fs.writeFileSync( + timerControl.timerAuthorizationProofPath(sandboxName, request.processToken), + JSON.stringify({ + schemaVersion: 1, + pid: marker.pid, + sandboxName, + processToken: request.processToken, + timerProcessStartIdentity: marker.timerProcessStartIdentity, + authoritySha256: timerControl.timerAuthoritySha256(marker), + }), + { mode: 0o600 }, + ); + } + } + return true; + }), + } as unknown as ChildProcess; + }); + + baseDockerExec = defaultDockerExec; + dockerExecSpy.mockImplementation(baseDockerExec); + shields = requireSource(indexModule); + + return { + auditSpy, + dockerExecSpy, + errorSpy, + homeDir, + runSpy, + seedLockedState, + setScenario, + shields, + }; + }, + afterEachHook: () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + delete requireSource.cache[requireSource.resolve(indexModule)]; + delete requireSource.cache[requireSource.resolve("./timer-bound-lock.js")]; + delete requireSource.cache[requireSource.resolve("./transition-lock.js")]; + fs.rmSync(homeDir, { recursive: true, force: true }); + }, + }; +} + +export function expectHermesShieldsUpRecord( + stateDir: string, + sandboxName: string, + shields: HermesUnsafeConfigHarness["shields"], +): void { + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf-8")), + ).toMatchObject({ shieldsDown: false, chattrApplied: true }); + expect(fs.existsSync(path.join(stateDir, `shields-timer-${sandboxName}.json`))).toBe(false); + expect(shields.isShieldsDown(sandboxName)).toBe(false); +} + +export function failHermesInferenceConvergence(requireSource: RequireSource): void { + const relockReconfirm = requireSource("./relock-reconfirm.js"); + vi.mocked(relockReconfirm.waitForHermesInferenceRouteConvergence).mockReturnValue({ + ok: false, + attempts: 3, + httpStatus: 503, + }); +}