From 9893f048d6c42b432d93fe4e2ceb8e10d091f451 Mon Sep 17 00:00:00 2001 From: Facundo Fernandez Date: Wed, 1 Apr 2026 15:08:07 -0600 Subject: [PATCH 1/2] fix(security): add --dry-run flag to policy-add for egress disclosure Add --dry-run flag to `nemoclaw policy-add` that shows which endpoints a preset would open without applying the policy change. Also shows endpoints before the Y/n confirmation in normal mode. This provides operator visibility into sandbox egress widening before changes take effect (CWE-285, NVBUG 6002814). The core policy approval gate depends on upstream OpenShell (openshell#584). Signed-off-by: Facundo Fernandez Made-with: Cursor --- bin/nemoclaw.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index f587e88d663..0ce9e14c3dd 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -669,7 +669,8 @@ function sandboxLogs(sandboxName, follow) { runOpenshell(args); } -async function sandboxPolicyAdd(sandboxName) { +async function sandboxPolicyAdd(sandboxName, args = []) { + const dryRun = args.includes("--dry-run"); const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -685,6 +686,19 @@ async function sandboxPolicyAdd(sandboxName) { const answer = await askPrompt(" Preset to apply: "); if (!answer) return; + const presetContent = policies.loadPreset(answer); + if (!presetContent) return; + + const endpoints = policies.getPresetEndpoints(presetContent); + if (endpoints.length > 0) { + console.log(` Endpoints that would be opened: ${endpoints.join(", ")}`); + } + + if (dryRun) { + console.log(" --dry-run: no changes applied."); + return; + } + const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); if (confirm.toLowerCase() === "n") return; @@ -749,7 +763,7 @@ function help() { nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} ${G}Policy Presets:${R} - nemoclaw policy-add Add a network or filesystem policy preset + nemoclaw policy-add Add a network or filesystem policy preset ${D}(--dry-run to preview)${R} nemoclaw policy-list List presets ${D}(● = applied)${R} ${G}Deploy:${R} @@ -825,7 +839,7 @@ const [cmd, ...args] = process.argv.slice(2); case "connect": await sandboxConnect(cmd); break; case "status": await sandboxStatus(cmd); break; case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; + case "policy-add": await sandboxPolicyAdd(cmd, actionArgs); break; case "policy-list": sandboxPolicyList(cmd); break; case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: From fe7981b0628ea91972301fab499f21eefce7cd7f Mon Sep 17 00:00:00 2001 From: Brandon Pelfrey Date: Wed, 8 Apr 2026 08:37:10 -0700 Subject: [PATCH 2/2] fix(cli): honor --dry-run for policy-add --- bin/nemoclaw.js | 2 +- test/policies.test.js | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 96664b82bee..6fcb16ac951 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -1299,7 +1299,7 @@ const [cmd, ...args] = process.argv.slice(2); sandboxLogs(cmd, actionArgs.includes("--follow")); break; case "policy-add": - await sandboxPolicyAdd(cmd); + await sandboxPolicyAdd(cmd, actionArgs); break; case "policy-list": sandboxPolicyList(cmd); diff --git a/test/policies.test.js b/test/policies.test.js index 27c9b6d57ea..eb1ce5e6524 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -19,7 +19,7 @@ const SELECT_FROM_LIST_ITEMS = [ { name: "pypi", description: "Python Package Index (PyPI) access" }, ]; -function runPolicyAdd(confirmAnswer) { +function runPolicyAdd(confirmAnswer, extraArgs = []) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); const scriptPath = path.join(tmpDir, "policy-add-check.js"); const script = String.raw` @@ -28,6 +28,8 @@ const policies = require(${POLICIES_PATH}); const credentials = require(${CREDENTIALS_PATH}); const calls = []; policies.selectFromList = async () => "pypi"; +policies.loadPreset = () => "network_policies:\n pypi:\n host: pypi.org\n"; +policies.getPresetEndpoints = () => ["pypi.org"]; credentials.prompt = async (message) => { calls.push({ type: "prompt", message }); return ${JSON.stringify(confirmAnswer)}; @@ -42,10 +44,10 @@ policies.getAppliedPresets = () => []; policies.applyPreset = (sandboxName, presetName) => { calls.push({ type: "apply", sandboxName, presetName }); }; -process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"]; +process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add", ...${JSON.stringify(extraArgs)}]; require(${CLI_PATH}); setImmediate(() => { - process.stdout.write(JSON.stringify(calls)); + process.stdout.write("\n__CALLS__" + JSON.stringify(calls)); }); `; @@ -658,7 +660,7 @@ describe("policies", () => { const result = runPolicyAdd("y"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); expect(calls).toContainEqual({ type: "prompt", message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", @@ -674,12 +676,23 @@ describe("policies", () => { const result = runPolicyAdd("n"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); expect(calls).toContainEqual({ type: "prompt", message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", }); expect(calls.some((call) => call.type === "apply")).toBeFalsy(); }); + + it("does not prompt or apply when --dry-run is passed", () => { + const result = runPolicyAdd("y", ["--dry-run"]); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call) => call.type === "apply")).toBeFalsy(); + expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/); + expect(result.stdout).toMatch(/--dry-run: no changes applied\./); + }); }); });