diff --git a/Dockerfile b/Dockerfile index eec5d0c5b9..cb01379ae4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,11 @@ FROM ${BASE_IMAGE} ARG OPENCLAW_VERSION=2026.5.27 ARG OPENCLAW_2026_5_27_INTEGRITY=sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw== +# OpenClaw 2026.5.27 loads some generated source through jiti. Disable its +# filesystem transform cache so source fragments that mention provider marker +# names do not persist under /tmp/jiti inside the sandbox. +ENV JITI_FS_CACHE=false + # Harden: remove unnecessary build tools and network probes from base image (#830) # Protect runtime tools before autoremove — the GHCR base may predate the # procps/e2fsprogs/tmux additions, leaving ps/chattr/tmux absent or auto-marked. diff --git a/Dockerfile.base b/Dockerfile.base index 9470ee733b..335d7c5a80 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -182,6 +182,10 @@ RUN printf '%s\n' \ ARG OPENCLAW_VERSION=2026.5.27 ARG OPENCLAW_2026_5_27_INTEGRITY=sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw== +# Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker +# names do not persist in runtime snapshots or leak-scan inputs. +ENV JITI_FS_CACHE=false + SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Install OpenClaw CLI + PyYAML. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index fc2e8fc852..e1ef2c25a6 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1762,6 +1762,20 @@ HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing # timeout reduction, and token cleanup for a more comprehensive fix. ALLOWED_CLIENTS = {'openclaw-control-ui'} ALLOWED_MODES = {'webchat', 'cli'} +ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} + + +def requested_scopes(device): + if 'scopes' in device: + scopes = device.get('scopes') + elif 'requestedScopes' in device: + scopes = device.get('requestedScopes') + else: + return set() + if not isinstance(scopes, list): + return None + return {str(scope).strip() for scope in scopes if str(scope or '').strip()} + RUN_TIMEOUT_SECS = _env_seconds('NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS', 10) @@ -1836,6 +1850,15 @@ while time.time() < DEADLINE: HANDLED.add(request_id) print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}') continue + scopes = requested_scopes(device) + if scopes is None: + HANDLED.add(request_id) + print(f'[auto-pair] rejected malformed scopes client={client_id} mode={client_mode}') + continue + if scopes and not scopes.issubset(ALLOWED_SCOPES): + HANDLED.add(request_id) + print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}') + continue arc, aout, aerr = run( OPENCLAW, 'devices', 'approve', request_id, '--json', strip_gateway_env=True, ) @@ -2081,6 +2104,7 @@ export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +export JITI_FS_CACHE="false" PROXYEOF local _openclaw_env_name _openclaw_env_value _escaped_openclaw_env_value for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR OPENCLAW_WORKSPACE_DIR; do @@ -2110,8 +2134,90 @@ openclaw() { # the approval command itself. Approval calls temporarily drop the gateway # URL/port/token; other commands keep the full gateway environment. if [ "${1:-}" = "devices" ] && [ "${2:-}" = "approve" ]; then - ( unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT OPENCLAW_GATEWAY_TOKEN; command openclaw "$@" ) - return $? + _nemoclaw_approve_request_id="${3:-}" + _nemoclaw_approve_state_dir="${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}" + _nemoclaw_approve_before="" + if [ -n "$_nemoclaw_approve_request_id" ] && command -v python3 >/dev/null 2>&1; then + _nemoclaw_approve_before="$(NEMOCLAW_APPROVE_REQUEST_ID="$_nemoclaw_approve_request_id" NEMOCLAW_APPROVE_STATE_DIR="$_nemoclaw_approve_state_dir" python3 - <<'PYAPPROVEBEFORE' 2>/dev/null || true +import json +import os +from pathlib import Path + +root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices" +request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" +try: + pending = json.loads((root / "pending.json").read_text(encoding="utf-8")) +except Exception: + pending = {} +if not isinstance(pending, dict): + pending = {} +request = next((item for item in pending.values() if isinstance(item, dict) and item.get("requestId") == request_id), None) +if request: + print(json.dumps({ + "requestId": request_id, + "deviceId": request.get("deviceId"), + "scopes": request.get("scopes") or request.get("requestedScopes") or [], + }, sort_keys=True)) +PYAPPROVEBEFORE +)" + fi + _nemoclaw_approve_errexit=0 + case $- in *e*) _nemoclaw_approve_errexit=1 ;; esac + set +e + _nemoclaw_approve_output="$(unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT OPENCLAW_GATEWAY_TOKEN; command openclaw "$@" 2>&1)" + _nemoclaw_approve_rc=$? + if [ "$_nemoclaw_approve_errexit" = "1" ]; then set -e; else set +e; fi + if [ "$_nemoclaw_approve_rc" -eq 0 ]; then + printf '%s\n' "$_nemoclaw_approve_output" + return 0 + fi + if [ -n "$_nemoclaw_approve_request_id" ] && [ -n "$_nemoclaw_approve_before" ] && command -v python3 >/dev/null 2>&1; then + if NEMOCLAW_APPROVE_REQUEST_ID="$_nemoclaw_approve_request_id" NEMOCLAW_APPROVE_STATE_DIR="$_nemoclaw_approve_state_dir" NEMOCLAW_APPROVE_BEFORE="$_nemoclaw_approve_before" python3 - <<'PYAPPROVEAFTER'; then +import json +import os +from pathlib import Path + +request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" +root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices" +try: + before = json.loads(os.environ.get("NEMOCLAW_APPROVE_BEFORE") or "{}") +except Exception: + before = {} + +def load(name): + try: + value = json.loads((root / name).read_text(encoding="utf-8")) + except Exception: + return {} + return value if isinstance(value, dict) else {} + +def norm(value): + return str(value or "").strip() + +def scopes(entry): + return {norm(scope) for scope in (entry.get("approvedScopes") or entry.get("scopes") or []) if norm(scope)} + +requested = {norm(scope) for scope in (before.get("scopes") or []) if norm(scope)} +device_id = norm(before.get("deviceId")) +pending = load("pending.json") +paired = load("paired.json") +still_pending = any(isinstance(item, dict) and item.get("requestId") == request_id for item in pending.values()) +paired_entry = paired.get(device_id) if device_id else None +if request_id and requested and not still_pending and isinstance(paired_entry, dict) and requested.issubset(scopes(paired_entry)): + print(json.dumps({ + "requestId": request_id, + "deviceId": device_id, + "approvedScopes": sorted(requested), + "compatibility": "openclaw-approve-applied-after-nonzero", + }, sort_keys=True)) + raise SystemExit(0) +raise SystemExit(1) +PYAPPROVEAFTER + return 0 + fi + fi + printf '%s\n' "$_nemoclaw_approve_output" + return "$_nemoclaw_approve_rc" fi case "$1" in configure) diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 48629c0ed7..377fabf359 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -697,8 +697,21 @@ import sys OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') ALLOWED_CLIENTS = {'openclaw-control-ui'} ALLOWED_MODES = {'webchat', 'cli'} +ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} MAX_APPROVALS = ${CONNECT_AUTO_PAIR_MAX_APPROVALS} + +def requested_scopes(device): + if 'scopes' in device: + scopes = device.get('scopes') + elif 'requestedScopes' in device: + scopes = device.get('requestedScopes') + else: + return set() + if not isinstance(scopes, list): + return None + return {str(scope).strip() for scope in scopes if str(scope or '').strip()} + try: proc = subprocess.run( [OPENCLAW, 'devices', 'list', '--json'], @@ -732,9 +745,14 @@ for device in pending: client_mode = device.get('clientMode', '') if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: continue + scopes = requested_scopes(device) + if scopes is None or (scopes and not scopes.issubset(ALLOWED_SCOPES)): + continue seen_request_ids.add(request_id) approve_env = os.environ.copy() approve_env.pop('OPENCLAW_GATEWAY_URL', None) + approve_env.pop('OPENCLAW_GATEWAY_PORT', None) + approve_env.pop('OPENCLAW_GATEWAY_TOKEN', None) attempted_count += 1 try: approve_proc = subprocess.run( diff --git a/test/e2e/test-issue-4462-scope-upgrade-approval.sh b/test/e2e/test-issue-4462-scope-upgrade-approval.sh index 9c9114ace7..8933626a70 100755 --- a/test/e2e/test-issue-4462-scope-upgrade-approval.sh +++ b/test/e2e/test-issue-4462-scope-upgrade-approval.sh @@ -307,7 +307,10 @@ for req in sorted(pending, key=lambda item: item.get("ts") or 0, reverse=True): print(req["requestId"]) raise SystemExit(0) if kind == "scope-upgrade" and paired_entry and roles(req).issubset(roles(paired_entry) or roles(req)): - if requested and not requested.issubset(approved): + # OpenClaw 2026.5.27 may create a follow-on operator.admin request after + # the write/read request has already been applied. Keep this selector + # focused on the NemoClaw gateway-mode upgrade contract. + if {"operator.write", "operator.read"}.intersection(requested) and not requested.issubset(approved): print(req["requestId"]) raise SystemExit(0) raise SystemExit(1) @@ -363,7 +366,32 @@ for device in sorted(paired, key=lambda item: item.get("approvedAtMs") or 0, rev if not is_cli(device): continue approved = scopes(device) - if "operator.admin" in approved or {"operator.write", "operator.read"}.issubset(approved): + if {"operator.write", "operator.read"}.issubset(approved): + print(norm(device.get("deviceId")) or "cli-device") + raise SystemExit(0) +raise SystemExit(1) +' +} + +select_cli_paired_with_admin() { + python3 -c ' +import json +import sys + +doc = json.load(sys.stdin) +paired = [p for p in doc.get("paired") or [] if isinstance(p, dict)] + +def norm(value): + return str(value or "").strip() + +def is_cli(entry): + return norm(entry.get("clientMode")).lower() == "cli" or "cli" in norm(entry.get("clientId")).lower() + +def scopes(entry): + return {norm(scope) for scope in (entry.get("approvedScopes") or entry.get("scopes") or []) if norm(scope)} + +for device in sorted(paired, key=lambda item: item.get("approvedAtMs") or 0, reverse=True): + if is_cli(device) and "operator.admin" in scopes(device): print(norm(device.get("deviceId")) or "cli-device") raise SystemExit(0) raise SystemExit(1) @@ -957,6 +985,7 @@ state="$(device_state_json 2>&1)" || { printf '=== state after scope-upgrade approval ===\n%s\n' "$state" >>"$STATE_LOG" pending_after_approval=$(printf '%s' "$state" | select_cli_request scope-upgrade 2>/dev/null) || pending_after_approval="" paired_with_agent_scopes=$(printf '%s' "$state" | select_cli_paired_with_agent_scopes 2>/dev/null) || paired_with_agent_scopes="" +paired_with_admin=$(printf '%s' "$state" | select_cli_paired_with_admin 2>/dev/null) || paired_with_admin="" if [ -n "$pending_after_approval" ]; then fail "Scope-upgrade request is still pending after approval (${pending_after_approval})" exit 1 @@ -965,7 +994,11 @@ if [ -z "$paired_with_agent_scopes" ]; then fail "No CLI paired device has operator.write and operator.read after approval: $(printf '%s' "$state" | summarize_device_state)" exit 1 fi -pass "scope-upgrade approval grants the CLI device operator.write and operator.read" +if [ -n "$paired_with_admin" ]; then + fail "Unexpected operator.admin approval for CLI device (${paired_with_admin})" + exit 1 +fi +pass "scope-upgrade approval grants the CLI device operator.write and operator.read without approving operator.admin" section "Phase 5: Verify agent stays on gateway path" diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 06a4f1f9f3..76c85d945d 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1773,6 +1773,122 @@ exit 2 } }, 40_000); + it("rejects malformed CLI scope request payloads", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-malformed-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const approveLog = path.join(tmpDir, "approvals.log"); + const malformedPending = JSON.stringify({ + pending: [ + { + requestId: "malformed-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: "operator.write", + }, + ], + paired: [], + }); + + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + printf '%s\n' ${JSON.stringify(malformedPending)} + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + echo "$3" >> ${JSON.stringify(approveLog)} + printf '{}\n' + exit 0 +fi +echo "unexpected: $*" >&2 +exit 2 +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", buildAutoPairScript()], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 20_000, + }); + expect(run.status).toBe(0); + expect(run.stdout).toContain( + "[auto-pair] rejected malformed scopes client=openclaw-cli mode=cli", + ); + expect(run.stdout).toContain("watcher deadline reached approvals=0"); + expect(fs.existsSync(approveLog)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30_000); + + it("rejects disallowed CLI admin scope requests", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-admin-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const approveLog = path.join(tmpDir, "approvals.log"); + const adminPending = JSON.stringify({ + pending: [ + { + requestId: "admin-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.admin"], + }, + ], + paired: [], + }); + + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + printf '%s\n' ${JSON.stringify(adminPending)} + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + echo "$3" >> ${JSON.stringify(approveLog)} + printf '{}\n' + exit 0 +fi +echo "unexpected: $*" >&2 +exit 2 +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", buildAutoPairScript()], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 20_000, + }); + expect(run.status).toBe(0); + expect(run.stdout).toContain( + "[auto-pair] rejected disallowed scopes=['operator.admin'] client=openclaw-cli mode=cli", + ); + expect(run.stdout).toContain("watcher deadline reached approvals=0"); + expect(fs.existsSync(approveLog)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30_000); + it("falls back to fast-deadline transition when no convergence signal arrives", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-slow-fastdl-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); diff --git a/test/runner.test.ts b/test/runner.test.ts index 3e2f16f9ed..0dd8aeb638 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -860,6 +860,20 @@ describe("regression guards", () => { }); }); + describe("OpenClaw runtime cache hardening", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + + it("disables jiti filesystem cache in base, runtime, and connect shells", () => { + const baseSrc = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf-8"); + const runtimeSrc = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8"); + const startSrc = fs.readFileSync(path.join(repoRoot, "scripts", "nemoclaw-start.sh"), "utf-8"); + + expect(baseSrc).toContain("ENV JITI_FS_CACHE=false"); + expect(runtimeSrc).toContain("ENV JITI_FS_CACHE=false"); + expect(startSrc).toContain('export JITI_FS_CACHE="false"'); + }); + }); + describe("sandbox ships tmux for the bundled tmux-session flow (#4513)", () => { const repoRoot = path.join(import.meta.dirname, ".."); diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference.test.ts index 3d1673b314..189ebe3537 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference.test.ts @@ -1335,11 +1335,17 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { expect(script).toContain("approve"); expect(script).toContain("approve_env = os.environ.copy()"); expect(script).toContain("approve_env.pop('OPENCLAW_GATEWAY_URL', None)"); + expect(script).toContain("approve_env.pop('OPENCLAW_GATEWAY_PORT', None)"); + expect(script).toContain("approve_env.pop('OPENCLAW_GATEWAY_TOKEN', None)"); expect(script).toContain("env=approve_env"); expect(script).toContain("if approve_proc.returncode == 0"); expect(script).toContain("openclaw-control-ui"); expect(script).toContain("webchat"); expect(script).toContain("cli"); + expect(script).toContain("operator.read"); + expect(script).toContain("operator.write"); + expect(script).toContain("return None"); + expect(script).toContain("if scopes is None or (scopes and not scopes.issubset(ALLOWED_SCOPES))"); expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan( script.indexOf("approve_env = os.environ.copy()"), );