diff --git a/brev/welcome-ui/__pycache__/server.cpython-310.pyc b/brev/welcome-ui/__pycache__/server.cpython-310.pyc new file mode 100644 index 0000000..1c9f8e1 Binary files /dev/null and b/brev/welcome-ui/__pycache__/server.cpython-310.pyc differ diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py index 90afc80..2c27bfc 100644 --- a/brev/welcome-ui/server.py +++ b/brev/welcome-ui/server.py @@ -5,6 +5,7 @@ """NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs.""" +import http.client import http.server import json import os @@ -12,20 +13,28 @@ import socket import subprocess import sys +import tempfile import threading import time +try: + import yaml as _yaml +except ImportError: + _yaml = None + PORT = int(os.environ.get("PORT", 8081)) ROOT = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.environ.get("REPO_ROOT", os.path.join(ROOT, "..", "..")) SANDBOX_DIR = os.path.join(REPO_ROOT, "sandboxes", "nemoclaw") NEMOCLAW_IMAGE = "ghcr.io/nvidia/nemoclaw-community/sandboxes/nemoclaw:local" -# POLICY_FILE = os.path.join(SANDBOX_DIR, "policy.yaml") +POLICY_FILE = os.path.join(SANDBOX_DIR, "policy.yaml") LOG_FILE = "/tmp/nemoclaw-sandbox-create.log" BREV_ENV_ID = os.environ.get("BREV_ENV_ID", "") _detected_brev_id = "" +SANDBOX_PORT = 18789 + _sandbox_lock = threading.Lock() _sandbox_state = { "status": "idle", # idle | creating | running | error @@ -35,6 +44,17 @@ } +def _sandbox_ready() -> bool: + with _sandbox_lock: + if _sandbox_state["status"] == "running": + return True + if _sandbox_state["status"] in ("idle", "creating"): + if _gateway_log_ready() and _port_open("127.0.0.1", SANDBOX_PORT): + _sandbox_state["status"] = "running" + return True + return False + + def _extract_brev_id(host: str) -> str: """Extract the Brev environment ID from a Host header like '80810-xxx.brevlab.com'.""" match = re.match(r"\d+-(.+?)\.brevlab\.com", host) @@ -53,15 +73,15 @@ def _maybe_detect_brev_id(host: str) -> None: def _build_openclaw_url(token: str | None) -> str: """Build the externally reachable OpenClaw URL. - Uses the Cloudflare tunnel pattern from nemoclaw-start.sh when - BREV_ENV_ID is available (or detected from the request Host header), - otherwise falls back to localhost. + Points to the welcome-ui server itself (port 8081) which reverse-proxies + to the sandbox. This keeps the browser on a single origin and avoids + Brev cross-origin blocks between port subdomains. """ brev_id = BREV_ENV_ID or _detected_brev_id if brev_id: - url = f"https://187890-{brev_id}.brevlab.com/" + url = f"https://80810-{brev_id}.brevlab.com/" else: - url = "http://127.0.0.1:18789/" + url = f"http://127.0.0.1:{PORT}/" if token: url += f"?token={token}" return url @@ -103,6 +123,34 @@ def _gateway_log_ready() -> bool: return False +def _generate_gateway_policy() -> str | None: + """Create a temp policy file suitable for gateway creation. + + Strips ``inference`` (not in the proto schema) and ``process`` (immutable + after creation — including it at creation locks you into it and makes + subsequent updates impossible). + + Returns the path to the temp file, or None if no source policy was found. + The caller is responsible for deleting the file. + """ + if not os.path.isfile(POLICY_FILE): + sys.stderr.write(f"[welcome-ui] Policy file not found: {POLICY_FILE}\n") + return None + + try: + with open(POLICY_FILE) as f: + raw = f.read() + stripped = _strip_policy_fields(raw, extra_fields=("process",)) + fd, path = tempfile.mkstemp(suffix=".yaml", prefix="sandbox-policy-") + with os.fdopen(fd, "w") as f: + f.write(stripped) + sys.stderr.write(f"[welcome-ui] Generated gateway policy from {POLICY_FILE} → {path}\n") + return path + except Exception as exc: + sys.stderr.write(f"[welcome-ui] Failed to generate gateway policy: {exc}\n") + return None + + def _cleanup_existing_sandbox(): """Delete any leftover sandbox named 'nemoclaw' from a previous attempt.""" try: @@ -127,6 +175,8 @@ def _run_sandbox_create(): chat_ui_url = _build_openclaw_url(token=None) + policy_path = _generate_gateway_policy() + env = os.environ.copy() # Use `env` to inject vars into the sandbox command. Avoids the # nemoclaw -e flag which has a quoting bug that causes SSH to @@ -138,6 +188,10 @@ def _run_sandbox_create(): "--name", "nemoclaw", "--from", NEMOCLAW_IMAGE, "--forward", "18789", + ] + if policy_path: + cmd += ["--policy", policy_path] + cmd += [ "--", "env", f"CHAT_UI_URL={chat_ui_url}", @@ -175,6 +229,12 @@ def _stream_output(): proc.wait() streamer.join(timeout=5) + if policy_path: + try: + os.unlink(policy_path) + except OSError: + pass + if proc.returncode != 0: with _sandbox_lock: _sandbox_state["status"] = "error" @@ -226,29 +286,223 @@ def _get_hostname() -> str: return socket.getfqdn() +def _strip_policy_fields(yaml_text: str, extra_fields: tuple[str, ...] = ()) -> str: + """Remove fields that the gateway does not understand or rejects. + + Always strips ``inference``. Pass additional top-level keys via + *extra_fields* (e.g. ``("process",)``) to strip those too. + """ + remove = {"inference"} | set(extra_fields) + if _yaml is not None: + doc = _yaml.safe_load(yaml_text) + if isinstance(doc, dict): + for key in remove: + doc.pop(key, None) + return _yaml.dump(doc, default_flow_style=False, sort_keys=False) + lines = yaml_text.splitlines(keepends=True) + out, skip = [], False + for line in lines: + if any(re.match(rf"^{re.escape(k)}:", line) for k in remove): + skip = True + continue + if skip and (line[0:1] in (" ", "\t") or line.strip() == ""): + continue + skip = False + out.append(line) + return "".join(out) + + +def _log(msg: str) -> None: + ts = time.strftime("%H:%M:%S") + sys.stderr.write(f"[policy-sync {ts}] {msg}\n") + sys.stderr.flush() + + +def _sync_policy_to_gateway(yaml_text: str, sandbox_name: str = "nemoclaw") -> dict: + """Push a policy YAML to the NemoClaw gateway via the host-side CLI.""" + _log(f"step 2/4: stripping inference+process fields ({len(yaml_text)} bytes in)") + stripped = _strip_policy_fields(yaml_text, extra_fields=("process",)) + _log(f" stripped to {len(stripped)} bytes") + + fd, tmp_path = tempfile.mkstemp(suffix=".yaml", prefix="policy-sync-") + try: + with os.fdopen(fd, "w") as f: + f.write(stripped) + cmd = ["nemoclaw", "policy", "set", sandbox_name, "--policy", tmp_path] + _log(f"step 3/4: running {' '.join(cmd)}") + t0 = time.time() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + elapsed = time.time() - t0 + _log(f" CLI exited {result.returncode} in {elapsed:.1f}s") + if result.stdout.strip(): + _log(f" stdout: {result.stdout.strip()}") + if result.stderr.strip(): + _log(f" stderr: {result.stderr.strip()}") + finally: + os.unlink(tmp_path) + + if result.returncode != 0: + err_msg = (result.stderr or result.stdout or "unknown error").strip() + _log(f"step 4/4: FAILED — {err_msg}") + return {"ok": False, "error": err_msg} + + output = result.stdout + result.stderr + ver_match = re.search(r"version\s+(\d+)", output) + hash_match = re.search(r"hash:\s*([a-f0-9]+)", output) + version = int(ver_match.group(1)) if ver_match else 0 + policy_hash = hash_match.group(1) if hash_match else "" + _log(f"step 4/4: SUCCESS — version={version} hash={policy_hash}") + return {"ok": True, "applied": True, "version": version, "policy_hash": policy_hash} + + class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=ROOT, **kwargs) + _proxy_response = False + def end_headers(self): - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + if not self._proxy_response: + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") super().end_headers() - # -- Routing -------------------------------------------------------- + # -- Unified routing ------------------------------------------------ - def do_POST(self): + def _route(self): _maybe_detect_brev_id(self.headers.get("Host", "")) - if self.path == "/api/install-openclaw": - return self._handle_install_openclaw() - self.send_error(404) + path = self.path.split("?")[0] - def do_GET(self): - _maybe_detect_brev_id(self.headers.get("Host", "")) - if self.path == "/api/sandbox-status": + if self.headers.get("Upgrade", "").lower() == "websocket" and _sandbox_ready(): + return self._proxy_websocket() + + if self.command == "OPTIONS": + self.send_response(204) + self.end_headers() + return + + if path == "/api/sandbox-status" and self.command == "GET": return self._handle_sandbox_status() - if self.path == "/api/connection-details": + if path == "/api/connection-details" and self.command == "GET": return self._handle_connection_details() - return super().do_GET() + if path == "/api/install-openclaw" and self.command == "POST": + return self._handle_install_openclaw() + if path == "/api/policy-sync" and self.command == "POST": + return self._handle_policy_sync() + + if _sandbox_ready(): + return self._proxy_to_sandbox() + + if self.command in ("GET", "HEAD"): + return super().do_GET() + + self.send_error(404) + + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = lambda self: self._route() + def do_OPTIONS(self): return self._route() + + # -- Reverse proxy to sandbox -------------------------------------- + + _HOP_BY_HOP = frozenset(( + "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailers", + "transfer-encoding", "upgrade", + )) + + def _proxy_to_sandbox(self): + """Forward an HTTP request to the sandbox proxy on localhost.""" + try: + conn = http.client.HTTPConnection("127.0.0.1", SANDBOX_PORT, timeout=120) + + body = None + cl = self.headers.get("Content-Length") + if cl: + body = self.rfile.read(int(cl)) + + hdrs = {} + for key, val in self.headers.items(): + if key.lower() == "host": + continue + hdrs[key] = val + hdrs["Host"] = f"127.0.0.1:{SANDBOX_PORT}" + + conn.request(self.command, self.path, body=body, headers=hdrs) + resp = conn.getresponse() + + resp_body = resp.read() + + self._proxy_response = True + self.send_response_only(resp.status, resp.reason) + for key, val in resp.getheaders(): + if key.lower() in self._HOP_BY_HOP: + continue + if key.lower() == "content-length": + continue + self.send_header(key, val) + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + + self.wfile.write(resp_body) + self.wfile.flush() + conn.close() + except Exception as exc: + sys.stderr.write(f"[welcome-ui] proxy error: {exc}\n") + try: + self.send_error(502, "Sandbox unavailable") + except Exception: + pass + finally: + self._proxy_response = False + self.close_connection = True + + def _proxy_websocket(self): + """Pipe a WebSocket upgrade to the sandbox via raw sockets.""" + try: + upstream = socket.create_connection( + ("127.0.0.1", SANDBOX_PORT), timeout=5, + ) + except OSError: + self.send_error(502, "Sandbox unavailable") + return + + req = f"{self.requestline}\r\n" + for key, val in self.headers.items(): + if key.lower() == "host": + req += f"Host: 127.0.0.1:{SANDBOX_PORT}\r\n" + else: + req += f"{key}: {val}\r\n" + req += "\r\n" + upstream.sendall(req.encode()) + + client = self.connection + + def _pipe(src, dst): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except Exception: + pass + try: + dst.shutdown(socket.SHUT_WR) + except Exception: + pass + + t1 = threading.Thread(target=_pipe, args=(client, upstream), daemon=True) + t2 = threading.Thread(target=_pipe, args=(upstream, client), daemon=True) + t1.start() + t2.start() + t1.join(timeout=7200) + t2.join(timeout=7200) + try: + upstream.close() + except Exception: + pass + self.close_connection = True # -- POST /api/install-openclaw ------------------------------------ @@ -275,15 +529,37 @@ def _handle_install_openclaw(self): return self._json_response(200, {"ok": True}) + # -- POST /api/policy-sync ------------------------------------------ + + def _handle_policy_sync(self): + origin = self.headers.get("Origin", "unknown") + _log(f"── POST /api/policy-sync received (origin={origin})") + _log("step 1/4: reading request body") + content_length = int(self.headers.get("Content-Length", 0)) + if content_length == 0: + _log(" REJECTED: empty body") + return self._json_response(400, {"ok": False, "error": "empty body"}) + body = self.rfile.read(content_length).decode("utf-8", errors="replace") + _log(f" received {len(body)} bytes") + if "version:" not in body: + _log(" REJECTED: missing version field") + return self._json_response(400, { + "ok": False, "error": "invalid policy: missing version field", + }) + result = _sync_policy_to_gateway(body) + status = 200 if result.get("ok") else 502 + _log(f"── responding {status}: {json.dumps(result)}") + return self._json_response(status, result) + # -- GET /api/sandbox-status ---------------------------------------- def _handle_sandbox_status(self): with _sandbox_lock: state = dict(_sandbox_state) - if (state["status"] == "creating" + if (state["status"] in ("creating", "idle") and _gateway_log_ready() - and _port_open("127.0.0.1", 18789)): + and _port_open("127.0.0.1", SANDBOX_PORT)): token = _read_openclaw_token() url = _build_openclaw_url(token) with _sandbox_lock: diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index 74eb63a..3fd648c 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -20,6 +20,17 @@ USER root COPY nemoclaw-start.sh /usr/local/bin/nemoclaw-start RUN chmod +x /usr/local/bin/nemoclaw-start +# Install the policy reverse proxy (sits in front of the OpenClaw gateway, +# intercepts /api/policy to read/write the sandbox policy file) and its +# runtime dependencies for gRPC gateway sync. +COPY policy-proxy.js /usr/local/lib/policy-proxy.js +COPY proto/ /usr/local/lib/nemoclaw-proto/ +RUN npm install -g @grpc/grpc-js @grpc/proto-loader js-yaml + +# Allow the sandbox user to read the default policy (the startup script +# copies it to a writable location; this chown covers non-Landlock envs) +RUN chown -R sandbox:sandbox /etc/navigator + # Stage the NeMoClaw DevX extension source COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ @@ -29,8 +40,11 @@ COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ # add \n|" "$UI_DIR/index.html"; \ - npm uninstall -g esbuild + npm uninstall -g esbuild; \ + rm -rf /opt/nemoclaw-devx/node_modules ENTRYPOINT ["/bin/bash"] diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 74ad006..4ed94c4 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -40,6 +40,14 @@ set -euo pipefail # that is blocked, we skip gracefully — users can still enter keys via # the API Keys page in the OpenClaw UI. # -------------------------------------------------------------------------- +if [ -z "${CHAT_UI_URL:-}" ]; then + echo "Error: CHAT_UI_URL environment variable is required." >&2 + echo "Set it to the URL where the chat UI will be accessed, e.g.:" >&2 + echo " Local: CHAT_UI_URL=http://127.0.0.1:18789" >&2 + echo " Brev: CHAT_UI_URL=https://187890-.brevlab.com" >&2 + exit 1 +fi + BUNDLE="$(npm root -g)/openclaw/dist/control-ui/assets/nemoclaw-devx.js" if [ -f "$BUNDLE" ]; then @@ -74,26 +82,21 @@ openclaw onboard \ --custom-api-key "not-used" \ --secret-input-mode plaintext \ --custom-compatibility openai \ - --gateway-port 18789 \ + --gateway-port 18788 \ --gateway-bind loopback export NVIDIA_API_KEY=" " -GATEWAY_PORT=18789 - -if [ -z "${CHAT_UI_URL:-}" ]; then - echo "Error: CHAT_UI_URL environment variable is required." >&2 - echo "Set it to the URL where the chat UI will be accessed, e.g.:" >&2 - echo " Local: CHAT_UI_URL=http://127.0.0.1:18789" >&2 - echo " Brev: CHAT_UI_URL=https://187890-.brevlab.com" >&2 - exit 1 -fi +INTERNAL_GATEWAY_PORT=18788 +PUBLIC_PORT=18789 +# allowedOrigins must reference the PUBLIC port (18789) since that is the +# origin the browser sends. The proxy on 18789 forwards to 18788 internally. python3 -c " import json, os from urllib.parse import urlparse cfg = json.load(open(os.environ['HOME'] + '/.openclaw/openclaw.json')) -local = 'http://127.0.0.1:${GATEWAY_PORT}' +local = 'http://127.0.0.1:${PUBLIC_PORT}' parsed = urlparse(os.environ['CHAT_UI_URL']) chat_origin = f'{parsed.scheme}://{parsed.netloc}' origins = [local] @@ -108,6 +111,24 @@ json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), inden nohup openclaw gateway > /tmp/gateway.log 2>&1 & +# Copy the default policy to a writable location so that policy-proxy can +# update it at runtime. /etc is read-only under Landlock, but /sandbox is +# read-write, so we use /sandbox/.openclaw/ which is already owned by the +# sandbox user. +_POLICY_SRC="/etc/navigator/policy.yaml" +_POLICY_DST="/sandbox/.openclaw/policy.yaml" +if [ ! -f "$_POLICY_DST" ] && [ -f "$_POLICY_SRC" ]; then + cp "$_POLICY_SRC" "$_POLICY_DST" 2>/dev/null || true +fi +_POLICY_PATH="${_POLICY_DST}" +[ -f "$_POLICY_PATH" ] || _POLICY_PATH="$_POLICY_SRC" + +# Start the policy reverse proxy on the public-facing port. It forwards all +# traffic to the OpenClaw gateway on the internal port and intercepts +# /api/policy requests to read/write the sandbox policy file. +NODE_PATH=$(npm root -g) POLICY_PATH=${_POLICY_PATH} UPSTREAM_PORT=${INTERNAL_GATEWAY_PORT} LISTEN_PORT=${PUBLIC_PORT} \ + nohup node /usr/local/lib/policy-proxy.js >> /tmp/gateway.log 2>&1 & + # Auto-approve pending device pairing requests so the browser is paired # before the user notices the "pairing required" prompt in the Control UI. ( diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts index 4f5e3a3..fbf1628 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts @@ -30,6 +30,30 @@ export const ICON_EYE = ``; +export const ICON_LOCK = ``; + +export const ICON_PLUS = ``; + +export const ICON_TRASH = ``; + +export const ICON_EDIT = ``; + +export const ICON_INFO = ``; + +export const ICON_GLOBE = ``; + +export const ICON_TERMINAL = ``; + +export const ICON_FOLDER = ``; + +export const ICON_USER = ``; + +export const ICON_CHEVRON_RIGHT = ``; + +export const ICON_SEARCH = ``; + +export const ICON_WARNING = ``; + export const TARGET_ICONS: Record = { "dgx-spark": ICON_CHIP, "dgx-station": ICON_SERVER, diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts index a94d1cb..c8a7c16 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts @@ -7,6 +7,7 @@ import { ICON_SHIELD, ICON_ROUTE, ICON_KEY } from "./icons.ts"; import { renderApiKeysPage, areAllKeysConfigured, updateStatusDots } from "./api-keys-page.ts"; +import { renderPolicyPage } from "./policy-page.ts"; // --------------------------------------------------------------------------- // Page definitions @@ -26,12 +27,12 @@ interface NemoClawPage { const NEMOCLAW_PAGES: NemoClawPage[] = [ { id: "nemoclaw-policy", - label: "Policy", + label: "Sandbox Policy", icon: ICON_SHIELD, - title: "Policy", - subtitle: "Manage deployment policies and guardrails", - emptyMessage: - "Policy configuration is coming soon. You'll be able to define safety policies, rate limits, and access controls for your NeMoClaw deployments here.", + title: "Sandbox Policy", + subtitle: "View and manage sandbox security guardrails", + emptyMessage: "", + customRender: renderPolicyPage, }, { id: "nemoclaw-inference-routes", diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json new file mode 100644 index 0000000..4267179 --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts new file mode 100644 index 0000000..c81ade9 --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts @@ -0,0 +1,1470 @@ +/** + * NeMoClaw DevX — Policy Page + * + * Interactive policy viewer and editor. Fetches the sandbox policy YAML from + * the policy-proxy API, renders educational sections for immutable fields and + * a full CRUD editor for network policies, and saves changes back via POST. + */ + +import * as yaml from "js-yaml"; +import { + ICON_LOCK, + ICON_GLOBE, + ICON_INFO, + ICON_PLUS, + ICON_TRASH, + ICON_CHECK, + ICON_CHEVRON_RIGHT, + ICON_CHEVRON_DOWN, + ICON_LOADER, + ICON_TERMINAL, + ICON_CLOSE, + ICON_SHIELD, + ICON_FOLDER, + ICON_USER, + ICON_WARNING, +} from "./icons.ts"; + +// --------------------------------------------------------------------------- +// Types — mirrors the YAML schema +// --------------------------------------------------------------------------- + +interface PolicyEndpoint { + host?: string; + port: number; + protocol?: string; + tls?: string; + enforcement?: string; + access?: string; + rules?: { allow: { method: string; path: string } }[]; + allowed_ips?: string[]; +} + +interface PolicyBinary { + path: string; +} + +interface NetworkPolicy { + name: string; + endpoints: PolicyEndpoint[]; + binaries: PolicyBinary[]; +} + +interface SandboxPolicy { + version: number; + filesystem_policy?: { + include_workdir?: boolean; + read_only?: string[]; + read_write?: string[]; + }; + landlock?: { compatibility?: string }; + process?: { run_as_user?: string; run_as_group?: string }; + network_policies?: Record; + inference?: Record; +} + +interface SelectOption { + value: string; + label: string; +} + +// --------------------------------------------------------------------------- +// Policy templates +// --------------------------------------------------------------------------- + +const POLICY_TEMPLATES: { label: string; key: string; policy: NetworkPolicy }[] = [ + { + label: "GitHub (git + API)", + key: "github_custom", + policy: { + name: "github_custom", + endpoints: [ + { host: "github.com", port: 443 }, + { host: "api.github.com", port: 443 }, + ], + binaries: [{ path: "/usr/bin/git" }, { path: "/usr/bin/gh" }], + }, + }, + { + label: "npm Registry", + key: "npm", + policy: { + name: "npm", + endpoints: [{ host: "registry.npmjs.org", port: 443 }], + binaries: [{ path: "/usr/bin/npm" }, { path: "/usr/bin/node" }], + }, + }, + { + label: "PyPI", + key: "pypi", + policy: { + name: "pypi", + endpoints: [ + { host: "pypi.org", port: 443 }, + { host: "files.pythonhosted.org", port: 443 }, + ], + binaries: [{ path: "/usr/bin/pip" }, { path: "/usr/bin/python3" }], + }, + }, + { + label: "Docker Hub", + key: "docker_hub", + policy: { + name: "docker_hub", + endpoints: [ + { host: "registry-1.docker.io", port: 443 }, + { host: "auth.docker.io", port: 443 }, + { host: "production.cloudflare.docker.com", port: 443 }, + ], + binaries: [{ path: "/usr/bin/docker" }], + }, + }, +]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let currentPolicy: SandboxPolicy | null = null; +let rawYaml = ""; +let isDirty = false; +const changeTracker = { + modified: new Set(), + added: new Set(), + deleted: new Set(), +}; +let pageContainer: HTMLElement | null = null; +let saveBarEl: HTMLElement | null = null; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +async function fetchPolicy(): Promise { + const res = await fetch("/api/policy"); + if (!res.ok) throw new Error(`Failed to load policy: ${res.status}`); + return res.text(); +} + +interface SavePolicyResult { + ok: boolean; + applied?: boolean; + version?: number; + policy_hash?: string; + reason?: string; +} + +async function savePolicy(yamlText: string): Promise { + console.log("[policy-save] step 1/2: POST /api/policy →", yamlText.length, "bytes"); + const res = await fetch("/api/policy", { + method: "POST", + headers: { "Content-Type": "text/yaml" }, + body: yamlText, + }); + const body = await res.json().catch(() => ({})) as SavePolicyResult; + console.log("[policy-save] step 1/2: proxy responded", JSON.stringify(body)); + if (!res.ok) { + throw new Error((body as { error?: string }).error || `Save failed: ${res.status}`); + } + return body; +} + +async function syncPolicyViaHost(yamlText: string): Promise { + console.log("[policy-save] step 2/2: POST /api/policy-sync →", yamlText.length, "bytes"); + const res = await fetch("/api/policy-sync", { + method: "POST", + headers: { "Content-Type": "text/yaml" }, + body: yamlText, + }); + const body = await res.json().catch(() => ({})) as SavePolicyResult; + console.log("[policy-save] step 2/2: host relay responded", JSON.stringify(body)); + if (!res.ok) { + throw new Error((body as { error?: string }).error || `Host sync failed: ${res.status}`); + } + return body; +} + +// --------------------------------------------------------------------------- +// Render entry point +// --------------------------------------------------------------------------- + +export function renderPolicyPage(container: HTMLElement): void { + container.innerHTML = ` +
+
+
Sandbox Policy
+
Controls what code in your sandbox can access
+
+
+
+
+ ${ICON_LOADER} + Loading policy… +
+
`; + + pageContainer = container; + loadAndRender(container); +} + +async function loadAndRender(container: HTMLElement): Promise { + const page = container.querySelector(".nemoclaw-policy-page")!; + try { + rawYaml = await fetchPolicy(); + currentPolicy = yaml.load(rawYaml) as SandboxPolicy; + isDirty = false; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + renderPageContent(page); + } catch (err) { + const errStr = String(err); + const is404 = errStr.includes("404"); + page.innerHTML = ` +
+

