Skip to content
Merged
365 changes: 13 additions & 352 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
@@ -1,357 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Policy preset management — list, load, merge, and apply presets.

const fs = require("fs");
const path = require("path");
const os = require("os");
const readline = require("readline");
const YAML = require("yaml");
const { ROOT, run, runCapture, shellQuote } = require("./runner");
const registry = require("./registry");

const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets");
function getOpenshellCommand() {
const binary = process.env.NEMOCLAW_OPENSHELL_BIN;
if (!binary) return "openshell";
return shellQuote(binary);
}

function listPresets() {
if (!fs.existsSync(PRESETS_DIR)) return [];
return fs
.readdirSync(PRESETS_DIR)
.filter((f) => f.endsWith(".yaml"))
.map((f) => {
const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8");
const nameMatch = content.match(/^\s*name:\s*(.+)$/m);
const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m);
return {
file: f,
name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""),
description: descMatch ? descMatch[1].trim() : "",
};
});
}

function loadPreset(name) {
const file = path.resolve(PRESETS_DIR, `${name}.yaml`);
if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) {
console.error(` Invalid preset name: ${name}`);
return null;
}
if (!fs.existsSync(file)) {
console.error(` Preset not found: ${name}`);
return null;
}
return fs.readFileSync(file, "utf-8");
}

function getPresetEndpoints(content) {
const hosts = [];
const regex = /host:\s*([^\s,}]+)/g;
let match;
while ((match = regex.exec(content)) !== null) {
hosts.push(match[1].replace(/^["']|["']$/g, ""));
}
return hosts;
}

/**
* Extract just the network_policies entries (indented content under
* the `network_policies:` key) from a preset file, stripping the
* `preset:` metadata header.
*/
function extractPresetEntries(presetContent) {
if (!presetContent) return null;
const npMatch = presetContent.match(/^network_policies:\n([\s\S]*)$/m);
if (!npMatch) return null;
return npMatch[1].trimEnd();
}

/**
* Parse the output of `openshell policy get --full` which has a metadata
* header (Version, Hash, etc.) followed by `---` and then the actual YAML.
*/
function parseCurrentPolicy(raw) {
if (!raw) return "";
const sep = raw.indexOf("---");
const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim();
if (!candidate) return "";
if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) {
return "";
}
if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) {
return "";
}
try {
const parsed = YAML.parse(candidate);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "";
}
} catch {
return "";
}
return candidate;
}

/**
* Build the openshell policy set command with properly quoted arguments.
*/
function buildPolicySetCommand(policyFile, sandboxName) {
return `${getOpenshellCommand()} policy set --policy ${shellQuote(policyFile)} --wait ${shellQuote(sandboxName)}`;
}

/**
* Build the openshell policy get command with properly quoted arguments.
*/
function buildPolicyGetCommand(sandboxName) {
return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`;
}

/**
* Text-based fallback for merging preset entries into policy YAML.
* Used when preset entries cannot be parsed as structured YAML.
*/
function textBasedMerge(currentPolicy, presetEntries) {
if (!currentPolicy) {
return "version: 1\n\nnetwork_policies:\n" + presetEntries;
}
let merged;
if (/^network_policies\s*:/m.test(currentPolicy)) {
const lines = currentPolicy.split("\n");
const result = [];
let inNp = false;
let inserted = false;
for (const line of lines) {
if (/^network_policies\s*:/.test(line)) {
inNp = true;
result.push(line);
continue;
}
if (inNp && /^\S.*:/.test(line) && !inserted) {
result.push(presetEntries);
inserted = true;
inNp = false;
}
result.push(line);
}
if (inNp && !inserted) result.push(presetEntries);
merged = result.join("\n");
} else {
merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries;
}
if (!merged.trimStart().startsWith("version:")) merged = "version: 1\n\n" + merged;
return merged;
}

/**
* Merge preset entries into existing policy YAML using structured YAML
* parsing. Replaces the previous text-based manipulation which could
* produce invalid YAML when indentation or ordering varied.
*
* Behavior:
* - Parses both current policy and preset entries as YAML
* - Merges network_policies by name (preset overrides on collision)
* - Preserves all non-network sections (filesystem_policy, process, etc.)
* - Ensures version: 1 exists
*
* @param {string} currentPolicy - Existing policy YAML (may be empty/versionless)
* @param {string} presetEntries - Indented network_policies entries from preset
* @returns {string} Merged YAML
*/
function mergePresetIntoPolicy(currentPolicy, presetEntries) {
const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy);
if (!presetEntries) {
return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n";
}

// Parse preset entries. They come as indented content under network_policies:,
// so we wrap them to make valid YAML for parsing.
let presetPolicies;
try {
const wrapped = "network_policies:\n" + presetEntries;
const parsed = YAML.parse(wrapped);
presetPolicies = parsed?.network_policies;
} catch {
presetPolicies = null;
}

// If YAML parsing failed or entries are not a mergeable object,
// fall back to the text-based approach for backward compatibility.
if (!presetPolicies || typeof presetPolicies !== "object" || Array.isArray(presetPolicies)) {
return textBasedMerge(normalizedCurrentPolicy, presetEntries);
}

if (!normalizedCurrentPolicy) {
return YAML.stringify({ version: 1, network_policies: presetPolicies });
}

// Parse the current policy as structured YAML
let current;
try {
current = YAML.parse(normalizedCurrentPolicy);
} catch {
return textBasedMerge(normalizedCurrentPolicy, presetEntries);
}

if (!current || typeof current !== "object") current = {};

// Structured merge: preset entries override existing on name collision.
// Guard: network_policies may be an array in legacy policies — only
// object-merge when both sides are plain objects.
const existingNp = current.network_policies;
let mergedNp;
if (existingNp && typeof existingNp === "object" && !Array.isArray(existingNp)) {
mergedNp = { ...existingNp, ...presetPolicies };
} else {
mergedNp = presetPolicies;
}

const output = { version: current.version || 1 };
for (const [key, val] of Object.entries(current)) {
if (key !== "version" && key !== "network_policies") output[key] = val;
}
output.network_policies = mergedNp;

return YAML.stringify(output);
}
function applyPreset(sandboxName, presetName) {
// Guard against truncated sandbox names — WSL can truncate hyphenated
// names during argument parsing, e.g. "my-assistant" → "m"
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 presetContent = loadPreset(presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
return false;
}

const presetEntries = extractPresetEntries(presetContent);
if (!presetEntries) {
console.error(` Preset ${presetName} has no network_policies section.`);
return false;
}

// Get current policy YAML from sandbox
let rawPolicy = "";
try {
rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true });
} catch {
/* ignored */
}

const currentPolicy = parseCurrentPolicy(rawPolicy);
const merged = mergePresetIntoPolicy(currentPolicy, presetEntries);

const endpoints = getPresetEndpoints(presetContent);
if (endpoints.length > 0) {
console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`);
}

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));

