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. | diff --git a/scripts/lib/normalize_mutable_config_perms.py b/scripts/lib/normalize_mutable_config_perms.py index 9f0d7ee01fb..bd3ba8b548f 100755 --- a/scripts/lib/normalize_mutable_config_perms.py +++ b/scripts/lib/normalize_mutable_config_perms.py @@ -178,6 +178,279 @@ def normalize_dir(directory_fd: int, *, top_level: bool = False) -> None: os.close(child_fd) +SEALED = 0 +UNSEALED = 1 +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) + or root_metadata.st_uid != 0 + or root_metadata.st_gid != 0 + ): + return INDETERMINATE + 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: + 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 + ) + + +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, +) -> None: + 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() + 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. + + 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: + 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 seal_state != UNSEALED: + raise UnsafeTree() + 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_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: + 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( root_fd: int, config_dir: str, @@ -1358,6 +1631,28 @@ def run_root_supervisor( def main() -> int: + if len(sys.argv) >= 2 and sys.argv[1] == "classify-seal": + 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 INDETERMINATE + 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: + 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 0942992b182..9ed862dcc32 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -519,6 +519,35 @@ 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 + # 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 + 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}" @@ -551,8 +580,20 @@ 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 + # 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 local expected_config_dir_uid expected_config_dir_gid if [ "$(id -u)" -eq 0 ]; then @@ -571,23 +612,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 @@ -622,6 +648,51 @@ PY_CLASSIFY_MUTABLE_CONFIG fi } +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" "$sandbox_uid" "$sandbox_gid" >/dev/null +} + +reclaim_collapsed_mutable_config() { + local config_dir="$1" + + 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 + printf '[SECURITY] Refusing mutable config reclaim — sandbox identity lookup failed\n' >&2 + return 1 + fi + + local normalizer + if ! normalizer="$(resolve_mutable_config_normalizer)"; then + printf '[SECURITY] Refusing mutable config reclaim — trusted normalizer is missing\n' >&2 + return 1 + fi + + 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 +} + # 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 @@ -705,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" @@ -4633,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/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 278fd9dc494..c69ca1e87b7 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 @@ -662,12 +668,17 @@ 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 "/^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) @@ -683,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 ───── @@ -698,6 +710,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 +761,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 +791,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 +827,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 @@ -829,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 "" diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts index f7e4276cf63..eeef41ba16a 100644 --- a/test/nemoclaw-start-perms.test.ts +++ b/test/nemoclaw-start-perms.test.ts @@ -39,7 +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); + expect(parts, `Expected exactly one replacement target: ${target}`).toHaveLength(2); + return `${parts[0]}${replacement}${parts[1]}`; +} + const oneShotFunction = extractShellFunction("run_oneshot_command"); +const resolveNormalizerFunction = extractShellFunction("resolve_mutable_config_normalizer"); describe("nemoclaw-start one-shot command lifecycle", () => { it("sources the trusted runtime env before preserving one-shot argv (#4504)", () => { @@ -116,12 +123,14 @@ 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)}`, ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -217,12 +226,14 @@ 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)}`, ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -255,12 +266,14 @@ 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)}`, ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, "rc=0", "normalize_mutable_config_perms || rc=$?", @@ -291,8 +304,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)", @@ -303,17 +317,20 @@ 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)}`, ); const script = [ "set -euo pipefail", `export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=${JSON.stringify(normalizerPath)}`, + resolveNormalizerFunction, normalizeFunction, "rc=0", "normalize_mutable_config_perms || rc=$?", @@ -350,12 +367,14 @@ 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)}`, ); const script = [ "set -euo pipefail", + resolveNormalizerFunction, normalizeFunction, oneShotFunction, "rc=0", @@ -396,3 +415,258 @@ 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) { + const script = [ + "set -uo pipefail", + resolveNormalizerFunction, + 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 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("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); + fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); + try { + expect(runClassify(configDir).stdout).toContain("rc=2"); + } finally { + 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-")); + 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 (#6300)", () => { + 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)( + "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"); + fs.mkdirSync(configDir, 0o2770); + fs.writeFileSync(path.join(configDir, "openclaw.json"), "{}\n"); + const beforeUid = fs.statSync(configDir).uid; + const script = [ + "set -uo pipefail", + resolveNormalizerFunction, + classifyFunction, + 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=1"); + expect(result.stderr).toContain("root privileges are required"); + 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-")); + fs.chownSync(root, Number(nobodyUid), Number(nobodyGid)); + fs.chmodSync(root, 0o755); + 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"); + fs.writeFileSync(configFile, "{}\n"); + fs.chmodSync(configFile, 0o600); + fs.writeFileSync(hashFile, "hash\n"); + fs.chmodSync(hashFile, 0o600); + + const normalizeFunction = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), + 'local config_dir="/sandbox/.openclaw"', + `local config_dir=${JSON.stringify(configDir)}`, + ); + 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, + 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 }); + } + }, + ); + + it.runIf(runningAsRoot)( + "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); + 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 = replaceRequired( + extractShellFunction("normalize_mutable_config_perms"), + 'local config_dir="/sandbox/.openclaw"', + `local config_dir=${JSON.stringify(configDir)}`, + ); + 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, + 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); + 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); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 18c9064c4ef..93887a8940f 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -4018,7 +4018,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { "#!/usr/bin/env bash", "set -euo pipefail", `export NEMOCLAW_MUTABLE_CONFIG_NORMALIZER=${JSON.stringify(helperPath)}`, - helperFns, + `${extractShellFunction("resolve_mutable_config_normalizer")}\n${helperFns}`, fn, "recover_openclaw_config_if_empty", ] @@ -4129,7 +4129,7 @@ describe("openclaw.json baseline + recovery (#3118)", () => { const wrapper = [ "#!/usr/bin/env bash", "set -euo pipefail", - 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) diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 25924a6a058..c3c40c09cde 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -48,12 +48,34 @@ 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); + expect(parts, `Expected exactly one replacement target: ${target}`).toHaveLength(2); + return `${parts[0]}${replacement}${parts[1]}`; +} + function normalizeMutableConfigPermsFor(configDir: string): string { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - return 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)}`, ); + const resolveNormalizerFunction = extractShellFunctionFromSource( + startScript, + "resolve_mutable_config_normalizer", + ); + const reclaimFunction = extractShellFunctionFromSource( + startScript, + "reclaim_collapsed_mutable_config", + ); + const classifyFunction = extractShellFunctionFromSource( + startScript, + "classify_openclaw_config_seal", + ); + return [resolveNormalizerFunction, classifyFunction, reclaimFunction, normalizeFunction].join( + "\n", + ); } function modeBits(filePath: string): number { @@ -704,8 +726,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 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),