From 019a889a4def5bb8100755c158a897b36cc2ff97 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 6 Jul 2026 09:33:21 +0000 Subject: [PATCH 01/11] fix(shields): self-heal root-owned mutable OpenClaw config on boot The boot-time normalize step treated any root-owned /sandbox/.openclaw as an intentional shields-up lock and skipped repair. When the runtime leaves the mutable-default config tree root-owned, the sandbox user can no longer write openclaw.json in the default state. Classify a root-owned tree by its sealed signature (0755 dir plus a 0444 root:root openclaw.json) and reclaim only a confirmed collapse back to the sandbox:sandbox 2770/660 group contract. A genuine shields-up lock, or any ambiguous state, is left untouched so the lock is never weakened. The reclaim is root-only and refuses symlinked config paths. Co-Authored-By: Claude Signed-off-by: Tinson Lai --- scripts/nemoclaw-start.sh | 92 ++++++++++++++++++++++++++++++- test/nemoclaw-start-perms.test.ts | 91 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e246fc56c4e..5e2596ff789 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -551,8 +551,16 @@ PY_CLASSIFY_MUTABLE_CONFIG return 1 fi [ "$config_dir_uid" = "missing" ] && return 0 - # Shields up: the root-owned config tree is intentionally locked. - [ "$config_dir_uid" = "0" ] && return 0 + if [ "$config_dir_uid" = "0" ]; then + [ "$operation" = "normalize" ] || return 0 + local seal_state + classify_openclaw_config_seal "$config_dir" + seal_state=$? + if [ "$seal_state" -eq 1 ]; then + reclaim_collapsed_mutable_config "$config_dir" || return 1 + fi + return 0 + fi local expected_config_dir_uid expected_config_dir_gid if [ "$(id -u)" -eq 0 ]; then @@ -622,6 +630,86 @@ PY_CLASSIFY_MUTABLE_CONFIG fi } +classify_openclaw_config_seal() { + local config_dir="$1" + python3 -I - "$config_dir" "$config_dir/openclaw.json" <<'PY_CLASSIFY_OPENCLAW_SEAL' +import os +import stat +import sys + +SEALED = 0 +UNSEALED = 1 +INDETERMINATE = 2 + +try: + dir_path, file_path = sys.argv[1], sys.argv[2] + try: + dir_meta = os.lstat(dir_path) + except OSError: + raise SystemExit(INDETERMINATE) + if not stat.S_ISDIR(dir_meta.st_mode): + raise SystemExit(INDETERMINATE) + dir_sealed = ( + dir_meta.st_uid == 0 + and dir_meta.st_gid == 0 + and stat.S_IMODE(dir_meta.st_mode) == 0o755 + ) + if not dir_sealed: + raise SystemExit(UNSEALED) + try: + file_meta = os.lstat(file_path) + except FileNotFoundError: + raise SystemExit(SEALED) + except OSError: + raise SystemExit(INDETERMINATE) + file_sealed = ( + stat.S_ISREG(file_meta.st_mode) + and file_meta.st_uid == 0 + and file_meta.st_gid == 0 + and stat.S_IMODE(file_meta.st_mode) == 0o444 + ) + raise SystemExit(SEALED if file_sealed else UNSEALED) +except SystemExit: + raise +except Exception: + raise SystemExit(INDETERMINATE) +PY_CLASSIFY_OPENCLAW_SEAL +} + +reclaim_collapsed_mutable_config() { + local config_dir="$1" + local config_file="$config_dir/openclaw.json" + local hash_file="$config_dir/.config-hash" + + [ "$(id -u)" -eq 0 ] || return 0 + + if [ -L "$config_dir" ] || [ -L "$config_file" ] || [ -L "$hash_file" ]; then + printf '[SECURITY] Refusing mutable config reclaim — config directory or file path is a symlink\n' >&2 + return 1 + fi + + if ! find -P "$config_dir" \( -type d -o -type f \) -exec chown sandbox:sandbox {} +; then + printf '[SECURITY] Failed to reclaim ownership of %s\n' "$config_dir" >&2 + return 1 + fi + if ! chmod 2770 "$config_dir"; then + printf '[SECURITY] Failed to restore permissions on %s\n' "$config_dir" >&2 + return 1 + fi + if ! find -P "$config_dir" -type d -exec chmod g+s {} +; then + printf '[SECURITY] Failed to restore setgid inheritance on %s\n' "$config_dir" >&2 + return 1 + fi + local f + for f in "$config_file" "$hash_file"; do + [ -e "$f" ] || continue + if ! chmod 660 "$f"; then + printf '[SECURITY] Failed to restore permissions on %s\n' "$f" >&2 + return 1 + fi + done +} + # Invalid state (#4538, #6047): OpenClaw assumes a single-UID 700/600 config # tree, while NemoClaw's separate sandbox and gateway UIDs require the mutable # 2770/660 group contract. The tightening originates at the OpenClaw command diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index fc26f3cca47..4c928348acb 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -329,3 +329,94 @@ describe("nemoclaw-start one-shot command lifecycle", () => { } }); }); + +const classifyFunction = extractShellFunction("classify_openclaw_config_seal"); +const reclaimFunction = extractShellFunction("reclaim_collapsed_mutable_config"); +const runningAsRoot = process.getuid?.() === 0; + +function runClassify(configDir: string) { + const script = [ + "set -uo pipefail", + classifyFunction, + "rc=0", + `classify_openclaw_config_seal ${JSON.stringify(configDir)} || rc=$?`, + 'printf "rc=%s\\n" "$rc"', + ].join("\n"); + return runBash(script); +} + +describe("nemoclaw-start mutable config seal classification", () => { + it("never reports a group-writable mutable directory as a shields-up seal", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-mutable-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir, 0o2770); + fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); + try { + expect(runClassify(configDir).stdout).toContain("rc=1"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("treats the 0755 directory with a 0444 config as sealed only when it is root-owned", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-owner-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir, 0o755); + const configFile = path.join(configDir, "openclaw.json"); + fs.writeFileSync(configFile, "{}\n"); + fs.chmodSync(configFile, 0o444); + try { + expect(runClassify(configDir).stdout).toContain(runningAsRoot ? "rc=0" : "rc=1"); + } finally { + fs.chmodSync(configFile, 0o644); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("reports a missing config directory as indeterminate", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-missing-")); + try { + expect(runClassify(path.join(root, ".openclaw")).stdout).toContain("rc=2"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("reports a symlinked config directory as indeterminate", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-symlink-")); + const realDir = path.join(root, "real"); + const linkDir = path.join(root, ".openclaw"); + fs.mkdirSync(realDir, 0o2770); + fs.symlinkSync(realDir, linkDir); + try { + expect(runClassify(linkDir).stdout).toContain("rc=2"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("nemoclaw-start mutable config reclaim", () => { + it("leaves the tree untouched when it cannot reclaim ownership without root", () => { + if (runningAsRoot) return; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-nonroot-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir, 0o2770); + fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); + const beforeUid = fs.statSync(configDir).uid; + const script = [ + "set -uo pipefail", + reclaimFunction, + "rc=0", + `reclaim_collapsed_mutable_config ${JSON.stringify(configDir)} || rc=$?`, + 'printf "rc=%s\\n" "$rc"', + ].join("\n"); + try { + const result = runBash(script); + expect(result.stdout).toContain("rc=0"); + expect(fs.statSync(configDir).uid).toBe(beforeUid); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); From 19ec64c45d09ebfe18ffddea9d09ecdc8fa6b962 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 6 Jul 2026 10:05:45 +0000 Subject: [PATCH 02/11] fix(shields): close TOCTOU window in collapsed config reclaim Root reclaim previously discovered entries with `find -P` and then mutated them by path with separate `chown`/`chmod` calls, leaving a window between discovery and mutation for a race to swap a pathname. Rewrite the reclaim as a single descriptor-safe pass: open the root directory with O_NOFOLLOW, re-verify it is still exactly root:root before touching anything, and recurse using pinned file descriptors (fchown/fchmod on already-open fds), verifying each entry's inode identity is unchanged after every mutation. A tree found already sealed at this point is left untouched instead of reclaimed. Add a root-mode regression test that drives the real boot path (normalize_mutable_config_perms) against a root-owned collapsed config and asserts the sandbox:sandbox 2770/660 contract and a group-writable touch both succeed, and replace the non-root test's early-return branch with it.skipIf/it.runIf gates to keep the test bodies linear. Co-Authored-By: Claude Signed-off-by: Tinson Lai --- scripts/nemoclaw-start.sh | 149 +++++++++++++++++++++++++----- test/nemoclaw-start-perms.test.ts | 102 +++++++++++++++----- 2 files changed, 207 insertions(+), 44 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 5e2596ff789..196024389f2 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -678,36 +678,141 @@ PY_CLASSIFY_OPENCLAW_SEAL reclaim_collapsed_mutable_config() { local config_dir="$1" - local config_file="$config_dir/openclaw.json" - local hash_file="$config_dir/.config-hash" [ "$(id -u)" -eq 0 ] || return 0 - if [ -L "$config_dir" ] || [ -L "$config_file" ] || [ -L "$hash_file" ]; then - printf '[SECURITY] Refusing mutable config reclaim — config directory or file path is a symlink\n' >&2 + local sandbox_uid sandbox_gid + if ! sandbox_uid="$(id -u sandbox)" || ! sandbox_gid="$(id -g sandbox)"; then + printf '[SECURITY] Refusing mutable config reclaim — sandbox identity lookup failed\n' >&2 return 1 fi - if ! find -P "$config_dir" \( -type d -o -type f \) -exec chown sandbox:sandbox {} +; then - printf '[SECURITY] Failed to reclaim ownership of %s\n' "$config_dir" >&2 - return 1 - fi - if ! chmod 2770 "$config_dir"; then - printf '[SECURITY] Failed to restore permissions on %s\n' "$config_dir" >&2 - return 1 - fi - if ! find -P "$config_dir" -type d -exec chmod g+s {} +; then - printf '[SECURITY] Failed to restore setgid inheritance on %s\n' "$config_dir" >&2 + if ! python3 -I - "$config_dir" "$sandbox_uid" "$sandbox_gid" <<'PY_RECLAIM_MUTABLE_CONFIG'; then +import os +import stat +import sys + + +class UnsafeTree(Exception): + """The collapsed config tree changed identity or violated its ownership contract.""" + + +FIXED_FILES = ("openclaw.json", ".config-hash") + + +def inode_key(metadata): + return metadata.st_dev, metadata.st_ino, stat.S_IFMT(metadata.st_mode) + + +def directory_flags(): + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow: + raise UnsafeTree() + return os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | nofollow + + +def file_flags(): + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow: + raise UnsafeTree() + return os.O_RDONLY | os.O_CLOEXEC | nofollow + + +def open_pinned(parent_fd, name, flags, expected): + child_fd = os.open(name, flags, dir_fd=parent_fd) + opened = os.fstat(child_fd) + if inode_key(opened) != inode_key(expected): + os.close(child_fd) + raise UnsafeTree() + return child_fd, opened + + +def verify_still_linked(parent_fd, name, opened): + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if inode_key(current) != inode_key(opened): + raise UnsafeTree() + + +def is_sealed(root_fd, root_metadata): + if stat.S_IMODE(root_metadata.st_mode) != 0o755: + return False + try: + file_meta = os.stat("openclaw.json", dir_fd=root_fd, follow_symlinks=False) + except FileNotFoundError: + return True + return ( + stat.S_ISREG(file_meta.st_mode) + and file_meta.st_uid == 0 + and file_meta.st_gid == 0 + and stat.S_IMODE(file_meta.st_mode) == 0o444 + ) + + +def reclaim_dir(directory_fd, sandbox_uid, sandbox_gid, *, top_level): + with os.scandir(directory_fd) as entries: + names = sorted(entry.name for entry in entries) + for name in names: + before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISLNK(before.st_mode): + raise UnsafeTree() + if stat.S_ISDIR(before.st_mode): + child_fd, opened = open_pinned(directory_fd, name, directory_flags(), before) + try: + reclaim_dir(child_fd, sandbox_uid, sandbox_gid, top_level=False) + os.fchown(child_fd, sandbox_uid, sandbox_gid) + current_mode = stat.S_IMODE(os.fstat(child_fd).st_mode) + os.fchmod(child_fd, (current_mode | 0o2070) & ~0o007) + verify_still_linked(directory_fd, name, opened) + finally: + os.close(child_fd) + continue + if not stat.S_ISREG(before.st_mode): + continue + child_fd, opened = open_pinned(directory_fd, name, file_flags(), before) + try: + os.fchown(child_fd, sandbox_uid, sandbox_gid) + target_mode = ( + 0o660 + if top_level and name in FIXED_FILES + else (stat.S_IMODE(opened.st_mode) | 0o060) & ~0o007 + ) + os.fchmod(child_fd, target_mode) + verify_still_linked(directory_fd, name, opened) + finally: + os.close(child_fd) + + +def main(): + config_dir, sandbox_uid, sandbox_gid = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) + root_fd = os.open(config_dir, directory_flags()) + try: + root_metadata = os.fstat(root_fd) + if ( + not stat.S_ISDIR(root_metadata.st_mode) + or root_metadata.st_uid != 0 + or root_metadata.st_gid != 0 + ): + raise UnsafeTree() + if is_sealed(root_fd, root_metadata): + return + reclaim_dir(root_fd, sandbox_uid, sandbox_gid, top_level=True) + os.fchown(root_fd, sandbox_uid, sandbox_gid) + os.fchmod(root_fd, 0o2770) + current = os.stat(config_dir, follow_symlinks=False) + if inode_key(current) != inode_key(os.fstat(root_fd)): + raise UnsafeTree() + finally: + os.close(root_fd) + + +try: + main() +except (OSError, UnsafeTree): + raise SystemExit(1) +PY_RECLAIM_MUTABLE_CONFIG + printf '[SECURITY] Refusing mutable config reclaim — descriptor-safe reclaim detected an unsafe link, race, owner, or metadata state\n' >&2 return 1 fi - local f - for f in "$config_file" "$hash_file"; do - [ -e "$f" ] || continue - if ! chmod 660 "$f"; then - printf '[SECURITY] Failed to restore permissions on %s\n' "$f" >&2 - return 1 - fi - done } # Invalid state (#4538, #6047): OpenClaw assumes a single-UID 700/600 config diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index 4c928348acb..9848a14e4d7 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -396,27 +396,85 @@ describe("nemoclaw-start mutable config seal classification", () => { }); }); +const nobodyUid = spawnSync("id", ["-u", "nobody"], { encoding: "utf-8" }).stdout.trim(); +const nobodyGid = spawnSync("id", ["-g", "nobody"], { encoding: "utf-8" }).stdout.trim(); + describe("nemoclaw-start mutable config reclaim", () => { - it("leaves the tree untouched when it cannot reclaim ownership without root", () => { - if (runningAsRoot) return; - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-nonroot-")); - const configDir = path.join(root, ".openclaw"); - fs.mkdirSync(configDir, 0o2770); - fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); - const beforeUid = fs.statSync(configDir).uid; - const script = [ - "set -uo pipefail", - reclaimFunction, - "rc=0", - `reclaim_collapsed_mutable_config ${JSON.stringify(configDir)} || rc=$?`, - 'printf "rc=%s\\n" "$rc"', - ].join("\n"); - try { - const result = runBash(script); - expect(result.stdout).toContain("rc=0"); - expect(fs.statSync(configDir).uid).toBe(beforeUid); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); + it.skipIf(runningAsRoot)( + "leaves the tree untouched when it cannot reclaim ownership without root", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-nonroot-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir, 0o2770); + fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); + const beforeUid = fs.statSync(configDir).uid; + const script = [ + "set -uo pipefail", + reclaimFunction, + "rc=0", + `reclaim_collapsed_mutable_config ${JSON.stringify(configDir)} || rc=$?`, + 'printf "rc=%s\\n" "$rc"', + ].join("\n"); + try { + const result = runBash(script); + expect(result.stdout).toContain("rc=0"); + expect(fs.statSync(configDir).uid).toBe(beforeUid); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(runningAsRoot)( + "reclaims a root-owned collapsed config to the sandbox contract and permits sandbox writes (#6300)", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-root-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir); + fs.chmodSync(configDir, 0o2770); + const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); + fs.writeFileSync(configFile, "{}\n"); + fs.chmodSync(configFile, 0o660); + fs.writeFileSync(hashFile, "hash\n"); + fs.chmodSync(hashFile, 0o660); + + const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + 'local config_dir="/sandbox/.openclaw"', + `local config_dir=${JSON.stringify(configDir)}`, + ); + const patchedReclaimFunction = reclaimFunction + .replace("id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`) + .replace("id -g sandbox", `echo ${JSON.stringify(nobodyGid)}`); + const script = [ + "set -euo pipefail", + classifyFunction, + patchedReclaimFunction, + normalizeFunction, + "normalize_mutable_config_perms", + ].join("\n"); + + try { + const result = runBash(script); + expect(result.status).toBe(0); + expect(mode(configDir)).toBe(0o2770); + expect(mode(configFile)).toBe(0o660); + expect(mode(hashFile)).toBe(0o660); + expect(fs.statSync(configDir).uid.toString()).toBe(nobodyUid); + expect(fs.statSync(configDir).gid.toString()).toBe(nobodyGid); + + const writeCheck = spawnSync("setpriv", [ + `--reuid=${nobodyUid}`, + `--regid=${nobodyGid}`, + "--clear-groups", + "--", + "touch", + path.join(configDir, "nemoclaw-write-check"), + ]); + expect(writeCheck.status).toBe(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); }); From 7fa059866ff3d6b796b44390a17cd5675c00b348 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 7 Jul 2026 12:23:50 +0000 Subject: [PATCH 03/11] fix(shields): make collapsed-config classify-and-reclaim atomic Signed-off-by: Tinson Lai --- ci/test-file-size-budget.json | 2 +- scripts/lib/normalize_mutable_config_perms.py | 127 ++++++++++ scripts/nemoclaw-start.sh | 233 ++++-------------- test/e2e-gateway-isolation.sh | 33 ++- test/nemoclaw-start-perms.test.ts | 14 +- test/nemoclaw-start.test.ts | 2 + test/repro-2681-group-writable.test.ts | 20 +- 7 files changed, 228 insertions(+), 203 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index efa6090688e..cb5abf418a3 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1871, "test/generate-openclaw-config.test.ts": 1945, "test/install-preflight.test.ts": 3934, - "test/nemoclaw-start.test.ts": 4827, + "test/nemoclaw-start.test.ts": 4829, "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 6146, "test/onboard.test.ts": 4057, diff --git a/scripts/lib/normalize_mutable_config_perms.py b/scripts/lib/normalize_mutable_config_perms.py index 9f0d7ee01fb..fc52c443867 100755 --- a/scripts/lib/normalize_mutable_config_perms.py +++ b/scripts/lib/normalize_mutable_config_perms.py @@ -178,6 +178,109 @@ def normalize_dir(directory_fd: int, *, top_level: bool = False) -> None: os.close(child_fd) +SEALED = 0 +UNSEALED = 1 +INDETERMINATE = 2 + + +def classify_seal(root_fd: int, root_metadata: os.stat_result) -> int: + if not stat.S_ISDIR(root_metadata.st_mode): + return INDETERMINATE + dir_sealed = ( + root_metadata.st_uid == 0 + and root_metadata.st_gid == 0 + and stat.S_IMODE(root_metadata.st_mode) == 0o755 + ) + if not dir_sealed: + return UNSEALED + try: + file_metadata = os.stat(CONFIG_NAME, dir_fd=root_fd, follow_symlinks=False) + except FileNotFoundError: + return SEALED + except OSError: + return INDETERMINATE + file_sealed = ( + stat.S_ISREG(file_metadata.st_mode) + and file_metadata.st_uid == 0 + and file_metadata.st_gid == 0 + and stat.S_IMODE(file_metadata.st_mode) == 0o444 + ) + return SEALED if file_sealed else UNSEALED + + +def reclaim_dir( + directory_fd: int, + sandbox_uid: int, + sandbox_gid: int, + *, + top_level: bool, +) -> None: + with os.scandir(directory_fd) as entries: + names = sorted(entry.name for entry in entries) + for name in names: + try: + before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError as exc: + raise UnsafeTree() from exc + if stat.S_ISLNK(before.st_mode): + raise UnsafeTree() + if stat.S_ISDIR(before.st_mode): + child_fd, opened = open_pinned(directory_fd, name, directory_flags(), before) + try: + reclaim_dir(child_fd, sandbox_uid, sandbox_gid, top_level=False) + os.fchown(child_fd, sandbox_uid, sandbox_gid) + current_mode = stat.S_IMODE(os.fstat(child_fd).st_mode) + os.fchmod(child_fd, (current_mode | 0o2070) & ~0o007) + verify_still_linked(directory_fd, name, opened) + finally: + os.close(child_fd) + continue + if not stat.S_ISREG(before.st_mode): + continue + child_fd, opened = open_pinned(directory_fd, name, file_flags(), before) + try: + os.fchown(child_fd, sandbox_uid, sandbox_gid) + target_mode = ( + 0o660 + if top_level and name in FIXED_FILES + else (stat.S_IMODE(opened.st_mode) | 0o060) & ~0o007 + ) + os.fchmod(child_fd, target_mode) + verify_still_linked(directory_fd, name, opened) + finally: + os.close(child_fd) + + +def reclaim_if_unsealed(config_dir: str, sandbox_uid: int, sandbox_gid: int) -> int: + """Classify and, only if unsealed, reclaim a root-collapsed OpenClaw config. + + Opens config_dir exactly once (O_NOFOLLOW) so classification and reclaim + act on the same pinned descriptor, closing the window between a + path-based seal check and a later, separately opened mutation. + """ + try: + root_fd = os.open(config_dir, directory_flags()) + except OSError: + return 0 + try: + root_metadata = os.fstat(root_fd) + if classify_seal(root_fd, root_metadata) != UNSEALED: + return 0 + if root_metadata.st_uid != 0 or root_metadata.st_gid != 0: + raise UnsafeTree() + reclaim_dir(root_fd, sandbox_uid, sandbox_gid, top_level=True) + os.fchown(root_fd, sandbox_uid, sandbox_gid) + os.fchmod(root_fd, 0o2770) + current = os.stat(config_dir, follow_symlinks=False) + if inode_key(current) != inode_key(os.fstat(root_fd)): + raise UnsafeTree() + return 0 + except (OSError, UnsafeTree): + return 1 + finally: + os.close(root_fd) + + def config_dir_matches( root_fd: int, config_dir: str, @@ -1358,6 +1461,30 @@ def run_root_supervisor( def main() -> int: + if len(sys.argv) >= 2 and sys.argv[1] == "classify-seal": + if len(sys.argv) != 3: + return 1 + config_dir = sys.argv[2] + try: + root_fd = os.open(config_dir, directory_flags()) + except OSError: + return INDETERMINATE + try: + return classify_seal(root_fd, os.fstat(root_fd)) + finally: + os.close(root_fd) + + if len(sys.argv) >= 2 and sys.argv[1] == "reclaim-if-unsealed": + if len(sys.argv) != 5: + return 1 + config_dir = sys.argv[2] + try: + sandbox_uid = int(sys.argv[3]) + sandbox_gid = int(sys.argv[4]) + except ValueError: + return 1 + return reclaim_if_unsealed(config_dir, sandbox_uid, sandbox_gid) + if len(sys.argv) not in {4, 5, 7}: return 1 config_dir = sys.argv[1] diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 196024389f2..7380170e6c0 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -519,6 +519,32 @@ export OPENCLAW_OAUTH_DIR="${_OPENCLAW_CREDENTIALS_DIR}" # restores the setgid + group-writable contract. Host-side, `nemoclaw # doctor --fix` and the rebuild post-upgrade repair step apply the same # normalization without requiring a restart. +resolve_mutable_config_normalizer() { + local normalizer="/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py" + if [ -f "$normalizer" ]; then + printf '%s\n' "$normalizer" + return 0 + fi + if [ "$(id -u)" -eq 0 ]; then + return 1 + fi + if [ -n "${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER:-}" ] \ + && [ -f "${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER}" ]; then + printf '%s\n' "${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER}" + return 0 + fi + if [ -f "scripts/lib/normalize_mutable_config_perms.py" ]; then + printf '%s\n' "scripts/lib/normalize_mutable_config_perms.py" + return 0 + fi + normalizer="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/normalize_mutable_config_perms.py" + if [ -f "$normalizer" ]; then + printf '%s\n' "$normalizer" + return 0 + fi + return 1 +} + normalize_mutable_config_perms() { local config_dir="/sandbox/.openclaw" local operation="${1:-normalize}" @@ -553,12 +579,15 @@ PY_CLASSIFY_MUTABLE_CONFIG [ "$config_dir_uid" = "missing" ] && return 0 if [ "$config_dir_uid" = "0" ]; then [ "$operation" = "normalize" ] || return 0 - local seal_state - classify_openclaw_config_seal "$config_dir" - seal_state=$? - if [ "$seal_state" -eq 1 ]; then - reclaim_collapsed_mutable_config "$config_dir" || return 1 - fi + # Root ownership here is the same OpenClaw doctor --fix collapse tracked + # by #6047 (https://github.com/NVIDIA/NemoClaw/issues/6047), the upstream + # tightening run_oneshot_command's wrapper below also works around. + # reclaim_collapsed_mutable_config classifies and, only if unsealed, + # reclaims under one pinned descriptor, so a genuinely sealed lock + # (root:root 0755 dir, 0444 config) is never reopened for mutation; drop + # this call once the pinned OpenClaw stops collapsing the sandbox + # 2770/660 contract on its own. + reclaim_collapsed_mutable_config "$config_dir" || return 1 return 0 fi @@ -579,23 +608,8 @@ PY_CLASSIFY_MUTABLE_CONFIG return 1 fi - # The installed helper wins in production. Repository-relative resolution is - # only for source-tree tests and ad-hoc development runs. - local normalizer="/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py" - if [ ! -f "$normalizer" ]; then - if [ "$(id -u)" -eq 0 ]; then - printf '[SECURITY] Refusing mutable config permission normalization — trusted normalizer is missing\n' >&2 - return 1 - elif [ -n "${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER:-}" ] \ - && [ -f "${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER}" ]; then - normalizer="${NEMOCLAW_MUTABLE_CONFIG_NORMALIZER}" - elif [ -f "scripts/lib/normalize_mutable_config_perms.py" ]; then - normalizer="scripts/lib/normalize_mutable_config_perms.py" - else - normalizer="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/normalize_mutable_config_perms.py" - fi - fi - if [ ! -f "$normalizer" ]; then + local normalizer + if ! normalizer="$(resolve_mutable_config_normalizer)"; then printf '[SECURITY] Refusing mutable config permission normalization — trusted normalizer is missing\n' >&2 return 1 fi @@ -632,48 +646,9 @@ PY_CLASSIFY_MUTABLE_CONFIG classify_openclaw_config_seal() { local config_dir="$1" - python3 -I - "$config_dir" "$config_dir/openclaw.json" <<'PY_CLASSIFY_OPENCLAW_SEAL' -import os -import stat -import sys - -SEALED = 0 -UNSEALED = 1 -INDETERMINATE = 2 - -try: - dir_path, file_path = sys.argv[1], sys.argv[2] - try: - dir_meta = os.lstat(dir_path) - except OSError: - raise SystemExit(INDETERMINATE) - if not stat.S_ISDIR(dir_meta.st_mode): - raise SystemExit(INDETERMINATE) - dir_sealed = ( - dir_meta.st_uid == 0 - and dir_meta.st_gid == 0 - and stat.S_IMODE(dir_meta.st_mode) == 0o755 - ) - if not dir_sealed: - raise SystemExit(UNSEALED) - try: - file_meta = os.lstat(file_path) - except FileNotFoundError: - raise SystemExit(SEALED) - except OSError: - raise SystemExit(INDETERMINATE) - file_sealed = ( - stat.S_ISREG(file_meta.st_mode) - and file_meta.st_uid == 0 - and file_meta.st_gid == 0 - and stat.S_IMODE(file_meta.st_mode) == 0o444 - ) - raise SystemExit(SEALED if file_sealed else UNSEALED) -except SystemExit: - raise -except Exception: - raise SystemExit(INDETERMINATE) -PY_CLASSIFY_OPENCLAW_SEAL + local normalizer + normalizer="$(resolve_mutable_config_normalizer)" || return 2 + python3 -I "$normalizer" classify-seal "$config_dir" >/dev/null } reclaim_collapsed_mutable_config() { @@ -687,129 +662,13 @@ reclaim_collapsed_mutable_config() { return 1 fi - if ! python3 -I - "$config_dir" "$sandbox_uid" "$sandbox_gid" <<'PY_RECLAIM_MUTABLE_CONFIG'; then -import os -import stat -import sys - - -class UnsafeTree(Exception): - """The collapsed config tree changed identity or violated its ownership contract.""" - - -FIXED_FILES = ("openclaw.json", ".config-hash") - - -def inode_key(metadata): - return metadata.st_dev, metadata.st_ino, stat.S_IFMT(metadata.st_mode) - - -def directory_flags(): - nofollow = getattr(os, "O_NOFOLLOW", 0) - if not nofollow: - raise UnsafeTree() - return os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | nofollow - - -def file_flags(): - nofollow = getattr(os, "O_NOFOLLOW", 0) - if not nofollow: - raise UnsafeTree() - return os.O_RDONLY | os.O_CLOEXEC | nofollow - - -def open_pinned(parent_fd, name, flags, expected): - child_fd = os.open(name, flags, dir_fd=parent_fd) - opened = os.fstat(child_fd) - if inode_key(opened) != inode_key(expected): - os.close(child_fd) - raise UnsafeTree() - return child_fd, opened - - -def verify_still_linked(parent_fd, name, opened): - current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) - if inode_key(current) != inode_key(opened): - raise UnsafeTree() - - -def is_sealed(root_fd, root_metadata): - if stat.S_IMODE(root_metadata.st_mode) != 0o755: - return False - try: - file_meta = os.stat("openclaw.json", dir_fd=root_fd, follow_symlinks=False) - except FileNotFoundError: - return True - return ( - stat.S_ISREG(file_meta.st_mode) - and file_meta.st_uid == 0 - and file_meta.st_gid == 0 - and stat.S_IMODE(file_meta.st_mode) == 0o444 - ) - - -def reclaim_dir(directory_fd, sandbox_uid, sandbox_gid, *, top_level): - with os.scandir(directory_fd) as entries: - names = sorted(entry.name for entry in entries) - for name in names: - before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if stat.S_ISLNK(before.st_mode): - raise UnsafeTree() - if stat.S_ISDIR(before.st_mode): - child_fd, opened = open_pinned(directory_fd, name, directory_flags(), before) - try: - reclaim_dir(child_fd, sandbox_uid, sandbox_gid, top_level=False) - os.fchown(child_fd, sandbox_uid, sandbox_gid) - current_mode = stat.S_IMODE(os.fstat(child_fd).st_mode) - os.fchmod(child_fd, (current_mode | 0o2070) & ~0o007) - verify_still_linked(directory_fd, name, opened) - finally: - os.close(child_fd) - continue - if not stat.S_ISREG(before.st_mode): - continue - child_fd, opened = open_pinned(directory_fd, name, file_flags(), before) - try: - os.fchown(child_fd, sandbox_uid, sandbox_gid) - target_mode = ( - 0o660 - if top_level and name in FIXED_FILES - else (stat.S_IMODE(opened.st_mode) | 0o060) & ~0o007 - ) - os.fchmod(child_fd, target_mode) - verify_still_linked(directory_fd, name, opened) - finally: - os.close(child_fd) - - -def main(): - config_dir, sandbox_uid, sandbox_gid = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) - root_fd = os.open(config_dir, directory_flags()) - try: - root_metadata = os.fstat(root_fd) - if ( - not stat.S_ISDIR(root_metadata.st_mode) - or root_metadata.st_uid != 0 - or root_metadata.st_gid != 0 - ): - raise UnsafeTree() - if is_sealed(root_fd, root_metadata): - return - reclaim_dir(root_fd, sandbox_uid, sandbox_gid, top_level=True) - os.fchown(root_fd, sandbox_uid, sandbox_gid) - os.fchmod(root_fd, 0o2770) - current = os.stat(config_dir, follow_symlinks=False) - if inode_key(current) != inode_key(os.fstat(root_fd)): - raise UnsafeTree() - finally: - os.close(root_fd) - + local normalizer + if ! normalizer="$(resolve_mutable_config_normalizer)"; then + printf '[SECURITY] Refusing mutable config reclaim — trusted normalizer is missing\n' >&2 + return 1 + fi -try: - main() -except (OSError, UnsafeTree): - raise SystemExit(1) -PY_RECLAIM_MUTABLE_CONFIG + if ! python3 -I "$normalizer" reclaim-if-unsealed "$config_dir" "$sandbox_uid" "$sandbox_gid" >/dev/null; then printf '[SECURITY] Refusing mutable config reclaim — descriptor-safe reclaim detected an unsafe link, race, owner, or metadata state\n' >&2 return 1 fi diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 278fd9dc494..7e40773dd5e 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -616,7 +616,10 @@ fi info "30. One-shot cleanup repairs 700/600 without CAP_DAC_OVERRIDE" OUT=$(docker run --rm --cap-drop DAC_OVERRIDE --entrypoint bash "$IMAGE" -lc ' set -euo pipefail - sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start >/tmp/normalize.sh + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/normalize.sh test -s /tmp/normalize.sh source /tmp/normalize.sh capsh --has-p=cap_setgid @@ -643,7 +646,10 @@ fi info "30a. One-shot cleanup rejects a mutable tree owned by another UID" OUT=$(docker run --rm --entrypoint bash "$IMAGE" -lc ' set -euo pipefail - sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start >/tmp/normalize.sh + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/normalize.sh test -s /tmp/normalize.sh source /tmp/normalize.sh chown -R gateway:gateway /sandbox/.openclaw @@ -667,7 +673,10 @@ fi info "30b. One-shot cleanup reports a missing CAP_SETGID precondition" OUT=$(docker run --rm --user 0:0 --cap-drop DAC_OVERRIDE --cap-drop SETGID --entrypoint bash "$IMAGE" -lc ' set -euo pipefail - sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start >/tmp/normalize.sh + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/normalize.sh test -s /tmp/normalize.sh source /tmp/normalize.sh sandbox_gid=$(id -g sandbox) @@ -698,6 +707,7 @@ info "30c. Post-override capture freshens a hardlinked recovery baseline" OUT=$(docker run --rm --entrypoint bash "$IMAGE" -lc ' set -euo pipefail { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start sed -n "/^write_openclaw_config_baseline() {$/,/^}$/p" /usr/local/bin/nemoclaw-start } >/tmp/normalize.sh @@ -748,9 +758,11 @@ if source.count(needle) != 1: raise SystemExit("handoff injection point changed") Path("/tmp/normalizer-handoff-race.py").write_text(source.replace(needle, replacement)) PY_INJECT_HANDOFF_RACE - sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start \ - | sed "s#/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py#/tmp/normalizer-handoff-race.py#" \ - >/tmp/normalize.sh + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start \ + | sed "s#/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py#/tmp/normalizer-handoff-race.py#" + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/normalize.sh source /tmp/normalize.sh find /sandbox/.openclaw -mindepth 1 -delete gosu sandbox sh -c "printf \"{}\\n\" > /sandbox/.openclaw/openclaw.json; printf \"hash\\n\" > /sandbox/.openclaw/.config-hash" @@ -776,6 +788,7 @@ info "30e. Empty-config recovery refuses a protected-target symlink" OUT=$(docker run --rm --entrypoint bash "$IMAGE" -lc ' set -euo pipefail { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start sed -n "/^recover_openclaw_config_if_empty() {$/,/^}$/p" /usr/local/bin/nemoclaw-start } >/tmp/recover.sh @@ -811,9 +824,11 @@ from pathlib import Path Path("/tmp/untrusted-normalizer-ran").write_text("unsafe\n") PY_UNTRUSTED_NORMALIZER - sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start \ - | sed "s#/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py#/tmp/missing-normalizer.py#" \ - >/tmp/normalize.sh + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start \ + | sed "s#/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py#/tmp/missing-normalizer.py#" + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/normalize.sh source /tmp/normalize.sh export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=/tmp/untrusted-normalizer.py rc=0 diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index 9848a14e4d7..5b20c1801ef 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -40,6 +40,7 @@ function mode(filePath: string): number { } const oneShotFunction = extractShellFunction("run_oneshot_command"); +const resolveNormalizerFunction = extractShellFunction("resolve_mutable_config_normalizer"); describe("nemoclaw-start one-shot command lifecycle", () => { it("restores a real mutable config tree and preserves child exit status (#6047)", () => { @@ -55,6 +56,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -156,6 +158,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -194,6 +197,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, "rc=0", "normalize_mutable_config_perms || rc=$?", @@ -247,6 +251,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { const script = [ "set -euo pipefail", `export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=${JSON.stringify(normalizerPath)}`, + resolveNormalizerFunction, normalizeFunction, "rc=0", "normalize_mutable_config_perms || rc=$?", @@ -289,6 +294,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -337,6 +343,7 @@ const runningAsRoot = process.getuid?.() === 0; function runClassify(configDir: string) { const script = [ "set -uo pipefail", + resolveNormalizerFunction, classifyFunction, "rc=0", `classify_openclaw_config_seal ${JSON.stringify(configDir)} || rc=$?`, @@ -431,13 +438,13 @@ describe("nemoclaw-start mutable config reclaim", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-root-")); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir); - fs.chmodSync(configDir, 0o2770); + fs.chmodSync(configDir, 0o700); const configFile = path.join(configDir, "openclaw.json"); const hashFile = path.join(configDir, ".config-hash"); fs.writeFileSync(configFile, "{}\n"); - fs.chmodSync(configFile, 0o660); + fs.chmodSync(configFile, 0o600); fs.writeFileSync(hashFile, "hash\n"); - fs.chmodSync(hashFile, 0o660); + fs.chmodSync(hashFile, 0o600); const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( 'local config_dir="/sandbox/.openclaw"', @@ -448,6 +455,7 @@ describe("nemoclaw-start mutable config reclaim", () => { .replace("id -g sandbox", `echo ${JSON.stringify(nobodyGid)}`); const script = [ "set -euo pipefail", + resolveNormalizerFunction, classifyFunction, patchedReclaimFunction, normalizeFunction, diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index c95abc652e5..1eabaf086e7 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -4018,6 +4018,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { "#!/usr/bin/env bash", "set -euo pipefail", `export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=${JSON.stringify(helperPath)}`, + extractShellFunction("resolve_mutable_config_normalizer"), helperFns, fn, "recover_openclaw_config_if_empty", @@ -4129,6 +4130,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { const wrapper = [ "#!/usr/bin/env bash", "set -euo pipefail", + extractShellFunction("resolve_mutable_config_normalizer"), extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root), "normalize_mutable_config_perms", ] diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 25924a6a058..f500efc1b5c 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -50,10 +50,22 @@ function extractShellFunctionFromSource(src: string, name: string): string { function normalizeMutableConfigPermsFor(configDir: string): string { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - return extractShellFunctionFromSource(startScript, "normalize_mutable_config_perms").replace( + const normalizeFunction = extractShellFunctionFromSource( + startScript, + "normalize_mutable_config_perms", + ).replace( 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); + const resolveNormalizerFunction = extractShellFunctionFromSource( + startScript, + "resolve_mutable_config_normalizer", + ); + const reclaimFunction = extractShellFunctionFromSource( + startScript, + "reclaim_collapsed_mutable_config", + ); + return [resolveNormalizerFunction, reclaimFunction, normalizeFunction].join("\n"); } function modeBits(filePath: string): number { @@ -704,8 +716,10 @@ process.stdout.write(JSON.stringify(calls)); [ "set -euo pipefail", // Model the descriptor observing root ownership without requiring - // the test runner itself to own this fixture as root. - 'python3() { if [ "${2:-}" != "-" ]; then printf "unexpected helper invocation\\n" >&2; return 68; fi; cat >/dev/null; printf "0\\n"; }', + // the test runner itself to own this fixture as root: the initial + // classification reports uid 0, and reclaim-if-unsealed reports a + // sealed/no-op tree, so normalize must never reach chmod or find. + 'python3() { if [ "${2:-}" = "-" ]; then cat >/dev/null; printf "0\\n"; return 0; fi; if [ "${3:-}" = "reclaim-if-unsealed" ]; then return 0; fi; printf "unexpected helper invocation\\n" >&2; return 68; }', 'chmod() { printf "CHMOD %s\\n" "$*" >&2; exit 66; }', 'find() { printf "FIND %s\\n" "$*" >&2; exit 67; }', normalizeMutableConfigPermsFor(configDir), From a5856601c1edfb00b4083bbaf2df89744a38d0cc Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 7 Jul 2026 12:54:11 +0000 Subject: [PATCH 04/11] fix(test): keep nemoclaw-start.test.ts within its legacy line budget Signed-off-by: Tinson Lai --- ci/test-file-size-budget.json | 2 +- test/nemoclaw-start.test.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 0ee1fe9d4be..62b508af933 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1904, "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3934, - "test/nemoclaw-start.test.ts": 4829, + "test/nemoclaw-start.test.ts": 4827, "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 4834, "test/onboard.test.ts": 4057, diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index d3a58aa5ee0..93887a8940f 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -4018,8 +4018,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { "#!/usr/bin/env bash", "set -euo pipefail", `export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=${JSON.stringify(helperPath)}`, - extractShellFunction("resolve_mutable_config_normalizer"), - helperFns, + `${extractShellFunction("resolve_mutable_config_normalizer")}\n${helperFns}`, fn, "recover_openclaw_config_if_empty", ] @@ -4130,8 +4129,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { const wrapper = [ "#!/usr/bin/env bash", "set -euo pipefail", - extractShellFunction("resolve_mutable_config_normalizer"), - extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root), + `${extractShellFunction("resolve_mutable_config_normalizer")}\n${extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root)}`, "normalize_mutable_config_perms", ] .filter(Boolean) From b5e000787295acc8127f988483add884c9980502 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 7 Jul 2026 13:25:57 +0000 Subject: [PATCH 05/11] fix(shields): exclude the recovery baseline from generic reclaim Signed-off-by: Tinson Lai --- scripts/lib/normalize_mutable_config_perms.py | 2 + test/nemoclaw-start-perms.test.ts | 49 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/lib/normalize_mutable_config_perms.py b/scripts/lib/normalize_mutable_config_perms.py index fc52c443867..34ecfed714d 100755 --- a/scripts/lib/normalize_mutable_config_perms.py +++ b/scripts/lib/normalize_mutable_config_perms.py @@ -237,6 +237,8 @@ def reclaim_dir( continue if not stat.S_ISREG(before.st_mode): continue + if top_level and name == BASELINE_NAME: + continue child_fd, opened = open_pinned(directory_fd, name, file_flags(), before) try: os.fchown(child_fd, sandbox_uid, sandbox_gid) diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index 091c080d6e4..45b8ba320c1 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -475,7 +475,7 @@ const nobodyGid = spawnSync("id", ["-g", "nobody"], { encoding: "utf-8" }).stdou describe("nemoclaw-start mutable config reclaim", () => { it.skipIf(runningAsRoot)( - "leaves the tree untouched when it cannot reclaim ownership without root", + "leaves the tree untouched when it cannot reclaim ownership without root (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-nonroot-")); const configDir = path.join(root, ".openclaw"); @@ -552,4 +552,51 @@ describe("nemoclaw-start mutable config reclaim", () => { } }, ); + + it.runIf(runningAsRoot)( + "leaves a root-owned recovery baseline untouched during reclaim (#6307)", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-baseline-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir); + fs.chmodSync(configDir, 0o700); + const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); + const baselineFile = path.join(configDir, "openclaw.json.nemoclaw-baseline"); + fs.writeFileSync(configFile, "{}\n"); + fs.chmodSync(configFile, 0o600); + fs.writeFileSync(hashFile, "hash\n"); + fs.chmodSync(hashFile, 0o600); + fs.writeFileSync(baselineFile, "{}\n"); + fs.chmodSync(baselineFile, 0o440); + const beforeBaselineUid = fs.statSync(baselineFile).uid; + const beforeBaselineGid = fs.statSync(baselineFile).gid; + + const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + 'local config_dir="/sandbox/.openclaw"', + `local config_dir=${JSON.stringify(configDir)}`, + ); + const patchedReclaimFunction = reclaimFunction + .replace("id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`) + .replace("id -g sandbox", `echo ${JSON.stringify(nobodyGid)}`); + const script = [ + "set -euo pipefail", + resolveNormalizerFunction, + classifyFunction, + patchedReclaimFunction, + normalizeFunction, + "normalize_mutable_config_perms", + ].join("\n"); + + try { + const result = runBash(script); + expect(result.status).toBe(0); + expect(mode(baselineFile)).toBe(0o440); + expect(fs.statSync(baselineFile).uid).toBe(beforeBaselineUid); + expect(fs.statSync(baselineFile).gid).toBe(beforeBaselineGid); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); }); From 2e64a30f0298e9db0d051f244eebb99db5f927af Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:10:46 -0700 Subject: [PATCH 06/11] fix(shields): fail closed on root config reclaim Signed-off-by: Apurv Kumaria --- scripts/lib/normalize_mutable_config_perms.py | 322 +++++++++++++----- scripts/nemoclaw-start.sh | 78 ++++- test/nemoclaw-start-perms.test.ts | 111 ++++-- test/repro-2681-group-writable.test.ts | 28 +- 4 files changed, 414 insertions(+), 125 deletions(-) diff --git a/scripts/lib/normalize_mutable_config_perms.py b/scripts/lib/normalize_mutable_config_perms.py index 34ecfed714d..bd3ba8b548f 100755 --- a/scripts/lib/normalize_mutable_config_perms.py +++ b/scripts/lib/normalize_mutable_config_perms.py @@ -183,104 +183,272 @@ def normalize_dir(directory_fd: int, *, top_level: bool = False) -> None: INDETERMINATE = 2 +def fd_mount_id(fd: int) -> int: + """Return Linux's mount ID for an open descriptor, failing closed.""" + + if not sys.platform.startswith("linux"): + raise UnsafeTree() + fdinfo_fd = -1 + try: + fdinfo_fd = os.open( + f"/proc/self/fdinfo/{fd}", + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + ) + payload = os.read(fdinfo_fd, 4097) + if len(payload) > 4096: + raise UnsafeTree() + mount_ids = [ + line.removeprefix(b"mnt_id:").strip() + for line in payload.splitlines() + if line.startswith(b"mnt_id:") + ] + if len(mount_ids) != 1 or not mount_ids[0].isdigit(): + raise UnsafeTree() + return int(mount_ids[0]) + except OSError as exc: + raise UnsafeTree() from exc + finally: + if fdinfo_fd >= 0: + os.close(fdinfo_fd) + + +def open_fixed_files( + root_fd: int, + root_metadata: os.stat_result, + *, + uid: int, + gid: int, + mode: int, + root_mount_id: int | None = None, +) -> list[tuple[str, int, os.stat_result]]: + opened_files: list[tuple[str, int, os.stat_result]] = [] + try: + for name in FIXED_FILES: + before = os.stat(name, dir_fd=root_fd, follow_symlinks=False) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_dev != root_metadata.st_dev + or before.st_uid != uid + or before.st_gid != gid + or stat.S_IMODE(before.st_mode) != mode + or before.st_nlink != 1 + ): + raise UnsafeTree() + child_fd, opened = open_pinned(root_fd, name, file_flags(), before) + try: + current = os.stat(name, dir_fd=root_fd, follow_symlinks=False) + if ( + stable_file_key(opened) != stable_file_key(before) + or stable_file_key(current) != stable_file_key(opened) + or ( + root_mount_id is not None + and fd_mount_id(child_fd) != root_mount_id + ) + ): + raise UnsafeTree() + except Exception: + os.close(child_fd) + raise + opened_files.append((name, child_fd, opened)) + return opened_files + except Exception: + for _name, child_fd, _opened in opened_files: + os.close(child_fd) + raise + + +def fixed_files_have_posture( + root_fd: int, + root_metadata: os.stat_result, + *, + uid: int, + gid: int, + mode: int, +) -> bool: + opened_files: list[tuple[str, int, os.stat_result]] = [] + try: + opened_files = open_fixed_files( + root_fd, root_metadata, uid=uid, gid=gid, mode=mode + ) + return True + except (OSError, UnsafeTree): + return False + finally: + for _name, child_fd, _opened in opened_files: + os.close(child_fd) + + def classify_seal(root_fd: int, root_metadata: os.stat_result) -> int: - if not stat.S_ISDIR(root_metadata.st_mode): + if ( + not stat.S_ISDIR(root_metadata.st_mode) + or root_metadata.st_uid != 0 + or root_metadata.st_gid != 0 + ): return INDETERMINATE - dir_sealed = ( - root_metadata.st_uid == 0 - and root_metadata.st_gid == 0 - and stat.S_IMODE(root_metadata.st_mode) == 0o755 - ) - if not dir_sealed: + root_mode = stat.S_IMODE(root_metadata.st_mode) + if root_mode == 0o755 and fixed_files_have_posture( + root_fd, root_metadata, uid=0, gid=0, mode=0o444 + ): + return SEALED + if root_mode == 0o700 and fixed_files_have_posture( + root_fd, root_metadata, uid=0, gid=0, mode=0o600 + ): return UNSEALED + return INDETERMINATE + + +def open_config_binding( + config_dir: str, +) -> tuple[int, os.stat_result, int, os.stat_result, str]: + normalized = os.path.normpath(config_dir) + if not os.path.isabs(normalized) or normalized == os.path.sep: + raise UnsafeTree() + parent_fd = -1 + root_fd = -1 try: - file_metadata = os.stat(CONFIG_NAME, dir_fd=root_fd, follow_symlinks=False) - except FileNotFoundError: - return SEALED - except OSError: - return INDETERMINATE - file_sealed = ( - stat.S_ISREG(file_metadata.st_mode) - and file_metadata.st_uid == 0 - and file_metadata.st_gid == 0 - and stat.S_IMODE(file_metadata.st_mode) == 0o444 + parent_path = os.path.dirname(normalized) + config_name = os.path.basename(normalized) + parent_fd = os.open(parent_path, directory_flags()) + before = os.stat(config_name, dir_fd=parent_fd, follow_symlinks=False) + root_fd, root_metadata = open_pinned( + parent_fd, config_name, directory_flags(), before + ) + return ( + parent_fd, + os.fstat(parent_fd), + root_fd, + root_metadata, + config_name, + ) + except Exception: + if root_fd >= 0: + os.close(root_fd) + if parent_fd >= 0: + os.close(parent_fd) + raise + + +def mutable_parent_matches( + parent_metadata: os.stat_result, + sandbox_uid: int, + sandbox_gid: int, +) -> bool: + return ( + stat.S_ISDIR(parent_metadata.st_mode) + and parent_metadata.st_uid == sandbox_uid + and parent_metadata.st_gid == sandbox_gid + and stat.S_IMODE(parent_metadata.st_mode) == 0o755 ) - return SEALED if file_sealed else UNSEALED -def reclaim_dir( - directory_fd: int, +def classify_config_path( + config_dir: str, + sandbox_uid: int, + sandbox_gid: int, +) -> int: + parent_fd = -1 + root_fd = -1 + try: + parent_fd, parent_metadata, root_fd, root_metadata, _name = ( + open_config_binding(config_dir) + ) + state = classify_seal(root_fd, root_metadata) + if state != UNSEALED: + return state + if ( + not mutable_parent_matches(parent_metadata, sandbox_uid, sandbox_gid) + or root_metadata.st_dev != parent_metadata.st_dev + or fd_mount_id(root_fd) != fd_mount_id(parent_fd) + ): + return INDETERMINATE + return UNSEALED + except (OSError, UnsafeTree): + return INDETERMINATE + finally: + if root_fd >= 0: + os.close(root_fd) + if parent_fd >= 0: + os.close(parent_fd) + + +def reclaim_fixed_files( + root_fd: int, + opened_files: list[tuple[str, int, os.stat_result]], sandbox_uid: int, sandbox_gid: int, - *, - top_level: bool, ) -> None: - with os.scandir(directory_fd) as entries: - names = sorted(entry.name for entry in entries) - for name in names: - try: - before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - except OSError as exc: - raise UnsafeTree() from exc - if stat.S_ISLNK(before.st_mode): + for name, child_fd, opened in opened_files: + os.fchown(child_fd, sandbox_uid, sandbox_gid) + os.fchmod(child_fd, 0o660) + current = os.fstat(child_fd) + if ( + current.st_uid != sandbox_uid + or current.st_gid != sandbox_gid + or stat.S_IMODE(current.st_mode) != 0o660 + or current.st_nlink != 1 + ): raise UnsafeTree() - if stat.S_ISDIR(before.st_mode): - child_fd, opened = open_pinned(directory_fd, name, directory_flags(), before) - try: - reclaim_dir(child_fd, sandbox_uid, sandbox_gid, top_level=False) - os.fchown(child_fd, sandbox_uid, sandbox_gid) - current_mode = stat.S_IMODE(os.fstat(child_fd).st_mode) - os.fchmod(child_fd, (current_mode | 0o2070) & ~0o007) - verify_still_linked(directory_fd, name, opened) - finally: - os.close(child_fd) - continue - if not stat.S_ISREG(before.st_mode): - continue - if top_level and name == BASELINE_NAME: - continue - child_fd, opened = open_pinned(directory_fd, name, file_flags(), before) - try: - os.fchown(child_fd, sandbox_uid, sandbox_gid) - target_mode = ( - 0o660 - if top_level and name in FIXED_FILES - else (stat.S_IMODE(opened.st_mode) | 0o060) & ~0o007 - ) - os.fchmod(child_fd, target_mode) - verify_still_linked(directory_fd, name, opened) - finally: - os.close(child_fd) + verify_still_linked(root_fd, name, opened) def reclaim_if_unsealed(config_dir: str, sandbox_uid: int, sandbox_gid: int) -> int: """Classify and, only if unsealed, reclaim a root-collapsed OpenClaw config. - Opens config_dir exactly once (O_NOFOLLOW) so classification and reclaim - act on the same pinned descriptor, closing the window between a - path-based seal check and a later, separately opened mutation. + Pins the parent, config directory, and both fixed files with O_NOFOLLOW so + classification and reclaim act on the same inodes. The directory handoff + occurs last, after every mutable file is ready for the sandbox identity. """ + if os.geteuid() != 0 or sandbox_uid <= 0 or sandbox_gid <= 0: + return 1 + parent_fd = -1 + root_fd = -1 + opened_files: list[tuple[str, int, os.stat_result]] = [] try: - root_fd = os.open(config_dir, directory_flags()) - except OSError: - return 0 - try: - root_metadata = os.fstat(root_fd) - if classify_seal(root_fd, root_metadata) != UNSEALED: + parent_fd, parent_metadata, root_fd, root_metadata, config_name = ( + open_config_binding(config_dir) + ) + seal_state = classify_seal(root_fd, root_metadata) + if seal_state == SEALED: return 0 - if root_metadata.st_uid != 0 or root_metadata.st_gid != 0: + if seal_state != UNSEALED: raise UnsafeTree() - reclaim_dir(root_fd, sandbox_uid, sandbox_gid, top_level=True) + if ( + not mutable_parent_matches(parent_metadata, sandbox_uid, sandbox_gid) + or root_metadata.st_dev != parent_metadata.st_dev + or fd_mount_id(root_fd) != fd_mount_id(parent_fd) + ): + raise UnsafeTree() + opened_files = open_fixed_files( + root_fd, + root_metadata, + uid=0, + gid=0, + mode=0o600, + root_mount_id=fd_mount_id(root_fd), + ) + reclaim_fixed_files(root_fd, opened_files, sandbox_uid, sandbox_gid) + os.fchmod(root_fd, 0o000) os.fchown(root_fd, sandbox_uid, sandbox_gid) os.fchmod(root_fd, 0o2770) - current = os.stat(config_dir, follow_symlinks=False) - if inode_key(current) != inode_key(os.fstat(root_fd)): + current = os.stat(config_name, dir_fd=parent_fd, follow_symlinks=False) + final_root = os.fstat(root_fd) + if ( + inode_key(current) != inode_key(final_root) + or final_root.st_uid != sandbox_uid + or final_root.st_gid != sandbox_gid + or stat.S_IMODE(final_root.st_mode) != 0o2770 + ): raise UnsafeTree() return 0 except (OSError, UnsafeTree): return 1 finally: - os.close(root_fd) + for _name, child_fd, _opened in opened_files: + os.close(child_fd) + if root_fd >= 0: + os.close(root_fd) + if parent_fd >= 0: + os.close(parent_fd) def config_dir_matches( @@ -1464,17 +1632,15 @@ def run_root_supervisor( def main() -> int: if len(sys.argv) >= 2 and sys.argv[1] == "classify-seal": - if len(sys.argv) != 3: + if len(sys.argv) != 5: return 1 config_dir = sys.argv[2] try: - root_fd = os.open(config_dir, directory_flags()) - except OSError: + sandbox_uid = int(sys.argv[3]) + sandbox_gid = int(sys.argv[4]) + except ValueError: return INDETERMINATE - try: - return classify_seal(root_fd, os.fstat(root_fd)) - finally: - os.close(root_fd) + return classify_config_path(config_dir, sandbox_uid, sandbox_gid) if len(sys.argv) >= 2 and sys.argv[1] == "reclaim-if-unsealed": if len(sys.argv) != 5: diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b4c1ebfcadc..9ed862dcc32 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -525,6 +525,9 @@ resolve_mutable_config_normalizer() { printf '%s\n' "$normalizer" return 0 fi + # A privileged repair may execute only the immutable helper installed in the + # image. The environment and checkout fallbacks below exist solely for + # non-root developer/test harnesses, where they cannot change ownership. if [ "$(id -u)" -eq 0 ]; then return 1 fi @@ -579,14 +582,15 @@ PY_CLASSIFY_MUTABLE_CONFIG [ "$config_dir_uid" = "missing" ] && return 0 if [ "$config_dir_uid" = "0" ]; then [ "$operation" = "normalize" ] || return 0 - # Root ownership here is the same OpenClaw doctor --fix collapse tracked - # by #6047 (https://github.com/NVIDIA/NemoClaw/issues/6047), the upstream - # tightening run_oneshot_command's wrapper below also works around. - # reclaim_collapsed_mutable_config classifies and, only if unsealed, - # reclaims under one pinned descriptor, so a genuinely sealed lock - # (root:root 0755 dir, 0444 config) is never reopened for mutation; drop - # this call once the pinned OpenClaw stops collapsing the sandbox - # 2770/660 contract on its own. + # Dockerfile and policy sources establish sandbox:sandbox 2770/660 as the + # mutable default. #6300 establishes the root-ownership/write regression, + # but not a broader safe-to-repair state; no in-repo producer has been + # identified. This compatibility path therefore accepts only the narrow + # root:root 0700/0600 fixture, under a sandbox:sandbox 0755 parent. That is + # distinct from #6047's sandbox-owned mode collapse, which the owner-UID + # normalizer below repairs. Every other root-owned state fails closed. + # Remove this path once the runtime preserves the declared ownership and + # the live shields-config regression proves that boundary. reclaim_collapsed_mutable_config "$config_dir" || return 1 return 0 fi @@ -646,15 +650,30 @@ PY_CLASSIFY_MUTABLE_CONFIG classify_openclaw_config_seal() { local config_dir="$1" + local sandbox_uid sandbox_gid + if [ "$(id -u)" -eq 0 ]; then + sandbox_uid="$(id -u sandbox)" || return 2 + sandbox_gid="$(id -g sandbox)" || return 2 + else + sandbox_uid="$(id -u)" + sandbox_gid="$(id -g)" + fi local normalizer normalizer="$(resolve_mutable_config_normalizer)" || return 2 - python3 -I "$normalizer" classify-seal "$config_dir" >/dev/null + python3 -I "$normalizer" classify-seal \ + "$config_dir" "$sandbox_uid" "$sandbox_gid" >/dev/null } reclaim_collapsed_mutable_config() { local config_dir="$1" - [ "$(id -u)" -eq 0 ] || return 0 + if [ "$(id -u)" -ne 0 ]; then + if classify_openclaw_config_seal "$config_dir"; then + return 0 + fi + printf '[SECURITY] Refusing mutable config reclaim — root privileges are required\n' >&2 + return 1 + fi local sandbox_uid sandbox_gid if ! sandbox_uid="$(id -u sandbox)" || ! sandbox_gid="$(id -g sandbox)"; then @@ -757,6 +776,36 @@ openclaw_locked_parent_is_protected() { esac } +prepare_openclaw_config_startup() { + run_openclaw_config_guard revoke-startup-ready --startup-owner || return 1 + + # A persisted #6300 root:root 0700/0600 mutable tree overlaps one broad + # orphan-freeze discriminator in the transaction guard. Repair only that + # exact signature before recovery; sealed and indeterminate states remain + # untouched for the guard to verify or recover under its mutation mutex. + if [ "$(openclaw_config_dir_owner /sandbox/.openclaw)" = "root" ]; then + local seal_state=0 + classify_openclaw_config_seal /sandbox/.openclaw || seal_state=$? + case "$seal_state" in + 0 | 2) ;; + 1) reclaim_collapsed_mutable_config /sandbox/.openclaw || return 1 ;; + *) + printf '[SECURITY] Refusing mutable config startup — invalid seal classification %s\n' \ + "$seal_state" >&2 + return 1 + ;; + esac + fi + + run_openclaw_config_guard recover --startup-owner || return 1 + if [ "$(stat -c '%a %U:%G' /sandbox/.openclaw 2>/dev/null || true)" = "500 root:root" ]; then + echo "[config-guard] resuming interrupted recursive OpenClaw state lock" >&2 + timeout --signal=TERM --kill-after=5s 12m \ + python3 -I "$_OPENCLAW_STATE_DIR_GUARD" lock \ + --config-dir /sandbox/.openclaw || return 1 + fi +} + prepare_openclaw_config_for_write() { local config_file="$1" local hash_file="$2" @@ -4685,14 +4734,7 @@ handle_openclaw_gateway_control_request() { # OpenClaw config. Recovery runs before the locked-parent discriminator so a # crash in a prior config write/restart/handoff can complete deterministically. if [ "$(id -u)" -eq 0 ]; then - run_openclaw_config_guard revoke-startup-ready --startup-owner || exit 1 - run_openclaw_config_guard recover --startup-owner || exit 1 - if [ "$(stat -c '%a %U:%G' /sandbox/.openclaw 2>/dev/null || true)" = "500 root:root" ]; then - echo "[config-guard] resuming interrupted recursive OpenClaw state lock" >&2 - timeout --signal=TERM --kill-after=5s 12m \ - python3 -I "$_OPENCLAW_STATE_DIR_GUARD" lock \ - --config-dir /sandbox/.openclaw || exit 1 - fi + prepare_openclaw_config_startup || exit 1 fi # A root-owned config directory is the shields-up discriminator. Its parent diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index 45b8ba320c1..7fc7c1a74d8 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -39,6 +39,14 @@ function mode(filePath: string): number { return fs.statSync(filePath).mode & 0o7777; } +function replaceRequired(source: string, target: string, replacement: string): string { + const parts = source.split(target); + if (parts.length !== 2) { + throw new Error(`Expected exactly one replacement target: ${target}`); + } + return `${parts[0]}${replacement}${parts[1]}`; +} + const oneShotFunction = extractShellFunction("run_oneshot_command"); const resolveNormalizerFunction = extractShellFunction("resolve_mutable_config_normalizer"); @@ -117,7 +125,8 @@ describe("nemoclaw-start one-shot command lifecycle", () => { fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); fs.writeFileSync(path.join(configDir, ".config-hash"), "hash\n"); - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -219,7 +228,8 @@ describe("nemoclaw-start one-shot command lifecycle", () => { fs.writeFileSync(protectedTarget, "protected\n", { mode: 0o640 }); const initialProtectedMode = mode(protectedTarget); - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -258,7 +268,8 @@ describe("nemoclaw-start one-shot command lifecycle", () => { const initialProtectedMode = mode(protectedTarget); fs.symlinkSync(protectedTarget, configDir); - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -295,8 +306,9 @@ describe("nemoclaw-start one-shot command lifecycle", () => { fs.chmodSync(configDir, 0o700); fs.chmodSync(path.join(configDir, "openclaw.json"), 0o600); - const injectedNormalizer = normalizerSource.replace( - " return root_fd, capture_source_fd\n", + const injectedNormalizer = replaceRequired( + normalizerSource, + " return root_fd, capture_source_fd\n except Exception:\n", [ ` os.rename(config_dir, ${JSON.stringify(normalizedDir)})`, " os.mkdir(config_dir, 0o700)", @@ -307,11 +319,13 @@ describe("nemoclaw-start one-shot command lifecycle", () => { ' os.chmod(os.path.join(config_dir, "openclaw.json"), 0o600)', ' os.chmod(os.path.join(config_dir, ".config-hash"), 0o600)', " return root_fd, capture_source_fd", + " except Exception:", "", ].join("\n"), ); fs.writeFileSync(normalizerPath, injectedNormalizer); - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -355,7 +369,8 @@ describe("nemoclaw-start one-shot command lifecycle", () => { fs.writeFileSync(path.join(configDir, `filler-${index}`), "x\n"); } - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -405,6 +420,7 @@ describe("nemoclaw-start one-shot command lifecycle", () => { const classifyFunction = extractShellFunction("classify_openclaw_config_seal"); const reclaimFunction = extractShellFunction("reclaim_collapsed_mutable_config"); +const prepareStartupFunction = extractShellFunction("prepare_openclaw_config_startup"); const runningAsRoot = process.getuid?.() === 0; function runClassify(configDir: string) { @@ -419,30 +435,61 @@ function runClassify(configDir: string) { return runBash(script); } +describe("nemoclaw-start mutable config startup ordering", () => { + it.each([ + [1, ["guard:revoke-startup-ready", "reclaim", "guard:recover"]], + [2, ["guard:revoke-startup-ready", "guard:recover"]], + ])("orders seal state %s before transaction recovery (#6300)", (sealState, expected) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-startup-order-")); + const events = path.join(root, "events"); + const script = [ + "set -euo pipefail", + `events=${JSON.stringify(events)}`, + 'run_openclaw_config_guard() { printf "guard:%s\\n" "$1" >>"$events"; }', + 'openclaw_config_dir_owner() { printf "root\\n"; }', + `classify_openclaw_config_seal() { return ${String(sealState)}; }`, + 'reclaim_collapsed_mutable_config() { printf "reclaim\\n" >>"$events"; }', + "stat() { return 1; }", + prepareStartupFunction, + "prepare_openclaw_config_startup", + ].join("\n"); + try { + expect(runBash(script).status).toBe(0); + expect(fs.readFileSync(events, "utf-8").trim().split("\n")).toEqual(expected); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("nemoclaw-start mutable config seal classification", () => { - it("never reports a group-writable mutable directory as a shields-up seal", () => { + it("reports a non-root mutable directory as indeterminate", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-mutable-")); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir, 0o2770); fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); try { - expect(runClassify(configDir).stdout).toContain("rc=1"); + expect(runClassify(configDir).stdout).toContain("rc=2"); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); - it("treats the 0755 directory with a 0444 config as sealed only when it is root-owned", () => { + it("requires both fixed files to match the exact root-owned sealed posture", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-owner-")); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir, 0o755); const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); fs.writeFileSync(configFile, "{}\n"); + fs.writeFileSync(hashFile, "hash\n"); fs.chmodSync(configFile, 0o444); + fs.chmodSync(hashFile, 0o444); try { - expect(runClassify(configDir).stdout).toContain(runningAsRoot ? "rc=0" : "rc=1"); + expect(runClassify(configDir).stdout).toContain(runningAsRoot ? "rc=0" : "rc=2"); } finally { fs.chmodSync(configFile, 0o644); + fs.chmodSync(hashFile, 0o644); fs.rmSync(root, { recursive: true, force: true }); } }); @@ -475,7 +522,7 @@ const nobodyGid = spawnSync("id", ["-g", "nobody"], { encoding: "utf-8" }).stdou describe("nemoclaw-start mutable config reclaim", () => { it.skipIf(runningAsRoot)( - "leaves the tree untouched when it cannot reclaim ownership without root (#6300)", + "fails closed without root and leaves the tree untouched (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-nonroot-")); const configDir = path.join(root, ".openclaw"); @@ -484,6 +531,8 @@ describe("nemoclaw-start mutable config reclaim", () => { const beforeUid = fs.statSync(configDir).uid; const script = [ "set -uo pipefail", + resolveNormalizerFunction, + classifyFunction, reclaimFunction, "rc=0", `reclaim_collapsed_mutable_config ${JSON.stringify(configDir)} || rc=$?`, @@ -491,7 +540,8 @@ describe("nemoclaw-start mutable config reclaim", () => { ].join("\n"); try { const result = runBash(script); - expect(result.stdout).toContain("rc=0"); + expect(result.stdout).toContain("rc=1"); + expect(result.stderr).toContain("root privileges are required"); expect(fs.statSync(configDir).uid).toBe(beforeUid); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -503,6 +553,8 @@ describe("nemoclaw-start mutable config reclaim", () => { "reclaims a root-owned collapsed config to the sandbox contract and permits sandbox writes (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-root-")); + fs.chownSync(root, Number(nobodyUid), Number(nobodyGid)); + fs.chmodSync(root, 0o755); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir); fs.chmodSync(configDir, 0o700); @@ -513,13 +565,16 @@ describe("nemoclaw-start mutable config reclaim", () => { fs.writeFileSync(hashFile, "hash\n"); fs.chmodSync(hashFile, 0o600); - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); - const patchedReclaimFunction = reclaimFunction - .replace("id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`) - .replace("id -g sandbox", `echo ${JSON.stringify(nobodyGid)}`); + const patchedReclaimFunction = replaceRequired( + replaceRequired(reclaimFunction, "id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`), + "id -g sandbox", + `echo ${JSON.stringify(nobodyGid)}`, + ); const script = [ "set -euo pipefail", resolveNormalizerFunction, @@ -557,6 +612,8 @@ describe("nemoclaw-start mutable config reclaim", () => { "leaves a root-owned recovery baseline untouched during reclaim (#6307)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reclaim-baseline-")); + fs.chownSync(root, Number(nobodyUid), Number(nobodyGid)); + fs.chmodSync(root, 0o755); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir); fs.chmodSync(configDir, 0o700); @@ -572,13 +629,16 @@ describe("nemoclaw-start mutable config reclaim", () => { const beforeBaselineUid = fs.statSync(baselineFile).uid; const beforeBaselineGid = fs.statSync(baselineFile).gid; - const normalizeFunction = extractShellFunction("normalize_mutable_config_perms").replace( + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); - const patchedReclaimFunction = reclaimFunction - .replace("id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`) - .replace("id -g sandbox", `echo ${JSON.stringify(nobodyGid)}`); + const patchedReclaimFunction = replaceRequired( + replaceRequired(reclaimFunction, "id -u sandbox", `echo ${JSON.stringify(nobodyUid)}`), + "id -g sandbox", + `echo ${JSON.stringify(nobodyGid)}`, + ); const script = [ "set -euo pipefail", resolveNormalizerFunction, @@ -591,6 +651,15 @@ describe("nemoclaw-start mutable config reclaim", () => { try { const result = runBash(script); expect(result.status).toBe(0); + expect(mode(configDir)).toBe(0o2770); + expect(mode(configFile)).toBe(0o660); + expect(mode(hashFile)).toBe(0o660); + expect(fs.statSync(configDir).uid.toString()).toBe(nobodyUid); + expect(fs.statSync(configDir).gid.toString()).toBe(nobodyGid); + expect(fs.statSync(configFile).uid.toString()).toBe(nobodyUid); + expect(fs.statSync(configFile).gid.toString()).toBe(nobodyGid); + expect(fs.statSync(hashFile).uid.toString()).toBe(nobodyUid); + expect(fs.statSync(hashFile).gid.toString()).toBe(nobodyGid); expect(mode(baselineFile)).toBe(0o440); expect(fs.statSync(baselineFile).uid).toBe(beforeBaselineUid); expect(fs.statSync(baselineFile).gid).toBe(beforeBaselineGid); diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index f500efc1b5c..1f7fe584c0f 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -48,12 +48,18 @@ function extractShellFunctionFromSource(src: string, name: string): string { return `${name}() {${match[1]}\n}`; } +function replaceRequired(source: string, target: string, replacement: string): string { + const parts = source.split(target); + if (parts.length !== 2) { + throw new Error(`Expected exactly one replacement target: ${target}`); + } + return `${parts[0]}${replacement}${parts[1]}`; +} + function normalizeMutableConfigPermsFor(configDir: string): string { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const normalizeFunction = extractShellFunctionFromSource( - startScript, - "normalize_mutable_config_perms", - ).replace( + const normalizeFunction = replaceRequired( + extractShellFunctionFromSource(startScript, "normalize_mutable_config_perms"), 'local config_dir="/sandbox/.openclaw"', `local config_dir=${JSON.stringify(configDir)}`, ); @@ -65,7 +71,13 @@ function normalizeMutableConfigPermsFor(configDir: string): string { startScript, "reclaim_collapsed_mutable_config", ); - return [resolveNormalizerFunction, reclaimFunction, normalizeFunction].join("\n"); + const classifyFunction = extractShellFunctionFromSource( + startScript, + "classify_openclaw_config_seal", + ); + return [resolveNormalizerFunction, classifyFunction, reclaimFunction, normalizeFunction].join( + "\n", + ); } function modeBits(filePath: string): number { @@ -717,9 +729,9 @@ process.stdout.write(JSON.stringify(calls)); "set -euo pipefail", // Model the descriptor observing root ownership without requiring // the test runner itself to own this fixture as root: the initial - // classification reports uid 0, and reclaim-if-unsealed reports a - // sealed/no-op tree, so normalize must never reach chmod or find. - 'python3() { if [ "${2:-}" = "-" ]; then cat >/dev/null; printf "0\\n"; return 0; fi; if [ "${3:-}" = "reclaim-if-unsealed" ]; then return 0; fi; printf "unexpected helper invocation\\n" >&2; return 68; }', + // classification reports uid 0, and classify-seal reports a + // sealed tree, so normalize must never reach chmod or find. + 'python3() { if [ "${2:-}" = "-" ]; then cat >/dev/null; printf "0\\n"; return 0; fi; if [ "${3:-}" = "classify-seal" ]; then return 0; fi; printf "unexpected helper invocation\\n" >&2; return 68; }', 'chmod() { printf "CHMOD %s\\n" "$*" >&2; exit 66; }', 'find() { printf "FIND %s\\n" "$*" >&2; exit 67; }', normalizeMutableConfigPermsFor(configDir), From a2416bf04026fbe694a6fd2971bf3aa5bbad4a16 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:11:25 -0700 Subject: [PATCH 07/11] test(shields): prove root config reclaim boundary Signed-off-by: Apurv Kumaria --- test/e2e-gateway-isolation.sh | 178 ++++++++++++++++++++++++++++++++-- 1 file changed, 168 insertions(+), 10 deletions(-) diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 7e40773dd5e..c69ca1e87b7 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -668,10 +668,12 @@ else fail "owner-UID mismatch was not rejected safely: $OUT" fi -# ── Test 30b: Baseline lock fails closed without CAP_SETGID ────── +# ── Test 30b: Baseline lock requires both identity capabilities ── -info "30b. One-shot cleanup reports a missing CAP_SETGID precondition" -OUT=$(docker run --rm --user 0:0 --cap-drop DAC_OVERRIDE --cap-drop SETGID --entrypoint bash "$IMAGE" -lc ' +for DROPPED_CAPABILITY in SETGID SETUID; do + info "30b. One-shot cleanup reports a missing CAP_${DROPPED_CAPABILITY} precondition" + OUT=$(docker run --rm --user 0:0 --cap-drop DAC_OVERRIDE \ + --cap-drop "$DROPPED_CAPABILITY" --entrypoint bash "$IMAGE" -lc ' set -euo pipefail { sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start @@ -692,14 +694,15 @@ PY_ASSERT_GROUP_ABSENT after=$(stat -c "%u %g %a" /sandbox/.openclaw) [ "$rc" -eq 1 ] [ "$before" = "$after" ] - printf "CAP_SETGID_REFUSAL_OK\n" + printf "IDENTITY_CAPABILITY_REFUSAL_OK\n" ' 2>&1 || true) -if echo "$OUT" | grep -q "CAP_SETGID_REFUSAL_OK" \ - && echo "$OUT" | grep -q "CAP_SETGID is required"; then - pass "baseline lock fails closed with an actionable CAP_SETGID diagnostic" -else - fail "missing CAP_SETGID was not reported safely: $OUT" -fi + if echo "$OUT" | grep -q "IDENTITY_CAPABILITY_REFUSAL_OK" \ + && echo "$OUT" | grep -q "CAP_${DROPPED_CAPABILITY}"; then + pass "baseline lock fails closed with an actionable CAP_${DROPPED_CAPABILITY} diagnostic" + else + fail "missing CAP_${DROPPED_CAPABILITY} was not reported safely: $OUT" + fi +done # ── Test 30c: Post-override capture severs hardlink aliases ───── @@ -844,6 +847,161 @@ else fail "root repair executed an environment-selected helper: $OUT" fi +# ── Test 30g: Exact root-owned boot recovery is fail-closed ────── + +info "30g. Boot recovery reclaims only the exact root-owned mutable signature" +OUT=$(docker run --rm --entrypoint bash "$IMAGE" -lc ' + set -euo pipefail + trap '\''printf "ROOT_BOOT_RECLAIM_FAIL line=%s status=%s\n" "$LINENO" "$?" >&2'\'' ERR + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^classify_openclaw_config_seal() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^reclaim_collapsed_mutable_config() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^openclaw_config_dir_owner() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^prepare_openclaw_config_startup() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/reclaim.sh + test -s /tmp/reclaim.sh + source /tmp/reclaim.sh + + chown sandbox:sandbox /sandbox + chmod 755 /sandbox + chown root:root /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + chmod 700 /sandbox/.openclaw + chmod g-s /sandbox/.openclaw + chmod 600 /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + run_openclaw_config_guard() { + case "$1" in + revoke-startup-ready) return 0 ;; + recover) + [ "$(stat -c "%a %U:%G" /sandbox/.openclaw)" = "2770 sandbox:sandbox" ] + return + ;; + *) return 90 ;; + esac + } + prepare_openclaw_config_startup + [ "$(stat -c "%a %U:%G" /sandbox/.openclaw)" = "2770 sandbox:sandbox" ] + [ "$(stat -c "%a %U:%G" /sandbox/.openclaw/openclaw.json)" = "660 sandbox:sandbox" ] + [ "$(stat -c "%a %U:%G" /sandbox/.openclaw/.config-hash)" = "660 sandbox:sandbox" ] + gosu sandbox sh -c "printf \" \" >>/sandbox/.openclaw/openclaw.json; touch /sandbox/.openclaw/reclaim-write-check" + + chown root:root /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + chmod 755 /sandbox/.openclaw + chmod g-s /sandbox/.openclaw + chmod 444 /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + sealed_before=$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash) + normalize_mutable_config_perms + [ "$sealed_before" = "$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash)" ] + ! gosu sandbox sh -c "printf x >>/sandbox/.openclaw/openclaw.json" + + chmod 644 /sandbox/.openclaw/openclaw.json + ambiguous_before=$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash) + rc=0 + normalize_mutable_config_perms || rc=$? + [ "$rc" -eq 1 ] + [ "$ambiguous_before" = "$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash)" ] + + chown root:sandbox /sandbox + chmod 1775 /sandbox + chmod 700 /sandbox/.openclaw + chmod g-s /sandbox/.openclaw + chmod 600 /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + parent_before=$(stat -c "%u %g %a" /sandbox /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash) + rc=0 + normalize_mutable_config_perms || rc=$? + [ "$rc" -eq 1 ] + [ "$parent_before" = "$(stat -c "%u %g %a" /sandbox /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash)" ] + chown sandbox:sandbox /sandbox + chmod 755 /sandbox + + rm -f /sandbox/.openclaw/openclaw.json + printf "{}\n" >/sandbox/reclaim-hardlink-target + chmod 600 /sandbox/reclaim-hardlink-target + chown root:root /sandbox/reclaim-hardlink-target /sandbox/.openclaw/.config-hash /sandbox/.openclaw + chmod 600 /sandbox/.openclaw/.config-hash + chmod 700 /sandbox/.openclaw + chmod g-s /sandbox/.openclaw + ln /sandbox/reclaim-hardlink-target /sandbox/.openclaw/openclaw.json + hardlink_before=$(stat -c "%u %g %a %h" /sandbox/reclaim-hardlink-target) + rc=0 + normalize_mutable_config_perms || rc=$? + [ "$rc" -eq 1 ] + [ "$hardlink_before" = "$(stat -c "%u %g %a %h" /sandbox/reclaim-hardlink-target)" ] + + rm -f /sandbox/.openclaw/openclaw.json + printf "protected\n" >/sandbox/reclaim-symlink-target + chmod 600 /sandbox/reclaim-symlink-target + chown root:root /sandbox/reclaim-symlink-target + ln -s /sandbox/reclaim-symlink-target /sandbox/.openclaw/openclaw.json + symlink_before=$(stat -c "%u %g %a" /sandbox/reclaim-symlink-target) + rc=0 + normalize_mutable_config_perms || rc=$? + [ "$rc" -eq 1 ] + [ "$symlink_before" = "$(stat -c "%u %g %a" /sandbox/reclaim-symlink-target)" ] + [ -L /sandbox/.openclaw/openclaw.json ] + + rm -f /sandbox/.openclaw/openclaw.json + printf "{}\n" >/sandbox/.openclaw/openclaw.json + chown root:root /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + chmod 700 /sandbox/.openclaw + chmod g-s /sandbox/.openclaw + chmod 600 /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + python3() { + if [ "${2:-}" = "-" ] && [ ! -e /tmp/reclaim-open-raced ]; then + command python3 "$@" + local classify_rc=$? + : >/tmp/reclaim-open-raced + mv /sandbox/.openclaw /sandbox/.openclaw-raced + return "$classify_rc" + fi + command python3 "$@" + } + race_output="" + rc=0 + race_output=$(normalize_mutable_config_perms 2>&1) || rc=$? + [ "$rc" -eq 1 ] + echo "$race_output" | grep -q "descriptor-safe reclaim detected an unsafe link, race, owner, or metadata state" + [ ! -e /sandbox/.openclaw ] + [ "$(stat -c "%u %g %a" /sandbox/.openclaw-raced)" = "0 0 700" ] + [ "$(stat -c "%u %g %a" /sandbox/.openclaw-raced/openclaw.json)" = "0 0 600" ] + [ "$(stat -c "%u %g %a" /sandbox/.openclaw-raced/.config-hash)" = "0 0 600" ] + printf "ROOT_BOOT_RECLAIM_OK\n" +' 2>&1 || true) +if echo "$OUT" | grep -q "ROOT_BOOT_RECLAIM_OK"; then + pass "root boot recovery repairs the exact mutable signature and rejects ambiguous links" +else + fail "root boot recovery contract failed: $OUT" +fi + +# ── Test 30h: Root recovery refuses mounted config trees ───────── + +info "30h. Boot recovery refuses a mounted .openclaw tree" +OUT=$(docker run --rm --tmpfs /sandbox/.openclaw:rw,mode=700,uid=0,gid=0 \ + --entrypoint bash "$IMAGE" -lc ' + set -euo pipefail + { + sed -n "/^resolve_mutable_config_normalizer() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^normalize_mutable_config_perms() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^reclaim_collapsed_mutable_config() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/reclaim.sh + source /tmp/reclaim.sh + printf "{}\n" >/sandbox/.openclaw/openclaw.json + printf "hash\n" >/sandbox/.openclaw/.config-hash + chmod 600 /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash + before=$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash) + rc=0 + normalize_mutable_config_perms || rc=$? + [ "$rc" -eq 1 ] + [ "$before" = "$(stat -c "%u %g %a" /sandbox/.openclaw /sandbox/.openclaw/openclaw.json /sandbox/.openclaw/.config-hash)" ] + printf "MOUNTED_RECLAIM_REFUSAL_OK\n" +' 2>&1 || true) +if echo "$OUT" | grep -q "MOUNTED_RECLAIM_REFUSAL_OK"; then + pass "root boot recovery leaves a mounted config tree untouched" +else + fail "mounted config tree was not rejected safely: $OUT" +fi + # ── Summary ────────────────────────────────────────────────────── echo "" From 232b9a5e1c74b6066e94a59c4a3c0e7b031b4424 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:11:55 -0700 Subject: [PATCH 08/11] docs(security): document root config recovery boundary Signed-off-by: Apurv Kumaria --- docs/reference/troubleshooting.mdx | 2 ++ docs/security/tcb-boundary.mdx | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 8f49a56bf37..83fe270f250 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1366,6 +1366,8 @@ $$nemoclaw doctor --fix `$$nemoclaw doctor` reports the drift as a `Config permissions` warning, and `--fix` restores `2770/660`. Restarting the sandbox repairs the same drift automatically when the config tree passes its safety checks, and NemoClaw's own `rebuild` re-applies the contract after its post-upgrade `openclaw doctor --fix` step. +For a persisted root-owned `700/600` tree, startup reclaims ownership only when both fixed config files have that exact posture under the expected sandbox-owned parent. +Other root-owned layouts, links, mounts, and ambiguous metadata fail closed so startup cannot mistake a shields-locked or unsafe tree for mutable drift. If startup reports `[SECURITY] Refusing mutable config permission normalization`, NemoClaw stops startup without following or modifying the unsafe target; safe permission repairs completed before detection are not rolled back. Rebuild with the current image and trusted host-side configuration instead of repairing the tree recursively. diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 8e861f0cc76..bb5b3b288e5 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -41,6 +41,7 @@ A successful build does not replace review of privilege, process identity, descr | Component | Execution and privilege | Trusted input | Security responsibility | |---|---|---|---| | `scripts/state-dir-guard.py` | The installed copy is root-owned and mode `0500`; the host reaches it through the shields transaction. | Fixed paths, a bounded action contract, and a lock token from the host coordinator. | Applies descriptor-rooted state-directory posture changes, rejects link and mount substitution, bounds traversal, and verifies the committed modes and ownership. | +| `scripts/lib/normalize_mutable_config_perms.py` | The installed copy is root-owned and mode `0555`; startup invokes it under the entrypoint identity, and only root can reclaim a root-owned tree. | The fixed OpenClaw config path, the resolved sandbox identity, and an exact `root:root 0700/0600` mutable-drift signature under the expected sandbox-owned parent. | Restores the mutable `2770/660` contract, pins every privileged handoff by descriptor, and rejects ambiguous posture, links, mount substitution, metadata races, and sealed config. | | `scripts/openclaw-config-guard.py` | The installed copy is root-owned and mode `0500`; direct root PID 1 or the authenticated host transaction invokes it. | Bounded strict JSON for writes, stable captured config bytes for restart validation, and fixed installed parser paths for existing JSON5 config. | Seals and unseals OpenClaw config with no-follow descriptors, stable inode checks, atomic replacement, hash coherence, and recoverable transaction journals. | | `scripts/managed-gateway-control.py` | The installed copy is root-owned and mode `0500`; the host invokes it through sanitized registry-scoped direct-container execution. | A fixed action, a 64-character nonce, fixed installed helpers, and a live OpenShell process tree observed through `/proc`. | Authenticates the host action, proves the managed supervisor and gateway identity, holds a root-owned mode `0600` lifecycle lock, publishes one root-owned mode `0444` exact-exit authorization bound to the gateway and live root controller identities, signals through a pidfd, waits for the normal respawn loop, and verifies listener and HTTP health. | | `src/lib/shields/transition-lock.ts` | Runs in the host CLI under the operator account and owns the canonical per-sandbox transition lock. | Host state directory entries whose owner PID and start identity match the live lock owner. | Serializes shields mutations, rejects ambiguous or reused owners, and allows takeover only through the explicit recovery contract. | From 4c58c650fb38eb03cdd39f25770c4e4552dc1afa Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:23:00 -0700 Subject: [PATCH 09/11] test(shields): keep replacement checks linear Signed-off-by: Apurv Kumaria --- test/nemoclaw-start-perms.test.ts | 4 +--- test/repro-2681-group-writable.test.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index 7fc7c1a74d8..cc0d48c34b8 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -41,9 +41,7 @@ function mode(filePath: string): number { function replaceRequired(source: string, target: string, replacement: string): string { const parts = source.split(target); - if (parts.length !== 2) { - throw new Error(`Expected exactly one replacement target: ${target}`); - } + expect(parts, `Expected exactly one replacement target: ${target}`).toHaveLength(2); return `${parts[0]}${replacement}${parts[1]}`; } diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 1f7fe584c0f..c3c40c09cde 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -50,9 +50,7 @@ function extractShellFunctionFromSource(src: string, name: string): string { function replaceRequired(source: string, target: string, replacement: string): string { const parts = source.split(target); - if (parts.length !== 2) { - throw new Error(`Expected exactly one replacement target: ${target}`); - } + expect(parts, `Expected exactly one replacement target: ${target}`).toHaveLength(2); return `${parts[0]}${replacement}${parts[1]}`; } From 9d5c2a2c1fc07e59ce0ef8d548fb5d234d058b65 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:30:37 -0700 Subject: [PATCH 10/11] test(shields): add issue refs to seal cases Signed-off-by: Apurv Kumaria --- test/nemoclaw-start-perms.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index cc0d48c34b8..a6433b42d73 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -461,7 +461,7 @@ describe("nemoclaw-start mutable config startup ordering", () => { }); describe("nemoclaw-start mutable config seal classification", () => { - it("reports a non-root mutable directory as indeterminate", () => { + it("reports a non-root mutable directory as indeterminate (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-mutable-")); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir, 0o2770); @@ -473,7 +473,7 @@ describe("nemoclaw-start mutable config seal classification", () => { } }); - it("requires both fixed files to match the exact root-owned sealed posture", () => { + it("requires both fixed files to match the exact root-owned sealed posture (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-owner-")); const configDir = path.join(root, ".openclaw"); fs.mkdirSync(configDir, 0o755); @@ -492,7 +492,7 @@ describe("nemoclaw-start mutable config seal classification", () => { } }); - it("reports a missing config directory as indeterminate", () => { + it("reports a missing config directory as indeterminate (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-missing-")); try { expect(runClassify(path.join(root, ".openclaw")).stdout).toContain("rc=2"); @@ -501,7 +501,7 @@ describe("nemoclaw-start mutable config seal classification", () => { } }); - it("reports a symlinked config directory as indeterminate", () => { + it("reports a symlinked config directory as indeterminate (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-symlink-")); const realDir = path.join(root, "real"); const linkDir = path.join(root, ".openclaw"); From c73ef23e576debd92a31010d5949bc4d7ff5e347 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 7 Jul 2026 11:45:04 -0700 Subject: [PATCH 11/11] test(shields): require both sealed config files Signed-off-by: Apurv Kumaria --- test/nemoclaw-start-perms.test.ts | 39 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index a6433b42d73..eeef41ba16a 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -473,24 +473,27 @@ describe("nemoclaw-start mutable config seal classification", () => { } }); - it("requires both fixed files to match the exact root-owned sealed posture (#6300)", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-owner-")); - const configDir = path.join(root, ".openclaw"); - fs.mkdirSync(configDir, 0o755); - const configFile = path.join(configDir, "openclaw.json"); - const hashFile = path.join(configDir, ".config-hash"); - fs.writeFileSync(configFile, "{}\n"); - fs.writeFileSync(hashFile, "hash\n"); - fs.chmodSync(configFile, 0o444); - fs.chmodSync(hashFile, 0o444); - try { - expect(runClassify(configDir).stdout).toContain(runningAsRoot ? "rc=0" : "rc=2"); - } finally { - fs.chmodSync(configFile, 0o644); - fs.chmodSync(hashFile, 0o644); - fs.rmSync(root, { recursive: true, force: true }); - } - }); + it.runIf(runningAsRoot)( + "requires both fixed files to match the exact root-owned sealed posture (#6300)", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-owner-")); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir, 0o755); + const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); + fs.writeFileSync(configFile, "{}\n"); + fs.writeFileSync(hashFile, "hash\n"); + fs.chmodSync(configFile, 0o444); + fs.chmodSync(hashFile, 0o444); + try { + expect(runClassify(configDir).stdout).toContain("rc=0"); + fs.rmSync(hashFile); + expect(runClassify(configDir).stdout).toContain("rc=2"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); it("reports a missing config directory as indeterminate (#6300)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seal-missing-"));