Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \

# Copy startup script and shared sandbox initialisation library
COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh
COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py
COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
# Copy NODE_OPTIONS preload modules to a Landlock-accessible path. OpenShell ≥0.0.36
# blocks /opt/nemoclaw-blueprint/ from non-root users, but the entrypoint
Expand All @@ -416,6 +417,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \
/usr/local/lib/nemoclaw/generate-openclaw-config.mts \
/usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py \
/usr/local/lib/nemoclaw/seed-wechat-accounts.py \
&& chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \
&& if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \
&& chmod 755 /usr/local/share/nemoclaw \
/usr/local/share/nemoclaw/openclaw-plugins \
Expand Down
75 changes: 75 additions & 0 deletions scripts/lib/openclaw_device_approval_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Shared OpenClaw device approval policy for NemoClaw sandbox helpers."""

import os


ALLOWED_CLIENTS = {"openclaw-control-ui"}
ALLOWED_MODES = {"webchat", "cli"}
ALLOWED_SCOPES = {"operator.pairing", "operator.read", "operator.write"}

GATEWAY_APPROVAL_ENV_KEYS = (
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_GATEWAY_TOKEN",
)


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()}


def approval_request_decision(device):
client_id = str(device.get("clientId", ""))
client_mode = str(device.get("clientMode", ""))
if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES:
return {
"allowed": False,
"reason": "unknown-client",
"client_id": client_id,
"client_mode": client_mode,
"scopes": set(),
}

scopes = requested_scopes(device)
if scopes is None:
return {
"allowed": False,
"reason": "malformed-scopes",
"client_id": client_id,
"client_mode": client_mode,
"scopes": set(),
}
if scopes and not scopes.issubset(ALLOWED_SCOPES):
return {
"allowed": False,
"reason": "disallowed-scopes",
"client_id": client_id,
"client_mode": client_mode,
"scopes": scopes,
}

return {
"allowed": True,
"reason": "allowlisted",
"client_id": client_id,
"client_mode": client_mode,
"scopes": scopes,
}


def gateway_approval_env(source_env=None):
env = dict(os.environ if source_env is None else source_env)
for key in GATEWAY_APPROVAL_ENV_KEYS:
env.pop(key, None)
return env
57 changes: 31 additions & 26 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1725,10 +1725,32 @@ start_auto_pair() {
fi
OPENCLAW_BIN="$OPENCLAW" nohup "${run_prefix[@]}" python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.log 2>&1 &
import json
import importlib.util
import os
import stat
import subprocess
import time

APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'


def load_approval_policy(path):
helper_stat = os.stat(path)
mode = helper_stat.st_mode
if mode & (stat.S_IWGRP | stat.S_IWOTH):
raise RuntimeError('approval policy helper is writable by group or other')
if helper_stat.st_uid == os.geteuid() and mode & stat.S_IWUSR:
raise RuntimeError('approval policy helper is writable by the current user')
spec = importlib.util.spec_from_file_location('openclaw_device_approval_policy', path)
if spec is None or spec.loader is None:
raise RuntimeError('approval policy helper could not be loaded')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.approval_request_decision, module.gateway_approval_env


approval_request_decision, gateway_approval_env = load_approval_policy(APPROVAL_POLICY_FILE)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw')


Expand Down Expand Up @@ -1760,22 +1782,7 @@ HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing
# (the gateway stores connectParams.client.id verbatim). This allowlist
# is defense-in-depth, not a trust boundary. PR #690 adds one-shot exit,
# 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()}

# The approval_request_decision helper is shared with connect-time approvals.

RUN_TIMEOUT_SECS = _env_seconds('NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS', 10)

Expand All @@ -1795,10 +1802,7 @@ def run(*args, strip_gateway_env=False):
# the fast→slow transition and the 8h deadline check.
env = None
if strip_gateway_env:
env = os.environ.copy()
env.pop('OPENCLAW_GATEWAY_URL', None)
env.pop('OPENCLAW_GATEWAY_PORT', None)
env.pop('OPENCLAW_GATEWAY_TOKEN', None)
env = gateway_approval_env(os.environ)
try:
proc = subprocess.run(
args, capture_output=True, text=True, timeout=RUN_TIMEOUT_SECS, env=env,
Expand Down Expand Up @@ -1844,19 +1848,20 @@ while time.time() < DEADLINE:
request_id = device.get('requestId')
if not request_id or request_id in HANDLED:
continue
client_id = device.get('clientId', '')
client_mode = device.get('clientMode', '')
if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES:
decision = approval_request_decision(device)
client_id = decision['client_id']
client_mode = decision['client_mode']
if decision['reason'] == 'unknown-client':
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:
if decision['reason'] == 'malformed-scopes':
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):
if decision['reason'] == 'disallowed-scopes':
HANDLED.add(request_id)
scopes = decision['scopes']
print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}')
continue
arc, aout, aerr = run(
Expand Down
68 changes: 41 additions & 27 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveOpenshell } from "../../adapters/openshell/resolve";
import {
captureOpenshell,
Expand Down Expand Up @@ -30,7 +32,7 @@ import {
} from "../../inference/ollama/proxy";
import { LOCAL_INFERENCE_TIMEOUT_SECS } from "../../onboard/env";
import { isWsl } from "../../platform";
import { ROOT } from "../../runner";
import { ROOT, shellQuote } from "../../runner";
import * as sandboxVersion from "../../sandbox/version";
import {
isTerminalSandboxPhase,
Expand Down Expand Up @@ -681,37 +683,56 @@ function ensureSandboxInferenceRouteOrExit(
// mid-loop kill cannot strand allowlisted requests within a normal batch.
const CONNECT_AUTO_PAIR_MAX_APPROVALS = 8;
const CONNECT_AUTO_PAIR_TIMEOUT_MS = 12_000;
const CONNECT_AUTO_PAIR_POLICY_PATH = path.join(
ROOT,
"scripts",
"lib",
"openclaw_device_approval_policy.py",
);

function readConnectAutoPairPolicyModule(): string | null {
try {
return readFileSync(CONNECT_AUTO_PAIR_POLICY_PATH, "utf-8");
} catch {
// The approval pass is best-effort, so a packaging/layout regression must
// not block connect. Build-context and package `files` coverage keep this
// helper present in supported installs.
return null;
}
}

function runConnectAutoPairApprovalPass(sandboxName: string): void {
const approvalPolicyModule = readConnectAutoPairPolicyModule();
if (!approvalPolicyModule) {
return;
}
const approvalPolicyModuleB64 = Buffer.from(approvalPolicyModule, "utf-8").toString("base64");
const script = `
PROXY_ENV=/tmp/nemoclaw-proxy-env.sh
[ -r "$PROXY_ENV" ] && . "$PROXY_ENV"
command -v openclaw >/dev/null 2>&1 || exit 0
command -v python3 >/dev/null 2>&1 || exit 0
OPENCLAW_BIN="$(command -v openclaw)" python3 - <<'PYAPPROVE'
OPENCLAW_BIN="$(command -v openclaw)" NEMOCLAW_APPROVAL_POLICY_B64=${shellQuote(approvalPolicyModuleB64)} python3 - <<'PYAPPROVE'
import base64
import json
import os
import subprocess
import sys

try:
policy_source = base64.b64decode(
os.environ.get('NEMOCLAW_APPROVAL_POLICY_B64', ''), validate=True,
).decode('utf-8')
policy_globals = {}
exec(compile(policy_source, 'openclaw_device_approval_policy.py', 'exec'), policy_globals)
approval_request_decision = policy_globals['approval_request_decision']
gateway_approval_env = policy_globals['gateway_approval_env']
except Exception:
sys.exit(0)

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'],
Expand Down Expand Up @@ -741,18 +762,11 @@ for device in pending:
request_id = device.get('requestId')
if not request_id or request_id in seen_request_ids:
continue
client_id = device.get('clientId', '')
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)):
decision = approval_request_decision(device)
if not decision['allowed']:
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)
approve_env = gateway_approval_env(os.environ)
attempted_count += 1
try:
approve_proc = subprocess.run(
Expand Down
4 changes: 4 additions & 0 deletions src/lib/sandbox/build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ function stageOptimizedSandboxBuildContext(
path.join(rootDir, "scripts", "lib", "sandbox-init.sh"),
path.join(stagedScriptsDir, "lib", "sandbox-init.sh"),
);
fs.copyFileSync(
path.join(rootDir, "scripts", "lib", "openclaw_device_approval_policy.py"),
path.join(stagedScriptsDir, "lib", "openclaw_device_approval_policy.py"),
);
// OpenClaw config generator extracted in #2449 (fixed in #2565)
fs.copyFileSync(
path.join(rootDir, "scripts", "generate-openclaw-config.mts"),
Expand Down
Loading
Loading