diff --git a/bin/lib/policies.js b/bin/lib/policies.js index b5550f061ac..1e3bb8f34dc 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -110,61 +110,111 @@ 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) { - const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); - if (!presetEntries) { - return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; - } - if (!normalizedCurrentPolicy) { +function textBasedMerge(currentPolicy, presetEntries) { + if (!currentPolicy) { return "version: 1\n\nnetwork_policies:\n" + presetEntries; } - let merged; - if (/^network_policies\s*:/m.test(normalizedCurrentPolicy)) { - const lines = normalizedCurrentPolicy.split("\n"); + 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)) { - inNetworkPolicies = true; + inNp = true; result.push(line); continue; } - - if (inNetworkPolicies && isTopLevel && !inserted) { + if (inNp && /^\S.*:/.test(line) && !inserted) { result.push(presetEntries); inserted = true; - inNetworkPolicies = false; + 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; +} - if (inNetworkPolicies && !inserted) { - result.push(presetEntries); - } +/** + * 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"; + } - merged = result.join("\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 { - merged = normalizedCurrentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; + mergedNp = presetPolicies; } - if (!merged.trimStart().startsWith("version:")) { - merged = "version: 1\n\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 b4807c80a75..5372ce86b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "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" @@ -13474,9 +13475,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 5e79c83d999..7b4658e4716 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,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 cd2840655bc..f57f8d2d935 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -251,12 +251,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"); @@ -265,7 +266,7 @@ describe("policies", () => { 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("version:"); expect(merged).toContain("existing.com"); expect(merged).toContain("example.com"); }); @@ -279,7 +280,8 @@ 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"); }); @@ -294,6 +296,80 @@ describe("policies", () => { const merged = policies.mergePresetIntoPolicy("some_key:\n foo: bar", sampleEntries); expect(merged.startsWith("version: 1\n\nsome_key:")).toBe(true); }); + + // --- 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); + 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); + 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", () => {