diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index dd2b75569c..74f0047a0d 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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. | diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 454e7a3a23..4e0ea6ed63 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -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); + }); + }); }); // ------------------------------------------------------------------- diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index fdca7e5161..7c27a46506 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -16,6 +16,9 @@ const { fork } = require("child_process"); const { randomBytes } = require("crypto"); const { run, runCapture, validateName } = require("../runner"); const { dockerExecFileSync } = require("../adapters/docker/exec"); +const { + privilegedSandboxExecArgv, +}: typeof import("../sandbox/privileged-exec") = require("../sandbox/privileged-exec"); const { buildPolicyGetCommand, buildPolicySetCommand, @@ -38,13 +41,13 @@ const { const { resolveNemoclawStateDir } = require("../state/paths"); const { appendAuditEntry } = require("./audit"); const { resolveAgentConfig } = require("../sandbox/config"); -const { - privilegedSandboxExecArgv, -}: typeof import("../sandbox/privileged-exec") = require("../sandbox/privileged-exec"); const { buildRuntimePermissivePolicy, }: typeof import("./permissive-runtime") = require("./permissive-runtime"); const { cleanupTempDir } = require("../onboard/temp-files"); +const { + verifyShieldsLockState, +}: typeof import("./verify-lock") = require("./verify-lock"); const STATE_DIR = resolveNemoclawStateDir(); @@ -52,10 +55,10 @@ const STATE_DIR = resolveNemoclawStateDir(); // privileged sandbox exec — bypasses the sandbox's Landlock context // // openshell sandbox exec runs commands INSIDE the Landlock domain, so it -// can't modify read_only paths or change chattr flags. Privileged Docker exec -// starts a new root process that does NOT inherit the Landlock ruleset. -// OpenShell gateways expose sandboxes as Docker containers, so we exec into -// the sandbox container directly as root. +// can't modify read_only paths or change chattr flags. We delegate the +// argv shape to the central registry-scoped helper in +// src/lib/sandbox/privileged-exec.ts, which fails closed when no matching +// sandbox container is running. // --------------------------------------------------------------------------- function privilegedSandboxExec(sandboxName: string, cmd: string[]): void { @@ -101,6 +104,7 @@ interface ShieldsState { shieldsDownReason?: string | null; shieldsDownPolicy?: string | null; shieldsPolicySnapshotPath?: string | null; + chattrApplied?: boolean; updatedAt?: string; } @@ -287,6 +291,7 @@ function isShieldsState(value: unknown): value is ShieldsState { isOptionalNullableString(value.shieldsDownReason) && isOptionalNullableString(value.shieldsDownPolicy) && isOptionalNullableString(value.shieldsPolicySnapshotPath) && + isOptionalBoolean(value.chattrApplied) && isOptionalString(value.updatedAt) ); } @@ -445,7 +450,7 @@ function assertNoLegacyStateLayout( // user and the gateway UID can write the mutable config tree. Hermes keeps its // tighter single-user layout. // -// Note on chattr: best-effort — it may silently fail if privileged exec +// Note on chattr: best-effort — it may silently fail if kubectl exec // lacks CAP_LINUX_IMMUTABLE or if the file was never immutable. That's fine: // the file becomes writable through the permissive policy (disables Landlock // read_only) + chown/chmod below. @@ -579,7 +584,7 @@ function unlockAgentConfig( // 2. UNIX permissions — 444 root:root (mandatory, verified here) // 3. chattr +i immutable bit — defense-in-depth (best-effort) // -// Layer 3 is best-effort because privileged exec may lack +// Layer 3 is best-effort because kubectl exec may lack // CAP_LINUX_IMMUTABLE. Layers 1+2 are sufficient. We still attempt it // in case the runtime environment supports it. // --------------------------------------------------------------------------- @@ -587,7 +592,7 @@ function unlockAgentConfig( function lockAgentConfig( sandboxName: string, target: AgentConfigTarget, -): void { +): { chattrApplied: boolean } { const errors: string[] = []; const filesToLock = [target.configPath, ...(target.sensitiveFiles || [])]; @@ -620,7 +625,7 @@ function lockAgentConfig( errors.push("chown root:root config dir"); } - // Best-effort: privileged exec may lack CAP_LINUX_IMMUTABLE. Track the + // Best-effort: kubectl exec may lack CAP_LINUX_IMMUTABLE. Track the // result so verification doesn't require something that was never there. let chattrSucceeded = true; for (const f of filesToLock) { @@ -657,70 +662,17 @@ function lockAgentConfig( // Verify the lock actually took effect. // Mode + ownership are mandatory (layers 1+2 depend on them). // Immutable bit is only verified if chattr succeeded above. - const issues: string[] = []; - for (const f of filesToLock) { - try { - const perms = privilegedSandboxExecCapture(sandboxName, [ - "stat", - "-c", - "%a %U:%G", - f, - ]); - const [mode, owner] = perms.split(" "); - if (!/^4[0-4][0-4]$/.test(mode)) - issues.push(`${f} mode=${mode} (expected 444)`); - if (owner !== "root:root") - issues.push(`${f} owner=${owner} (expected root:root)`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - issues.push(`${f} stat failed: ${msg}`); - } - } - - try { - const dirPerms = privilegedSandboxExecCapture(sandboxName, [ - "stat", - "-c", - "%a %U:%G", - target.configDir, - ]); - const [dirMode, dirOwner] = dirPerms.split(" "); - if (dirMode !== "755") issues.push(`dir mode=${dirMode} (expected 755)`); - if (dirOwner !== "root:root") - issues.push(`dir owner=${dirOwner} (expected root:root)`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - issues.push(`dir stat failed: ${msg}`); - } - - if (chattrSucceeded) { - for (const f of filesToLock) { - try { - const attrs = privilegedSandboxExecCapture(sandboxName, [ - "lsattr", - "-d", - f, - ]); - // lsattr format: "----i---------e----- /path/to/file" - // First whitespace-delimited token is the flags field. - const [flags] = attrs.trim().split(/\s+/, 1); - if (!flags.includes("i")) issues.push(`${f} immutable bit not set`); - } catch { - // lsattr may not be available on all images — skip - } - } - } - - try { - assertNoLegacyStateLayout(sandboxName, target.configDir); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - issues.push(msg); - } + const { issues } = verifyShieldsLockState(sandboxName, target, { + verifyChattr: chattrSucceeded, + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + }); if (issues.length > 0) { throw new Error(`Config not locked: ${issues.join(", ")}`); } + + return { chattrApplied: chattrSucceeded }; } function rollbackShieldsDown( @@ -732,11 +684,11 @@ function rollbackShieldsDown( const rollbackResult = run(buildPolicySetCommand(snapshotPath, sandboxName), { ignoreError: true, }); - let rollbackLocked = false; + let rollbackChattrApplied: boolean | null = null; if (rollbackResult.status === 0) { try { - lockAgentConfig(sandboxName, target); - rollbackLocked = true; + const lockResult = lockAgentConfig(sandboxName, target); + rollbackChattrApplied = lockResult.chattrApplied; } catch { console.error( " Warning: Rollback re-lock could not be verified. Check config manually.", @@ -745,19 +697,20 @@ function rollbackShieldsDown( } else { console.error(" Warning: Policy restore failed during rollback."); } - if (rollbackLocked) { + if (rollbackChattrApplied !== null) { saveShieldsState(sandboxName, { shieldsDown: false, shieldsDownAt: null, shieldsDownTimeout: null, shieldsDownReason: null, shieldsDownPolicy: null, + chattrApplied: rollbackChattrApplied, }); console.error(" Lockdown restored. Config was never left unguarded."); } else { console.error(" Config remains unlocked — manual intervention required."); console.error( - ` Restore sandbox access, then run: nemoclaw ${sandboxName} shields up`, + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); } } @@ -1020,7 +973,7 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { // 4. Start auto-restore timer (detached child process), unless skipped. // Pass the absolute restore time, not a relative timeout. Steps 1-2b - // can take minutes (policy apply + privileged chmod), so a relative timeout + // can take minutes (policy apply + kubectl chmod), so a relative timeout // passed at fork time would fire too early. if (!opts.skipTimer) { const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); @@ -1118,8 +1071,47 @@ function shieldsUp(sandboxName: string, opts: { throwOnError?: boolean } = {}): // shieldsDown === false means explicitly locked by a previous shields-up. // undefined (no state file) means fresh sandbox — mutable default, allow shields-up. if (state.shieldsDown === false) { + // Verify the sandbox filesystem still matches the locked posture. If a + // host-root tamper has reverted protected perms, re-apply the lock so + // the recovery hint surfaced by `shields status` actually works. + const target = resolveAgentConfig(sandboxName); + const { issues } = verifyShieldsLockState(sandboxName, target, { + verifyChattr: state.chattrApplied === true, + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + }); + if (issues.length === 0) { + clearTimerMarker(sandboxName); + console.log(" Lockdown is already active."); + return; + } + console.log( + ` Lockdown drifted — re-applying lock for ${sandboxName}...`, + ); + let lockResult: { chattrApplied: boolean }; + try { + lockResult = lockAgentConfig(sandboxName, target); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(` ERROR: ${message}`); + console.error( + " Config remains drifted — manual intervention required.", + ); + return failShieldsCommand(message, opts.throwOnError); + } + saveShieldsState(sandboxName, { + shieldsDown: false, + chattrApplied: lockResult.chattrApplied, + }); clearTimerMarker(sandboxName); - console.log(" Lockdown is already active."); + appendAuditEntry({ + action: "shields_up", + sandbox: sandboxName, + timestamp: new Date().toISOString(), + restored_by: "operator", + reason: "drift remediation", + }); + console.log(` Lockdown re-applied for ${sandboxName}`); return; } @@ -1150,21 +1142,22 @@ function shieldsUp(sandboxName: string, opts: { throwOnError?: boolean } = {}): " Config remains unlocked — manual intervention required.", ); console.error( - ` Restore sandbox access, then run: nemoclaw ${sandboxName} shields up`, + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); return failShieldsCommand(activation.error ?? "unknown restore error", opts.throwOnError); } } else { // 2b. Lock config file to read-only. - // Uses direct privileged exec to bypass Landlock (same as shields down). + // Uses kubectl exec to bypass Landlock (same as shields down). // Each operation runs independently and the result is verified. // If verification fails, config remains unlocked — we do not lie about state. const target = resolveAgentConfig(sandboxName); console.log( ` Locking ${target.agentName} config (${target.configPath})...`, ); + let lockResult: { chattrApplied: boolean }; try { - lockAgentConfig(sandboxName, target); + lockResult = lockAgentConfig(sandboxName, target); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(` ERROR: ${message}`); @@ -1172,10 +1165,11 @@ function shieldsUp(sandboxName: string, opts: { throwOnError?: boolean } = {}): " Config remains unlocked — manual intervention required.", ); console.error( - ` Restore sandbox access, then run: nemoclaw ${sandboxName} shields up`, + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, ); return failShieldsCommand(message, opts.throwOnError); } + saveShieldsState(sandboxName, { chattrApplied: lockResult.chattrApplied }); } // 3. Calculate duration @@ -1192,7 +1186,7 @@ function shieldsUp(sandboxName: string, opts: { throwOnError?: boolean } = {}): shieldsDownTimeout: null, shieldsDownReason: null, shieldsDownPolicy: null, - // Keep snapshotPath for forensics — don't clear it + // Keep snapshotPath + chattrApplied for forensics / drift re-verify }); clearTimerMarker(sandboxName); @@ -1220,9 +1214,21 @@ function shieldsUp(sandboxName: string, opts: { throwOnError?: boolean } = {}): // shields status // --------------------------------------------------------------------------- -function shieldsStatus(sandboxName: string, allowInlineRecovery = true): void { +type ShieldsStatusDeps = { + verifyLockState?: typeof verifyShieldsLockState; + resolveConfig?: typeof resolveAgentConfig; +}; + +function shieldsStatus( + sandboxName: string, + allowInlineRecovery = true, + deps: ShieldsStatusDeps = {}, +): void { validateName(sandboxName, "sandbox name"); + const verify = deps.verifyLockState ?? verifyShieldsLockState; + const resolveConfig = deps.resolveConfig ?? resolveAgentConfig; + const posture = getShieldsPosture(sandboxName, allowInlineRecovery); const { state } = posture; if (state._isCorrupt) { @@ -1245,15 +1251,47 @@ function shieldsStatus(sandboxName: string, allowInlineRecovery = true): void { ); return; - case "locked": + case "locked": { + // Cross-check the sandbox filesystem so a host-root tamper that reverts + // protected perms back to a sandbox-writable state is surfaced as drift + // instead of reported as a clean lockdown. + let driftIssues: string[] = []; + try { + const target = resolveConfig(sandboxName); + driftIssues = verify(sandboxName, target, { + verifyChattr: state.chattrApplied === true, + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + }).issues; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + driftIssues = [`unable to resolve agent config target: ${msg}`]; + } + const policyLine = ` Policy: restrictive${state.shieldsPolicySnapshotPath ? " (snapshot preserved)" : ""}`; + if (driftIssues.length > 0) { + console.error( + " Shields: UP (DRIFTED — declared locked but sandbox filesystem differs)", + ); + console.error(policyLine); + if (state.shieldsDownAt) { + console.error(` Last unlocked: ${state.shieldsDownAt}`); + } + console.error(" Drift:"); + for (const issue of driftIssues) { + console.error(` - ${issue}`); + } + console.error( + ` Recovery: nemoclaw ${sandboxName} shields up # re-lock and re-verify`, + ); + process.exit(2); + } console.log(` Shields: ${posture.statusText}`); - console.log( - ` Policy: restrictive${state.shieldsPolicySnapshotPath ? " (snapshot preserved)" : ""}`, - ); + console.log(policyLine); if (state.shieldsDownAt) { console.log(` Last unlocked: ${state.shieldsDownAt}`); } return; + } case "temporarily_unlocked": { const downSince = state.shieldsDownAt diff --git a/src/lib/shields/verify-lock.test.ts b/src/lib/shields/verify-lock.test.ts new file mode 100644 index 0000000000..d40093689c --- /dev/null +++ b/src/lib/shields/verify-lock.test.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import path from "node:path"; + +// Import from compiled dist/ for correct coverage attribution. +async function loadVerifier(): Promise { + const distModulePath = path.join( + process.cwd(), + "dist", + "lib", + "shields", + "verify-lock.js", + ); + return import(distModulePath); +} + +const target = { + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], +}; + +type StatLookup = Record; + +function makeExec(perms: StatLookup): (cmd: string[]) => string { + return (cmd: string[]) => { + if (cmd[0] === "stat") { + const file = cmd[cmd.length - 1]; + if (file in perms) return perms[file]; + } + return ""; + }; +} + +describe("verifyShieldsLockState", () => { + it("returns ok when all locked files and the config dir match the expected perms", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = makeExec({ + "/sandbox/.openclaw/openclaw.json": "444 root:root", + "/sandbox/.openclaw/.config-hash": "444 root:root", + "/sandbox/.openclaw": "755 root:root", + }); + + const result = verifyShieldsLockState("openclaw", target, { exec }); + + expect(result.ok).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("flags drift when host-root tamper reverts dir + files to sandbox-writable perms", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = makeExec({ + "/sandbox/.openclaw/openclaw.json": "660 sandbox:sandbox", + "/sandbox/.openclaw/.config-hash": "660 sandbox:sandbox", + "/sandbox/.openclaw": "2770 sandbox:sandbox", + }); + + const result = verifyShieldsLockState("openclaw", target, { exec }); + + expect(result.ok).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + "/sandbox/.openclaw/openclaw.json mode=660 (expected 444)", + "/sandbox/.openclaw/openclaw.json owner=sandbox:sandbox (expected root:root)", + "/sandbox/.openclaw/.config-hash mode=660 (expected 444)", + "/sandbox/.openclaw/.config-hash owner=sandbox:sandbox (expected root:root)", + "dir mode=2770 (expected 755)", + "dir owner=sandbox:sandbox (expected root:root)", + ]), + ); + }); + + it.each([ + ["402", "world-writable file"], + ["420", "group-writable file"], + ["422", "group + world-writable file"], + ["440", "missing world read"], + ["404", "missing group read"], + ["644", "owner-writable file"], + ["445", "world-execute file"], + ])( + "rejects mode %s (%s) so writable perms cannot masquerade as locked", + async (mode, _description) => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = makeExec({ + "/sandbox/.openclaw/openclaw.json": `${mode} root:root`, + "/sandbox/.openclaw/.config-hash": "444 root:root", + "/sandbox/.openclaw": "755 root:root", + }); + + const result = verifyShieldsLockState("openclaw", target, { exec }); + + expect(result.ok).toBe(false); + expect(result.issues).toContain( + `/sandbox/.openclaw/openclaw.json mode=${mode} (expected 444)`, + ); + }, + ); + + it("rejects any non-755 dir mode even when the file modes are clean", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = makeExec({ + "/sandbox/.openclaw/openclaw.json": "444 root:root", + "/sandbox/.openclaw/.config-hash": "444 root:root", + "/sandbox/.openclaw": "775 root:root", + }); + + const result = verifyShieldsLockState("openclaw", target, { exec }); + + expect(result.ok).toBe(false); + expect(result.issues).toContain("dir mode=775 (expected 755)"); + }); + + it("reports stat failures as drift when the sandbox cannot be reached", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = (_cmd: string[]): string => { + throw new Error("Container not found"); + }; + + const result = verifyShieldsLockState("openclaw", target, { exec }); + + expect(result.ok).toBe(false); + expect(result.issues.some((issue: string) => issue.includes("stat failed"))).toBe( + true, + ); + expect( + result.issues.some((issue: string) => issue.includes("Container not found")), + ).toBe(true); + }); + + it("flags missing immutable bit only when verifyChattr is requested", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = (cmd: string[]): string => { + if (cmd[0] === "stat") { + if (cmd[cmd.length - 1] === "/sandbox/.openclaw") return "755 root:root"; + return "444 root:root"; + } + if (cmd[0] === "lsattr") { + // No 'i' flag present. + return `----e----- ${cmd[cmd.length - 1]}`; + } + return ""; + }; + + const withoutChattrCheck = verifyShieldsLockState("openclaw", target, { exec }); + expect(withoutChattrCheck.ok).toBe(true); + + const withChattrCheck = verifyShieldsLockState("openclaw", target, { + exec, + verifyChattr: true, + }); + expect(withChattrCheck.ok).toBe(false); + expect(withChattrCheck.issues).toEqual( + expect.arrayContaining([ + "/sandbox/.openclaw/openclaw.json immutable bit not set", + "/sandbox/.openclaw/.config-hash immutable bit not set", + ]), + ); + }); + + it("surfaces a legacy state layout violation when the asserter throws", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const exec = makeExec({ + "/sandbox/.openclaw/openclaw.json": "444 root:root", + "/sandbox/.openclaw/.config-hash": "444 root:root", + "/sandbox/.openclaw": "755 root:root", + }); + + const result = verifyShieldsLockState("openclaw", target, { + exec, + assertLegacyLayout: () => { + throw new Error("legacy data dir exists: /sandbox/.openclaw-data"); + }, + }); + + expect(result.ok).toBe(false); + expect(result.issues).toContain( + "legacy data dir exists: /sandbox/.openclaw-data", + ); + }); + + it("rejects calls without an exec dependency so production paths cannot silently no-op", async () => { + const { verifyShieldsLockState } = await loadVerifier(); + const call = verifyShieldsLockState as unknown as ( + name: string, + lockTarget: unknown, + ) => unknown; + expect(() => call("openclaw", target)).toThrow(/requires options\.exec/); + }); +}); diff --git a/src/lib/shields/verify-lock.ts b/src/lib/shields/verify-lock.ts new file mode 100644 index 0000000000..8f1fee5ee5 --- /dev/null +++ b/src/lib/shields/verify-lock.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Re-verify that the sandbox filesystem still matches what `shields up` +// established: 444 root:root on each locked file, 755 root:root on the +// config directory, no legacy state layout, and (when the caller knows +// chattr was applied) the immutable bit. Returns the list of mismatches +// so callers can either fail the lock operation or surface drift after a +// host-root tamper. Stat/lsattr failures are folded into `issues` so the +// caller can decide whether to treat them as drift. + +export type LockTarget = { + configPath: string; + configDir: string; + sensitiveFiles?: string[]; +}; + +export type VerifyShieldsLockOptions = { + verifyChattr?: boolean; + exec: (cmd: string[]) => string; + assertLegacyLayout?: (sandboxName: string, configDir: string) => void; +}; + +export type VerifyShieldsLockResult = { + ok: boolean; + issues: string[]; +}; + +const EXPECTED_FILE_MODE = "444"; +const EXPECTED_DIR_MODE = "755"; +const EXPECTED_OWNER = "root:root"; + +function noopAssertLegacyLayout(_sandboxName: string, _configDir: string): void { + // Production callers replace this with the real legacy-layout assertion; + // when omitted, the verifier treats legacy-layout state as "no issue". +} + +export function verifyShieldsLockState( + sandboxName: string, + target: LockTarget, + options: VerifyShieldsLockOptions, +): VerifyShieldsLockResult { + if (!options || !options.exec) { + throw new Error("verifyShieldsLockState requires options.exec"); + } + const exec = options.exec; + const assertLegacyLayout = options.assertLegacyLayout ?? noopAssertLegacyLayout; + const issues: string[] = []; + const filesToVerify = [target.configPath, ...(target.sensitiveFiles || [])]; + + for (const f of filesToVerify) { + try { + const perms = exec(["stat", "-c", "%a %U:%G", f]); + const [mode, owner] = perms.split(" "); + if (mode !== EXPECTED_FILE_MODE) + issues.push(`${f} mode=${mode} (expected ${EXPECTED_FILE_MODE})`); + if (owner !== EXPECTED_OWNER) + issues.push(`${f} owner=${owner} (expected ${EXPECTED_OWNER})`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + issues.push(`${f} stat failed: ${msg}`); + } + } + + try { + const dirPerms = exec(["stat", "-c", "%a %U:%G", target.configDir]); + const [dirMode, dirOwner] = dirPerms.split(" "); + if (dirMode !== EXPECTED_DIR_MODE) + issues.push(`dir mode=${dirMode} (expected ${EXPECTED_DIR_MODE})`); + if (dirOwner !== EXPECTED_OWNER) + issues.push(`dir owner=${dirOwner} (expected ${EXPECTED_OWNER})`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + issues.push(`dir stat failed: ${msg}`); + } + + if (options.verifyChattr) { + for (const f of filesToVerify) { + try { + const attrs = exec(["lsattr", "-d", f]); + // lsattr format: "----i---------e----- /path/to/file" + // First whitespace-delimited token is the flags field. + const [flags] = attrs.trim().split(/\s+/, 1); + if (!flags.includes("i")) issues.push(`${f} immutable bit not set`); + } catch { + // lsattr may not be available on all images — skip + } + } + } + + try { + assertLegacyLayout(sandboxName, target.configDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + issues.push(msg); + } + + return { ok: issues.length === 0, issues }; +}