diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 6a9accc4f5..64602bd396 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -53,7 +53,7 @@ function getPresetEndpoints(content) { const regex = /host:\s*([^\s,}]+)/g; let match; while ((match = regex.exec(content)) !== null) { - hosts.push(match[1]); + hosts.push(match[1].replace(/^["']|["']$/g, "")); } return hosts; } @@ -251,6 +251,11 @@ function applyPreset(sandboxName, presetName) { const currentPolicy = parseCurrentPolicy(rawPolicy); const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); + const endpoints = getPresetEndpoints(presetContent); + if (endpoints.length > 0) { + console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); + } + 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 }); diff --git a/test/policies.test.js b/test/policies.test.js index 9c46509a25..be4529c67a 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { spawnSync } from "node:child_process"; import policies from "../bin/lib/policies"; @@ -166,6 +166,82 @@ describe("policies", () => { expect(hosts.length > 0).toBeTruthy(); } }); + + it("strips surrounding quotes from hostnames", () => { + const yaml = "host: \"example.com\"\n host: 'other.com'"; + const hosts = policies.getPresetEndpoints(yaml); + expect(hosts).toEqual(["example.com", "other.com"]); + }); + }); + + describe("applyPreset disclosure logging", () => { + it("logs egress endpoints before applying", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exit"); + }); + + try { + try { + policies.applyPreset("test-sandbox", "npm"); + } catch { + /* applyPreset may throw if sandbox not running — we only care about the log */ + } + const messages = logSpy.mock.calls.map((c) => c[0]); + expect( + messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), + ).toBe(true); + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); + + it("does not log when preset does not exist", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + policies.applyPreset("test-sandbox", "nonexistent"); + const messages = logSpy.mock.calls.map((c) => c[0]); + expect( + messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), + ).toBe(false); + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + } + }); + + it("does not log when preset exists but has no host entries", () => { + const noHostPreset = + "preset:\n name: empty\n\nnetwork_policies:\n empty_rule:\n name: empty_rule\n endpoints: []\n"; + const loadSpy = vi.spyOn(policies, "loadPreset").mockReturnValue(noHostPreset); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exit"); + }); + + try { + try { + policies.applyPreset("test-sandbox", "empty"); + } catch { + /* applyPreset may throw if sandbox not running */ + } + const messages = logSpy.mock.calls.map((c) => c[0]); + expect( + messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), + ).toBe(false); + } finally { + loadSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); }); describe("buildPolicySetCommand", () => {