Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a2b6296
fix(policy): disclose full messaging-preset egress before channels apply
laitingsheng Jul 19, 2026
a7b18a7
fix(policy): read real preset fixtures in --from-dir egress test and …
laitingsheng Jul 19, 2026
4cef61d
fix(policy): disclose preset scope before lifecycle effects
apurvvkumaria Jul 19, 2026
5bd1f73
refactor(onboard): keep policy disclosure out of entrypoint
apurvvkumaria Jul 19, 2026
2d73e77
fix(policy): sanitize disclosure output and cover every preset-applic…
laitingsheng Jul 19, 2026
fc8ed4c
fix(policy): preserve owned preset refresh transactions
apurvvkumaria Jul 19, 2026
d868b11
style(policy): format owned preset refresh
apurvvkumaria Jul 19, 2026
feed1fa
refactor(policy): consolidate channel scope disclosure
apurvvkumaria Jul 19, 2026
15578d3
Merge remote-tracking branch 'origin/fix/7179-disclose-preset-egress-…
laitingsheng Jul 19, 2026
888cb91
Merge remote-tracking branch 'origin/main' into fix/7179-disclose-pre…
laitingsheng Jul 19, 2026
990d0c4
Merge remote-tracking branch 'origin/fix/7179-disclose-preset-egress-…
laitingsheng Jul 19, 2026
ab1e3c9
test(policy): mock live policy read in start dry-run disclosure test
laitingsheng Jul 19, 2026
a835d7a
fix(policy): avoid duplicate egress disclosure
apurvvkumaria Jul 19, 2026
4b12426
docs(policy): clarify no-op egress disclosure
apurvvkumaria Jul 19, 2026
13fb5bf
fix(policy): revalidate disclosure state before mutation
cv Jul 19, 2026
f6518c3
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
25aa91b
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
40144dd
fix(policy): redact preset scope disclosure
cv Jul 19, 2026
297c9a1
chore(ci): refresh policy disclosure checks
cv Jul 19, 2026
5b894f5
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
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
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"test/nemoclaw-start.test.ts": 4826,
"test/onboard-messaging.test.ts": 2049,
"test/onboard-selection.test.ts": 4769,
"test/policies.test.ts": 1531
"test/policies.test.ts": 1530
}
}
7 changes: 7 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ Three tiers are available:
After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access.
For details on tiers and the presets each includes, refer to [Network Policies](network-policies#policy-tiers).
When you finish the policy step, NemoClaw records the finalized built-in preset selection for that sandbox.
When onboarding creates or recreates a sandbox with presets, NemoClaw prints the exact finalized create-time policy scope before registering providers or creating the sandbox.
Later re-onboard runs seed from that finalized selection, so presets you intentionally removed stay removed unless you select them again or override the policy mode.

In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER` (default: `balanced`):
Expand Down Expand Up @@ -1639,6 +1640,8 @@ Do not pass `--raw` output to `openshell policy set` because the metadata header
Add a policy preset to a sandbox.
Presets extend the baseline network policy with additional endpoints.
Before applying, the command shows which endpoints the preset would open and prompts for confirmation.
The scope comes from the exact preset YAML and includes each endpoint's host, port, access, protocol, TLS, and enforcement settings, allowed methods and paths, and binary allowlist.
When a lifecycle operation reapplies a preset, NemoClaw compares it with the live policy and reports whether the preset opens new egress, replaces a drifted entry, or is already effective with no new egress.

```bash
$$nemoclaw my-assistant policy-add
Expand Down Expand Up @@ -1845,6 +1848,8 @@ The operation is idempotent.
Channel names are trimmed and lowercased before NemoClaw stores credentials, names bridge providers, or prints rebuild messages.
NemoClaw requires the matching built-in network policy preset YAML to be present.
A missing or malformed preset YAML (no `network_policies:` section) aborts `channels add` before any token prompt, registry write, or rebuild prompt.
After validating that preset, NemoClaw prints its exact effective egress scope before prompting for credentials or changing gateway or registry state.
The `--dry-run` path prints the same scope without collecting credentials or applying changes.
With the preset file in place, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API.
When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean <surfaces>` warning so the operator can clean up manually.
When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel.
Expand Down Expand Up @@ -1916,6 +1921,7 @@ Use `channels stop` instead of `channels remove` when you want to pause a bridge

Re-enable a channel previously paused with `channels stop`. The channel is removed from the disabled list, the sandbox is rebuilt, and the bridge registers with the gateway again using the stored credentials.
Before the rebuild, NemoClaw reapplies the matching built-in network policy preset so the restored bridge has egress to its upstream API.
NemoClaw prints the preset's exact effective egress scope before updating the disabled list or applying the policy.
If policy restoration fails, NemoClaw rolls the channel back to disabled and exits without rebuilding into a partially active state.

```bash
Expand Down Expand Up @@ -2459,6 +2465,7 @@ Upgrade a sandbox to the current agent version while preserving workspace state.
The command backs up workspace state, destroys the old sandbox (including its host-side Docker image), recreates it with the current image via `onboard --resume`, and restores workspace state into the new sandbox.
Credentials are stripped from backups before storage.
Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild.
Before creating the replacement sandbox, NemoClaw prints the finalized create-time policy scope whenever presets are included.
The replacement uses the recorded compatible-endpoint reasoning mode and web search selection instead of ambient shell values.
Rebuild preserves the recorded sandbox GPU enablement mode and, for an explicitly enabled sandbox, its recorded device selector.
It re-resolves the Docker-driver GPU route from the current host and current `NEMOCLAW_DOCKER_GPU_PATCH` value, so native-only, explicitly authorized native-with-fallback, and compatibility-only routing may differ from the original onboarding run.
Expand Down
28 changes: 28 additions & 0 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ let getDisabledChannelsMock: MockInstance;
let listSandboxesMock: MockInstance;
let rebuildSandboxMock: MockInstance;
let ensureMessagingHostForwardAfterRebuildMock: MockInstance;
let scopeDisclosureMock: MockInstance;

function arrangeRegistry(opts: { current: SandboxEntry; others?: SandboxEntry[] }): void {
const all = [opts.current, ...(opts.others ?? [])];
Expand Down Expand Up @@ -330,6 +331,10 @@ beforeEach(() => {
vi.spyOn(policy, "loadPreset").mockReturnValue("network_policies:\n stub: {}\n");
vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["stub"]);
vi.spyOn(policy, "listPresets").mockReturnValue([]);
vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("absent");
scopeDisclosureMock = vi
.spyOn(policy, "logPresetScopeForState")
.mockImplementation(() => undefined);
applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true);
vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]);

