Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion docs/security/filesystem-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 27 additions & 1 deletion scripts/state-dir-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
171 changes: 171 additions & 0 deletions test/state-dir-guard-verification.test.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're going to have to add a guard to curb the embedded python (or bash, for that matter) from creeping further into the code.

A human developer wouldn't have written this, so we shouldn't let our agents get away with it.

Original file line number Diff line number Diff line change
Expand Up @@ -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
),
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`;

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], {
Expand All @@ -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<string, Array<{ code: string }> | 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",
}),
);
});
});
Loading