diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 293537aa5..bc1fe4260 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3091,22 +3091,17 @@ async function _setupPolicies(sandboxName) { } } } else { - console.log(""); - console.log(" Available policy presets:"); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) || suggestions.includes(p.name) ? "●" : "○"; - const suggested = suggestions.includes(p.name) ? " (suggested)" : ""; - console.log(` ${marker} ${p.name} — ${p.description}${suggested}`); - }); - console.log(""); - - const answer = await prompt( - ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, - ); - - if (answer.toLowerCase() === "n") { - console.log(" Skipping policy presets."); - return; + const { selectAndMerge } = require("./policy-wizard-static"); + let mergedYaml; + try { + mergedYaml = await selectAndMerge(); + } catch (err) { + if (err && err.isCancellation) { + console.log(" Policy selection cancelled by user."); + return; + } + console.error(` Policy selection failed: ${err && err.stack ? err.stack : err}`); + process.exit(1); } if (!waitForSandboxReady(sandboxName)) { @@ -3114,39 +3109,15 @@ async function _setupPolicies(sandboxName) { process.exit(1); } - if (answer.toLowerCase() === "list") { - // Let user pick - const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - for (const name of selected) { - try { - policies.applyPreset(sandboxName, name); - } catch (err) { - const message = err && err.message ? err.message : String(err); - if (message.includes("Unimplemented")) { - console.error(" OpenShell policy updates are not supported by this gateway build."); - console.error(" This is a known issue tracked in NemoClaw #536."); - } - throw err; - } - } - } else { - // Apply suggested - for (const name of suggestions) { - try { - policies.applyPreset(sandboxName, name); - } catch (err) { - const message = err && err.message ? err.message : String(err); - if (message.includes("Unimplemented")) { - console.error(" OpenShell policy updates are not supported by this gateway build."); - console.error(" This is a known issue tracked in NemoClaw #536."); - } - throw err; - } + try { + policies.applyPresetFromContent(sandboxName, mergedYaml); + } catch (err) { + const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); } + throw err; } } @@ -3251,47 +3222,36 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { return chosen; } - console.log(""); - console.log(" Available policy presets:"); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; - const suggested = suggestions.includes(p.name) ? " (suggested)" : ""; - console.log(` ${marker} ${p.name} — ${p.description}${suggested}`); - }); - console.log(""); - - const answer = await prompt( - ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, - ); - - if (answer.toLowerCase() === "n") { - console.log(" Skipping policy presets."); - return []; - } - - let interactiveChoice = suggestions; - if (answer.toLowerCase() === "list") { - const custom = await prompt(" Enter preset names (comma-separated): "); - interactiveChoice = parsePolicyPresetEnv(custom); - } - - const knownPresets = new Set(allPresets.map((p) => p.name)); - const invalidPresets = interactiveChoice.filter((name) => !knownPresets.has(name)); - if (invalidPresets.length > 0) { - console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); + const { selectAndMerge } = require("./policy-wizard-static"); + let mergedYaml; + try { + mergedYaml = await selectAndMerge(); + } catch (err) { + if (err && err.isCancellation) { + console.log(" Policy selection cancelled by user."); + return []; + } + console.error(` Policy selection failed: ${err && err.stack ? err.stack : err}`); process.exit(1); } - if (onSelection) onSelection(interactiveChoice); + if (onSelection) onSelection(["custom"]); if (!waitForSandboxReady(sandboxName)) { console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); process.exit(1); } - for (const name of interactiveChoice) { - policies.applyPreset(sandboxName, name); + try { + policies.applyPresetFromContent(sandboxName, mergedYaml); + } catch (err) { + const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); + } + throw err; } - return interactiveChoice; + return ["custom"]; } // ── Dashboard ──────────────────────────────────────────────────── diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 1e3bb8f34..ae487cb35 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -216,6 +216,48 @@ function mergePresetIntoPolicy(currentPolicy, presetEntries) { return YAML.stringify(output); } +function applyPresetFromContent(sandboxName, content) { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { + throw new Error( + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, + ); + } + + const presetEntries = extractPresetEntries(content); + if (!presetEntries) throw new Error("Provided policy content has no network_policies section."); + + let rawPolicy = ""; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + } catch { /* ignored */ } + + const merged = mergePresetIntoPolicy(rawPolicy, presetEntries); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); + + try { + run(buildPolicySetCommand(tmpFile, sandboxName)); + + const presetName = YAML.parse(content)?.preset?.name; + if (presetName) { + const sandbox = registry.getSandbox(sandboxName); + if (sandbox) { + const pols = sandbox.policies || []; + if (!pols.includes(presetName)) { + pols.push(presetName); + } + registry.updateSandbox(sandboxName, { policies: pols }); + } + } + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignored */ } + try { fs.rmdirSync(tmpDir); } catch { /* ignored */ } + } +} + function applyPreset(sandboxName, presetName) { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -299,5 +341,6 @@ module.exports = { buildPolicyGetCommand, mergePresetIntoPolicy, applyPreset, + applyPresetFromContent, getAppliedPresets, }; diff --git a/bin/lib/policy-compose.js b/bin/lib/policy-compose.js new file mode 100644 index 000000000..2d1b78c94 --- /dev/null +++ b/bin/lib/policy-compose.js @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// policy-compose.js — Static policy composition engine for NemoClaw. +// +// Replaces LLM-based policy generation with deterministic preset composition: +// +// 1. Load a preset YAML file from the presets library for each selected tool. +// 2. Filter endpoints and rules by the user's chosen access level (read / write). +// 3. Merge all per-tool network_policies sections into a single output document. +// 4. Serialize back to YAML with the standard NemoClaw header. +// +// No inference endpoint required. No network calls. Fully offline. +// +// Coverage gaps +// ------------- +// Tools that do not yet have a preset file will be listed in the returned +// `missing` array. The caller should warn the user and skip those tools. +// Add a new .yaml in nemoclaw-blueprint/policies/presets/ to fill a gap. + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const yaml = require("yaml"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PRESETS_DIR = path.resolve(__dirname, "../../nemoclaw-blueprint/policies/presets"); + +/** HTTP methods that constitute "write" access. */ +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** + * NemoClaw-only annotation fields that are meaningful to this tooling but are + * not valid OpenShell policy fields. At serialisation time these are converted + * to inline YAML comments so the information is preserved in the file without + * causing OpenShell to reject the policy. + * + * Add new annotation field names here as the schema evolves. + */ +const ANNOTATION_FIELDS = [ + "exfil_risk", +]; + +// --------------------------------------------------------------------------- +// Preset loading +// --------------------------------------------------------------------------- + +/** + * Load and parse a preset YAML file for the given tool ID. + * Returns the parsed object, or null if no preset file exists. + * + * @param {string} toolId e.g. "slack", "jira" + * @param {string} [dir] override the default presets directory (for testing) + * @returns {object|null} + */ +function loadPreset(toolId, dir = PRESETS_DIR) { + const file = path.join(dir, `${toolId}.yaml`); + if (!fs.existsSync(file)) return null; + try { + const raw = fs.readFileSync(file, "utf-8"); + return yaml.parse(raw); + } catch (err) { + throw new Error(`Failed to parse preset ${toolId}.yaml: ${err.message}`); + } +} + +// --------------------------------------------------------------------------- +// Rule-level filtering +// --------------------------------------------------------------------------- + +/** + * Return true if an allow-rule's method is a write method. + * @param {{ allow: { method: string } }} rule + */ +function isWriteRule(rule) { + return WRITE_METHODS.has((rule.allow?.method || "").toUpperCase()); +} + +/** + * Filter an endpoint's rules by access level. + * access "read" → keep only GET rules + * access "write" → keep all rules + * + * @param {object[]} rules + * @param {"read"|"write"} access + * @returns {object[]} + */ +function filterByAccess(rules, access) { + if (!rules) return []; + if (access === "read") return rules.filter(r => !isWriteRule(r)); + return rules; // write: keep all +} + +// --------------------------------------------------------------------------- +// Endpoint-level filtering +// --------------------------------------------------------------------------- + +/** + * Filter the endpoint list for a single policy block. + * + * - Tunnel endpoints (`access: full`) are stripped for read access, kept for write. + * - REST endpoints have their rules filtered by access level. + * - Endpoints that end up with zero rules are dropped. + * + * @param {object[]} endpoints + * @param {"read"|"write"} access + * @returns {object[]} + */ +function filterEndpoints(endpoints, access) { + if (!endpoints) return []; + + const result = []; + + for (const ep of endpoints) { + // Tunnel endpoint (WebSocket CONNECT, etc.) + if (ep.access === "full") { + if (access === "read") continue; // strip tunnels for read-only + result.push(ep); + continue; + } + + // REST endpoint + const filtered = filterByAccess(ep.rules, access); + if (filtered.length === 0) continue; + + result.push({ ...ep, rules: filtered }); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +/** + * Compose a merged network_policies object from a list of tool selections. + * + * @param {Array<{ tool: object, level: "read"|"write" }>} toolSelections + * @param {string} [presetsDir] override for testing + * @returns {{ policies: object, missing: string[], warnings: string[] }} + * policies — merged network_policies ready to embed in the output YAML + * missing — tool IDs for which no preset file was found + * warnings — human-readable risk warnings generated during composition + */ +function composePresets(toolSelections, presetsDir = PRESETS_DIR) { + const policies = {}; + const missing = []; + const warnings = []; + + // Deduplicate by tool.id, keeping the highest access level ("write" > "read"). + const levelRank = { read: 0, write: 1 }; + const deduped = Object.values( + toolSelections.reduce((acc, sel) => { + const existing = acc[sel.tool.id]; + if (!existing || (levelRank[sel.level] ?? 0) > (levelRank[existing.level] ?? 0)) { + acc[sel.tool.id] = sel; + } + return acc; + }, {}), + ); + + const multi = deduped.length > 1; + + for (const { tool, level } of deduped) { + const preset = loadPreset(tool.id, presetsDir); + if (!preset) { + missing.push(tool.id); + continue; + } + + const networkPolicies = preset.network_policies || {}; + + for (const [blockName, block] of Object.entries(networkPolicies)) { + const filtered = filterEndpoints(block.endpoints, level); + if (filtered.length === 0) { + warnings.push(`${tool.id}: all endpoints removed after access filter — skipped`); + continue; + } + + // When multiple tools are merged, prefix the block name with the tool ID + // to avoid collisions (e.g. both jira and confluence use "atlassian"). + const key = multi ? `${tool.id}-${blockName}` : blockName; + + policies[key] = { + name: key, + endpoints: filtered, + ...(block.binaries ? { binaries: block.binaries } : {}), + }; + } + } + + return { policies, missing, warnings }; +} + +// --------------------------------------------------------------------------- +// YAML serialization +// --------------------------------------------------------------------------- + +/** + * Build the final preset YAML string from composed policies. + * + * @param {string} presetName slug used as the file name and preset.name + * @param {object} policies merged network_policies object + * @param {{ readOnly: string[], readWrite: string[] }} fsAccess extra FS paths + * @returns {string} + */ +function buildPresetYaml(presetName, policies, fsAccess) { + const acc = fsAccess || {}; + + // Re-stamp enforcement + tls on each REST endpoint (clone to avoid mutating input). + for (const block of Object.values(policies)) { + block.endpoints = (block.endpoints || []).map((ep) => { + if (ep.access === "full") return ep; // tunnel — leave alone + return { ...ep, enforcement: "enforce", tls: ep.tls ?? "terminate" }; + }); + } + + const doc = { + preset: { name: presetName }, + + filesystem_policy: { + include_workdir: true, + read_only: ["/usr", "/lib", "/etc", "/app", ...(acc.readOnly || [])], + read_write: ["/sandbox", "/tmp", "/dev/null", ...(acc.readWrite || [])], + }, + + landlock: { compatibility: "best_effort" }, + + process: { + run_as_user: "sandbox", + run_as_group: "sandbox", + }, + + network_policies: policies, + }; + + const header = [ + "# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.", + "# SPDX-License-Identifier: Apache-2.0", + "#", + `# Generated by nemoclaw policy-wizard (static) on ${new Date().toISOString()}`, + "#", + "# WHAT REMAINS BLOCKED (OpenShell allowlist — unlisted = denied):", + "# All hosts not listed in this policy", + "# All RFC1918 / loopback / link-local / CGNAT addresses", + "# All binaries not explicitly listed in 'binaries:'", + "", + ].join("\n"); + + const raw = yaml.stringify(doc, { indent: 2, lineWidth: 0 }); + // Convert all NemoClaw annotation fields to inline YAML comments so they + // survive in the generated file but are invisible to OpenShell's parser. + const annotationRe = new RegExp( + `^(\\s+)(${ANNOTATION_FIELDS.join("|")}): (.*)$`, + "gm", + ); + const annotated = raw.replace(annotationRe, "$1# $2: $3"); + return header + annotated; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { + loadPreset, + filterByAccess, + filterEndpoints, + composePresets, + buildPresetYaml, + PRESETS_DIR, +}; diff --git a/bin/lib/policy-wizard-static.js b/bin/lib/policy-wizard-static.js new file mode 100644 index 000000000..4120df8da --- /dev/null +++ b/bin/lib/policy-wizard-static.js @@ -0,0 +1,675 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// policy-wizard-static.js — Static policy wizard for NemoClaw. No LLM required. +// +// Guided TUI for policy composition without an LLM: +// +// Q1 Select presets (checklist — browse all preset files) +// Q2 Access level per preset (read / write toggle grid) +// Compose → exfil warning → review → name → save +// +// Missing presets: the wizard warns and skips tools with no preset file. +// → Add .yaml to nemoclaw-blueprint/policies/presets/ to fill gaps. + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); +const yaml = require("yaml"); +const { + filterEndpoints, + buildPresetYaml, + PRESETS_DIR, +} = require("./policy-compose"); + +const CUSTOM_DIR = path.resolve(PRESETS_DIR, "../custom"); + +// --------------------------------------------------------------------------- +// Terminal colors (respects NO_COLOR) +// --------------------------------------------------------------------------- + +const _color = !process.env.NO_COLOR && !!process.stdout.isTTY; +const _tc = _color && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); + +const C = { + green: _color ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : "", + yellow: _color ? "\x1b[33m" : "", + red: _color ? "\x1b[31m" : "", + bold: _color ? "\x1b[1m" : "", + dim: _color ? "\x1b[2m" : "", + cyan: _color ? "\x1b[36m" : "", + reset: _color ? "\x1b[0m" : "", +}; + +// --------------------------------------------------------------------------- +// Raw-mode TUI helpers +// --------------------------------------------------------------------------- + +function rawInput(handler) { + try { process.stdin.setRawMode(true); } catch { /* not a TTY */ } + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", handler); +} + +function rawCleanup(handler) { + try { process.stdin.setRawMode(false); } catch { /* ignore */ } + process.stdin.pause(); + process.stdin.removeListener("data", handler); +} + +const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; +function writeLine(s, counter) { + process.stdout.write(s + "\n"); + const cols = process.stdout.columns || 80; + const visible = s.replace(ANSI_RE, ""); + return counter + Math.max(1, Math.ceil(visible.length / cols)); +} + +function isPrintable(key) { + return key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127; +} + +// --------------------------------------------------------------------------- +// Q1 — Use-case selector (↑↓ · type to filter · Enter) +// --------------------------------------------------------------------------- + +function promptArrowSelect(title, options, defaultIdx = 0) { + return new Promise((resolve) => { + let cursor = defaultIdx < options.length ? defaultIdx : 0; + let filter = ""; + let lineCount = 0; + + function filtered() { + if (!filter) return options.map((o, i) => ({ ...o, origIdx: i })); + const lf = filter.toLowerCase(); + return options + .map((o, i) => ({ ...o, origIdx: i })) + .filter((o) => o.name.toLowerCase().includes(lf) || (o.desc || "").toLowerCase().includes(lf)); + } + + function render() { + let n = 0; + const vis = filtered(); + if (cursor >= vis.length) cursor = Math.max(0, vis.length - 1); + n = writeLine(` ${C.bold}${title}${C.reset}`, n); + n = writeLine("", n); + if (vis.length === 0) { + n = writeLine(` ${C.dim}No matches — press Enter to use "${filter}" as custom value${C.reset}`, n); + } else { + for (let i = 0; i < vis.length; i++) { + const { name, desc } = vis[i]; + if (i === cursor) { + n = writeLine(` ${C.green}${C.bold}▶ ${name.padEnd(22)}${C.reset} ${C.dim}${desc || ""}${C.reset}`, n); + } else { + n = writeLine(` ${C.dim}${name.padEnd(22)} ${desc || ""}${C.reset}`, n); + } + } + } + n = writeLine("", n); + const hint = filter + ? ` ${C.dim}Filter:${C.reset} ${filter}${C.dim}▌ Esc clear · Enter confirm${C.reset}` + : ` ${C.dim}↑↓ cycle · Enter confirm · type to filter${C.reset}`; + n = writeLine(hint, n); + lineCount = n; + } + + function renderConfirmed(name) { + process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); + writeLine(` ${C.bold}${title}${C.reset}`, 0); + writeLine("", 0); + writeLine(` ${C.green}✓ ${name}${C.reset}`, 0); + writeLine("", 0); + } + + render(); + + const handler = (key) => { + const vis = filtered(); + if (key === "\x1b[A") { + cursor = (cursor - 1 + Math.max(vis.length, 1)) % Math.max(vis.length, 1); + redraw(); + } else if (key === "\x1b[B") { + cursor = (cursor + 1) % Math.max(vis.length, 1); + redraw(); + } else if (key === "\r" || key === "\n") { + rawCleanup(handler); + if (vis.length === 0 || (filter && vis[cursor]?.name.toLowerCase() !== filter.toLowerCase())) { + const label = filter || (vis.length > 0 ? vis[cursor].name : ""); + renderConfirmed(label); + resolve(filter ? { _freeText: filter } : vis.length > 0 ? vis[cursor].origIdx : null); + } else { + renderConfirmed(vis[cursor].name); + resolve(vis[cursor].origIdx); + } + } else if (key === "\x1b") { + filter = ""; cursor = 0; redraw(); + } else if (key === "\x7f" || key === "\b") { + filter = filter.slice(0, -1); cursor = 0; redraw(); + } else if (isPrintable(key)) { + filter += key; cursor = 0; redraw(); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + }; + + rawInput(handler); + function redraw() { process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); render(); } + }); +} + +// --------------------------------------------------------------------------- +// Q3 — Access level grid (↑↓ · ←→ or r/w · Enter confirm) +// --------------------------------------------------------------------------- + +function promptAccessLevels(tools) { + return new Promise((resolve) => { + let cursor = 0; + const access = Object.fromEntries(tools.map((t) => [t.id, "read"])); + let lineCount = 0; + + function render() { + let n = 0; + n = writeLine(` ${C.bold}Access levels${C.reset}`, n); + n = writeLine("", n); + for (let i = 0; i < tools.length; i++) { + const t = tools[i]; + const isCurs = i === cursor; + const isWrite = access[t.id] === "write"; + const curs = isCurs ? `${C.green}▶${C.reset}` : " "; + const readLbl = !isWrite ? `${C.bold}${C.green}● Read only ${C.reset}` : `${C.dim}○ Read only ${C.reset}`; + const wriLbl = isWrite ? `${C.bold}${C.green}● Read+write ${C.reset}` : `${C.dim}○ Read+write ${C.reset}`; + n = writeLine(` ${curs} ${t.name.padEnd(18)} ${readLbl} ${wriLbl}`, n); + } + n = writeLine("", n); + n = writeLine(` ${C.dim}↑↓ cycle · ←→ or r/w toggle · Enter confirm${C.reset}`, n); + lineCount = n; + } + + render(); + + const handler = (key) => { + if (key === "\x1b[A") { + cursor = (cursor - 1 + tools.length) % tools.length; redraw(); + } else if (key === "\x1b[B") { + cursor = (cursor + 1) % tools.length; redraw(); + } else if (key === "\x1b[C" || key === "\x1b[D" || key === "r" || key === "w" || key === "\t") { + const t = tools[cursor]; + if (key === "r") access[t.id] = "read"; + else if (key === "w") access[t.id] = "write"; + else access[t.id] = access[t.id] === "read" ? "write" : "read"; + redraw(); + } else if (key === "\r" || key === "\n") { + rawCleanup(handler); process.stdout.write("\n"); resolve(access); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + }; + + rawInput(handler); + function redraw() { process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); render(); } + }); +} + +// --------------------------------------------------------------------------- +// Q4 — Filesystem paths (checklist + custom entry) +// --------------------------------------------------------------------------- + +const FS_PRESETS = [ + { path: "/data", access: "rw", desc: "General data directory" }, + { path: "/workspace", access: "rw", desc: "Working directory / project root" }, + { path: "/models", access: "ro", desc: "ML model weights (read-only)" }, + { path: "/config", access: "ro", desc: "App config files (read-only)" }, + { path: "/secrets", access: "ro", desc: "Secrets / credentials (read-only)"}, + { path: "/output", access: "rw", desc: "Agent output directory" }, + { path: "/repo", access: "ro", desc: "Source code checkout (read-only)" }, + { path: "/logs", access: "rw", desc: "Log files" }, +]; + +async function promptFilesystemPaths() { + process.stdout.write(` ${C.bold}Q4 Additional filesystem access?${C.reset}\n`); + process.stdout.write(` ${C.dim}Defaults always included: /usr /lib /etc /app (ro) · /sandbox /tmp (rw)${C.reset}\n\n`); + + const items = FS_PRESETS.map((p) => ({ + id: p.path, name: p.path, + desc: `${p.desc} ${C.dim}[${p.access === "rw" ? "read+write" : "read-only"}]${C.reset}`, + defaultAccess: p.access, + })); + + const selected = await new Promise((resolve) => { + let cursor = 0; + let filter = ""; + const chosen = new Set(); + let lineCount = 0; + const customPaths = []; + + function visItems() { + const base = [...items, ...customPaths.map((p) => ({ id: p, name: p, desc: "custom", defaultAccess: "rw" }))]; + if (!filter) return base; + const lf = filter.toLowerCase(); + return base.filter((it) => it.name.toLowerCase().includes(lf)); + } + + function render() { + let n = 0; + const vis = visItems(); + if (cursor >= vis.length && vis.length > 0) cursor = vis.length - 1; + n = writeLine("", n); + for (let i = 0; i < vis.length; i++) { + const it = vis[i]; + const tick = chosen.has(it.id) ? `${C.green}✓${C.reset}` : `${C.dim}○${C.reset}`; + const arrow = i === cursor ? `${C.green}${C.bold}▶${C.reset}` : " "; + n = writeLine(` ${arrow} ${tick} ${it.name.padEnd(14)} ${C.dim}${it.desc}${C.reset}`, n); + } + n = writeLine("", n); + const hint = filter + ? ` ${C.dim}Filter:${C.reset} ${filter}${C.dim}▌ Enter to add as custom path · Esc clear${C.reset}` + : ` ${C.dim}↑↓ move · Space toggle · Enter confirm · type to filter/add custom path${C.reset}`; + n = writeLine(hint, n); + lineCount = n; + } + + render(); + + const handler = (key) => { + const vis = visItems(); + if (key === "\x1b[A") { + cursor = (cursor - 1 + Math.max(vis.length, 1)) % Math.max(vis.length, 1); redraw(); + } else if (key === "\x1b[B") { + cursor = (cursor + 1) % Math.max(vis.length, 1); redraw(); + } else if (key === " ") { + const it = vis[cursor]; + if (it) { chosen.has(it.id) ? chosen.delete(it.id) : chosen.add(it.id); } + redraw(); + } else if (key === "\r" || key === "\n") { + if (filter) { + const custom = filter.trim(); + if (custom.startsWith("/") && !items.find((it) => it.id === custom) && !customPaths.includes(custom)) { + customPaths.push(custom); + chosen.add(custom); + } + filter = ""; redraw(); + } else { + rawCleanup(handler); + process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); + const allItems = [...items, ...customPaths.map((p) => ({ id: p, name: p, defaultAccess: "rw" }))]; + const result = allItems.filter((it) => chosen.has(it.id)); + if (result.length > 0) { + writeLine(` ${C.green}✓ Filesystem paths${C.reset} ${C.dim}${result.map((it) => it.name).join(" ")}${C.reset}`, 0); + } else { + writeLine(` ${C.dim}✓ Filesystem paths defaults only${C.reset}`, 0); + } + writeLine("", 0); + resolve(result); + } + } else if (key === "\x1b") { + filter = ""; redraw(); + } else if (key === "\x7f" || key === "\b") { + filter = filter.slice(0, -1); redraw(); + } else if (isPrintable(key)) { + filter += key; cursor = 0; redraw(); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + }; + + rawInput(handler); + function redraw() { process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); render(); } + }); + + const readOnly = []; + const readWrite = []; + + for (const it of selected) { + if (it.defaultAccess === "ro") { readOnly.push(it.id); continue; } + const answer = await new Promise((resolve) => { + process.stdout.write( + ` ${it.name} ${C.dim}access: ${C.reset}${C.bold}[r]${C.reset}${C.dim}ead-only${C.reset} / ${C.bold}[w]${C.reset}${C.dim}rite (r/w):${C.reset} `, + ); + const handler = (key) => { + if (key === "r" || key === "R") { + rawCleanup(handler); process.stdout.write(`${C.dim}read-only${C.reset}\n`); resolve("ro"); + } else if (key === "w" || key === "W" || key === "\r" || key === "\n") { + rawCleanup(handler); process.stdout.write(`${C.dim}read+write${C.reset}\n`); resolve("rw"); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + }; + rawInput(handler); + }); + (answer === "ro" ? readOnly : readWrite).push(it.id); + } + + process.stdout.write("\n"); + return { readOnly, readWrite }; +} + +// --------------------------------------------------------------------------- +// Preset picker helpers +// --------------------------------------------------------------------------- + +function loadAllPresets() { + const files = fs.readdirSync(PRESETS_DIR).filter((f) => f.endsWith(".yaml")).sort(); + return files.map((file) => { + const raw = fs.readFileSync(path.join(PRESETS_DIR, file), "utf-8"); + const doc = yaml.parse(raw); + const name = doc?.preset?.name || file.replace(/\.yaml$/, ""); + const networkPolicies = doc?.network_policies || {}; + const hosts = Object.values(networkPolicies) + .flatMap((b) => b.endpoints || []) + .map((ep) => ep.host); + return { file, name, id: name, doc, hosts: [...new Set(hosts)] }; + }); +} + +function promptPresetChecklist(presets) { + const sorted = [...presets].sort((a, b) => a.name.localeCompare(b.name)); + + return new Promise((resolve) => { + let cursor = 0; + const chosen = new Set(); // indices into sorted + let filter = ""; + let searching = false; + let lineCount = 0; + + function visible() { + if (!filter) return sorted.map((p, i) => ({ p, i })); + const lf = filter.toLowerCase(); + return sorted + .map((p, i) => ({ p, i })) + .filter(({ p }) => p.name.toLowerCase().includes(lf) || p.hosts.some((h) => h.toLowerCase().includes(lf))); + } + + function render() { + let n = 0; + n = writeLine(` ${C.bold}Select presets${C.reset}`, n); + n = writeLine("", n); + const vis = visible(); + if (cursor >= vis.length) cursor = Math.max(0, vis.length - 1); + + if (vis.length === 0) { + n = writeLine(` ${C.dim}No matches for "${filter}"${C.reset}`, n); + } else { + for (let j = 0; j < vis.length; j++) { + const { p, i } = vis[j]; + const isSel = chosen.has(i); + const isCurs = j === cursor; + const curs = isCurs ? `${C.green}▶${C.reset}` : " "; + const check = isSel ? `${C.green}✓${C.reset}` : " "; + const hosts = p.hosts.length ? ` ${C.dim}${p.hosts.join(" ")}${C.reset}` : ""; + n = writeLine(` ${curs} [${check}] ${p.name}${hosts}`, n); + } + } + + n = writeLine("", n); + const selHint = chosen.size === 0 ? "select at least one" : `${chosen.size} selected`; + if (searching) { + n = writeLine(` ${C.dim}/${C.reset}${filter}${C.green}▌${C.reset} ${C.dim}Esc clear · Enter confirm · ${selHint}${C.reset}`, n); + } else { + n = writeLine(` ${C.dim}j/k move · Space toggle · / search · Enter confirm · ${selHint}${C.reset}`, n); + } + lineCount = n; + } + + render(); + + const handler = (key) => { + const vis = visible(); + + if (searching) { + if (key === "\x1b" || key === "\x1b[") { + filter = ""; searching = false; cursor = 0; redraw(); + } else if (key === "\x7f" || key === "\b") { + filter = filter.slice(0, -1); + if (filter === "") searching = false; + cursor = 0; redraw(); + } else if (key === "\r" || key === "\n") { + searching = false; redraw(); + } else if (isPrintable(key)) { + filter += key; cursor = 0; redraw(); + } else if (key === "\x1b[A" || key === "k") { + cursor = (cursor - 1 + Math.max(vis.length, 1)) % Math.max(vis.length, 1); redraw(); + } else if (key === "\x1b[B" || key === "j") { + cursor = (cursor + 1) % Math.max(vis.length, 1); redraw(); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + return; + } + + if (key === "\x1b[A" || key === "k") { cursor = (cursor - 1 + Math.max(vis.length, 1)) % Math.max(vis.length, 1); redraw(); } + else if (key === "\x1b[B" || key === "j") { cursor = (cursor + 1) % Math.max(vis.length, 1); redraw(); } + else if (key === " ") { + if (vis.length === 0) return; + const { i } = vis[cursor]; + if (chosen.has(i)) chosen.delete(i); else chosen.add(i); + redraw(); + } else if (key === "/") { + searching = true; redraw(); + } else if (key === "\x1b") { + filter = ""; cursor = 0; redraw(); + } else if (key === "\r" || key === "\n") { + if (chosen.size === 0) return; + rawCleanup(handler); + process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); + const names = [...chosen].map((i) => sorted[i].name); + writeLine(` ${C.green}✓ ${names.join(", ")}${C.reset}`, 0); + process.stdout.write("\n"); + resolve(sorted.filter((_, i) => chosen.has(i))); + } else if (key === "\u0003") { + rawCleanup(handler); + process.stderr.write(`\n ${C.dim}Goodbye.${C.reset}\n\n`); + process.exit(0); + } + }; + + rawInput(handler); + let _redrawTimer = null; + function redraw() { + if (_redrawTimer) return; + _redrawTimer = setTimeout(() => { + _redrawTimer = null; + process.stdout.write(`\x1b[${lineCount}A\x1b[0J`); + render(); + }, 50); + } + }); +} + +function mergePresets(selections) { + const policies = {}; + const multi = selections.length > 1; + for (const { preset, level } of selections) { + const networkPolicies = preset.doc?.network_policies || {}; + for (const [blockName, block] of Object.entries(networkPolicies)) { + const filtered = filterEndpoints(block.endpoints, level); + if (filtered.length === 0) continue; + const key = multi ? `${preset.name}-${blockName}` : blockName; + policies[key] = { + name: key, + endpoints: filtered, + ...(block.binaries ? { binaries: block.binaries } : {}), + }; + } + } + return policies; +} + +// --------------------------------------------------------------------------- +// Exfiltration risk warnings +// --------------------------------------------------------------------------- + +/** + * Collect exfil_risk annotations from all rules across a merged policies object. + * Returns an array of { policyName, host, method, path, risk } entries. + */ +function collectExfilRisks(policies) { + const results = []; + for (const [policyName, block] of Object.entries(policies)) { + for (const ep of block.endpoints || []) { + for (const rule of ep.rules || []) { + if (rule.exfil_risk) { + results.push({ + policyName, + host: ep.host, + method: rule.allow?.method, + path: rule.allow?.path, + risk: rule.exfil_risk, + }); + } + } + } + } + return results; +} + +async function warnExfil(policies) { + const risks = collectExfilRisks(policies); + + if (risks.length === 0) return; + + process.stdout.write(`\n ${C.yellow}${C.bold}⚠ EXFILTRATION RISK${C.reset}\n`); + process.stdout.write(` ${C.dim}The following rules can move data outside the sandbox:${C.reset}\n\n`); + let lastPolicy = null; + let lastHost = null; + for (const { policyName, host, method, path, risk } of risks) { + if (policyName !== lastPolicy) { + process.stdout.write(` ${C.yellow}●${C.reset} ${C.bold}${policyName}${C.reset}\n`); + lastPolicy = policyName; + lastHost = null; + } + if (host !== lastHost) { + process.stdout.write(` ${C.cyan}${host}${C.reset}\n`); + lastHost = host; + } + process.stdout.write(` ${C.dim}${method} ${path}${C.reset} — ${risk}\n`); + } + + process.stdout.write("\n"); + await new Promise((resolve) => { + process.stdout.write(` ${C.dim}Press Enter to acknowledge and continue, or Ctrl+C to abort.${C.reset} `); + const handler = (key) => { + if (key === "\u0003") { rawCleanup(handler); process.stderr.write("\n"); process.exit(0); } + if (key === "\r" || key === "\n") { rawCleanup(handler); process.stdout.write("\n\n"); resolve(); } + }; + rawInput(handler); + }); +} + +function renderMergedReview(policies) { + const allEndpoints = Object.values(policies).flatMap((b) => b.endpoints || []); + process.stdout.write(` ${C.bold}WHAT IS OPENED${C.reset}\n`); + for (const ep of allEndpoints) { + const methods = [...new Set((ep.rules || []).map((r) => r.allow?.method).filter(Boolean))]; + process.stdout.write(` ${C.dim}${ep.host}:${ep.port}${C.reset} ${methods.join(" ")}\n`); + } + process.stdout.write("\n"); + process.stdout.write(` ${C.bold}WHAT REMAINS BLOCKED${C.reset}\n`); + process.stdout.write(` ${C.dim} All hosts not listed above${C.reset}\n`); + process.stdout.write(` ${C.dim} All RFC1918 / loopback / link-local / CGNAT addresses${C.reset}\n`); + process.stdout.write(` ${C.dim} All binaries not explicitly listed in policies${C.reset}\n`); + process.stdout.write("\n"); +} + +// --------------------------------------------------------------------------- +// Main wizard run +// --------------------------------------------------------------------------- + +async function run() { + process.stdout.write("\n"); + process.stdout.write(` ${C.bold}${C.green}NemoClaw Policy Wizard${C.reset} ${C.dim}(static — no inference required)${C.reset}\n\n`); + + const allPresets = loadAllPresets(); + if (allPresets.length === 0) { + process.stderr.write(` ${C.red}No preset files found in ${PRESETS_DIR}${C.reset}\n\n`); + process.exit(1); + } + + // ── Select presets ──────────────────────────────────────────────────────── + const selected = await promptPresetChecklist(allPresets); + + // ── Access level per preset ─────────────────────────────────────────────── + const accessMap = await promptAccessLevels(selected); + const selections = selected.map((preset) => ({ preset, level: accessMap[preset.id] })); + + // ── Merge ───────────────────────────────────────────────────────────────── + const policies = mergePresets(selections); + + if (Object.keys(policies).length === 0) { + process.stderr.write(` ${C.red}No endpoints remained after filtering. Try write access or check preset files.${C.reset}\n\n`); + process.exit(1); + } + + // ── Exfiltration warning ────────────────────────────────────────────────── + await warnExfil(policies); + + renderMergedReview(policies); + + // ── Save to custom/ ─────────────────────────────────────────────────────── + fs.mkdirSync(CUSTOM_DIR, { recursive: true }); + const defaultName = selections.map(({ preset, level }) => `${preset.name}-${level[0]}`).join("--"); + const nameAnswer = await new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(` Save as [${defaultName}]: `, (ans) => { rl.close(); resolve(ans.trim()); }); + }); + const outName = (nameAnswer || defaultName).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || defaultName; + const content = buildPresetYaml(outName, policies, { readOnly: [], readWrite: [] }); + const outPath = path.join(CUSTOM_DIR, `${outName}.yaml`); + fs.writeFileSync(outPath, content, "utf-8"); + + process.stdout.write(` ${C.green}✓${C.reset} Saved to ${outPath}\n\n`); + process.stdout.write(` To apply to a sandbox:\n`); + process.stdout.write(` ${C.bold}nemoclaw policy apply --preset-file ${outPath}${C.reset}\n\n`); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Run just the TUI selection (checklist + access levels) and return the + * merged policy YAML string. No file is written; the caller decides what + * to do with the content (e.g. apply directly to a sandbox). + */ +async function selectAndMerge() { + const allPresets = loadAllPresets(); + if (allPresets.length === 0) throw new Error(`No preset files found in ${PRESETS_DIR}`); + + const selected = await promptPresetChecklist(allPresets); + const accessMap = await promptAccessLevels(selected); + const selections = selected.map((preset) => ({ preset, level: accessMap[preset.id] })); + + const policies = mergePresets(selections); + + if (Object.keys(policies).length === 0) { + throw new Error("No endpoints remained after filtering. Try write access or check preset files."); + } + + await warnExfil(policies); + + renderMergedReview(policies); + + const name = selections.map(({ preset, level }) => `${preset.name}-${level[0]}`).join("--"); + return buildPresetYaml(name, policies, { readOnly: [], readWrite: [] }); +} + +module.exports = { run, selectAndMerge }; + +if (require.main === module) { + run().catch((err) => { + console.error(`\n Unexpected error: ${err.message}\n`); + process.exit(1); + }); +} diff --git a/nemoclaw-blueprint/policies/presets/calendar.yaml b/nemoclaw-blueprint/policies/presets/calendar.yaml new file mode 100644 index 000000000..9f22aae82 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/calendar.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: calendar + description: "Google Calendar API for reading and managing events" + + +network_policies: + calendar: + name: calendar + endpoints: + - host: oauth2.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: /token } + - host: www.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /calendar/v3/users/me/calendarList } + - allow: { method: GET, path: /calendar/v3/calendars/* } + - allow: { method: GET, path: /calendar/v3/calendars/*/events } + exfil_risk: "GET exposes calendar event metadata including attendees and titles" + - allow: { method: GET, path: /calendar/v3/calendars/*/events/* } + exfil_risk: "GET exposes full calendar event details including attendees, location, and description" + - allow: { method: POST, path: /calendar/v3/calendars/*/events } + exfil_risk: "POST can create calendar events with arbitrary attendees and content" + - allow: { method: PUT, path: /calendar/v3/calendars/*/events/* } + exfil_risk: "PUT can overwrite calendar event content and attendees" + - allow: { method: PATCH, path: /calendar/v3/calendars/*/events/* } + exfil_risk: "PATCH can modify calendar event details and attendees" + - allow: { method: DELETE, path: /calendar/v3/calendars/*/events/* } + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/confluence.yaml b/nemoclaw-blueprint/policies/presets/confluence.yaml new file mode 100644 index 000000000..f48523eb6 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/confluence.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: confluence + description: "Confluence Cloud wiki and pages API" + + +network_policies: + confluence: + name: confluence + endpoints: + - host: auth.atlassian.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /authorize } + - allow: { method: POST, path: /oauth/token } + - host: api.atlassian.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/pages } + exfil_risk: "GET exposes Confluence page metadata and content" + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/pages/* } + exfil_risk: "GET exposes full Confluence page content including attachments" + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/spaces } + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/spaces/* } + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/search } + exfil_risk: "GET search results expose Confluence page content matching arbitrary queries" + - allow: { method: GET, path: /ex/confluence/*/wiki/api/v2/labels } + - allow: { method: POST, path: /ex/confluence/*/wiki/api/v2/pages } + exfil_risk: "POST can write wiki page content to external Confluence spaces" + - allow: { method: PUT, path: /ex/confluence/*/wiki/api/v2/pages/* } + exfil_risk: "PUT can overwrite existing Confluence page content" + - allow: { method: DELETE, path: /ex/confluence/*/wiki/api/v2/pages/* } + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/datadog.yaml b/nemoclaw-blueprint/policies/presets/datadog.yaml new file mode 100644 index 000000000..ae208489c --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/datadog.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: datadog + description: "Datadog metrics, dashboards, monitors, and logs API" + + +network_policies: + datadog: + name: datadog + endpoints: + - host: api.datadoghq.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /api/v1/query } + - allow: { method: GET, path: /api/v1/metrics } + - allow: { method: GET, path: /api/v1/monitor } + - allow: { method: GET, path: /api/v1/monitor/* } + - allow: { method: GET, path: /api/v1/dashboard } + - allow: { method: GET, path: /api/v1/dashboard/* } + - allow: { method: GET, path: /api/v1/events } + - allow: { method: GET, path: /api/v2/metrics/* } + - allow: { method: POST, path: /api/v1/series } + exfil_risk: "POST can submit arbitrary metric data to external Datadog account" + - allow: { method: POST, path: /api/v1/events } + exfil_risk: "POST can publish arbitrary event data to external Datadog" + - allow: { method: POST, path: /api/v1/logs/list } + exfil_risk: "POST exposes log data matching arbitrary search queries" + - allow: { method: POST, path: /api/v1/monitor } + exfil_risk: "POST can create monitors that trigger alerts to external destinations" + - allow: { method: PUT, path: /api/v1/monitor/* } + exfil_risk: "PUT can modify monitor configuration including alert destinations" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/local/bin/python3 } diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index 8ffd1bc63..3b51f069e 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -17,8 +17,11 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST can send messages and files to external Discord servers" - allow: { method: PUT, path: "/**" } + exfil_risk: "PUT can write content to external Discord servers" - allow: { method: PATCH, path: "/**" } + exfil_risk: "PATCH can modify content on external Discord servers" - allow: { method: DELETE, path: "/**" } # WebSocket gateway — must use access: full (CONNECT tunnel) instead # of protocol: rest. The proxy's HTTP idle timeout (~2 min) kills diff --git a/nemoclaw-blueprint/policies/presets/docker.yaml b/nemoclaw-blueprint/policies/presets/docker.yaml index 184ca875a..580abe72a 100644 --- a/nemoclaw-blueprint/policies/presets/docker.yaml +++ b/nemoclaw-blueprint/policies/presets/docker.yaml @@ -3,6 +3,7 @@ preset: name: docker + description: "Docker Hub and NVIDIA container registry access" network_policies: @@ -11,12 +12,12 @@ network_policies: endpoints: - host: registry-1.docker.io port: 443 - protocol: rest + access: full enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST can push container images containing arbitrary filesystem content to Docker Hub" - host: auth.docker.io port: 443 protocol: rest @@ -27,12 +28,12 @@ network_policies: - allow: { method: POST, path: "/**" } - host: nvcr.io port: 443 - protocol: rest + access: full enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST can push container images containing arbitrary filesystem content to NVIDIA Container Registry" - host: authn.nvidia.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/presets/email.yaml b/nemoclaw-blueprint/policies/presets/email.yaml new file mode 100644 index 000000000..af888be5d --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/email.yaml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: email + description: "Gmail API for reading and sending email" + +network_policies: + gmail: + name: gmail + endpoints: + - host: oauth2.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: /token } + - host: gmail.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /gmail/v1/users/*/messages } + exfil_risk: "GET exposes email message metadata and content from the inbox" + - allow: { method: GET, path: /gmail/v1/users/*/messages/* } + exfil_risk: "GET exposes full email content including attachments" + - allow: { method: GET, path: /gmail/v1/users/*/threads } + exfil_risk: "GET exposes email thread metadata" + - allow: { method: GET, path: /gmail/v1/users/*/threads/* } + exfil_risk: "GET exposes full email thread content" + - allow: { method: GET, path: /gmail/v1/users/*/labels } + - allow: { method: GET, path: /gmail/v1/users/*/labels/* } + - allow: { method: GET, path: /gmail/v1/users/*/profile } + - allow: { method: POST, path: /gmail/v1/users/*/messages/send } + exfil_risk: "POST sends email to any recipient — direct data exfiltration vector" + - allow: { method: POST, path: /gmail/v1/users/*/messages/modify } + - allow: { method: POST, path: /gmail/v1/users/*/drafts } + exfil_risk: "POST creates drafts that can be sent to any recipient" + - allow: { method: PUT, path: /gmail/v1/users/*/drafts/* } + exfil_risk: "PUT updates drafts that can be sent to any recipient" + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/github.yaml b/nemoclaw-blueprint/policies/presets/github.yaml new file mode 100644 index 000000000..ad8ae01ac --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/github.yaml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: github + description: "GitHub REST API and raw content access" + +network_policies: + github: + name: github + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /repos } + - allow: { method: GET, path: /repos/* } + - allow: { method: GET, path: /orgs } + - allow: { method: GET, path: /orgs/* } + - allow: { method: GET, path: /users/* } + - allow: { method: GET, path: /search/** } + exfil_risk: "GET search query parameters can encode and transmit data to GitHub" + - allow: { method: GET, path: /issues/** } + - allow: { method: GET, path: /pulls/** } + - allow: { method: GET, path: /gists/** } + - allow: { method: GET, path: /notifications } + - allow: { method: POST, path: /repos/** } + exfil_risk: "POST can push code, files, or data to external GitHub repositories" + - allow: { method: POST, path: /issues/** } + exfil_risk: "POST can write issue content to external GitHub repositories" + - allow: { method: POST, path: /pulls/** } + exfil_risk: "POST can write pull request content to external GitHub repositories" + - allow: { method: PATCH, path: /repos/** } + exfil_risk: "PATCH can modify repository content on external GitHub" + - allow: { method: PATCH, path: /issues/** } + exfil_risk: "PATCH can modify issue content on external GitHub repositories" + - allow: { method: PATCH, path: /pulls/** } + exfil_risk: "PATCH can modify pull request content on external GitHub repositories" + - allow: { method: PUT, path: /repos/** } + exfil_risk: "PUT can write files directly to external GitHub repositories" + - host: raw.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/git } diff --git a/nemoclaw-blueprint/policies/presets/gitlab.yaml b/nemoclaw-blueprint/policies/presets/gitlab.yaml new file mode 100644 index 000000000..bf9f2a47e --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/gitlab.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: gitlab + description: "GitLab REST API access" + +network_policies: + gitlab: + name: gitlab + endpoints: + - host: gitlab.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /api/v4/projects } + - allow: { method: GET, path: /api/v4/projects/* } + - allow: { method: GET, path: /api/v4/groups } + - allow: { method: GET, path: /api/v4/groups/* } + - allow: { method: GET, path: /api/v4/users/* } + - allow: { method: GET, path: /api/v4/issues } + - allow: { method: GET, path: /api/v4/merge_requests } + - allow: { method: GET, path: /api/v4/search } + exfil_risk: "GET search query parameters can encode and transmit data to GitLab" + - allow: { method: POST, path: /api/v4/projects/*/issues } + exfil_risk: "POST can write issue content to external GitLab repositories" + - allow: { method: POST, path: /api/v4/projects/*/merge_requests } + exfil_risk: "POST can write merge request content to external GitLab repositories" + - allow: { method: POST, path: /api/v4/projects/*/issues/*/notes } + exfil_risk: "POST can write comments to external GitLab issues" + - allow: { method: PUT, path: /api/v4/projects/*/issues/* } + exfil_risk: "PUT can overwrite issue content on external GitLab repositories" + - allow: { method: PUT, path: /api/v4/projects/*/merge_requests/* } + exfil_risk: "PUT can overwrite merge request content on external GitLab repositories" + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/grafana.yaml b/nemoclaw-blueprint/policies/presets/grafana.yaml new file mode 100644 index 000000000..7d7cbd74e --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/grafana.yaml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: grafana + description: "Grafana Cloud dashboards, datasources, and alerts API" + + +network_policies: + grafana: + name: grafana + endpoints: + - host: grafana.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /api/dashboards/uid/* } + - allow: { method: GET, path: /api/datasources } + - allow: { method: GET, path: /api/datasources/* } + - allow: { method: GET, path: /api/alerts } + - allow: { method: GET, path: /api/annotations } + - allow: { method: GET, path: /api/org } + - allow: { method: GET, path: /api/search } + - allow: { method: POST, path: /api/dashboards/db } + exfil_risk: "POST can write dashboard definitions with arbitrary queries to external Grafana" + - allow: { method: POST, path: /api/annotations } + exfil_risk: "POST can write arbitrary annotation content to Grafana dashboards" + - allow: { method: PUT, path: /api/datasources/* } + exfil_risk: "PUT can redirect datasource connections to attacker-controlled endpoints" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/local/bin/python3 } diff --git a/nemoclaw-blueprint/policies/presets/huggingface.yaml b/nemoclaw-blueprint/policies/presets/huggingface.yaml index 6462e238b..729218355 100644 --- a/nemoclaw-blueprint/policies/presets/huggingface.yaml +++ b/nemoclaw-blueprint/policies/presets/huggingface.yaml @@ -3,6 +3,7 @@ preset: name: huggingface + description: "Hugging Face Hub, LFS, and Inference API access" network_policies: @@ -16,7 +17,9 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes model files, datasets, and repository metadata from Hugging Face Hub" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can push models, datasets, or arbitrary files to public Hugging Face repositories" - host: cdn-lfs.huggingface.co port: 443 protocol: rest @@ -24,6 +27,7 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET downloads model weights and large files from Hugging Face LFS storage" - host: api-inference.huggingface.co port: 443 protocol: rest @@ -31,7 +35,9 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET may expose model outputs or metadata from Hugging Face inference endpoints" - allow: { method: POST, path: "/**" } + exfil_risk: "POST sends arbitrary input data to Hugging Face hosted inference endpoints" binaries: - { path: /usr/local/bin/python3 } - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/jira.yaml b/nemoclaw-blueprint/policies/presets/jira.yaml index 9e9df6741..5b592bc7a 100644 --- a/nemoclaw-blueprint/policies/presets/jira.yaml +++ b/nemoclaw-blueprint/policies/presets/jira.yaml @@ -5,6 +5,7 @@ preset: name: jira description: "Jira and Atlassian Cloud access" + network_policies: atlassian: name: atlassian @@ -16,7 +17,9 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes all Jira issues, comments, and project data" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can create or update Jira issues with arbitrary content" - host: auth.atlassian.com port: 443 protocol: rest @@ -32,6 +35,8 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes all Atlassian API data including Jira and Confluence resources" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can write to any Atlassian API endpoint with arbitrary content" binaries: - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/linear.yaml b/nemoclaw-blueprint/policies/presets/linear.yaml new file mode 100644 index 000000000..739517255 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/linear.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: linear + description: "Linear project management GraphQL API" + + +network_policies: + linear: + name: linear + endpoints: + - host: api.linear.app + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + # Linear is a pure GraphQL API — all reads and writes go through POST /graphql. + # GET is included for introspection and health checks. + - allow: { method: GET, path: /graphql } + - allow: { method: POST, path: /graphql } + exfil_risk: "POST /graphql handles all Linear writes — can create or update issues, projects, and comments with arbitrary content" + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/notion.yaml b/nemoclaw-blueprint/policies/presets/notion.yaml new file mode 100644 index 000000000..3aa8e34ba --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/notion.yaml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: notion + description: "Notion API for pages, databases, and blocks" + + +network_policies: + notion: + name: notion + endpoints: + - host: api.notion.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /v1/pages/* } + exfil_risk: "GET exposes full Notion page content" + - allow: { method: GET, path: /v1/databases/* } + exfil_risk: "GET exposes Notion database structure and records" + - allow: { method: GET, path: /v1/blocks/* } + exfil_risk: "GET exposes Notion block content from any page" + - allow: { method: GET, path: /v1/users } + - allow: { method: GET, path: /v1/users/* } + - allow: { method: POST, path: /v1/search } + exfil_risk: "POST search results expose Notion page and database content matching arbitrary queries" + - allow: { method: POST, path: /v1/pages } + exfil_risk: "POST can create Notion pages with arbitrary content in any workspace" + - allow: { method: POST, path: /v1/databases } + exfil_risk: "POST can create Notion databases in any workspace" + - allow: { method: POST, path: /v1/databases/*/query } + exfil_risk: "POST exposes all records from a Notion database" + - allow: { method: POST, path: /v1/blocks/*/children } + exfil_risk: "POST can append blocks with arbitrary content to any Notion page" + - allow: { method: PATCH, path: /v1/pages/* } + exfil_risk: "PATCH can modify Notion page content and properties" + - allow: { method: PATCH, path: /v1/blocks/* } + exfil_risk: "PATCH can modify Notion block content" + - allow: { method: PATCH, path: /v1/databases/* } + exfil_risk: "PATCH can modify Notion database structure and properties" + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/openai.yaml b/nemoclaw-blueprint/policies/presets/openai.yaml new file mode 100644 index 000000000..8d802cfab --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/openai.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: openai + description: "OpenAI API for chat completions, embeddings, and models" + + +network_policies: + openai: + name: openai + endpoints: + - host: api.openai.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /v1/models } + - allow: { method: GET, path: /v1/models/* } + - allow: { method: POST, path: /v1/chat/completions } + exfil_risk: "POST sends prompt content to OpenAI — prompt injection could exfiltrate context data" + - allow: { method: POST, path: /v1/completions } + exfil_risk: "POST sends prompt content to OpenAI — prompt injection could exfiltrate context data" + - allow: { method: POST, path: /v1/embeddings } + exfil_risk: "POST sends arbitrary text to OpenAI for embedding — can transmit sensitive data" + - allow: { method: POST, path: /v1/images/generations } + exfil_risk: "POST sends prompt text to OpenAI image generation" + - allow: { method: POST, path: /v1/images/edits } + exfil_risk: "POST sends image data and prompts to OpenAI" + - allow: { method: POST, path: /v1/audio/transcriptions } + exfil_risk: "POST sends audio data to OpenAI for transcription" + - allow: { method: POST, path: /v1/audio/speech } + exfil_risk: "POST sends text content to OpenAI for speech synthesis" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/local/bin/python3 } diff --git a/nemoclaw-blueprint/policies/presets/outlook.yaml b/nemoclaw-blueprint/policies/presets/outlook.yaml index ece3d0e0c..5c4d00f16 100644 --- a/nemoclaw-blueprint/policies/presets/outlook.yaml +++ b/nemoclaw-blueprint/policies/presets/outlook.yaml @@ -16,7 +16,9 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes email, calendar, and contact data via Microsoft Graph" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can send email and create calendar events via Microsoft Graph" - host: login.microsoftonline.com port: 443 protocol: rest @@ -32,7 +34,9 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes email content from Outlook" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can send email to any recipient via Outlook" - host: outlook.office.com port: 443 protocol: rest @@ -40,6 +44,8 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } + exfil_risk: "GET exposes email content from Outlook" - allow: { method: POST, path: "/**" } + exfil_risk: "POST can send email to any recipient via Outlook" binaries: - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/pagerduty.yaml b/nemoclaw-blueprint/policies/presets/pagerduty.yaml new file mode 100644 index 000000000..94b988af5 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/pagerduty.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: pagerduty + description: "PagerDuty incident and on-call management API" + +network_policies: + pagerduty: + name: pagerduty + endpoints: + - host: api.pagerduty.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /incidents } + - allow: { method: GET, path: /incidents/* } + - allow: { method: GET, path: /services } + - allow: { method: GET, path: /services/* } + - allow: { method: GET, path: /oncalls } + - allow: { method: GET, path: /schedules } + - allow: { method: GET, path: /schedules/* } + - allow: { method: GET, path: /alerts } + - allow: { method: GET, path: /analytics/metrics/incidents } + - allow: { method: POST, path: /incidents } + exfil_risk: "POST can create incidents with arbitrary content and trigger notifications to external responders" + - allow: { method: POST, path: /incidents/*/notes } + exfil_risk: "POST can write arbitrary notes to incidents visible to all responders" + - allow: { method: PUT, path: /incidents } + exfil_risk: "PUT can bulk-update incident content and status" + - allow: { method: PUT, path: /incidents/* } + exfil_risk: "PUT can overwrite incident details and escalation targets" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/local/bin/python3 } diff --git a/nemoclaw-blueprint/policies/presets/sendgrid.yaml b/nemoclaw-blueprint/policies/presets/sendgrid.yaml new file mode 100644 index 000000000..b02640d16 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/sendgrid.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: sendgrid + description: "SendGrid transactional email delivery API" + +network_policies: + sendgrid: + name: sendgrid + endpoints: + - host: api.sendgrid.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /v3/stats } + - allow: { method: GET, path: /v3/suppression/bounces } + - allow: { method: GET, path: /v3/suppression/unsubscribes } + - allow: { method: GET, path: /v3/templates } + - allow: { method: GET, path: /v3/templates/* } + - allow: { method: POST, path: /v3/mail/send } + exfil_risk: "POST delivers email to any recipient — direct data exfiltration vector" + - allow: { method: POST, path: /v3/templates } + - allow: { method: PATCH, path: /v3/templates/* } + - allow: { method: DELETE, path: /v3/suppression/bounces } + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/nemoclaw-blueprint/policies/presets/slack.yaml index e2a7c4706..32aafe6ec 100644 --- a/nemoclaw-blueprint/policies/presets/slack.yaml +++ b/nemoclaw-blueprint/policies/presets/slack.yaml @@ -17,6 +17,7 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST can deliver messages and files to external Slack workspaces" - host: api.slack.com port: 443 protocol: rest @@ -25,6 +26,7 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST can deliver messages, upload files, and trigger actions in external Slack workspaces" - host: hooks.slack.com port: 443 protocol: rest @@ -33,6 +35,7 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + exfil_risk: "POST delivers webhook payloads to external Slack channels" # Socket Mode WebSocket — requires CONNECT tunnel to avoid # HTTP idle timeout killing the persistent connection. See #409. - host: wss-primary.slack.com diff --git a/nemoclaw-blueprint/policies/presets/stripe.yaml b/nemoclaw-blueprint/policies/presets/stripe.yaml new file mode 100644 index 000000000..9df1afd2c --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/stripe.yaml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: stripe + description: "Stripe payment processing API" + +network_policies: + stripe: + name: stripe + endpoints: + - host: api.stripe.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /v1/customers } + exfil_risk: "GET exposes customer PII including name, email, and billing address" + - allow: { method: GET, path: /v1/customers/* } + exfil_risk: "GET exposes full customer record including PII and payment methods" + - allow: { method: GET, path: /v1/charges } + exfil_risk: "GET exposes payment charge history and amounts" + - allow: { method: GET, path: /v1/charges/* } + exfil_risk: "GET exposes full charge details including card last4 and billing info" + - allow: { method: GET, path: /v1/invoices } + exfil_risk: "GET exposes invoice history and financial data" + - allow: { method: GET, path: /v1/invoices/* } + exfil_risk: "GET exposes full invoice details including line items and amounts" + - allow: { method: GET, path: /v1/subscriptions } + exfil_risk: "GET exposes subscription data including pricing and renewal dates" + - allow: { method: GET, path: /v1/subscriptions/* } + exfil_risk: "GET exposes full subscription details including payment schedule" + - allow: { method: GET, path: /v1/payment_intents } + exfil_risk: "GET exposes payment intent metadata and amounts" + - allow: { method: GET, path: /v1/payment_intents/* } + exfil_risk: "GET exposes full payment intent including amount and status" + - allow: { method: GET, path: /v1/balance } + exfil_risk: "GET exposes account balance and available funds" + - allow: { method: POST, path: /v1/customers } + exfil_risk: "POST can create customer records with attacker-controlled data" + - allow: { method: POST, path: /v1/payment_intents } + exfil_risk: "POST can initiate payment intents for arbitrary amounts" + - allow: { method: POST, path: /v1/payment_intents/*/confirm } + exfil_risk: "POST can confirm and charge payment intents" + - allow: { method: POST, path: /v1/refunds } + exfil_risk: "POST can issue refunds to arbitrary charges" + - allow: { method: POST, path: /v1/invoices/*/finalize } + exfil_risk: "POST finalizes and sends invoices to customers" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/local/bin/python3 } diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/nemoclaw-blueprint/policies/presets/teams.yaml new file mode 100644 index 000000000..ca9483df3 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/teams.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: teams + description: "Microsoft Teams messaging and channels via Graph API" + +network_policies: + teams: + name: teams + endpoints: + - host: login.microsoftonline.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: /*/oauth2/v2.0/token } + - host: graph.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /v1.0/me/joinedTeams } + - allow: { method: GET, path: /v1.0/teams/* } + - allow: { method: GET, path: /v1.0/me/chats } + - allow: { method: GET, path: /v1.0/chats/* } + - allow: { method: GET, path: /v1.0/chats/*/messages } + - allow: { method: GET, path: /v1.0/teams/*/channels } + - allow: { method: GET, path: /v1.0/teams/*/channels/*/messages } + - allow: { method: POST, path: /v1.0/chats/*/messages } + exfil_risk: "POST can send messages to external Teams chats" + - allow: { method: POST, path: /v1.0/teams/*/channels/*/messages } + exfil_risk: "POST can send messages to external Teams channels" + binaries: + - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/nemoclaw-blueprint/policies/presets/telegram.yaml index b80d7b959..dd2366949 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -17,5 +17,6 @@ network_policies: rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } + exfil_risk: "POST can send messages to arbitrary Telegram chats and channels" binaries: - { path: /usr/local/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/websearch.yaml b/nemoclaw-blueprint/policies/presets/websearch.yaml new file mode 100644 index 000000000..4484491cb --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/websearch.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: websearch + description: "Brave Search API for web and news search" + +network_policies: + websearch: + name: websearch + endpoints: + - host: api.search.brave.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: /res/v1/web/search } + exfil_risk: "GET search query parameters can encode and transmit data to Brave Search" + - allow: { method: GET, path: /res/v1/news/search } + exfil_risk: "GET search query parameters can encode and transmit data to Brave Search" + - allow: { method: GET, path: /res/v1/images/search } + exfil_risk: "GET search query parameters can encode and transmit data to Brave Search" + binaries: + - { path: /usr/local/bin/node } diff --git a/package-lock.json b/package-lock.json index 5372ce86b..4c74ffb2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -953,6 +953,14 @@ "scripts/actions/documentation" ] }, + "node_modules/@buape/carbon/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@buape/carbon/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", @@ -1345,6 +1353,14 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@discordjs/voice/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",