Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,7 @@ These flags change defaults for commands that manage existing sandboxes.
|----------|--------|--------|
| `NEMOCLAW_CLEANUP_GATEWAY` | `1`, `true`, or `yes` to enable; `0`, `false`, or `no` to disable | Sets the default for whether `nemoclaw <name> destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. |
| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `nemoclaw <name> connect` and `nemoclaw <name> connect --probe-only`. Use only as a troubleshooting escape hatch. |
| `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | Applies in two cases: (1) sandboxes that were locked before the SHA-256 content seal landed (no `fileHashes` in shields state), and (2) partial seals where the locked file set grew after the existing seal was captured (some entries sealed, some missing). In both cases the existing on-disk bytes for the unsealed files have no independently verified baseline. By default, `shields up` refuses to capture a seal and asks you to rebuild the sandbox for a known-good baseline. Set this to `1` to accept the current bytes as the trusted baseline and let the seal be captured anyway. Once captured, subsequent `shields status` runs detect any future drift. |

## NemoHermes Alias

Expand Down
9 changes: 9 additions & 0 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ For sensitive workloads, use a reviewed host-side immutability workflow after in

- **DAC permissions (default).** The sandbox user owns `/sandbox/.openclaw` with mode `2770` (setgid `sandbox:sandbox`) and `openclaw.json` with mode `660`, so the agent and its group can read and write config directly. A reviewed host-side immutability workflow should compare the intended ownership and mode with the live sandbox filesystem before treating the config tree as locked.
- **Config integrity hash.** The image includes a SHA256 hash of `openclaw.json`. In the default mutable state, `.config-hash` is sandbox-owned and is not a tamper-proof trust anchor, so startup does not fail closed on that hash. When the hash is root-owned and read-only, startup enforces it and refuses to start if the hash does not match.
- **Content seal under shields up.**
When `nemoclaw <name> shields up` runs against a clean lock, it captures a SHA-256 seal of `openclaw.json` and any other locked files into the host-side shields state file.
On sealed sandboxes, every `shields status` call recomputes the hash inside the sandbox and surfaces drift on any mismatch, so a host-root tamper that flips perms back to `444 root:root` after rewriting the file is still flagged.
Sandboxes locked before this seal landed have no recorded hash; perm-only verification cannot prove their bytes match the image-original, so the seal is **not** a retroactive proof of integrity for legacy state.
The same refusal applies to partial seals where the locked file set grew after the existing seal was captured (some entries sealed, some missing).
By default, `shields up` refuses to seal in either case and asks you to rebuild the sandbox first for a known-good baseline.
`shields status` on a legacy lockdown surfaces `UP (UNSEALED — content integrity unknown for legacy lockdown)` and exits with status 2 so scripts treat it as a failure until the operator seals an explicit baseline.
If you explicitly trust the current bytes, opt in via `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1`, which captures a seal over the current files and is acknowledged in the log line.
Once a sandbox is sealed, `shields up` refuses to re-seal a tampered baseline; restore the original file or rebuild the sandbox before re-running.
- **Gateway token environment.** The gateway exports `OPENCLAW_GATEWAY_TOKEN` and writes it to `/tmp/nemoclaw-proxy-env.sh` for interactive sandbox sessions. Keep this in mind when deciding whether a workload should run with mutable config or an immutable config posture.

| Aspect | Detail |
Expand Down
163 changes: 161 additions & 2 deletions src/lib/shields/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,37 @@ describe("shields — unit logic", () => {
);
});

it("rejects state files whose fileHashes entries are not SHA-256 hex strings", async () => {
const sandboxName = "openclaw";
fs.mkdirSync(stateDir(), { recursive: true });
// Hash value is the right length but contains non-hex chars,
// and another value is far too short. Either alone should fail
// the isOptionalHashMap guard.
fs.writeFileSync(
path.join(stateDir(), `shields-${sandboxName}.json`),
JSON.stringify({
shieldsDown: false,
fileHashes: {
"/sandbox/.openclaw/openclaw.json": "not-a-real-hash",
},
updatedAt: new Date().toISOString(),
}),
);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null) => {
throw new Error(`exit ${String(code)}`);
});

