Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
92 changes: 90 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,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
Expand Down
91 changes: 91 additions & 0 deletions test/nemoclaw-start-perms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
Loading