Skip to content
Merged
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
109 changes: 58 additions & 51 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
Expand All @@ -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 || [];
Expand All @@ -217,6 +223,7 @@ module.exports = {
parseCurrentPolicy,
buildPolicySetCommand,
buildPolicyGetCommand,
mergePresetIntoPolicy,
applyPreset,
getAppliedPresets,
};
35 changes: 35 additions & 0 deletions test/policies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading