Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions docs/network-policy/apply-policy-presets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -111,13 +111,44 @@ $$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 <preset>` 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.
NemoClaw also records maintained presets and custom presets applied through `--from-file` or `--from-dir`.
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 <name> 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).

Expand Down
8 changes: 6 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <exact-host-or-ip>` option to admit matching RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local endpoints.
The option is invalid for built-in presets.
Expand Down Expand Up @@ -2590,7 +2592,7 @@ $$nemoclaw my-assistant policy list
### `$$nemoclaw <name> 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
Expand All @@ -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 |
|------|-------------|
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/policy-channel-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
57 changes: 57 additions & 0 deletions src/lib/actions/sandbox/policy-channel-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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"],
});
});
});
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/policy-channel-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
15 changes: 13 additions & 2 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -2009,7 +2020,7 @@ async function removeSandboxPolicyUnlocked(
exitPromptStdinClosed(usage);
}
answer = await pickPresetOrExit(
() => policies.selectForRemoval(allPresets, { applied }),
() => policies.selectForRemoval(allPresets, { applied: removable }),
usage,
);
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 31 additions & 1 deletion test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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;
Expand All @@ -749,6 +750,7 @@ exit 1
let resolveSpy: ReturnType<typeof vi.spyOn>;
let savedGetSandbox: any;
let savedAddCustomPolicy: any;
let savedUpdateSandbox: any;

beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-issue4510-"));
Expand All @@ -767,6 +769,7 @@ exit 1
.mockReturnValue(fakeOpenshell);
savedGetSandbox = registryModule.getSandbox;
savedAddCustomPolicy = registryModule.addCustomPolicy;
savedUpdateSandbox = registryModule.updateSandbox;
});

afterEach(() => {
Expand All @@ -775,6 +778,7 @@ exit 1
resolveSpy.mockRestore();
registryModule.getSandbox = savedGetSandbox;
registryModule.addCustomPolicy = savedAddCustomPolicy;
registryModule.updateSandbox = savedUpdateSandbox;
fs.rmSync(tmpHome, { recursive: true, force: true });
});

Expand Down Expand Up @@ -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);
Expand Down
Loading