Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 195 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -622,6 +630,191 @@ 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"

[ "$(id -u)" -eq 0 ] || return 0

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 ! 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
}

# 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
Expand Down
149 changes: 149 additions & 0 deletions test/nemoclaw-start-perms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,152 @@ 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 });
}
});
});

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.skipIf(runningAsRoot)(
"leaves the tree untouched when it cannot reclaim ownership without root",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
() => {
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 });
}
},
);
});
Loading