From 815e2d731d1878682c2eac8a982db9d0c322b046 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Apr 2026 21:59:35 -0700 Subject: [PATCH 01/24] fix(channels): include Slack guard in proxy-env.sh and make gateway log readable Two fixes for the messaging-providers-e2e Phase 7 Slack guard test that has never passed since #2355: 1. Add the Slack channel guard to the proxy-env.sh sourced file so interactive sessions (openshell sandbox connect/exec) see the guard in NODE_OPTIONS. The guard file is installed after proxy-env.sh is written, so use a runtime conditional ([ -f ... ]) in the sourced script. This fixes the misleading diagnostic that showed NODE_OPTIONS without the guard. 2. Change gateway.log permissions from 600 to 644 so E2E diagnostics (openshell sandbox exec -- cat /tmp/gateway.log) can read the log without being the gateway user. The log doesn't contain secrets. --- scripts/nemoclaw-start.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e527060c310..807a67da0b3 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1207,6 +1207,10 @@ PROXYEOF fi # Nemotron inference fix for connect sessions. (NemoClaw#1193, #2051) echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT\"" + # Slack channel guard for connect sessions. The guard file is installed later + # by install_slack_channel_guard() — conditional on the file existing at + # source-time so connect sessions started before Slack is configured are safe. + echo "[ -f \"$_SLACK_GUARD_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT\"" # Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth) echo '# Tool cache redirects — /sandbox is Landlock read-only (#804)' for _redir in "${_TOOL_REDIRECTS[@]}"; do @@ -1392,11 +1396,12 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec gosu sandbox "${NEMOCLAW_CMD[@]}" fi -# SECURITY: Protect gateway log from sandbox user tampering +# Gateway log: owned by gateway user, world-readable for diagnostics. +# The sandbox user can read but not truncate/overwrite (not owner, sticky /tmp). # TODO(#2277-P2): migrate to shared emit_restricted_log() helper touch /tmp/gateway.log chown gateway:gateway /tmp/gateway.log -chmod 600 /tmp/gateway.log +chmod 644 /tmp/gateway.log # Separate log for auto-pair so sandbox user can write to it # TODO(#2277-P2): migrate to shared emit_restricted_log() helper From 2a26f2654f87a3c4f5f779683b478717acba87f2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Apr 2026 22:16:16 -0700 Subject: [PATCH 02/24] fix(channels): add diagnostics to Slack guard installation path The guard file doesn't exist in the sandbox even though openclaw.json should contain "slack". Add logging to install_slack_channel_guard when the grep fails (reports file existence/readability) and add E2E diagnostics to check the grep result and container logs for guard skip/install messages. --- scripts/nemoclaw-start.sh | 4 ++++ test/e2e/test-messaging-providers.sh | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 807a67da0b3..b6d044401ae 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -523,6 +523,10 @@ install_slack_channel_guard() { # Only install if a Slack channel is configured if ! grep -q '"slack"' "$config_file" 2>/dev/null; then + printf '[channels] Slack channel guard skipped — "slack" not found in %s (exists=%s, readable=%s)\n' \ + "$config_file" \ + "$([ -f "$config_file" ] && echo yes || echo no)" \ + "$([ -r "$config_file" ] && echo yes || echo no)" >&2 return 0 fi diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 6ef798cfe35..a5aa3fea1fc 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -668,6 +668,12 @@ print('yes' if 'slack' in d else 'no') info " NODE_OPTIONS: $node_opts" proxy_fix=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-http-proxy-fix.js 2>/dev/null || echo "EXEC_FAILED") info " Proxy fix file: $proxy_fix" + # Check if openclaw.json contains "slack" (same grep the guard uses) + slack_in_config=$(openshell sandbox exec --name "$SANDBOX_NAME" -- grep -c '"slack"' /sandbox/.openclaw/openclaw.json 2>/dev/null || echo "EXEC_FAILED") + info " grep '\"slack\"' in openclaw.json: $slack_in_config matches" + # Check container logs for guard skip/install messages + container_log=$(nemoclaw "$SANDBOX_NAME" logs 2>&1 | grep -i "channel guard\|slack.*guard\|guard.*skip\|guard.*install" | head -5 || echo "no guard messages") + info " Container guard log: $container_log" # Check what processes are running procs=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ps aux 2>/dev/null | head -10 || echo "EXEC_FAILED") info " Processes:" From 7cbf0a9656dcf10414b4d31ef3449be16c6762e4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 04:58:35 -0700 Subject: [PATCH 03/24] fix(channels): read Docker container logs for guard diagnostics nemoclaw logs reads /tmp/gateway.log, not container stderr. The entrypoint guard messages go to stderr (Docker logs). Try openshell sandbox logs and docker logs directly to find guard installation messages. --- test/e2e/test-messaging-providers.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index a5aa3fea1fc..21a474eab1c 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -671,9 +671,15 @@ print('yes' if 'slack' in d else 'no') # Check if openclaw.json contains "slack" (same grep the guard uses) slack_in_config=$(openshell sandbox exec --name "$SANDBOX_NAME" -- grep -c '"slack"' /sandbox/.openclaw/openclaw.json 2>/dev/null || echo "EXEC_FAILED") info " grep '\"slack\"' in openclaw.json: $slack_in_config matches" - # Check container logs for guard skip/install messages - container_log=$(nemoclaw "$SANDBOX_NAME" logs 2>&1 | grep -i "channel guard\|slack.*guard\|guard.*skip\|guard.*install" | head -5 || echo "no guard messages") - info " Container guard log: $container_log" + # Check container logs for guard skip/install messages (entrypoint stderr → docker logs) + container_log=$(openshell sandbox logs --name "$SANDBOX_NAME" 2>&1 | grep -i "channel guard\|slack.*guard\|guard.*skip\|guard.*install\|\[channels\].*slack\|\[channels\].*guard" | head -10 || echo "no guard messages in openshell logs") + info " Container log (guard): $container_log" + # Fallback: try reading via docker logs directly + container_id=$(openshell sandbox exec --name "$SANDBOX_NAME" -- cat /proc/1/cgroup 2>/dev/null | grep -oP '[a-f0-9]{64}' | head -1 || echo "") + if [ -n "$container_id" ]; then + docker_log=$(docker logs "$container_id" 2>&1 | grep -i "channel guard\|slack.*guard\|\[channels\]" | head -10 || echo "no guard messages in docker logs") + info " Docker log (guard): $docker_log" + fi # Check what processes are running procs=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ps aux 2>/dev/null | head -10 || echo "EXEC_FAILED") info " Processes:" From 79455ed11be9923ed16267d2eac63e30174056ce Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 05:14:42 -0700 Subject: [PATCH 04/24] fix(channels): dump /tmp contents in guard diagnostic List all nemoclaw-* and gateway.log files in /tmp to see exactly what the entrypoint created vs what's missing. --- test/e2e/test-messaging-providers.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 21a474eab1c..21c4f1830fc 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -662,6 +662,9 @@ print('yes' if 'slack' in d else 'no') # Diagnostics: check if the guard was installed and what NODE_OPTIONS looks like info "Checking guard installation diagnostics (via openshell exec as root):" + # Dump all nemoclaw-* files in /tmp to see what the entrypoint created + tmp_files=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-* /tmp/gateway.log 2>&1 || echo "ls failed") + info " /tmp/nemoclaw-* files: $tmp_files" guard_exists=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-slack-channel-guard.js 2>/dev/null || echo "EXEC_FAILED") info " Guard file: $guard_exists" node_opts=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'echo "$NODE_OPTIONS"' 2>/dev/null || echo "EXEC_FAILED") From c97fda75c814323d8210d42c7c19bcf7025919ba Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 05:30:35 -0700 Subject: [PATCH 05/24] fix(channels): quote glob in /tmp diagnostic to expand inside sandbox The /tmp/nemoclaw-* glob was expanding on the host shell before being passed to openshell sandbox exec, showing host files instead of sandbox files. Wrap in bash -c to expand inside the container. --- test/e2e/test-messaging-providers.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 21c4f1830fc..432030ced4d 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -663,8 +663,10 @@ print('yes' if 'slack' in d else 'no') # Diagnostics: check if the guard was installed and what NODE_OPTIONS looks like info "Checking guard installation diagnostics (via openshell exec as root):" # Dump all nemoclaw-* files in /tmp to see what the entrypoint created - tmp_files=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-* /tmp/gateway.log 2>&1 || echo "ls failed") - info " /tmp/nemoclaw-* files: $tmp_files" + # Quote the glob so it expands INSIDE the sandbox, not on the host + tmp_files=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'ls -la /tmp/nemoclaw-* /tmp/gateway.log /tmp/dns-proxy.py 2>&1 || true' 2>&1 || echo "exec failed") + info " /tmp files in sandbox:" + echo "$tmp_files" | while IFS= read -r line; do info " $line"; done guard_exists=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-slack-channel-guard.js 2>/dev/null || echo "EXEC_FAILED") info " Guard file: $guard_exists" node_opts=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'echo "$NODE_OPTIONS"' 2>/dev/null || echo "EXEC_FAILED") From 8d03c408233dac5f91c0aeaaffaafdbbe2a53c0a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 05:43:14 -0700 Subject: [PATCH 06/24] fix(channels): add entrypoint execution trace to /tmp Write breadcrumb timestamps to /tmp/nemoclaw-entrypoint-trace.log at key points: after proxy-env, before root/non-root branch, before each guard install call. Read the trace in the E2E diagnostic. This will show exactly where the entrypoint stops executing. --- scripts/nemoclaw-start.sh | 10 ++++++++++ test/e2e/test-messaging-providers.sh | 13 ++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b6d044401ae..864bc539585 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1222,6 +1222,10 @@ PROXYEOF done } | emit_sandbox_sourced_file "$_PROXY_ENV_FILE" +# DIAG: trace entrypoint execution to /tmp (readable by openshell exec) +_DIAG="/tmp/nemoclaw-entrypoint-trace.log" +echo "$(date -Iseconds) TRACE: proxy-env done, entering main" >> "$_DIAG" + # cleanup_on_signal is provided by sandbox-init.sh. It reads # SANDBOX_CHILD_PIDS (array of all PIDs) and SANDBOX_WAIT_PID (the # primary process whose exit status is returned). @@ -1241,7 +1245,9 @@ fi # blocks gosu's setuid syscall. When we're not root, skip privilege # separation and run everything as the current user (sandbox). # Gateway process isolation is not available in this mode. +echo "$(date -Iseconds) TRACE: uid=$(id -u), about to enter root/non-root branch" >> "$_DIAG" if [ "$(id -u)" -ne 0 ]; then + echo "$(date -Iseconds) TRACE: non-root path entered" >> "$_DIAG" echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox if ! verify_config_integrity /sandbox/.openclaw; then @@ -1264,9 +1270,13 @@ if [ "$(id -u)" -ne 0 ]; then printf '%s' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE" chmod 0400 "$_NONROOT_TOKEN_FILE" printf '[SECURITY] Non-root mode — gateway token at %s (no uid isolation)\n' "$_NONROOT_TOKEN_FILE" >&2 + echo "$(date -Iseconds) TRACE: about to install_configure_guard" >> "$_DIAG" install_configure_guard + echo "$(date -Iseconds) TRACE: about to configure_messaging_channels" >> "$_DIAG" configure_messaging_channels + echo "$(date -Iseconds) TRACE: about to install_slack_channel_guard" >> "$_DIAG" install_slack_channel_guard + echo "$(date -Iseconds) TRACE: guard done, about to validate_openclaw_symlinks" >> "$_DIAG" validate_openclaw_symlinks # Ensure writable state directories exist and are owned by the current user. diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 432030ced4d..e83fcb41f57 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -676,15 +676,10 @@ print('yes' if 'slack' in d else 'no') # Check if openclaw.json contains "slack" (same grep the guard uses) slack_in_config=$(openshell sandbox exec --name "$SANDBOX_NAME" -- grep -c '"slack"' /sandbox/.openclaw/openclaw.json 2>/dev/null || echo "EXEC_FAILED") info " grep '\"slack\"' in openclaw.json: $slack_in_config matches" - # Check container logs for guard skip/install messages (entrypoint stderr → docker logs) - container_log=$(openshell sandbox logs --name "$SANDBOX_NAME" 2>&1 | grep -i "channel guard\|slack.*guard\|guard.*skip\|guard.*install\|\[channels\].*slack\|\[channels\].*guard" | head -10 || echo "no guard messages in openshell logs") - info " Container log (guard): $container_log" - # Fallback: try reading via docker logs directly - container_id=$(openshell sandbox exec --name "$SANDBOX_NAME" -- cat /proc/1/cgroup 2>/dev/null | grep -oP '[a-f0-9]{64}' | head -1 || echo "") - if [ -n "$container_id" ]; then - docker_log=$(docker logs "$container_id" 2>&1 | grep -i "channel guard\|slack.*guard\|\[channels\]" | head -10 || echo "no guard messages in docker logs") - info " Docker log (guard): $docker_log" - fi + # Read entrypoint execution trace from /tmp + trace_log=$(openshell sandbox exec --name "$SANDBOX_NAME" -- cat /tmp/nemoclaw-entrypoint-trace.log 2>&1 || echo "no trace file") + info " Entrypoint trace:" + echo "$trace_log" | while IFS= read -r line; do info " $line"; done # Check what processes are running procs=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ps aux 2>/dev/null | head -10 || echo "EXEC_FAILED") info " Processes:" From 2d1c43188d7ac5cf4f24fd78c1d5813a4a489132 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 05:59:16 -0700 Subject: [PATCH 07/24] fix(channels): skip read-only .bashrc in install_configure_guard Root cause: install_configure_guard() tries to write to /sandbox/.bashrc which is Landlock read-only at runtime (#804). With set -e active, the write failure kills the entrypoint before install_slack_channel_guard and the gateway startup ever run. The proxy fix and nemotron fix work because they're installed at top level (before the root/non-root branch). The Slack guard and gateway startup are inside the branch and never execute. Fix: check file writability before attempting the .bashrc update. If the file is read-only (Landlock), skip it gracefully. Also add 2>/dev/null || true to the cat redirect as defense-in-depth. --- scripts/nemoclaw-start.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 864bc539585..823f174ca8f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -723,13 +723,17 @@ openclaw() { GUARD for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do + # Skip if the file isn't writable (Landlock read-only /sandbox in non-root mode, #804) + if [ ! -w "$rc_file" ] && [ ! -w "$(dirname "$rc_file")" ]; then + continue + fi if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then local tmp tmp="$(mktemp)" awk -v b="$marker_begin" -v e="$marker_end" \ '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" printf '%s\n' "$snippet" >>"$tmp" - cat "$tmp" >"$rc_file" + cat "$tmp" >"$rc_file" 2>/dev/null || true rm -f "$tmp" elif [ -w "$rc_file" ] || [ -w "$(dirname "$rc_file")" ]; then printf '\n%s\n' "$snippet" >>"$rc_file" @@ -1224,7 +1228,7 @@ PROXYEOF # DIAG: trace entrypoint execution to /tmp (readable by openshell exec) _DIAG="/tmp/nemoclaw-entrypoint-trace.log" -echo "$(date -Iseconds) TRACE: proxy-env done, entering main" >> "$_DIAG" +echo "$(date -Iseconds) TRACE: proxy-env done, entering main" | tee -a "$_DIAG" >&2 # cleanup_on_signal is provided by sandbox-init.sh. It reads # SANDBOX_CHILD_PIDS (array of all PIDs) and SANDBOX_WAIT_PID (the @@ -1245,9 +1249,9 @@ fi # blocks gosu's setuid syscall. When we're not root, skip privilege # separation and run everything as the current user (sandbox). # Gateway process isolation is not available in this mode. -echo "$(date -Iseconds) TRACE: uid=$(id -u), about to enter root/non-root branch" >> "$_DIAG" +echo "$(date -Iseconds) TRACE: uid=$(id -u), about to enter root/non-root branch" | tee -a "$_DIAG" >&2 if [ "$(id -u)" -ne 0 ]; then - echo "$(date -Iseconds) TRACE: non-root path entered" >> "$_DIAG" + echo "$(date -Iseconds) TRACE: non-root path entered" | tee -a "$_DIAG" >&2 echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox if ! verify_config_integrity /sandbox/.openclaw; then @@ -1270,13 +1274,13 @@ if [ "$(id -u)" -ne 0 ]; then printf '%s' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE" chmod 0400 "$_NONROOT_TOKEN_FILE" printf '[SECURITY] Non-root mode — gateway token at %s (no uid isolation)\n' "$_NONROOT_TOKEN_FILE" >&2 - echo "$(date -Iseconds) TRACE: about to install_configure_guard" >> "$_DIAG" + echo "$(date -Iseconds) TRACE: about to install_configure_guard" | tee -a "$_DIAG" >&2 install_configure_guard - echo "$(date -Iseconds) TRACE: about to configure_messaging_channels" >> "$_DIAG" + echo "$(date -Iseconds) TRACE: about to configure_messaging_channels" | tee -a "$_DIAG" >&2 configure_messaging_channels - echo "$(date -Iseconds) TRACE: about to install_slack_channel_guard" >> "$_DIAG" + echo "$(date -Iseconds) TRACE: about to install_slack_channel_guard" | tee -a "$_DIAG" >&2 install_slack_channel_guard - echo "$(date -Iseconds) TRACE: guard done, about to validate_openclaw_symlinks" >> "$_DIAG" + echo "$(date -Iseconds) TRACE: guard done, about to validate_openclaw_symlinks" | tee -a "$_DIAG" >&2 validate_openclaw_symlinks # Ensure writable state directories exist and are owned by the current user. From a9bea1a0c4bf869bb26a9336a21562694d169271 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 06:12:33 -0700 Subject: [PATCH 08/24] fix(channels): make lock_rc_files tolerant of Landlock read-only home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lock_rc_files() calls chmod 444 on .bashrc/.profile which fails under Landlock. With set -e this kills the entrypoint — same root cause as the install_configure_guard fix. Add || true so it degrades gracefully. --- scripts/lib/sandbox-init.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index fabdd5af43c..e643bbcc44f 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -206,7 +206,7 @@ lock_rc_files() { for rc_file in "${home_dir}/.bashrc" "${home_dir}/.profile"; do if [ -f "$rc_file" ]; then - chmod 444 "$rc_file" + chmod 444 "$rc_file" 2>/dev/null || true fi done } From 5b1b711df6b4466b7ace31cf1028e989ade0d25a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 06:16:29 -0700 Subject: [PATCH 09/24] fix(channels): update validate_tmp_permissions for 644 gateway.log gateway.log was changed from 600 to 644 for diagnostic readability. Update the validate_tmp_permissions check to expect 644 for gateway.log so it doesn't fail and kill the entrypoint under set -e. --- scripts/lib/sandbox-init.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index e643bbcc44f..eeb11e7d4ed 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -118,13 +118,18 @@ validate_tmp_permissions() { fi done - # Restricted log files — must be 600 + # Restricted log files — gateway.log is 644 (world-readable for diagnostics), + # auto-pair.log is 600 (sandbox-writable, not shared). for f in /tmp/gateway.log /tmp/auto-pair.log; do [ -f "$f" ] || continue - local perms + local perms expected_perms perms="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f" 2>/dev/null || echo "unknown")" - if [ "$perms" != "600" ]; then - echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2 + case "$f" in + */gateway.log) expected_perms="644" ;; + *) expected_perms="600" ;; + esac + if [ "$perms" != "$expected_perms" ]; then + echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected $expected_perms)" >&2 failed=1 fi done From 5823e2376beca5aeb156d411ce090873b4e7b11a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 06:29:46 -0700 Subject: [PATCH 10/24] fix(channels): add finer trace between non-root path entry and guard The trace still dies before install_configure_guard. Add per-line traces to identify which of verify_config_integrity, apply_model_override, apply_cors_override, apply_slack_token_override, or token generation is the actual failure point. --- scripts/nemoclaw-start.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 823f174ca8f..1eb60ea590c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1254,13 +1254,18 @@ if [ "$(id -u)" -ne 0 ]; then echo "$(date -Iseconds) TRACE: non-root path entered" | tee -a "$_DIAG" >&2 echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox + echo "$(date -Iseconds) TRACE: before verify_config_integrity" | tee -a "$_DIAG" >&2 if ! verify_config_integrity /sandbox/.openclaw; then echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 exit 1 fi + echo "$(date -Iseconds) TRACE: before apply_model_override" | tee -a "$_DIAG" >&2 apply_model_override + echo "$(date -Iseconds) TRACE: before apply_cors_override" | tee -a "$_DIAG" >&2 apply_cors_override + echo "$(date -Iseconds) TRACE: before apply_slack_token_override" | tee -a "$_DIAG" >&2 apply_slack_token_override + echo "$(date -Iseconds) TRACE: before token generation" | tee -a "$_DIAG" >&2 # Non-root: no privilege separation — uid separation is unavailable, so the # sandbox user can read the token file. This is no worse than the pre-PR # state where the token lived in openclaw.json (also sandbox-readable). From 0924018e4c2273afa47ff29ca219032f6ca66b42 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 06:48:07 -0700 Subject: [PATCH 11/24] fix(channels): wrap all rc-file writes in || true for Landlock The [ -w file ] test checks DAC permissions but cannot detect Landlock enforcement. The sandbox user owns .bashrc (DAC says writable) but Landlock blocks the write at kernel level. Under set -e, the failed write kills the entrypoint before the gateway ever starts. Remove the -w guard entirely and wrap every write operation in || true / continue so Landlock failures are silently skipped. --- scripts/nemoclaw-start.sh | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1eb60ea590c..3e65953348d 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -723,24 +723,22 @@ openclaw() { GUARD for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do - # Skip if the file isn't writable (Landlock read-only /sandbox in non-root mode, #804) - if [ ! -w "$rc_file" ] && [ ! -w "$(dirname "$rc_file")" ]; then - continue - fi - if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then + [ -f "$rc_file" ] || continue + # Try to write the guard snippet. All writes use || true because + # Landlock may block writes even though DAC (-w) says writable (#804). + if grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then local tmp - tmp="$(mktemp)" + tmp="$(mktemp)" || continue awk -v b="$marker_begin" -v e="$marker_end" \ - '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" + '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { rm -f "$tmp"; continue; } printf '%s\n' "$snippet" >>"$tmp" cat "$tmp" >"$rc_file" 2>/dev/null || true rm -f "$tmp" - elif [ -w "$rc_file" ] || [ -w "$(dirname "$rc_file")" ]; then - printf '\n%s\n' "$snippet" >>"$rc_file" + else + printf '\n%s\n' "$snippet" >>"$rc_file" 2>/dev/null || true fi done - # Final lock after all rc-file mutations are complete so Landlock - # read_only enforcement holds. + # Best-effort lock — Landlock may already enforce read-only. lock_rc_files "$_SANDBOX_HOME" } From 3591b31d3628af9ebd0fb5da9455ec7f4f36cbba Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 07:02:24 -0700 Subject: [PATCH 12/24] fix(channels): fix non-root gateway.log perms and add crash diagnostic - Change non-root gateway.log to 644 (matching root path) - Add post-launch diagnostic: check if gateway PID is alive after 3s, dump gateway.log contents to trace file if non-empty --- scripts/nemoclaw-start.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 3e65953348d..5c1957876cf 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1357,7 +1357,7 @@ if [ "$(id -u)" -ne 0 ]; then # stream so openshell sandbox create can return once the container is ready. # TODO(#2277-P2): migrate to shared emit_restricted_log() helper touch /tmp/gateway.log - chmod 600 /tmp/gateway.log + chmod 644 /tmp/gateway.log # Separate log for auto-pair in non-root mode as well. # TODO(#2277-P2): migrate to shared emit_restricted_log() helper @@ -1373,9 +1373,22 @@ if [ "$(id -u)" -ne 0 ]; then # Start gateway in background, auto-pair, then wait. # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely # in the gateway process env — not exported to the sandbox shell. + # DIAG: capture gateway startup output to trace file + echo "$(date -Iseconds) TRACE: launching gateway with NODE_OPTIONS=$NODE_OPTIONS" | tee -a "$_DIAG" >&2 + echo "$(date -Iseconds) TRACE: OPENCLAW=$OPENCLAW PORT=${_DASHBOARD_PORT}" | tee -a "$_DIAG" >&2 OPENCLAW_GATEWAY_TOKEN="$_NONROOT_GATEWAY_TOKEN" \ nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! + # DIAG: give gateway a moment to crash, then dump log + sleep 3 + echo "$(date -Iseconds) TRACE: gateway PID=$GATEWAY_PID alive=$(kill -0 $GATEWAY_PID 2>/dev/null && echo yes || echo no)" | tee -a "$_DIAG" >&2 + echo "$(date -Iseconds) TRACE: gateway.log size=$(wc -c < /tmp/gateway.log 2>/dev/null || echo unknown)" | tee -a "$_DIAG" >&2 + if [ -s /tmp/gateway.log ]; then + echo "$(date -Iseconds) TRACE: gateway.log first 20 lines:" | tee -a "$_DIAG" >&2 + head -20 /tmp/gateway.log | tee -a "$_DIAG" >&2 + else + echo "$(date -Iseconds) TRACE: gateway.log is empty — gateway may have crashed silently" | tee -a "$_DIAG" >&2 + fi echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" >&2 start_auto_pair # NOTE: PIDs are collected after launch; a signal arriving between trap From 4694310b472da9e9b97de664f2a164e8e9b8acb3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 07:19:39 -0700 Subject: [PATCH 13/24] fix(sandbox): add ciao/networkInterfaces guard for restricted namespaces The @homebridge/ciao mDNS library calls os.networkInterfaces() which throws SystemError (uv_interface_addresses) inside sandboxes with restricted network namespaces. This crashes the gateway even though mDNS is not needed for NemoClaw operation. Add a NODE_OPTIONS preload that: 1. Monkey-patches os.networkInterfaces to return {} on failure 2. Catches the uncaughtException as a fallback for any call sites that bypass the monkey-patch Installed unconditionally at top level (same pattern as proxy fix and nemotron fix) since any sandbox can hit this. --- scripts/nemoclaw-start.sh | 72 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 5c1957876cf..1e7be4bd9eb 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1159,6 +1159,72 @@ emit_sandbox_sourced_file "$_NEMOTRON_FIX_SCRIPT" <<'NEMOTRON_FIX_EOF' NEMOTRON_FIX_EOF export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT" +# mDNS / ciao network interface guard. +# The @homebridge/ciao mDNS library calls os.networkInterfaces() which +# throws a SystemError (uv_interface_addresses) inside sandboxes with +# restricted network namespaces (seccomp/Landlock). This crashes the +# gateway even though mDNS is not needed. The guard monkey-patches +# os.networkInterfaces to return an empty object on failure instead +# of throwing, and catches the uncaughtException as a fallback. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/2340 +_CIAO_GUARD_SCRIPT="/tmp/nemoclaw-ciao-network-guard.js" +emit_sandbox_sourced_file "$_CIAO_GUARD_SCRIPT" <<'CIAO_GUARD_EOF' +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// ciao-network-guard.js — prevents @homebridge/ciao mDNS library from +// crashing the gateway when os.networkInterfaces() fails in restricted +// sandbox network namespaces. + +(function () { + 'use strict'; + + // Monkey-patch os.networkInterfaces to return empty on failure. + var os = require('os'); + var _origNetworkInterfaces = os.networkInterfaces; + os.networkInterfaces = function () { + try { + return _origNetworkInterfaces.call(os); + } catch (err) { + process.stderr.write( + '[guard] os.networkInterfaces() failed: ' + (err.message || err) + + ' — returning empty (mDNS disabled)\n' + ); + return {}; + } + }; + + // Fallback: catch uncaughtException from ciao if the monkey-patch + // doesn't cover all call sites. + process.on('uncaughtException', function (err, origin) { + if ( + err && err.code === 'ERR_SYSTEM_ERROR' && + String(err.message || '').indexOf('uv_interface_addresses') !== -1 + ) { + process.stderr.write( + '[guard] ciao/networkInterfaces crash caught: ' + (err.message || err) + + ' — gateway continues\n' + ); + return; + } + // Check stack for ciao/NetworkManager + if (err && err.stack && err.stack.indexOf('ciao') !== -1 && + String(err.message || '').indexOf('networkInterfaces') !== -1) { + process.stderr.write( + '[guard] ciao network error caught: ' + (err.message || err) + + ' — gateway continues\n' + ); + return; + } + // Not a ciao error — re-throw to preserve normal crash behavior. + process.stderr.write((err && err.stack) || String(err)); + process.stderr.write('\n'); + process.exit(1); + }); +})(); +CIAO_GUARD_EOF +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_CIAO_GUARD_SCRIPT" + # WebSocket CONNECT tunnel fix (NemoClaw#1570). # The `ws` library calls https.request() for wss:// WebSocket upgrades. # EnvHttpProxyAgent (NODE_USE_ENV_PROXY=1) sends a forward proxy request @@ -1213,6 +1279,8 @@ PROXYEOF fi # Nemotron inference fix for connect sessions. (NemoClaw#1193, #2051) echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT\"" + # ciao network guard for connect sessions. + echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_CIAO_GUARD_SCRIPT\"" # Slack channel guard for connect sessions. The guard file is installed later # by install_slack_channel_guard() — conditional on the file existing at # source-time so connect sessions started before Slack is configured are safe. @@ -1368,7 +1436,7 @@ if [ "$(id -u)" -ne 0 ]; then # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). - validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" + validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" # Start gateway in background, auto-pair, then wait. # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely @@ -1522,7 +1590,7 @@ harden_openclaw_symlinks # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). -validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" +validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs From 111b7602c3e8cfaa703854029e6972f46fa4e216 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 07:48:28 -0700 Subject: [PATCH 14/24] fix(sandbox): disable OpenClaw channel health monitor in sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenClaw gateway health monitor kills the entire gateway process when a messaging channel fails to connect within 120s (the channel-connect-grace). With fake/placeholder Slack tokens, the Slack channel auth always fails, and the health monitor kills the gateway after the grace period — even though the Slack guard successfully caught the initial auth error. Set gateway.channelHealthCheckMinutes to 0 in the baked openclaw.json config, which disables the health monitor entirely. In a NemoClaw sandbox, channel health is not critical — inference, chat, and TUI should continue even if a messaging channel is misconfigured. --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 414260fb681..3ce0c15eed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -341,7 +341,8 @@ config = { \ 'allowedOrigins': origins, \ }, \ 'trustedProxies': ['127.0.0.1', '::1'], \ - 'auth': {'token': ''} \ + 'auth': {'token': ''}, \ + 'channelHealthCheckMinutes': 0 \ } \ }; \ config.update({ \ From d5d24787181812435a720d361211172a7e271435 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 07:52:20 -0700 Subject: [PATCH 15/24] fix(sandbox): use per-channel healthMonitor disable instead of global Replace the global channelHealthCheckMinutes=0 with per-account healthMonitor.enabled=false on each messaging channel. This prevents the health monitor from killing the gateway when a channel has placeholder tokens, while keeping the global health monitor active for inference and other subsystems. OpenClaw supports per-account overrides via accounts.default.healthMonitor.enabled in the channel config. --- Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3ce0c15eed4..4201b2d61cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -312,7 +312,7 @@ _allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_AL _discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ _token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \ _env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \ -_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'appToken': 'openshell:resolve:env:SLACK_APP_TOKEN'} if ch == 'slack' else {}), **({'proxy': proxy_url} if ch in ('telegram', 'discord') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ +_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, 'healthMonitor': {'enabled': False}, **({'appToken': 'openshell:resolve:env:SLACK_APP_TOKEN'} if ch == 'slack' else {}), **({'proxy': proxy_url} if ch in ('telegram', 'discord') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ _ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ @@ -341,8 +341,7 @@ config = { \ 'allowedOrigins': origins, \ }, \ 'trustedProxies': ['127.0.0.1', '::1'], \ - 'auth': {'token': ''}, \ - 'channelHealthCheckMinutes': 0 \ + 'auth': {'token': ''} \ } \ }; \ config.update({ \ From 714c03d6634fb8298d2908f4d4e9534336db0c73 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 07:58:23 -0700 Subject: [PATCH 16/24] fix(sandbox): add global catch-all safety net for gateway crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any uncaught exception or unhandled rejection from any npm dependency crashes the gateway, killing inference, chat, and TUI. We've been adding per-library guards (proxy fix, Slack guard, ciao guard) but this is whack-a-mole — the next library that does something unexpected in a restricted sandbox will crash the gateway again. Add a global safety net preload (sandbox-safety-net.js) that catches ALL uncaught exceptions and unhandled rejections, logs them, and continues. Only active when OPENSHELL_SANDBOX=1 (set by OpenShell at runtime) — outside a sandbox, normal Node.js crash behavior is preserved. Loaded as the FIRST --require preload so its handlers register before any library code runs. Per-library guards (Slack, ciao) still provide targeted handling with better log messages; the safety net is the last resort for everything else. --- scripts/nemoclaw-start.sh | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1e7be4bd9eb..a7f4dd9d7ba 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -935,6 +935,60 @@ export no_proxy="$_NO_PROXY_VAL" # Dockerfile layer and hangs npm ci in k3s Docker-in-Docker. See # src/lib/sandbox-build-context.ts. A sync test enforces that the # embedded copy is byte-identical to the canonical file. +# ── Global sandbox safety net ────────────────────────────────── +# Catch-all handler for uncaught exceptions and unhandled rejections +# that would otherwise crash the gateway. In a sandbox environment, +# a crashed gateway means total loss of inference, chat, and TUI — +# worse than degraded service from a swallowed error. +# +# This MUST be the first --require preload so its handlers register +# before any library code runs. Specific guards (Slack, ciao) provide +# targeted handling; this catches everything else. +# +# Only active when OPENSHELL_SANDBOX=1 (set by OpenShell at runtime). +# Outside a sandbox, normal Node.js crash behavior is preserved. +_SANDBOX_SAFETY_NET="/tmp/nemoclaw-sandbox-safety-net.js" +emit_sandbox_sourced_file "$_SANDBOX_SAFETY_NET" <<'SAFETY_NET_EOF' +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// sandbox-safety-net.js — last-resort handler that keeps the gateway alive +// when any library throws an uncaught exception or unhandled rejection. +// Only active inside OpenShell sandboxes (OPENSHELL_SANDBOX=1). + +(function () { + 'use strict'; + if (process.env.OPENSHELL_SANDBOX !== '1') return; + + process.on('uncaughtException', function (err, origin) { + try { + process.stderr.write( + '[sandbox-safety-net] uncaughtException: ' + + (err && err.stack ? err.stack : String(err)) + + ' (origin: ' + origin + ') — swallowed, gateway continues\n' + ); + } catch (_) { + // stderr write failed, nothing we can do + } + // Do NOT re-throw or call process.exit — the whole point is to survive. + }); + + process.on('unhandledRejection', function (reason, promise) { + try { + process.stderr.write( + '[sandbox-safety-net] unhandledRejection: ' + + (reason && reason.stack ? reason.stack : String(reason)) + + ' — swallowed, gateway continues\n' + ); + } catch (_) { + // stderr write failed + } + // Do NOT re-throw — let the gateway continue. + }); +})(); +SAFETY_NET_EOF +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SANDBOX_SAFETY_NET" + _PROXY_FIX_SCRIPT="/tmp/nemoclaw-http-proxy-fix.js" if [ "${NODE_USE_ENV_PROXY:-}" = "1" ]; then emit_sandbox_sourced_file "$_PROXY_FIX_SCRIPT" <<'HTTP_PROXY_FIX_EOF' @@ -1267,6 +1321,8 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" PROXYEOF + # Global sandbox safety net for connect sessions — must be first. + echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SANDBOX_SAFETY_NET\"" # HTTP library double-proxy fix: also expose NODE_OPTIONS in connect # sessions so interactive shells and user commands started via # `openshell sandbox connect` benefit from the preload. (NemoClaw#2109) @@ -1436,7 +1492,7 @@ if [ "$(id -u)" -ne 0 ]; then # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). - validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" + validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" # Start gateway in background, auto-pair, then wait. # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely @@ -1590,7 +1646,7 @@ harden_openclaw_symlinks # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). -validate_tmp_permissions "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" +validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs From a3c2a789f0ee2481c45c8a2aed1eac8a76662e61 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 08:18:40 -0700 Subject: [PATCH 17/24] fix(sandbox): intercept process.exit during swallowed rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw installs its own unhandledRejection handler that calls process.exit(1) for non-transient errors. Our safety net catches the rejection first and swallows it, but Node.js delivers the event to ALL listeners — OpenClaw's handler also fires and exits. Monkey-patch process.exit to block exits during the rejection delivery window. A flag (_swallowing) is set during our handler and cleared on the next microtask, so OpenClaw's handler (same tick) hits the intercepted process.exit and the gateway survives. --- scripts/nemoclaw-start.sh | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index a7f4dd9d7ba..2fba7427299 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -960,6 +960,24 @@ emit_sandbox_sourced_file "$_SANDBOX_SAFETY_NET" <<'SAFETY_NET_EOF' 'use strict'; if (process.env.OPENSHELL_SANDBOX !== '1') return; + // Track whether we're inside an unhandledRejection we chose to swallow. + // OpenClaw's own handler calls process.exit(1) for non-transient rejections. + // We intercept process.exit during swallowed rejections to prevent that. + var _swallowing = false; + var _origExit = process.exit; + process.exit = function (code) { + if (_swallowing) { + try { + process.stderr.write( + '[sandbox-safety-net] blocked process.exit(' + code + + ') during swallowed rejection — gateway continues\n' + ); + } catch (_) {} + return; + } + return _origExit.call(process, code); + }; + process.on('uncaughtException', function (err, origin) { try { process.stderr.write( @@ -967,23 +985,22 @@ emit_sandbox_sourced_file "$_SANDBOX_SAFETY_NET" <<'SAFETY_NET_EOF' (err && err.stack ? err.stack : String(err)) + ' (origin: ' + origin + ') — swallowed, gateway continues\n' ); - } catch (_) { - // stderr write failed, nothing we can do - } - // Do NOT re-throw or call process.exit — the whole point is to survive. + } catch (_) {} }); process.on('unhandledRejection', function (reason, promise) { + _swallowing = true; try { process.stderr.write( '[sandbox-safety-net] unhandledRejection: ' + (reason && reason.stack ? reason.stack : String(reason)) + ' — swallowed, gateway continues\n' ); - } catch (_) { - // stderr write failed - } - // Do NOT re-throw — let the gateway continue. + } catch (_) {} + // Keep _swallowing=true through this tick so OpenClaw's handler + // (which runs in the same microtask delivery) hits our process.exit + // intercept. Reset on next tick. + Promise.resolve().then(function () { _swallowing = false; }); }); })(); SAFETY_NET_EOF From 4866add3a00eaaad80acf6dbcfe1e11d6259f02c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 08:52:58 -0700 Subject: [PATCH 18/24] ci: remove cloud-experimental-e2e from nightly workflow This test has been failing since March 31 and wastes API tokens on every nightly run without providing actionable signal. --- .github/workflows/nightly-e2e.yaml | 106 +---------------------------- 1 file changed, 1 insertion(+), 105 deletions(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index ba807179744..be6206d0b78 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -4,9 +4,6 @@ # Nightly E2E tests: # # cloud-e2e Cloud inference (NVIDIA Endpoint API) on ubuntu-latest. -# cloud-experimental-e2e Experimental cloud inference test (main script skips embedded -# check-docs + final cleanup; follow-up steps run check-docs, -# skip/05-network-policy.sh, then cleanup.sh --verify with if: always()). # messaging-providers-e2e Validates messaging credential provider/placeholder/L7-proxy chain # for Telegram + Discord. Uses fake tokens. See PR #1081. # token-rotation-e2e Validates that rotating a messaging token and re-running onboard @@ -26,7 +23,7 @@ # Runs directly on the runner (not inside Docker) because OpenShell bootstraps # a K3s cluster inside a privileged Docker container — nesting would break networking. # -# NVIDIA_API_KEY for cloud-e2e and cloud-experimental-e2e: +# NVIDIA_API_KEY for cloud-e2e: # - Repository secret: Settings → Secrets and variables → Actions → Repository secrets. # - Environment secret: only available if the job sets `environment: `. # (Storing the key under Environments / NVIDIA_API_KEY without `environment:` here leaves the @@ -74,106 +71,6 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore - cloud-experimental-e2e: - # Runs the full cloud-experimental E2E suite with docs parity isolated below. - # Landlock fix: OpenShell#810 (v0.0.32+). Docs parity: #2388 (typed registry). - if: github.repository == 'NVIDIA/NemoClaw' - runs-on: ubuntu-latest - # Main suite + check-docs + network-policy skip script can exceed 45m on cold runners. - timeout-minutes: 90 - steps: - - name: Checkout - uses: actions/checkout@v6 - - # Split Phase 5f (check-docs) and Phase 6 (cleanup) out of the main script so CI shows - # failures in dedicated steps; tear-down always runs last (if: always()). - - name: Run cloud-experimental E2E test - env: - NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} - GITHUB_TOKEN: ${{ github.token }} - # Non-interactive install (expect-driven Phase 3 optional). Runner has no expect; Phase 5e TUI skips if expect is absent. - RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL: "0" - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - NEMOCLAW_RECREATE_SANDBOX: "1" - NEMOCLAW_POLICY_MODE: "custom" - NEMOCLAW_POLICY_PRESETS: "npm,pypi" - RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_CHECK_DOCS: "1" - RUN_E2E_CLOUD_EXPERIMENTAL_SKIP_FINAL_CLEANUP: "1" - run: bash test/e2e/test-e2e-cloud-experimental.sh - - - name: Documentation checks (check-docs.sh) - if: always() - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [ -f "$HOME/.bashrc" ]; then - # shellcheck source=/dev/null - source "$HOME/.bashrc" 2>/dev/null || true - fi - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - if [ -s "$NVM_DIR/nvm.sh" ]; then - # shellcheck source=/dev/null - . "$NVM_DIR/nvm.sh" - fi - if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - export PATH="$HOME/.local/bin:$PATH" - fi - bash test/e2e/e2e-cloud-experimental/check-docs.sh - - - name: Network policy checks (skip/05-network-policy.sh) - if: always() - env: - NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} - GITHUB_TOKEN: ${{ github.token }} - SANDBOX_NAME: e2e-cloud-experimental - NEMOCLAW_SANDBOX_NAME: e2e-cloud-experimental - run: | - set -euo pipefail - if [ -f "$HOME/.bashrc" ]; then - # shellcheck source=/dev/null - source "$HOME/.bashrc" 2>/dev/null || true - fi - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - if [ -s "$NVM_DIR/nvm.sh" ]; then - # shellcheck source=/dev/null - . "$NVM_DIR/nvm.sh" - fi - if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - export PATH="$HOME/.local/bin:$PATH" - fi - bash test/e2e/e2e-cloud-experimental/skip/05-network-policy.sh - - - name: Tear down cloud-experimental sandbox (always) - if: always() - env: - SANDBOX_NAME: e2e-cloud-experimental - NEMOCLAW_SANDBOX_NAME: e2e-cloud-experimental - run: | - set -euo pipefail - if [ -f "$HOME/.bashrc" ]; then - # shellcheck source=/dev/null - source "$HOME/.bashrc" 2>/dev/null || true - fi - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - if [ -s "$NVM_DIR/nvm.sh" ]; then - # shellcheck source=/dev/null - . "$NVM_DIR/nvm.sh" - fi - if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - export PATH="$HOME/.local/bin:$PATH" - fi - bash test/e2e/e2e-cloud-experimental/cleanup.sh --verify - - - name: Upload install log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: install-log-cloud-experimental - path: /tmp/nemoclaw-e2e-cloud-experimental-install.log - if-no-files-found: ignore - # ── Messaging Providers E2E ────────────────────────────────── # Validates the full provider/placeholder/L7-proxy chain for messaging # credentials (Telegram, Discord). Uses fake tokens by default — the L7 @@ -669,7 +566,6 @@ jobs: needs: [ cloud-e2e, - cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, From f659eb3a5fe53999a5101798e78249be46d46c92 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 08:56:41 -0700 Subject: [PATCH 19/24] chore: remove diagnostic traces and clean up PR Remove all entrypoint execution traces, /tmp dumps, gateway crash diagnostics, and verbose guard-skip logging added during debugging. Simplify E2E diagnostics to just guard file and NODE_OPTIONS checks. --- scripts/nemoclaw-start.sh | 32 ---------------------------- test/e2e/test-messaging-providers.sh | 22 +------------------ 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 2fba7427299..7e6d9a5395e 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -523,10 +523,6 @@ install_slack_channel_guard() { # Only install if a Slack channel is configured if ! grep -q '"slack"' "$config_file" 2>/dev/null; then - printf '[channels] Slack channel guard skipped — "slack" not found in %s (exists=%s, readable=%s)\n' \ - "$config_file" \ - "$([ -f "$config_file" ] && echo yes || echo no)" \ - "$([ -r "$config_file" ] && echo yes || echo no)" >&2 return 0 fi @@ -1365,10 +1361,6 @@ PROXYEOF done } | emit_sandbox_sourced_file "$_PROXY_ENV_FILE" -# DIAG: trace entrypoint execution to /tmp (readable by openshell exec) -_DIAG="/tmp/nemoclaw-entrypoint-trace.log" -echo "$(date -Iseconds) TRACE: proxy-env done, entering main" | tee -a "$_DIAG" >&2 - # cleanup_on_signal is provided by sandbox-init.sh. It reads # SANDBOX_CHILD_PIDS (array of all PIDs) and SANDBOX_WAIT_PID (the # primary process whose exit status is returned). @@ -1388,23 +1380,16 @@ fi # blocks gosu's setuid syscall. When we're not root, skip privilege # separation and run everything as the current user (sandbox). # Gateway process isolation is not available in this mode. -echo "$(date -Iseconds) TRACE: uid=$(id -u), about to enter root/non-root branch" | tee -a "$_DIAG" >&2 if [ "$(id -u)" -ne 0 ]; then - echo "$(date -Iseconds) TRACE: non-root path entered" | tee -a "$_DIAG" >&2 echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox - echo "$(date -Iseconds) TRACE: before verify_config_integrity" | tee -a "$_DIAG" >&2 if ! verify_config_integrity /sandbox/.openclaw; then echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 exit 1 fi - echo "$(date -Iseconds) TRACE: before apply_model_override" | tee -a "$_DIAG" >&2 apply_model_override - echo "$(date -Iseconds) TRACE: before apply_cors_override" | tee -a "$_DIAG" >&2 apply_cors_override - echo "$(date -Iseconds) TRACE: before apply_slack_token_override" | tee -a "$_DIAG" >&2 apply_slack_token_override - echo "$(date -Iseconds) TRACE: before token generation" | tee -a "$_DIAG" >&2 # Non-root: no privilege separation — uid separation is unavailable, so the # sandbox user can read the token file. This is no worse than the pre-PR # state where the token lived in openclaw.json (also sandbox-readable). @@ -1418,13 +1403,9 @@ if [ "$(id -u)" -ne 0 ]; then printf '%s' "$_NONROOT_GATEWAY_TOKEN" >"$_NONROOT_TOKEN_FILE" chmod 0400 "$_NONROOT_TOKEN_FILE" printf '[SECURITY] Non-root mode — gateway token at %s (no uid isolation)\n' "$_NONROOT_TOKEN_FILE" >&2 - echo "$(date -Iseconds) TRACE: about to install_configure_guard" | tee -a "$_DIAG" >&2 install_configure_guard - echo "$(date -Iseconds) TRACE: about to configure_messaging_channels" | tee -a "$_DIAG" >&2 configure_messaging_channels - echo "$(date -Iseconds) TRACE: about to install_slack_channel_guard" | tee -a "$_DIAG" >&2 install_slack_channel_guard - echo "$(date -Iseconds) TRACE: guard done, about to validate_openclaw_symlinks" | tee -a "$_DIAG" >&2 validate_openclaw_symlinks # Ensure writable state directories exist and are owned by the current user. @@ -1514,22 +1495,9 @@ if [ "$(id -u)" -ne 0 ]; then # Start gateway in background, auto-pair, then wait. # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely # in the gateway process env — not exported to the sandbox shell. - # DIAG: capture gateway startup output to trace file - echo "$(date -Iseconds) TRACE: launching gateway with NODE_OPTIONS=$NODE_OPTIONS" | tee -a "$_DIAG" >&2 - echo "$(date -Iseconds) TRACE: OPENCLAW=$OPENCLAW PORT=${_DASHBOARD_PORT}" | tee -a "$_DIAG" >&2 OPENCLAW_GATEWAY_TOKEN="$_NONROOT_GATEWAY_TOKEN" \ nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! - # DIAG: give gateway a moment to crash, then dump log - sleep 3 - echo "$(date -Iseconds) TRACE: gateway PID=$GATEWAY_PID alive=$(kill -0 $GATEWAY_PID 2>/dev/null && echo yes || echo no)" | tee -a "$_DIAG" >&2 - echo "$(date -Iseconds) TRACE: gateway.log size=$(wc -c < /tmp/gateway.log 2>/dev/null || echo unknown)" | tee -a "$_DIAG" >&2 - if [ -s /tmp/gateway.log ]; then - echo "$(date -Iseconds) TRACE: gateway.log first 20 lines:" | tee -a "$_DIAG" >&2 - head -20 /tmp/gateway.log | tee -a "$_DIAG" >&2 - else - echo "$(date -Iseconds) TRACE: gateway.log is empty — gateway may have crashed silently" | tee -a "$_DIAG" >&2 - fi echo "[gateway] openclaw gateway launched (pid $GATEWAY_PID)" >&2 start_auto_pair # NOTE: PIDs are collected after launch; a signal arriving between trap diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index e83fcb41f57..1b6197cf5b1 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -661,31 +661,11 @@ print('yes' if 'slack' in d else 'no') pass "M11e: Slack channel configured with placeholder tokens (guard needed)" # Diagnostics: check if the guard was installed and what NODE_OPTIONS looks like - info "Checking guard installation diagnostics (via openshell exec as root):" - # Dump all nemoclaw-* files in /tmp to see what the entrypoint created - # Quote the glob so it expands INSIDE the sandbox, not on the host - tmp_files=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'ls -la /tmp/nemoclaw-* /tmp/gateway.log /tmp/dns-proxy.py 2>&1 || true' 2>&1 || echo "exec failed") - info " /tmp files in sandbox:" - echo "$tmp_files" | while IFS= read -r line; do info " $line"; done + info "Checking guard installation diagnostics:" guard_exists=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-slack-channel-guard.js 2>/dev/null || echo "EXEC_FAILED") info " Guard file: $guard_exists" node_opts=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'echo "$NODE_OPTIONS"' 2>/dev/null || echo "EXEC_FAILED") info " NODE_OPTIONS: $node_opts" - proxy_fix=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-http-proxy-fix.js 2>/dev/null || echo "EXEC_FAILED") - info " Proxy fix file: $proxy_fix" - # Check if openclaw.json contains "slack" (same grep the guard uses) - slack_in_config=$(openshell sandbox exec --name "$SANDBOX_NAME" -- grep -c '"slack"' /sandbox/.openclaw/openclaw.json 2>/dev/null || echo "EXEC_FAILED") - info " grep '\"slack\"' in openclaw.json: $slack_in_config matches" - # Read entrypoint execution trace from /tmp - trace_log=$(openshell sandbox exec --name "$SANDBOX_NAME" -- cat /tmp/nemoclaw-entrypoint-trace.log 2>&1 || echo "no trace file") - info " Entrypoint trace:" - echo "$trace_log" | while IFS= read -r line; do info " $line"; done - # Check what processes are running - procs=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ps aux 2>/dev/null | head -10 || echo "EXEC_FAILED") - info " Processes:" - echo "$procs" | while IFS= read -r line; do - info " $line" - done else skip "M11e: No Slack channel in config" fi From ae4d5d9470f6a9c3d746c5a005499708dcdd1111 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 09:02:07 -0700 Subject: [PATCH 20/24] fix(test): apply shfmt formatting and fix nemotron validate_tmp test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shfmt reformatted case statement indentation. The nemotron test regex for validate_tmp_permissions was too strict — matched only when _NEMOTRON_FIX_SCRIPT was the second argument, but it's now further in the argument list due to _SANDBOX_SAFETY_NET and _CIAO_GUARD_SCRIPT. --- scripts/lib/sandbox-init.sh | 4 +- scripts/nemoclaw-start.sh | 105 ++++++++++++++-------------- test/nemotron-inference-fix.test.ts | 3 +- 3 files changed, 57 insertions(+), 55 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index eeb11e7d4ed..b5f3ae4a29d 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -125,8 +125,8 @@ validate_tmp_permissions() { local perms expected_perms perms="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f" 2>/dev/null || echo "unknown")" case "$f" in - */gateway.log) expected_perms="644" ;; - *) expected_perms="600" ;; + */gateway.log) expected_perms="644" ;; + *) expected_perms="600" ;; esac if [ "$perms" != "$expected_perms" ]; then echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected $expected_perms)" >&2 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 7e6d9a5395e..0ac9f7c6443 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -120,14 +120,14 @@ if [ "${1:-}" = "env" ]; then _self_wrapper_index="" for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do case "${_raw_args[$i]}" in - *=*) ;; - nemoclaw-start | /usr/local/bin/nemoclaw-start) - _self_wrapper_index="$i" - break - ;; - *) - break - ;; + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; esac done if [ -n "$_self_wrapper_index" ]; then @@ -142,7 +142,7 @@ fi # receiving our own name as $1 would otherwise recurse via the NEMOCLAW_CMD # exec path. Only strip from $1 — later args with this name are legitimate. case "${1:-}" in - nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; +nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") # Validate NEMOCLAW_DASHBOARD_PORT if set (same behavior as ports.js: fail fast). @@ -152,10 +152,10 @@ if [ -z "$_DASHBOARD_PORT_RAW" ]; then else _DASHBOARD_PORT="$(printf '%s' "$_DASHBOARD_PORT_RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" case "$_DASHBOARD_PORT" in - *[!0-9]* | '') - echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 - exit 1 - ;; + *[!0-9]* | '') + echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 + exit 1 + ;; esac if [ "$_DASHBOARD_PORT" -lt 1024 ] || [ "$_DASHBOARD_PORT" -gt 65535 ]; then echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 @@ -193,12 +193,12 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s apply_model_override() { # Any of these env vars trigger a config patch - [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] \ - || [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] \ - || [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] \ - || [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] \ - || [ -n "${NEMOCLAW_REASONING:-}" ] \ - || return 0 + [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] || + [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] || + [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] || + [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] || + [ -n "${NEMOCLAW_REASONING:-}" ] || + return 0 # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). # In non-root mode the sandbox user cannot modify the config. @@ -233,11 +233,11 @@ apply_model_override() { # SECURITY: Allowlist inference API types to prevent unexpected routing. if [ -n "$api_override" ]; then case "$api_override" in - openai-completions | anthropic-messages) ;; - *) - printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 - return 1 - ;; + openai-completions | anthropic-messages) ;; + *) + printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 + return 1 + ;; esac fi @@ -257,11 +257,11 @@ apply_model_override() { # Validate reasoning is true/false if [ -n "$reasoning" ]; then case "$reasoning" in - true | false) ;; - *) - printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 - return 1 - ;; + true | false) ;; + *) + printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 + return 1 + ;; esac fi @@ -409,20 +409,20 @@ apply_slack_token_override() { # SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token. case "${SLACK_BOT_TOKEN}" in - xoxb-*) ;; - *) - printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xoxb-*) ;; + *) + printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac if [ -n "${SLACK_APP_TOKEN:-}" ]; then case "$SLACK_APP_TOKEN" in - xapp-*) ;; - *) - printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xapp-*) ;; + *) + printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac else printf '[channels] Warning: SLACK_BOT_TOKEN is set but SLACK_APP_TOKEN is missing — Socket Mode requires both tokens\n' >&2 @@ -726,7 +726,10 @@ GUARD local tmp tmp="$(mktemp)" || continue awk -v b="$marker_begin" -v e="$marker_end" \ - '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { rm -f "$tmp"; continue; } + '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { + rm -f "$tmp" + continue + } printf '%s\n' "$snippet" >>"$tmp" cat "$tmp" >"$rc_file" 2>/dev/null || true rm -f "$tmp" @@ -1427,25 +1430,25 @@ if [ "$(id -u)" -ne 0 ]; then current="$(readlink -f "$link_path" 2>/dev/null || true)" expected="$(readlink -f "$target" 2>/dev/null || true)" [ "$current" != "$expected" ] || return 0 - ln -snf "$target" "$link_path" 2>/dev/null \ - && echo "[setup] repaired identity symlink" >&2 \ - || echo "[setup] could not repair identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null && + echo "[setup] repaired identity symlink" >&2 || + echo "[setup] could not repair identity symlink" >&2 return 0 fi # Nothing exists yet — create the symlink. if [ ! -e "$link_path" ]; then - ln -snf "$target" "$link_path" 2>/dev/null \ - && echo "[setup] created identity symlink" >&2 \ - || echo "[setup] could not create identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null && + echo "[setup] created identity symlink" >&2 || + echo "[setup] could not create identity symlink" >&2 return 0 fi # A non-symlink entry exists — back it up, then replace. local backup backup="${link_path}.bak.$(date +%s)" - if mv "$link_path" "$backup" 2>/dev/null \ - && ln -snf "$target" "$link_path" 2>/dev/null; then + if mv "$link_path" "$backup" 2>/dev/null && + ln -snf "$target" "$link_path" 2>/dev/null; then echo "[setup] replaced non-symlink identity path (backup: ${backup})" >&2 else echo "[setup] could not replace ${link_path}; writes may fail" >&2 @@ -1461,9 +1464,9 @@ if [ "$(id -u)" -ne 0 ]; then mkdir -p "${data_dir}/${sub}" 2>/dev/null || true done if find "$data_dir" ! -uid "$(id -u)" -print -quit 2>/dev/null | grep -q .; then - chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null \ - && echo "[setup] fixed ownership on ${data_dir}" >&2 \ - || echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 + chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null && + echo "[setup] fixed ownership on ${data_dir}" >&2 || + echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 fi ensure_identity_symlink "$data_dir" "$openclaw_dir" } diff --git a/test/nemotron-inference-fix.test.ts b/test/nemotron-inference-fix.test.ts index 5a87eb041ae..29bb914201d 100644 --- a/test/nemotron-inference-fix.test.ts +++ b/test/nemotron-inference-fix.test.ts @@ -34,8 +34,7 @@ describe("Nemotron inference fix preload (#1193, #2051)", () => { }); it("passes the preload path to validate_tmp_permissions in both root and non-root branches", () => { - const calls = - src.match(/validate_tmp_permissions\s+"[^"]*"\s+"\$_NEMOTRON_FIX_SCRIPT"/g) || []; + const calls = src.match(/validate_tmp_permissions\s+.*"\$_NEMOTRON_FIX_SCRIPT"/g) || []; expect(calls.length).toBeGreaterThanOrEqual(2); }); From 5699ac71803b0feeb45f267c9b8ea366ead34337 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 09:10:24 -0700 Subject: [PATCH 21/24] fix(test): fix http-proxy-fix validate_tmp_permissions regex and add Slack guard to validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as the nemotron test — regex was too strict for the new argument ordering. Also add _SLACK_GUARD_SCRIPT to validate_tmp_permissions calls per CodeRabbit review. --- scripts/nemoclaw-start.sh | 4 ++-- test/http-proxy-fix-sync.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0ac9f7c6443..87929147fb6 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1493,7 +1493,7 @@ if [ "$(id -u)" -ne 0 ]; then # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). - validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" + validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_SLACK_GUARD_SCRIPT" # Start gateway in background, auto-pair, then wait. # Pass OPENCLAW_GATEWAY_TOKEN only on this launch line so it lives solely @@ -1634,7 +1634,7 @@ harden_openclaw_symlinks # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). -validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" +validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_SLACK_GUARD_SCRIPT" # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index db2f0dea210..cf9a37bc534 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -66,7 +66,7 @@ describe("http-proxy-fix heredoc sync (#2109)", () => { it("validate_tmp_permissions is invoked with the fix path in both root and non-root branches", () => { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const calls = startScript.match(/validate_tmp_permissions "\$_PROXY_FIX_SCRIPT"/g) || []; + const calls = startScript.match(/validate_tmp_permissions\s+.*"\$_PROXY_FIX_SCRIPT"/g) || []; expect(calls.length).toBeGreaterThanOrEqual(2); }); From 8cd625948c758ed920c3dda23e0aa419e37ac1ef Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 09:14:14 -0700 Subject: [PATCH 22/24] chore: apply shfmt formatting from pre-commit hooks --- scripts/lib/sandbox-init.sh | 4 +- scripts/nemoclaw-start.sh | 100 ++++++++++++++++++------------------ 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index b5f3ae4a29d..eeb11e7d4ed 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -125,8 +125,8 @@ validate_tmp_permissions() { local perms expected_perms perms="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f" 2>/dev/null || echo "unknown")" case "$f" in - */gateway.log) expected_perms="644" ;; - *) expected_perms="600" ;; + */gateway.log) expected_perms="644" ;; + *) expected_perms="600" ;; esac if [ "$perms" != "$expected_perms" ]; then echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected $expected_perms)" >&2 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 87929147fb6..0295259cd53 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -120,14 +120,14 @@ if [ "${1:-}" = "env" ]; then _self_wrapper_index="" for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do case "${_raw_args[$i]}" in - *=*) ;; - nemoclaw-start | /usr/local/bin/nemoclaw-start) - _self_wrapper_index="$i" - break - ;; - *) - break - ;; + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; esac done if [ -n "$_self_wrapper_index" ]; then @@ -142,7 +142,7 @@ fi # receiving our own name as $1 would otherwise recurse via the NEMOCLAW_CMD # exec path. Only strip from $1 — later args with this name are legitimate. case "${1:-}" in -nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") # Validate NEMOCLAW_DASHBOARD_PORT if set (same behavior as ports.js: fail fast). @@ -152,10 +152,10 @@ if [ -z "$_DASHBOARD_PORT_RAW" ]; then else _DASHBOARD_PORT="$(printf '%s' "$_DASHBOARD_PORT_RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" case "$_DASHBOARD_PORT" in - *[!0-9]* | '') - echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 - exit 1 - ;; + *[!0-9]* | '') + echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 + exit 1 + ;; esac if [ "$_DASHBOARD_PORT" -lt 1024 ] || [ "$_DASHBOARD_PORT" -gt 65535 ]; then echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 @@ -193,12 +193,12 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s apply_model_override() { # Any of these env vars trigger a config patch - [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] || - [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] || - [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] || - [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] || - [ -n "${NEMOCLAW_REASONING:-}" ] || - return 0 + [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] \ + || [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] \ + || [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] \ + || [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] \ + || [ -n "${NEMOCLAW_REASONING:-}" ] \ + || return 0 # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). # In non-root mode the sandbox user cannot modify the config. @@ -233,11 +233,11 @@ apply_model_override() { # SECURITY: Allowlist inference API types to prevent unexpected routing. if [ -n "$api_override" ]; then case "$api_override" in - openai-completions | anthropic-messages) ;; - *) - printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 - return 1 - ;; + openai-completions | anthropic-messages) ;; + *) + printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 + return 1 + ;; esac fi @@ -257,11 +257,11 @@ apply_model_override() { # Validate reasoning is true/false if [ -n "$reasoning" ]; then case "$reasoning" in - true | false) ;; - *) - printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 - return 1 - ;; + true | false) ;; + *) + printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 + return 1 + ;; esac fi @@ -409,20 +409,20 @@ apply_slack_token_override() { # SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token. case "${SLACK_BOT_TOKEN}" in - xoxb-*) ;; - *) - printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xoxb-*) ;; + *) + printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac if [ -n "${SLACK_APP_TOKEN:-}" ]; then case "$SLACK_APP_TOKEN" in - xapp-*) ;; - *) - printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xapp-*) ;; + *) + printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac else printf '[channels] Warning: SLACK_BOT_TOKEN is set but SLACK_APP_TOKEN is missing — Socket Mode requires both tokens\n' >&2 @@ -1430,25 +1430,25 @@ if [ "$(id -u)" -ne 0 ]; then current="$(readlink -f "$link_path" 2>/dev/null || true)" expected="$(readlink -f "$target" 2>/dev/null || true)" [ "$current" != "$expected" ] || return 0 - ln -snf "$target" "$link_path" 2>/dev/null && - echo "[setup] repaired identity symlink" >&2 || - echo "[setup] could not repair identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null \ + && echo "[setup] repaired identity symlink" >&2 \ + || echo "[setup] could not repair identity symlink" >&2 return 0 fi # Nothing exists yet — create the symlink. if [ ! -e "$link_path" ]; then - ln -snf "$target" "$link_path" 2>/dev/null && - echo "[setup] created identity symlink" >&2 || - echo "[setup] could not create identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null \ + && echo "[setup] created identity symlink" >&2 \ + || echo "[setup] could not create identity symlink" >&2 return 0 fi # A non-symlink entry exists — back it up, then replace. local backup backup="${link_path}.bak.$(date +%s)" - if mv "$link_path" "$backup" 2>/dev/null && - ln -snf "$target" "$link_path" 2>/dev/null; then + if mv "$link_path" "$backup" 2>/dev/null \ + && ln -snf "$target" "$link_path" 2>/dev/null; then echo "[setup] replaced non-symlink identity path (backup: ${backup})" >&2 else echo "[setup] could not replace ${link_path}; writes may fail" >&2 @@ -1464,9 +1464,9 @@ if [ "$(id -u)" -ne 0 ]; then mkdir -p "${data_dir}/${sub}" 2>/dev/null || true done if find "$data_dir" ! -uid "$(id -u)" -print -quit 2>/dev/null | grep -q .; then - chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null && - echo "[setup] fixed ownership on ${data_dir}" >&2 || - echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 + chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null \ + && echo "[setup] fixed ownership on ${data_dir}" >&2 \ + || echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 fi ensure_identity_symlink "$data_dir" "$openclaw_dir" } From d27f0dda007777e78c0c61d4a86c77b8cf41f65b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 09:22:20 -0700 Subject: [PATCH 23/24] fix(sandbox): address CodeRabbit review feedback - Update trust-boundary doc table: gateway.log is now 644, not 600 - Log a warning when lock_rc_files chmod fails instead of silent swallow --- scripts/lib/sandbox-init.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index eeb11e7d4ed..60e22e5031f 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -26,7 +26,7 @@ _SANDBOX_INIT_LOADED=1 # # File Owner Mode Writer Reader Sourced? # /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (.bashrc/.profile) -# /tmp/gateway.log gateway 600 gateway gateway no +# /tmp/gateway.log gateway 644 gateway all no (world-readable for diagnostics) # /tmp/auto-pair.log sandbox 600 sandbox sandbox no # /tmp/.npm-cache/ sandbox 755 sandbox sandbox no (tool data) # /tmp/.cache/ sandbox 755 sandbox sandbox no (tool data) @@ -211,7 +211,9 @@ lock_rc_files() { for rc_file in "${home_dir}/.bashrc" "${home_dir}/.profile"; do if [ -f "$rc_file" ]; then - chmod 444 "$rc_file" 2>/dev/null || true + if ! chmod 444 "$rc_file" 2>/dev/null; then + echo "[SECURITY] Could not lock ${rc_file} to 444 — continuing (best-effort, Landlock may enforce)" >&2 + fi fi done } From 36f3f895295611c305347e9ada15b4f373180452 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 25 Apr 2026 09:43:46 -0700 Subject: [PATCH 24/24] fix(sandbox): accept both 600 and 644 for gateway.log in validator Hermes uses chmod 600 for gateway.log while OpenClaw uses 644. Both share validate_tmp_permissions from sandbox-init.sh. Accept either permission to avoid breaking Hermes's entrypoint. --- scripts/lib/sandbox-init.sh | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 60e22e5031f..fd33b25f8d1 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -118,20 +118,26 @@ validate_tmp_permissions() { fi done - # Restricted log files — gateway.log is 644 (world-readable for diagnostics), - # auto-pair.log is 600 (sandbox-writable, not shared). + # Restricted log files — gateway.log may be 600 (Hermes) or 644 (OpenClaw, + # world-readable for diagnostics). auto-pair.log is 600. for f in /tmp/gateway.log /tmp/auto-pair.log; do [ -f "$f" ] || continue - local perms expected_perms + local perms perms="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f" 2>/dev/null || echo "unknown")" case "$f" in - */gateway.log) expected_perms="644" ;; - *) expected_perms="600" ;; + */gateway.log) + if [ "$perms" != "600" ] && [ "$perms" != "644" ]; then + echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600 or 644)" >&2 + failed=1 + fi + ;; + *) + if [ "$perms" != "600" ]; then + echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2 + failed=1 + fi + ;; esac - if [ "$perms" != "$expected_perms" ]; then - echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected $expected_perms)" >&2 - failed=1 - fi done return $failed