diff --git a/docs/network-policy/apply-policy-presets.mdx b/docs/network-policy/apply-policy-presets.mdx index 1ebc10e6c03..b136dca0a0f 100644 --- a/docs/network-policy/apply-policy-presets.mdx +++ b/docs/network-policy/apply-policy-presets.mdx @@ -4,8 +4,8 @@ title: "Apply Policy Presets" sidebar-title: "Apply Policy Presets" description: "Add, reapply, list, or remove policy presets for a running NemoClaw sandbox." -description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, or removing access." -keywords: ["nemoclaw policy presets", "policy add", "policy remove"] +description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, removing access, or removing a preset the gateway enforces without a local record." +keywords: ["nemoclaw policy presets", "policy add", "policy remove", "active on gateway missing from local state"] content: type: "how_to" skill: @@ -111,6 +111,32 @@ $$nemoclaw my-assistant policy remove weather --yes `policy remove` accepts maintained and custom preset names. +## Remove a Preset the Gateway Enforces Without a Local Record + +`policy list` marks a preset that the OpenShell gateway enforces while no local record explains it: + +```bash +$$nemoclaw my-assistant policy list +``` + +Expected output: + +```text + ● github [source unverified] — GitHub.com and GitHub API access (git) (active on gateway, missing from local state) +``` + +`policy remove` accepts that preset: + +```bash +$$nemoclaw my-assistant policy remove github --yes +``` + +It narrows the live policy and clears whatever local record remains. + +When NemoClaw cannot reach the gateway, `policy remove ` has only the local record to check. +It refuses an unrecorded preset and reports that it could not query the gateway, rather than treating an unanswered query as absence. +The interactive picker lists the recorded presets only in that case. + ## Understand Persistence Dynamic changes apply to the current live policy. @@ -118,6 +144,11 @@ NemoClaw also records maintained presets and custom presets applied through `--f The custom preset record includes the full YAML content. Snapshot restore and rebuild replay the recorded presets, even when the original custom file no longer exists. +A sandbox that is absent from the local registry has nothing to record a preset against. +For a maintained preset, `policy add` still applies it to the gateway and warns that `policy list` reports it as active on gateway, missing from local state. +For a custom preset applied with `--from-file` or `--from-dir`, `policy add` reaches the gateway but exits non-zero, because a custom preset is discoverable only through the registry and would appear in neither `policy list` nor `status`. +Recover or re-onboard the sandbox to restore the record, then re-apply any custom preset that failed this way. + `$$nemoclaw rebuild` reapplies every recorded policy preset to the recreated sandbox. For baseline changes that apply to every future sandbox, follow [Change the Baseline Network Policy](change-baseline-network-policy). diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 2725c915eab..4f5b8e85154 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2510,6 +2510,8 @@ If the base policy read returns non-empty output that NemoClaw cannot parse, the Fix the gateway or policy read problem, then rerun the command. For custom presets, the command also reports when the preset reached the gateway but NemoClaw could not record it in the local sandbox registry, because unrecorded custom presets will not appear in `policy list` or `status`. Recover or re-onboard the sandbox, then re-apply the custom preset. +For built-in presets in that same case, the command applies the preset and returns success, because a built-in preset stays discoverable from the gateway. +It warns that `policy list` will report the preset as active on gateway, missing from local state. With `--from-file` or `--from-dir`, pass a repeatable `--trusted-private-host ` option to admit matching RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local endpoints. The option is invalid for built-in presets. @@ -2590,7 +2592,7 @@ $$nemoclaw my-assistant policy list ### `$$nemoclaw policy remove` Remove a previously applied policy preset from a sandbox. -The command lists only the presets currently applied, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress. +The command lists the presets the local registry records together with the presets the live gateway enforces, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress. ```bash $$nemoclaw my-assistant policy remove @@ -2604,7 +2606,9 @@ $$nemoclaw my-assistant policy remove pypi --yes Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`. Without a preset name, `policy remove` reports the same two picker errors as `policy add` and exits non-zero. -If the preset is unknown or not currently applied, the command exits non-zero with a clear error. +If the preset is unknown, or neither the local registry nor the live gateway holds it, the command exits non-zero with a clear error. +A preset the gateway enforces without a local registry record is removable, which is the state `policy list` reports as active on gateway, missing from local state. +When NemoClaw cannot query the gateway, the command checks the local registry alone; with a preset name it also reports that the gateway could not be queried. | Flag | Description | |------|-------------| diff --git a/src/lib/actions/sandbox/policy-channel-lock.test.ts b/src/lib/actions/sandbox/policy-channel-lock.test.ts index 9e61690e2ca..b685349f3e8 100644 --- a/src/lib/actions/sandbox/policy-channel-lock.test.ts +++ b/src/lib/actions/sandbox/policy-channel-lock.test.ts @@ -51,6 +51,7 @@ describe("policy and channel sandbox mutation locking", () => { ]); vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( (_sandboxName, presetName) => `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: example.com\n port: 443\n`, diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 29df1779853..147ab08ef0c 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -46,6 +46,7 @@ let exitSpy: MockInstance; let promptMock: MockInstance; let getSandboxMock: MockInstance; let getAppliedPresetsMock: MockInstance; +let getGatewayPresetsMock: MockInstance; let selectFromListMock: MockInstance; let selectForRemovalMock: MockInstance; let loadPresetForSandboxMock: MockInstance; @@ -108,6 +109,7 @@ beforeEach(() => { vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); getAppliedPresetsMock = vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + getGatewayPresetsMock = vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); selectFromListMock = vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi"); selectForRemovalMock = vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi"); vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { @@ -420,4 +422,59 @@ describe("removeSandboxPolicy", () => { expect(printedText()).toContain("No input available on stdin"); expect(removePresetMock).not.toHaveBeenCalled(); }); + + it("removes a preset the gateway enforces but the registry never recorded (#9295)", async () => { + getAppliedPresetsMock.mockReturnValue([]); + getGatewayPresetsMock.mockReturnValue(["npm"]); + + await removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true }); + + expect(removePresetMock).toHaveBeenCalledWith("test-sandbox", "npm"); + }); + + it("refuses a preset neither the registry nor the gateway holds (#9295)", async () => { + getAppliedPresetsMock.mockReturnValue([]); + getGatewayPresetsMock.mockReturnValue(["pypi"]); + + await expect( + captureExit(() => removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true })), + ).resolves.toBe(1); + + expect(printedText()).toContain("Preset 'npm' is not applied."); + expect(removePresetMock).not.toHaveBeenCalled(); + }); + + it("names the unreachable gateway when it refuses on local state alone (#9295)", async () => { + getAppliedPresetsMock.mockReturnValue([]); + getGatewayPresetsMock.mockReturnValue(null); + + await expect( + captureExit(() => removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true })), + ).resolves.toBe(1); + + expect(printedText()).toContain( + "Could not query the gateway, so only local state was checked.", + ); + expect(removePresetMock).not.toHaveBeenCalled(); + }); + + it("offers a gateway-only preset in the removal picker (#9295)", async () => { + getGatewayPresetsMock.mockReturnValue(["npm"]); + + await removeSandboxPolicy("test-sandbox"); + + expect(selectForRemovalMock).toHaveBeenCalledWith(POLICY_PRESETS, { + applied: ["pypi", "npm"], + }); + }); + + it("lists a preset both sources hold only once in the removal picker (#9295)", async () => { + getGatewayPresetsMock.mockReturnValue(["pypi", "npm"]); + + await removeSandboxPolicy("test-sandbox"); + + expect(selectForRemovalMock).toHaveBeenCalledWith(POLICY_PRESETS, { + applied: ["pypi", "npm"], + }); + }); }); diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 9610caf1b76..7a8d57c25a0 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -92,6 +92,7 @@ beforeEach(() => { vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi"); vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi"); vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 36904758281..546ba327b6f 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1981,7 +1981,15 @@ async function removeSandboxPolicyUnlocked( const builtinPresets = policies.listPresets(); const customPresets = policies.listCustomPresets(sandboxName); const allPresets = [...builtinPresets, ...customPresets]; + // `policy list` reports a preset as active when either the registry or the + // gateway holds it, so removal has to accept the same set. A preset the + // gateway enforces but the registry never recorded is exactly the state + // `policy list` flags as "active on gateway, missing from local state", and + // removePreset() reconciles it without needing the registry entry. Null means + // the gateway could not be queried, which is not evidence of absence. (#9295) const applied = policies.getAppliedPresets(sandboxName); + const gatewayPresets = policies.getGatewayPresets(sandboxName); + const removable = gatewayPresets ? [...new Set([...applied, ...gatewayPresets])] : applied; const presetArg = options.preset; let answer = null; @@ -1995,8 +2003,11 @@ async function removeSandboxPolicyUnlocked( ); process.exit(1); } - if (!applied.includes(preset.name)) { + if (!removable.includes(preset.name)) { console.error(` Preset '${preset.name}' is not applied.`); + if (gatewayPresets === null) { + console.error(" Could not query the gateway, so only local state was checked."); + } process.exit(1); } answer = preset.name; @@ -2009,7 +2020,7 @@ async function removeSandboxPolicyUnlocked( exitPromptStdinClosed(usage); } answer = await pickPresetOrExit( - () => policies.selectForRemoval(allPresets, { applied }), + () => policies.selectForRemoval(allPresets, { applied: removable }), usage, ); } diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 7f33f2c1d3c..fb29dca58e8 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -2131,6 +2131,16 @@ function applyPresetContent( `re-onboard the sandbox, then re-apply.`, ); return false; + } else { + // A built-in preset stays discoverable from the gateway, so the mutation + // stands. Name the gap anyway: silence here is what leaves an operator + // holding egress that no local state explains. (#9295) + console.error( + ` Warning: '${presetName}' was applied to the gateway but could not be ` + + `recorded locally because sandbox '${sandboxName}' is not in the ` + + `registry, so policy list will report it as active on gateway, missing ` + + `from local state.`, + ); } return true; diff --git a/test/policies.test.ts b/test/policies.test.ts index cea7bd9b7d3..080983b025e 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -736,11 +736,12 @@ exit 1 }); }); - describe("policy-add --from-file false success when the sandbox is absent from the registry (#4510)", () => { + describe("policy-add when the sandbox is absent from the registry (#4510, #9295)", () => { const registryModule = requireForTest( path.join(REPO_ROOT, "src", "lib", "state", "registry.ts"), ) as Record; const CUSTOM_CONTENT = "network_policies:\n slack-files-upload:\n host: files.slack.com\n"; + const BUILTIN_CONTENT = "network_policies:\n github:\n host: github.com\n"; const SOURCE_PATH = "/tmp/slack-files-upload-case.yaml"; let tmpHome: string; @@ -749,6 +750,7 @@ exit 1 let resolveSpy: ReturnType; let savedGetSandbox: any; let savedAddCustomPolicy: any; + let savedUpdateSandbox: any; beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-issue4510-")); @@ -767,6 +769,7 @@ exit 1 .mockReturnValue(fakeOpenshell); savedGetSandbox = registryModule.getSandbox; savedAddCustomPolicy = registryModule.addCustomPolicy; + savedUpdateSandbox = registryModule.updateSandbox; }); afterEach(() => { @@ -775,6 +778,7 @@ exit 1 resolveSpy.mockRestore(); registryModule.getSandbox = savedGetSandbox; registryModule.addCustomPolicy = savedAddCustomPolicy; + registryModule.updateSandbox = savedUpdateSandbox; fs.rmSync(tmpHome, { recursive: true, force: true }); }); @@ -810,6 +814,32 @@ exit 1 } }); + it("warns but keeps the mutation when a built-in preset cannot be recorded locally (#9295)", () => { + registryModule.getSandbox = () => null; + const updateSpy = vi.fn(() => true); + registryModule.updateSandbox = updateSpy; + const errors: string[] = []; + const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { + errors.push(a.map((x) => String(x)).join(" ")); + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + // A built-in preset stays discoverable from the gateway, so the applied + // policy stands. The warning is what tells the operator why policy list + // will report it without local state behind it. + const result = policies.applyPresetContent("my-assistant", "github", BUILTIN_CONTENT, {}); + expect(result).toBe(true); + expect(updateSpy).not.toHaveBeenCalled(); + const combined = errors.join("\n"); + expect(combined).toContain("my-assistant"); + expect(combined).toMatch(/could not be\s+recorded locally/); + expect(combined).toMatch(/active on gateway, missing\s+from local state/); + } finally { + errSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + it("records the custom preset and returns true when the sandbox is registered", () => { registryModule.getSandbox = (name: string) => ({ name }); const addSpy = vi.fn(() => true);