From 4cef3a05d27eeac0cc45333948a872f8eb0a20b4 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 14:30:28 -0700 Subject: [PATCH 01/10] fix(shields): keep UP when Hermes config path is unsafe (#8804) Refuse unsafe Hermes config paths before shields-down weakens policy, and clear a provisional DOWN state if unlock still fails so status stays UP. Signed-off-by: Aarav Sharma --- docs/reference/commands.mdx | 4 + src/lib/shields/index.ts | 131 ++++++++++ src/lib/shields/openclaw-transition.test.ts | 267 ++++++++++++++++++++ 3 files changed, 402 insertions(+) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 9c3f79381aa..d94186643da 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1334,6 +1334,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` returns an error and keeps the sandbox in the Shields up state. +The command refuses that path before it weakens policy, writes a provisional Shields down record, or starts a timer. +If unlock fails on that unsafe path after a provisional Shields down record exists, the command restores the restrictive policy, clears the provisional record and timer, and `shields status` remains `UP`. + 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/index.ts b/src/lib/shields/index.ts index 3e60ee675f7..bb78ad2ca32 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -800,6 +800,101 @@ 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_nofollow(path, 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) + try: + return os.open(path, flags) + except OSError as exc: + if exc.errno in (errno.ELOOP, getattr(errno, "ENOTDIR", errno.EINVAL)): + die("refusing symlink path: " + path) + if exc.errno == errno.ENOENT: + die("missing config path: " + path) + die("open failed for %s: %s" % (path, exc)) + +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) +finally: + os.close(dir_fd) +for path in files: + fd = open_nofollow(path, False) + try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + die("refusing non-regular config path: " + path) + finally: + os.close(fd) +`; + +function errorText(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const stderr = + "stderr" in error && error.stderr != null + ? Buffer.isBuffer(error.stderr) + ? error.stderr.toString("utf8") + : String(error.stderr) + : ""; + return `${error.message}\n${stderr}`.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 hermesShieldsGuardArgs( action: string, target: AgentConfigTarget, @@ -3985,6 +4080,29 @@ function rollbackShieldsDown( console.error( ` Warning: Rollback re-lock could not be re-confirmed. Check config manually. ${relock.error ?? ""}`.trimEnd(), ); + // An unsafe config path (for example a symlink planted after shields up) + // fails unlock and then fails the same re-lock. Restrictive policy is + // already restored. Clearing the provisional DOWN record keeps status + // honest instead of reporting DOWN/permissive for an unlock that never + // completed (#8804 / #8198 status integrity). + if ( + initialMode === "locked" && + relock.error !== undefined && + isUnsafeShieldsConfigPathError(relock.error) + ) { + const timerCancellation = killTimer(sandboxName); + timerAuthorityRevoked = timerCancellation.authorityRevoked; + if (!timerCancellation.authorityRevoked) { + console.error( + ` Warning: Restrictive policy was restored, but auto-restore timer authority could not be revoked: ${timerCancellation.warnings.join("; ")}`, + ); + } + restoreShieldsStateSnapshot(sandboxName, initialState); + console.error( + " Restrictive policy restored and provisional Shields down cleared. The config path remains unsafe; restore a regular config file before retrying.", + ); + return { outcome: "lockdown_restored", timerAuthorityRevoked }; + } } } else { console.error(" Warning: Policy restore failed during rollback."); @@ -4686,6 +4804,19 @@ 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). + 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`."); + return failShieldsCommand(message, 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. diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 25fdf1bb168..3a1d904c927 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -767,3 +767,270 @@ describe("OpenClaw shields flow rollback and recovery", () => { ).toBe(true); }); }); + +describe("Hermes Shields down unsafe config path (#8804)", () => { + const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; + const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; + 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, + }; + + let homeDir: string; + let shields: typeof import("./index.js"); + let runSpy: MockInstance; + let dockerExecSpy: MockInstance; + let auditSpy: MockInstance; + let errorSpy: MockInstance; + + 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 { + return ( + cmd[0] === "python3" && + cmd[1] === "-I" && + cmd[2] === "-c" && + typeof cmd[3] === "string" && + cmd[3].includes("nemoclaw-shields-down-path-preflight") + ); + } + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-unsafe-config-")); + vi.stubEnv("HOME", homeDir); + delete require.cache[requireSource.resolve(INDEX_MODULE)]; + delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; + delete require.cache[requireSource.resolve("./transition-lock.js")]; + + 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; + }), + }; + }); + + dockerExecSpy.mockImplementation((cmd: 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 ""; + }); + + shields = requireSource(INDEX_MODULE); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + delete require.cache[requireSource.resolve(INDEX_MODULE)]; + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + function 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; + } + + it("rejects a Hermes config symlink before Shields down weakens posture (#8804)", () => { + const stateDir = seedLockedState("hermes-shields"); + const prior = dockerExecSpy.getMockImplementation(); + dockerExecSpy.mockImplementation((cmd: string[]) => { + if (isPathPreflight(cmd)) { + throw new Error("refusing symlink path: /sandbox/.hermes/config.yaml"); + } + return prior ? prior(cmd) : ""; + }); + + expect(() => + shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), + ).toThrow(/refusing symlink path: \/sandbox\/\.hermes\/config\.yaml/); + + expect(runSpy).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), + ).toMatchObject({ shieldsDown: false }); + expect(fs.existsSync(path.join(stateDir, "shields-timer-hermes-shields.json"))).toBe(false); + expect(shields.isShieldsDown("hermes-shields")).toBe(false); + expect(shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); + }); + + it("clears provisional DOWN when unlock fails on an unsafe Hermes config symlink (#8804)", () => { + const stateDir = seedLockedState("hermes-shields"); + const unsafePathError = new Error("refusing to follow symlink: /sandbox/.hermes/config.yaml"); + const prior = dockerExecSpy.getMockImplementation(); + dockerExecSpy.mockImplementation((cmd: string[]) => { + if (isPathPreflight(cmd)) return ""; + if (isGuardAction(cmd, "begin-shields-transition")) throw unsafePathError; + return prior ? prior(cmd) : ""; + }); + + expect(() => + 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: false, chattrApplied: true }); + expect(fs.existsSync(path.join(stateDir, "shields-timer-hermes-shields.json"))).toBe(false); + expect(auditSpy).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().map(String).join("\n")).toContain( + "Restrictive policy restored and provisional Shields down cleared", + ); + expect(shields.isShieldsDown("hermes-shields")).toBe(false); + }); +}); From 98000362bab023e2867fbff7075e9d37788e33f0 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 14:48:03 -0700 Subject: [PATCH 02/10] fix(shields): harden unsafe Hermes path rollback (#8804) Only clear provisional DOWN when unlock never completed, open preflight files through a held config dir fd, and cover unlock-then-unsafe re-lock. Signed-off-by: Aarav Sharma --- docs/reference/commands.mdx | 5 +- src/lib/shields/index.ts | 58 +++- src/lib/shields/openclaw-transition.test.ts | 279 +++------------ .../hermes-unsafe-config-shields-harness.ts | 320 ++++++++++++++++++ 4 files changed, 407 insertions(+), 255 deletions(-) create mode 100644 test/helpers/hermes-unsafe-config-shields-harness.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d94186643da..2f9dc6271cb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1334,9 +1334,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` returns an error and keeps the sandbox in the Shields up state. -The command refuses that path before it weakens policy, writes a provisional Shields down record, or starts a timer. +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 unlock fails on that unsafe path after a provisional Shields down record exists, the command restores the restrictive policy, clears the provisional record and timer, and `shields status` remains `UP`. +If unlock already succeeded and a later rollback cannot re-lock because the config path is unsafe, the command restores the restrictive policy when it can, keeps the Shields down record until config protection is verified, and requires manual intervention. 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/index.ts b/src/lib/shields/index.ts index bb78ad2ca32..51e603a90e9 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -815,20 +815,35 @@ def die(message): sys.stderr.write(message + "\n") raise SystemExit(1) -def open_nofollow(path, want_dir): +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: - return os.open(path, flags) + 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: " + path) + die("refusing symlink path: " + label) if exc.errno == errno.ENOENT: - die("missing config path: " + path) - die("open failed for %s: %s" % (path, exc)) + 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:] @@ -837,16 +852,17 @@ 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) -for path in files: - fd = open_nofollow(path, False) - try: - mode = os.fstat(fd).st_mode - if not stat.S_ISREG(mode): - die("refusing non-regular config path: " + path) - finally: - os.close(fd) `; function errorText(error: unknown): string { @@ -4027,6 +4043,7 @@ function rollbackShieldsDown( initialState: LoadedShieldsState, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + configUnlocked = false, ): ShieldsDownRollbackResult { console.error(" Rolling back — restoring policy from snapshot..."); let rollbackResult: ReturnType | null = null; @@ -4080,13 +4097,13 @@ function rollbackShieldsDown( console.error( ` Warning: Rollback re-lock could not be re-confirmed. Check config manually. ${relock.error ?? ""}`.trimEnd(), ); - // An unsafe config path (for example a symlink planted after shields up) - // fails unlock and then fails the same re-lock. Restrictive policy is - // already restored. Clearing the provisional DOWN record keeps status - // honest instead of reporting DOWN/permissive for an unlock that never - // completed (#8804 / #8198 status integrity). + // Unlock never completed, but an unsafe config path also blocks re-lock. + // Restrictive policy is already restored. Clearing the provisional DOWN + // record keeps status honest for an unlock that never happened (#8804). + // After a successful unlock, do not claim UP without a verified re-lock. if ( initialMode === "locked" && + !configUnlocked && relock.error !== undefined && isUnsafeShieldsConfigPathError(relock.error) ) { @@ -4546,6 +4563,7 @@ function failRecoveredHermesShieldsDown( state, allowLegacyHermesProtocol, "provider-state-mutation-v2", + true, ); if (completion.transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, completion.transition.processToken); @@ -5075,6 +5093,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = // is a member of the sandbox group, can mutate runtime config. console.log(` Unlocking ${target.agentName} config (${target.configPath})...`); let inferenceRouteConvergenceFailed = false; + let configUnlocked = false; try { if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); @@ -5086,6 +5105,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = opts.allowLegacyHermesProtocol === true, protocol, ); + configUnlocked = true; if (target.agentName === "hermes") { console.log(" Confirming Hermes inference route after policy transition..."); const convergence = waitForHermesInferenceRouteConvergence(sandboxName, { run }); @@ -5108,6 +5128,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = state, opts.allowLegacyHermesProtocol === true, protocol, + configUnlocked, ); if (transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); @@ -5161,6 +5182,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = state, opts.allowLegacyHermesProtocol === true, protocol, + true, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 3a1d904c927..86a662ac603 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, @@ -769,268 +775,71 @@ describe("OpenClaw shields flow rollback and recovery", () => { }); describe("Hermes Shields down unsafe config path (#8804)", () => { - const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; - const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; - 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, - }; - - let homeDir: string; - let shields: typeof import("./index.js"); - let runSpy: MockInstance; - let dockerExecSpy: MockInstance; - let auditSpy: MockInstance; - let errorSpy: MockInstance; - - 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 { - return ( - cmd[0] === "python3" && - cmd[1] === "-I" && - cmd[2] === "-c" && - typeof cmd[3] === "string" && - cmd[3].includes("nemoclaw-shields-down-path-preflight") - ); - } + const harnessFactory = createHermesUnsafeConfigHarness(requireSource, INDEX_MODULE); + let harness: HermesUnsafeConfigHarness; beforeEach(() => { - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-unsafe-config-")); - vi.stubEnv("HOME", homeDir); - delete require.cache[requireSource.resolve(INDEX_MODULE)]; - delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; - delete require.cache[requireSource.resolve("./transition-lock.js")]; - - 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; - }), - }; - }); - - dockerExecSpy.mockImplementation((cmd: 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 ""; - }); - - shields = requireSource(INDEX_MODULE); + harness = harnessFactory.beforeEachHook(); }); afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - delete require.cache[requireSource.resolve(INDEX_MODULE)]; - fs.rmSync(homeDir, { recursive: true, force: true }); + harnessFactory.afterEachHook(); }); - function 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; - } - it("rejects a Hermes config symlink before Shields down weakens posture (#8804)", () => { - const stateDir = seedLockedState("hermes-shields"); - const prior = dockerExecSpy.getMockImplementation(); - dockerExecSpy.mockImplementation((cmd: string[]) => { - if (isPathPreflight(cmd)) { - throw new Error("refusing symlink path: /sandbox/.hermes/config.yaml"); - } - return prior ? prior(cmd) : ""; - }); + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("preflight-symlink"); expect(() => - shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), + harness.shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), ).toThrow(/refusing symlink path: \/sandbox\/\.hermes\/config\.yaml/); - expect(runSpy).not.toHaveBeenCalled(); - expect(auditSpy).not.toHaveBeenCalled(); - expect( - JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), - ).toMatchObject({ shieldsDown: false }); - expect(fs.existsSync(path.join(stateDir, "shields-timer-hermes-shields.json"))).toBe(false); - expect(shields.isShieldsDown("hermes-shields")).toBe(false); - expect(shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + 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("clears provisional DOWN when unlock fails on an unsafe Hermes config symlink (#8804)", () => { - const stateDir = seedLockedState("hermes-shields"); - const unsafePathError = new Error("refusing to follow symlink: /sandbox/.hermes/config.yaml"); - const prior = dockerExecSpy.getMockImplementation(); - dockerExecSpy.mockImplementation((cmd: string[]) => { - if (isPathPreflight(cmd)) return ""; - if (isGuardAction(cmd, "begin-shields-transition")) throw unsafePathError; - return prior ? prior(cmd) : ""; - }); + const stateDir = harness.seedLockedState("hermes-shields"); + harness.setScenario("unlock-symlink"); expect(() => - shields.shieldsDown("hermes-shields", { + 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: false, chattrApplied: true }); - expect(fs.existsSync(path.join(stateDir, "shields-timer-hermes-shields.json"))).toBe(false); - expect(auditSpy).not.toHaveBeenCalled(); - expect(errorSpy.mock.calls.flat().map(String).join("\n")).toContain( + expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.auditSpy).not.toHaveBeenCalled(); + expect(harness.errorSpy.mock.calls.flat().map(String).join("\n")).toContain( "Restrictive policy restored and provisional Shields down cleared", ); - expect(shields.isShieldsDown("hermes-shields")).toBe(false); + }); + + 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/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts new file mode 100644 index 00000000000..79ffd8fc8c1 --- /dev/null +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { expect, type MockInstance, vi } from "vitest"; + +type RequireSource = { + (id: string): unknown; + cache: NodeJS.Require["cache"]; + resolve: (id: string) => string; +}; + +const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; +const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; +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" + | "unlock-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 { + return ( + cmd[0] === "python3" && + cmd[1] === "-I" && + cmd[2] === "-c" && + typeof cmd[3] === "string" && + cmd[3].includes("nemoclaw-shields-down-path-preflight") + ); +} + +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 wrapScenario(scenario: HermesUnsafeConfigScenario, prior: DockerExecImpl): DockerExecImpl { + const unsafePathError = new Error("refusing to follow symlink: /sandbox/.hermes/config.yaml"); + const preflightError = new Error("refusing symlink path: /sandbox/.hermes/config.yaml"); + + return (cmd: string[]) => { + if (scenario === "preflight-symlink" && isPathPreflight(cmd)) { + throw preflightError; + } + if (scenario !== "preflight-symlink" && isPathPreflight(cmd)) { + return ""; + } + if (scenario === "unlock-symlink" && isGuardAction(cmd, "begin-shields-transition")) { + 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; + + 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) => { + dockerExecSpy.mockImplementation(wrapScenario(scenario, baseDockerExec)); + }; + + return { + beforeEachHook: () => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-unsafe-")); + vi.stubEnv("HOME", homeDir); + delete require.cache[requireSource.resolve(indexModule)]; + delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; + delete require.cache[requireSource.resolve("./transition-lock.js")]; + + 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; + }), + }; + }); + + baseDockerExec = defaultDockerExec; + dockerExecSpy.mockImplementation(baseDockerExec); + shields = requireSource(indexModule); + + return { + auditSpy, + dockerExecSpy, + errorSpy, + homeDir, + runSpy, + seedLockedState, + setScenario, + shields, + }; + }, + afterEachHook: () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + delete require.cache[requireSource.resolve(indexModule)]; + 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.spyOn(relockReconfirm, "waitForHermesInferenceRouteConvergence").mockReturnValue({ + ok: false, + attempts: 3, + httpStatus: 503, + }); +} From c1a179e482a351be44d454c56c4c494caafc9d04 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 14:51:03 -0700 Subject: [PATCH 03/10] fix(shields): type Hermes unsafe-config test harness (#8804) Use NodeJS.Require so the helper typechecks without a createRequire seam. Signed-off-by: Aarav Sharma --- test/helpers/hermes-unsafe-config-shields-harness.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index 79ffd8fc8c1..5a08705b3f6 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -7,11 +7,7 @@ import path from "node:path"; import { expect, type MockInstance, vi } from "vitest"; -type RequireSource = { - (id: string): unknown; - cache: NodeJS.Require["cache"]; - resolve: (id: string) => string; -}; +type RequireSource = NodeJS.Require; const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; From 5b37e7e1a6cc4be225552924ac540ed5ada961cf Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 15:37:54 -0700 Subject: [PATCH 04/10] test(shields): exercise unsafe Hermes paths (#8804) --- src/lib/shields/index.ts | 13 ++- src/lib/shields/openclaw-transition.test.ts | 23 ++++- .../hermes-unsafe-config-shields-harness.ts | 83 ++++++++++++++----- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 51e603a90e9..ba974736a35 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -865,15 +865,14 @@ 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); - const stderr = - "stderr" in error && error.stderr != null - ? Buffer.isBuffer(error.stderr) - ? error.stderr.toString("utf8") - : String(error.stderr) - : ""; - return `${error.message}\n${stderr}`.trim(); + return `${errorStderr(error)}\n${error.message}`.trim(); } function isUnsafeShieldsConfigPathError(error: unknown): boolean { diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 86a662ac603..5e4f00af134 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -792,7 +792,24 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { expect(() => harness.shields.shieldsDown("hermes-shields", { reason: "unsafe-path", throwOnError: true }), - ).toThrow(/refusing symlink path: \/sandbox\/\.hermes\/config\.yaml/); + ).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(); @@ -816,6 +833,10 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { ).toThrow(/refusing to follow symlink: \/sandbox\/\.hermes\/config\.yaml/); expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); + expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ + locked: true, + mutable: false, + }); expect(harness.auditSpy).not.toHaveBeenCalled(); expect(harness.errorSpy.mock.calls.flat().map(String).join("\n")).toContain( "Restrictive policy restored and provisional Shields down cleared", diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index 5a08705b3f6..da3ad57da3b 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -1,6 +1,7 @@ // 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"; @@ -11,6 +12,7 @@ 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", @@ -43,6 +45,7 @@ const hermesTarget = { export type HermesUnsafeConfigScenario = | "preflight-symlink" + | "preflight-dir-symlink" | "unlock-symlink" | "unlock-ok-relock-symlink"; @@ -54,13 +57,17 @@ function isGuardAction(cmd: string[], action: string): boolean { } function isPathPreflight(cmd: string[]): boolean { - return ( + const matchesCommand = cmd[0] === "python3" && cmd[1] === "-I" && cmd[2] === "-c" && - typeof cmd[3] === "string" && - cmd[3].includes("nemoclaw-shields-down-path-preflight") - ); + 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 { @@ -91,17 +98,26 @@ function defaultDockerExec(cmd: string[]): string { return ""; } -function wrapScenario(scenario: HermesUnsafeConfigScenario, prior: DockerExecImpl): DockerExecImpl { +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"); - const preflightError = new Error("refusing symlink path: /sandbox/.hermes/config.yaml"); return (cmd: string[]) => { - if (scenario === "preflight-symlink" && isPathPreflight(cmd)) { - throw preflightError; - } - if (scenario !== "preflight-symlink" && isPathPreflight(cmd)) { - return ""; - } + if (isPathPreflight(cmd)) return runPathPreflight(cmd, configFixtureDir); if (scenario === "unlock-symlink" && isGuardAction(cmd, "begin-shields-transition")) { throw unsafePathError; } @@ -142,6 +158,31 @@ export function createHermesUnsafeConfigHarness( 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"); + } + }; const seedLockedState = (sandboxName: string): string => { const stateDir = path.join(homeDir, ".nemoclaw", "state"); @@ -163,16 +204,18 @@ export function createHermesUnsafeConfigHarness( }; const setScenario = (scenario: HermesUnsafeConfigScenario) => { - dockerExecSpy.mockImplementation(wrapScenario(scenario, baseDockerExec)); + 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 require.cache[requireSource.resolve(indexModule)]; - delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; - delete require.cache[requireSource.resolve("./transition-lock.js")]; + 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"); @@ -267,7 +310,7 @@ export function createHermesUnsafeConfigHarness( } return true; }), - }; + } as unknown as ChildProcess; }); baseDockerExec = defaultDockerExec; @@ -288,7 +331,9 @@ export function createHermesUnsafeConfigHarness( afterEachHook: () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - delete require.cache[requireSource.resolve(indexModule)]; + 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 }); }, }; @@ -308,7 +353,7 @@ export function expectHermesShieldsUpRecord( export function failHermesInferenceConvergence(requireSource: RequireSource): void { const relockReconfirm = requireSource("./relock-reconfirm.js"); - vi.spyOn(relockReconfirm, "waitForHermesInferenceRouteConvergence").mockReturnValue({ + vi.mocked(relockReconfirm.waitForHermesInferenceRouteConvergence).mockReturnValue({ ok: false, attempts: 3, httpStatus: 503, From 108969687dcca8cf706361c38f8be51f93881452 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 16:01:42 -0700 Subject: [PATCH 05/10] test(shields): cover unsafe Hermes sensitive files (#8804) --- src/lib/shields/openclaw-transition.test.ts | 17 +++++++++++++++++ .../hermes-unsafe-config-shields-harness.ts | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 5e4f00af134..8c6ebd0b814 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -820,6 +820,23 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { }); }); + 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("clears provisional DOWN when unlock fails on an unsafe Hermes config symlink (#8804)", () => { const stateDir = harness.seedLockedState("hermes-shields"); harness.setScenario("unlock-symlink"); diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index da3ad57da3b..e54b52c094a 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -46,6 +46,7 @@ const hermesTarget = { export type HermesUnsafeConfigScenario = | "preflight-symlink" | "preflight-dir-symlink" + | "preflight-sensitive-file-symlink" | "unlock-symlink" | "unlock-ok-relock-symlink"; @@ -182,6 +183,12 @@ export function createHermesUnsafeConfigHarness( fs.renameSync(configFixtureDir, replacementDir); fs.symlinkSync(replacementDir, configFixtureDir, "dir"); } + 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 => { From d0678931d0666e5c8ef96b3dbdee61d38c4a7e71 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 11 Aug 2026 23:35:08 -0700 Subject: [PATCH 06/10] fix(shields): require verified rollback posture (#8804) Signed-off-by: Aarav Sharma --- docs/reference/commands.mdx | 3 +- src/lib/shields/index.ts | 56 ++++++++----------- src/lib/shields/openclaw-transition.test.ts | 38 ++++++++++--- .../hermes-unsafe-config-shields-harness.ts | 18 ++++++ 4 files changed, 71 insertions(+), 44 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 2f9dc6271cb..0b3e90b0a91 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1336,8 +1336,7 @@ If the rejection marker also cannot be written, `shields status` reports the inc 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 unlock fails on that unsafe path after a provisional Shields down record exists, the command restores the restrictive policy, clears the provisional record and timer, and `shields status` remains `UP`. -If unlock already succeeded and a later rollback cannot re-lock because the config path is unsafe, the command restores the restrictive policy when it can, keeps the Shields down record until config protection is verified, and requires manual intervention. +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/index.ts b/src/lib/shields/index.ts index ba974736a35..28256eb0f69 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -4042,7 +4042,6 @@ function rollbackShieldsDown( initialState: LoadedShieldsState, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, - configUnlocked = false, ): ShieldsDownRollbackResult { console.error(" Rolling back — restoring policy from snapshot..."); let rollbackResult: ReturnType | null = null; @@ -4096,29 +4095,6 @@ function rollbackShieldsDown( console.error( ` Warning: Rollback re-lock could not be re-confirmed. Check config manually. ${relock.error ?? ""}`.trimEnd(), ); - // Unlock never completed, but an unsafe config path also blocks re-lock. - // Restrictive policy is already restored. Clearing the provisional DOWN - // record keeps status honest for an unlock that never happened (#8804). - // After a successful unlock, do not claim UP without a verified re-lock. - if ( - initialMode === "locked" && - !configUnlocked && - relock.error !== undefined && - isUnsafeShieldsConfigPathError(relock.error) - ) { - const timerCancellation = killTimer(sandboxName); - timerAuthorityRevoked = timerCancellation.authorityRevoked; - if (!timerCancellation.authorityRevoked) { - console.error( - ` Warning: Restrictive policy was restored, but auto-restore timer authority could not be revoked: ${timerCancellation.warnings.join("; ")}`, - ); - } - restoreShieldsStateSnapshot(sandboxName, initialState); - console.error( - " Restrictive policy restored and provisional Shields down cleared. The config path remains unsafe; restore a regular config file before retrying.", - ); - return { outcome: "lockdown_restored", timerAuthorityRevoked }; - } } } else { console.error(" Warning: Policy restore failed during rollback."); @@ -4562,7 +4538,6 @@ function failRecoveredHermesShieldsDown( state, allowLegacyHermesProtocol, "provider-state-mutation-v2", - true, ); if (completion.transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, completion.transition.processToken); @@ -5092,7 +5067,6 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = // is a member of the sandbox group, can mutate runtime config. console.log(` Unlocking ${target.agentName} config (${target.configPath})...`); let inferenceRouteConvergenceFailed = false; - let configUnlocked = false; try { if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); @@ -5104,7 +5078,6 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = opts.allowLegacyHermesProtocol === true, protocol, ); - configUnlocked = true; if (target.agentName === "hermes") { console.log(" Confirming Hermes inference route after policy transition..."); const convergence = waitForHermesInferenceRouteConvergence(sandboxName, { run }); @@ -5127,27 +5100,45 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = state, opts.allowLegacyHermesProtocol === true, protocol, - configUnlocked, ); + 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) { @@ -5181,7 +5172,6 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = state, opts.allowLegacyHermesProtocol === true, protocol, - true, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 8c6ebd0b814..79ad9c77d9f 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -837,7 +837,7 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { }); }); - it("clears provisional DOWN when unlock fails on an unsafe Hermes config symlink (#8804)", () => { + it("keeps DOWN when unlock fails and unsafe re-lock cannot verify protection (#8804)", () => { const stateDir = harness.seedLockedState("hermes-shields"); harness.setScenario("unlock-symlink"); @@ -849,15 +849,35 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { }), ).toThrow(/refusing to follow symlink: \/sandbox\/\.hermes\/config\.yaml/); - expectHermesShieldsUpRecord(stateDir, "hermes-shields", harness.shields); - expect(harness.shields.getShieldsPosture("hermes-shields", false)).toMatchObject({ - locked: true, - mutable: false, - }); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-hermes-shields.json"), "utf-8")), + ).toMatchObject({ shieldsDown: true }); expect(harness.auditSpy).not.toHaveBeenCalled(); - expect(harness.errorSpy.mock.calls.flat().map(String).join("\n")).toContain( - "Restrictive policy restored and provisional Shields down cleared", - ); + 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)", () => { diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index e54b52c094a..7a2aa8becc3 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -48,6 +48,7 @@ export type HermesUnsafeConfigScenario = | "preflight-dir-symlink" | "preflight-sensitive-file-symlink" | "unlock-symlink" + | "unlock-partial-rollback-symlink" | "unlock-ok-relock-symlink"; type DockerExecImpl = (cmd: string[]) => string; @@ -116,12 +117,29 @@ function wrapScenario( 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") && From 8d5c287d58d46ab81cc497f927d1b412fb4f33ec Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 12:19:18 -0700 Subject: [PATCH 07/10] test(shields): cover missing Hermes config Signed-off-by: Apurv Kumaria --- src/lib/shields/openclaw-transition.test.ts | 20 +++++++++++++++++++ .../hermes-unsafe-config-shields-harness.ts | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 79ad9c77d9f..69d243379ca 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -820,6 +820,26 @@ describe("Hermes Shields down unsafe config path (#8804)", () => { }); }); + 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"); diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index 7a2aa8becc3..3e8a013ad1b 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -46,6 +46,7 @@ const hermesTarget = { export type HermesUnsafeConfigScenario = | "preflight-symlink" | "preflight-dir-symlink" + | "preflight-missing-config" | "preflight-sensitive-file-symlink" | "unlock-symlink" | "unlock-partial-rollback-symlink" @@ -201,6 +202,9 @@ export function createHermesUnsafeConfigHarness( 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"); From 4b74b8f1580c8d29fb2281f4c338010502d7c472 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 13:29:23 -0700 Subject: [PATCH 08/10] test(shields): preserve preflight transition coverage Signed-off-by: Apurv Kumaria --- src/lib/shields/flow.test.ts | 3 +++ src/lib/shields/policy-transition.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index c60d36f0811..983b8adf0fd 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -990,6 +990,9 @@ describe("shields command flow", () => { }, dockerExecFileSync: (argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; + if (args.some((arg) => arg.includes("nemoclaw-shields-down-path-preflight"))) { + return ""; + } observedPreparingDuringUnlock ||= readOnlyTransition().phase === "preparing"; switch (true) { case args.includes("sha256sum"): 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); From 006f1970e65fb91ac03284ebf11b71ed6383f231 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 13:35:58 -0700 Subject: [PATCH 09/10] test(shields): keep preflight fixture linear Signed-off-by: Apurv Kumaria --- src/lib/shields/flow.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 983b8adf0fd..50743597c1b 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -990,8 +990,11 @@ describe("shields command flow", () => { }, dockerExecFileSync: (argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; - if (args.some((arg) => arg.includes("nemoclaw-shields-down-path-preflight"))) { - return ""; + switch (true) { + case args.some((arg) => arg.includes("nemoclaw-shields-down-path-preflight")): + return ""; + default: + break; } observedPreparingDuringUnlock ||= readOnlyTransition().phase === "preparing"; switch (true) { From da7e90135b421ef57339c118760982271468e811 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 14:11:16 -0700 Subject: [PATCH 10/10] refactor(shields): isolate unsafe path failure Signed-off-by: Apurv Kumaria --- src/lib/shields/index.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 28256eb0f69..c15ce1d8e49 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -910,6 +910,21 @@ function assertShieldsDownConfigPathsSafe(sandboxName: string, target: AgentConf } } +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, @@ -4800,14 +4815,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = // 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). - 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`."); - return failShieldsCommand(message, opts.throwOnError); - } + 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