+
+ ${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 = `
+
+ 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 = `
+
+ 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 = `
+
+ 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 = ``;
+
+ 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 = ``;
+
+ 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;
+}