Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> shields up`.
If the retry still fails, rebuild a known-good baseline with `$$nemoclaw <name> rebuild --yes`.

Expand Down
6 changes: 6 additions & 0 deletions src/lib/shields/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
158 changes: 154 additions & 4 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
154 changes: 154 additions & 0 deletions src/lib/shields/openclaw-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
});
Loading
Loading