Expand Down Expand Up @@ -1092,6 +1097,29 @@ describe("Teams host-forward lifecycle (PRA-2)", () => {
expect(loggedText()).toContain("Change queued");
});

it("channels start discloses before dry-run return and before persisted-plan mutation (#7179)", async () => {
const current = makeTeamsEntry("alpha", { disabled: true });
arrangeRegistry({ current });
getDisabledChannelsMock.mockReturnValue(["teams"]);

await startSandboxChannel("alpha", { channel: "teams", dryRun: true });

expect(scopeDisclosureMock).toHaveBeenCalledOnce();
expect(updateSandboxMock).not.toHaveBeenCalled();
expect(applyPresetMock).not.toHaveBeenCalled();

scopeDisclosureMock.mockClear();
await startSandboxChannel("alpha", { channel: "teams" });

expect(scopeDisclosureMock).toHaveBeenCalledOnce();
expect(scopeDisclosureMock.mock.invocationCallOrder[0]).toBeLessThan(
updateSandboxMock.mock.invocationCallOrder[0],
);
expect(scopeDisclosureMock.mock.invocationCallOrder[0]).toBeLessThan(
applyPresetMock.mock.invocationCallOrder[0],
);
});

it("channels start restores the disabled plan and skips rebuild when its policy preset fails", async () => {
const current = makeTeamsEntry("alpha", { disabled: true });
arrangeRegistry({ current });
Expand Down
9 changes: 5 additions & 4 deletions src/lib/actions/sandbox/policy-channel-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ beforeEach(() => {
selectForRemovalMock = vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi");
vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => {
const presetName = String(name);
return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`;
return `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: ${presetName}.example.com\n port: 443\n protocol: rest\n rules:\n - allow: { method: GET, path: "/**" }\n`;
});
loadPresetForSandboxMock = vi
.spyOn(policies, "loadPresetForSandbox")
.mockImplementation((_sandboxName: unknown, name: unknown) => {
const presetName = String(name);
return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`;
return `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: ${presetName}.example.com\n port: 443\n protocol: rest\n rules:\n - allow: { method: GET, path: "/**" }\n`;
});
applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true);
removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true);
Expand Down Expand Up @@ -140,7 +140,8 @@ describe("addSandboxPolicy", () => {

expect(promptMock).not.toHaveBeenCalled();
expect(applyPresetMock).not.toHaveBeenCalled();
expect(printedText()).toContain("Endpoints that would be opened: pypi.example.com");
expect(printedText()).toContain("Effective egress that would be opened:");
expect(printedText()).toContain("- pypi.example.com:443");
expect(printedText()).toContain("--dry-run: no changes applied.");
});

Expand Down Expand Up @@ -242,7 +243,7 @@ describe("addSandboxPolicy", () => {
expect(output).not.toContain("not supported for agent");
expect(output).not.toContain("Channels supported by agent");
expect(output).not.toContain("Preset not found");
expect(output).not.toContain("Endpoints that would be opened");
expect(output).not.toContain("Effective egress that would be opened");
expect(promptMock).not.toHaveBeenCalled();
expect(loadPresetForSandboxMock).not.toHaveBeenCalled();
expect(applyPresetMock).not.toHaveBeenCalled();
Expand Down
49 changes: 46 additions & 3 deletions src/lib/actions/sandbox/policy-channel-remove-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";

import * as policies from "../../policy";
import * as runner from "../../runner";
import * as registry from "../../state/registry";
import { removeSandboxChannel, startSandboxChannel, stopSandboxChannel } from "./policy-channel";
import { policyChannelDependencies } from "./policy-channel-dependencies";
Expand Down Expand Up @@ -56,7 +57,7 @@ describe("policy channel remove/enable flows", () => {
expect(exitSpy).not.toHaveBeenCalled();
});

it("supports start dry runs without applying a preset or persisting the enabled plan", async () => {
it("supports start dry runs without applying a preset or persisting the enabled plan, and discloses effective egress first (#7179)", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" });
vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]);
vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]);
Expand All @@ -67,12 +68,54 @@ describe("policy channel remove/enable flows", () => {
startSandboxChannel("alpha", { channel: "telegram", dryRun: true }),
).resolves.toBeUndefined();

expect(logSpy.mock.calls.flat().join("\n")).toContain(
"--dry-run: would start channel 'telegram' for 'alpha'.",
const lines = logSpy.mock.calls.map((call) => call.map(String).join(" "));
const joined = lines.join("\n");
expect(joined).toContain("Effective egress scope to be applied (live delta unavailable):");
expect(joined).toContain("- api.telegram.org:443 (protocol: rest, enforcement: enforce)");
const scopeHeader = lines.findIndex((line) =>
line.includes("Effective egress scope to be applied (live delta unavailable):"),
);
const wouldStart = lines.findIndex((line) => line.includes("--dry-run: would start channel"));
expect(scopeHeader).toBeGreaterThan(-1);
expect(wouldStart).toBeGreaterThan(scopeHeader);
expect(applyPresetSpy).not.toHaveBeenCalled();
expect(updateSandboxSpy).not.toHaveBeenCalled();
expect(rebuildSpy).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("does not claim new egress on a start dry run when the preset already matches the live policy (#7179)", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" });
vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]);
vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]);
const liveTelegramPolicy = [
"version: 1",
"network_policies:",
" telegram_bot:",
" name: telegram_bot",
" endpoints:",
" - host: api.telegram.org",
" port: 443",
" protocol: rest",
" enforcement: enforce",
" rules:",
" - allow: { method: GET, path: '/bot*/**' }",
" - allow: { method: POST, path: '/bot*/**' }",
" - allow: { method: GET, path: '/file/bot*/**' }",
" binaries:",
" - { path: /usr/local/bin/node }",
" - { path: /usr/bin/node }",
"",
].join("\n");
vi.spyOn(runner, "runCapture").mockReturnValue(liveTelegramPolicy);

await expect(
startSandboxChannel("alpha", { channel: "telegram", dryRun: true }),
).resolves.toBeUndefined();

const joined = logSpy.mock.calls.map((call) => call.map(String).join(" ")).join("\n");
expect(joined).not.toContain("Effective egress that would be opened:");
expect(joined).toContain("is already effective; no new egress would be opened.");
expect(exitSpy).not.toHaveBeenCalled();
});
});
116 changes: 116 additions & 0 deletions src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";

