diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 145ad85e4..05064f5a3 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const YAML = require("yaml"); const { ROOT, run, runCapture, shellQuote } = require("./runner"); const registry = require("./registry"); @@ -94,60 +95,102 @@ function buildPolicyGetCommand(sandboxName) { } /** - * 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 + * Text-based fallback for merging preset entries into policy YAML. + * Used when preset entries cannot be parsed as structured YAML. */ -function mergePresetIntoPolicy(currentPolicy, presetEntries) { - if (!presetEntries) { - return currentPolicy || "version: 1\n\nnetwork_policies:\n"; - } +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 inNetworkPolicies = false; + let inNp = false; let inserted = false; - for (const line of lines) { - const isTopLevel = /^\S.*:/.test(line); + 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" + merged; + return merged; +} - if (/^network_policies\s*:/.test(line)) { - inNetworkPolicies = true; - result.push(line); - continue; - } +/** + * 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) { + if (!presetEntries) { + return currentPolicy || "version: 1\n\nnetwork_policies:\n"; + } - if (inNetworkPolicies && isTopLevel && !inserted) { - result.push(presetEntries); - inserted = true; - inNetworkPolicies = false; - } + // 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; + } - result.push(line); - } + // 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(currentPolicy, presetEntries); + } - if (inNetworkPolicies && !inserted) { - result.push(presetEntries); - } + if (!currentPolicy) { + return YAML.stringify({ version: 1, network_policies: presetPolicies }); + } - merged = result.join("\n"); + // Parse the current policy as structured YAML + let current; + try { + current = YAML.parse(currentPolicy); + } catch { + return textBasedMerge(currentPolicy, 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 { - merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; + mergedNp = presetPolicies; } - if (!merged.trimStart().startsWith("version:")) { - merged = "version: 1\n" + merged; + const output = { version: current.version || 1 }; + for (const [key, val] of Object.entries(current)) { + if (key !== "version" && key !== "network_policies") output[key] = val; } - return merged; + output.network_policies = mergedNp; + + return YAML.stringify(output); } function applyPreset(sandboxName, presetName) { // Guard against truncated sandbox names — WSL can truncate hyphenated diff --git a/package-lock.json b/package-lock.json index f2197f791..598e547f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,14 @@ "": { "name": "nemoclaw", "version": "0.1.0", + "bundleDependencies": [ + "p-retry" + ], "license": "Apache-2.0", "dependencies": { "openclaw": "2026.3.11", - "p-retry": "^4.6.2" + "p-retry": "^4.6.2", + "yaml": "^2.8.3" }, "bin": { "nemoclaw": "bin/nemoclaw.js" @@ -5893,6 +5897,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, "license": "MIT" }, "node_modules/@types/send": { @@ -10994,6 +10999,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "inBundle": true, "license": "MIT", "dependencies": { "@types/retry": "0.12.0", @@ -11771,6 +11777,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "inBundle": true, "license": "MIT", "engines": { "node": ">= 4" @@ -13467,9 +13474,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 603801061..a3f6bc9f7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ }, "dependencies": { "openclaw": "2026.3.11", - "p-retry": "^4.6.2" + "p-retry": "^4.6.2", + "yaml": "^2.8.3" }, "bundleDependencies": [ "p-retry" diff --git a/test/policies.test.js b/test/policies.test.js index a3050435e..f914d0ad9 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -137,12 +137,13 @@ describe("policies", () => { }); describe("mergePresetIntoPolicy", () => { + // Legacy list-style entries (backward compat — uses text-based fallback) 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("version:"); expect(merged).toContain("some_key:"); expect(merged).toContain("network_policies:"); expect(merged).toContain("example.com"); @@ -152,7 +153,7 @@ describe("policies", () => { 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("version:"); expect(merged).toContain("existing.com"); expect(merged).toContain("example.com"); }); @@ -166,9 +167,86 @@ describe("policies", () => { 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("version: 1"); + expect(merged).toContain("network_policies:"); expect(merged).toContain("example.com"); }); + + // --- Structured merge tests (real preset format) --- + const realisticEntries = + " pypi_access:\n" + + " name: pypi_access\n" + + " endpoints:\n" + + " - host: pypi.org\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/bin/python3* }\n"; + + it("uses structured YAML merge for real preset entries", () => { + const current = + "version: 1\n\n" + + "network_policies:\n" + + " npm_yarn:\n" + + " name: npm_yarn\n" + + " endpoints:\n" + + " - host: registry.npmjs.org\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/local/bin/npm* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + // Both policies should be present + expect(merged).toContain("npm_yarn"); + expect(merged).toContain("registry.npmjs.org"); + expect(merged).toContain("pypi_access"); + expect(merged).toContain("pypi.org"); + expect(merged).toContain("version: 1"); + }); + + it("deduplicates on policy name collision (preset overrides existing)", () => { + const current = + "version: 1\n\n" + + "network_policies:\n" + + " pypi_access:\n" + + " name: pypi_access\n" + + " endpoints:\n" + + " - host: old-pypi.example.com\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/bin/pip* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + // New preset should override the old one + expect(merged).toContain("pypi.org"); + expect(merged).not.toContain("old-pypi.example.com"); + }); + + it("preserves non-network sections during structured merge", () => { + const current = + "version: 1\n\n" + + "filesystem_policy:\n" + + " include_workdir: true\n" + + " read_only:\n" + + " - /usr\n\n" + + "process:\n" + + " run_as_user: sandbox\n\n" + + "network_policies:\n" + + " existing:\n" + + " name: existing\n" + + " endpoints:\n" + + " - host: api.example.com\n" + + " port: 443\n" + + " access: full\n" + + " binaries:\n" + + " - { path: /usr/local/bin/node* }\n"; + const merged = policies.mergePresetIntoPolicy(current, realisticEntries); + expect(merged).toContain("filesystem_policy"); + expect(merged).toContain("include_workdir"); + expect(merged).toContain("run_as_user: sandbox"); + expect(merged).toContain("existing"); + expect(merged).toContain("pypi_access"); + }); }); describe("preset YAML schema", () => {