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..e6776ac05f8 100644 --- a/test/state-dir-guard-verification.test.ts +++ b/test/state-dir-guard-verification.test.ts @@ -34,6 +34,119 @@ 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, + nested_directory_mode=0o755, +): + 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) + 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( + -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-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"), + "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 +160,62 @@ 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-nested-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", + }), + ); + }); });