import * as defs from "../../agent/defs";
import * as policy from "../../policy";
import * as registry from "../../state/registry";
import { addSandboxChannel } from "./policy-channel";

const WHATSAPP_PRESET = `preset:
name: whatsapp
description: "WhatsApp Web WebSocket and media"
network_policies:
whatsapp:
name: whatsapp
endpoints:
- host: web.whatsapp.com
port: 443
access: full
tls: skip
- host: raw.githubusercontent.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow:
method: GET
path: "/WhiskeySockets/Baileys/master/src/Defaults/index.ts"
binaries:
- { path: /usr/local/bin/node }
`;

let exitMock: MockInstance;
let logSpy: MockInstance;

function agentFixture(name: string): defs.AgentDefinition {
return { name } as defs.AgentDefinition;
}

beforeEach(() => {
vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", undefined);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
exitMock = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);

vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "sb-scope" });
vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("openclaw"));
vi.spyOn(policy, "loadPresetForSandbox").mockReturnValue(WHATSAPP_PRESET);
vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["whatsapp"]);
vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("absent");
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

function collectLogOutput(): string {
return (logSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" ")).join("\n");
}

describe("channels add --dry-run discloses effective preset egress before mutation (#7179)", () => {
it("prints every declared endpoint host with its port and mode", async () => {
await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true });

const output = collectLogOutput();
expect(output).toContain("Effective egress that would be opened:");
expect(output).toContain("- web.whatsapp.com:443 (access: full, tls: skip)");
expect(output).toContain(
"- raw.githubusercontent.com:443 (protocol: rest, enforcement: enforce)",
);
});

it("names the narrowly scoped Baileys version-fetch method and path, not just the host", async () => {
await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true });

const output = collectLogOutput();
expect(output).toMatch(
/allow:\s+GET\s+\/WhiskeySockets\/Baileys\/master\/src\/Defaults\/index\.ts/,
);
});

it("lists declared binaries alongside the endpoints", async () => {
await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true });

const output = collectLogOutput();
expect(output).toContain("binaries:");
expect(output).toContain("- /usr/local/bin/node");
});

it("emits the scope block before the 'would enable channel' summary", async () => {
await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true });

const lines = (logSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" "));
const scopeHeader = lines.findIndex((line) =>
line.includes("Effective egress that would be opened:"),
);
const wouldEnable = lines.findIndex((line) => line.includes("--dry-run: would enable channel"));
expect(scopeHeader).toBeGreaterThan(-1);
expect(wouldEnable).toBeGreaterThan(scopeHeader);
void exitMock;
});

it("does not claim new egress when the channel's preset already matches the live policy (#7179)", async () => {
vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("match");

await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true });

const output = collectLogOutput();
expect(output).not.toContain("Effective egress that would be opened:");
expect(output).toContain("is already effective; no new egress would be opened.");
});
});
Loading
Loading