diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index acd2c2059ba..3f06ea51b93 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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//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. diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index d2913e61f45..12e7ffacc94 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -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: @@ -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, @@ -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) diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index c5e0caf2b20..5041221ac11 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -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 @@ -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); + 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");