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
2 changes: 1 addition & 1 deletion docs/security/tcb-boundary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ A successful build does not replace review of privilege, process identity, descr
| `scripts/lib/normalize_mutable_config_perms.py` | The installed copy is root-owned and mode `0555`; startup invokes it under the entrypoint identity, and only root can reclaim a root-owned tree. | The fixed OpenClaw config path, the resolved sandbox identity, and an exact `root:root 0700/0600` mutable-drift signature under the expected sandbox-owned parent. | Restores the mutable `2770/660` contract, pins every privileged handoff by descriptor, and rejects ambiguous posture, links, mount substitution, metadata races, and sealed config. |
| `scripts/openclaw-config-guard.py` | The installed copy is root-owned and mode `0500`; direct root PID 1 or the authenticated host transaction invokes it. | Bounded strict JSON for writes, stable captured config bytes for restart validation, and fixed installed parser paths for existing JSON5 config. | Seals and unseals OpenClaw config with no-follow descriptors, stable inode checks, atomic replacement, hash coherence, and recoverable transaction journals. |
| `scripts/managed-gateway-control.py` | The installed copy is root-owned and mode `0500`; the host invokes it through sanitized registry-scoped direct-container execution. | A fixed action, a 64-character nonce, fixed installed helpers, and a live OpenShell process tree observed through `/proc`. | Authenticates the host action, proves the managed supervisor and gateway identity, holds a root-owned mode `0600` lifecycle lock, publishes one root-owned mode `0444` exact-exit authorization bound to the gateway and live root controller identities, signals through a pidfd, waits for the normal respawn loop, and verifies listener and HTTP health. |
| `src/lib/shields/transition-lock.ts` | Runs in the host CLI under the operator account and owns the canonical per-sandbox transition lock. | Host state directory entries whose owner PID and start identity match the live lock owner. | Serializes shields mutations, rejects ambiguous or reused owners, and allows takeover only through the explicit recovery contract. |
| `src/lib/shields/transition-lock.ts` | Runs in the host CLI under the operator account and owns the canonical per-sandbox transition lock. | Host state directory entries whose owner PID and start identity match the live lock owner, or prove that the recorded owner is definitively dead or PID-reused. | Serializes shields mutations, recovers definitively stale owners through inode-checked quarantine, rejects ambiguous owners, and allows token-gated takeover only through the explicit recovery contract. |
| `src/lib/shields/timer-bound-lock.ts` | Runs in the host CLI and composes the transition lock with the recorded auto-restore generation. | A validated timer marker and transition owner from the host state directory. | Prevents an expired or replaced timer from authorizing a later mutation and keeps restore authority bound to one generation. |
| `src/lib/shields/verify-lock.ts` | Runs in the host CLI and delegates sandbox inspection through the privileged execution adapter. | Resolved built-in agent paths and the expected locked posture recorded by the host. | Verifies modes, ownership, immutable flags, layout, and recorded content hashes before NemoClaw reports shields as locked. |
| `agents/hermes/runtime-config-guard.py` | The installed copy is root-owned; its privileged actions require direct startup authority, a root-owned readiness lease, or the narrowly proven OpenShell-managed startup shape. | Fixed Hermes paths, bounded actions, stable descriptor snapshots, a transaction token, and authenticated startup or host authority. | Enforces the Hermes secret boundary, config and hash transactions, restart seals, shields transitions, rollback, and stable state-directory posture. |
Expand Down
111 changes: 66 additions & 45 deletions src/lib/shields/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execFileSync as nodeExecFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -70,7 +71,7 @@ vi.mock("child_process", () => ({
}));

