diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 21b1f319d98..145ad85e4d1 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -93,63 +93,40 @@ function buildPolicyGetCommand(sandboxName) { return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; } -// eslint-disable-next-line complexity -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); +/** + * Merge preset entries into existing policy YAML. Handles versionless policies + * by ensuring the merged result has a version header when the current policy + * has content but no version field. Pure function for testing. + * + * @param {string} currentPolicy - Existing policy YAML (may be versionless) + * @param {string} presetEntries - Indented network_policies entries from preset + * @returns {string} Merged YAML with version header when missing + */ +function mergePresetIntoPolicy(currentPolicy, presetEntries) { if (!presetEntries) { - console.error(` Preset ${presetName} has no network_policies section.`); - return false; + return currentPolicy || "version: 1\n\nnetwork_policies:\n"; + } + if (!currentPolicy) { + return "version: 1\n\nnetwork_policies:\n" + presetEntries; } - // Get current policy YAML from sandbox - let rawPolicy = ""; - try { - rawPolicy = runCapture( - buildPolicyGetCommand(sandboxName), - { ignoreError: true } - ); - } catch { /* ignored */ } - - let currentPolicy = parseCurrentPolicy(rawPolicy); - - // Merge: inject preset entries under the existing network_policies key let merged; - if (currentPolicy && currentPolicy.includes("network_policies:")) { - // Find the network_policies: line and append the new entries after it - // We need to insert before the next top-level key or end of file + if (/^network_policies\s*:/m.test(currentPolicy)) { const lines = currentPolicy.split("\n"); const result = []; let inNetworkPolicies = false; let inserted = false; for (const line of lines) { - // Detect top-level keys (no leading whitespace, ends with colon) const isTopLevel = /^\S.*:/.test(line); - if (line.trim() === "network_policies:" || line.trim().startsWith("network_policies:")) { + if (/^network_policies\s*:/.test(line)) { inNetworkPolicies = true; result.push(line); continue; } if (inNetworkPolicies && isTopLevel && !inserted) { - // We hit the next top-level key — insert preset entries before it result.push(presetEntries); inserted = true; inNetworkPolicies = false; @@ -158,25 +135,55 @@ function applyPreset(sandboxName, presetName) { result.push(line); } - // If network_policies was the last section, append at end if (inNetworkPolicies && !inserted) { result.push(presetEntries); } merged = result.join("\n"); - } else if (currentPolicy) { - // No network_policies section yet — append one - // Ensure version field exists - if (!currentPolicy.includes("version:")) { - currentPolicy = "version: 1\n" + currentPolicy; - } - merged = currentPolicy + "\n\nnetwork_policies:\n" + presetEntries; } else { - // No current policy at all - merged = "version: 1\n\nnetwork_policies:\n" + presetEntries; + merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; } - // Write temp file and apply + if (!merged.trimStart().startsWith("version:")) { + merged = "version: 1\n" + merged; + } + return merged; +} +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 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 }); @@ -190,7 +197,6 @@ function applyPreset(sandboxName, presetName) { try { fs.rmdirSync(tmpDir); } catch { /* ignored */ } } - // Update registry const sandbox = registry.getSandbox(sandboxName); if (sandbox) { const pols = sandbox.policies || []; @@ -217,6 +223,7 @@ module.exports = { parseCurrentPolicy, buildPolicySetCommand, buildPolicyGetCommand, + mergePresetIntoPolicy, applyPreset, getAppliedPresets, }; diff --git a/test/policies.test.js b/test/policies.test.js index 1671b77265c..a3050435ee9 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -136,6 +136,41 @@ describe("policies", () => { }); }); + describe("mergePresetIntoPolicy", () => { + const sampleEntries = " - host: example.com\n allow: true"; + + it("appends network_policies when current policy has content but no version header", () => { + const versionless = "some_key:\n foo: bar"; + const merged = policies.mergePresetIntoPolicy(versionless, sampleEntries); + expect(merged.startsWith("version: 1\n")).toBe(true); + expect(merged).toContain("some_key:"); + expect(merged).toContain("network_policies:"); + expect(merged).toContain("example.com"); + }); + + it("appends preset entries when current policy has network_policies but no version", () => { + const versionlessWithNp = + "network_policies:\n - host: existing.com\n allow: true"; + const merged = policies.mergePresetIntoPolicy(versionlessWithNp, sampleEntries); + expect(merged.trimStart().startsWith("version: 1\n")).toBe(true); + expect(merged).toContain("existing.com"); + expect(merged).toContain("example.com"); + }); + + it("keeps existing version when present", () => { + const withVersion = "version: 2\n\nnetwork_policies:\n - host: old.com"; + const merged = policies.mergePresetIntoPolicy(withVersion, sampleEntries); + expect(merged).toContain("version: 2"); + expect(merged).toContain("example.com"); + }); + + it("returns version + network_policies when current policy is empty", () => { + const merged = policies.mergePresetIntoPolicy("", sampleEntries); + expect(merged.startsWith("version: 1\n\nnetwork_policies:")).toBe(true); + expect(merged).toContain("example.com"); + }); + }); + describe("preset YAML schema", () => { it("no preset has rules at NetworkPolicyRuleDef level", () => { // rules must be inside endpoints, not as sibling of endpoints/binaries