From 8c3dae8f359e52f0fbfaaeb30748e2fc4840ab7b Mon Sep 17 00:00:00 2001 From: Kim Yang Date: Mon, 20 Apr 2026 00:19:07 +0800 Subject: [PATCH 1/4] feat(policy): support custom preset files via --from-file and --from-dir Lets users apply user-authored preset YAML to a running sandbox without editing the baseline policy or dropping to openshell policy set. - --from-file applies a single custom preset - --from-dir applies every .yaml preset in a directory; stops at the first failure and does not roll back already-applied presets - --yes / -y and NEMOCLAW_NON_INTERACTIVE=1 skip the confirmation prompt, also covering the original built-in preset path - Custom presets must declare preset.name as an RFC 1123 label that does not collide with a built-in preset; the file must include a network_policies section - Custom hosts are not vetted and a warning is printed before apply; the docs page spells out the risk Closes #2039 Signed-off-by: Kim Yang --- .../nemoclaw-user-manage-policy/SKILL.md | 52 +++ .../customize-network-policy.md | 52 +++ src/lib/policies.ts | 126 +++++++- src/nemoclaw.ts | 113 ++++++- test/policies.test.ts | 301 +++++++++++++++++- 5 files changed, 623 insertions(+), 21 deletions(-) diff --git a/.agents/skills/nemoclaw-user-manage-policy/SKILL.md b/.agents/skills/nemoclaw-user-manage-policy/SKILL.md index bd21391e4d8..15cca8fb7b1 100644 --- a/.agents/skills/nemoclaw-user-manage-policy/SKILL.md +++ b/.agents/skills/nemoclaw-user-manage-policy/SKILL.md @@ -247,6 +247,7 @@ To include a preset in the baseline, merge its entries into `openclaw-sandbox.ya > **Note:** The `openshell policy set --policy ` command operates on raw policy files and does not > accept the `preset:` metadata block used in preset YAML files. Use `nemoclaw policy-add` for > presets. + For scripted workflows, `policy-add` and `policy-remove` accept the preset name as a positional argument: ```console @@ -259,6 +260,57 @@ See Commands (use the `nemoclaw-user-reference` skill) for the full flag referen `nemoclaw rebuild` reapplies every policy preset to the recreated sandbox, so presets survive an agent-version upgrade without manual reapplication. +## Step 8: Custom Preset Files + +Apply a user-authored preset YAML to a running sandbox without editing the baseline or dropping to `openshell policy set`. + +### Authoring + +A custom preset follows the same shape as the built-in ones under `nemoclaw-blueprint/policies/presets/`: + +```yaml +preset: + name: my-internal-api + description: "Internal service" +network_policies: + my-internal-api: + name: my-internal-api + endpoints: + - host: api.example.internal + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } +``` + +The top-level `preset.name` must be a lowercase RFC 1123 label (letters, digits, hyphens) and must not collide with a built-in preset name such as `slack` or `pypi`. +Rename `preset.name` if NemoClaw refuses to apply the file because of a collision. + +### Apply a Single File + +```console +$ nemoclaw my-assistant policy-add --from-file ./presets/my-internal-api.yaml +``` + +Preview the endpoints without applying with `--dry-run`, and skip the confirmation prompt with `--yes` or by exporting `NEMOCLAW_NON_INTERACTIVE=1`. + +### Apply Every File in a Directory + +```console +$ nemoclaw my-assistant policy-add --from-dir ./presets/ --yes +``` + +Files are processed in lexicographic order. +Processing stops at the first failure; presets already applied are not rolled back. +Fix the failing file and re-run the command to continue. + +> [!WARNING] +> Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations. +> Review every host in a custom preset before applying it, especially when the file originates outside your team. + ## Related Skills - `nemoclaw-user-reference` — Network Policies (use the `nemoclaw-user-reference` skill) for the full baseline policy reference diff --git a/docs/network-policy/customize-network-policy.md b/docs/network-policy/customize-network-policy.md index ce5362360ab..6d9202a1b92 100644 --- a/docs/network-policy/customize-network-policy.md +++ b/docs/network-policy/customize-network-policy.md @@ -218,6 +218,7 @@ The `openshell policy set --policy ` command operates on ra accept the `preset:` metadata block used in preset YAML files. Use `nemoclaw policy-add` for presets. ::: + For scripted workflows, `policy-add` and `policy-remove` accept the preset name as a positional argument: ```console @@ -230,6 +231,57 @@ See [Commands](../reference/commands.md#nemoclaw-name-policy-add) for the full f `nemoclaw rebuild` reapplies every policy preset to the recreated sandbox, so presets survive an agent-version upgrade without manual reapplication. +## Custom Preset Files + +Apply a user-authored preset YAML to a running sandbox without editing the baseline or dropping to `openshell policy set`. + +### Authoring + +A custom preset follows the same shape as the built-in ones under `nemoclaw-blueprint/policies/presets/`: + +```yaml +preset: + name: my-internal-api + description: "Internal service" +network_policies: + my-internal-api: + name: my-internal-api + endpoints: + - host: api.example.internal + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } +``` + +The top-level `preset.name` must be a lowercase RFC 1123 label (letters, digits, hyphens) and must not collide with a built-in preset name such as `slack` or `pypi`. +Rename `preset.name` if NemoClaw refuses to apply the file because of a collision. + +### Apply a Single File + +```console +$ nemoclaw my-assistant policy-add --from-file ./presets/my-internal-api.yaml +``` + +Preview the endpoints without applying with `--dry-run`, and skip the confirmation prompt with `--yes` or by exporting `NEMOCLAW_NON_INTERACTIVE=1`. + +### Apply Every File in a Directory + +```console +$ nemoclaw my-assistant policy-add --from-dir ./presets/ --yes +``` + +Files are processed in lexicographic order. +Processing stops at the first failure; presets already applied are not rolled back. +Fix the failing file and re-run the command to continue. + +> [!WARNING] +> Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations. +> Review every host in a custom preset before applying it, especially when the file originates outside your team. + ## Related Topics - [Approve or Deny Agent Network Requests](approve-network-requests.md) for real-time operator approval. diff --git a/src/lib/policies.ts b/src/lib/policies.ts index 8529fefe82d..0edcd1b0a18 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -15,6 +15,11 @@ const { loadAgent } = require("./agent-defs"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); +/** + * Enumerate every preset YAML under `nemoclaw-blueprint/policies/presets/` + * and return `{ file, name, description }` triples parsed from the file's + * `preset:` header. + */ function listPresets() { if (!fs.existsSync(PRESETS_DIR)) return []; return fs @@ -32,6 +37,10 @@ function listPresets() { }); } +/** + * Read a built-in preset by short name from `PRESETS_DIR`. Guards against + * path traversal and returns `null` if the preset does not exist. + */ function loadPreset(name) { const file = path.resolve(PRESETS_DIR, `${name}.yaml`); if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { @@ -45,6 +54,11 @@ function loadPreset(name) { return fs.readFileSync(file, "utf-8"); } +/** + * Extract the bare hostnames declared in a preset YAML (anything matched by + * `host: `), with surrounding quotes stripped. Used to show the + * "endpoints that would be opened" preview before applying a preset. + */ function getPresetEndpoints(content) { const hosts = []; const regex = /host:\s*([^\s,}]+)/g; @@ -241,9 +255,7 @@ function removePresetFromPolicy(currentPolicy, presetEntries) { try { const wrapped = "network_policies:\n" + presetEntries; const parsed = YAML.parse(wrapped); - presetKeys = parsed?.network_policies - ? Object.keys(parsed.network_policies) - : []; + presetKeys = parsed?.network_policies ? Object.keys(parsed.network_policies) : []; } catch { presetKeys = []; } @@ -275,6 +287,11 @@ function removePresetFromPolicy(currentPolicy, presetEntries) { return YAML.stringify(current); } +/** + * Remove a previously-applied preset from the running sandbox policy and + * delete its name from the registry entry. Returns `false` if the preset is + * unknown or has no `network_policies` section. + */ function removePreset(sandboxName, presetName) { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -353,6 +370,11 @@ function removePreset(sandboxName, presetName) { return true; } +/** + * Interactive preset picker for the `policy-remove` command. Prompts on + * stderr and resolves to the chosen preset name, or `null` if the user + * cancels or enters an invalid selection. + */ function selectForRemoval(items, { applied = [] } = {}) { return new Promise((resolve) => { const appliedItems = items.filter((item) => applied.includes(item.name)); @@ -397,7 +419,15 @@ function selectForRemoval(items, { applied = [] } = {}) { }); } -function applyPreset(sandboxName, presetName, _options = {}) { +/** + * Apply raw preset content (already loaded in memory) to a running sandbox. + * Validates the sandbox name, extracts the `network_policies` entries, merges + * them into the sandbox's current policy, runs `openshell policy set --wait`, + * and records the preset name in the registry. Returns `false` if the content + * has no `network_policies` section. Used by both `applyPreset` (built-in + * presets) and the `--from-file` / `--from-dir` paths (custom preset files). + */ +function applyPresetContent(sandboxName, presetName, presetContent, _options = {}) { // 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); @@ -408,12 +438,6 @@ function applyPreset(sandboxName, presetName, _options = {}) { ); } - 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.`); @@ -469,6 +493,76 @@ function applyPreset(sandboxName, presetName, _options = {}) { return true; } +/** + * Apply a built-in preset (by name) to a running sandbox. Loads the preset + * from `nemoclaw-blueprint/policies/presets/.yaml` and delegates to + * `applyPresetContent`. Returns `false` if the named preset does not exist. + */ +function applyPreset(sandboxName, presetName, options = {}) { + const presetContent = loadPreset(presetName); + if (!presetContent) { + console.error(` Cannot load preset: ${presetName}`); + return false; + } + return applyPresetContent(sandboxName, presetName, presetContent, options); +} + +/** + * Load a user-authored preset YAML from an arbitrary path on disk, validate + * its shape, and return `{ presetName, content }` for use with + * `applyPresetContent`. Returns `null` (and logs a specific error) for any + * of: missing/non-file path, non-`.yaml`/`.yml` extension, invalid YAML, + * missing or malformed `preset.name`, missing `network_policies` object, or + * a name collision with a built-in preset (built-ins must be addressed by + * their own name, so the custom file must be renamed). + */ +function loadPresetFromFile(filePath) { + const abs = path.resolve(filePath); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { + console.error(` Preset file not found: ${filePath}`); + return null; + } + if (!/\.ya?ml$/i.test(abs)) { + console.error(` Preset file must be .yaml or .yml: ${filePath}`); + return null; + } + const content = fs.readFileSync(abs, "utf-8"); + let parsed; + try { + parsed = YAML.parse(content); + } catch (err) { + console.error(` Invalid YAML in ${filePath}: ${err.message}`); + return null; + } + const presetName = parsed?.preset?.name; + if (typeof presetName !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(presetName)) { + console.error( + ` Preset must declare preset.name (lowercase, hyphenated RFC 1123 label): ${filePath}`, + ); + return null; + } + if ( + !parsed?.network_policies || + typeof parsed.network_policies !== "object" || + Array.isArray(parsed.network_policies) + ) { + console.error(` Preset missing network_policies section: ${filePath}`); + return null; + } + const builtin = listPresets().map((p) => p.name); + if (builtin.includes(presetName)) { + console.error( + ` Preset name '${presetName}' collides with a built-in preset. Rename 'preset.name' in ${filePath}.`, + ); + return null; + } + return { presetName, content }; +} + +/** + * Return the list of preset names currently recorded as applied to the + * sandbox, or an empty array if the sandbox is not tracked in the registry. + */ function getAppliedPresets(sandboxName) { const sandbox = registry.getSandbox(sandboxName); return sandbox ? sandbox.policies || [] : []; @@ -543,6 +637,11 @@ function getGatewayPresets(sandboxName) { return matched; } +/** + * Interactive preset picker for the `policy-add` command. Prints the + * presets on stderr (● applied, ○ not applied), prompts for a number, and + * resolves to the chosen preset name or `null` on cancel. + */ function selectFromList(items, { applied = [] } = {}) { return new Promise((resolve) => { process.stderr.write("\n Available presets:\n"); @@ -597,6 +696,11 @@ const PERMISSIVE_POLICY_PATH = path.join( "openclaw-sandbox-permissive.yaml", ); +/** + * Resolve the on-disk path to the permissive policy YAML for the given + * sandbox, honoring the agent-specific override registered in + * `agent-defs.ts`. Returns `null` if no permissive policy is configured. + */ function resolvePermissivePolicyPath(sandboxName) { // Use agent-specific permissive policy if the sandbox has an agent with one. try { @@ -652,6 +756,8 @@ export { mergePresetIntoPolicy, removePresetFromPolicy, applyPreset, + applyPresetContent, + loadPresetFromFile, removePreset, applyPermissivePolicy, getAppliedPresets, diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 29337185c14..bce7846135c 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1147,10 +1147,7 @@ function backfillAndFindOverlaps() { // Non-critical path: status must remain usable even if the gateway probe or // registry write throws, so any failure yields an empty overlap list. try { - const { - backfillMessagingChannels, - findAllOverlaps, - } = require("./lib/messaging-conflict"); + const { backfillMessagingChannels, findAllOverlaps } = require("./lib/messaging-conflict"); backfillMessagingChannels(registry, makeConflictProbe()); return findAllOverlaps(registry); } catch { @@ -1643,10 +1640,72 @@ function sandboxLogs(sandboxName, follow) { exitWithSpawnResult(result); } +/** + * Handle `nemoclaw policy-add [flags]`. Supports three mutually + * exclusive modes: interactive preset picker (default), `--from-file ` + * for a single custom preset YAML, and `--from-dir ` for every + * `.yaml`/`.yml` file in a directory. `--dry-run` previews without applying, + * `--yes`/`-y` (or `NEMOCLAW_NON_INTERACTIVE=1`) skips the confirmation + * prompt. `--from-dir` applies files in lexicographic order and aborts at + * the first failure (already-applied presets are not rolled back). + */ async function sandboxPolicyAdd(sandboxName, args = []) { const dryRun = args.includes("--dry-run"); const skipConfirm = - args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + args.includes("--yes") || + args.includes("-y") || + args.includes("--force") || + process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + + const fromFileIdx = args.indexOf("--from-file"); + const fromDirIdx = args.indexOf("--from-dir"); + + if (fromFileIdx >= 0 && fromDirIdx >= 0) { + console.error(" --from-file and --from-dir are mutually exclusive."); + process.exit(1); + } + + if (fromFileIdx >= 0) { + const filePath = args[fromFileIdx + 1]; + if (!filePath || filePath.startsWith("--")) { + console.error(" --from-file requires a path argument."); + process.exit(1); + } + const ok = await applyExternalPreset(sandboxName, filePath, { dryRun, yes: skipConfirm }); + if (!ok) process.exit(1); + return; + } + + if (fromDirIdx >= 0) { + const dirPath = args[fromDirIdx + 1]; + if (!dirPath || dirPath.startsWith("--")) { + console.error(" --from-dir requires a directory path."); + process.exit(1); + } + const absDir = path.resolve(dirPath); + if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) { + console.error(` Directory not found: ${dirPath}`); + process.exit(1); + } + const files = fs + .readdirSync(absDir) + .filter((f) => /\.ya?ml$/i.test(f)) + .map((f) => path.join(absDir, f)) + .sort(); + if (files.length === 0) { + console.error(` No .yaml/.yml preset files in ${dirPath}`); + process.exit(1); + } + for (const f of files) { + const ok = await applyExternalPreset(sandboxName, f, { dryRun, yes: skipConfirm }); + if (!ok) { + console.error(` Aborting --from-dir: ${f} failed. Remaining presets not applied.`); + process.exit(1); + } + } + return; + } + const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -1690,12 +1749,48 @@ async function sandboxPolicyAdd(sandboxName, args = []) { if (!skipConfirm) { const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); - if (confirm.toLowerCase() === "n") return; + if (confirm.trim().toLowerCase().startsWith("n")) return; } policies.applyPreset(sandboxName, answer); } +/** + * Apply one custom preset file (`--from-file`, or one entry of `--from-dir`) + * to a sandbox. Loads and validates the file via `policies.loadPresetFromFile`, + * prints the egress endpoints with a warning that custom targets are not + * vetted, honors `dryRun` and `yes`, and delegates to + * `policies.applyPresetContent`. Returns `true` on success, `false` on any + * load/apply failure so the caller can decide whether to abort. + */ +async function applyExternalPreset(sandboxName, filePath, { dryRun, yes }) { + const loaded = policies.loadPresetFromFile(filePath); + if (!loaded) return false; + + const endpoints = policies.getPresetEndpoints(loaded.content); + if (endpoints.length > 0) { + console.log(` [${loaded.presetName}] Endpoints that would be opened: ${endpoints.join(", ")}`); + console.log( + ` ${YW}Warning: custom preset targets are not vetted. Review hosts before applying.${R}`, + ); + } + + if (dryRun) { + console.log(` --dry-run: '${loaded.presetName}' not applied.`); + return true; + } + + if (!yes) { + const confirm = await askPrompt( + ` Apply '${loaded.presetName}' from ${filePath} to sandbox '${sandboxName}'? [Y/n]: `, + ); + if (confirm.trim().toLowerCase().startsWith("n")) return true; // user-cancel counts as success (no abort) + } + + const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content); + return result !== false; +} + function sandboxPolicyList(sandboxName) { const allPresets = policies.listPresets(); const registryPresets = policies.getAppliedPresets(sandboxName); @@ -2985,6 +3080,8 @@ function help() { ${G}Policy Presets:${R} nemoclaw policy-add [preset] Add a network or filesystem policy preset ${D}(--yes, --dry-run)${R} + ${D}--from-file apply a custom preset YAML${R} + ${D}--from-dir apply every .yaml preset in a directory${R} nemoclaw policy-remove [preset] Remove an applied policy preset ${D}(--yes, --dry-run)${R} nemoclaw policy-list List presets ${D}(● = applied)${R} @@ -3223,7 +3320,9 @@ const [cmd, ...args] = process.argv.slice(2); opts.reason = shieldsFlags[++i]; } else if (shieldsFlags[i] === "--policy") { if (i + 1 >= shieldsFlags.length || shieldsFlags[i + 1].startsWith("--")) { - console.error(" --policy requires a value (e.g. permissive, /path/to/policy.yaml)"); + console.error( + " --policy requires a value (e.g. permissive, /path/to/policy.yaml)", + ); process.exit(1); } opts.policy = shieldsFlags[++i]; diff --git a/test/policies.test.ts b/test/policies.test.ts index e81531fd58e..425427380e7 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -277,7 +277,15 @@ describe("policies", () => { describe("buildPolicySetCommand", () => { it("returns an argv array with sandbox name as a separate element", () => { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - expect(cmd).toEqual(["openshell", "policy", "set", "--policy", "/tmp/policy.yaml", "--wait", "my-assistant"]); + expect(cmd).toEqual([ + "openshell", + "policy", + "set", + "--policy", + "/tmp/policy.yaml", + "--wait", + "my-assistant", + ]); }); it("preserves shell metacharacters literally in sandbox name (no injection)", () => { @@ -297,7 +305,15 @@ describe("policies", () => { process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; try { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - expect(cmd).toEqual(["/tmp/fake path/openshell", "policy", "set", "--policy", "/tmp/policy.yaml", "--wait", "my-assistant"]); + expect(cmd).toEqual([ + "/tmp/fake path/openshell", + "policy", + "set", + "--policy", + "/tmp/policy.yaml", + "--wait", + "my-assistant", + ]); } finally { delete process.env.NEMOCLAW_OPENSHELL_BIN; } @@ -782,8 +798,7 @@ describe("policies", () => { }); it("returns policy unchanged when network_policies is a legacy array", () => { - const current = - "version: 1\n\nnetwork_policies:\n - host: pypi.org\n allow: true\n"; + const current = "version: 1\n\nnetwork_policies:\n - host: pypi.org\n allow: true\n"; const result = policies.removePresetFromPolicy(current, pypiEntries); expect(result).toContain("pypi.org"); expect(result).toContain("allow: true"); @@ -1059,4 +1074,282 @@ setImmediate(() => { expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/); }); }); + + describe("loadPresetFromFile", () => { + function writeTmp(body, ext = "yaml") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preset-")); + const file = path.join(dir, `custom.${ext}`); + fs.writeFileSync(file, body); + return { dir, file }; + } + + it("loads a valid custom preset and returns its declared name", () => { + const body = [ + "preset:", + " name: custom-rule", + " description: custom", + "network_policies:", + " custom-rule:", + " name: custom-rule", + " endpoints:", + " - host: custom.example.com", + " port: 443", + ].join("\n"); + const { file } = writeTmp(body); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const loaded = policies.loadPresetFromFile(file); + expect(loaded).toBeTruthy(); + expect(loaded.presetName).toBe("custom-rule"); + expect(loaded.content).toContain("custom.example.com"); + } finally { + errSpy.mockRestore(); + } + }); + + it("returns null when the file does not exist", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile("/definitely/not/a/file.yaml")).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect(msgs.some((m) => typeof m === "string" && m.includes("not found"))).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects non-yaml file extensions", () => { + const { file } = writeTmp("preset:\n name: ok\nnetwork_policies:\n r: {}", "txt"); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect(msgs.some((m) => typeof m === "string" && m.includes(".yaml or .yml"))).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects invalid YAML", () => { + const { file } = writeTmp(": : :\nfoo: [unclosed"); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect(msgs.some((m) => typeof m === "string" && m.includes("Invalid YAML"))).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects preset missing preset.name", () => { + const body = "preset:\n description: no name\nnetwork_policies:\n r:\n name: r\n"; + const { file } = writeTmp(body); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect( + msgs.some((m) => typeof m === "string" && m.includes("must declare preset.name")), + ).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects preset.name that is not an RFC 1123 label", () => { + const body = "preset:\n name: Has_Underscore\nnetwork_policies:\n r:\n name: r\n"; + const { file } = writeTmp(body); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects preset missing network_policies", () => { + const body = "preset:\n name: ok\n"; + const { file } = writeTmp(body); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect( + msgs.some((m) => typeof m === "string" && m.includes("missing network_policies")), + ).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + + it("rejects a preset name that collides with a built-in", () => { + const body = "preset:\n name: slack\nnetwork_policies:\n r:\n name: r\n"; + const { file } = writeTmp(body); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(policies.loadPresetFromFile(file)).toBe(null); + const msgs = errSpy.mock.calls.map((c) => c[0]); + expect( + msgs.some((m) => typeof m === "string" && m.includes("collides with a built-in")), + ).toBe(true); + } finally { + errSpy.mockRestore(); + } + }); + }); + + describe("policy-add --from-file / --from-dir", () => { + function runPolicyAddExternal(extraArgs = [], envOverrides = {}, promptAnswer = "y") { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-external-")); + const scriptPath = path.join(tmpDir, "policy-add-external.js"); + const script = String.raw` +const registry = require(${POLICIES_PATH.replace("policies.js", "registry.js")}); +const policies = require(${POLICIES_PATH}); +const credentials = require(${CREDENTIALS_PATH}); +const calls = []; +policies.selectFromList = async () => null; +policies.listPresets = () => []; +policies.getAppliedPresets = () => []; +policies.loadPresetFromFile = (p) => { + calls.push({ type: "load", path: p }); + if (String(p).includes("bad")) return null; + const m = String(p).match(/([a-z0-9-]+)\.yaml$/); + const name = m ? m[1] : "unknown"; + return { presetName: name, content: "network_policies:\n " + name + ":\n host: " + name + ".example.com\n" }; +}; +policies.applyPresetContent = (sandboxName, presetName) => { + calls.push({ type: "apply", sandboxName, presetName }); + return true; +}; +policies.getPresetEndpoints = (content) => { + const m = String(content).match(/host:\s*([^\s]+)/); + return m ? [m[1]] : []; +}; +credentials.prompt = async (message) => { + calls.push({ type: "prompt", message }); + return ${JSON.stringify(promptAnswer)}; +}; +registry.getSandbox = (name) => (name === "test-sandbox" ? { name } : null); +registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); +process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add", ...${JSON.stringify(extraArgs)}]; +require(${CLI_PATH}); +setImmediate(() => { + process.stdout.write("\n__CALLS__" + JSON.stringify(calls)); +}); +`; + fs.writeFileSync(scriptPath, script); + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, ...envOverrides }, + }); + } + + it("applies a custom preset when --from-file and --yes are provided", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-")); + const file = path.join(tmp, "custom-rule.yaml"); + fs.writeFileSync( + file, + "preset:\n name: custom-rule\nnetwork_policies:\n custom-rule:\n name: r\n", + ); + const result = runPolicyAddExternal(["--from-file", file, "--yes"]); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls).toContainEqual({ type: "load", path: file }); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "custom-rule", + }); + expect(calls.some((c) => c.type === "prompt")).toBeFalsy(); + }); + + it("exits non-zero when --from-file points to an unreadable preset", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-bad-")); + const file = path.join(tmp, "bad.yaml"); + fs.writeFileSync(file, "preset:\n name: ignored\n"); + const result = runPolicyAddExternal(["--from-file", file, "--yes"]); + expect(result.status).not.toBe(0); + }); + + it("does not apply and does not prompt under --from-file --dry-run", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-dry-")); + const file = path.join(tmp, "custom-rule.yaml"); + fs.writeFileSync(file, "preset:\n name: custom-rule\nnetwork_policies: {}\n"); + const result = runPolicyAddExternal(["--from-file", file, "--dry-run", "--yes"]); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((c) => c.type === "apply")).toBeFalsy(); + expect(calls.some((c) => c.type === "prompt")).toBeFalsy(); + expect(result.stdout).toMatch(/--dry-run: 'custom-rule' not applied\./); + }); + + it("skips the confirmation prompt when NEMOCLAW_NON_INTERACTIVE=1", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-env-")); + const file = path.join(tmp, "custom-rule.yaml"); + fs.writeFileSync(file, "preset:\n name: custom-rule\nnetwork_policies: {}\n"); + const result = runPolicyAddExternal(["--from-file", file], { NEMOCLAW_NON_INTERACTIVE: "1" }); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((c) => c.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "custom-rule", + }); + }); + + it("does not apply an external preset when the confirmation prompt is declined", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-no-")); + const file = path.join(tmp, "custom-rule.yaml"); + fs.writeFileSync(file, "preset:\n name: custom-rule\nnetwork_policies: {}\n"); + const result = runPolicyAddExternal(["--from-file", file], {}, "no"); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((c) => c.type === "prompt")).toBeTruthy(); + expect(calls.some((c) => c.type === "apply")).toBeFalsy(); + }); + + it("errors when --from-file and --from-dir are combined", () => { + const result = runPolicyAddExternal(["--from-file", "a.yaml", "--from-dir", "b"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/mutually exclusive/); + }); + + it("errors when --from-file is missing its path argument", () => { + const result = runPolicyAddExternal(["--from-file"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/--from-file requires a path argument/); + }); + + it("applies every preset in --from-dir in sorted order and aborts on the first failure", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-dir-")); + fs.writeFileSync( + path.join(dir, "a-good.yaml"), + "preset:\n name: a-good\nnetwork_policies: {}\n", + ); + fs.writeFileSync( + path.join(dir, "b-bad.yaml"), + "preset:\n name: b-bad\nnetwork_policies: {}\n", + ); + fs.writeFileSync( + path.join(dir, "c-skipped.yaml"), + "preset:\n name: c-skipped\nnetwork_policies: {}\n", + ); + const result = runPolicyAddExternal(["--from-dir", dir, "--yes"]); + expect(result.status).not.toBe(0); + // a-good succeeded (visible as the [a-good] endpoints log), b-bad triggered abort, + // c-skipped was never loaded because the loop stopped at b-bad. + expect(result.stdout).toMatch(/\[a-good\] Endpoints that would be opened/); + expect(result.stdout).not.toMatch(/\[c-skipped\]/); + expect(result.stderr).toMatch(/Aborting --from-dir/); + }); + + it("errors when --from-dir points at a non-directory", () => { + const result = runPolicyAddExternal(["--from-dir", "/does/not/exist"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Directory not found/); + }); + }); }); From 5f4ef0fbf1e61ded98f0cfcb988e0937665d6ef4 Mon Sep 17 00:00:00 2001 From: Kim Yang Date: Thu, 23 Apr 2026 15:46:27 +0800 Subject: [PATCH 2/4] fix(policy): harden preset file read and confirm parsing Wrap fs.readFileSync in loadPresetFromFile in a try block so a TOCTOU read failure between existsSync and readFileSync follows the same graceful null-return path as YAML parse errors. Normalize the policy-remove confirmation prompt to match policy-add (trim + startsWith("n")) so typing "no" actually declines. Addresses CodeRabbit feedback on PR #2077. --- src/lib/policies.ts | 9 +++++++-- src/nemoclaw.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/policies.ts b/src/lib/policies.ts index 0edcd1b0a18..581be71181d 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -526,12 +526,17 @@ function loadPresetFromFile(filePath) { console.error(` Preset file must be .yaml or .yml: ${filePath}`); return null; } - const content = fs.readFileSync(abs, "utf-8"); + let content; let parsed; try { + content = fs.readFileSync(abs, "utf-8"); parsed = YAML.parse(content); } catch (err) { - console.error(` Invalid YAML in ${filePath}: ${err.message}`); + const msg = + err.code === "ENOENT" || err.code === "EACCES" + ? `Cannot read ${filePath}: ${err.message}` + : `Invalid YAML in ${filePath}: ${err.message}`; + console.error(` ${msg}`); return null; } const presetName = parsed?.preset?.name; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index bce7846135c..6bfa7360cd1 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -2194,7 +2194,7 @@ async function sandboxPolicyRemove(sandboxName, args = []) { if (!skipConfirm) { const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `); - if (confirm.toLowerCase() === "n") return; + if (confirm.trim().toLowerCase().startsWith("n")) return; } if (!policies.removePreset(sandboxName, answer)) { From 59dc4d251d71ff45ea1510f4d4830fc08ec4ad91 Mon Sep 17 00:00:00 2001 From: Kim Yang Date: Sat, 25 Apr 2026 23:43:56 +0800 Subject: [PATCH 3/4] fix(policy): harden --from-dir filtering and external-preset error path Address CodeRabbit review on #2077: - --from-dir now enumerates with withFileTypes and rejects entries that are not regular files. Previously a sub-directory named 'archived.yaml' would be passed to applyExternalPreset and abort the entire batch even though no preset file was invalid. - applyExternalPreset wraps both loadPresetFromFile and applyPresetContent in try/catch. loadPresetFromFile still has a TOCTOU window around fs.existsSync, and applyPresetContent throws on invalid sandbox names or failed openshell policy-set; previously either would crash the CLI instead of returning false and letting the caller abort cleanly. - Add regression test: --from-dir skips a sub-directory whose name ends in .yaml so the real preset alongside it still applies. --- src/nemoclaw.ts | 25 +++++++++++++++++++------ test/policies.test.ts | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 19b71b30069..24f7701dd3e 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1855,9 +1855,9 @@ async function sandboxPolicyAdd(sandboxName: string, args: string[] = []): Promi process.exit(1); } const files = fs - .readdirSync(absDir) - .filter((f: string) => /\.ya?ml$/i.test(f)) - .map((f: string) => path.join(absDir, f)) + .readdirSync(absDir, { withFileTypes: true }) + .filter((ent: { name: string; isFile(): boolean }) => ent.isFile() && /\.ya?ml$/i.test(ent.name)) + .map((ent: { name: string }) => path.join(absDir, ent.name)) .sort(); if (files.length === 0) { console.error(` No .yaml/.yml preset files in ${dirPath}`); @@ -1937,7 +1937,14 @@ async function applyExternalPreset( filePath: string, { dryRun, yes }: { dryRun: boolean; yes: boolean }, ): Promise { - const loaded = policies.loadPresetFromFile(filePath); + let loaded; + try { + loaded = policies.loadPresetFromFile(filePath); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(` Failed to load preset ${filePath}: ${message}`); + return false; + } if (!loaded) return false; const endpoints = policies.getPresetEndpoints(loaded.content); @@ -1960,8 +1967,14 @@ async function applyExternalPreset( if (confirm.trim().toLowerCase().startsWith("n")) return true; // user-cancel counts as success (no abort) } - const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content); - return result !== false; + try { + const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content); + return result !== false; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(` Failed to apply preset '${loaded.presetName}': ${message}`); + return false; + } } function sandboxPolicyList(sandboxName: string) { diff --git a/test/policies.test.ts b/test/policies.test.ts index 937a9fb4aca..d77d463b800 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1401,5 +1401,22 @@ setImmediate(() => { expect(result.status).not.toBe(0); expect(result.stderr).toMatch(/Directory not found/); }); + + it("--from-dir skips sub-directories whose names end in .yaml/.yml", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-dir-skipdir-")); + // A real preset file and a directory that happens to match the yaml glob. + fs.writeFileSync( + path.join(dir, "real.yaml"), + "preset:\n name: real\nnetwork_policies: {}\n", + ); + fs.mkdirSync(path.join(dir, "archived.yaml")); + const result = runPolicyAddExternal(["--from-dir", dir, "--yes"]); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; + // Only the real file should have been loaded. + const loads = calls.filter((c) => c.type === "load").map((c) => c.path); + expect(loads.length).toBe(1); + expect(loads[0]).toMatch(/real\.yaml$/); + }); }); }); From ec73dfbcf1a109ba7f9a287e2a30ab91e2aa845f Mon Sep 17 00:00:00 2001 From: Kim Yang Date: Sat, 25 Apr 2026 23:58:10 +0800 Subject: [PATCH 4/4] feat(policy): allow policy-remove to undo custom presets Address jyaunches's blocker on #2077: custom presets applied via --from-file / --from-dir were written to the registry by name only, so policy-remove failed because loadPreset() only reads PRESETS_DIR. This was a one-way door. Fix: - registry: add SandboxEntry.customPolicies (array of { name, content, sourcePath, appliedAt }), plus getCustomPolicies / addCustomPolicy / removeCustomPolicyByName helpers. Built-in preset names continue to live in SandboxEntry.policies, so old registries keep working. - applyPresetContent: when called with options.custom, persist the full YAML in customPolicies instead of appending to policies. External preset apply path now passes { custom: { sourcePath } }. - removePreset: resolve preset content from built-ins first, then from customPolicies; prune the correct registry bucket on success. - policy-remove: accept custom-preset names as valid targets, add -y alias, show custom presets in the interactive selector, preview endpoints from persisted content. - policy-list: mix built-in and custom presets when listing. - command-registry: advertise -y on both add/remove, mention custom presets on remove. - docs: MyST ::: warning block (was GH > [!WARNING]) and a new 'Remove a Custom Preset' section. - tests: registry unit tests for the new helpers and a policy-remove custom-preset round-trip in policies.test.ts. --- .../customize-network-policy.md | 17 +++- src/lib/command-registry.ts | 6 +- src/lib/policies.ts | 78 ++++++++++++++++--- src/lib/registry.ts | 43 ++++++++++ src/nemoclaw.ts | 32 ++++++-- test/policies.test.ts | 78 +++++++++++++++++++ test/registry.test.ts | 46 +++++++++++ 7 files changed, 277 insertions(+), 23 deletions(-) diff --git a/docs/network-policy/customize-network-policy.md b/docs/network-policy/customize-network-policy.md index 6d9202a1b92..171cdc85739 100644 --- a/docs/network-policy/customize-network-policy.md +++ b/docs/network-policy/customize-network-policy.md @@ -278,9 +278,20 @@ Files are processed in lexicographic order. Processing stops at the first failure; presets already applied are not rolled back. Fix the failing file and re-run the command to continue. -> [!WARNING] -> Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations. -> Review every host in a custom preset before applying it, especially when the file originates outside your team. +:::{warning} +Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations. +Review every host in a custom preset before applying it, especially when the file originates outside your team. +::: + +### Remove a Custom Preset + +Custom presets applied with `--from-file` or `--from-dir` are recorded in the NemoClaw sandbox registry alongside their full YAML content, so they can be removed by name — the original file does not need to be kept on disk: + +```console +$ nemoclaw my-assistant policy-remove my-internal-api --yes +``` + +`policy-remove` accepts both built-in and custom preset names. Run `nemoclaw policy-list` to see every preset currently applied to the sandbox. ## Related Topics diff --git a/src/lib/command-registry.ts b/src/lib/command-registry.ts index b12ea206036..b57698b1f8c 100644 --- a/src/lib/command-registry.ts +++ b/src/lib/command-registry.ts @@ -150,14 +150,14 @@ export const COMMANDS: readonly CommandDef[] = [ { usage: "nemoclaw policy-add", description: "Add a network or filesystem policy preset", - flags: "(--yes, --dry-run, --from-file , --from-dir )", + flags: "(--yes, -y, --dry-run, --from-file , --from-dir )", group: "Policy Presets", scope: "sandbox", }, { usage: "nemoclaw policy-remove", - description: "Remove an applied policy preset", - flags: "(--yes, --dry-run)", + description: "Remove an applied policy preset (built-in or custom)", + flags: "(--yes, -y, --dry-run)", group: "Policy Presets", scope: "sandbox", }, diff --git a/src/lib/policies.ts b/src/lib/policies.ts index 2095c294348..9dea5b09907 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -316,8 +316,11 @@ function removePresetFromPolicy( /** * Remove a previously-applied preset from the running sandbox policy and - * delete its name from the registry entry. Returns `false` if the preset is - * unknown or has no `network_policies` section. + * delete its name from the registry entry. Resolves the preset's content + * from the built-in presets directory first, then from the registry's + * `customPolicies` list for presets applied via `--from-file`/`--from-dir`. + * Returns `false` if the preset is unknown or has no `network_policies` + * section. */ function removePreset(sandboxName: string, presetName: string): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -330,7 +333,20 @@ function removePreset(sandboxName: string, presetName: string): boolean { ); } - const presetContent = loadPreset(presetName); + // Resolve preset content: built-in first, then custom presets persisted + // in the registry. `isCustom` controls which registry bucket to prune on + // success. + let presetContent: string | null = loadPreset(presetName); + let isCustom = false; + if (!presetContent) { + const custom = registry + .getCustomPolicies(sandboxName) + .find((p: { name: string }) => p.name === presetName); + if (custom) { + presetContent = custom.content; + isCustom = true; + } + } if (!presetContent) { console.error(` Cannot load preset: ${presetName}`); return false; @@ -390,8 +406,12 @@ function removePreset(sandboxName: string, presetName: string): boolean { const sandbox = registry.getSandbox(sandboxName); if (sandbox) { - const pols = (sandbox.policies || []).filter((p: string) => p !== presetName); - registry.updateSandbox(sandboxName, { policies: pols }); + if (isCustom) { + registry.removeCustomPolicyByName(sandboxName, presetName); + } else { + const pols = (sandbox.policies || []).filter((p: string) => p !== presetName); + registry.updateSandbox(sandboxName, { policies: pols }); + } } return true; @@ -456,12 +476,16 @@ function selectForRemoval( * and records the preset name in the registry. Returns `false` if the content * has no `network_policies` section. Used by both `applyPreset` (built-in * presets) and the `--from-file` / `--from-dir` paths (custom preset files). + * + * When `options.custom` is set, the preset content is also persisted under + * `customPolicies` in the registry so `removePreset` can later undo a + * custom preset purely by name. */ function applyPresetContent( sandboxName: string, presetName: string, presetContent: string, - _options: Record = {}, + options: { custom?: { sourcePath?: string } } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -518,11 +542,21 @@ function applyPresetContent( const sandbox = registry.getSandbox(sandboxName); if (sandbox) { - const pols = sandbox.policies || []; - if (!pols.includes(presetName)) { - pols.push(presetName); + if (options.custom) { + // Custom preset: persist full content so it can be removed later + // without requiring the user to still have the file on disk. + registry.addCustomPolicy(sandboxName, { + name: presetName, + content: presetContent, + sourcePath: options.custom.sourcePath, + }); + } else { + const pols = sandbox.policies || []; + if (!pols.includes(presetName)) { + pols.push(presetName); + } + registry.updateSandbox(sandboxName, { policies: pols }); } - registry.updateSandbox(sandboxName, { policies: pols }); } return true; @@ -615,11 +649,30 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st /** * Return the list of preset names currently recorded as applied to the - * sandbox, or an empty array if the sandbox is not tracked in the registry. + * sandbox (both built-in names and custom-preset names), or an empty array + * if the sandbox is not tracked in the registry. */ function getAppliedPresets(sandboxName: string): string[] { const sandbox = registry.getSandbox(sandboxName); - return sandbox ? sandbox.policies || [] : []; + if (!sandbox) return []; + const builtin = sandbox.policies || []; + const custom = (sandbox.customPolicies || []).map((p: { name: string }) => p.name); + return [...builtin, ...custom]; +} + +/** + * Return the custom preset entries recorded on the sandbox as + * `PresetInfo`-shaped objects, so they can be mixed with built-in presets + * in listing / selection UIs. `file` is populated from `sourcePath` when + * available for a user hint; `description` is empty. + */ +function listCustomPresets(sandboxName: string): PresetInfo[] { + const entries = registry.getCustomPolicies(sandboxName); + return entries.map((e: { name: string; sourcePath?: string }) => ({ + file: e.sourcePath || `${e.name}.yaml`, + name: e.name, + description: "custom preset", + })); } /** @@ -819,6 +872,7 @@ export { applyPermissivePolicy, getAppliedPresets, getGatewayPresets, + listCustomPresets, selectFromList, selectForRemoval, }; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index edb0ba0550b..be5561face8 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -7,6 +7,13 @@ import path from "node:path"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; import { isErrnoException } from "./errno"; +export interface CustomPolicyEntry { + name: string; + content: string; + sourcePath?: string; + appliedAt?: string; +} + export interface SandboxEntry { name: string; createdAt?: string; @@ -15,6 +22,7 @@ export interface SandboxEntry { provider?: string | null; gpuEnabled?: boolean; policies?: string[]; + customPolicies?: CustomPolicyEntry[]; policyTier?: string | null; agent?: string | null; dangerouslySkipPermissions?: boolean; @@ -252,6 +260,41 @@ export function clearAll(): void { }); } +/** Return the list of custom policy entries recorded for a sandbox (never null). */ +export function getCustomPolicies(name: string): CustomPolicyEntry[] { + const data = load(); + return data.sandboxes[name]?.customPolicies ?? []; +} + +/** Upsert a custom policy by name. Replaces any existing entry with the same name. */ +export function addCustomPolicy(name: string, entry: CustomPolicyEntry): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox) return false; + const list = (sandbox.customPolicies ?? []).filter((p) => p.name !== entry.name); + list.push({ ...entry, appliedAt: entry.appliedAt ?? new Date().toISOString() }); + sandbox.customPolicies = list; + save(data); + return true; + }); +} + +/** Remove a custom policy by name. Returns true if an entry was removed. */ +export function removeCustomPolicyByName(name: string, presetName: string): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox) return false; + const list = sandbox.customPolicies ?? []; + const next = list.filter((p) => p.name !== presetName); + if (next.length === list.length) return false; + sandbox.customPolicies = next.length > 0 ? next : undefined; + save(data); + return true; + }); +} + export function getDisabledChannels(name: string): string[] { const data = load(); return data.sandboxes[name]?.disabledChannels ?? []; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 24f7701dd3e..b76bb96c57a 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1968,7 +1968,9 @@ async function applyExternalPreset( } try { - const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content); + const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content, { + custom: { sourcePath: path.resolve(filePath) }, + }); return result !== false; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -1978,7 +1980,9 @@ async function applyExternalPreset( } function sandboxPolicyList(sandboxName: string) { - const allPresets = policies.listPresets(); + const builtin = policies.listPresets(); + const custom = policies.listCustomPresets(sandboxName); + const allPresets = [...builtin, ...custom]; const registryPresets = policies.getAppliedPresets(sandboxName); // getGatewayPresets returns null when gateway is unreachable, or an @@ -2338,9 +2342,15 @@ async function sandboxPolicyRemove(sandboxName: string, args: string[] = []): Pr const dryRun = args.includes("--dry-run"); const skipConfirm = args.includes("--yes") || + args.includes("-y") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; - const allPresets = policies.listPresets(); + + // Remove-able presets = built-in presets + custom presets applied via + // --from-file / --from-dir (tracked in registry.customPolicies). + const builtinPresets = policies.listPresets(); + const customPresets = policies.listCustomPresets(sandboxName); + const allPresets = [...builtinPresets, ...customPresets]; const applied = policies.getAppliedPresets(sandboxName); const presetArg = args.find((arg) => !arg.startsWith("-")); @@ -2351,7 +2361,7 @@ async function sandboxPolicyRemove(sandboxName: string, args: string[] = []): Pr if (!preset) { console.error(` Unknown preset '${presetArg}'.`); console.error( - ` Valid presets: ${allPresets.map((item: { name: string }) => item.name).join(", ")}`, + ` Valid presets: ${allPresets.map((item: { name: string }) => item.name).join(", ") || "(none)"}`, ); process.exit(1); } @@ -2370,7 +2380,19 @@ async function sandboxPolicyRemove(sandboxName: string, args: string[] = []): Pr } if (!answer) return; - const presetContent = policies.loadPreset(answer); + // Resolve preset content: built-in first, then custom (persisted in + // registry). Needed only for the endpoint preview below — removePreset() + // itself re-resolves on the library side. + let presetContent: string | null = policies.loadPreset(answer); + if (!presetContent) { + const entry = customPresets.find((p: { name: string }) => p.name === answer); + if (entry) { + const persisted = registry + .getCustomPolicies(sandboxName) + .find((p: { name: string }) => p.name === answer); + presetContent = persisted ? persisted.content : null; + } + } if (!presetContent) return; const endpoints = policies.getPresetEndpoints(presetContent); diff --git a/test/policies.test.ts b/test/policies.test.ts index d77d463b800..f2cfb8afe36 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1021,6 +1021,7 @@ policies.listPresets = () => [ { name: "npm", description: "npm and Yarn registry access" }, { name: "pypi", description: "Python Package Index (PyPI) access" }, ]; +policies.listCustomPresets = () => []; policies.getAppliedPresets = () => ["pypi"]; policies.removePreset = (sandboxName, presetName) => { calls.push({ type: "remove", sandboxName, presetName }); @@ -1119,6 +1120,83 @@ setImmediate(() => { /Non-interactive mode requires a preset name/, ); }); + + it("accepts -y as an alias for --yes", () => { + const result = runPolicyRemove("n", ["pypi", "-y"]); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "remove", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + }); + + describe("policy-remove custom presets", () => { + function runPolicyRemoveCustom( + presetName: string, + extraArgs: string[] = [], + envOverrides: Record = {}, + ) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-custom-")); + const scriptPath = path.join(tmpDir, "policy-remove-custom-check.js"); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +const credentials = require(${CREDENTIALS_PATH}); +const calls = []; +// No built-in matches. +policies.listPresets = () => []; +policies.listCustomPresets = () => [ + { file: "/tmp/my-api.yaml", name: "my-api", description: "custom preset" }, +]; +policies.getAppliedPresets = () => ["my-api"]; +policies.loadPreset = () => null; // built-in lookup misses +policies.getPresetEndpoints = () => ["api.example.internal"]; +policies.removePreset = (sandboxName, presetName) => { + calls.push({ type: "remove", sandboxName, presetName }); + return true; +}; +registry.getSandbox = (name) => + name === "test-sandbox" ? { name, policies: [], customPolicies: [] } : null; +registry.getCustomPolicies = () => [ + { name: "my-api", content: "network_policies:\n my-api: {}\n", sourcePath: "/tmp/my-api.yaml" }, +]; +registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); +credentials.prompt = async () => "y"; +process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-remove", ${JSON.stringify(presetName)}, ...${JSON.stringify(extraArgs)}]; +require(${CLI_PATH}); +setImmediate(() => { + process.stdout.write("\n__CALLS__" + JSON.stringify(calls)); +}); +`; + fs.writeFileSync(scriptPath, script); + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, ...envOverrides }, + }); + } + + it("removes a custom preset by name using registry-persisted content", () => { + const result = runPolicyRemoveCustom("my-api", ["--yes"]); + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; + expect(calls).toContainEqual({ + type: "remove", + sandboxName: "test-sandbox", + presetName: "my-api", + }); + expect(result.stdout).toMatch(/api\.example\.internal/); + }); + + it("rejects an unknown preset name even when no built-ins are defined", () => { + const result = runPolicyRemoveCustom("bogus", ["--yes"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Unknown preset 'bogus'/); + }); }); describe("loadPresetFromFile", () => { diff --git a/test/registry.test.ts b/test/registry.test.ts index ddcfd4b89b4..3f9e9fc9fbd 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -218,6 +218,52 @@ describe("registry", () => { }); expect(registry.getDisabledChannels("s1")).toEqual(["telegram"]); }); + + it("addCustomPolicy persists name, content, and sourcePath", () => { + registry.registerSandbox({ name: "cp1" }); + const added = registry.addCustomPolicy("cp1", { + name: "my-api", + content: "preset:\n name: my-api\nnetwork_policies: {}\n", + sourcePath: "/tmp/my-api.yaml", + }); + expect(added).toBe(true); + const list = registry.getCustomPolicies("cp1"); + expect(list.length).toBe(1); + expect(list[0].name).toBe("my-api"); + expect(list[0].content).toMatch(/name: my-api/); + expect(list[0].sourcePath).toBe("/tmp/my-api.yaml"); + expect(typeof list[0].appliedAt).toBe("string"); + }); + + it("addCustomPolicy replaces an existing entry with the same name", () => { + registry.registerSandbox({ name: "cp2" }); + registry.addCustomPolicy("cp2", { name: "dup", content: "v1" }); + registry.addCustomPolicy("cp2", { name: "dup", content: "v2" }); + const list = registry.getCustomPolicies("cp2"); + expect(list.length).toBe(1); + expect(list[0].content).toBe("v2"); + }); + + it("removeCustomPolicyByName removes an entry and returns true", () => { + registry.registerSandbox({ name: "cp3" }); + registry.addCustomPolicy("cp3", { name: "a", content: "x" }); + registry.addCustomPolicy("cp3", { name: "b", content: "y" }); + expect(registry.removeCustomPolicyByName("cp3", "a")).toBe(true); + const list = registry.getCustomPolicies("cp3"); + expect(list.length).toBe(1); + expect(list[0].name).toBe("b"); + }); + + it("removeCustomPolicyByName returns false when the entry is missing", () => { + registry.registerSandbox({ name: "cp4" }); + expect(registry.removeCustomPolicyByName("cp4", "nope")).toBe(false); + }); + + it("getCustomPolicies returns [] for unknown or fresh sandboxes", () => { + expect(registry.getCustomPolicies("nonexistent")).toEqual([]); + registry.registerSandbox({ name: "cp5" }); + expect(registry.getCustomPolicies("cp5")).toEqual([]); + }); }); describe("atomic writes", () => {