Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 99 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,15 @@ 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):
scopes = device.get('scopes') or device.get('requestedScopes') or []
if not isinstance(scopes, list):
return set()
return {str(scope).strip() for scope in scopes if str(scope or '').strip()}

Comment thread
coderabbitai[bot] marked this conversation as resolved.

RUN_TIMEOUT_SECS = _env_seconds('NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS', 10)

Expand Down Expand Up @@ -1836,6 +1845,11 @@ 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 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,
)
Expand Down Expand Up @@ -2081,6 +2095,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
Expand Down Expand Up @@ -2110,8 +2125,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)
Expand Down
13 changes: 13 additions & 0 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,16 @@ 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):
scopes = device.get('scopes') or device.get('requestedScopes') or []
if not isinstance(scopes, list):
return set()
return {str(scope).strip() for scope in scopes if str(scope or '').strip()}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
proc = subprocess.run(
[OPENCLAW, 'devices', 'list', '--json'],
Expand Down Expand Up @@ -732,9 +740,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 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(
Expand Down
39 changes: 36 additions & 3 deletions test/e2e/test-issue-4462-scope-upgrade-approval.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down
58 changes: 58 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,64 @@ exit 2
}
}, 40_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");
Expand Down
14 changes: 14 additions & 0 deletions test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");

Expand Down
5 changes: 5 additions & 0 deletions test/sandbox-connect-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,11 +1335,16 @@ 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("not scopes.issubset(ALLOWED_SCOPES)");
expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan(
script.indexOf("approve_env = os.environ.copy()"),
);
Expand Down
Loading