console.log(` Applied preset: ${presetName}`);
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
/* ignored */
}
try {
fs.rmdirSync(tmpDir);
} catch {
/* ignored */
}
}

const sandbox = registry.getSandbox(sandboxName);
if (sandbox) {
const pols = sandbox.policies || [];
if (!pols.includes(presetName)) {
pols.push(presetName);
}
registry.updateSandbox(sandboxName, { policies: pols });
}

return true;
}

function getAppliedPresets(sandboxName) {
const sandbox = registry.getSandbox(sandboxName);
return sandbox ? sandbox.policies || [] : [];
}

function selectFromList(items, { applied = [] } = {}) {
return new Promise((resolve) => {
process.stderr.write("\n Available presets:\n");
items.forEach((item, i) => {
const marker = applied.includes(item.name) ? "●" : "○";
const description = item.description ? ` — ${item.description}` : "";
process.stderr.write(` ${i + 1}) ${marker} ${item.name}${description}\n`);
});
process.stderr.write("\n ● applied, ○ not applied\n\n");
const defaultIdx = items.findIndex((item) => !applied.includes(item.name));
const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null;
const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: ";
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(question, (answer) => {
rl.close();
if (!process.stdin.isTTY) {
if (typeof process.stdin.pause === "function") process.stdin.pause();
if (typeof process.stdin.unref === "function") process.stdin.unref();
}
const trimmed = answer.trim();
const effectiveInput = trimmed || (defaultNum ? String(defaultNum) : "");
if (!effectiveInput) {
resolve(null);
return;
}
if (!/^\d+$/.test(effectiveInput)) {
process.stderr.write("\n Invalid preset number.\n");
resolve(null);
return;
}
const num = Number(effectiveInput);
const item = items[num - 1];
if (!item) {
process.stderr.write("\n Invalid preset number.\n");
resolve(null);
return;
}
if (applied.includes(item.name)) {
process.stderr.write(`\n Preset '${item.name}' is already applied.\n`);
resolve(null);
return;
}
resolve(item.name);
});
});
}

const mod = require("../../dist/lib/policies");
module.exports = {
PRESETS_DIR,
listPresets,
loadPreset,
getPresetEndpoints,
extractPresetEntries,
parseCurrentPolicy,
buildPolicySetCommand,
buildPolicyGetCommand,
mergePresetIntoPolicy,
applyPreset,
getAppliedPresets,
selectFromList,
PRESETS_DIR: mod.PRESETS_DIR,
listPresets: mod.listPresets,
loadPreset: mod.loadPreset,
getPresetEndpoints: mod.getPresetEndpoints,
extractPresetEntries: mod.extractPresetEntries,
parseCurrentPolicy: mod.parseCurrentPolicy,
buildPolicySetCommand: mod.buildPolicySetCommand,
buildPolicyGetCommand: mod.buildPolicyGetCommand,
mergePresetIntoPolicy: mod.mergePresetIntoPolicy,
applyPreset: mod.applyPreset,
getAppliedPresets: mod.getAppliedPresets,
selectFromList: mod.selectFromList,
};
11 changes: 11 additions & 0 deletions scripts/ts-migration/phases/07-policies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"renameTests": [
"test/policies.test.js"
],
"moveRuntime": {
"bin/lib/policies.js": "src/lib/policies.ts"
},
"shimStrategy": {
"bin/lib/policies.js": "simple"
}
}
Loading
Loading