diff --git a/.agents/skills/nemoclaw-user-manage-policy/SKILL.md b/.agents/skills/nemoclaw-user-manage-policy/SKILL.md index bd21391e4d..15cca8fb7b 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 ce5362360a..171cdc8573 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,68 @@ 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. +::: + +### 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 - [Approve or Deny Agent Network Requests](approve-network-requests.md) for real-time operator approval. diff --git a/src/lib/command-registry.ts b/src/lib/command-registry.ts index fa3e7c8720..b57698b1f8 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)", + 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 055b5b80c5..9dea5b0990 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -39,6 +39,11 @@ function isPolicyDocument(value: PolicyValue): value is PolicyDocument { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Enumerate every preset YAML under `nemoclaw-blueprint/policies/presets/` + * and return `{ file, name, description }` triples parsed from the file's + * `preset:` header. + */ function listPresets(): PresetInfo[] { if (!fs.existsSync(PRESETS_DIR)) return []; return fs @@ -56,6 +61,10 @@ function listPresets(): PresetInfo[] { }); } +/** + * 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: string): string | null { const file = path.resolve(PRESETS_DIR, `${name}.yaml`); if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { @@ -69,6 +78,11 @@ function loadPreset(name: string): string | null { 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: string): string[] { const hosts: string[] = []; const regex = /host:\s*([^\s,}]+)/g; @@ -300,6 +314,14 @@ function removePresetFromPolicy( return YAML.stringify(current); } +/** + * Remove a previously-applied preset from the running sandbox policy and + * 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 // names during argument parsing, e.g. "my-assistant" → "m" @@ -311,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; @@ -371,13 +406,22 @@ 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; } +/** + * 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: PresetInfo[], { applied = [] }: SelectionOptions = {}, @@ -425,7 +469,24 @@ function selectForRemoval( }); } -function applyPreset(sandboxName: string, presetName: string, _options = {}): boolean { +/** + * 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). + * + * 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: { custom?: { sourcePath?: string } } = {}, +): boolean { // 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); @@ -436,12 +497,6 @@ function applyPreset(sandboxName: string, presetName: string, _options = {}): bo ); } - 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.`); @@ -487,19 +542,137 @@ function applyPreset(sandboxName: string, presetName: string, _options = {}): bo 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; } +/** + * 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: string, + presetName: string, + options: Record = {}, +): boolean { + 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: string): { presetName: string; content: string } | null { + 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; + } + let content: string; + let parsed: PolicyValue; + try { + content = fs.readFileSync(abs, "utf-8"); + parsed = YAML.parse(content); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException)?.code; + const message = err instanceof Error ? err.message : String(err); + const msg = + code === "ENOENT" || code === "EACCES" + ? `Cannot read ${filePath}: ${message}` + : `Invalid YAML in ${filePath}: ${message}`; + console.error(` ${msg}`); + return null; + } + if (!isPolicyDocument(parsed)) { + console.error(` Preset must be a YAML mapping: ${filePath}`); + return null; + } + const presetMeta = parsed.preset; + const presetName = + presetMeta && typeof presetMeta === "object" && !Array.isArray(presetMeta) + ? (presetMeta as PolicyObject).name + : undefined; + 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 (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", + })); } /** @@ -571,6 +744,11 @@ function getGatewayPresets(sandboxName: string): string[] | null { 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: PresetInfo[], { applied = [] }: SelectionOptions = {}, @@ -628,6 +806,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: string): string { // Use agent-specific permissive policy if the sandbox has an agent with one. try { @@ -683,10 +866,13 @@ export { mergePresetIntoPolicy, removePresetFromPolicy, applyPreset, + applyPresetContent, + loadPresetFromFile, removePreset, applyPermissivePolicy, getAppliedPresets, getGatewayPresets, + listCustomPresets, selectFromList, selectForRemoval, }; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index edb0ba0550..be5561face 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 fdceef0579..0f3c15dffd 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1806,12 +1806,73 @@ function buildSandboxLogsArgs(sandboxName: string, follow: boolean): string[] { return args; } +/** + * 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`/`--force` (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: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const skipConfirm = 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, { 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}`); + 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); @@ -1857,14 +1918,71 @@ async function sandboxPolicyAdd(sandboxName: string, args: string[] = []): Promi 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: string, + filePath: string, + { dryRun, yes }: { dryRun: boolean; yes: boolean }, +): Promise { + 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); + 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) + } + + try { + 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); + console.error(` Failed to apply preset '${loaded.presetName}': ${message}`); + return false; + } +} + 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 @@ -2224,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("-")); @@ -2237,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); } @@ -2256,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); @@ -2271,7 +2407,7 @@ async function sandboxPolicyRemove(sandboxName: string, args: string[] = []): Pr 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)) { diff --git a/test/policies.test.ts b/test/policies.test.ts index ae902efbc1..f2cfb8afe3 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -24,6 +24,7 @@ type PolicyCall = { message?: string; sandboxName?: string; presetName?: string; + path?: string; }; type AppliedOptions = { @@ -923,7 +924,7 @@ selectForRemoval(items, options) const result = runPolicyAdd("y"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls).toContainEqual({ type: "prompt", message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", @@ -939,7 +940,7 @@ selectForRemoval(items, options) const result = runPolicyAdd("n"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls).toContainEqual({ type: "prompt", message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", @@ -951,7 +952,7 @@ selectForRemoval(items, options) const result = runPolicyAdd("y", ["--dry-run"]); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls.some((call: PolicyCall) => call.type === "apply")).toBeFalsy(); expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/); @@ -962,7 +963,7 @@ selectForRemoval(items, options) const result = runPolicyAdd("n", ["pypi", "--yes"]); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + 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: "apply", @@ -975,7 +976,7 @@ selectForRemoval(items, options) const result = runPolicyAdd("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" }); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + 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: "apply", @@ -1020,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 }); @@ -1049,7 +1051,7 @@ setImmediate(() => { const result = runPolicyRemove("y"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls).toContainEqual({ type: "prompt", message: " Remove 'pypi' from sandbox 'test-sandbox'? [Y/n]: ", @@ -1065,7 +1067,7 @@ setImmediate(() => { const result = runPolicyRemove("n"); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls).toContainEqual({ type: "prompt", message: " Remove 'pypi' from sandbox 'test-sandbox'? [Y/n]: ", @@ -1077,7 +1079,7 @@ setImmediate(() => { const result = runPolicyRemove("y", ["--dry-run"]); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls.some((call: PolicyCall) => call.type === "remove")).toBeFalsy(); expect(result.stdout).toMatch(/Endpoints that would be removed: pypi\.org/); @@ -1088,7 +1090,7 @@ setImmediate(() => { const result = runPolicyRemove("n", ["pypi", "--yes"]); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + 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", @@ -1101,7 +1103,7 @@ setImmediate(() => { const result = runPolicyRemove("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" }); expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + 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", @@ -1118,5 +1120,381 @@ 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", () => { + function writeTmp(body: string, 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: string[] = [], + envOverrides: Record = {}, + 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()) as PolicyCall[]; + 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()) as PolicyCall[]; + 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()) as PolicyCall[]; + 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()) as PolicyCall[]; + 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/); + }); + + 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$/); + }); }); }); diff --git a/test/registry.test.ts b/test/registry.test.ts index ddcfd4b89b..3f9e9fc9fb 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", () => {