diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 0fd77977b5e..46fda59280f 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -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 } } diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dff723935af..bdf50367d57 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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`): @@ -1640,6 +1641,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 @@ -1846,6 +1849,9 @@ 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 discloses its effect before prompting for credentials or changing gateway or registry state. +It prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. +The `--dry-run` path prints the same disclosure 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 ` 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. @@ -1917,6 +1923,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. +Before updating the disabled list or applying the policy, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. If policy restoration fails, NemoClaw rolls the channel back to disabled and exits without rebuilding into a partially active state. ```bash @@ -2460,6 +2467,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. diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index e7fd0b2e958..9034683b202 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -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 ?? [])]; @@ -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([]); @@ -1061,7 +1066,9 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { + disclosedPresetState: "absent", + }); expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]); expect(applyPresetMock.mock.invocationCallOrder[0]).toBeLessThan( rebuildSandboxMock.mock.invocationCallOrder[0], @@ -1087,11 +1094,36 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { + disclosedPresetState: "absent", + }); expect(rebuildSandboxMock).not.toHaveBeenCalled(); 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 }); @@ -1108,7 +1140,9 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { "process.exit(1)", ); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { + disclosedPresetState: "absent", + }); expect(registry.getDisabledChannels("alpha")).toContain("teams"); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("channels start teams"); diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 26ba67840b1..ac22c9e49d2 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -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); @@ -123,7 +123,9 @@ describe("addSandboxPolicy", () => { await addSandboxPolicy("test-sandbox"); expect(promptMock).toHaveBeenCalledWith(" Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: "); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi", { + suppressDisclosure: true, + }); }); it("skips applying an interactively selected preset when confirmation is declined", async () => { @@ -140,7 +142,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."); }); @@ -148,7 +151,9 @@ describe("addSandboxPolicy", () => { await addSandboxPolicy("test-sandbox", { preset: "pypi", yes: true }); expect(promptMock).not.toHaveBeenCalled(); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi", { + suppressDisclosure: true, + }); }); it("honors non-interactive mode when an explicit preset is provided", async () => { @@ -157,7 +162,9 @@ describe("addSandboxPolicy", () => { await addSandboxPolicy("test-sandbox", { preset: "pypi" }); expect(promptMock).not.toHaveBeenCalled(); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi", { + suppressDisclosure: true, + }); }); it("fails fast in non-interactive mode without an explicit preset", async () => { @@ -242,7 +249,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(); @@ -275,7 +282,9 @@ describe("addSandboxPolicy", () => { expect(printedText()).toContain(expected); expect(printedText()).toContain(detail); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", preset); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", preset, { + suppressDisclosure: true, + }); }); it("prints Discord validation guidance when the preset name is provided", async () => { @@ -284,7 +293,9 @@ describe("addSandboxPolicy", () => { expect(printedText()).toContain("curl is not in the preset binary allowlist"); expect(printedText()).toContain("Node HTTPS"); expect(promptMock).not.toHaveBeenCalled(); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "discord"); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "discord", { + suppressDisclosure: true, + }); }); it("does not print messaging guidance when a non-messaging preset is selected", async () => { @@ -292,7 +303,9 @@ describe("addSandboxPolicy", () => { expect(printedText()).not.toContain("only opens network egress to the"); expect(printedText()).not.toContain("re-run 'nemoclaw onboard' and select"); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", "pypi", { + suppressDisclosure: true, + }); }); }); diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 7d83693dfa9..082b4b96ed1 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -121,7 +121,9 @@ describe("addSandboxPolicy refresh contract", () => { it("refreshes the in-sandbox POLICY.md after a successful built-in apply", async () => { await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { + suppressDisclosure: true, + }); expect(refreshSpy).toHaveBeenCalledTimes(1); expect(refreshSpy).toHaveBeenCalledWith("alpha"); }); @@ -149,7 +151,9 @@ describe("addSandboxPolicy refresh contract", () => { captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), ).resolves.toBe(1); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { + suppressDisclosure: true, + }); expect(refreshSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts index 8bb97ad2381..c6931fb0f05 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -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"; @@ -56,23 +57,66 @@ 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"]); const updateSandboxSpy = vi.spyOn(registry, "updateSandbox"); const applyPresetSpy = vi.spyOn(policies, "applyPreset"); const rebuildSpy = vi.spyOn(policyChannelDependencies, "rebuildSandbox"); + vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies: {}\n"); await expect( 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 that would be opened:"); + expect(joined).toContain("- api.telegram.org:443 (protocol: rest, enforcement: enforce)"); + const scopeHeader = lines.findIndex((line) => + line.includes("Effective egress that would be opened:"), ); + 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(); }); + + 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(); + }); }); diff --git a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts new file mode 100644 index 00000000000..d8ab1fc1d33 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -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."); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 99bb7809047..c35e7b885be 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -177,10 +177,7 @@ async function addSandboxPolicyUnlocked( const presetContent = policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) return; - const endpoints = policies.getPresetEndpoints(presetContent); - if (endpoints.length > 0) { - console.log(` Endpoints that would be opened: ${endpoints.join(", ")}`); - } + policies.logPresetScope(presetContent); const presetWarning = policies.getPresetValidationWarning(answer); if (presetWarning) { @@ -199,7 +196,7 @@ async function addSandboxPolicyUnlocked( if (confirm.trim().toLowerCase().startsWith("n")) return; } - if (!policies.applyPreset(sandboxName, answer)) { + if (!policies.applyPreset(sandboxName, answer, { suppressDisclosure: true })) { process.exit(1); } syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add"); @@ -229,9 +226,10 @@ async function applyExternalPreset( } 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(", ")}`); + const scopeLines = policies.renderPresetScope(loaded.content); + if (scopeLines.length > 0) { + console.log(` [${loaded.presetName}]`); + for (const line of scopeLines) console.log(line); console.log( ` ${YW}Warning: custom preset targets are not vetted. Review hosts before applying.${R}`, ); @@ -252,6 +250,7 @@ async function applyExternalPreset( try { const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content, { custom: { sourcePath: path.resolve(filePath) }, + suppressDisclosure: true, }); if (result !== false) { // Custom presets share the registry slot with built-ins (customPolicies @@ -906,6 +905,40 @@ function hydrateAddChannelEnvFromStoredState(sandboxName: string): void { hydrateMessagingChannelConfig(getStoredMessagingChannelConfig(sandboxName, savedSession)); } +function discloseChannelPresetScope( + sandboxName: string, + presetName: string, + presetContent: string, +): policies.PresetPolicyState | null { + const gatewayState = policies.getPresetContentGatewayState(sandboxName, presetContent); + policies.logPresetScopeForState(presetName, presetContent, gatewayState); + return gatewayState; +} + +function loadValidateAndDiscloseChannelPreset( + sandboxName: string, + channelName: string, + verb: "add" | "start", +): policies.PresetPolicyState | null { + const presetContent = policies.loadPresetForSandbox(sandboxName, channelName); + const presetPolicyKeys = + presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); + if (presetContent === null || presetPolicyKeys.length === 0) { + if (presetContent === null) { + console.error(` Cannot load policy preset for channel '${channelName}'.`); + } else { + console.error( + ` Preset YAML for channel '${channelName}' has no parseable entries under 'network_policies:'.`, + ); + } + console.error( + ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels ${verb} ${channelName}`, + ); + process.exit(1); + } + return discloseChannelPresetScope(sandboxName, channelName, presetContent); +} + function safeLoadOnboardSession(): ReturnType { try { return onboardSession.loadSession(); @@ -958,20 +991,9 @@ async function addSandboxChannelUnlocked( process.exit(1); } - const presetContent = policies.loadPresetForSandbox(sandboxName, canonical); - const presetPolicyKeys = - presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); - if (presetContent === null || presetPolicyKeys.length === 0) { - if (presetContent !== null && presetPolicyKeys.length === 0) { - console.error( - ` Preset YAML for channel '${canonical}' has no parseable entries under 'network_policies:'.`, - ); - } - console.error( - ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${canonical}`, - ); - process.exit(1); - } + // Disclose before credential collection, conflict prompts, or any gateway / + // registry mutation. The core apply path rechecks immediately before set. + const disclosedPresetState = loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add"); if (dryRun) { console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); @@ -994,7 +1016,11 @@ async function addSandboxChannelUnlocked( // host-side credential to acquire; register the bridge now and let the // operator complete pairing after rebuild. if (manifest.auth.mode === "in-sandbox-qr") { - if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { + if ( + !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + disclosedPresetState, + }) + ) { process.exit(1); } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); @@ -1046,7 +1072,11 @@ async function addSandboxChannelUnlocked( await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired); console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); - if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { + if ( + !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + disclosedPresetState, + }) + ) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, priorCreds, @@ -1133,9 +1163,14 @@ export function applyChannelPresetIfAvailable( sandboxName: string, channelName: string, retryAction: "add" | "start" = "add", + options: { disclosedPresetState?: policies.PresetPolicyState | null } = {}, ): boolean { try { - const applied = policies.applyPreset(sandboxName, channelName); + const applied = Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") + ? policies.applyPreset(sandboxName, channelName, { + disclosedPresetState: options.disclosedPresetState, + }) + : policies.applyPreset(sandboxName, channelName); if (!applied) { console.error( ` ${YW}⚠${R} Cannot enable channel '${channelName}': policy preset failed to apply.`, @@ -1438,6 +1473,10 @@ async function sandboxChannelsSetEnabled( return; } + const disclosedPresetState = disabled + ? undefined + : loadValidateAndDiscloseChannelPreset(sandboxName, normalized, "start"); + if (dryRun) { console.log(` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`); return; @@ -1453,7 +1492,12 @@ async function sandboxChannelsSetEnabled( // registry and backup manifest carry the enabled plan's policy intent. // If policy application fails, put the plan back in its disabled state so // runtime configuration cannot later be rebuilt without the required egress. - if (!disabled && !applyChannelPresetIfAvailable(sandboxName, normalized, "start")) { + if ( + !disabled && + !applyChannelPresetIfAvailable(sandboxName, normalized, "start", { + disclosedPresetState, + }) + ) { const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, normalized, true); if (!rolledBack) { console.error( diff --git a/src/lib/messaging/channels/whatsapp/policy/hermes.yaml b/src/lib/messaging/channels/whatsapp/policy/hermes.yaml index 9119a926778..6b0fe0d5ff2 100644 --- a/src/lib/messaging/channels/whatsapp/policy/hermes.yaml +++ b/src/lib/messaging/channels/whatsapp/policy/hermes.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 preset: name: whatsapp - description: "WhatsApp Web WebSocket and media access" + description: "WhatsApp Web WebSocket, media access, and a narrowly scoped Baileys protocol-version fetch from raw.githubusercontent.com" network_policies: whatsapp: name: whatsapp diff --git a/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml b/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml index 9119a926778..6b0fe0d5ff2 100644 --- a/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml +++ b/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 preset: name: whatsapp - description: "WhatsApp Web WebSocket and media access" + description: "WhatsApp Web WebSocket, media access, and a narrowly scoped Baileys protocol-version fetch from raw.githubusercontent.com" network_policies: whatsapp: name: whatsapp diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 44cf6c265bb..3c117bbf2d7 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -106,6 +106,7 @@ const { const { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, + discloseInitialSandboxPolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); const { getSelectionDrift, @@ -2706,15 +2707,11 @@ async function createSandboxWithBaseImageResolution( upsertMessagingProviders, getHermesToolGatewayProviderName: (targetSandbox) => getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), + discloseInitialSandboxPolicy, }); if (initialSandboxPolicy.cleanup) { process.on("exit", initialSandboxPolicy.cleanup); } - if (initialSandboxPolicy.appliedPresets.length > 0) { - console.log( - ` Including policy preset(s) at sandbox boot: ${initialSandboxPolicy.appliedPresets.join(", ")}`, - ); - } if (sandboxGpuLogMessage) console.log(sandboxGpuLogMessage); console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 5145db9f47d..a9d91a2fff5 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -22,6 +22,12 @@ export type InitialSandboxPolicy = { cleanup?: () => boolean; }; +export function discloseInitialSandboxPolicy(policy: InitialSandboxPolicy): void { + if (policy.appliedPresets.length === 0) return; + console.log(" Including policy preset(s) at sandbox boot:", policy.appliedPresets.join(", ")); + policies.logPresetScope(fs.readFileSync(policy.policyPath, "utf8")); +} + const HERMES_MESSAGING_POLICY_KEYS = getMessagingPolicyKeysByChannel({ agent: "hermes" }); const PROC_PATH = "/proc"; diff --git a/src/lib/onboard/sandbox-create-intent-types.ts b/src/lib/onboard/sandbox-create-intent-types.ts index 353ad41d225..592ef810b71 100644 --- a/src/lib/onboard/sandbox-create-intent-types.ts +++ b/src/lib/onboard/sandbox-create-intent-types.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { DockerGpuRoutePlan } from "./docker-gpu-route"; +import type { InitialSandboxPolicy } from "./initial-policy"; import type { MessagingTokenDef } from "./messaging-prep"; import type { MessagingChannel } from "./messaging-state"; import type { SandboxGpuCreateConfig } from "./sandbox-gpu-create"; @@ -90,5 +91,6 @@ export type MaterializeSandboxCreatePlanInput = { options: { replaceExisting: true }, ): string[]; getHermesToolGatewayProviderName(sandboxName: string): string; + discloseInitialSandboxPolicy?(policy: InitialSandboxPolicy): void; prepareInitialSandboxCreatePolicy?: PrepareInitialSandboxCreatePolicy; }; diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index cb85f45e852..091fe246995 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -121,6 +121,7 @@ export function materializeSandboxCreatePlan({ runProviderPreDeleteCleanup, upsertMessagingProviders, getHermesToolGatewayProviderName, + discloseInitialSandboxPolicy, prepareInitialSandboxCreatePolicy = getInitialSandboxCreatePolicy, }: MaterializeSandboxCreatePlanInput): SandboxCreatePlan { const enabledMessagingTokenDefs = validateSandboxCreateIntentBindings(intent, messagingTokenDefs); @@ -136,6 +137,12 @@ export function materializeSandboxCreatePlan({ intent.gpuRoutePlan, prepareInitialSandboxCreatePolicy, ); + try { + discloseInitialSandboxPolicy?.(initialSandboxPolicy); + } catch (error) { + initialSandboxPolicy.cleanup?.(); + throw error; + } const createArgs = [ "--from", `${buildCtx}/Dockerfile`, diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index 2f11cce07b7..adb964368c8 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -241,6 +241,10 @@ describe("resolveSandboxCreateIntent", () => { events.push("policy"); return { policyPath: "/tmp/policy.yaml", appliedPresets: ["telegram"] }; }), + discloseInitialSandboxPolicy: (policy) => { + events.push("disclose"); + expect(policy.appliedPresets).toEqual(["telegram"]); + }, runProviderPreDeleteCleanup: () => events.push("cleanup"), upsertMessagingProviders: vi.fn((receivedTokenDefs) => { events.push("upsert"); @@ -253,7 +257,7 @@ describe("resolveSandboxCreateIntent", () => { }, }); - expect(events).toEqual(["policy", "cleanup", "upsert", "hermes"]); + expect(events).toEqual(["policy", "disclose", "cleanup", "upsert", "hermes"]); expect(result.createArgs).toEqual([ "--from", "/tmp/nemoclaw-build-1/Dockerfile", @@ -277,6 +281,51 @@ describe("resolveSandboxCreateIntent", () => { expect(JSON.stringify(intent)).toBe(serializedIntent); }); + it("cleans up the prepared policy when disclosure fails before provider effects (#7179)", () => { + const intent = resolveSandboxCreateIntent({ + basePolicyPath: "/repo/policy.yaml", + sandboxName: "sandbox", + channels, + enabledChannels: [], + disabledChannelNames: new Set(), + messagingProviderRequests: [], + primaryMessagingCredentialEnvKeys: [], + reusableMessagingChannels: [], + reusableMessagingProviders: [], + hermesToolGateways: [], + sandboxGpuConfig, + gpuCreateArgs: [], + gpuRoutePlan: "native-only", + sandboxGpuLogMessage: null, + policyTier: null, + }); + const cleanupPolicy = vi.fn(() => true); + const cleanupProviders = vi.fn(); + const upsertProviders = vi.fn(() => []); + + expect(() => + materializeSandboxCreatePlan({ + intent, + buildCtx: "/tmp/nemoclaw-build-1", + messagingTokenDefs: [], + prepareInitialSandboxCreatePolicy: vi.fn(() => ({ + policyPath: "/tmp/policy.yaml", + appliedPresets: [], + cleanup: cleanupPolicy, + })), + discloseInitialSandboxPolicy: () => { + throw new Error("disclosure failed"); + }, + runProviderPreDeleteCleanup: cleanupProviders, + upsertMessagingProviders: upsertProviders, + getHermesToolGatewayProviderName: vi.fn(), + }), + ).toThrow("disclosure failed"); + expect(cleanupPolicy).toHaveBeenCalledOnce(); + expect(cleanupProviders).not.toHaveBeenCalled(); + expect(upsertProviders).not.toHaveBeenCalled(); + }); + it("rejects changed credential availability before running effects", () => { expectCredentialBindingFailure({ plannedTokenDef: { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index a4235b5423a..1374e9413e7 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -7,6 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import readline from "node:readline"; +import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; // Namespace access keeps resolveOpenshell spyable in focused policy tests. @@ -44,6 +45,7 @@ import { type PolicyValue, parseNetworkPolicies, } from "./preset-parsing"; +import { escapeTerminalText, logPresetScope, renderPresetScope } from "./preset-scope-render"; import { splitSemanticFindings, validatePolicySemantics } from "./semantic-validation"; const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); @@ -560,6 +562,66 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st return YAML.stringify(output); } +export type PresetPolicyState = "absent" | "drift" | "match"; + +function classifyPresetEntries(currentPolicy: string, presetEntries: string): PresetPolicyState { + try { + const current = YAML.parse(currentPolicy)?.network_policies; + const expected = YAML.parse(`network_policies:\n${presetEntries}`)?.network_policies; + if (!expected || typeof expected !== "object" || Array.isArray(expected)) { + return "drift"; + } + const expectedEntries = Object.entries(expected); + if (expectedEntries.length === 0) return "drift"; + if (!current || typeof current !== "object" || Array.isArray(current)) return "absent"; + const presentEntries = expectedEntries.filter(([key]) => Object.hasOwn(current, key)); + if (presentEntries.length === 0) return "absent"; + return expectedEntries.every( + ([key, value]) => Object.hasOwn(current, key) && isDeepStrictEqual(current[key], value), + ) + ? "match" + : "drift"; + } catch { + return "drift"; + } +} + +function policyDocumentsMatch(left: string, right: string): boolean { + try { + return isDeepStrictEqual(YAML.parse(left), YAML.parse(right)); + } catch { + return false; + } +} + +function logPresetNoNewEgress( + presetName: string, + logger: (line: string) => void = console.log, +): void { + logger( + ` Preset '${escapeTerminalText(presetName)}' is already effective; no new egress would be opened.`, + ); +} + +function logPresetScopeForState( + presetName: string, + content: string, + state: PresetPolicyState | null, + logger: (line: string) => void = console.log, +): void { + if (state === "match") { + logPresetNoNewEgress(presetName, logger); + return; + } + const heading = + state === "absent" + ? " Effective egress that would be opened:" + : state === "drift" + ? " Effective egress scope that would replace the current preset policy:" + : " Effective egress scope to be applied (live delta unavailable):"; + for (const line of renderPresetScope(content, { heading })) logger(line); +} + function mergePresetNamesIntoPolicy( currentPolicy: string, presetNames: string[], @@ -850,6 +912,8 @@ function applyPresetContent( expectedExistingNetworkPolicyContent?: string | null; nonFatal?: boolean; skipRegistryUpdate?: boolean; + suppressDisclosure?: boolean; + disclosedPresetState?: PresetPolicyState | null; } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -935,33 +999,49 @@ function applyPresetContent( } const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - const endpoints = getPresetEndpoints(presetContent); - if (endpoints.length > 0) { - console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); - } + const presetState = classifyPresetEntries(currentPolicy, presetEntries); + const disclosedStateStillCurrent = + Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") && + options.disclosedPresetState === presetState; + if (!options.suppressDisclosure && !disclosedStateStillCurrent) { + logPresetScopeForState(presetName, presetContent, presetState); + } + + // Ownership-aware callers use a successful `policy set --wait` as part of + // their live-policy/registry transaction, even when the desired document is + // byte-for-byte equivalent to the current policy. Skipping that submission + // would let the caller commit its ownership reservation without observing a + // failed gateway mutation. Ordinary preset re-application remains a no-op. + const requiresOwnedKeyRefresh = Object.prototype.hasOwnProperty.call( + options, + "expectedExistingNetworkPolicyContent", + ); + const policyChanged = requiresOwnedKeyRefresh || !policyDocumentsMatch(currentPolicy, merged); // Run before creating temp resources so a missing-binary exit doesn't // orphan files in $TMPDIR (the finally cleanup doesn't run on process.exit). - if (!assertOpenshellResolvable(options)) return false; + if (policyChanged && !assertOpenshellResolvable(options)) return false; - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); - const tmpFile = path.join(tmpDir, "policy.yaml"); - fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); - - try { - if (!setPolicyFile(tmpFile, sandboxName, options)) return false; + if (policyChanged) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); - console.log(` Applied preset: ${presetName}`); - } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignored */ - } try { - fs.rmdirSync(tmpDir); - } catch { - /* ignored */ + if (!setPolicyFile(tmpFile, sandboxName, options)) return false; + + console.log(` Applied preset: ${presetName}`); + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } } } @@ -1061,7 +1141,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { ); return false; } - const endpointLogs: string[][] = []; + const presetContents: Array<{ + content: string; + name: string; + state: PresetPolicyState; + }> = []; + const originalPolicy = merged; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -1076,41 +1161,43 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } - const endpoints = getPresetEndpoints(presetContent); - endpointLogs.push(endpoints); + const state = classifyPresetEntries(merged, presetEntries); + presetContents.push({ content: presetContent, name: presetName, state }); merged = mergePresetIntoPolicy(merged, presetEntries); } - for (const endpoints of endpointLogs) { - if (endpoints.length > 0) { - console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); - } + for (const preset of presetContents) { + logPresetScopeForState(preset.name, preset.content, preset.state); } + const policyChanged = !policyDocumentsMatch(originalPolicy, merged); + // Run before creating temp resources so a missing-binary exit doesn't // orphan files in $TMPDIR (the finally cleanup doesn't run on process.exit). - assertOpenshellResolvable(); + if (policyChanged) assertOpenshellResolvable(); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); - const tmpFile = path.join(tmpDir, "policy.yaml"); - fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); - - try { - run(buildPolicySetCommand(tmpFile, sandboxName)); + if (policyChanged) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); - for (const presetName of uniquePresetNames) { - console.log(` Applied preset: ${presetName}`); - } - } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignored */ - } try { - fs.rmdirSync(tmpDir); - } catch { - /* ignored */ + run(buildPolicySetCommand(tmpFile, sandboxName)); + + for (const preset of presetContents.filter((entry) => entry.state !== "match")) { + console.log(` Applied preset: ${preset.name}`); + } + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } } } @@ -1538,6 +1625,9 @@ export { loadPreset, loadPresetForSandbox, loadPresetFromFile, + logPresetNoNewEgress, + logPresetScope, + logPresetScopeForState, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, networkPoliciesHasAllowedIps, @@ -1549,6 +1639,7 @@ export { removeBuiltinPresetAttribution, removePreset, removePresetFromPolicy, + renderPresetScope, resolvePermissivePolicyPath, selectForRemoval, selectFromList, diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts new file mode 100644 index 00000000000..92be27e08bc --- /dev/null +++ b/src/lib/policy/preset-scope-render.test.ts @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { renderPresetScope } from "./preset-scope-render"; + +const WHATSAPP_LIKE_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: "*.whatsapp.net" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - 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 } + - { path: /usr/bin/node } +`; + +describe("renderPresetScope (#7179)", () => { + it("returns an empty list for content with no network_policies", () => { + expect(renderPresetScope("preset:\n name: x\n description: 'y'\n")).toEqual([]); + expect(renderPresetScope("")).toEqual([]); + }); + + it("returns an empty list for malformed YAML instead of throwing", () => { + expect(renderPresetScope("::: not yaml :::")).toEqual([]); + }); + + it("renders full L4 tunnel endpoints with access + tls but no rule lines", () => { + const lines = renderPresetScope(WHATSAPP_LIKE_PRESET); + const expectedLine = " - web.whatsapp.com:443 (access: full, tls: skip)"; + expect(lines).toContain(expectedLine); + const idx = lines.indexOf(expectedLine); + expect(lines[idx + 1] ?? "").not.toMatch(/^\s+allow:/); + }); + + it("renders REST endpoints with per-rule methods and paths", () => { + const lines = renderPresetScope(WHATSAPP_LIKE_PRESET); + const joined = lines.join("\n"); + expect(joined).toContain("- *.whatsapp.net:443 (protocol: rest, enforcement: enforce)"); + expect(joined).toMatch(/allow:\s+GET\s+\/\*\*/); + expect(joined).toMatch(/allow:\s+POST\s+\/\*\*/); + }); + + it("surfaces the narrowly scoped Baileys version-fetch path, not just the host", () => { + const joined = renderPresetScope(WHATSAPP_LIKE_PRESET).join("\n"); + expect(joined).toContain("raw.githubusercontent.com:443"); + expect(joined).toContain("/WhiskeySockets/Baileys/master/src/Defaults/index.ts"); + }); + + it("lists declared binaries", () => { + const joined = renderPresetScope(WHATSAPP_LIKE_PRESET).join("\n"); + expect(joined).toContain("binaries:"); + expect(joined).toContain("- /usr/local/bin/node"); + expect(joined).toContain("- /usr/bin/node"); + }); + + it("prints one policy block per preset network policy", () => { + const multi = `network_policies: + policy_a: + name: policy_a + endpoints: + - host: a.example + port: 443 + protocol: rest + rules: + - allow: { method: GET, path: "/a" } + policy_b: + name: policy_b + endpoints: + - host: b.example + port: 443 + access: full +`; + const joined = renderPresetScope(multi).join("\n"); + expect(joined).toContain("policy 'policy_a':"); + expect(joined).toContain("policy 'policy_b':"); + expect(joined).toContain("- a.example:443"); + expect(joined).toContain("- b.example:443"); + }); + + it("skips malformed endpoint entries without dropping the surrounding scope", () => { + const partial = `network_policies: + mixed: + name: mixed + endpoints: + - host: 42 + - foo: bar + - host: good.example + port: 443 + protocol: rest + rules: + - allow: { method: GET, path: "/**" } +`; + const joined = renderPresetScope(partial).join("\n"); + expect(joined).toContain("- good.example:443"); + expect(joined).not.toMatch(/^\s+- 42/m); + }); + + it("emits (no endpoints declared) rather than skipping an empty policy", () => { + const empty = `network_policies: + bare: + name: bare + endpoints: [] +`; + const joined = renderPresetScope(empty).join("\n"); + expect(joined).toContain("policy 'bare':"); + expect(joined).toContain("(no endpoints declared)"); + }); + + it("renders terminal controls from every YAML-derived field as visible escapes", () => { + const adversarial = `network_policies: + "policy\\u001b[2J": + name: "name\\u000dspoof" + endpoints: + - host: "safe.example\\u001b[H" + port: "443\\u000aFAKE" + protocol: "rest\\u009b" + rules: + - allow: + method: "GET\\u0009POST" + path: "/safe\\u202eexe" + binaries: + - path: "/usr/bin/node\\u0007" +`; + + const lines = renderPresetScope(adversarial); + for (const line of lines) { + expect(line).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u202e]/u); + } + const joined = lines.join("\n"); + expect(joined).toContain("name\\u{000d}spoof"); + expect(joined).toContain("safe.example\\u{001b}[H:443\\u{000a}FAKE"); + expect(joined).toContain("rest\\u{009b}"); + expect(joined).toContain("GET\\u{0009}POST"); + expect(joined).toContain("/safe\\u{202e}exe"); + expect(joined).toContain("/usr/bin/node\\u{0007}"); + }); + + it("redacts credentials from every YAML-derived field before disclosure", () => { + const credentialBearing = `network_policies: + unsafe: + name: "Bearer opaque-policy-secret" + endpoints: + - host: "https://user:password@example.com/v1?api_key=opaque-query-secret#token=fragment-secret" + port: "token=opaque-port-secret" + protocol: "sk-proj-abcdefghijklmnopqrstuvwxyz123456" + access: "authorization=opaque-access-secret" + tls: "Bearer opaque-tls-secret" + enforcement: "password=opaque-enforcement-secret" + rules: + - allow: + method: "Bearer opaque-method-secret" + path: "/v1?api_key=opaque-path-secret" + binaries: + - path: "/opt/token=opaque-binary-secret" +`; + + const joined = renderPresetScope(credentialBearing).join("\n"); + expect(joined).not.toMatch(/opaque-|password@|sk-proj-/); + expect(joined).toContain("https://example.com/v1?api_key="); + expect(joined).toContain("/v1?api_key="); + expect(joined).toContain("Bearer "); + }); +}); diff --git a/src/lib/policy/preset-scope-render.ts b/src/lib/policy/preset-scope-render.ts new file mode 100644 index 00000000000..4328af5ee69 --- /dev/null +++ b/src/lib/policy/preset-scope-render.ts @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isObjectRecord } from "../core/json-types"; +import { redactFull, redactUrl } from "../security/redact"; +import { URL_TOKEN_PATTERN } from "../security/redact-url"; +import { type PolicyValue, parseNetworkPolicies } from "./preset-parsing"; + +type RuleScope = { + action: "allow" | "deny"; + methods: string[]; + paths: string[]; +}; + +type EndpointScope = { + host: string; + port?: number | string; + protocol?: string; + access?: string; + tls?: string; + enforcement?: string; + rules: RuleScope[]; +}; + +type PolicyScope = { + name: string; + endpoints: EndpointScope[]; + binaries: string[]; +}; + +export type PresetScope = { + policies: PolicyScope[]; +}; + +type RenderPresetScopeOptions = { + heading?: string; +}; + +const UNICODE_FORMAT_CONTROL = /^\p{Cf}$/u; + +/** Render untrusted YAML scalars without allowing terminal-control sequences. */ +export function escapeTerminalText(value: string): string { + return [...value] + .map((character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isC0 = codePoint <= 0x1f; + const isDeleteOrC1 = codePoint >= 0x7f && codePoint <= 0x9f; + const isLineSeparator = codePoint === 0x2028 || codePoint === 0x2029; + if (!isC0 && !isDeleteOrC1 && !isLineSeparator && !UNICODE_FORMAT_CONTROL.test(character)) { + return character; + } + return `\\u{${codePoint.toString(16).padStart(4, "0")}}`; + }) + .join(""); +} + +/** Redact credential-shaped content before rendering untrusted YAML scalars. */ +function renderTerminalText(value: string): string { + const redactedUrls = value.replace(URL_TOKEN_PATTERN, (url) => redactUrl(url) ?? ""); + return escapeTerminalText(redactFull(redactedUrls)); +} + +function toStringOrUndefined(value: PolicyValue | undefined): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return undefined; +} + +function toPortOrUndefined(value: unknown): number | string | undefined { + if (typeof value === "number") return value; + if (typeof value === "string" && value.length > 0) return value; + return undefined; +} + +function stringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + if (typeof value === "string") return [value]; + return []; +} + +function collectRules(endpoint: Record): RuleScope[] { + const rawRules = endpoint.rules; + if (!Array.isArray(rawRules)) return []; + const out: RuleScope[] = []; + for (const entry of rawRules) { + if (!isObjectRecord(entry)) continue; + const action: "allow" | "deny" = "deny" in entry ? "deny" : "allow"; + const spec = entry[action]; + if (!isObjectRecord(spec)) continue; + const methods = [...stringArray(spec.method), ...stringArray(spec.methods)]; + const paths = [...stringArray(spec.path), ...stringArray(spec.paths)]; + out.push({ + action, + methods: methods.length > 0 ? methods : ["*"], + paths: paths.length > 0 ? paths : ["/**"], + }); + } + return out; +} + +function collectBinaries(policy: Record): string[] { + const binaries = policy.binaries; + if (!Array.isArray(binaries)) return []; + const out: string[] = []; + for (const entry of binaries) { + if (typeof entry === "string") { + out.push(entry); + continue; + } + if (isObjectRecord(entry) && typeof entry.path === "string") out.push(entry.path); + } + return out; +} + +function extractPresetScope(content: string): PresetScope | null { + const parsed = parseNetworkPolicies(content); + if (!parsed) return null; + const policies: PolicyScope[] = []; + for (const [rawName, rawPolicy] of Object.entries(parsed)) { + if (!isObjectRecord(rawPolicy)) continue; + const name = typeof rawPolicy.name === "string" ? rawPolicy.name : rawName; + const endpoints: EndpointScope[] = []; + const rawEndpoints = rawPolicy.endpoints; + if (Array.isArray(rawEndpoints)) { + for (const rawEndpoint of rawEndpoints) { + if (!isObjectRecord(rawEndpoint)) continue; + const host = typeof rawEndpoint.host === "string" ? rawEndpoint.host : null; + if (!host) continue; + endpoints.push({ + host, + port: toPortOrUndefined(rawEndpoint.port), + protocol: toStringOrUndefined(rawEndpoint.protocol as PolicyValue | undefined), + access: toStringOrUndefined(rawEndpoint.access as PolicyValue | undefined), + tls: toStringOrUndefined(rawEndpoint.tls as PolicyValue | undefined), + enforcement: toStringOrUndefined(rawEndpoint.enforcement as PolicyValue | undefined), + rules: collectRules(rawEndpoint), + }); + } + } + policies.push({ name, endpoints, binaries: collectBinaries(rawPolicy) }); + } + return { policies }; +} + +function formatEndpoint(endpoint: EndpointScope): string[] { + const port = renderTerminalText(String(endpoint.port ?? "?")); + const modeBits: string[] = []; + if (endpoint.access) modeBits.push(`access: ${renderTerminalText(endpoint.access)}`); + if (endpoint.protocol) modeBits.push(`protocol: ${renderTerminalText(endpoint.protocol)}`); + if (endpoint.tls) modeBits.push(`tls: ${renderTerminalText(endpoint.tls)}`); + if (endpoint.enforcement) { + modeBits.push(`enforcement: ${renderTerminalText(endpoint.enforcement)}`); + } + const modeSuffix = modeBits.length > 0 ? ` (${modeBits.join(", ")})` : ""; + const header = ` - ${renderTerminalText(endpoint.host)}:${port}${modeSuffix}`; + if (endpoint.rules.length === 0) return [header]; + const ruleLines = endpoint.rules.map((rule) => { + const methods = rule.methods.map(renderTerminalText).join(", "); + const paths = rule.paths.map(renderTerminalText).join(", "); + return ` ${rule.action}: ${methods} ${paths}`; + }); + return [header, ...ruleLines]; +} + +export function renderPresetScope( + content: string, + options: RenderPresetScopeOptions = {}, +): string[] { + const scope = extractPresetScope(content); + if (!scope || scope.policies.length === 0) return []; + const lines: string[] = [options.heading ?? " Effective egress that would be opened:"]; + for (const policy of scope.policies) { + lines.push(` policy '${renderTerminalText(policy.name)}':`); + if (policy.endpoints.length === 0) { + lines.push(" (no endpoints declared)"); + } else { + for (const endpoint of policy.endpoints) { + lines.push(...formatEndpoint(endpoint)); + } + } + if (policy.binaries.length > 0) { + lines.push(" binaries:"); + for (const bin of policy.binaries) { + lines.push(` - ${renderTerminalText(bin)}`); + } + } + } + return lines; +} + +export function logPresetScope( + content: string, + logger: (line: string) => void = console.log, +): void { + const lines = renderPresetScope(content); + for (const line of lines) logger(line); +} diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index ea5f946dba0..ccd05f0fcbb 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -194,8 +194,10 @@ beforeEach(() => { testLog = ""; logSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + const text = args.map(String).join(" "); callOrder.push( - ...(args.map(String).join(" ").includes("Change queued") ? ["promptAndRebuild"] : []), + ...(text.includes("Effective egress that would be opened") ? ["scopeDisclosure"] : []), + ...(text.includes("Change queued") ? ["promptAndRebuild"] : []), ); }); errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -208,7 +210,10 @@ beforeEach(() => { sandboxes: [registryEntry], defaultSandbox: "test-sb", })); - updateSandboxSpy = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + updateSandboxSpy = vi.spyOn(registry, "updateSandbox").mockImplementation(() => { + callOrder.push("updateSandbox"); + return true; + }); loadPresetForSandboxSpy = vi .spyOn(policies, "loadPresetForSandbox") @@ -216,6 +221,7 @@ beforeEach(() => { callOrder.push(`loadPresetForSandbox:${sandboxName}:${presetName}`); return presetContent; }); + vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); vi.spyOn(policies, "listPresets").mockImplementation(() => ["telegram", "slack", "discord", "whatsapp", "npm", "github"].map((name) => ({ name, @@ -240,7 +246,10 @@ beforeEach(() => { callOrder.push(`saveCredential:${key}`); }); deleteCredentialSpy = vi.spyOn(store, "deleteCredential").mockImplementation(() => true); - promptSpy = vi.spyOn(store, "prompt").mockResolvedValue("y"); + promptSpy = vi.spyOn(store, "prompt").mockImplementation(async () => { + callOrder.push("credentialPrompt"); + return "y"; + }); vi.spyOn(onboardSession, "loadSession").mockImplementation(() => sessionState); vi.spyOn(onboardSession, "updateSession").mockImplementation((mutator) => { @@ -322,6 +331,23 @@ afterEach(() => { }); describe("channels add applies a matching policy preset (#3437)", () => { + it("discloses token-channel egress before credential prompts and gateway mutation (#7179)", async () => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + delete process.env.TELEGRAM_BOT_TOKEN; + + await addSandboxChannel("test-sb", { channel: "telegram" }); + + expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan( + callOrder.indexOf("credentialPrompt"), + ); + expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan( + callOrder.indexOf("upsertMessagingProviders"), + ); + expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan( + callOrder.indexOf("applyPreset:telegram"), + ); + }); + it("plans channel enrollment through the messaging manifest workflow", async () => { await addSandboxChannel("test-sb", { channel: "slack" }); @@ -342,7 +368,9 @@ describe("channels add applies a matching policy preset (#3437)", () => { await addSandboxChannel("test-sb", { channel }); expect(applyPresetSpy).toHaveBeenCalledOnce(); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", channel); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", channel, { + disclosedPresetState: "absent", + }); expect(loadPresetForSandboxSpy).toHaveBeenCalledWith("test-sb", channel); expect(callOrder.indexOf(`applyPreset:${channel}`)).toBeLessThan( callOrder.indexOf("promptAndRebuild"), @@ -374,7 +402,10 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(messagingUpdate?.[1]).not.toHaveProperty("messagingChannels"); expect(messagingUpdate?.[1]).not.toHaveProperty("disabledChannels"); expect(applyPresetSpy).toHaveBeenCalledOnce(); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp", { + disclosedPresetState: "absent", + }); + expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan(callOrder.indexOf("updateSandbox")); expect(callOrder.indexOf("applyPreset:whatsapp")).toBeLessThan( callOrder.indexOf("promptAndRebuild"), ); @@ -389,7 +420,9 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(providerSpy).not.toHaveBeenCalled(); expect(updateSandboxSpy).not.toHaveBeenCalled(); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp", { + disclosedPresetState: "absent", + }); expect(callOrder).not.toContain("promptAndRebuild"); }); @@ -485,7 +518,9 @@ describe("channels add applies a matching policy preset (#3437)", () => { await expectExit(() => addSandboxChannel("test-sb", { channel: "telegram" })); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "telegram"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "telegram", { + disclosedPresetState: "absent", + }); expect(updateSandboxSpy).not.toHaveBeenCalled(); expect(deleteCredentialSpy).toHaveBeenCalledWith("TELEGRAM_BOT_TOKEN"); expect(sessionUpdates).toEqual([]); @@ -635,7 +670,9 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { + disclosedPresetState: "absent", + }); expect(sessionUpdates).toEqual([]); expect(sessionState?.policyPresets).toEqual(["npm", "github"]); }); @@ -645,7 +682,9 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { + disclosedPresetState: "absent", + }); expect(sessionUpdates).toEqual([]); expect(callOrder).toContain("promptAndRebuild"); }); @@ -655,7 +694,9 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack"); + expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { + disclosedPresetState: "absent", + }); expect(callOrder).toContain("promptAndRebuild"); }); diff --git a/test/e2e/live/mcp-bridge-reliability.ts b/test/e2e/live/mcp-bridge-reliability.ts index 1fe6aeff364..a4c865e6aef 100644 --- a/test/e2e/live/mcp-bridge-reliability.ts +++ b/test/e2e/live/mcp-bridge-reliability.ts @@ -11,8 +11,9 @@ const HERMES_RESTART_TRANSPORT_FAILURE_SUFFIX = [ ].join("\n"); const HERMES_RESTART_SUCCESS_PREFIX = new RegExp( `^${[ - String.raw`Widening sandbox egress — adding: (?[a-z0-9-]+\.trycloudflare\.com)`, - String.raw`Applied preset: mcp-bridge-concurrent`, + String.raw`Effective egress that would be opened:`, + String.raw`(?:.*\n)*?\s*- (?[a-z0-9-]+\.trycloudflare\.com):\d+[^\n]*`, + String.raw`(?:.*\n)*?Applied preset: mcp-bridge-concurrent`, String.raw`Narrowing sandbox egress — removing: \k`, String.raw`Removed preset: mcp-bridge-concurrent`, String.raw`✓ Policy version (?\d+) submitted \(hash: [0-9a-f]+\)`, diff --git a/test/e2e/support/mcp-bridge-reliability.test.ts b/test/e2e/support/mcp-bridge-reliability.test.ts index a6775c558bf..43105b65b4e 100644 --- a/test/e2e/support/mcp-bridge-reliability.test.ts +++ b/test/e2e/support/mcp-bridge-reliability.test.ts @@ -8,7 +8,9 @@ import { retryAfterHermesRestartTransportFailure, } from "../live/mcp-bridge-reliability.ts"; -const HERMES_BROKEN_PIPE = ` Widening sandbox egress — adding: fixture.trycloudflare.com +const HERMES_BROKEN_PIPE = ` Effective egress that would be opened: + policy 'mcp-bridge-concurrent': + - fixture.trycloudflare.com:443 (protocol: rest, enforcement: enforce) Applied preset: mcp-bridge-concurrent Narrowing sandbox egress — removing: fixture.trycloudflare.com Removed preset: mcp-bridge-concurrent diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 2e9e4101f34..3dab297fc93 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -140,7 +140,8 @@ policies.loadPresetFromFile = (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" }; + const content = require("fs").readFileSync(p, "utf-8"); + return { presetName: name, content }; }; policies.applyPresetContent = (sandboxName, presetName) => { calls.push({ type: "apply", sandboxName, presetName }); @@ -208,6 +209,30 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => { expect(result.stdout).toMatch(/--dry-run: 'custom-rule' not applied\./); }); + it("renders hostile custom-preset terminal controls visibly during dry-run (#7179)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-control-")); + const file = path.join(tmp, "custom-control.yaml"); + fs.writeFileSync( + file, + String.raw`preset: + name: custom-control +network_policies: + hostile: + name: "safe\u001b[2JFAKE" + endpoints: + - host: "api.example\u000aAPPROVED" + port: 443 +`, + ); + + const result = runPolicyAddExternal(["--from-file", file, "--dry-run", "--yes"]); + + expect(result.status).toBe(0); + expect(result.stdout).not.toMatch(/[\u001b\u0000-\u0008\u000b-\u001f\u007f-\u009f]/u); + expect(result.stdout).toContain("safe\\u{001b}[2JFAKE"); + expect(result.stdout).toContain("api.example\\u{000a}APPROVED:443"); + }); + 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"); @@ -249,10 +274,19 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => { 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", - ); + const endpointBlock = [ + "network_policies:", + " a-good:", + " name: a-good", + " endpoints:", + " - host: a-good.example.com", + " port: 443", + " protocol: rest", + " rules:", + ' - allow: { method: GET, path: "/**" }', + "", + ].join("\n"); + fs.writeFileSync(path.join(dir, "a-good.yaml"), `preset:\n name: a-good\n${endpointBlock}`); fs.writeFileSync( path.join(dir, "b-bad.yaml"), "preset:\n name: b-bad\nnetwork_policies: {}\n", @@ -263,9 +297,11 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => { ); 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, + // a-good succeeded (visible as the [a-good] scope 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).toMatch(/\[a-good\]/); + expect(result.stdout).toMatch(/Effective egress that would be opened/); + expect(result.stdout).toMatch(/- a-good\.example\.com:443/); expect(result.stdout).not.toMatch(/\[c-skipped\]/); expect(result.stderr).toMatch(/Aborting --from-dir/); }); diff --git a/test/policies.test.ts b/test/policies.test.ts index 9853bcc8189..09724ba7fb9 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -120,7 +120,9 @@ describe("policies", () => { it("does not include the WhatsApp preset YAML body in the description", () => { const whatsapp = policies.listPresets().find((p) => p.name === "whatsapp"); - expect(whatsapp?.description).toBe("WhatsApp Web WebSocket and media access"); + expect(whatsapp?.description).toBe( + "WhatsApp Web WebSocket, media access, and a narrowly scoped Baileys protocol-version fetch from raw.githubusercontent.com", + ); expect(whatsapp?.description).not.toContain("network_policies:"); }); }); @@ -433,6 +435,9 @@ exit 1 }); describe("applyPreset disclosure logging", () => { + const hasScopeHeader = (m: unknown): m is string => + typeof m === "string" && m.includes("Effective egress that would be opened"); + it("logs egress endpoints before applying", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-disclosure-")); const fakeOpenshell = path.join(tmpDir, "openshell"); @@ -453,9 +458,7 @@ exit 1 const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); - expect( - messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), - ).toBe(true); + expect(messages.some(hasScopeHeader)).toBe(true); } finally { logSpy.mockRestore(); errSpy.mockRestore(); @@ -473,16 +476,14 @@ exit 1 const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); - expect( - messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), - ).toBe(false); + expect(messages.some(hasScopeHeader)).toBe(false); } finally { logSpy.mockRestore(); errSpy.mockRestore(); } }); - it("does not log when preset exists but has no host entries", () => { + it("does not log when preset does not exist under any sandbox load path", () => { const noHostPreset = "preset:\n name: empty\n\nnetwork_policies:\n empty_rule:\n name: empty_rule\n endpoints: []\n"; const loadSpy = vi.spyOn(policies, "loadPreset").mockReturnValue(noHostPreset); @@ -501,9 +502,7 @@ exit 1 const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); - expect( - messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), - ).toBe(false); + expect(messages.some(hasScopeHeader)).toBe(false); } finally { loadSpy.mockRestore(); logSpy.mockRestore(); diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts index b7d3b06d260..b0460d275e0 100644 --- a/test/policy-channel-agent-resolution.test.ts +++ b/test/policy-channel-agent-resolution.test.ts @@ -163,7 +163,7 @@ registry.registerSandbox({ expect(text).not.toContain("not supported for agent"); expect(text).not.toContain("Terminal-runtime agents do not run inbound messaging bridges."); expect(text).not.toContain("Preset not found"); - expect(text).not.toContain("Endpoints that would be opened"); + expect(text).not.toContain("Effective egress that would be opened"); expect(text).not.toContain("Apply 'telegram'"); }); }); diff --git a/test/policy-preset-noop-disclosure.test.ts b/test/policy-preset-noop-disclosure.test.ts new file mode 100644 index 00000000000..4e69cbe5fbc --- /dev/null +++ b/test/policy-preset-noop-disclosure.test.ts @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import * as policies from "../src/lib/policy"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const POLICY_MODULE = JSON.stringify(path.join(REPO_ROOT, "src/lib/policy/index.ts")); +const REGISTRY_MODULE = JSON.stringify(path.join(REPO_ROOT, "src/lib/state/registry.ts")); +const SOURCE_NODE_ARGS = ["--import", "tsx"]; +const tempRoots: string[] = []; + +type Scenario = { + currentPolicy: string; + presetNames: string[]; + batch?: boolean; + suppressDisclosure?: boolean; + disclosedPresetState?: policies.PresetPolicyState | null; +}; + +function runScenario({ + currentPolicy, + presetNames, + batch = false, + suppressDisclosure = false, + disclosedPresetState, +}: Scenario) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preset-disclosure-")); + tempRoots.push(root); + const currentPolicyPath = path.join(root, "current.yaml"); + const appliedPolicyPath = path.join(root, "applied.yaml"); + const callsPath = path.join(root, "calls.log"); + const openshell = path.join(root, "openshell"); + fs.writeFileSync(currentPolicyPath, currentPolicy); + fs.writeFileSync(callsPath, ""); + fs.writeFileSync( + openshell, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\n---\n' + cat ${JSON.stringify(currentPolicyPath)} + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + printf 'policy set\n' >> ${JSON.stringify(callsPath)} + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + cp "$2" ${JSON.stringify(appliedPolicyPath)} + break + fi + shift + done + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + const invocation = batch + ? `policies.applyPresets("alpha", ${JSON.stringify(presetNames)})` + : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])}, ${JSON.stringify({ suppressDisclosure, disclosedPresetState })})`; + const script = ` +const fs = require("node:fs"); +const policies = require(${POLICY_MODULE}); +const registry = require(${REGISTRY_MODULE}); +registry.registerSandbox({ name: "alpha", policies: [] }); +const result = ${invocation}; +process.stdout.write("\\n__RESULT__" + JSON.stringify({ + result, + calls: fs.readFileSync(process.env.CALLS_PATH, "utf8").trim().split("\\n").filter(Boolean), + registry: registry.getSandbox("alpha"), +})); +`; + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + ...process.env, + HOME: root, + NEMOCLAW_OPENSHELL_BIN: openshell, + CURRENT_POLICY_PATH: currentPolicyPath, + APPLIED_POLICY_PATH: appliedPolicyPath, + CALLS_PATH: callsPath, + }, + }); + expect(result.status, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1]) as { + result: boolean; + calls: string[]; + registry: { policies: string[] }; + }; + return { output: `${result.stdout.split("__RESULT__")[0]}\n${result.stderr}`, payload }; +} + +function policyWithPresets(names: string[]): string { + return policies.mergePresetNamesIntoPolicy("version: 1\nnetwork_policies: {}\n", names).policy; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("preset no-op egress disclosure (#7179)", () => { + it("repairs single-preset attribution without claiming or submitting new egress", () => { + const { output, payload } = runScenario({ + currentPolicy: policyWithPresets(["npm"]), + presetNames: ["npm"], + }); + + expect(output).toContain("Preset 'npm' is already effective; no new egress would be opened."); + expect(output).not.toContain("Effective egress that would be opened:"); + expect(payload.calls).toEqual([]); + expect(payload.registry.policies).toEqual(["npm"]); + }); + + it("skips the gateway set when every batch preset already matches", () => { + const { output, payload } = runScenario({ + currentPolicy: policyWithPresets(["npm", "pypi"]), + presetNames: ["npm", "pypi"], + batch: true, + }); + + expect(output).toContain("Preset 'npm' is already effective"); + expect(output).toContain("Preset 'pypi' is already effective"); + expect(payload.calls).toEqual([]); + expect(payload.registry.policies).toEqual(["npm", "pypi"]); + }); + + it("discloses and submits only the absent part of a mixed batch", () => { + const { output, payload } = runScenario({ + currentPolicy: policyWithPresets(["npm"]), + presetNames: ["npm", "pypi"], + batch: true, + }); + + expect(output).toContain("Preset 'npm' is already effective"); + expect(output).toContain("Effective egress that would be opened:"); + expect(output).toContain("policy 'pypi'"); + expect(payload.calls).toEqual(["policy set"]); + }); + + it("treats same-key drift as an effective-scope replacement", () => { + const drifted = policyWithPresets(["npm"]).replace("registry.npmjs.org", "drift.example"); + const { output, payload } = runScenario({ currentPolicy: drifted, presetNames: ["npm"] }); + + expect(output).toContain( + "Effective egress scope that would replace the current preset policy:", + ); + expect(output).not.toContain("Preset 'npm' is already effective"); + expect(payload.calls).toEqual(["policy set"]); + }); + + it("does not print a duplicate scope when the caller already disclosed it", () => { + const { output, payload } = runScenario({ + currentPolicy: "version: 1\nnetwork_policies: {}\n", + presetNames: ["npm"], + suppressDisclosure: true, + }); + + expect(output).not.toContain("Effective egress"); + expect(output).not.toContain("Preset 'npm' is already effective"); + expect(payload.calls).toEqual(["policy set"]); + expect(payload.registry.policies).toEqual(["npm"]); + }); + + it("discloses again when the live policy changed after an earlier no-op preview (#7179)", () => { + const { output, payload } = runScenario({ + currentPolicy: "version: 1\nnetwork_policies: {}\n", + presetNames: ["npm"], + disclosedPresetState: "match", + }); + + expect(output).toContain("Effective egress that would be opened:"); + expect(output).not.toContain("Preset 'npm' is already effective"); + expect(payload.calls).toEqual(["policy set"]); + }); +});