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
8fe051d
fix(shields): cross-check sandbox filesystem in shields status
laitingsheng May 27, 2026
0c3b6a4
fix(shields): tighten lock verifier + split into focused module
laitingsheng May 27, 2026
c4062fc
Merge remote-tracking branch 'origin/main' into fix/4243-shields-stat…
laitingsheng May 27, 2026
0edc268
fix(shields): require exec + assertLegacyLayout deps, record lsattr f…
laitingsheng May 27, 2026
337bd08
fix(shields): add tamper E2E, scope drift to DAC, dedup test helpers
laitingsheng May 27, 2026
6b067d2
fix(shields): guard E2E exit codes + scope drift wording to locked state
laitingsheng May 27, 2026
5829c9f
fix(shields): drop errexit toggles in E2E, move drift tests to focuse…
laitingsheng May 27, 2026
d4dcbc1
Merge branch 'main' into fix/4243-shields-status-drift
laitingsheng May 27, 2026
5333c7a
fix(shields,e2e): capture true status exit + clear setgid on restore
laitingsheng May 27, 2026
dd38ddb
revert: roll shields PR files back to 0c3b6a4fa state
laitingsheng May 27, 2026
45a3956
fix(shields): central privileged-exec, drift re-lock, tighten verifier
laitingsheng May 27, 2026
08b1d68
fix(shields): persist chattrApplied + drop kubectl from operator-faci…
laitingsheng May 27, 2026
3a7e852
revert: roll shields PR files back to 0c3b6a4fa state
laitingsheng May 27, 2026
db1fabe
fix(shields): re-lock on drift, persist chattrApplied, use central pr…
laitingsheng May 27, 2026
cb5fb35
Merge branch 'main' into fix/4243-shields-status-drift
cv May 27, 2026
b46a88a
Merge branch 'main' into fix/4243-shields-status-drift
cv May 27, 2026
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: 2 additions & 2 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,13 @@ Writable agent state such as plugins, skills, hooks, and workspace metadata live
By default, this directory starts writable so the agent can manage its own config, install skills, and write to standard home-directory paths natively.
For sensitive workloads, use a reviewed host-side immutability workflow after initial setup so config and writable state entry points cannot be changed by the sandbox user.

- **DAC permissions (default).** The sandbox user owns `/sandbox/.openclaw` with mode `700` and `openclaw.json` with mode `600`, so the agent can read and write config directly.
- **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. `shields status` cross-checks the locked posture against the sandbox filesystem and reports drift when a host-root tamper reverts these perms.
- **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.
- **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 |
|---|---|
| Default | The sandbox keeps `/sandbox/.openclaw` writable (`700 sandbox:sandbox`), sets `openclaw.json` to `600 sandbox:sandbox`, lets the agent manage state directly, and has the gateway place `OPENCLAW_GATEWAY_TOKEN` in `/tmp/nemoclaw-proxy-env.sh` for interactive shells. |
| Default | The sandbox keeps `/sandbox/.openclaw` writable (`2770 sandbox:sandbox`), sets `openclaw.json` to `660 sandbox:sandbox`, lets the agent manage state directly, and has the gateway place `OPENCLAW_GATEWAY_TOKEN` in `/tmp/nemoclaw-proxy-env.sh` for interactive shells. |
| What you can change | Apply a reviewed host-side immutability workflow to lock config and state directories with DAC permissions and the immutable flag where available. |
| Risk of default | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS or redirecting inference to an attacker-controlled endpoint. |
| Recommendation | For always-on assistants handling sensitive workloads, lock config after initial setup. For development workflows, the writable default is appropriate. |
Expand Down
125 changes: 125 additions & 0 deletions src/lib/shields/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,131 @@ describe("shields — unit logic", () => {
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

// -------------------------------------------------------------------
// shieldsStatus: locked-state drift surface
// -------------------------------------------------------------------
describe("shieldsStatus surfaces drift returned by the verifier", () => {
async function loadShieldsModule() {
const distModulePath = path.join(
process.cwd(),
"dist",
"lib",
"shields",
"index.js",
);
return import(distModulePath);
}

function stateDir(): string {
return path.join(tmpDir, ".nemoclaw", "state");
}

function writeLockedState(sandboxName: string): void {
fs.mkdirSync(stateDir(), { recursive: true });
fs.writeFileSync(
path.join(stateDir(), `shields-${sandboxName}.json`),
JSON.stringify(
{
shieldsDown: false,
updatedAt: new Date().toISOString(),
},
null,
2,
),
{ mode: 0o600 },
);
}

it("prints DRIFTED with the issue list and exits 2 when the verifier reports drift", async () => {
const sandboxName = "openclaw";
writeLockedState(sandboxName);
const driftIssues = [
"/sandbox/.openclaw/openclaw.json mode=660 (expected 444)",
"/sandbox/.openclaw/openclaw.json owner=sandbox:sandbox (expected root:root)",
"dir mode=2770 (expected 755)",
"dir owner=sandbox:sandbox (expected root:root)",
];
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");

expect(errorSpy).toHaveBeenCalledWith(
" Shields: UP (DRIFTED — declared locked but sandbox filesystem differs)",
);
expect(errorSpy).toHaveBeenCalledWith(" Drift:");
for (const issue of driftIssues) {
expect(errorSpy).toHaveBeenCalledWith(` - ${issue}`);
}
expect(errorSpy).toHaveBeenCalledWith(
` Recovery: nemoclaw ${sandboxName} shields up # re-lock and re-verify`,
);
expect(exitSpy).toHaveBeenCalledWith(2);
});

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

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

expect(logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)");
expect(logSpy).toHaveBeenCalledWith(" Policy: restrictive");
expect(errorSpy).not.toHaveBeenCalled();
});

it("treats a resolveConfig throw as drift so the locked status cannot mask a setup gap", 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: () => {
throw new Error("agent config not found");
},
}),
).toThrow("exit 2");

const allErrors = errorSpy.mock.calls.map((args) => args[0]).join("\n");
expect(allErrors).toContain(
"unable to resolve agent config target: agent config not found",
);
expect(exitSpy).toHaveBeenCalledWith(2);
});
});
});

// -------------------------------------------------------------------
Expand Down
Loading
Loading