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
2 changes: 2 additions & 0 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ Two exemption kinds keep runtime data writable.
The lock inventory omits top-level Hermes runtime dirs (`sessions/`, `memories/`, `logs/`, `cache/`, `plans/`) and the image-build-regenerated `openclaw-weixin/`.
The lock helper never touches those paths.
Inside a locked tree, the helper keeps each `agents/<agent-id>/sessions/` root at `sandbox:sandbox 2770` so the OpenClaw TUI can create and write session metadata under an otherwise root-owned parent.
After containment, when an agent directory has no `sessions` entry, lockdown creates that carve-out root.
An agent booting for the first time under an active lock can then write sessions.
It validates the carve-out root but deliberately does not traverse or rewrite live session descendants.
Cross-device entries, detected traversal races, or failures to remove or replace an unsafe entry make lockdown fail closed with the exact path and reason.

Expand Down
80 changes: 80 additions & 0 deletions scripts/state-dir-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ def _is_under_runtime_carveout(relative_path: str) -> bool:
)


def _is_runtime_carveout_parent(relative_path: str) -> bool:
"""Return whether this is an agent directory whose ``sessions`` child is
the carveout location."""

parts = relative_path.split("/")
return len(parts) == 2 and parts[0] == "agents" and parts[1] not in {"", ".", ".."}