${is404 ? "Policy file not found. The sandbox may still be starting." : "Could not load the sandbox policy."}

+

${escapeHtml(errStr)}

+ +
`; + page.querySelector(".nemoclaw-policy-retry-btn")?.addEventListener("click", () => { + page.innerHTML = ` +
+ ${ICON_LOADER} + Loading policy… +
`; + loadAndRender(container); + }); + } +} + +// --------------------------------------------------------------------------- +// Main page layout +// --------------------------------------------------------------------------- + +function renderPageContent(page: HTMLElement): void { + if (!currentPolicy) return; + + page.innerHTML = ""; + + page.appendChild(buildTabLayout()); + + saveBarEl = buildSaveBar(); + page.appendChild(saveBarEl); +} + +// --------------------------------------------------------------------------- +// Tab layout (Editable default, Locked for inspection) +// --------------------------------------------------------------------------- + +function buildTabLayout(): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-tabs-wrapper"; + + const policies = currentPolicy?.network_policies || {}; + const policyCount = Object.keys(policies).length; + + const tabbar = document.createElement("div"); + tabbar.className = "nemoclaw-policy-tabbar"; + + const editableTab = document.createElement("button"); + editableTab.type = "button"; + editableTab.className = "nemoclaw-policy-tabbar__tab nemoclaw-policy-tabbar__tab--active"; + editableTab.innerHTML = `Editable ${policyCount}`; + + const lockedTab = document.createElement("button"); + lockedTab.type = "button"; + lockedTab.className = "nemoclaw-policy-tabbar__tab"; + lockedTab.innerHTML = `${ICON_LOCK} Locked`; + + tabbar.appendChild(editableTab); + tabbar.appendChild(lockedTab); + wrapper.appendChild(tabbar); + + const editablePanel = document.createElement("div"); + editablePanel.className = "nemoclaw-policy-tab-panel"; + editablePanel.appendChild(buildNetworkPoliciesSection()); + + const lockedPanel = document.createElement("div"); + lockedPanel.className = "nemoclaw-policy-tab-panel"; + lockedPanel.style.display = "none"; + lockedPanel.appendChild(buildImmutableGrid()); + + wrapper.appendChild(editablePanel); + wrapper.appendChild(lockedPanel); + + editableTab.addEventListener("click", () => { + editableTab.classList.add("nemoclaw-policy-tabbar__tab--active"); + lockedTab.classList.remove("nemoclaw-policy-tabbar__tab--active"); + editablePanel.style.display = ""; + lockedPanel.style.display = "none"; + }); + + lockedTab.addEventListener("click", () => { + lockedTab.classList.add("nemoclaw-policy-tabbar__tab--active"); + editableTab.classList.remove("nemoclaw-policy-tabbar__tab--active"); + lockedPanel.style.display = ""; + editablePanel.style.display = "none"; + }); + + return wrapper; +} + +// --------------------------------------------------------------------------- +// Immutable grid (3 flat read-only cards) +// --------------------------------------------------------------------------- + +function buildImmutableGrid(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-policy-immutable-section"; + section.dataset.section = "immutable"; + + const intro = document.createElement("p"); + intro.className = "nemoclaw-policy-immutable-intro"; + intro.textContent = "These policies are set when the sandbox is created and cannot be changed at runtime. They define the security boundary that all code inside the sandbox must operate within."; + section.appendChild(intro); + + const grid = document.createElement("div"); + grid.className = "nemoclaw-policy-immutable-grid"; + + grid.appendChild(buildFilesystemCard()); + grid.appendChild(buildProcessCard()); + grid.appendChild(buildKernelCard()); + + section.appendChild(grid); + + const footer = document.createElement("p"); + footer.className = "nemoclaw-policy-immutable-footer"; + footer.innerHTML = `To modify these settings, update policy.yaml and recreate the sandbox.`; + section.appendChild(footer); + + return section; +} + +function buildFilesystemCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; + + const fs = currentPolicy?.filesystem_policy; + + card.innerHTML = ` +
+ ${ICON_FOLDER} + Filesystem Access + ${ICON_LOCK} +
+
Paths the sandbox can read or write
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + + if (!fs) { + content.innerHTML = `No filesystem policy defined`; + } else { + let html = ""; + if (fs.read_only?.length) { + html += `
Read-only
`; + html += `
${fs.read_only.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + if (fs.read_write?.length) { + html += `
Read-write
`; + html += `
${fs.read_write.map((p) => `${escapeHtml(p)}`).join("")}
`; + } + if (fs.include_workdir) { + html += `
Working directory included
`; + } + content.innerHTML = html; + } + + card.appendChild(content); + return card; +} + +function buildProcessCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; + + const p = currentPolicy?.process; + const user = p?.run_as_user || "not set"; + const group = p?.run_as_group || "not set"; + + card.innerHTML = ` +
+ ${ICON_USER} + Process Identity + ${ICON_LOCK} +
+
All code runs as this OS user
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + content.innerHTML = ` +
+ User + ${escapeHtml(user)} +
+
+ Group + ${escapeHtml(group)} +
`; + + card.appendChild(content); + return card; +} + +function buildKernelCard(): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-imm-card"; + + const ll = currentPolicy?.landlock; + const compat = ll?.compatibility || "not set"; + + card.innerHTML = ` +
+ ${ICON_SHIELD} + Kernel Enforcement + ${ICON_LOCK} +
+
Linux kernel restricts filesystem and network access
`; + + const content = document.createElement("div"); + content.className = "nemoclaw-policy-imm-card__content"; + content.innerHTML = ` +
+ Mode + ${escapeHtml(compat)} +
`; + + card.appendChild(content); + return card; +} + +// --------------------------------------------------------------------------- +// Network policies (editable) +// --------------------------------------------------------------------------- + +function buildNetworkPoliciesSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-policy-section"; + section.dataset.section = "network"; + + const policies = currentPolicy?.network_policies || {}; + const policyCount = Object.keys(policies).length; + + const headerRow = document.createElement("div"); + headerRow.className = "nemoclaw-policy-section__header"; + headerRow.innerHTML = ` + ${ICON_GLOBE} +

Network Policies

+ ${policyCount}`; + + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.className = "nemoclaw-policy-search"; + searchInput.placeholder = "Filter policies..."; + searchInput.addEventListener("input", () => { + const q = searchInput.value.toLowerCase().trim(); + section.querySelectorAll(".nemoclaw-policy-netcard").forEach((card) => { + if (!q) { + card.style.display = ""; + return; + } + const key = card.dataset.policyKey || ""; + const policy = currentPolicy?.network_policies?.[key]; + const hosts = (policy?.endpoints || []).map((ep) => ep.host || "").join(" "); + const bins = (policy?.binaries || []).map((b) => b.path).join(" "); + const haystack = `${key} ${policy?.name || ""} ${hosts} ${bins}`.toLowerCase(); + card.style.display = haystack.includes(q) ? "" : "none"; + }); + }); + headerRow.appendChild(searchInput); + section.appendChild(headerRow); + + const desc = document.createElement("p"); + desc.className = "nemoclaw-policy-section__desc"; + desc.textContent = "Each rule controls which binaries can reach which hosts. All outbound access is denied by default \u2014 add permissions below to allow specific connections."; + section.appendChild(desc); + + const list = document.createElement("div"); + list.className = "nemoclaw-policy-netpolicies"; + + if (policyCount === 0) { + list.appendChild(buildNetworkEmptyState()); + } else { + for (const [key, policy] of Object.entries(policies)) { + list.appendChild(buildNetworkPolicyCard(key, policy, list)); + } + } + + section.appendChild(list); + + const addWrap = document.createElement("div"); + addWrap.className = "nemoclaw-policy-add-wrap"; + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add Network Policy ${ICON_CHEVRON_DOWN}`; + + let dropdownOpen = false; + let dropdownEl: HTMLElement | null = null; + + function closeDropdown() { + dropdownOpen = false; + dropdownEl?.remove(); + dropdownEl = null; + } + + addBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (dropdownOpen) { + closeDropdown(); + return; + } + dropdownOpen = true; + dropdownEl = document.createElement("div"); + dropdownEl.className = "nemoclaw-policy-templates"; + + // Blank option at the top + const blankOpt = document.createElement("button"); + blankOpt.type = "button"; + blankOpt.className = "nemoclaw-policy-template-option nemoclaw-policy-template-option--blank"; + blankOpt.innerHTML = `Blank + Start from scratch`; + blankOpt.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeDropdown(); + showInlineNewPolicyForm(list); + }); + dropdownEl.appendChild(blankOpt); + + for (const tmpl of POLICY_TEMPLATES) { + const hosts = tmpl.policy.endpoints.map((ep) => ep.host).filter(Boolean).slice(0, 2).join(", "); + const bins = tmpl.policy.binaries.map((b) => b.path.split("/").pop()).join(", "); + + const opt = document.createElement("button"); + opt.type = "button"; + opt.className = "nemoclaw-policy-template-option"; + opt.innerHTML = `${escapeHtml(tmpl.label)} + ${escapeHtml(hosts)} — ${escapeHtml(bins)}`; + opt.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeDropdown(); + showInlineNewPolicyForm(list, tmpl); + }); + dropdownEl.appendChild(opt); + } + + addWrap.appendChild(dropdownEl); + }); + + document.addEventListener("click", () => { if (dropdownOpen) closeDropdown(); }); + + addWrap.appendChild(addBtn); + section.appendChild(addWrap); + + return section; +} + +// --------------------------------------------------------------------------- +// Network empty state +// --------------------------------------------------------------------------- + +function buildNetworkEmptyState(): HTMLElement { + const el = document.createElement("div"); + el.className = "nemoclaw-policy-net-empty"; + el.innerHTML = ` + ${ICON_GLOBE} + No network policies + Your sandbox cannot make outbound connections.`; + return el; +} + +// --------------------------------------------------------------------------- +// Network policy card +// --------------------------------------------------------------------------- + +function hasEnforcement(policy: NetworkPolicy): boolean { + return (policy.endpoints || []).some((ep) => ep.enforcement === "enforce"); +} + +function hasAudit(policy: NetworkPolicy): boolean { + return (policy.endpoints || []).some((ep) => ep.enforcement === "audit"); +} + +function generatePolicyTooltip(policy: NetworkPolicy): string { + const bins = (policy.binaries || []).map((b) => b.path.split("/").pop()).filter(Boolean); + const hosts = (policy.endpoints || []).map((ep) => ep.host).filter(Boolean) as string[]; + if (!bins.length && !hosts.length) return ""; + + const binStr = bins.length <= 2 ? bins.join(" and ") : `${bins[0]} and ${bins.length - 1} others`; + const hostStr = hosts.length <= 2 ? hosts.join(" and ") : `${hosts[0]} and ${hosts.length - 1} other hosts`; + + if (bins.length && hosts.length) return `Allows ${binStr} to reach ${hostStr}`; + if (hosts.length) return `Allows connections to ${hostStr}`; + return ""; +} + +function buildNetworkPolicyCard(key: string, policy: NetworkPolicy, list: HTMLElement): HTMLElement { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-netcard"; + card.dataset.policyKey = key; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-netcard__header"; + + const enforcing = hasEnforcement(policy); + const auditing = hasAudit(policy); + const enfIndicator = enforcing + ? `L7 Enforced` + : auditing + ? `L7 Audit` + : `L4 Default`; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "nemoclaw-policy-netcard__toggle"; + toggle.innerHTML = `${ICON_CHEVRON_RIGHT} + ${escapeHtml(policy.name || key)} + ${enfIndicator} + ${policy.endpoints?.length || 0} endpoint${(policy.endpoints?.length || 0) !== 1 ? "s" : ""}, ${policy.binaries?.length || 0} ${(policy.binaries?.length || 0) !== 1 ? "binaries" : "binary"}`; + + const tooltip = generatePolicyTooltip(policy); + if (tooltip) toggle.title = tooltip; + + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-netcard__actions"; + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + deleteBtn.title = "Delete policy"; + deleteBtn.innerHTML = ICON_TRASH; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showDeleteConfirmation(actions, deleteBtn, key, card); + }); + actions.appendChild(deleteBtn); + + header.appendChild(toggle); + header.appendChild(actions); + + const preview = document.createElement("div"); + preview.className = "nemoclaw-policy-netcard__preview"; + const hosts = (policy.endpoints || []).map((ep) => ep.host).filter(Boolean) as string[]; + const maxChips = 3; + for (let i = 0; i < Math.min(hosts.length, maxChips); i++) { + const chip = document.createElement("code"); + chip.className = "nemoclaw-policy-host-chip"; + chip.textContent = hosts[i]; + preview.appendChild(chip); + } + if (hosts.length > maxChips) { + const more = document.createElement("span"); + more.className = "nemoclaw-policy-host-chip nemoclaw-policy-host-chip--more"; + more.textContent = `+${hosts.length - maxChips} more`; + preview.appendChild(more); + } + + const body = document.createElement("div"); + body.className = "nemoclaw-policy-netcard__body"; + body.style.display = "none"; + renderNetworkPolicyBody(body, key, policy); + + let expanded = false; + toggle.addEventListener("click", () => { + expanded = !expanded; + body.style.display = expanded ? "" : "none"; + card.classList.toggle("nemoclaw-policy-netcard--expanded", expanded); + }); + + card.appendChild(header); + card.appendChild(preview); + card.appendChild(body); + return card; +} + +// --------------------------------------------------------------------------- +// Delete confirmation +// --------------------------------------------------------------------------- + +function showDeleteConfirmation(actions: HTMLElement, deleteBtn: HTMLElement, key: string, card: HTMLElement): void { + deleteBtn.style.display = "none"; + + const confirmWrap = document.createElement("div"); + confirmWrap.className = "nemoclaw-policy-confirm-actions"; + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--delete"; + confirmBtn.textContent = "Delete"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + confirmWrap.appendChild(confirmBtn); + confirmWrap.appendChild(cancelBtn); + actions.appendChild(confirmWrap); + card.classList.add("nemoclaw-policy-netcard--confirming"); + + const revert = () => { + confirmWrap.remove(); + deleteBtn.style.display = ""; + card.classList.remove("nemoclaw-policy-netcard--confirming"); + }; + + const timeout = setTimeout(revert, 5000); + + cancelBtn.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(timeout); + revert(); + }); + + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(timeout); + if (currentPolicy?.network_policies) { + delete currentPolicy.network_policies[key]; + markDirty(key, "deleted"); + card.remove(); + updateNetworkCount(); + if (Object.keys(currentPolicy.network_policies).length === 0) { + const list = document.querySelector(".nemoclaw-policy-netpolicies"); + if (list) list.appendChild(buildNetworkEmptyState()); + } + } + }); +} + +// --------------------------------------------------------------------------- +// Inline new-policy form +// --------------------------------------------------------------------------- + +function showInlineNewPolicyForm(list: HTMLElement, template?: { key: string; label: string; policy: NetworkPolicy }): void { + const existing = list.querySelector(".nemoclaw-policy-newcard"); + if (existing) existing.remove(); + const emptyState = list.querySelector(".nemoclaw-policy-net-empty"); + if (emptyState) emptyState.remove(); + + const form = document.createElement("div"); + form.className = "nemoclaw-policy-newcard"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "e.g. my_custom_api"; + input.value = template ? template.key : ""; + + const createBtn = document.createElement("button"); + createBtn.type = "button"; + createBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; + createBtn.textContent = "Create"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + const hint = document.createElement("div"); + hint.className = "nemoclaw-policy-newcard__hint"; + hint.textContent = "Use snake_case. Only letters, numbers, _ and - allowed."; + + const error = document.createElement("div"); + error.className = "nemoclaw-policy-newcard__error"; + + form.appendChild(input); + form.appendChild(createBtn); + form.appendChild(cancelBtn); + form.appendChild(hint); + form.appendChild(error); + list.prepend(form); + + requestAnimationFrame(() => input.focus()); + + const cancel = () => { + form.remove(); + if (currentPolicy && Object.keys(currentPolicy.network_policies || {}).length === 0) { + list.appendChild(buildNetworkEmptyState()); + } + }; + + cancelBtn.addEventListener("click", cancel); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancel(); + if (e.key === "Enter") doCreate(); + }); + + function doCreate() { + const raw = input.value.trim(); + if (!raw) { + error.textContent = "Name is required."; + return; + } + const key = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + if (!currentPolicy) return; + if (!currentPolicy.network_policies) currentPolicy.network_policies = {}; + if (currentPolicy.network_policies[key]) { + error.textContent = `A policy named "${key}" already exists.`; + input.classList.add("nemoclaw-policy-input--error"); + return; + } + + const newPolicy: NetworkPolicy = template + ? JSON.parse(JSON.stringify(template.policy)) + : { name: key, endpoints: [{ host: "", port: 443 }], binaries: [{ path: "" }] }; + newPolicy.name = key; + + currentPolicy.network_policies[key] = newPolicy; + markDirty(key, "added"); + + form.remove(); + + const card = buildNetworkPolicyCard(key, newPolicy, list); + card.classList.add("nemoclaw-policy-netcard--expanded"); + const cardBody = card.querySelector(".nemoclaw-policy-netcard__body"); + if (cardBody) cardBody.style.display = ""; + const cardPreview = card.querySelector(".nemoclaw-policy-netcard__preview"); + if (cardPreview) cardPreview.style.display = "none"; + list.appendChild(card); + updateNetworkCount(); + } + + createBtn.addEventListener("click", doCreate); +} + +// --------------------------------------------------------------------------- +// Network policy body +// --------------------------------------------------------------------------- + +function renderNetworkPolicyBody(body: HTMLElement, key: string, policy: NetworkPolicy): void { + body.innerHTML = ""; + + const epSection = document.createElement("div"); + epSection.className = "nemoclaw-policy-subsection"; + epSection.innerHTML = `
+ Allowed Endpoints + ${ICON_INFO} +
`; + + const epList = document.createElement("div"); + epList.className = "nemoclaw-policy-ep-list"; + + (policy.endpoints || []).forEach((ep, idx) => { + epList.appendChild(buildEndpointRow(key, ep, idx)); + }); + epSection.appendChild(epList); + + const addEpBtn = document.createElement("button"); + addEpBtn.type = "button"; + addEpBtn.className = "nemoclaw-policy-add-small-btn"; + addEpBtn.innerHTML = `${ICON_PLUS} Add Endpoint`; + addEpBtn.addEventListener("click", () => { + const newEp: PolicyEndpoint = { host: "", port: 443 }; + policy.endpoints = policy.endpoints || []; + policy.endpoints.push(newEp); + markDirty(key, "modified"); + epList.appendChild(buildEndpointRow(key, newEp, policy.endpoints.length - 1)); + }); + epSection.appendChild(addEpBtn); + + const binSection = document.createElement("div"); + binSection.className = "nemoclaw-policy-subsection"; + binSection.innerHTML = `
+ Allowed Binaries + ${ICON_INFO} +
`; + + const binList = document.createElement("div"); + binList.className = "nemoclaw-policy-bin-list"; + + (policy.binaries || []).forEach((bin, idx) => { + binList.appendChild(buildBinaryRow(key, policy, bin, idx)); + }); + binSection.appendChild(binList); + + const addBinBtn = document.createElement("button"); + addBinBtn.type = "button"; + addBinBtn.className = "nemoclaw-policy-add-small-btn"; + addBinBtn.innerHTML = `${ICON_PLUS} Add Binary`; + addBinBtn.addEventListener("click", () => { + const newBin: PolicyBinary = { path: "" }; + policy.binaries = policy.binaries || []; + policy.binaries.push(newBin); + markDirty(key, "modified"); + binList.appendChild(buildBinaryRow(key, policy, newBin, policy.binaries.length - 1)); + }); + binSection.appendChild(addBinBtn); + + body.appendChild(binSection); + body.appendChild(epSection); +} + +// --------------------------------------------------------------------------- +// Endpoint row (progressive: Host+Port primary, advanced toggle) +// --------------------------------------------------------------------------- + +function hasAdvancedFields(ep: PolicyEndpoint): boolean { + return !!(ep.protocol || ep.tls || ep.enforcement || ep.access); +} + +function buildEndpointRow(policyKey: string, ep: PolicyEndpoint, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-ep-row"; + + const mainLine = document.createElement("div"); + mainLine.className = "nemoclaw-policy-ep-row__main"; + + const hostInput = createInput("Host", ep.host || "", (v) => { ep.host = v || undefined; markDirty(policyKey, "modified"); }, "Domain or IP. Supports wildcards like *.example.com"); + hostInput.className += " nemoclaw-policy-input--host"; + + const portInput = createInput("Port", String(ep.port || ""), (v) => { ep.port = parseInt(v, 10) || 0; markDirty(policyKey, "modified"); }, "TCP port (e.g. 443 for HTTPS)"); + portInput.className += " nemoclaw-policy-input--port"; + + mainLine.appendChild(hostInput); + mainLine.appendChild(portInput); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger nemoclaw-policy-ep-row__del"; + delBtn.title = "Remove endpoint"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const policy = currentPolicy?.network_policies?.[policyKey]; + if (policy?.endpoints) { + policy.endpoints.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + mainLine.appendChild(delBtn); + row.appendChild(mainLine); + + // Advanced options (progressive disclosure) + const advancedExpanded = hasAdvancedFields(ep); + + const advToggle = document.createElement("button"); + advToggle.type = "button"; + advToggle.className = "nemoclaw-policy-ep-advanced-toggle"; + advToggle.innerHTML = `${ICON_CHEVRON_RIGHT} Advanced Settings ${ICON_INFO}`; + if (advancedExpanded) advToggle.classList.add("nemoclaw-policy-ep-advanced-toggle--open"); + + const optsLine = document.createElement("div"); + optsLine.className = "nemoclaw-policy-ep-row__opts"; + optsLine.style.display = advancedExpanded ? "" : "none"; + + const protoSelect = createSelect("Protocol", [ + { value: "", label: "(none)" }, + { value: "rest", label: "REST (HTTP inspection)" }, + ], ep.protocol || "", (v) => { + ep.protocol = v || undefined; + markDirty(policyKey, "modified"); + if (v === "rest") { + let rulesEl = row.querySelector(".nemoclaw-policy-ep-rules"); + if (!rulesEl) { + const sibling = row.querySelector(".nemoclaw-policy-ep-ips") || null; + const newRulesEl = buildHttpRulesEditor(policyKey, ep); + if (sibling) row.insertBefore(newRulesEl, sibling); + else row.appendChild(newRulesEl); + } + } + }, "REST enables HTTP method/path inspection"); + + const tlsSelect = createSelect("TLS", [ + { value: "", label: "(none)" }, + { value: "terminate", label: "Terminate (inspect)" }, + { value: "passthrough", label: "Passthrough (encrypted)" }, + ], ep.tls || "", (v) => { ep.tls = v || undefined; markDirty(policyKey, "modified"); }, "Terminate: proxy decrypts for inspection. Passthrough: end-to-end encrypted"); + + const enfSelect = createSelect("Enforcement", [ + { value: "", label: "(none)" }, + { value: "enforce", label: "Enforce (block)" }, + { value: "audit", label: "Audit (log only)" }, + ], ep.enforcement || "", (v) => { ep.enforcement = v || undefined; markDirty(policyKey, "modified"); }, "Enforce: block violations. Audit: log only"); + + const accessSelect = createSelect("Access", [ + { value: "", label: "(none)" }, + { value: "read-only", label: "Read-only" }, + { value: "read-write", label: "Read-write" }, + { value: "full", label: "Full access" }, + ], ep.access || "", (v) => { ep.access = v || undefined; markDirty(policyKey, "modified"); }, "Scope of allowed operations on this endpoint"); + + optsLine.appendChild(protoSelect); + optsLine.appendChild(tlsSelect); + optsLine.appendChild(enfSelect); + optsLine.appendChild(accessSelect); + + advToggle.addEventListener("click", () => { + const isOpen = optsLine.style.display !== "none"; + optsLine.style.display = isOpen ? "none" : ""; + advToggle.classList.toggle("nemoclaw-policy-ep-advanced-toggle--open", !isOpen); + }); + + row.appendChild(advToggle); + row.appendChild(optsLine); + + if (ep.rules?.length || ep.protocol === "rest") { + row.appendChild(buildHttpRulesEditor(policyKey, ep)); + } + + if (ep.allowed_ips?.length) { + row.appendChild(buildAllowedIpsEditor(policyKey, ep)); + } + + return row; +} + +// --------------------------------------------------------------------------- +// HTTP Rules editor (renamed from L7) +// --------------------------------------------------------------------------- + +function buildHttpRulesEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-ep-rules"; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-subsection__header"; + header.innerHTML = ` + HTTP Rules (${ep.rules?.length || 0}) + ${ICON_INFO}`; + wrapper.appendChild(header); + + const microLabel = document.createElement("div"); + microLabel.className = "nemoclaw-policy-micro-label"; + microLabel.textContent = "Only matching HTTP requests are allowed"; + wrapper.appendChild(microLabel); + + const ruleList = document.createElement("div"); + ruleList.className = "nemoclaw-policy-rule-list"; + + (ep.rules || []).forEach((rule, idx) => { + ruleList.appendChild(buildHttpRuleRow(policyKey, ep, rule, idx, ruleList)); + }); + wrapper.appendChild(ruleList); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-small-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add Rule`; + addBtn.addEventListener("click", () => { + if (!ep.rules) ep.rules = []; + const newRule = { allow: { method: "GET", path: "" } }; + ep.rules.push(newRule); + markDirty(policyKey, "modified"); + ruleList.appendChild(buildHttpRuleRow(policyKey, ep, newRule, ep.rules.length - 1, ruleList)); + }); + wrapper.appendChild(addBtn); + + return wrapper; +} + +function buildHttpRuleRow(policyKey: string, ep: PolicyEndpoint, rule: { allow: { method: string; path: string } }, idx: number, ruleList: HTMLElement): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-rule-row"; + + const methodSelect = document.createElement("select"); + methodSelect.className = "nemoclaw-policy-select nemoclaw-policy-rule-method"; + for (const m of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]) { + const o = document.createElement("option"); + o.value = m; + o.textContent = m; + if (m === rule.allow.method) o.selected = true; + methodSelect.appendChild(o); + } + methodSelect.addEventListener("change", () => { rule.allow.method = methodSelect.value; markDirty(policyKey, "modified"); }); + + const pathInput = document.createElement("input"); + pathInput.type = "text"; + pathInput.className = "nemoclaw-policy-input nemoclaw-policy-rule-path"; + pathInput.placeholder = "/**/info/refs*"; + pathInput.value = rule.allow.path; + pathInput.addEventListener("input", () => { rule.allow.path = pathInput.value; markDirty(policyKey, "modified"); }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove rule"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + if (ep.rules) { + ep.rules.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + + row.appendChild(methodSelect); + row.appendChild(pathInput); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Allowed IPs editor +// --------------------------------------------------------------------------- + +function buildAllowedIpsEditor(policyKey: string, ep: PolicyEndpoint): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "nemoclaw-policy-ep-rules nemoclaw-policy-ep-ips"; + + const header = document.createElement("div"); + header.className = "nemoclaw-policy-subsection__header"; + header.innerHTML = ` + Allowed IPs + ${ICON_INFO}`; + wrapper.appendChild(header); + + const microLabel = document.createElement("div"); + microLabel.className = "nemoclaw-policy-micro-label"; + microLabel.textContent = "Bypasses private IP protection for these ranges"; + wrapper.appendChild(microLabel); + + const ipList = document.createElement("div"); + ipList.className = "nemoclaw-policy-bin-list"; + + (ep.allowed_ips || []).forEach((ip, idx) => { + ipList.appendChild(buildIpRow(policyKey, ep, ip, idx)); + }); + wrapper.appendChild(ipList); + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-add-small-btn"; + addBtn.innerHTML = `${ICON_PLUS} Add IP`; + addBtn.addEventListener("click", () => { + if (!ep.allowed_ips) ep.allowed_ips = []; + ep.allowed_ips.push(""); + markDirty(policyKey, "modified"); + ipList.appendChild(buildIpRow(policyKey, ep, "", ep.allowed_ips.length - 1)); + }); + wrapper.appendChild(addBtn); + + return wrapper; +} + +function isValidCidr(s: string): boolean { + if (!s.trim()) return true; + const match = s.match(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/); + return !!match; +} + +function buildIpRow(policyKey: string, ep: PolicyEndpoint, ip: string, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-ip-row"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "10.0.0.0/8"; + input.value = ip; + + const errorEl = document.createElement("span"); + errorEl.className = "nemoclaw-policy-ip-error"; + + input.addEventListener("input", () => { + if (ep.allowed_ips) { + ep.allowed_ips[idx] = input.value; + markDirty(policyKey, "modified"); + } + if (input.value.trim() && !isValidCidr(input.value.trim())) { + errorEl.textContent = "Expected CIDR (e.g. 10.0.0.0/8)"; + input.classList.add("nemoclaw-policy-input--error"); + } else { + errorEl.textContent = ""; + input.classList.remove("nemoclaw-policy-input--error"); + } + }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove IP"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + if (ep.allowed_ips) { + ep.allowed_ips.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + } + }); + + row.appendChild(input); + row.appendChild(delBtn); + row.appendChild(errorEl); + return row; +} + +// --------------------------------------------------------------------------- +// Binary row (with wildcard warning) +// --------------------------------------------------------------------------- + +function isWildcardBinary(path: string): boolean { + return path === "/**" || path === "/*" || path === "*"; +} + +function buildBinaryRow(policyKey: string, policy: NetworkPolicy, bin: PolicyBinary, idx: number): HTMLElement { + const row = document.createElement("div"); + row.className = "nemoclaw-policy-bin-row"; + + const icon = document.createElement("span"); + icon.className = "nemoclaw-policy-bin-row__icon"; + icon.innerHTML = ICON_TERMINAL; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.placeholder = "/usr/bin/example"; + input.value = bin.path; + + const warningChip = document.createElement("span"); + warningChip.className = "nemoclaw-policy-wildcard-chip"; + warningChip.innerHTML = `${ICON_WARNING} All binaries`; + warningChip.title = "This wildcard allows any binary to use these endpoints"; + warningChip.style.display = isWildcardBinary(bin.path) ? "" : "none"; + + input.addEventListener("input", () => { + bin.path = input.value; + markDirty(policyKey, "modified"); + warningChip.style.display = isWildcardBinary(input.value) ? "" : "none"; + }); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove binary"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + policy.binaries.splice(idx, 1); + markDirty(policyKey, "modified"); + row.remove(); + }); + + row.appendChild(icon); + row.appendChild(input); + row.appendChild(warningChip); + row.appendChild(delBtn); + return row; +} + +// --------------------------------------------------------------------------- +// Save bar +// --------------------------------------------------------------------------- + +function buildSaveBar(): HTMLElement { + const bar = document.createElement("div"); + bar.className = "nemoclaw-policy-savebar nemoclaw-policy-savebar--hidden"; + + const info = document.createElement("div"); + info.className = "nemoclaw-policy-savebar__info"; + info.innerHTML = ` +
+ Unsaved changes + Network policies take effect on new connections. +
`; + + const actions = document.createElement("div"); + actions.className = "nemoclaw-policy-savebar__actions"; + + const feedback = document.createElement("div"); + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.setAttribute("role", "status"); + + const discardBtn = document.createElement("button"); + discardBtn.type = "button"; + discardBtn.className = "nemoclaw-policy-discard-btn"; + discardBtn.textContent = "Discard"; + discardBtn.addEventListener("click", () => handleDiscard(bar, discardBtn)); + + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "nemoclaw-policy-save-btn"; + saveBtn.textContent = "Save Policy"; + saveBtn.addEventListener("click", () => handleSave(saveBtn, feedback, bar)); + + actions.appendChild(feedback); + actions.appendChild(discardBtn); + actions.appendChild(saveBtn); + + bar.appendChild(info); + bar.appendChild(actions); + return bar; +} + +function updateSaveBarSummary(): void { + if (!saveBarEl) return; + const summaryEl = saveBarEl.querySelector(".nemoclaw-policy-savebar__summary"); + if (!summaryEl) return; + + const parts: string[] = []; + if (changeTracker.modified.size > 0) parts.push(`${changeTracker.modified.size} modified`); + if (changeTracker.added.size > 0) parts.push(`${changeTracker.added.size} added`); + if (changeTracker.deleted.size > 0) parts.push(`${changeTracker.deleted.size} deleted`); + + summaryEl.textContent = parts.length > 0 ? `Unsaved: ${parts.join(", ")}` : "Unsaved changes"; +} + +function handleDiscard(bar: HTMLElement, discardBtn: HTMLButtonElement): void { + if (discardBtn.dataset.confirming === "true") return; + + discardBtn.dataset.confirming = "true"; + const origText = discardBtn.textContent; + discardBtn.textContent = "Discard all changes?"; + discardBtn.classList.add("nemoclaw-policy-discard-btn--confirming"); + + const timer = setTimeout(() => { + discardBtn.textContent = origText; + discardBtn.classList.remove("nemoclaw-policy-discard-btn--confirming"); + delete discardBtn.dataset.confirming; + }, 3000); + + discardBtn.addEventListener("click", function onConfirm() { + discardBtn.removeEventListener("click", onConfirm); + clearTimeout(timer); + delete discardBtn.dataset.confirming; + if (!pageContainer) return; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + loadAndRender(pageContainer); + }, { once: true }); +} + +async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HTMLElement): Promise { + if (!currentPolicy) return; + + btn.disabled = true; + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--saving"; + feedback.innerHTML = `${ICON_LOADER} Saving…`; + + try { + const yamlText = yaml.dump(currentPolicy, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + + console.log("[policy-save] ── Save Policy clicked"); + let result = await savePolicy(yamlText); + + rawYaml = yamlText; + isDirty = false; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + + // When the in-sandbox gRPC is blocked by network enforcement, relay + // through the host-side welcome-ui server which can reach the gateway. + if (result.applied === false) { + console.log("[policy-save] proxy gRPC unavailable — falling back to host relay"); + feedback.innerHTML = `${ICON_LOADER} Applying…`; + try { + const hostResult = await syncPolicyViaHost(yamlText); + if (hostResult.ok && hostResult.applied) { + console.log("[policy-save] host relay succeeded — policy applied live"); + result = hostResult; + } else { + console.warn("[policy-save] host relay returned applied=false", hostResult); + } + } catch (relayErr) { + console.warn("[policy-save] host relay failed:", relayErr); + } + } + + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--success"; + if (result.applied && result.version) { + console.log(`[policy-save] ── done: applied v${result.version}`); + feedback.innerHTML = `${ICON_CHECK} Policy applied (v${result.version}). New connections will use updated rules.`; + } else if (result.applied === false) { + console.log("[policy-save] ── done: saved to disk only (live apply failed)"); + feedback.innerHTML = `${ICON_CHECK} Policy saved. To apply live, run: nemoclaw policy set nemoclaw`; + } else { + console.log("[policy-save] ── done: saved"); + feedback.innerHTML = `${ICON_CHECK} Saved. New connections will use updated rules.`; + } + setTimeout(() => { + feedback.className = "nemoclaw-policy-savebar__feedback"; + feedback.textContent = ""; + bar.classList.remove("nemoclaw-policy-savebar--visible"); + bar.classList.add("nemoclaw-policy-savebar--hidden"); + }, 5000); + } catch (err) { + feedback.className = "nemoclaw-policy-savebar__feedback nemoclaw-policy-savebar__feedback--error"; + feedback.innerHTML = `${ICON_CLOSE} ${escapeHtml(String(err))}`; + } finally { + btn.disabled = false; + } +} + +// --------------------------------------------------------------------------- +// Shared UI helpers +// --------------------------------------------------------------------------- + +function createInput(label: string, value: string, onChange: (v: string) => void, _tooltip?: string): HTMLElement { + const wrapper = document.createElement("label"); + wrapper.className = "nemoclaw-policy-field"; + wrapper.innerHTML = `${label}`; + const input = document.createElement("input"); + input.type = "text"; + input.className = "nemoclaw-policy-input"; + input.value = value; + input.placeholder = label; + input.addEventListener("input", () => onChange(input.value)); + wrapper.appendChild(input); + return wrapper; +} + +function createSelect(label: string, options: SelectOption[], value: string, onChange: (v: string) => void, _tooltip?: string): HTMLElement { + const wrapper = document.createElement("label"); + wrapper.className = "nemoclaw-policy-field"; + wrapper.innerHTML = `${label}`; + const select = document.createElement("select"); + select.className = "nemoclaw-policy-select"; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.label; + if (opt.value === value) o.selected = true; + select.appendChild(o); + } + select.addEventListener("change", () => onChange(select.value)); + wrapper.appendChild(select); + return wrapper; +} + +function markDirty(policyKey?: string, changeType?: "modified" | "added" | "deleted"): void { + isDirty = true; + if (policyKey && changeType) { + if (changeType === "deleted") { + changeTracker.added.delete(policyKey); + changeTracker.modified.delete(policyKey); + changeTracker.deleted.add(policyKey); + } else if (changeType === "added") { + changeTracker.added.add(policyKey); + } else { + if (!changeTracker.added.has(policyKey)) { + changeTracker.modified.add(policyKey); + } + } + } + if (saveBarEl) { + saveBarEl.classList.remove("nemoclaw-policy-savebar--hidden"); + saveBarEl.classList.add("nemoclaw-policy-savebar--visible"); + updateSaveBarSummary(); + } +} + +function updateNetworkCount(): void { + const countEl = document.querySelector(".nemoclaw-policy-section__count"); + if (countEl && currentPolicy?.network_policies) { + countEl.textContent = String(Object.keys(currentPolicy.network_policies).length); + } + const tabCount = document.querySelector(".nemoclaw-policy-tabbar__count"); + if (tabCount && currentPolicy?.network_policies) { + tabCount.textContent = String(Object.keys(currentPolicy.network_policies).length); + } +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css index 415a7da..1f212d3 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css @@ -1020,3 +1020,1670 @@ body.nemoclaw-switching openclaw-app { width: 100%; } } + +/* =========================================== + Policy Page + =========================================== */ + +.nemoclaw-policy-page { + padding: 8px 24px 100px; + animation: nemoclaw-fade-in 250ms ease; +} + +/* Loading / error */ + +.nemoclaw-policy-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 80px 24px; + color: var(--muted, #71717a); + font-size: 14px; +} + +.nemoclaw-policy-loading__spinner { + display: flex; + width: 18px; + height: 18px; + color: #76B900; +} + +.nemoclaw-policy-loading__spinner svg { + width: 18px; + height: 18px; + animation: nemoclaw-spin 1s linear infinite; +} + +.nemoclaw-policy-error { + text-align: center; + padding: 60px 24px; + color: var(--muted, #71717a); + font-size: 14px; +} + +.nemoclaw-policy-error__detail { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--danger, #ef4444); + margin: 8px 0 16px; +} + +.nemoclaw-policy-retry-btn { + padding: 8px 20px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 13px; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease; +} + +.nemoclaw-policy-retry-btn:hover { + border-color: #76B900; + background: rgba(118, 185, 0, 0.06); +} + +/* Summary Strip (two-panel: locked vs editable) */ + +/* Tab bar */ + +.nemoclaw-policy-tabbar { + display: flex; + gap: 0; + border-bottom: 1px solid rgba(161, 161, 170, 0.18); + margin-bottom: 20px; +} + +.nemoclaw-policy-tabbar__tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--muted, #a1a1aa); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease; +} + +.nemoclaw-policy-tabbar__tab svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.nemoclaw-policy-tabbar__tab:hover { + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-tabbar__tab--active { + color: #76B900; + border-bottom-color: #76B900; +} + +.nemoclaw-policy-tabbar__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-size: 11px; + font-weight: 700; + border-radius: 9999px; + background: rgba(118, 185, 0, 0.12); + color: #76B900; +} + +.nemoclaw-policy-tab-panel { + min-height: 0; +} + +/* Immutable Section */ + +.nemoclaw-policy-immutable-section { + margin-bottom: 28px; +} + +.nemoclaw-policy-immutable-section[data-section="immutable"] { + scroll-margin-top: 16px; +} + +.nemoclaw-policy-immutable-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 10px; +} + +.nemoclaw-policy-imm-card { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + padding: 14px; + position: relative; + overflow: hidden; +} + +:root[data-theme="light"] .nemoclaw-policy-imm-card { + background: #fff; +} + +.nemoclaw-policy-imm-card__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.nemoclaw-policy-imm-card__icon { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + flex-shrink: 0; +} + +.nemoclaw-policy-imm-card__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-imm-card__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong, #fafafa); + flex: 1; +} + +.nemoclaw-policy-imm-card__lock { + display: flex; + width: 12px; + height: 12px; + color: var(--muted, #71717a); + opacity: 0.4; +} + +.nemoclaw-policy-imm-card__lock svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-imm-card__desc { + font-size: 11px; + color: var(--muted, #71717a); + margin-bottom: 10px; + line-height: 1.4; +} + +.nemoclaw-policy-imm-card__content { + border-top: 1px solid var(--border, #27272a); + padding-top: 10px; +} + +.nemoclaw-policy-imm-card__note { + font-size: 11px; + color: var(--muted, #71717a); + margin-top: 6px; + font-style: italic; +} + +.nemoclaw-policy-immutable-intro { + font-size: 13px; + color: var(--muted, #71717a); + line-height: 1.55; + margin: 0 0 16px; +} + +.nemoclaw-policy-immutable-footer { + font-size: 13px; + color: var(--muted, #71717a); + line-height: 1.55; + margin: 0; +} + +.nemoclaw-policy-immutable-footer code { + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* Badges */ + +.nemoclaw-policy-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: var(--radius-full, 9999px); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.nemoclaw-policy-badge svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-badge--locked { + border: 1px solid rgba(161, 161, 170, 0.25); + background: rgba(161, 161, 170, 0.08); + color: var(--muted, #a1a1aa); +} + +.nemoclaw-policy-badge--editable { + border: 1px solid rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* Section */ + +.nemoclaw-policy-section { + margin-bottom: 28px; +} + +.nemoclaw-policy-section__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.nemoclaw-policy-section__icon { + display: flex; + width: 20px; + height: 20px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-section__icon svg { + width: 20px; + height: 20px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-section__title { + font-size: 16px; + font-weight: 700; + color: var(--text-strong, #fafafa); + margin: 0; +} + +.nemoclaw-policy-section__desc { + font-size: 13px; + line-height: 1.55; + color: var(--muted, #71717a); + margin: 0 0 16px; +} + +.nemoclaw-policy-section__desc code { + font-size: 12px; + padding: 1px 5px; + border-radius: 4px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +/* Immutable cards */ + +.nemoclaw-policy-immutable-cards { + display: grid; + gap: 12px; +} + +.nemoclaw-policy-card { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + padding: 16px; + background: var(--bg-elevated, #1a1d25); +} + +:root[data-theme="light"] .nemoclaw-policy-card { + background: #fff; +} + +.nemoclaw-policy-card--locked { + opacity: 0.85; +} + +.nemoclaw-policy-card__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.nemoclaw-policy-card__icon { + display: flex; + width: 18px; + height: 18px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-card__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-card__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); + flex: 1; +} + +.nemoclaw-policy-card__lock { + display: flex; + width: 14px; + height: 14px; + color: var(--muted, #71717a); + opacity: 0.5; +} + +.nemoclaw-policy-card__lock svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-card__desc { + font-size: 12px; + line-height: 1.5; + color: var(--muted, #71717a); + margin: 0 0 12px; +} + +.nemoclaw-policy-card__content { + border-top: 1px solid var(--border, #27272a); + padding-top: 12px; +} + +/* Property rows inside cards */ + +.nemoclaw-policy-prop { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; +} + +.nemoclaw-policy-prop__label { + font-size: 12px; + font-weight: 600; + color: var(--muted, #a1a1aa); + white-space: nowrap; +} + +.nemoclaw-policy-prop__value { + font-size: 13px; + color: var(--text, #e4e4e7); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.nemoclaw-policy-muted { + font-size: 12px; + color: var(--muted, #71717a); + font-style: italic; +} + +/* Path list (read-only / read-write) */ + +.nemoclaw-policy-pathlist { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 0; +} + +.nemoclaw-policy-path { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 3px 8px; + border-radius: 4px; + background: rgba(161, 161, 170, 0.08); + color: var(--text, #e4e4e7); + border: 1px solid var(--border, #27272a); +} + +.nemoclaw-policy-path--rw { + background: rgba(118, 185, 0, 0.06); + border-color: rgba(118, 185, 0, 0.2); + color: #76B900; +} + +/* Network policy cards */ + +.nemoclaw-policy-netpolicies { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.nemoclaw-policy-netcard { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); + overflow: hidden; + transition: border-color 150ms ease; +} + +:root[data-theme="light"] .nemoclaw-policy-netcard { + background: #fff; +} + +.nemoclaw-policy-netcard--expanded { + border-color: rgba(118, 185, 0, 0.3); +} + +.nemoclaw-policy-netcard__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; +} + +.nemoclaw-policy-netcard__toggle { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font: inherit; + text-align: left; +} + +.nemoclaw-policy-netcard__chevron { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + transition: transform 200ms ease; +} + +.nemoclaw-policy-netcard__chevron svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__chevron { + transform: rotate(90deg); + color: #76B900; +} + +.nemoclaw-policy-netcard__name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.nemoclaw-policy-netcard__summary { + font-size: 12px; + color: var(--muted, #71717a); + margin-left: auto; + white-space: nowrap; +} + +.nemoclaw-policy-netcard__actions { + display: flex; + gap: 4px; +} + +.nemoclaw-policy-netcard__body { + border-top: 1px solid var(--border, #27272a); + padding: 14px; +} + +/* Icon buttons (shared) */ + +.nemoclaw-policy-icon-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.nemoclaw-policy-icon-btn:hover { + background: var(--bg-hover, #262a35); + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-icon-btn--danger:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--danger, #ef4444); + border-color: rgba(239, 68, 68, 0.2); +} + +.nemoclaw-policy-icon-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Subsections inside network policy body */ + +.nemoclaw-policy-subsection { + margin-bottom: 16px; +} + +.nemoclaw-policy-subsection:last-child { + margin-bottom: 0; +} + +.nemoclaw-policy-subsection__header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.nemoclaw-policy-subsection__title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted, #a1a1aa); +} + +.nemoclaw-policy-info-tip { + display: inline-flex; + width: 14px; + height: 14px; + color: var(--muted, #71717a); + cursor: help; + position: relative; +} + +.nemoclaw-policy-info-tip svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-info-tip[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + border-radius: 6px; + background: var(--card, #181b22); + border: 1px solid var(--border, #27272a); + color: var(--text, #e4e4e7); + font-size: 11px; + font-weight: 400; + line-height: 1.4; + text-transform: none; + letter-spacing: normal; + white-space: normal; + max-width: 240px; + width: max-content; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.nemoclaw-policy-info-tip[data-tip]:hover::after { + opacity: 1; +} + +.nemoclaw-policy-prop__value[data-tip] { + position: relative; + cursor: help; + border-bottom: 1px dotted var(--muted, #71717a); +} + +.nemoclaw-policy-prop__value[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + border-radius: 6px; + background: var(--card, #181b22); + border: 1px solid var(--border, #27272a); + color: var(--text, #e4e4e7); + font-size: 11px; + font-weight: 400; + line-height: 1.4; + white-space: normal; + max-width: 240px; + width: max-content; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.nemoclaw-policy-prop__value[data-tip]:hover::after { + opacity: 1; +} + +/* Endpoint rows */ + +.nemoclaw-policy-ep-list { + display: grid; + gap: 10px; +} + +.nemoclaw-policy-ep-row { + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + padding: 10px; + background: rgba(0, 0, 0, 0.12); +} + +:root[data-theme="light"] .nemoclaw-policy-ep-row { + background: rgba(0, 0, 0, 0.02); +} + +.nemoclaw-policy-ep-row__main { + display: flex; + gap: 8px; + align-items: flex-end; + margin-bottom: 8px; +} + +.nemoclaw-policy-ep-row__opts { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.nemoclaw-policy-ep-row__del { + flex-shrink: 0; + align-self: flex-end; +} + +/* YAML preview for L7 rules */ + +.nemoclaw-policy-yaml-preview { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + line-height: 1.5; + padding: 8px 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.2); + color: var(--text, #e4e4e7); + overflow-x: auto; + margin: 4px 0 0; + border: 1px solid var(--border, #27272a); + white-space: pre; +} + +:root[data-theme="light"] .nemoclaw-policy-yaml-preview { + background: rgba(0, 0, 0, 0.04); +} + +.nemoclaw-policy-ep-rules { + margin-top: 6px; +} + +/* Binary rows */ + +.nemoclaw-policy-bin-list { + display: grid; + gap: 6px; +} + +.nemoclaw-policy-bin-row { + display: flex; + align-items: center; + gap: 8px; +} + +.nemoclaw-policy-bin-row__icon { + display: flex; + width: 16px; + height: 16px; + color: var(--muted, #71717a); + flex-shrink: 0; +} + +.nemoclaw-policy-bin-row__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Shared inputs and selects */ + +.nemoclaw-policy-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.nemoclaw-policy-field__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-input, +.nemoclaw-policy-select { + padding: 7px 10px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; + min-width: 0; +} + +:root[data-theme="light"] .nemoclaw-policy-input, +:root[data-theme="light"] .nemoclaw-policy-select { + background: #fff; +} + +.nemoclaw-policy-input:focus, +.nemoclaw-policy-select:focus { + border-color: #76B900; + box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.15); +} + +.nemoclaw-policy-input::placeholder { + color: var(--muted, #71717a); + opacity: 0.5; +} + +.nemoclaw-policy-input--host { + flex: 1; +} + +.nemoclaw-policy-input--port { + width: 80px; +} + +.nemoclaw-policy-bin-row .nemoclaw-policy-input { + flex: 1; +} + +/* Add buttons */ + +.nemoclaw-policy-add-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--muted, #71717a); + font-size: 13px; + font-weight: 500; + cursor: pointer; + position: relative; + transition: border-color 150ms ease, color 150ms ease, background 150ms ease; +} + +.nemoclaw-policy-add-btn:hover { + border-color: #76B900; + color: #76B900; + background: rgba(118, 185, 0, 0.04); +} + +.nemoclaw-policy-add-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-add-small-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + margin-top: 8px; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease; +} + +.nemoclaw-policy-add-small-btn:hover { + border-color: #76B900; + color: #76B900; +} + +.nemoclaw-policy-add-small-btn svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Save bar */ + +.nemoclaw-policy-savebar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + margin-top: 24px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-lg, 12px); + background: var(--card, #181b22); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.25); +} + +:root[data-theme="light"] .nemoclaw-policy-savebar { + background: #fff; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08); +} + +.nemoclaw-policy-savebar__info { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11px; + line-height: 1.55; + color: var(--muted, #71717a); + flex: 1; + min-width: 0; +} + +.nemoclaw-policy-savebar__info code { + font-size: 10px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.08); + color: #76B900; +} + +.nemoclaw-policy-savebar__info-icon { + display: flex; + flex-shrink: 0; + width: 14px; + height: 14px; + margin-top: 1px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-savebar__info-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-savebar__actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.nemoclaw-policy-save-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 22px; + border: 1px solid #76B900; + border-radius: var(--radius-md, 8px); + background: #76B900; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.nemoclaw-policy-save-btn:hover { + background: #6aa300; + border-color: #6aa300; + box-shadow: 0 4px 12px rgba(118, 185, 0, 0.35); + transform: translateY(-1px); +} + +.nemoclaw-policy-save-btn:active { + background: #5a8500; + border-color: #5a8500; + transform: translateY(0); +} + +.nemoclaw-policy-save-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.nemoclaw-policy-save-btn:focus-visible { + outline: 2px solid #76B900; + outline-offset: 2px; +} + +/* Save feedback */ + +.nemoclaw-policy-savebar__feedback { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + min-height: 18px; + white-space: nowrap; +} + +.nemoclaw-policy-savebar__feedback--saving { + color: #76B900; +} + +.nemoclaw-policy-savebar__feedback--success { + color: #76B900; + animation: nemoclaw-fade-in 200ms ease; +} + +.nemoclaw-policy-savebar__feedback--error { + color: var(--danger, #ef4444); + animation: nemoclaw-fade-in 200ms ease; +} + +.nemoclaw-policy-savebar__feedback svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.nemoclaw-policy-savebar__spinner { + display: flex; + width: 14px; + height: 14px; +} + +.nemoclaw-policy-savebar__spinner svg { + width: 14px; + height: 14px; + animation: nemoclaw-spin 1s linear infinite; +} + +/* Responsive */ + +@media (max-width: 640px) { + .nemoclaw-policy-immutable-grid { + grid-template-columns: 1fr; + } + + .nemoclaw-policy-ep-row__main { + flex-wrap: wrap; + } + + .nemoclaw-policy-ep-row__opts { + grid-template-columns: repeat(2, 1fr); + } + + .nemoclaw-policy-savebar { + flex-direction: column; + align-items: stretch; + } + + .nemoclaw-policy-savebar__actions { + justify-content: flex-end; + } + + .nemoclaw-policy-section__header { + flex-wrap: wrap; + } + + .nemoclaw-policy-search { + width: 100%; + margin-left: 0; + margin-top: 8px; + } +} + +@media (min-width: 641px) and (max-width: 800px) { + .nemoclaw-policy-immutable-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* =========================================== + Policy Page — Network Card Host Preview + =========================================== */ + +.nemoclaw-policy-netcard__preview { + padding: 0 14px 10px 38px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.nemoclaw-policy-netcard--expanded .nemoclaw-policy-netcard__preview { + display: none; +} + +.nemoclaw-policy-host-chip { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 2px 7px; + border-radius: 3px; + background: rgba(118, 185, 0, 0.06); + border: 1px solid rgba(118, 185, 0, 0.15); + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-host-chip--more { + color: var(--muted, #71717a); + background: transparent; + border-color: transparent; + font-family: inherit; + font-style: italic; +} + +/* =========================================== + Policy Page — Enforcement Indicator + =========================================== */ + +.nemoclaw-policy-enf-pill { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + padding: 1px 8px; + border-radius: 9999px; + margin-left: 4px; + letter-spacing: 0.02em; + line-height: 1.6; +} + +.nemoclaw-policy-enf-pill--enforce { + color: #76B900; + background: rgba(118, 185, 0, 0.1); + border: 1px solid rgba(118, 185, 0, 0.25); +} + +.nemoclaw-policy-enf-pill--audit { + color: var(--muted, #a1a1aa); + background: rgba(161, 161, 170, 0.08); + border: 1px solid rgba(161, 161, 170, 0.2); +} + +.nemoclaw-policy-enf-pill--default { + color: var(--muted, #71717a); + background: rgba(161, 161, 170, 0.05); + border: 1px solid rgba(161, 161, 170, 0.15); +} + +/* =========================================== + Policy Page — Network Empty State + =========================================== */ + +.nemoclaw-policy-net-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 40px 24px; + text-align: center; + border: 1px dashed var(--border, #27272a); + border-radius: var(--radius-md, 8px); + animation: nemoclaw-fade-in 200ms ease; +} + +.nemoclaw-policy-net-empty__icon { + display: flex; + width: 32px; + height: 32px; + color: var(--muted, #71717a); + opacity: 0.5; +} + +.nemoclaw-policy-net-empty__icon svg { + width: 32px; + height: 32px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-net-empty__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nemoclaw-policy-net-empty__desc { + font-size: 12px; + color: var(--muted, #71717a); +} + +/* =========================================== + Policy Page — Inline New-Policy Form + =========================================== */ + +.nemoclaw-policy-newcard { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border: 1px dashed rgba(118, 185, 0, 0.3); + border-radius: var(--radius-md, 8px); + background: rgba(118, 185, 0, 0.03); + margin-bottom: 8px; + animation: nemoclaw-fade-in 150ms ease; +} + +.nemoclaw-policy-newcard .nemoclaw-policy-input { + flex: 1; + min-width: 160px; +} + +.nemoclaw-policy-newcard__hint { + width: 100%; + font-size: 11px; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-newcard__error { + width: 100%; + font-size: 11px; + color: var(--danger, #ef4444); + min-height: 0; +} + +.nemoclaw-policy-newcard__error:empty { + display: none; +} + +.nemoclaw-policy-input--error { + border-color: var(--danger, #ef4444) !important; +} + +/* =========================================== + Policy Page — Delete Confirmation + =========================================== */ + +.nemoclaw-policy-confirm-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.nemoclaw-policy-confirm-btn { + padding: 4px 12px; + border: none; + border-radius: var(--radius-sm, 6px); + font-size: 11px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease; +} + +.nemoclaw-policy-confirm-btn--delete { + background: rgba(239, 68, 68, 0.12); + color: var(--danger, #ef4444); +} + +.nemoclaw-policy-confirm-btn--delete:hover { + background: rgba(239, 68, 68, 0.22); +} + +.nemoclaw-policy-confirm-btn--create { + background: rgba(118, 185, 0, 0.12); + color: #76B900; +} + +.nemoclaw-policy-confirm-btn--create:hover { + background: rgba(118, 185, 0, 0.22); +} + +.nemoclaw-policy-confirm-btn--cancel { + background: transparent; + color: var(--muted, #71717a); +} + +.nemoclaw-policy-confirm-btn--cancel:hover { + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-netcard--confirming { + border-left: 3px solid var(--danger, #ef4444); +} + +/* =========================================== + Policy Page — HTTP Rule Editor Rows + =========================================== */ + +.nemoclaw-policy-rule-list { + display: grid; + gap: 6px; +} + +.nemoclaw-policy-rule-row { + display: flex; + gap: 8px; + align-items: center; +} + +.nemoclaw-policy-rule-method { + width: 100px; + flex-shrink: 0; +} + +.nemoclaw-policy-rule-path { + flex: 1; +} + +/* =========================================== + Policy Page — IP Row + =========================================== */ + +.nemoclaw-policy-ip-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.nemoclaw-policy-ip-row .nemoclaw-policy-input { + flex: 1; +} + +.nemoclaw-policy-ip-error { + width: 100%; + font-size: 10px; + color: var(--danger, #ef4444); + min-height: 0; +} + +.nemoclaw-policy-ip-error:empty { + display: none; +} + +/* =========================================== + Policy Page — Micro Labels + =========================================== */ + +.nemoclaw-policy-micro-label { + font-size: 11px; + color: var(--muted, #71717a); + margin-bottom: 8px; + font-style: italic; +} + +/* =========================================== + Policy Page — Progressive Endpoint Advanced Toggle + =========================================== */ + +.nemoclaw-policy-ep-advanced-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 6px 0 2px; + padding: 0; + border: none; + background: none; + color: var(--muted, #71717a); + font-size: 11px; + font-family: inherit; + cursor: pointer; + transition: color 150ms ease; +} + +.nemoclaw-policy-ep-advanced-toggle:hover { + color: #76B900; +} + +.nemoclaw-policy-ep-advanced-toggle__chevron { + display: flex; + width: 12px; + height: 12px; + transition: transform 200ms ease; +} + +.nemoclaw-policy-ep-advanced-toggle__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-ep-advanced-toggle--open .nemoclaw-policy-ep-advanced-toggle__chevron { + transform: rotate(90deg); +} + +.nemoclaw-policy-ep-advanced-toggle--open { + color: #76B900; +} + +/* =========================================== + Policy Page — Wildcard Binary Warning + =========================================== */ + +.nemoclaw-policy-wildcard-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full, 9999px); + font-size: 10px; + font-weight: 600; + border: 1px solid rgba(234, 179, 8, 0.3); + background: rgba(234, 179, 8, 0.08); + color: #eab308; + white-space: nowrap; + flex-shrink: 0; +} + +.nemoclaw-policy-wildcard-chip svg { + width: 11px; + height: 11px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* =========================================== + Policy Page — Search Filter + =========================================== */ + +.nemoclaw-policy-search { + padding: 6px 10px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); + background: var(--bg-elevated, #1a1d25); + color: var(--text, #e4e4e7); + font-size: 12px; + font-family: inherit; + outline: none; + width: 180px; + margin-left: auto; + transition: border-color 150ms ease, width 200ms ease; +} + +.nemoclaw-policy-search:focus { + border-color: #76B900; + width: 240px; +} + +.nemoclaw-policy-search::placeholder { + color: var(--muted, #71717a); + opacity: 0.6; +} + +:root[data-theme="light"] .nemoclaw-policy-search { + background: #fff; +} + +/* =========================================== + Policy Page — Section Count Badge + =========================================== */ + +.nemoclaw-policy-section__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 6px; + border-radius: var(--radius-full, 9999px); + background: rgba(118, 185, 0, 0.12); + color: #76B900; + font-size: 11px; + font-weight: 700; +} + +/* =========================================== + Policy Page — Template Dropdown (enriched) + =========================================== */ + +.nemoclaw-policy-add-wrap { + position: relative; + display: inline-flex; +} + +.nemoclaw-policy-add-btn__chevron { + display: flex; + width: 12px; + height: 12px; + margin-left: 2px; +} + +.nemoclaw-policy-add-btn__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-templates { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + min-width: 280px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--card, #181b22); + padding: 5px; + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.35), + 0 0 0 1px rgba(255, 255, 255, 0.04); + animation: nemoclaw-scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +:root[data-theme="light"] .nemoclaw-policy-templates { + background: var(--bg, #fff); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.12), + 0 0 0 1px rgba(0, 0, 0, 0.06); +} + +.nemoclaw-policy-template-option { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--text, #e4e4e7); + font-size: 13px; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: background 100ms ease; +} + +.nemoclaw-policy-template-option:hover { + background: var(--bg-hover, #262a35); +} + +.nemoclaw-policy-template-option__label { + font-weight: 500; +} + +.nemoclaw-policy-template-option__meta { + font-size: 11px; + color: var(--muted, #71717a); + font-weight: 400; +} + +.nemoclaw-policy-template-option--blank { + border-bottom: 1px solid var(--border, #27272a); + margin-bottom: 4px; + padding-bottom: 10px; + border-radius: var(--radius-sm, 6px) var(--radius-sm, 6px) 0 0; +} + +.nemoclaw-policy-template-option--blank .nemoclaw-policy-template-option__meta { + font-style: italic; +} + +/* =========================================== + Policy Page — Conditional Save Bar + =========================================== */ + +.nemoclaw-policy-savebar--hidden { + display: none !important; +} + +.nemoclaw-policy-savebar--visible { + display: flex !important; + animation: nemoclaw-savebar-slide-up 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes nemoclaw-savebar-slide-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.nemoclaw-policy-savebar__summary { + font-size: 13px; + font-weight: 500; + color: var(--text, #e4e4e7); + display: block; +} + +.nemoclaw-policy-savebar__consequence { + display: block; + font-size: 11px; + color: var(--muted, #71717a); + margin-top: 2px; +} + +.nemoclaw-policy-discard-btn { + padding: 9px 18px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: transparent; + color: var(--text, #e4e4e7); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, color 150ms ease; + white-space: nowrap; +} + +.nemoclaw-policy-discard-btn:hover { + border-color: var(--danger, #ef4444); + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.06); +} + +.nemoclaw-policy-discard-btn--confirming { + border-color: var(--danger, #ef4444); + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.08); +} diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js new file mode 100644 index 0000000..53321c9 --- /dev/null +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// policy-proxy.js — Lightweight reverse proxy that sits in front of the +// OpenClaw gateway. Intercepts /api/policy requests to read/write the +// sandbox policy YAML file and push updates to the NemoClaw gateway via +// gRPC so changes take effect on the running sandbox. Everything else +// (including WebSocket upgrades) is transparently forwarded to the +// upstream OpenClaw gateway. + +const http = require("http"); +const fs = require("fs"); +const os = require("os"); +const net = require("net"); + +const POLICY_PATH = process.env.POLICY_PATH || "/etc/navigator/policy.yaml"; +const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); +const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); +const UPSTREAM_HOST = "127.0.0.1"; + +const PROTO_DIR = "/usr/local/lib/nemoclaw-proto"; + +// Well-known paths for TLS credentials (volume-mounted by the NemoClaw +// platform). When the proxy runs inside an SSH session the env vars are +// cleared, but the files on disk remain accessible. +const TLS_WELL_KNOWN = { + ca: "/etc/navigator-tls/client/ca.crt", + cert: "/etc/navigator-tls/client/tls.crt", + key: "/etc/navigator-tls/client/tls.key", +}; + +const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080"; + +// Resolved at init time. +let gatewayEndpoint = ""; +let sandboxName = ""; + +// --------------------------------------------------------------------------- +// Discovery helpers +// --------------------------------------------------------------------------- + +function discoverFromSupervisor() { + try { + const raw = fs.readFileSync("/proc/1/cmdline"); + const args = raw.toString("utf8").split("\0").filter(Boolean); + const result = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--navigator-endpoint" && i + 1 < args.length) { + result.endpoint = args[i + 1]; + } else if (args[i] === "--sandbox-id" && i + 1 < args.length) { + result.sandboxId = args[i + 1]; + } else if (args[i] === "--sandbox" && i + 1 < args.length) { + result.sandbox = args[i + 1]; + } + } + return result; + } catch (e) { + return {}; + } +} + +function resolveTlsPaths() { + const ca = process.env.NEMOCLAW_TLS_CA || (fileExists(TLS_WELL_KNOWN.ca) ? TLS_WELL_KNOWN.ca : ""); + const cert = process.env.NEMOCLAW_TLS_CERT || (fileExists(TLS_WELL_KNOWN.cert) ? TLS_WELL_KNOWN.cert : ""); + const key = process.env.NEMOCLAW_TLS_KEY || (fileExists(TLS_WELL_KNOWN.key) ? TLS_WELL_KNOWN.key : ""); + return { ca, cert, key }; +} + +function fileExists(p) { + try { fs.accessSync(p, fs.constants.R_OK); return true; } catch { return false; } +} + +// --------------------------------------------------------------------------- +// gRPC client (lazy-initialized) +// --------------------------------------------------------------------------- + +let grpcClient = null; +let grpcEnabled = false; +let grpcPermanentlyDisabled = false; + +function initGrpcClient() { + // 1. Resolve gateway endpoint. + gatewayEndpoint = process.env.NEMOCLAW_ENDPOINT || ""; + + // 2. Resolve sandbox name. NEMOCLAW_SANDBOX is overridden to "1" by + // the supervisor for all child processes, so prefer NEMOCLAW_SANDBOX_ID. + sandboxName = process.env.NEMOCLAW_SANDBOX_ID || ""; + + // 3. Cmdline fallback (useful when env vars were passed as CLI args). + if (!gatewayEndpoint || !sandboxName) { + const discovered = discoverFromSupervisor(); + if (!gatewayEndpoint && discovered.endpoint) { + gatewayEndpoint = discovered.endpoint; + console.log(`[policy-proxy] Discovered endpoint from supervisor cmdline: ${gatewayEndpoint}`); + } + if (!sandboxName) { + sandboxName = discovered.sandboxId || discovered.sandbox || ""; + } + } + + // 4. Well-known fallbacks for SSH sessions where env_clear() stripped + // the container env vars. + if (!gatewayEndpoint && fileExists(TLS_WELL_KNOWN.ca)) { + gatewayEndpoint = WELL_KNOWN_ENDPOINT; + console.log(`[policy-proxy] Using well-known gateway endpoint: ${gatewayEndpoint}`); + } + if (!sandboxName) { + sandboxName = os.hostname() || ""; + if (sandboxName) { + console.log(`[policy-proxy] Using hostname as sandbox name: ${sandboxName}`); + } + } + + if (!gatewayEndpoint || !sandboxName) { + console.log( + `[policy-proxy] Gateway sync disabled — endpoint=${gatewayEndpoint || "(unset)"}, ` + + `sandbox=${sandboxName || "(unset)"}.` + ); + return; + } + + let grpc, protoLoader; + try { + grpc = require("@grpc/grpc-js"); + protoLoader = require("@grpc/proto-loader"); + } catch (e) { + console.error("[policy-proxy] gRPC packages not available; gateway sync disabled:", e.message); + return; + } + + let packageDef; + try { + packageDef = protoLoader.loadSync("navigator.proto", { + keepCase: true, + longs: Number, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [PROTO_DIR], + }); + } catch (e) { + console.error("[policy-proxy] Failed to load proto definitions:", e.message); + return; + } + + const proto = grpc.loadPackageDefinition(packageDef); + + // Build channel credentials: mTLS when certs exist, TLS-only with CA + // when only the CA is available, insecure as last resort. + const tls = resolveTlsPaths(); + let creds; + try { + if (tls.ca && tls.cert && tls.key) { + const rootCerts = fs.readFileSync(tls.ca); + const privateKey = fs.readFileSync(tls.key); + const certChain = fs.readFileSync(tls.cert); + creds = grpc.credentials.createSsl(rootCerts, privateKey, certChain); + } else if (tls.ca) { + const rootCerts = fs.readFileSync(tls.ca); + creds = grpc.credentials.createSsl(rootCerts); + } else { + creds = grpc.credentials.createInsecure(); + } + } catch (e) { + console.error("[policy-proxy] Failed to load TLS credentials:", e.message); + creds = grpc.credentials.createInsecure(); + } + + // Strip scheme prefix — grpc-js expects "host:port". + const target = gatewayEndpoint.replace(/^https?:\/\//, ""); + + grpcClient = new proto.navigator.v1.Navigator(target, creds); + grpcEnabled = true; + console.log(`[policy-proxy] gRPC client initialized → ${target} (sandbox: ${sandboxName})`); + + // Proactive connectivity probe: try to establish a connection within 3s. + // If the network enforcement proxy blocks us, fail fast here instead of + // making every Save wait for a 5s RPC timeout. + const probeDeadline = new Date(Date.now() + 3000); + grpcClient.waitForReady(probeDeadline, (err) => { + if (err) { + console.warn(`[policy-proxy] gRPC connectivity probe failed — disabling gateway sync: ${err.message}`); + grpcEnabled = false; + grpcPermanentlyDisabled = true; + } else { + console.log("[policy-proxy] gRPC connectivity probe succeeded."); + } + }); +} + +// --------------------------------------------------------------------------- +// YAML → proto conversion +// --------------------------------------------------------------------------- + +function yamlToProto(parsed) { + const fp = parsed.filesystem_policy; + return { + version: parsed.version || 1, + filesystem: fp ? { + include_workdir: !!fp.include_workdir, + read_only: fp.read_only || [], + read_write: fp.read_write || [], + } : undefined, + landlock: parsed.landlock ? { + compatibility: parsed.landlock.compatibility || "", + } : undefined, + process: parsed.process ? { + run_as_user: parsed.process.run_as_user || "", + run_as_group: parsed.process.run_as_group || "", + } : undefined, + network_policies: convertNetworkPolicies(parsed.network_policies || {}), + }; +} + +function convertNetworkPolicies(policies) { + const result = {}; + for (const [key, rule] of Object.entries(policies)) { + result[key] = { + name: rule.name || key, + endpoints: (rule.endpoints || []).map(convertEndpoint), + binaries: (rule.binaries || []).map((b) => ({ path: b.path || "" })), + }; + } + return result; +} + +function convertEndpoint(ep) { + return { + host: ep.host || "", + port: ep.port || 0, + protocol: ep.protocol || "", + tls: ep.tls || "", + enforcement: ep.enforcement || "", + access: ep.access || "", + rules: (ep.rules || []).map((r) => ({ + allow: { + method: (r.allow && r.allow.method) || "", + path: (r.allow && r.allow.path) || "", + command: (r.allow && r.allow.command) || "", + }, + })), + allowed_ips: ep.allowed_ips || [], + }; +} + +// --------------------------------------------------------------------------- +// Push policy to gateway via gRPC +// --------------------------------------------------------------------------- + +function pushPolicyToGateway(yamlBody) { + return new Promise((resolve) => { + if (!grpcEnabled || !grpcClient || grpcPermanentlyDisabled) { + resolve({ applied: false, reason: "network_enforcement" }); + return; + } + + let yaml; + try { + yaml = require("js-yaml"); + } catch (e) { + resolve({ applied: false, reason: "js-yaml not available: " + e.message }); + return; + } + + let parsed; + try { + parsed = yaml.load(yamlBody); + } catch (e) { + resolve({ applied: false, reason: "YAML parse error: " + e.message }); + return; + } + + let policyProto; + try { + policyProto = yamlToProto(parsed); + } catch (e) { + resolve({ applied: false, reason: "proto conversion error: " + e.message }); + return; + } + + const request = { + name: sandboxName, + policy: policyProto, + }; + + const deadline = new Date(Date.now() + 5000); + grpcClient.UpdateSandboxPolicy(request, { deadline }, (err, response) => { + if (err) { + console.error("[policy-proxy] gRPC UpdateSandboxPolicy failed:", err.message); + grpcEnabled = false; + grpcPermanentlyDisabled = true; + console.warn("[policy-proxy] Circuit-breaker tripped — disabling gateway sync for future requests."); + resolve({ applied: false, reason: "network_enforcement" }); + return; + } + console.log( + `[policy-proxy] Policy pushed to gateway: version=${response.version}, hash=${response.policy_hash}` + ); + resolve({ + applied: true, + version: response.version, + policy_hash: response.policy_hash, + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// HTTP proxy helpers +// --------------------------------------------------------------------------- + +function proxyRequest(clientReq, clientRes) { + const opts = { + hostname: UPSTREAM_HOST, + port: UPSTREAM_PORT, + path: clientReq.url, + method: clientReq.method, + headers: clientReq.headers, + }; + + const upstream = http.request(opts, (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); + upstreamRes.pipe(clientRes, { end: true }); + }); + + upstream.on("error", (err) => { + console.error("[proxy] upstream error:", err.message); + if (!clientRes.headersSent) { + clientRes.writeHead(502, { "Content-Type": "application/json" }); + } + clientRes.end(JSON.stringify({ error: "upstream unavailable" })); + }); + + clientReq.pipe(upstream, { end: true }); +} + +// --------------------------------------------------------------------------- +// /api/policy handlers +// --------------------------------------------------------------------------- + +function handlePolicyGet(req, res) { + fs.readFile(POLICY_PATH, "utf8", (err, data) => { + if (err) { + res.writeHead(err.code === "ENOENT" ? 404 : 500, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify({ error: err.code === "ENOENT" ? "policy file not found" : err.message })); + return; + } + res.writeHead(200, { "Content-Type": "text/yaml; charset=utf-8" }); + res.end(data); + }); +} + +function handlePolicyPost(req, res) { + const t0 = Date.now(); + console.log(`[policy-proxy] ── POST /api/policy received`); + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + console.log(`[policy-proxy] body: ${body.length} bytes`); + + if (!body.trim()) { + console.log(`[policy-proxy] REJECTED: empty body`); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "empty body" })); + return; + } + + if (!body.includes("version:")) { + console.log(`[policy-proxy] REJECTED: missing version field`); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid policy: missing version field" })); + return; + } + + console.log(`[policy-proxy] step 1/3: writing to disk → ${POLICY_PATH}`); + const tmp = os.tmpdir() + "/policy.yaml.tmp." + process.pid; + fs.writeFile(tmp, body, "utf8", (writeErr) => { + if (writeErr) { + console.error(`[policy-proxy] step 1/3: FAILED — ${writeErr.message}`); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "write failed: " + writeErr.message })); + return; + } + fs.rename(tmp, POLICY_PATH, (renameErr) => { + if (renameErr) { + fs.writeFile(POLICY_PATH, body, "utf8", (fallbackErr) => { + fs.unlink(tmp, () => {}); + if (fallbackErr) { + console.error(`[policy-proxy] step 1/3: FAILED (fallback) — ${fallbackErr.message}`); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "write failed: " + fallbackErr.message })); + return; + } + console.log(`[policy-proxy] step 1/3: saved to disk (fallback write) [${Date.now() - t0}ms]`); + syncAndRespond(body, res, t0); + }); + return; + } + console.log(`[policy-proxy] step 1/3: saved to disk (atomic rename) [${Date.now() - t0}ms]`); + syncAndRespond(body, res, t0); + }); + }); + }); +} + +function syncAndRespond(yamlBody, res, t0) { + console.log(`[policy-proxy] step 2/3: attempting gRPC gateway sync (enabled=${grpcEnabled}, disabled=${grpcPermanentlyDisabled})`); + pushPolicyToGateway(yamlBody).then((result) => { + const payload = { ok: true, ...result }; + console.log(`[policy-proxy] step 3/3: responding — applied=${result.applied}, reason=${result.reason || "n/a"} [${Date.now() - t0}ms total]`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }); +} + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- + +const server = http.createServer((req, res) => { + if (req.url === "/api/policy") { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + } else if (req.method === "GET") { + handlePolicyGet(req, res); + } else if (req.method === "POST") { + handlePolicyPost(req, res); + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "method not allowed" })); + } + return; + } + + proxyRequest(req, res); +}); + +// WebSocket upgrade — pipe raw TCP to upstream +server.on("upgrade", (req, socket, head) => { + const upstream = net.createConnection({ host: UPSTREAM_HOST, port: UPSTREAM_PORT }, () => { + const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + let headers = ""; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + headers += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`; + } + upstream.write(reqLine + headers + "\r\n"); + if (head && head.length) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + }); + + upstream.on("error", (err) => { + console.error("[proxy] websocket upstream error:", err.message); + socket.destroy(); + }); + + socket.on("error", (err) => { + console.error("[proxy] websocket client error:", err.message); + upstream.destroy(); + }); +}); + +// Initialize gRPC client before starting the HTTP server. +initGrpcClient(); + +server.listen(LISTEN_PORT, "127.0.0.1", () => { + console.log(`[policy-proxy] Listening on 127.0.0.1:${LISTEN_PORT}, upstream 127.0.0.1:${UPSTREAM_PORT}`); +}); diff --git a/sandboxes/nemoclaw/policy.yaml b/sandboxes/nemoclaw/policy.yaml index 308e077..397971a 100644 --- a/sandboxes/nemoclaw/policy.yaml +++ b/sandboxes/nemoclaw/policy.yaml @@ -7,8 +7,8 @@ version: 1 filesystem_policy: include_workdir: true - # read_only: - read_write: + read_only: + # read_write: - /usr - /lib - /proc @@ -16,7 +16,7 @@ filesystem_policy: - /app - /etc - /var/log - # read_write: + read_write: - /sandbox - /tmp - /dev/null diff --git a/sandboxes/nemoclaw/proto/datamodel.proto b/sandboxes/nemoclaw/proto/datamodel.proto new file mode 100644 index 0000000..137d3fc --- /dev/null +++ b/sandboxes/nemoclaw/proto/datamodel.proto @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.datamodel.v1; + +import "google/protobuf/struct.proto"; +import "sandbox.proto"; + +// Sandbox model stored by Navigator. +message Sandbox { + string id = 1; + string name = 2; + string namespace = 3; + SandboxSpec spec = 4; + SandboxStatus status = 5; + SandboxPhase phase = 6; + // Milliseconds since Unix epoch when the sandbox was created. + int64 created_at_ms = 7; + // Currently active policy version (updated when sandbox reports loaded). + uint32 current_policy_version = 8; +} + +// Navigator-level sandbox spec. +message SandboxSpec { + string log_level = 1; + map environment = 5; + SandboxTemplate template = 6; + // Required sandbox policy configuration. + navigator.sandbox.v1.SandboxPolicy policy = 7; + // Provider names to attach to this sandbox. + repeated string providers = 8; +} + +// Sandbox template mapped onto Kubernetes pod template inputs. +message SandboxTemplate { + string image = 1; + string runtime_class_name = 2; + string agent_socket = 3; + map labels = 4; + map annotations = 5; + map environment = 6; + google.protobuf.Struct resources = 7; + google.protobuf.Struct pod_template = 8; + google.protobuf.Struct volume_claim_templates = 9; +} + +// Sandbox status captured from Kubernetes. +message SandboxStatus { + string sandbox_name = 1; + string agent_pod = 2; + string agent_fd = 3; + string sandbox_fd = 4; + repeated SandboxCondition conditions = 5; +} + +// Sandbox condition mirrors Kubernetes conditions. +message SandboxCondition { + string type = 1; + string status = 2; + string reason = 3; + string message = 4; + string last_transition_time = 5; +} + +// High-level sandbox lifecycle phase. +enum SandboxPhase { + SANDBOX_PHASE_UNSPECIFIED = 0; + SANDBOX_PHASE_PROVISIONING = 1; + SANDBOX_PHASE_READY = 2; + SANDBOX_PHASE_ERROR = 3; + SANDBOX_PHASE_DELETING = 4; + SANDBOX_PHASE_UNKNOWN = 5; +} + +// Provider model stored by Navigator. +message Provider { + string id = 1; + string name = 2; + // Canonical provider type slug (for example: "claude", "gitlab"). + string type = 3; + // Secret values used for authentication. + map credentials = 4; + // Non-secret provider configuration. + map config = 5; +} diff --git a/sandboxes/nemoclaw/proto/navigator.proto b/sandboxes/nemoclaw/proto/navigator.proto new file mode 100644 index 0000000..b6513fb --- /dev/null +++ b/sandboxes/nemoclaw/proto/navigator.proto @@ -0,0 +1,533 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.v1; + +import "datamodel.proto"; +import "sandbox.proto"; + +option java_multiple_files = true; +option java_package = "com.anthropic.navigator.v1"; + +// Navigator service provides sandbox, provider, and runtime management capabilities. +service Navigator { + // Check the health of the service. + rpc Health(HealthRequest) returns (HealthResponse); + + // Create a new sandbox. + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + + // Fetch a sandbox by name. + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + + // List sandboxes. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // Delete a sandbox by name. + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + + // Create a short-lived SSH session for a sandbox. + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + + // Revoke a previously issued SSH session. + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + + // Execute a command in a ready sandbox and stream output. + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + + // Create a provider. + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + + // Fetch a provider by name. + rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + + // List providers. + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + + // Update an existing provider by name. + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + + // Delete a provider by name. + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + + // Get sandbox policy by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxPolicy(navigator.sandbox.v1.GetSandboxPolicyRequest) + returns (navigator.sandbox.v1.GetSandboxPolicyResponse); + + // Update sandbox policy on a live sandbox. + rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) + returns (UpdateSandboxPolicyResponse); + + // Get the load status of a specific policy version. + rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) + returns (GetSandboxPolicyStatusResponse); + + // List policy history for a sandbox. + rpc ListSandboxPolicies(ListSandboxPoliciesRequest) + returns (ListSandboxPoliciesResponse); + + // Report policy load result (called by sandbox after reload attempt). + rpc ReportPolicyStatus(ReportPolicyStatusRequest) + returns (ReportPolicyStatusResponse); + + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) + returns (GetSandboxProviderEnvironmentResponse); + + // Fetch recent sandbox logs (one-shot). + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + + // Push sandbox supervisor logs to the server (client-streaming). + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - Navigator server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); +} + +// Health check request. +message HealthRequest {} + +// Health check response. +message HealthResponse { + // Service status. + ServiceStatus status = 1; + + // Service version. + string version = 2; +} + +// Create sandbox request. +message CreateSandboxRequest { + navigator.datamodel.v1.SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; +} + +// Get sandbox request. +message GetSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// List sandboxes request. +message ListSandboxesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Delete sandbox request. +message DeleteSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// Sandbox response. +message SandboxResponse { + navigator.datamodel.v1.Sandbox sandbox = 1; +} + +// List sandboxes response. +message ListSandboxesResponse { + repeated navigator.datamodel.v1.Sandbox sandboxes = 1; +} + +// Delete sandbox response. +message DeleteSandboxResponse { + bool deleted = 1; +} + +// Create SSH session request. +message CreateSshSessionRequest { + // Sandbox id. + string sandbox_id = 1; +} + +// Create SSH session response. +message CreateSshSessionResponse { + // Sandbox id. + string sandbox_id = 1; + + // Session token for the gateway tunnel. + string token = 2; + + // Gateway host for SSH proxy connection. + string gateway_host = 3; + + // Gateway port for SSH proxy connection. + uint32 gateway_port = 4; + + // Gateway scheme (http or https). + string gateway_scheme = 5; + + // HTTP path for the CONNECT/upgrade endpoint. + string connect_path = 6; + + // Optional host key fingerprint. + string host_key_fingerprint = 7; +} + +// Revoke SSH session request. +message RevokeSshSessionRequest { + // Session token to revoke. + string token = 1; +} + +// Revoke SSH session response. +message RevokeSshSessionResponse { + // True when a session was revoked. + bool revoked = 1; +} + +// Execute command request. +message ExecSandboxRequest { + // Sandbox id. + string sandbox_id = 1; + + // Command and arguments. + repeated string command = 2; + + // Optional working directory. + string workdir = 3; + + // Optional environment overrides. + map environment = 4; + + // Optional timeout in seconds. 0 means no timeout. + uint32 timeout_seconds = 5; + + // Optional stdin payload passed to the command. + bytes stdin = 6; +} + +// One stdout chunk from a sandbox exec. +message ExecSandboxStdout { + bytes data = 1; +} + +// One stderr chunk from a sandbox exec. +message ExecSandboxStderr { + bytes data = 1; +} + +// Final exit status for a sandbox exec. +message ExecSandboxExit { + int32 exit_code = 1; +} + +// One event in a sandbox exec stream. +message ExecSandboxEvent { + oneof payload { + ExecSandboxStdout stdout = 1; + ExecSandboxStderr stderr = 2; + ExecSandboxExit exit = 3; + } +} + +// SSH session record stored in persistence. +message SshSession { + // Unique id (token). + string id = 1; + + // Sandbox id. + string sandbox_id = 2; + + // Session token. + string token = 3; + + // Creation timestamp in milliseconds since epoch. + int64 created_at_ms = 4; + + // Revoked flag. + bool revoked = 5; + + // Human-friendly name (auto-generated if not provided). + string name = 6; +} + +// Watch sandbox request. +message WatchSandboxRequest { + // Sandbox id. + string id = 1; + + // Stream sandbox status snapshots. + bool follow_status = 2; + + // Stream navigator-server process logs correlated to this sandbox. + bool follow_logs = 3; + + // Stream platform events correlated to this sandbox. + bool follow_events = 4; + + // Replay the last N log lines (best-effort) before following. + uint32 log_tail_lines = 5; + + // Replay the last N platform events (best-effort) before following. + uint32 event_tail = 6; + + // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + bool stop_on_terminal = 7; + + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + int64 log_since_ms = 8; + + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string log_sources = 9; + + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string log_min_level = 10; +} + +// One event in a sandbox watch stream. +message SandboxStreamEvent { + oneof payload { + // Latest sandbox snapshot. + navigator.datamodel.v1.Sandbox sandbox = 1; + // One server log line/event. + SandboxLogLine log = 2; + // One platform event. + PlatformEvent event = 3; + // Warning from the server (e.g. missed messages due to lag). + SandboxStreamWarning warning = 4; + } +} + +// Log line correlated to a sandbox. +message SandboxLogLine { + string sandbox_id = 1; + int64 timestamp_ms = 2; + string level = 3; + string target = 4; + string message = 5; + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + string source = 6; + // Structured key-value fields from the tracing event (e.g. dst_host, action). + map fields = 7; +} + +// Platform event correlated to a sandbox. +message PlatformEvent { + // Event timestamp in milliseconds since epoch. + int64 timestamp_ms = 1; + // Event source (e.g. "kubernetes", "docker", "process"). + string source = 2; + // Event type/severity (e.g. "Normal", "Warning"). + string type = 3; + // Short reason code (e.g. "Started", "Pulled", "Failed"). + string reason = 4; + // Human-readable event message. + string message = 5; + // Optional metadata as key-value pairs. + map metadata = 6; +} + +message SandboxStreamWarning { + string message = 1; +} + +// Create provider request. +message CreateProviderRequest { + navigator.datamodel.v1.Provider provider = 1; +} + +// Get provider request. +message GetProviderRequest { + string name = 1; +} + +// List providers request. +message ListProvidersRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Update provider request. +message UpdateProviderRequest { + navigator.datamodel.v1.Provider provider = 1; +} + +// Delete provider request. +message DeleteProviderRequest { + string name = 1; +} + +// Provider response. +message ProviderResponse { + navigator.datamodel.v1.Provider provider = 1; +} + +// List providers response. +message ListProvidersResponse { + repeated navigator.datamodel.v1.Provider providers = 1; +} + +// Delete provider response. +message DeleteProviderResponse { + bool deleted = 1; +} + +// Get sandbox provider environment request. +message GetSandboxProviderEnvironmentRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Get sandbox provider environment response. +message GetSandboxProviderEnvironmentResponse { + // Provider credential environment variables. + map environment = 1; +} + +// --------------------------------------------------------------------------- +// Policy update messages +// --------------------------------------------------------------------------- + +// Update sandbox policy request. +message UpdateSandboxPolicyRequest { + // Sandbox name (canonical lookup key). + string name = 1; + // The new policy to apply. Only network_policies and inference fields may + // differ from the create-time policy; static fields (filesystem, landlock, + // process) must match version 1 or the request is rejected. + navigator.sandbox.v1.SandboxPolicy policy = 2; +} + +// Update sandbox policy response. +message UpdateSandboxPolicyResponse { + // Assigned policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; +} + +// Get sandbox policy status request. +message GetSandboxPolicyStatusRequest { + // Sandbox name (canonical lookup key). + string name = 1; + // The specific policy version to query. 0 means latest. + uint32 version = 2; +} + +// Get sandbox policy status response. +message GetSandboxPolicyStatusResponse { + // The queried policy revision. + SandboxPolicyRevision revision = 1; + // The currently active (loaded) policy version for this sandbox. + uint32 active_version = 2; +} + +// List sandbox policies request. +message ListSandboxPoliciesRequest { + // Sandbox name (canonical lookup key). + string name = 1; + uint32 limit = 2; + uint32 offset = 3; +} + +// List sandbox policies response. +message ListSandboxPoliciesResponse { + repeated SandboxPolicyRevision revisions = 1; +} + +// Report policy load status (called by sandbox runtime after reload attempt). +message ReportPolicyStatusRequest { + // Sandbox id. + string sandbox_id = 1; + // The policy version that was attempted. + uint32 version = 2; + // Load result status. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; +} + +// Report policy status response. +message ReportPolicyStatusResponse {} + +// A versioned policy revision with metadata. +message SandboxPolicyRevision { + // Policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Load status of this revision. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; + // Milliseconds since epoch when this revision was created. + int64 created_at_ms = 5; + // Milliseconds since epoch when this revision was loaded by the sandbox. + int64 loaded_at_ms = 6; + // The full policy (only populated when explicitly requested). + navigator.sandbox.v1.SandboxPolicy policy = 7; +} + +// Policy load status. +enum PolicyStatus { + POLICY_STATUS_UNSPECIFIED = 0; + // Server received the update; sandbox has not yet loaded it. + POLICY_STATUS_PENDING = 1; + // Sandbox successfully applied this policy version. + POLICY_STATUS_LOADED = 2; + // Sandbox attempted to apply but failed; LKG policy remains active. + POLICY_STATUS_FAILED = 3; + // A newer version was persisted before the sandbox loaded this one. + POLICY_STATUS_SUPERSEDED = 4; +} + +// --------------------------------------------------------------------------- +// Sandbox logs messages +// --------------------------------------------------------------------------- + +// Get sandbox logs request (one-shot fetch). +message GetSandboxLogsRequest { + // Sandbox id. + string sandbox_id = 1; + // Maximum number of log lines to return. 0 means use default (2000). + uint32 lines = 2; + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + int64 since_ms = 3; + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string sources = 4; + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string min_level = 5; +} + +// Batch of log lines pushed from sandbox to server. +message PushSandboxLogsRequest { + // The sandbox ID. + string sandbox_id = 1; + // Log lines to ingest. + repeated SandboxLogLine logs = 2; +} + +// Push sandbox logs response. +message PushSandboxLogsResponse {} + +// Get sandbox logs response. +message GetSandboxLogsResponse { + // Log lines in chronological order. + repeated SandboxLogLine logs = 1; + // Total number of lines in the server's buffer for this sandbox. + uint32 buffer_total = 2; +} + +// --------------------------------------------------------------------------- +// Service status +// --------------------------------------------------------------------------- + +// Service status enum. +enum ServiceStatus { + SERVICE_STATUS_UNSPECIFIED = 0; + SERVICE_STATUS_HEALTHY = 1; + SERVICE_STATUS_DEGRADED = 2; + SERVICE_STATUS_UNHEALTHY = 3; +} diff --git a/sandboxes/nemoclaw/proto/sandbox.proto b/sandboxes/nemoclaw/proto/sandbox.proto new file mode 100644 index 0000000..062026b --- /dev/null +++ b/sandboxes/nemoclaw/proto/sandbox.proto @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package navigator.sandbox.v1; + +// Sandbox security policy configuration. +message SandboxPolicy { + // Policy version. + uint32 version = 1; + // Filesystem access policy. + FilesystemPolicy filesystem = 2; + // Landlock configuration. + LandlockPolicy landlock = 3; + // Process execution policy. + ProcessPolicy process = 4; + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + map network_policies = 5; +} + +// Filesystem access policy. +message FilesystemPolicy { + // Automatically include the workdir as read-write. + bool include_workdir = 1; + // Read-only directory allow list. + repeated string read_only = 2; + // Read-write directory allow list. + repeated string read_write = 3; +} + +// Landlock policy configuration. +message LandlockPolicy { + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + string compatibility = 1; +} + +// Process execution policy. +message ProcessPolicy { + // User name to run the sandboxed process as. + string run_as_user = 1; + // Group name to run the sandboxed process as. + string run_as_group = 2; +} + +// A named network access policy rule. +message NetworkPolicyRule { + // Human-readable name for this policy rule. + string name = 1; + // Allowed endpoint (host:port) pairs. + repeated NetworkEndpoint endpoints = 2; + // Allowed binary identities. + repeated NetworkBinary binaries = 3; +} + +// A network endpoint (host + port) with optional L7 inspection config. +message NetworkEndpoint { + string host = 1; + uint32 port = 2; + // Application protocol for L7 inspection: "rest", "sql", or "" (L4-only). + string protocol = 3; + // TLS handling: "terminate" or "passthrough" (default). + string tls = 4; + // Enforcement mode: "enforce" or "audit" (default). + string enforcement = 5; + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + string access = 6; + // Explicit L7 rules (mutually exclusive with access). + repeated L7Rule rules = 7; + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + repeated string allowed_ips = 8; +} + +// An L7 policy rule (allow-only). +message L7Rule { + L7Allow allow = 1; +} + +// Allowed action definition for L7 rules. +message L7Allow { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/**", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; +} + +// A binary identity for network policy matching. +message NetworkBinary { + string path = 1; + // Deprecated: the harness concept has been removed. This field is ignored. + bool harness = 2 [deprecated = true]; +} + +// Request to get sandbox policy by sandbox ID. +message GetSandboxPolicyRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Response containing sandbox policy. +message GetSandboxPolicyResponse { + // The sandbox policy configuration. + SandboxPolicy policy = 1; + // Current policy version (monotonically increasing per sandbox). + uint32 version = 2; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 3; +}