Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 78 additions & 35 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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.
* 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.
*
* @param {string} currentPolicy - Existing policy YAML (may be versionless)
* 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 with version header when missing
* @returns {string} Merged YAML
*/
function mergePresetIntoPolicy(currentPolicy, presetEntries) {
if (!presetEntries) {
return currentPolicy || "version: 1\n\nnetwork_policies:\n";
}
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 inserted = false;

for (const line of lines) {
const isTopLevel = /^\S.*:/.test(line);
// 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 (/^network_policies\s*:/.test(line)) {
inNetworkPolicies = true;
// 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)) {
if (!currentPolicy) {
return "version: 1\n\nnetwork_policies:\n" + presetEntries;
}
let merged;
if (/^network_policies\s*:/m.test(currentPolicy)) {
// Insert before the next top-level key after network_policies
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);
continue;
}
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 (inNetworkPolicies && isTopLevel && !inserted) {
result.push(presetEntries);
inserted = true;
inNetworkPolicies = false;
}
if (!currentPolicy) {
return YAML.stringify({ version: 1, network_policies: presetPolicies });
}

result.push(line);
}
// Parse the current policy as structured YAML
let current;
try {
current = YAML.parse(currentPolicy);
} catch {
// Unparseable — fall back to text append
const withEntries = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries;
if (!withEntries.trimStart().startsWith("version:")) return "version: 1\n" + withEntries;
return withEntries;
}

if (inNetworkPolicies && !inserted) {
result.push(presetEntries);
}
if (!current || typeof current !== "object") current = {};

merged = result.join("\n");
// Structured merge: preset entries override existing on name collision.
// This prevents duplicate policy groups that the text-based approach
// would create when re-applying the same preset.
// 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;
// Legacy array format or missing — use preset as-is
mergedNp = presetPolicies;
}

if (!merged.trimStart().startsWith("version:")) {
merged = "version: 1\n" + merged;
// Rebuild with version first, then preserved sections, then network_policies
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
Expand Down
27 changes: 22 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc"
},
"dependencies": {
"openclaw": "2026.3.11"
"openclaw": "2026.3.11",
"yaml": "^2.8.3"
},
"files": [
"bin/",
Expand All @@ -42,8 +43,8 @@
"@j178/prek": "^0.3.6",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"execa": "^9.6.1",
"eslint": "^10.1.0",
"execa": "^9.6.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.0"
Expand Down
84 changes: 81 additions & 3 deletions test/policies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});
Expand All @@ -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", () => {
Expand Down