def _no_follow_flag() -> int:
flag = getattr(os, "O_NOFOLLOW", 0)
if not flag:
Expand Down Expand Up @@ -1226,6 +1234,73 @@ def _chown_symlink(
raise GuardOperationError(issue)


def _ensure_runtime_carveout(
context: TraversalContext,
parent_fd: int,
relative_dir: str,
policy: Policy,
identity: Identity,
result: GuardResult,
) -> None:
"""Create the writable sessions carveout for an agent that has none.

The locked agent directory is root-owned and read-only for the sandbox
identity, so an agent booting for the first time after shields-up cannot
create its own sessions directory and fails with EACCES. ``_mutate_dir``
calls this after its entry loop so an agent whose unsafe ``sessions``
entry was removed earlier in the same lock pass also converges on a
created carveout; a surviving non-directory entry keeps its locked
posture. A name that appears between the existence check and the mkdir
makes the lock fail closed, matching the raced-entry policy.
"""

relative_path = posixpath.join(relative_dir, "sessions")
path = context.display(relative_path)
try:
os.stat("sessions", dir_fd=parent_fd, follow_symlinks=False)
return
except FileNotFoundError:
pass
except OSError as exc:
raise GuardOperationError(
_os_issue(
"carveout-create-failed",
path,
"check for a sessions carveout",
exc,
)
) from exc
try:
os.mkdir("sessions", mode=0o700, dir_fd=parent_fd)
os.fsync(parent_fd)
created = os.stat("sessions", dir_fd=parent_fd, follow_symlinks=False)
context.budget.observe_entry(path, created)
child_fd = _open_child_dir(parent_fd, "sessions", created)
except OSError as exc:
raise GuardOperationError(
_os_issue(
"carveout-create-failed",
path,
"create writable sessions carveout",
exc,
)
) from exc
try:
_set_dir_metadata(child_fd, policy, "unlock", identity)
except OSError as exc:
raise GuardOperationError(
_os_issue(
"metadata-update-failed",
path,
"prepare writable sessions carveout",
exc,
)
) from exc
finally:
os.close(child_fd)
result.directories += 1


def _mutate_dir(
context: TraversalContext,
dir_fd: int,
Expand Down Expand Up @@ -1397,6 +1472,11 @@ def _mutate_dir(
) from exc
result.removed_entries += 1

if action == "lock" and _is_runtime_carveout_parent(relative_dir):
_ensure_runtime_carveout(
context, dir_fd, relative_dir, policy, identity, result
)

if action == "unlock":
try:
_set_dir_metadata(dir_fd, policy, action, identity)
Expand Down
116 changes: 116 additions & 0 deletions test/state-dir-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@ finally:
os.close(config_fd)
`;

const RUN_CARVEOUT_MKDIR_RACE = String.raw`
import importlib.util
import json
import os
import sys

guard_path, config_dir = sys.argv[1:3]
spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard_carveout", guard_path)
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 racing_mkdir(*args, **kwargs):
raise FileExistsError(17, "File exists", "sessions")

module.os.mkdir = racing_mkdir
result = module.run_guard("lock", config_dir, identity)
print(json.dumps({
"ok": result.ok,
"issues": [issue.as_json() for issue in result.issues],
}))
`;

const RUN_FAKE_MOUNT_BOUNDARY = String.raw`
import importlib.util
import json
Expand Down Expand Up @@ -865,6 +892,95 @@ describe("state-dir-guard", () => {
expect(mode(agentDir)).toBe(0o2770);
});

it("creates a missing sessions carveout during lock so a first-boot agent can write sessions (#7545)", () => {
const { configDir } = fixture(".openclaw");
const agentDir = path.join(configDir, "agents", "main");
const sessionsDir = path.join(agentDir, "sessions");
const memoryDir = path.join(agentDir, "memory");
const stagedDir = path.join(configDir, "agents", "second");
const stagedSessions = path.join(stagedDir, "sessions");
const pluginsDir = path.join(configDir, "plugins", "nested");
fs.mkdirSync(agentDir, { recursive: true });
fs.mkdirSync(memoryDir);
fs.mkdirSync(stagedDir, { recursive: true });
fs.mkdirSync(pluginsDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, "agent.js"), "export {};\n", { mode: 0o664 });
fs.symlinkSync("../../plugins/nested", stagedSessions);

const locked = runGuard("lock", configDir);

expect(locked.status, locked.stderr).toBe(0);
expect(fs.statSync(sessionsDir).isDirectory()).toBe(true);
expect(mode(sessionsDir)).toBe(0o2770);
expect(fs.lstatSync(stagedSessions).isDirectory()).toBe(true);
expect(mode(stagedSessions)).toBe(0o2770);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(fs.existsSync(path.join(memoryDir, "sessions"))).toBe(false);
expect(fs.existsSync(path.join(pluginsDir, "sessions"))).toBe(false);
expect(fs.existsSync(path.join(configDir, "agents", "sessions"))).toBe(false);

const sessionPath = path.join(sessionsDir, "active.jsonl");
fs.writeFileSync(sessionPath, "runtime\n", { mode: 0o660 });
expect(fs.readFileSync(sessionPath, "utf-8")).toBe("runtime\n");

const relocked = runGuard("lock", configDir);

expect(relocked.status, relocked.stderr).toBe(0);
expect(mode(sessionsDir)).toBe(0o2770);
expect(fs.readFileSync(sessionPath, "utf-8")).toBe("runtime\n");

const unlocked = runGuard("unlock", configDir);

expect(unlocked.status, unlocked.stderr).toBe(0);
expect(mode(sessionsDir)).toBe(0o2770);
});

it("fails closed when a sessions entry appears between the carveout check and its mkdir (#7545)", () => {
const { configDir } = fixture(".openclaw");
const agentDir = path.join(configDir, "agents", "main");
fs.mkdirSync(agentDir, { recursive: true });

const result = spawnSync("python3", ["-c", RUN_CARVEOUT_MKDIR_RACE, GUARD_PATH, configDir], {
encoding: "utf-8",
timeout: 15_000,
});

expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout.trim())).toEqual(
expect.objectContaining({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({
code: "carveout-create-failed",
path: path.join(agentDir, "sessions"),
}),
]),
}),
);
expect(fs.existsSync(path.join(agentDir, "sessions"))).toBe(false);
});

it("keeps a regular-file sessions entry locked instead of creating a writable carveout (#7545)", () => {
const { configDir } = fixture(".openclaw");
const agentDir = path.join(configDir, "agents", "main");
const sessionsPath = path.join(agentDir, "sessions");
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(sessionsPath, "not a directory\n", { mode: 0o664 });
const oldInode = fs.statSync(sessionsPath).ino;

const locked = runGuard("lock", configDir);

expect(locked.status, locked.stderr).toBe(0);
expect(fs.lstatSync(sessionsPath).isFile()).toBe(true);
expect(mode(sessionsPath)).toBe(0o644);
expect(fs.statSync(sessionsPath).ino).not.toBe(oldInode);

const relocked = runGuard("lock", configDir);

expect(relocked.status, relocked.stderr).toBe(0);
expect(fs.lstatSync(sessionsPath).isFile()).toBe(true);
expect(mode(sessionsPath)).toBe(0o644);
});

it("rejects hardlinks and special entries during the read-only preflight", () => {
const { configDir } = fixture();
const skillsDir = path.join(configDir, "skills");
Expand Down
Loading