From f5328aca99a797fe71dc4bdc757d7f85db5fe57d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sun, 16 Aug 2026 19:35:36 -0700 Subject: [PATCH 1/2] fix(shields): accept native OpenClaw device modes Signed-off-by: Senthil Ravichandran --- docs/security/filesystem-controls.mdx | 5 +- scripts/state-dir-guard.py | 28 +++- test/state-dir-guard-verification.test.ts | 163 ++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/docs/security/filesystem-controls.mdx b/docs/security/filesystem-controls.mdx index 770b67f544e..fa3e7184891 100644 --- a/docs/security/filesystem-controls.mdx +++ b/docs/security/filesystem-controls.mdx @@ -61,7 +61,10 @@ The sandbox group cannot list, create, or remove entries in a confidentiality ro They can inspect metadata for a direct child only when they already know its name. Probing a missing direct child, such as the legacy `credentials/oauth.json`, returns `ENOENT` instead of `EACCES`. -Restoring the mutable-default posture returns those directories to `sandbox:sandbox 2770`. +Restoring the mutable-default posture returns protected directories to a sandbox-owned, mutable state. +The guard normally sets them to `sandbox:sandbox 2770`. +If a running OpenClaw process reopens its `devices` store during the transition, it can restore native `0700` or `0755` directory modes and `0600` file modes. +Shields accepts those modes only for the sandbox-owned OpenClaw `devices` subtree while restoring mutable state; `shields up` still transfers ownership and removes write access. For plan-aware current images and host-injected transitions, each agent manifest declares only its own protected paths, confidential paths, dynamic prefixes, and writable subpaths. The lock helper applies only that selected manifest plan and skips declared paths that are not present. diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 088aad4d8bb..18910fb7338 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -46,6 +46,8 @@ PRODUCTION_FAIL_CLOSED_CONFIG_DIRS = frozenset( {"/sandbox/.openclaw", "/sandbox/.hermes", "/sandbox/.deepagents"} ) +OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw" +OPENCLAW_NATIVE_MUTABLE_ROOT = "devices" OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock" MAX_TRANSITION_LOCK_BYTES = 16 * 1024 # Keep this exact source/target contract aligned with @@ -696,6 +698,12 @@ def is_private_writable_root(self, relative_path: str) -> bool: and relative_path == HERMES_PRIVATE_WRITABLE_SUBPATH ) + def is_openclaw_native_mutable_path(self, relative_path: str) -> bool: + return self.config_path == OPENCLAW_CONFIG_DIR and ( + relative_path == OPENCLAW_NATIVE_MUTABLE_ROOT + or relative_path.startswith(f"{OPENCLAW_NATIVE_MUTABLE_ROOT}/") + ) + def is_under_writable_root(self, relative_path: str) -> bool: components = tuple(relative_path.split("/")) return any( @@ -1787,6 +1795,7 @@ def _verify_metadata( action: Action, identity: Identity, is_confidentiality_root: bool = False, + allow_openclaw_native_mutable: bool = False, ) -> Issue | None: expected_uid, expected_gid = _expected_ids( policy, action, identity, is_confidentiality_root @@ -1801,6 +1810,8 @@ def _verify_metadata( return None mode = stat.S_IMODE(st.st_mode) if entry_type == "directory": + if allow_openclaw_native_mutable and mode in (0o700, 0o755): + return None expected_mode = _expected_dir_mode(policy, action, is_confidentiality_root) if mode != expected_mode: return Issue( @@ -1816,6 +1827,8 @@ def _verify_metadata( f"file retains special mode bits: {mode:04o}", ) if action == "unlock": + if allow_openclaw_native_mutable and mode == 0o600: + return None if mode & 0o007 or mode & 0o060 != 0o060: return Issue( "verification-mode-mismatch", @@ -1914,6 +1927,9 @@ def _verify_dir( action, identity, is_root and policy == "confidentiality", + action == "unlock" + and policy == "high-risk" + and context.is_openclaw_native_mutable_path(relative_dir), ) if dir_issue is not None: issues.append(dir_issue) @@ -1982,7 +1998,17 @@ def _verify_dir( ) ) metadata_issue = _verify_metadata( - path, st, "file", policy, action, identity + path, + st, + "file", + policy, + action, + identity, + allow_openclaw_native_mutable=( + action == "unlock" + and policy == "high-risk" + and context.is_openclaw_native_mutable_path(relative_path) + ), ) if metadata_issue is not None: issues.append(metadata_issue) diff --git a/test/state-dir-guard-verification.test.ts b/test/state-dir-guard-verification.test.ts index a44cdd10dab..a8706f462f2 100644 --- a/test/state-dir-guard-verification.test.ts +++ b/test/state-dir-guard-verification.test.ts @@ -34,6 +34,112 @@ def verify(mode): print(json.dumps({format(mode, "04o"): verify(mode) for mode in (0o600, 0o700, 0o640, 0o750)})) `; +const VERIFY_OPENCLAW_NATIVE_MUTABLE_MODES = String.raw` +import importlib.util +import json +import os +import stat +import sys +import tempfile +import time + +spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +identity = module.Identity( + root_uid=os.getuid(), + root_gid=os.getgid(), + sandbox_uid=os.getuid(), + sandbox_gid=os.getgid(), +) + +def verify( + config_path, + root_name, + action, + checked_identity=identity, + root_mode=0o755, + file_mode=0o600, +): + with tempfile.TemporaryDirectory() as temp_root: + root_path = os.path.join(temp_root, root_name) + os.mkdir(root_path, root_mode) + os.chmod(root_path, root_mode) + record_path = os.path.join(root_path, "paired.json") + with open(record_path, "w", encoding="utf-8") as record: + record.write("{}") + os.chmod(record_path, file_mode) + root_fd = os.open(root_path, os.O_RDONLY | os.O_DIRECTORY) + try: + context = module.TraversalContext( + -1, + config_path, + os.fstat(root_fd).st_dev, + (root_name,), + module.WorkBudget(time.monotonic() + 10), + ) + issues = [] + module._verify_dir( + context, + root_fd, + root_name, + "high-risk", + action, + checked_identity, + {}, + issues, + 1, + is_root=True, + ) + return [issue.as_json() for issue in issues] + finally: + os.close(root_fd) + +def verify_native_metadata(entry_type, mode): + kind = stat.S_IFDIR if entry_type == "directory" else stat.S_IFREG + entry = os.stat_result( + (kind | mode, 1, 1, 1, os.getuid(), os.getgid(), 0, 0, 0, 0) + ) + issue = module._verify_metadata( + f"/sandbox/.openclaw/devices/{entry_type}", + entry, + entry_type, + "high-risk", + "unlock", + identity, + allow_openclaw_native_mutable=True, + ) + return None if issue is None else issue.as_json() + +wrong_owner = module.Identity( + root_uid=os.getuid() + 1, + root_gid=os.getgid(), + sandbox_uid=os.getuid() + 1, + sandbox_gid=os.getgid(), +) +print(json.dumps({ + "devices-unlock": verify("/sandbox/.openclaw", "devices", "unlock"), + "devices-private-directory": verify( + "/sandbox/.openclaw", "devices", "unlock", root_mode=0o700 + ), + "devices-normal-directory": verify_native_metadata("directory", 0o2770), + "devices-normal-file": verify_native_metadata("file", 0o660), + "other-root-unlock": verify("/sandbox/.openclaw", "skills", "unlock"), + "other-config-unlock": verify("/tmp/.openclaw", "devices", "unlock"), + "devices-lock": verify("/sandbox/.openclaw", "devices", "lock"), + "devices-wrong-owner": verify( + "/sandbox/.openclaw", "devices", "unlock", wrong_owner + ), + "devices-unsafe-directory": verify( + "/sandbox/.openclaw", "devices", "unlock", root_mode=0o777 + ), + "devices-unsafe-file": verify( + "/sandbox/.openclaw", "devices", "unlock", file_mode=0o644 + ), +})) +`; + describe("state directory guard verification", () => { it("rejects locked high-risk files that lost sandbox group access (#8304)", () => { const result = spawnSync("python3", ["-I", "-c", VERIFY_HIGH_RISK_MODES, GUARD_PATH], { @@ -47,4 +153,61 @@ describe("state directory guard verification", () => { expect(modes["0640"]).toBeNull(); expect(modes["0750"]).toBeNull(); }); + + it("accepts native OpenClaw devices modes only while restoring mutable state (#8112)", () => { + const result = spawnSync( + "python3", + ["-I", "-c", VERIFY_OPENCLAW_NATIVE_MUTABLE_MODES, GUARD_PATH], + { encoding: "utf-8" }, + ); + + expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0); + const outcomes = JSON.parse(result.stdout) as Record | null>; + expect(outcomes["devices-unlock"]).toEqual([]); + expect(outcomes["devices-private-directory"]).toEqual([]); + expect(outcomes["devices-normal-directory"]).toBeNull(); + expect(outcomes["devices-normal-file"]).toBeNull(); + expect(outcomes["other-root-unlock"]).toContainEqual( + expect.objectContaining({ + code: "verification-mode-mismatch", + path: "/sandbox/.openclaw/skills", + detail: "directory mode is 0755, expected 2770", + }), + ); + expect(outcomes["other-config-unlock"]).toContainEqual( + expect.objectContaining({ + code: "verification-mode-mismatch", + path: "/tmp/.openclaw/devices", + detail: "directory mode is 0755, expected 2770", + }), + ); + expect(outcomes["devices-lock"]).toContainEqual( + expect.objectContaining({ + code: "verification-mode-mismatch", + path: "/sandbox/.openclaw/devices/paired.json", + detail: + "high-risk file does not preserve owner read/execute access for the sandbox group: 0600", + }), + ); + expect(outcomes["devices-wrong-owner"]).toContainEqual( + expect.objectContaining({ + code: "verification-owner-mismatch", + path: "/sandbox/.openclaw/devices", + detail: expect.any(String), + }), + ); + expect(outcomes["devices-unsafe-directory"]).toContainEqual( + expect.objectContaining({ + code: "verification-mode-mismatch", + path: "/sandbox/.openclaw/devices", + }), + ); + expect(outcomes["devices-unsafe-file"]).toContainEqual( + expect.objectContaining({ + code: "verification-mode-mismatch", + path: "/sandbox/.openclaw/devices/paired.json", + detail: "mutable file mode does not satisfy g+rwX,o-rwx: 0644", + }), + ); + }); }); From 5a3142d048719aafd5cad76e3bba1d0b56bd7fbe Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sun, 16 Aug 2026 20:29:32 -0700 Subject: [PATCH 2/2] test(shields): cover nested native device modes Signed-off-by: Senthil Ravichandran --- test/state-dir-guard-verification.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/state-dir-guard-verification.test.ts b/test/state-dir-guard-verification.test.ts index a8706f462f2..e6776ac05f8 100644 --- a/test/state-dir-guard-verification.test.ts +++ b/test/state-dir-guard-verification.test.ts @@ -61,6 +61,7 @@ def verify( checked_identity=identity, root_mode=0o755, file_mode=0o600, + nested_directory_mode=0o755, ): with tempfile.TemporaryDirectory() as temp_root: root_path = os.path.join(temp_root, root_name) @@ -70,6 +71,9 @@ def verify( with open(record_path, "w", encoding="utf-8") as record: record.write("{}") os.chmod(record_path, file_mode) + nested_path = os.path.join(root_path, "nested") + os.mkdir(nested_path, nested_directory_mode) + os.chmod(nested_path, nested_directory_mode) root_fd = os.open(root_path, os.O_RDONLY | os.O_DIRECTORY) try: context = module.TraversalContext( @@ -123,6 +127,9 @@ print(json.dumps({ "devices-private-directory": verify( "/sandbox/.openclaw", "devices", "unlock", root_mode=0o700 ), + "devices-nested-private-directory": verify( + "/sandbox/.openclaw", "devices", "unlock", nested_directory_mode=0o700 + ), "devices-normal-directory": verify_native_metadata("directory", 0o2770), "devices-normal-file": verify_native_metadata("file", 0o660), "other-root-unlock": verify("/sandbox/.openclaw", "skills", "unlock"), @@ -165,6 +172,7 @@ describe("state directory guard verification", () => { const outcomes = JSON.parse(result.stdout) as Record | null>; expect(outcomes["devices-unlock"]).toEqual([]); expect(outcomes["devices-private-directory"]).toEqual([]); + expect(outcomes["devices-nested-private-directory"]).toEqual([]); expect(outcomes["devices-normal-directory"]).toBeNull(); expect(outcomes["devices-normal-file"]).toBeNull(); expect(outcomes["other-root-unlock"]).toContainEqual(