const { shieldsStatus } = await loadShieldsModule();
expect(() => shieldsStatus(sandboxName)).toThrow("exit 1");
expect(errorSpy).toHaveBeenCalledWith(
" Shields: ERROR (state file is corrupt)",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("status fails fast on corrupt shields state instead of reporting NOT CONFIGURED", async () => {
const sandboxName = "openclaw";
fs.mkdirSync(stateDir(), { recursive: true });
Expand Down Expand Up @@ -675,14 +706,18 @@ describe("shields — unit logic", () => {
return path.join(tmpDir, ".nemoclaw", "state");
}

function writeLockedState(sandboxName: string): void {
function writeLockedState(
sandboxName: string,
extra: Record<string, unknown> = {},
): void {
fs.mkdirSync(stateDir(), { recursive: true });
fs.writeFileSync(
path.join(stateDir(), `shields-${sandboxName}.json`),
JSON.stringify(
{
shieldsDown: false,
updatedAt: new Date().toISOString(),
...extra,
},
null,
2,
Expand All @@ -691,6 +726,16 @@ describe("shields — unit logic", () => {
);
}

const SEAL_HASH =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

function writeSealedLockedState(sandboxName: string): void {
writeLockedState(sandboxName, {
chattrApplied: true,
fileHashes: { "/sandbox/.openclaw/openclaw.json": SEAL_HASH },
});
}

it("prints DRIFTED with the issue list and exits 2 when the verifier reports drift", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
Expand Down Expand Up @@ -734,7 +779,7 @@ describe("shields — unit logic", () => {

it("prints a clean locked status when the verifier reports no drift", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
writeSealedLockedState(sandboxName);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

Expand All @@ -753,6 +798,120 @@ describe("shields — unit logic", () => {
expect(errorSpy).not.toHaveBeenCalled();
});

it("passes the persisted fileHashes seal to the verifier when present", async () => {
const sandboxName = "openclaw";
const fileHashes = {
"/sandbox/.openclaw/openclaw.json":
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
};
fs.mkdirSync(stateDir(), { recursive: true });
fs.writeFileSync(
path.join(stateDir(), `shields-${sandboxName}.json`),
JSON.stringify(
{
shieldsDown: false,
chattrApplied: true,
fileHashes,
updatedAt: new Date().toISOString(),
},
null,
2,
),
{ mode: 0o600 },
);
let receivedExpectedHashes:
| { [path: string]: string }
| undefined;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const { shieldsStatus } = await loadShieldsModule();
shieldsStatus(sandboxName, true, {
verifyLockState: (
_name: string,
_target: unknown,
options: { expectedHashes?: { [path: string]: string } },
) => {
receivedExpectedHashes = options.expectedHashes;
return { ok: true, issues: [] };
},
resolveConfig: () => ({
agentName: "openclaw",
configPath: "/sandbox/.openclaw/openclaw.json",
configDir: "/sandbox/.openclaw",
}),
});

expect(receivedExpectedHashes).toEqual(fileHashes);
// No legacy-state notice when a seal is recorded.
expect(
logSpy.mock.calls.map((args) => args[0]).join("\n"),
).not.toContain("no content seal recorded");
expect(errorSpy).not.toHaveBeenCalled();
});

it("exits 2 with an UNSEALED line when locked but no fileHashes seal is recorded", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null) => {
throw new Error(`exit ${String(code)}`);
});

const { shieldsStatus } = await loadShieldsModule();
expect(() =>
shieldsStatus(sandboxName, true, {
verifyLockState: () => ({ ok: true, issues: [] }),
resolveConfig: () => ({
agentName: "openclaw",
configPath: "/sandbox/.openclaw/openclaw.json",
configDir: "/sandbox/.openclaw",
}),
}),
).toThrow("exit 2");

const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n");
expect(errors).toContain(
"Shields: UP (UNSEALED — content integrity unknown for legacy lockdown)",
);
expect(errors).toContain(
`or set NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 and re-run \`nemoclaw ${sandboxName} shields up\` to seal the current bytes.`,
);
expect(exitSpy).toHaveBeenCalledWith(2);
});

it("surfaces content-drift entries from the verifier without re-locking", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
const driftIssues = [
"/sandbox/.openclaw/openclaw.json content drifted (sha256 fff... != sealed 012...)",
];
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null) => {
throw new Error(`exit ${String(code)}`);
});

const { shieldsStatus } = await loadShieldsModule();
expect(() =>
shieldsStatus(sandboxName, true, {
verifyLockState: () => ({ ok: false, issues: driftIssues }),
resolveConfig: () => ({
agentName: "openclaw",
configPath: "/sandbox/.openclaw/openclaw.json",
configDir: "/sandbox/.openclaw",
}),
}),
).toThrow("exit 2");

const allErrors = errorSpy.mock.calls.map((args) => args[0]).join("\n");
expect(allErrors).toContain("content drifted");
expect(exitSpy).toHaveBeenCalledWith(2);
});

it("treats a resolveConfig throw as drift so the locked status cannot mask a setup gap", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
Expand Down
Loading
Loading