vi.mock("node:child_process", () => ({
execFileSync: vi.fn(() => ""),
execFileSync: vi.fn(),
spawnSync: vi.fn(() => ({
status: 0,
stdout: "",
Expand All @@ -81,11 +82,33 @@ vi.mock("node:child_process", () => ({

let tmpDir: string;

type NodeExecFileSyncMock = ReturnType<typeof vi.fn>;

function defaultNodeExecFileSync(file: string, argv?: readonly string[]): string {
const args = Array.isArray(argv) ? argv : [];
return file === "ps" && args.includes("lstart=") ? "Mon Jan 01 00:00:00 2026" : "";
}

function setNodeExecFileSyncMock(
implementation: (file: string, argv?: readonly string[]) => string = defaultNodeExecFileSync,
): void {
(nodeExecFileSync as unknown as NodeExecFileSyncMock).mockImplementation(implementation);
}

function withDefaultNodeExecFileSync(
file: string,
argv: readonly string[] | undefined,
fallback: () => string,
): string {
return defaultNodeExecFileSync(file, argv) || fallback();
}

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-test-"));
vi.stubEnv("HOME", tmpDir);
vi.resetModules();
vi.clearAllMocks();
setNodeExecFileSyncMock();
});

afterEach(() => {
Expand Down Expand Up @@ -427,28 +450,27 @@ describe("shields — unit logic", () => {
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const dockerExecFileSync = (await import("node:child_process")).execFileSync as ReturnType<
typeof vi.fn
>;
dockerExecFileSync.mockImplementation((_file: string, argv?: readonly string[]) => {
const cmd = Array.isArray(argv) ? argv.join(" ") : "";
if (cmd.includes(` stat -c %a %U:%G ${hashPath}`)) {
return "444 root:root";
}
if (cmd.includes(` stat -c %a %U:%G ${configPath}`)) {
return "444 root:root";
}
if (cmd.includes(` lsattr -d ${hashPath}`)) {
return `----i---------e----- ${hashPath}`;
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw")) {
return "755 root:root";
}
if (cmd.includes(` lsattr -d ${configPath}`)) {
return `----i---------e----- ${configPath}`;
}
return "";
});
setNodeExecFileSyncMock((_file: string, argv?: readonly string[]) =>
withDefaultNodeExecFileSync(_file, argv, () => {
const cmd = Array.isArray(argv) ? argv.join(" ") : "";
if (cmd.includes(` stat -c %a %U:%G ${hashPath}`)) {
return "444 root:root";
}
if (cmd.includes(` stat -c %a %U:%G ${configPath}`)) {
return "444 root:root";
}
if (cmd.includes(` lsattr -d ${hashPath}`)) {
return `----i---------e----- ${hashPath}`;
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw")) {
return "755 root:root";
}
if (cmd.includes(` lsattr -d ${configPath}`)) {
return `----i---------e----- ${configPath}`;
}
return "";
}),
);

const { shieldsStatus } = await loadShieldsModule();

Expand Down Expand Up @@ -548,28 +570,27 @@ describe("shields — unit logic", () => {

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const dockerExecFileSync = (await import("node:child_process")).execFileSync as ReturnType<
typeof vi.fn
>;
dockerExecFileSync.mockImplementation((_file: string, argv?: readonly string[]) => {
const cmd = Array.isArray(argv) ? argv.join(" ") : "";
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw/.config-hash")) {
return "444 root:root";
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw/openclaw.json")) {
return "444 root:root";
}
if (cmd.includes(" lsattr -d /sandbox/.openclaw/.config-hash")) {
return "----i---------e----- /sandbox/.openclaw/.config-hash";
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw")) {
return "755 root:root";
}
if (cmd.includes(" lsattr -d /sandbox/.openclaw/openclaw.json")) {
return "----i---------e----- /sandbox/.openclaw/openclaw.json";
}
return "";
});
setNodeExecFileSyncMock((_file: string, argv?: readonly string[]) =>
withDefaultNodeExecFileSync(_file, argv, () => {
const cmd = Array.isArray(argv) ? argv.join(" ") : "";
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw/.config-hash")) {
return "444 root:root";
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw/openclaw.json")) {
return "444 root:root";
}
if (cmd.includes(" lsattr -d /sandbox/.openclaw/.config-hash")) {
return "----i---------e----- /sandbox/.openclaw/.config-hash";
}
if (cmd.includes(" stat -c %a %U:%G /sandbox/.openclaw")) {
return "755 root:root";
}
if (cmd.includes(" lsattr -d /sandbox/.openclaw/openclaw.json")) {
return "----i---------e----- /sandbox/.openclaw/openclaw.json";
}
return "";
}),
);

const { shieldsStatus } = await loadShieldsModule();
shieldsStatus(sandboxName);
Expand Down
Loading
Loading