From 88511bce97cd658d2726b3436923f2e6bb064303 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 27 Jul 2026 10:39:19 +0000 Subject: [PATCH 1/6] fix(shields): unlock idempotently when config already mutable (#7430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On DGX Spark/Station and macOS an in-sandbox OpenClaw reconciler re-permissions the config back to the sandbox-owned mutable posture after a host shields lock returns, and a freshly onboarded or snapshot-restored sandbox boots mutable before the lock settles. The config guard's `unlock` transition required the exact shields-locked posture, so the first `nemoclaw shields down` on a clone/restore (or fresh) sandbox failed with `config-not-locked` even though the config already held the mutable target posture; the rollback then re-locked it, so a retry succeeded. Make `unlock` idempotent, mirroring the already-locked short-circuits in the lock branch: when the config already holds the exact mutable posture, verify the mutable files and return a no-op success. Security-safe — it returns early only for the sandbox-owned mutable target, so nothing locked is unlocked. Reproduced and verified on a real DGX Spark (GB10, arm64, OpenShell 0.0.85) with the worktree CLI: before the fix `shields down` returned `[config-not-locked]`; after, it returns "Config unlocked". Signed-off-by: Yimo Jiang Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/openclaw-config-guard.py | 13 +++++++++++++ test/openclaw-config-guard.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/openclaw-config-guard.py b/scripts/openclaw-config-guard.py index dd6c16b263a..e886a8d900a 100755 --- a/scripts/openclaw-config-guard.py +++ b/scripts/openclaw-config-guard.py @@ -3461,6 +3461,19 @@ def _transition( raise GuardError(exc.code, exc.path, detail) from exc raise GuardError("transition-failed", opened.config_path, detail) from exc + if _is_mutable_dir_posture(opened, identity): + # Unlock is idempotent, mirroring the already-locked short-circuits in + # the lock branch above. A config already in the exact mutable posture + # is the unlock target: on some platforms (DGX Spark/Station, macOS) an + # in-sandbox OpenClaw reconciler re-permissions the config back to + # sandbox-owned mutable *after* a host lock returns, and a freshly + # onboarded or snapshot-restored sandbox boots mutable before the lock + # settles. Requiring the locked posture there rejected a legitimate + # `shields down` with config-not-locked even though the config already + # holds the mutable target posture. Verify the exact mutable file + # posture and treat the transition as a no-op instead of failing. + _verify_mutable_files(opened, _snapshot_raw_pair(opened), identity) + return pair = _snapshot_pair(opened) _verify_locked_posture(opened, pair, identity, allow_blocking_flags=True) snapshots: list[FileSnapshot] = [] diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index 6188ac4b7fe..ba13b1a7d72 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -414,6 +414,15 @@ describe("openclaw-config-guard", () => { expect(fs.readFileSync(hashPath)).toEqual(hashBytes); }); + it("unlocks idempotently when the config already holds the mutable posture (#7430)", () => { + // An in-sandbox reconciler can leave the config mutable before shields-down; unlock must be a no-op. + const { configDir } = fixture(); + const r = runGuard("unlock", configDir); + expect(r.status, JSON.stringify(r.lines)).toBe(0); + expect(r.lines.at(-1)).toMatchObject({ action: "unlock", status: "ok" }); + expect(mode(configDir)).toBe(0o2770); + }); + it("fresh-replaces both files on lock and unlock while preserving bytes, times, and xattrs", () => { const { root, configDir, configPath, hashPath } = fixture(); const preservedTime = new Date("2025-01-02T03:04:05.000Z"); From 33b70e9c88bf98bf70d2052b4bddbb2a23974b78 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 27 Jul 2026 11:15:09 +0000 Subject: [PATCH 2/6] test(shields): assert config file modes unchanged on idempotent unlock (#7430) Address CodeRabbit review on #7629: the idempotent-unlock regression test verified only the config directory mode. Also assert the openclaw.json and .config-hash file modes stay sandbox-owned 0660 so a regression that re-permissions config files during the no-op unlock cannot pass. Kept within the 1500-line test-file-size budget by dropping the redundant result-shape assertion. Signed-off-by: Yimo Jiang Co-Authored-By: Claude Opus 4.8 (1M context) --- test/openclaw-config-guard.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index ba13b1a7d72..009c1b63de0 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -416,11 +416,10 @@ describe("openclaw-config-guard", () => { it("unlocks idempotently when the config already holds the mutable posture (#7430)", () => { // An in-sandbox reconciler can leave the config mutable before shields-down; unlock must be a no-op. - const { configDir } = fixture(); + const { configDir, configPath, hashPath } = fixture(); const r = runGuard("unlock", configDir); expect(r.status, JSON.stringify(r.lines)).toBe(0); - expect(r.lines.at(-1)).toMatchObject({ action: "unlock", status: "ok" }); - expect(mode(configDir)).toBe(0o2770); + expect([mode(configDir), mode(configPath), mode(hashPath)]).toEqual([0o2770, 0o660, 0o660]); }); it("fresh-replaces both files on lock and unlock while preserving bytes, times, and xattrs", () => { From 593d37afdfb129ea61754d7ed0a43594c7ea8295 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 27 Jul 2026 14:52:24 -0700 Subject: [PATCH 3/6] fix(sandbox): revalidate mutable posture after capture Signed-off-by: Apurv Kumaria --- scripts/openclaw-config-guard.py | 7 ++++--- test/openclaw-config-guard.test.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/openclaw-config-guard.py b/scripts/openclaw-config-guard.py index e886a8d900a..078420276d1 100755 --- a/scripts/openclaw-config-guard.py +++ b/scripts/openclaw-config-guard.py @@ -3470,9 +3470,10 @@ def _transition( # onboarded or snapshot-restored sandbox boots mutable before the lock # settles. Requiring the locked posture there rejected a legitimate # `shields down` with config-not-locked even though the config already - # holds the mutable target posture. Verify the exact mutable file - # posture and treat the transition as a no-op instead of failing. - _verify_mutable_files(opened, _snapshot_raw_pair(opened), identity) + # holds the mutable target posture. Verify the exact mutable posture + # and treat the transition as a no-op instead of failing. + pair = _snapshot_raw_pair(opened) + _verify_mutable_posture(opened, pair, identity) return pair = _snapshot_pair(opened) _verify_locked_posture(opened, pair, identity, allow_blocking_flags=True) diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index 009c1b63de0..d30efb58cca 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -72,7 +72,7 @@ if failure in {"installed-nonroot-no-cap", "installed-nonroot-not-ready"}: module._pid1_effective_uid = lambda: identity.root_uid + 1 if failure == "startup-owner": module.os.getppid = lambda: 1 -if failure == "pair-race": +if failure in {"pair-race", "mutable-dir-drift"}: original_snapshot = module._snapshot_file raced = False def race_pair(opened, name): @@ -80,6 +80,9 @@ if failure == "pair-race": snapshot = original_snapshot(opened, name) if name == "openclaw.json" and not raced: raced = True + if failure == "mutable-dir-drift": + os.chmod(config_dir, 0o700) + return snapshot updated = b'{"gateway":{"port":19001}}\n' with open(os.path.join(config_dir, "openclaw.json"), "wb") as stream: stream.write(updated) @@ -413,13 +416,13 @@ describe("openclaw-config-guard", () => { expect(fs.readFileSync(configPath)).toEqual(configBytes); expect(fs.readFileSync(hashPath)).toEqual(hashBytes); }); - it("unlocks idempotently when the config already holds the mutable posture (#7430)", () => { - // An in-sandbox reconciler can leave the config mutable before shields-down; unlock must be a no-op. const { configDir, configPath, hashPath } = fixture(); const r = runGuard("unlock", configDir); expect(r.status, JSON.stringify(r.lines)).toBe(0); expect([mode(configDir), mode(configPath), mode(hashPath)]).toEqual([0o2770, 0o660, 0o660]); + const drift = runGuard("unlock", configDir, "mutable-dir-drift"); + expect(drift.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" })); }); it("fresh-replaces both files on lock and unlock while preserving bytes, times, and xattrs", () => { @@ -569,9 +572,7 @@ describe("openclaw-config-guard", () => { it("retries config and hash as one pair when a writer interleaves their capture", () => { const { configDir, configPath, hashPath } = fixture(); - const result = runGuard("lock", configDir, "pair-race"); - expect(result.status).toBe(0); const updated = Buffer.from('{"gateway":{"port":19001}}\n'); expect(fs.readFileSync(configPath)).toEqual(updated); From fcf5b1704232ea108e7f7f03c5d86108cc4c4bb6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 27 Jul 2026 15:04:09 -0700 Subject: [PATCH 4/6] test(sandbox): prove mutable unlock is a no-op Signed-off-by: Apurv Kumaria --- test/openclaw-config-guard.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index d30efb58cca..68149666093 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -339,6 +339,10 @@ function mode(filePath: string): number { return fs.lstatSync(filePath).mode & 0o7777; } +function fileIdentity(filePath: string): [number, Buffer] { + return [fs.statSync(filePath).ino, fs.readFileSync(filePath)]; +} + function setUserXattr(filePath: string, value: string): boolean { return ( spawnSync( @@ -399,10 +403,7 @@ describe("openclaw-config-guard", () => { const { root, configDir, configPath, hashPath } = fixture(); const first = runGuard("lock", configDir); expect(first.status, JSON.stringify(first.lines)).toBe(0); - const configInode = fs.statSync(configPath).ino; - const hashInode = fs.statSync(hashPath).ino; - const configBytes = fs.readFileSync(configPath); - const hashBytes = fs.readFileSync(hashPath); + const fileIdentities = [configPath, hashPath].map(fileIdentity); expect(mode(root)).toBe(0o1775); const second = runGuard("lock", configDir); @@ -411,16 +412,15 @@ describe("openclaw-config-guard", () => { expect(mode(configDir)).toBe(0o755); expect(mode(configPath)).toBe(0o444); expect(mode(hashPath)).toBe(0o444); - expect(fs.statSync(configPath).ino).toBe(configInode); - expect(fs.statSync(hashPath).ino).toBe(hashInode); - expect(fs.readFileSync(configPath)).toEqual(configBytes); - expect(fs.readFileSync(hashPath)).toEqual(hashBytes); + expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); }); it("unlocks idempotently when the config already holds the mutable posture (#7430)", () => { const { configDir, configPath, hashPath } = fixture(); + const fileIdentities = [configPath, hashPath].map(fileIdentity); const r = runGuard("unlock", configDir); expect(r.status, JSON.stringify(r.lines)).toBe(0); expect([mode(configDir), mode(configPath), mode(hashPath)]).toEqual([0o2770, 0o660, 0o660]); + expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); const drift = runGuard("unlock", configDir, "mutable-dir-drift"); expect(drift.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" })); }); From 67ec5af8838536aedccfe8f561cbca9889c699be Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 27 Jul 2026 15:22:44 -0700 Subject: [PATCH 5/6] test(sandbox): harden mutable posture assertions Signed-off-by: Apurv Kumaria --- test/openclaw-config-guard.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index 68149666093..cc4263981f9 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -10,14 +10,12 @@ import { afterEach, describe, expect, it } from "vitest"; const GUARD_PATH = path.resolve("scripts/openclaw-config-guard.py"); const fixtures: string[] = []; - const RUN_AS_CURRENT_USER = String.raw` import importlib.util import hashlib import os import sys import time - guard_path, action, config_dir, failure, expected_sha256 = sys.argv[1:6] spec = importlib.util.spec_from_file_location("nemoclaw_openclaw_config_guard", guard_path) module = importlib.util.module_from_spec(spec) @@ -340,7 +338,12 @@ function mode(filePath: string): number { } function fileIdentity(filePath: string): [number, Buffer] { - return [fs.statSync(filePath).ino, fs.readFileSync(filePath)]; + const fd = fs.openSync(filePath, "r"); + try { + return [fs.fstatSync(fd).ino, fs.readFileSync(fd)]; + } finally { + fs.closeSync(fd); + } } function setUserXattr(filePath: string, value: string): boolean { @@ -421,10 +424,17 @@ describe("openclaw-config-guard", () => { expect(r.status, JSON.stringify(r.lines)).toBe(0); expect([mode(configDir), mode(configPath), mode(hashPath)]).toEqual([0o2770, 0o660, 0o660]); expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); + for (const invalidPath of [configPath, hashPath]) { + fs.chmodSync(invalidPath, 0o600); + const invalid = runGuard("unlock", configDir); + expect(invalid.status).not.toBe(0); + expect(invalid.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" })); + expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); + fs.chmodSync(invalidPath, 0o660); + } const drift = runGuard("unlock", configDir, "mutable-dir-drift"); expect(drift.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" })); }); - it("fresh-replaces both files on lock and unlock while preserving bytes, times, and xattrs", () => { const { root, configDir, configPath, hashPath } = fixture(); const preservedTime = new Date("2025-01-02T03:04:05.000Z"); @@ -486,7 +496,6 @@ describe("openclaw-config-guard", () => { fs.closeSync(staleHashFd); } }); - it("rejects external symlink, hardlink, and special-file substitutions", () => { for (const attack of ["symlink", "hardlink", "fifo"] as const) { const { root, configDir, configPath, hashPath } = fixture(); @@ -517,7 +526,6 @@ describe("openclaw-config-guard", () => { expect(fs.readFileSync(hashPath)).toEqual(beforeHash); } }); - it("fail-closes a rename-swapped config namespace and leaves the external tree untouched", () => { const { root, configDir } = fixture(); const realConfig = path.join(root, "real-openclaw"); @@ -534,7 +542,6 @@ describe("openclaw-config-guard", () => { expect(mode(configDir)).toBe(0o755); expect(mode(path.join(configDir, "openclaw.json"))).toBe(0o444); }); - it("canonicalizes a stale mutable hash while strict preflight rejects a bad record path", () => { const mismatch = fixture(); const mismatchBytes = fs.readFileSync(mismatch.configPath); @@ -569,7 +576,6 @@ describe("openclaw-config-guard", () => { }); expect(runGuard("preflight", absolute.configDir).status).toBe(0); }); - it("retries config and hash as one pair when a writer interleaves their capture", () => { const { configDir, configPath, hashPath } = fixture(); const result = runGuard("lock", configDir, "pair-race"); @@ -582,7 +588,6 @@ describe("openclaw-config-guard", () => { expect(mode(configPath)).toBe(0o444); expect(mode(hashPath)).toBe(0o444); }); - it("restart preflight accepts a stable parseable config with a stale mutable hash", () => { const { configDir, hashPath } = fixture(); fs.writeFileSync(hashPath, `${"0".repeat(64)} openclaw.json\n`); @@ -597,7 +602,6 @@ describe("openclaw-config-guard", () => { ]), ); }); - it("rolls a failed unlock back to the complete locked parent, config, and file posture", () => { const { root, configDir, configPath, hashPath } = fixture(); const configBytes = fs.readFileSync(configPath); @@ -619,7 +623,6 @@ describe("openclaw-config-guard", () => { expect(fs.readFileSync(configPath)).toEqual(configBytes); expect(fs.readFileSync(hashPath)).toEqual(hashBytes); }); - it("clears descriptor-bound immutable flags for replacement and restores them on rollback", () => { const { root, configDir } = fixture(); const flagLog = path.join(root, "inode-flags.log"); @@ -641,7 +644,6 @@ describe("openclaw-config-guard", () => { expect(appliedFlags).toContain(0x10); expect(mode(configDir)).toBe(0o755); }); - it("enforces the exact production path and bounded config artifact sizes", () => { const { configDir, hashPath } = fixture(); const noPathOverride = RUN_AS_CURRENT_USER.replace( @@ -669,7 +671,6 @@ describe("openclaw-config-guard", () => { ]), ); }); - it("CAS-writes a fresh mutable config/hash pair and revokes stale descriptors", () => { const { root, configDir, configPath, hashPath } = fixture(); const oldConfig = fs.readFileSync(configPath); @@ -714,7 +715,6 @@ describe("openclaw-config-guard", () => { fs.closeSync(staleHashFd); } }); - it("safely replaces a sandbox-precreated persistent journal symlink", () => { const { root, configDir, configPath } = fixture(); const original = fs.readFileSync(configPath); From 617370927850ba465adc40a2f10aaa14fdecd0fd Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 27 Jul 2026 15:44:45 -0700 Subject: [PATCH 6/6] test(sandbox): preserve rejected mutable modes Signed-off-by: Apurv Kumaria --- test/openclaw-config-guard.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/openclaw-config-guard.test.ts b/test/openclaw-config-guard.test.ts index cc4263981f9..7267a5f8583 100644 --- a/test/openclaw-config-guard.test.ts +++ b/test/openclaw-config-guard.test.ts @@ -258,11 +258,9 @@ type GuardLine = { function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } - function trustedNodePath(configDir: string): string { return path.join(path.dirname(configDir), ".nemoclaw-test-node"); } - function fixture() { const created = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-config-guard-")); const root = fs.realpathSync(created); @@ -288,7 +286,6 @@ function fixture() { fs.chmodSync(root, 0o755); return { root, configDir, configPath, hashPath }; } - type GuardAction = | "preflight" | "preflight-restart" @@ -300,7 +297,6 @@ type GuardAction = | "publish-startup-ready" | "write-config" | "recover"; - function runGuard( action: GuardAction, configDir: string, @@ -332,7 +328,6 @@ function runGuard( .map((line) => JSON.parse(line) as GuardLine); return { ...result, lines }; } - function mode(filePath: string): number { return fs.lstatSync(filePath).mode & 0o7777; } @@ -408,7 +403,6 @@ describe("openclaw-config-guard", () => { expect(first.status, JSON.stringify(first.lines)).toBe(0); const fileIdentities = [configPath, hashPath].map(fileIdentity); expect(mode(root)).toBe(0o1775); - const second = runGuard("lock", configDir); expect(second.status, JSON.stringify(second.lines)).toBe(0); expect(mode(root)).toBe(0o1775); @@ -426,11 +420,17 @@ describe("openclaw-config-guard", () => { expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); for (const invalidPath of [configPath, hashPath]) { fs.chmodSync(invalidPath, 0o600); - const invalid = runGuard("unlock", configDir); - expect(invalid.status).not.toBe(0); - expect(invalid.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" })); - expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); - fs.chmodSync(invalidPath, 0o660); + try { + const invalid = runGuard("unlock", configDir); + expect(invalid.status).not.toBe(0); + expect(invalid.lines).toContainEqual( + expect.objectContaining({ code: "config-not-mutable" }), + ); + expect(mode(invalidPath)).toBe(0o600); + expect([configPath, hashPath].map(fileIdentity)).toEqual(fileIdentities); + } finally { + fs.chmodSync(invalidPath, 0o660); + } } const drift = runGuard("unlock", configDir, "mutable-dir-drift"); expect(drift.lines).toContainEqual(expect.objectContaining({ code: "config-not-mutable" }));