From a2b62969816e5859ffa4be47bd63bf76d15ee83d Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 19 Jul 2026 07:04:04 +0000 Subject: [PATCH 01/14] fix(policy): disclose full messaging-preset egress before channels apply Signed-off-by: Tinson Lai --- ci/test-file-size-budget.json | 2 +- .../sandbox/policy-channel-policy.test.ts | 9 +- .../policy-channel-scope-disclosure.test.ts | 104 +++++++++++ src/lib/actions/sandbox/policy-channel.ts | 13 +- .../channels/whatsapp/policy/hermes.yaml | 2 +- .../channels/whatsapp/policy/openclaw.yaml | 2 +- src/lib/policy/index.ts | 19 +- src/lib/policy/preset-scope-render.test.ts | 131 ++++++++++++++ src/lib/policy/preset-scope-render.ts | 162 ++++++++++++++++++ test/e2e/live/mcp-bridge-reliability.ts | 5 +- .../support/mcp-bridge-reliability.test.ts | 4 +- .../cli/policy-dispatch.test.ts | 23 ++- test/policies.test.ts | 21 ++- test/policy-channel-agent-resolution.test.ts | 2 +- 14 files changed, 453 insertions(+), 46 deletions(-) create mode 100644 src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts create mode 100644 src/lib/policy/preset-scope-render.test.ts create mode 100644 src/lib/policy/preset-scope-render.ts 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/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 26ba67840b1..2f31df3ecb2 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); @@ -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."); }); @@ -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(); 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..6a622c9f8b6 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -0,0 +1,104 @@ +// 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(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + 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"]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +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; + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 99bb7809047..7b2d48bef7b 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) { @@ -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}`, ); @@ -974,6 +972,7 @@ async function addSandboxChannelUnlocked( } if (dryRun) { + policies.logPresetScope(presetContent); console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); return; } 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/policy/index.ts b/src/lib/policy/index.ts index a4235b5423a..1d9a5acc162 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -44,6 +44,7 @@ import { type PolicyValue, parseNetworkPolicies, } from "./preset-parsing"; +import { logPresetScope, renderPresetScope } from "./preset-scope-render"; import { splitSemanticFindings, validatePolicySemantics } from "./semantic-validation"; const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); @@ -935,10 +936,7 @@ function applyPresetContent( } const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - const endpoints = getPresetEndpoints(presetContent); - if (endpoints.length > 0) { - console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); - } + logPresetScope(presetContent); // 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). @@ -1061,7 +1059,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { ); return false; } - const endpointLogs: string[][] = []; + const presetContents: string[] = []; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -1076,15 +1074,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } - const endpoints = getPresetEndpoints(presetContent); - endpointLogs.push(endpoints); + presetContents.push(presetContent); merged = mergePresetIntoPolicy(merged, presetEntries); } - for (const endpoints of endpointLogs) { - if (endpoints.length > 0) { - console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); - } + for (const presetContent of presetContents) { + logPresetScope(presetContent); } // Run before creating temp resources so a missing-binary exit doesn't @@ -1538,6 +1533,7 @@ export { loadPreset, loadPresetForSandbox, loadPresetFromFile, + logPresetScope, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, networkPoliciesHasAllowedIps, @@ -1549,6 +1545,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..d6da066a8f0 --- /dev/null +++ b/src/lib/policy/preset-scope-render.test.ts @@ -0,0 +1,131 @@ +// 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", () => { + 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 joined = lines.join("\n"); + expect(joined).toContain("- web.whatsapp.com:443 (access: full, tls: skip)"); + const idx = lines.findIndex((line) => line.includes("web.whatsapp.com:443")); + expect(idx).toBeGreaterThan(-1); + 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)"); + }); +}); diff --git a/src/lib/policy/preset-scope-render.ts b/src/lib/policy/preset-scope-render.ts new file mode 100644 index 00000000000..01ac54b86b6 --- /dev/null +++ b/src/lib/policy/preset-scope-render.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isObjectRecord } from "../core/json-types"; +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[]; +}; + +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 = endpoint.port ?? "?"; + const modeBits: string[] = []; + if (endpoint.access) modeBits.push(`access: ${endpoint.access}`); + if (endpoint.protocol) modeBits.push(`protocol: ${endpoint.protocol}`); + if (endpoint.tls) modeBits.push(`tls: ${endpoint.tls}`); + if (endpoint.enforcement) modeBits.push(`enforcement: ${endpoint.enforcement}`); + const modeSuffix = modeBits.length > 0 ? ` (${modeBits.join(", ")})` : ""; + const header = ` - ${endpoint.host}:${port}${modeSuffix}`; + if (endpoint.rules.length === 0) return [header]; + const ruleLines = endpoint.rules.map((rule) => { + const methods = rule.methods.join(", "); + const paths = rule.paths.join(", "); + return ` ${rule.action}: ${methods} ${paths}`; + }); + return [header, ...ruleLines]; +} + +export function renderPresetScope(content: string): string[] { + const scope = extractPresetScope(content); + if (!scope || scope.policies.length === 0) return []; + const lines: string[] = [" Effective egress that would be opened:"]; + for (const policy of scope.policies) { + lines.push(` policy '${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(` - ${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/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..6132cc9ac94 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -249,10 +249,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 +272,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'"); }); }); From a7b18a7edaf2df5cb2104d24f825551aba954a34 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 19 Jul 2026 08:50:31 +0000 Subject: [PATCH 02/14] fix(policy): read real preset fixtures in --from-dir egress test and fix substring-URL CodeQL alert The --from-dir package-contract test's loadPresetFromFile stub synthesized minimal fake content instead of reading the real on-disk fixture, so the new egress disclosure assertion could never see the endpoint's port. The substring host:port check in preset-scope-render.test.ts also tripped a CodeQL incomplete-URL-substring-sanitization alert; switched to exact line membership. Also restores NEMOCLAW_NON_INTERACTIVE via vi.stubEnv/ unstubAllEnvs instead of a raw delete, and adds the missing (#7179) suffix to a new describe title per project convention. Signed-off-by: Tinson Lai --- .../sandbox/policy-channel-scope-disclosure.test.ts | 3 ++- src/lib/policy/preset-scope-render.test.ts | 9 ++++----- test/package-contract/cli/policy-dispatch.test.ts | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts index 6a622c9f8b6..3e83f698768 100644 --- a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -39,7 +39,7 @@ function agentFixture(name: string): defs.AgentDefinition { } beforeEach(() => { - delete process.env.NEMOCLAW_NON_INTERACTIVE; + 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) => { @@ -54,6 +54,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); function collectLogOutput(): string { diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts index d6da066a8f0..28634ea4789 100644 --- a/src/lib/policy/preset-scope-render.test.ts +++ b/src/lib/policy/preset-scope-render.test.ts @@ -36,7 +36,7 @@ network_policies: - { path: /usr/bin/node } `; -describe("renderPresetScope", () => { +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([]); @@ -48,10 +48,9 @@ describe("renderPresetScope", () => { it("renders full L4 tunnel endpoints with access + tls but no rule lines", () => { const lines = renderPresetScope(WHATSAPP_LIKE_PRESET); - const joined = lines.join("\n"); - expect(joined).toContain("- web.whatsapp.com:443 (access: full, tls: skip)"); - const idx = lines.findIndex((line) => line.includes("web.whatsapp.com:443")); - expect(idx).toBeGreaterThan(-1); + 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:/); }); diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 6132cc9ac94..9da5be2486f 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 }); From 4cef61d483a428a889fe8b241e222f8c67e26c5b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 03:28:28 -0700 Subject: [PATCH 03/14] fix(policy): disclose preset scope before lifecycle effects Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 7 + .../sandbox/policy-channel-conflict.test.ts | 28 +++ .../policy-channel-scope-disclosure.test.ts | 1 + src/lib/actions/sandbox/policy-channel.ts | 28 ++- src/lib/onboard.ts | 12 +- .../onboard/sandbox-create-intent-types.ts | 2 + .../sandbox-create-plan-materialization.ts | 7 + src/lib/onboard/sandbox-create-plan.test.ts | 51 +++++- src/lib/policy/index.ts | 162 +++++++++++++----- src/lib/policy/preset-scope-render.test.ts | 29 ++++ src/lib/policy/preset-scope-render.ts | 51 ++++-- test/channels-add-preset.test.ts | 33 +++- .../cli/policy-dispatch.test.ts | 24 +++ test/policy-preset-noop-disclosure.test.ts | 151 ++++++++++++++++ 14 files changed, 522 insertions(+), 64 deletions(-) create mode 100644 test/policy-preset-noop-disclosure.test.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0e8c3fed80f..4c7bf5fb883 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`): @@ -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 or protocol 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 @@ -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 ` 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. @@ -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 @@ -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. diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index e7fd0b2e958..bb5426c52f7 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([]); @@ -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 }); diff --git a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts index 3e83f698768..1c66db5d4bb 100644 --- a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -50,6 +50,7 @@ beforeEach(() => { 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(() => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 7b2d48bef7b..4f4b5597fd8 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -904,6 +904,15 @@ function hydrateAddChannelEnvFromStoredState(sandboxName: string): void { hydrateMessagingChannelConfig(getStoredMessagingChannelConfig(sandboxName, savedSession)); } +function discloseChannelPresetScope( + sandboxName: string, + presetName: string, + presetContent: string, +): void { + const gatewayState = policies.getPresetContentGatewayState(sandboxName, presetContent); + policies.logPresetScopeForState(presetName, presetContent, gatewayState); +} + function safeLoadOnboardSession(): ReturnType { try { return onboardSession.loadSession(); @@ -971,8 +980,11 @@ async function addSandboxChannelUnlocked( process.exit(1); } + // Disclose before credential collection, conflict prompts, or any gateway / + // registry mutation. The core apply path rechecks immediately before set. + discloseChannelPresetScope(sandboxName, canonical, presetContent); + if (dryRun) { - policies.logPresetScope(presetContent); console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); return; } @@ -1437,6 +1449,20 @@ async function sandboxChannelsSetEnabled( return; } + if (!disabled) { + const presetContent = policies.loadPresetForSandbox(sandboxName, normalized); + const presetPolicyKeys = + presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); + if (presetContent === null || presetPolicyKeys.length === 0) { + console.error(` Cannot load policy preset for channel '${normalized}'.`); + console.error( + ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels start ${normalized}`, + ); + process.exit(1); + } + discloseChannelPresetScope(sandboxName, normalized, presetContent); + } + if (dryRun) { console.log(` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`); return; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0367dd0a0c4..bfc95850377 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2719,15 +2719,17 @@ async function createSandboxWithBaseImageResolution( upsertMessagingProviders, getHermesToolGatewayProviderName: (targetSandbox) => getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), + discloseInitialSandboxPolicy: (policy) => { + 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")); + }, }); 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/sandbox-create-intent-types.ts b/src/lib/onboard/sandbox-create-intent-types.ts index d87945045a8..2b837c13406 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"; @@ -88,5 +89,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 f859695d285..41384364a68 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 b93f08233a2..2ce15e77c54 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 1d9a5acc162..dfce3f23602 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,7 +45,7 @@ import { type PolicyValue, parseNetworkPolicies, } from "./preset-parsing"; -import { logPresetScope, renderPresetScope } from "./preset-scope-render"; +import { escapeTerminalText, logPresetScope, renderPresetScope } from "./preset-scope-render"; import { splitSemanticFindings, validatePolicySemantics } from "./semantic-validation"; const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); @@ -561,6 +562,66 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st return YAML.stringify(output); } +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[], @@ -936,30 +997,35 @@ function applyPresetContent( } const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - logPresetScope(presetContent); + const presetState = classifyPresetEntries(currentPolicy, presetEntries); + logPresetScopeForState(presetName, presetContent, presetState); + + const policyChanged = !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 }); + 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 }); - try { - 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 */ + 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 */ + } } } @@ -1059,7 +1125,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { ); return false; } - const presetContents: string[] = []; + const presetContents: Array<{ + content: string; + name: string; + state: PresetPolicyState; + }> = []; + const originalPolicy = merged; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -1074,38 +1145,43 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } - presetContents.push(presetContent); + const state = classifyPresetEntries(merged, presetEntries); + presetContents.push({ content: presetContent, name: presetName, state }); merged = mergePresetIntoPolicy(merged, presetEntries); } - for (const presetContent of presetContents) { - logPresetScope(presetContent); + 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(); - - 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 }); + if (policyChanged) assertOpenshellResolvable(); - 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 */ + } } } @@ -1533,7 +1609,9 @@ export { loadPreset, loadPresetForSandbox, loadPresetFromFile, + logPresetNoNewEgress, logPresetScope, + logPresetScopeForState, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, networkPoliciesHasAllowedIps, diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts index 28634ea4789..893f56c594c 100644 --- a/src/lib/policy/preset-scope-render.test.ts +++ b/src/lib/policy/preset-scope-render.test.ts @@ -127,4 +127,33 @@ describe("renderPresetScope (#7179)", () => { 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}"); + }); }); diff --git a/src/lib/policy/preset-scope-render.ts b/src/lib/policy/preset-scope-render.ts index 01ac54b86b6..8fea8552b36 100644 --- a/src/lib/policy/preset-scope-render.ts +++ b/src/lib/policy/preset-scope-render.ts @@ -30,6 +30,28 @@ 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(""); +} + function toStringOrUndefined(value: PolicyValue | undefined): string | undefined { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); @@ -113,29 +135,34 @@ function extractPresetScope(content: string): PresetScope | null { } function formatEndpoint(endpoint: EndpointScope): string[] { - const port = endpoint.port ?? "?"; + const port = escapeTerminalText(String(endpoint.port ?? "?")); const modeBits: string[] = []; - if (endpoint.access) modeBits.push(`access: ${endpoint.access}`); - if (endpoint.protocol) modeBits.push(`protocol: ${endpoint.protocol}`); - if (endpoint.tls) modeBits.push(`tls: ${endpoint.tls}`); - if (endpoint.enforcement) modeBits.push(`enforcement: ${endpoint.enforcement}`); + if (endpoint.access) modeBits.push(`access: ${escapeTerminalText(endpoint.access)}`); + if (endpoint.protocol) modeBits.push(`protocol: ${escapeTerminalText(endpoint.protocol)}`); + if (endpoint.tls) modeBits.push(`tls: ${escapeTerminalText(endpoint.tls)}`); + if (endpoint.enforcement) { + modeBits.push(`enforcement: ${escapeTerminalText(endpoint.enforcement)}`); + } const modeSuffix = modeBits.length > 0 ? ` (${modeBits.join(", ")})` : ""; - const header = ` - ${endpoint.host}:${port}${modeSuffix}`; + const header = ` - ${escapeTerminalText(endpoint.host)}:${port}${modeSuffix}`; if (endpoint.rules.length === 0) return [header]; const ruleLines = endpoint.rules.map((rule) => { - const methods = rule.methods.join(", "); - const paths = rule.paths.join(", "); + const methods = rule.methods.map(escapeTerminalText).join(", "); + const paths = rule.paths.map(escapeTerminalText).join(", "); return ` ${rule.action}: ${methods} ${paths}`; }); return [header, ...ruleLines]; } -export function renderPresetScope(content: string): string[] { +export function renderPresetScope( + content: string, + options: RenderPresetScopeOptions = {}, +): string[] { const scope = extractPresetScope(content); if (!scope || scope.policies.length === 0) return []; - const lines: string[] = [" Effective egress that would be opened:"]; + const lines: string[] = [options.heading ?? " Effective egress that would be opened:"]; for (const policy of scope.policies) { - lines.push(` policy '${policy.name}':`); + lines.push(` policy '${escapeTerminalText(policy.name)}':`); if (policy.endpoints.length === 0) { lines.push(" (no endpoints declared)"); } else { @@ -146,7 +173,7 @@ export function renderPresetScope(content: string): string[] { if (policy.binaries.length > 0) { lines.push(" binaries:"); for (const bin of policy.binaries) { - lines.push(` - ${bin}`); + lines.push(` - ${escapeTerminalText(bin)}`); } } } diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index ea5f946dba0..6ccfdf5f8fd 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" }); @@ -375,6 +401,7 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(messagingUpdate?.[1]).not.toHaveProperty("disabledChannels"); expect(applyPresetSpy).toHaveBeenCalledOnce(); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp"); + expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan(callOrder.indexOf("updateSandbox")); expect(callOrder.indexOf("applyPreset:whatsapp")).toBeLessThan( callOrder.indexOf("promptAndRebuild"), ); diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 9da5be2486f..3b23f6f7e65 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -209,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 before the prompt (#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"); diff --git a/test/policy-preset-noop-disclosure.test.ts b/test/policy-preset-noop-disclosure.test.ts new file mode 100644 index 00000000000..9ef02252fab --- /dev/null +++ b/test/policy-preset-noop-disclosure.test.ts @@ -0,0 +1,151 @@ +// 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; +}; + +function runScenario({ currentPolicy, presetNames, batch = false }: 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])})`; + 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"]); + }); +}); From 5bd1f739cb4cf7609d9e9a99d1298f23e97968a8 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 03:33:13 -0700 Subject: [PATCH 04/14] refactor(onboard): keep policy disclosure out of entrypoint Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 9 ++------- src/lib/onboard/initial-policy.ts | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bfc95850377..3cc936bde2c 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, @@ -2719,13 +2720,7 @@ async function createSandboxWithBaseImageResolution( upsertMessagingProviders, getHermesToolGatewayProviderName: (targetSandbox) => getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), - discloseInitialSandboxPolicy: (policy) => { - 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")); - }, + discloseInitialSandboxPolicy, }); if (initialSandboxPolicy.cleanup) { process.on("exit", initialSandboxPolicy.cleanup); 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"; From 2d73e77d8f93c2b804097d1d4f1ce95ebcc94acd Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 19 Jul 2026 10:39:04 +0000 Subject: [PATCH 05/14] fix(policy): sanitize disclosure output and cover every preset-application path per #7179 Strip terminal control characters from every rendered preset field so a crafted --from-file/--from-dir preset cannot forge or erase the egress disclosure block before a user reviews it. Suppress the disclosure's "would be opened" wording when a preset's declared policies already match the sandbox's live policy, via a new presetIntroducesNewEgress delta check shared by applyPresetContent, applyPresets, and mergePresetNamesIntoPolicy. This also gives onboarding's create-time preset merge (interactive and headless) a full audit block before the sandbox is created, and rebuild's preset replay already routes through the same functions. Add disclosure to channels start --dry-run (previously silent) and make channels add --dry-run no-op aware via a new logSandboxPresetScopeIfNew helper, so a dry-run preview cannot claim new egress for an already-applied preset. Signed-off-by: Tinson Lai --- .../checks/openshell-policy-mutation-read.mts | 2 +- .../policy-channel-remove-flow.test.ts | 49 +++++++- .../policy-channel-scope-disclosure.test.ts | 31 +++++ src/lib/actions/sandbox/policy-channel.ts | 8 +- src/lib/policy/index.ts | 67 ++++++++++- src/lib/policy/preset-ownership.ts | 12 ++ src/lib/policy/preset-scope-render.test.ts | 26 +++++ src/lib/policy/preset-scope-render.ts | 32 ++++-- ...licy-preset-egress-disclosure-noop.test.ts | 108 ++++++++++++++++++ 9 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 test/policy-preset-egress-disclosure-noop.test.ts diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index c0677c8f704..74bc81faab4 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -44,7 +44,7 @@ export const MUTATION_READS: readonly AuditedMutationRead[] = [ }, { relativePath: "src/lib/policy/index.ts", - expectedReadCalls: 6, + expectedReadCalls: 7, baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", unsafeBaseCommand: "runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true })", fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true })", 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..c096d5a4a9d 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,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"]); @@ -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 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("already matches the sandbox's live network policy; no new egress."); + 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 index 3e83f698768..e84e8e853d3 100644 --- a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr import * as defs from "../../agent/defs"; import * as policy from "../../policy"; +import * as runner from "../../runner"; import * as registry from "../../state/registry"; import { addSandboxChannel } from "./policy-channel"; @@ -102,4 +103,34 @@ describe("channels add --dry-run discloses effective preset egress before mutati expect(wouldEnable).toBeGreaterThan(scopeHeader); void exitMock; }); + + it("does not claim new egress when the channel's preset already matches the live policy (#7179)", async () => { + const liveWhatsappPolicy = [ + "version: 1", + "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 }", + "", + ].join("\n"); + vi.spyOn(runner, "runCapture").mockReturnValue(liveWhatsappPolicy); + + await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true }); + + const output = collectLogOutput(); + expect(output).not.toContain("Effective egress that would be opened:"); + expect(output).toContain("already matches the sandbox's live network policy; no new egress."); + }); }); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 7b2d48bef7b..c95912ab901 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -972,7 +972,7 @@ async function addSandboxChannelUnlocked( } if (dryRun) { - policies.logPresetScope(presetContent); + policies.logSandboxPresetScopeIfNew(sandboxName, canonical, presetContent); console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); return; } @@ -1438,6 +1438,12 @@ async function sandboxChannelsSetEnabled( } if (dryRun) { + if (!disabled) { + const presetContent = policies.loadPresetForSandbox(sandboxName, normalized); + if (presetContent) { + policies.logSandboxPresetScopeIfNew(sandboxName, normalized, presetContent); + } + } console.log(` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`); return; } diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 1d9a5acc162..211a26a27d3 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -34,7 +34,7 @@ import { stripProviderComposedPolicies, withoutProviderComposedPolicies, } from "./merge"; -import { findUnexpectedExistingPolicyKey } from "./preset-ownership"; +import { findUnexpectedExistingPolicyKey, presetIntroducesNewEgress } from "./preset-ownership"; import { isPolicyDocument, isPolicyObject, @@ -578,6 +578,13 @@ function mergePresetNamesIntoPolicy( continue; } + if (presetContent && presetIntroducesNewEgress(currentPolicy, presetContent)) { + logPresetScope(presetContent); + } else if (presetContent) { + console.log( + ` Preset '${presetName}' already matches the sandbox's live network policy; no new egress.`, + ); + } merged = mergePresetIntoPolicy(merged, presetEntries); appliedPresets.push(presetName); } @@ -830,6 +837,36 @@ function selectForRemoval( }); } +/** + * Print the full effective-egress disclosure for `presetContent` against a + * sandbox's live policy, or a no-new-egress note when the preset's declared + * policies already match what is live. Used by preview paths (`--dry-run`) + * that show what a mutation would do without performing it, so they cannot + * misreport a no-op as new egress. If the live policy cannot be read, fails + * open toward disclosure (unlike the mutating paths, which fail closed and + * refuse to apply). + */ +function logSandboxPresetScopeIfNew( + sandboxName: string, + presetName: string, + presetContent: string, +): void { + let rawPolicy: string | null = null; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + } catch { + /* Fall through to disclosure below. */ + } + const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); + if (!currentPolicy || presetIntroducesNewEgress(currentPolicy, presetContent)) { + logPresetScope(presetContent); + return; + } + console.log( + ` Preset '${presetName}' already matches the sandbox's live network policy; no new egress.`, + ); +} + /** * Apply raw preset content (already loaded in memory) to a running sandbox. * Validates the sandbox name, extracts the `network_policies` entries, merges @@ -936,7 +973,13 @@ function applyPresetContent( } const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - logPresetScope(presetContent); + if (presetIntroducesNewEgress(currentPolicy, presetContent)) { + logPresetScope(presetContent); + } else { + console.log( + ` Preset '${presetName}' already matches the sandbox's live network policy; no new egress.`, + ); + } // 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). @@ -1059,7 +1102,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { ); return false; } - const presetContents: string[] = []; + // Every disclosure below compares against this pre-batch snapshot, not the + // progressively merged policy, so it always answers "is this new relative + // to what is live right now" rather than "new relative to earlier presets + // in this same batch". + const originalPolicy = merged; + const presetEntriesByName: { presetName: string; presetContent: string }[] = []; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -1074,12 +1122,18 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } - presetContents.push(presetContent); + presetEntriesByName.push({ presetName, presetContent }); merged = mergePresetIntoPolicy(merged, presetEntries); } - for (const presetContent of presetContents) { - logPresetScope(presetContent); + for (const { presetName, presetContent } of presetEntriesByName) { + if (presetIntroducesNewEgress(originalPolicy, presetContent)) { + logPresetScope(presetContent); + } else { + console.log( + ` Preset '${presetName}' already matches the sandbox's live network policy; no new egress.`, + ); + } } // Run before creating temp resources so a missing-binary exit doesn't @@ -1534,6 +1588,7 @@ export { loadPresetForSandbox, loadPresetFromFile, logPresetScope, + logSandboxPresetScopeIfNew, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, networkPoliciesHasAllowedIps, diff --git a/src/lib/policy/preset-ownership.ts b/src/lib/policy/preset-ownership.ts index 1dea5169dd7..b58d1c8c261 100644 --- a/src/lib/policy/preset-ownership.ts +++ b/src/lib/policy/preset-ownership.ts @@ -10,6 +10,18 @@ function policyMap(content: string): Record { return policies && typeof policies === "object" && !Array.isArray(policies) ? policies : {}; } +/** + * Return whether applying `presetContent` on top of `currentPolicy` would add + * or change any network_policies key. False means the preset's declared + * policies already match what is live, so disclosing "effective egress that + * would be opened" would misrepresent a no-op mutation as new egress. + */ +export function presetIntroducesNewEgress(currentPolicy: string, presetContent: string): boolean { + const current = policyMap(currentPolicy); + const incoming = policyMap(presetContent); + return Object.entries(incoming).some(([key, value]) => !isDeepStrictEqual(current[key], value)); +} + /** * Return the first incoming key whose live value is not exactly the value the * caller previously proved it owned. A null expected document owns no keys. diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts index 28634ea4789..7a5bc44a236 100644 --- a/src/lib/policy/preset-scope-render.test.ts +++ b/src/lib/policy/preset-scope-render.test.ts @@ -127,4 +127,30 @@ describe("renderPresetScope (#7179)", () => { expect(joined).toContain("policy 'bare':"); expect(joined).toContain("(no endpoints declared)"); }); + + it("strips terminal control characters from every rendered field so a crafted preset cannot forge or erase the disclosure", () => { + const esc = String.fromCharCode(0x1b); + const clearScreen = esc + "[2J" + esc + "[H"; + const hostile = [ + "network_policies:", + " hostile:", + ` name: "innocent${clearScreen}evil"`, + " endpoints:", + ` - host: "web.whatsapp.com${clearScreen}spoofed.example.com"`, + " port: 443", + ` protocol: "rest${clearScreen}x"`, + ` access: "full${clearScreen}x"`, + ` tls: "skip${clearScreen}x"`, + ` enforcement: "enforce${clearScreen}x"`, + " rules:", + ` - allow: { method: "GET${clearScreen}x", path: "/**${clearScreen}x" }`, + " binaries:", + ` - { path: "/usr/bin/node${clearScreen}x" }`, + "", + ].join("\n"); + const joined = renderPresetScope(hostile).join("\n"); + expect(joined).not.toContain(esc); + expect(joined).toContain("innocent[2J[Hevil"); + expect(joined).toContain("web.whatsapp.com[2J[Hspoofed.example.com:443"); + }); }); diff --git a/src/lib/policy/preset-scope-render.ts b/src/lib/policy/preset-scope-render.ts index 01ac54b86b6..f232ebd5058 100644 --- a/src/lib/policy/preset-scope-render.ts +++ b/src/lib/policy/preset-scope-render.ts @@ -4,6 +4,17 @@ import { isObjectRecord } from "../core/json-types"; import { type PolicyValue, parseNetworkPolicies } from "./preset-parsing"; +// Every rendered field is parsed straight from a preset YAML, which for +// --from-file/--from-dir presets is arbitrary user-supplied content: strip +// terminal control characters so a crafted host/protocol/path cannot forge +// or erase the disclosure block before the user reads it (no escape-sequence +// forgery in operator terminals). +const CONTROL_CHARS_RE = new RegExp("[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f]", "g"); + +function sanitizeForTerminal(value: string): string { + return value.replace(CONTROL_CHARS_RE, ""); +} + type RuleScope = { action: "allow" | "deny"; methods: string[]; @@ -31,20 +42,22 @@ export type PresetScope = { }; function toStringOrUndefined(value: PolicyValue | undefined): string | undefined { - if (typeof value === "string") return value; + if (typeof value === "string") return sanitizeForTerminal(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; + if (typeof value === "string" && value.length > 0) return sanitizeForTerminal(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]; + if (Array.isArray(value)) { + return value.filter((v): v is string => typeof v === "string").map(sanitizeForTerminal); + } + if (typeof value === "string") return [sanitizeForTerminal(value)]; return []; } @@ -74,10 +87,12 @@ function collectBinaries(policy: Record): string[] { const out: string[] = []; for (const entry of binaries) { if (typeof entry === "string") { - out.push(entry); + out.push(sanitizeForTerminal(entry)); continue; } - if (isObjectRecord(entry) && typeof entry.path === "string") out.push(entry.path); + if (isObjectRecord(entry) && typeof entry.path === "string") { + out.push(sanitizeForTerminal(entry.path)); + } } return out; } @@ -88,13 +103,14 @@ function extractPresetScope(content: string): PresetScope | 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 name = sanitizeForTerminal(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; + const host = + typeof rawEndpoint.host === "string" ? sanitizeForTerminal(rawEndpoint.host) : null; if (!host) continue; endpoints.push({ host, diff --git a/test/policy-preset-egress-disclosure-noop.test.ts b/test/policy-preset-egress-disclosure-noop.test.ts new file mode 100644 index 00000000000..1f858543039 --- /dev/null +++ b/test/policy-preset-egress-disclosure-noop.test.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const requireForTest = createRequire(import.meta.url); +const policies = requireForTest( + path.join(import.meta.dirname, "..", "src", "lib", "policy", "index.ts"), +) as typeof import("../src/lib/policy"); + +const hasScopeHeader = (m: unknown): m is string => + typeof m === "string" && m.includes("Effective egress that would be opened"); + +const LIVE_NPM_POLICY = [ + "version: 1", + "network_policies:", + " npm_yarn:", + " name: npm_yarn", + " endpoints:", + " - host: registry.npmjs.org", + " port: 443", + " access: full", + " tls: skip", + " - host: registry.yarnpkg.com", + " port: 443", + " access: full", + " tls: skip", + " binaries:", + " - { path: /usr/local/bin/npm* }", + " - { path: /usr/local/bin/npx* }", + " - { path: /usr/local/bin/node* }", + " - { path: /usr/local/bin/yarn* }", + " - { path: /usr/bin/npm* }", + " - { path: /usr/bin/node* }", + "", +].join("\n"); + +describe("preset egress disclosure does not claim new egress for a no-op (#7179)", () => { + it("applyPreset does not claim new egress when the preset already matches the live policy", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-noop-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + fs.writeFileSync( + fakeOpenshell, + `#!/bin/sh\ncat <<'POLICY_EOF'\n${LIVE_NPM_POLICY}POLICY_EOF\nexit 0\n`, + { mode: 0o755 }, + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + try { + try { + policies.applyPreset("test-sandbox", "npm"); + } catch { + /* applyPreset may throw if sandbox not running — we only care about the log */ + } + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); + expect(messages.some(hasScopeHeader)).toBe(false); + expect( + messages.some((m) => m?.includes("already matches the sandbox's live network policy")), + ).toBe(true); + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("mergePresetNamesIntoPolicy discloses effective egress for a newly merged preset before onboarding creates the sandbox", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const current = "version: 1\n\nnetwork_policies: {}\n"; + + policies.mergePresetNamesIntoPolicy(current, ["npm"]); + + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); + expect(messages.some(hasScopeHeader)).toBe(true); + expect(messages.some((m) => m?.includes("registry.npmjs.org"))).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); + + it("mergePresetNamesIntoPolicy does not claim new egress when the named preset already matches the current policy", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + policies.mergePresetNamesIntoPolicy(LIVE_NPM_POLICY, ["npm"]); + + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); + expect(messages.some(hasScopeHeader)).toBe(false); + expect( + messages.some((m) => m?.includes("already matches the sandbox's live network policy")), + ).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); +}); From fc8ed4c3a6b506f0690febe0539fa5bc161c692c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 03:46:16 -0700 Subject: [PATCH 06/14] fix(policy): preserve owned preset refresh transactions Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- src/lib/policy/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index dfce3f23602..d866dda0dd3 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1000,7 +1000,17 @@ function applyPresetContent( const presetState = classifyPresetEntries(currentPolicy, presetEntries); logPresetScopeForState(presetName, presetContent, presetState); - const policyChanged = !policyDocumentsMatch(currentPolicy, merged); + // 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). From d868b1172e3e9df6967176ecffc9ea078165e80d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 03:54:17 -0700 Subject: [PATCH 07/14] style(policy): format owned preset refresh Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- src/lib/policy/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index d866dda0dd3..f19044acc0a 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1009,8 +1009,7 @@ function applyPresetContent( options, "expectedExistingNetworkPolicyContent", ); - const policyChanged = - requiresOwnedKeyRefresh || !policyDocumentsMatch(currentPolicy, merged); + 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). From feed1fa82efdfc4af065b013ff48c86efbe62a75 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 04:25:42 -0700 Subject: [PATCH 08/14] refactor(policy): consolidate channel scope disclosure Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 2 +- src/lib/actions/sandbox/policy-channel.ts | 53 +++++++++---------- .../cli/policy-dispatch.test.ts | 2 +- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 4c7bf5fb883..abb4afe05d8 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1640,7 +1640,7 @@ 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 or protocol settings, allowed methods and paths, and binary allowlist. +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 diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 4f4b5597fd8..8e76b2c2e25 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -913,6 +913,30 @@ function discloseChannelPresetScope( policies.logPresetScopeForState(presetName, presetContent, gatewayState); } +function loadValidateAndDiscloseChannelPreset( + sandboxName: string, + channelName: string, + verb: "add" | "start", +): void { + 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); + } + discloseChannelPresetScope(sandboxName, channelName, presetContent); +} + function safeLoadOnboardSession(): ReturnType { try { return onboardSession.loadSession(); @@ -965,24 +989,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. - discloseChannelPresetScope(sandboxName, canonical, presetContent); + loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add"); if (dryRun) { console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); @@ -1450,17 +1459,7 @@ async function sandboxChannelsSetEnabled( } if (!disabled) { - const presetContent = policies.loadPresetForSandbox(sandboxName, normalized); - const presetPolicyKeys = - presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); - if (presetContent === null || presetPolicyKeys.length === 0) { - console.error(` Cannot load policy preset for channel '${normalized}'.`); - console.error( - ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels start ${normalized}`, - ); - process.exit(1); - } - discloseChannelPresetScope(sandboxName, normalized, presetContent); + loadValidateAndDiscloseChannelPreset(sandboxName, normalized, "start"); } if (dryRun) { diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 3b23f6f7e65..3dab297fc93 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -209,7 +209,7 @@ 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 before the prompt (#7179)", () => { + 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( From ab1e3c9fbed3e3451cb1440a70a4d3977f28d6ac Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 19 Jul 2026 12:15:46 +0000 Subject: [PATCH 09/14] test(policy): mock live policy read in start dry-run disclosure test Deliberately stub runner.runCapture to a clean empty policy instead of relying on the real subprocess call failing incidentally, so the disclosure state is a deterministic "absent" rather than an accidental "live delta unavailable". Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/policy-channel-remove-flow.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 78babb28468..c6931fb0f05 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -64,16 +64,17 @@ describe("policy channel remove/enable flows", () => { 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(); 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("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 scope to be applied (live delta unavailable):"), + line.includes("Effective egress that would be opened:"), ); const wouldStart = lines.findIndex((line) => line.includes("--dry-run: would start channel")); expect(scopeHeader).toBeGreaterThan(-1); From a835d7a31b7071168f510be39aec03acc733893e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 06:24:45 -0700 Subject: [PATCH 10/14] fix(policy): avoid duplicate egress disclosure Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- .../sandbox/policy-channel-conflict.test.ts | 12 ++++++-- .../sandbox/policy-channel-policy.test.ts | 24 ++++++++++++---- .../sandbox/policy-channel-refresh.test.ts | 8 ++++-- src/lib/actions/sandbox/policy-channel.ts | 27 ++++++++++++++---- src/lib/policy/index.ts | 5 +++- test/channels-add-preset.test.ts | 28 ++++++++++++++----- test/policy-preset-noop-disclosure.test.ts | 23 +++++++++++++-- 7 files changed, 101 insertions(+), 26 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index bb5426c52f7..cb2a9f3ed2c 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -1066,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", { + suppressDisclosure: true, + }); expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]); expect(applyPresetMock.mock.invocationCallOrder[0]).toBeLessThan( rebuildSandboxMock.mock.invocationCallOrder[0], @@ -1092,7 +1094,9 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { + suppressDisclosure: true, + }); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("Change queued"); }); @@ -1136,7 +1140,9 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { "process.exit(1)", ); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { + suppressDisclosure: true, + }); 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 2f31df3ecb2..ac22c9e49d2 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -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 () => { @@ -149,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 () => { @@ -158,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 () => { @@ -276,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 () => { @@ -285,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 () => { @@ -293,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.ts b/src/lib/actions/sandbox/policy-channel.ts index 8e76b2c2e25..bd37072cd09 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -196,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"); @@ -250,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 @@ -1014,7 +1015,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", { + disclosureAlreadyShown: true, + }) + ) { process.exit(1); } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); @@ -1066,7 +1071,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", { + disclosureAlreadyShown: true, + }) + ) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, priorCreds, @@ -1153,9 +1162,12 @@ export function applyChannelPresetIfAvailable( sandboxName: string, channelName: string, retryAction: "add" | "start" = "add", + options: { disclosureAlreadyShown?: boolean } = {}, ): boolean { try { - const applied = policies.applyPreset(sandboxName, channelName); + const applied = options.disclosureAlreadyShown + ? policies.applyPreset(sandboxName, channelName, { suppressDisclosure: true }) + : policies.applyPreset(sandboxName, channelName); if (!applied) { console.error( ` ${YW}⚠${R} Cannot enable channel '${channelName}': policy preset failed to apply.`, @@ -1477,7 +1489,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", { + disclosureAlreadyShown: true, + }) + ) { const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, normalized, true); if (!rolledBack) { console.error( diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index f19044acc0a..767ccc57972 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -912,6 +912,7 @@ function applyPresetContent( expectedExistingNetworkPolicyContent?: string | null; nonFatal?: boolean; skipRegistryUpdate?: boolean; + suppressDisclosure?: boolean; } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -998,7 +999,9 @@ function applyPresetContent( const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); const presetState = classifyPresetEntries(currentPolicy, presetEntries); - logPresetScopeForState(presetName, presetContent, presetState); + if (!options.suppressDisclosure) { + 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 diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 6ccfdf5f8fd..dfdd697b47d 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -368,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, { + suppressDisclosure: true, + }); expect(loadPresetForSandboxSpy).toHaveBeenCalledWith("test-sb", channel); expect(callOrder.indexOf(`applyPreset:${channel}`)).toBeLessThan( callOrder.indexOf("promptAndRebuild"), @@ -400,7 +402,9 @@ 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", { + suppressDisclosure: true, + }); expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan(callOrder.indexOf("updateSandbox")); expect(callOrder.indexOf("applyPreset:whatsapp")).toBeLessThan( callOrder.indexOf("promptAndRebuild"), @@ -416,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", { + suppressDisclosure: true, + }); expect(callOrder).not.toContain("promptAndRebuild"); }); @@ -512,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", { + suppressDisclosure: true, + }); expect(updateSandboxSpy).not.toHaveBeenCalled(); expect(deleteCredentialSpy).toHaveBeenCalledWith("TELEGRAM_BOT_TOKEN"); expect(sessionUpdates).toEqual([]); @@ -662,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", { + suppressDisclosure: true, + }); expect(sessionUpdates).toEqual([]); expect(sessionState?.policyPresets).toEqual(["npm", "github"]); }); @@ -672,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", { + suppressDisclosure: true, + }); expect(sessionUpdates).toEqual([]); expect(callOrder).toContain("promptAndRebuild"); }); @@ -682,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", { + suppressDisclosure: true, + }); expect(callOrder).toContain("promptAndRebuild"); }); diff --git a/test/policy-preset-noop-disclosure.test.ts b/test/policy-preset-noop-disclosure.test.ts index 9ef02252fab..2f1a7e35de0 100644 --- a/test/policy-preset-noop-disclosure.test.ts +++ b/test/policy-preset-noop-disclosure.test.ts @@ -19,9 +19,15 @@ type Scenario = { currentPolicy: string; presetNames: string[]; batch?: boolean; + suppressDisclosure?: boolean; }; -function runScenario({ currentPolicy, presetNames, batch = false }: Scenario) { +function runScenario({ + currentPolicy, + presetNames, + batch = false, + suppressDisclosure = false, +}: Scenario) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preset-disclosure-")); tempRoots.push(root); const currentPolicyPath = path.join(root, "current.yaml"); @@ -57,7 +63,7 @@ exit 1 const invocation = batch ? `policies.applyPresets("alpha", ${JSON.stringify(presetNames)})` - : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])})`; + : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])}, ${JSON.stringify({ suppressDisclosure })})`; const script = ` const fs = require("node:fs"); const policies = require(${POLICY_MODULE}); @@ -148,4 +154,17 @@ describe("preset no-op egress disclosure (#7179)", () => { 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"]); + }); }); From 4b12426f421026c69b48cf549e69abcfac0a321c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 19 Jul 2026 06:29:22 -0700 Subject: [PATCH 11/14] docs(policy): clarify no-op egress disclosure Co-authored-by: Tinson Lai Signed-off-by: Apurv Kumaria --- docs/reference/commands.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index abb4afe05d8..a2325159815 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1848,8 +1848,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 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. +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. @@ -1921,7 +1922,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. +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 From 13fb5bfbf80eee5ef1609602a07d128be0751954 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 19 Jul 2026 06:54:17 -0700 Subject: [PATCH 12/14] fix(policy): revalidate disclosure state before mutation Signed-off-by: Carlos Villela --- .../sandbox/policy-channel-conflict.test.ts | 6 ++-- src/lib/actions/sandbox/policy-channel.ts | 29 ++++++++++--------- src/lib/policy/index.ts | 8 +++-- test/channels-add-preset.test.ts | 14 ++++----- test/policy-preset-noop-disclosure.test.ts | 16 +++++++++- 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index cb2a9f3ed2c..9034683b202 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -1067,7 +1067,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(rebuildSandboxMock).toHaveBeenCalledWith("alpha", ["--yes"]); expect(applyPresetMock.mock.invocationCallOrder[0]).toBeLessThan( @@ -1095,7 +1095,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("Change queued"); @@ -1141,7 +1141,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { ); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(registry.getDisabledChannels("alpha")).toContain("teams"); expect(rebuildSandboxMock).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index bd37072cd09..c35e7b885be 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -909,16 +909,17 @@ function discloseChannelPresetScope( sandboxName: string, presetName: string, presetContent: string, -): void { +): 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", -): void { +): policies.PresetPolicyState | null { const presetContent = policies.loadPresetForSandbox(sandboxName, channelName); const presetPolicyKeys = presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); @@ -935,7 +936,7 @@ function loadValidateAndDiscloseChannelPreset( ); process.exit(1); } - discloseChannelPresetScope(sandboxName, channelName, presetContent); + return discloseChannelPresetScope(sandboxName, channelName, presetContent); } function safeLoadOnboardSession(): ReturnType { @@ -992,7 +993,7 @@ async function addSandboxChannelUnlocked( // Disclose before credential collection, conflict prompts, or any gateway / // registry mutation. The core apply path rechecks immediately before set. - loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add"); + const disclosedPresetState = loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add"); if (dryRun) { console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); @@ -1017,7 +1018,7 @@ async function addSandboxChannelUnlocked( if (manifest.auth.mode === "in-sandbox-qr") { if ( !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { - disclosureAlreadyShown: true, + disclosedPresetState, }) ) { process.exit(1); @@ -1073,7 +1074,7 @@ async function addSandboxChannelUnlocked( if ( !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { - disclosureAlreadyShown: true, + disclosedPresetState, }) ) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { @@ -1162,11 +1163,13 @@ export function applyChannelPresetIfAvailable( sandboxName: string, channelName: string, retryAction: "add" | "start" = "add", - options: { disclosureAlreadyShown?: boolean } = {}, + options: { disclosedPresetState?: policies.PresetPolicyState | null } = {}, ): boolean { try { - const applied = options.disclosureAlreadyShown - ? policies.applyPreset(sandboxName, channelName, { suppressDisclosure: true }) + const applied = Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") + ? policies.applyPreset(sandboxName, channelName, { + disclosedPresetState: options.disclosedPresetState, + }) : policies.applyPreset(sandboxName, channelName); if (!applied) { console.error( @@ -1470,9 +1473,9 @@ async function sandboxChannelsSetEnabled( return; } - if (!disabled) { - loadValidateAndDiscloseChannelPreset(sandboxName, normalized, "start"); - } + const disclosedPresetState = disabled + ? undefined + : loadValidateAndDiscloseChannelPreset(sandboxName, normalized, "start"); if (dryRun) { console.log(` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`); @@ -1492,7 +1495,7 @@ async function sandboxChannelsSetEnabled( if ( !disabled && !applyChannelPresetIfAvailable(sandboxName, normalized, "start", { - disclosureAlreadyShown: true, + disclosedPresetState, }) ) { const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, normalized, true); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 767ccc57972..1374e9413e7 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -562,7 +562,7 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st return YAML.stringify(output); } -type PresetPolicyState = "absent" | "drift" | "match"; +export type PresetPolicyState = "absent" | "drift" | "match"; function classifyPresetEntries(currentPolicy: string, presetEntries: string): PresetPolicyState { try { @@ -913,6 +913,7 @@ function applyPresetContent( nonFatal?: boolean; skipRegistryUpdate?: boolean; suppressDisclosure?: boolean; + disclosedPresetState?: PresetPolicyState | null; } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -999,7 +1000,10 @@ function applyPresetContent( const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); const presetState = classifyPresetEntries(currentPolicy, presetEntries); - if (!options.suppressDisclosure) { + const disclosedStateStillCurrent = + Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") && + options.disclosedPresetState === presetState; + if (!options.suppressDisclosure && !disclosedStateStillCurrent) { logPresetScopeForState(presetName, presetContent, presetState); } diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index dfdd697b47d..ccd05f0fcbb 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -369,7 +369,7 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(applyPresetSpy).toHaveBeenCalledOnce(); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", channel, { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(loadPresetForSandboxSpy).toHaveBeenCalledWith("test-sb", channel); expect(callOrder.indexOf(`applyPreset:${channel}`)).toBeLessThan( @@ -403,7 +403,7 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(messagingUpdate?.[1]).not.toHaveProperty("disabledChannels"); expect(applyPresetSpy).toHaveBeenCalledOnce(); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(callOrder.indexOf("scopeDisclosure")).toBeLessThan(callOrder.indexOf("updateSandbox")); expect(callOrder.indexOf("applyPreset:whatsapp")).toBeLessThan( @@ -421,7 +421,7 @@ describe("channels add applies a matching policy preset (#3437)", () => { expect(providerSpy).not.toHaveBeenCalled(); expect(updateSandboxSpy).not.toHaveBeenCalled(); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "whatsapp", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(callOrder).not.toContain("promptAndRebuild"); }); @@ -519,7 +519,7 @@ describe("channels add applies a matching policy preset (#3437)", () => { await expectExit(() => addSandboxChannel("test-sb", { channel: "telegram" })); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "telegram", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(updateSandboxSpy).not.toHaveBeenCalled(); expect(deleteCredentialSpy).toHaveBeenCalledWith("TELEGRAM_BOT_TOKEN"); @@ -671,7 +671,7 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(sessionUpdates).toEqual([]); expect(sessionState?.policyPresets).toEqual(["npm", "github"]); @@ -683,7 +683,7 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(sessionUpdates).toEqual([]); expect(callOrder).toContain("promptAndRebuild"); @@ -695,7 +695,7 @@ describe("channels add/remove keeps session.policyPresets in sync with registry" await addSandboxChannel("test-sb", { channel: "slack" }); expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - suppressDisclosure: true, + disclosedPresetState: "absent", }); expect(callOrder).toContain("promptAndRebuild"); }); diff --git a/test/policy-preset-noop-disclosure.test.ts b/test/policy-preset-noop-disclosure.test.ts index 2f1a7e35de0..4e69cbe5fbc 100644 --- a/test/policy-preset-noop-disclosure.test.ts +++ b/test/policy-preset-noop-disclosure.test.ts @@ -20,6 +20,7 @@ type Scenario = { presetNames: string[]; batch?: boolean; suppressDisclosure?: boolean; + disclosedPresetState?: policies.PresetPolicyState | null; }; function runScenario({ @@ -27,6 +28,7 @@ function runScenario({ presetNames, batch = false, suppressDisclosure = false, + disclosedPresetState, }: Scenario) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preset-disclosure-")); tempRoots.push(root); @@ -63,7 +65,7 @@ exit 1 const invocation = batch ? `policies.applyPresets("alpha", ${JSON.stringify(presetNames)})` - : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])}, ${JSON.stringify({ suppressDisclosure })})`; + : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])}, ${JSON.stringify({ suppressDisclosure, disclosedPresetState })})`; const script = ` const fs = require("node:fs"); const policies = require(${POLICY_MODULE}); @@ -167,4 +169,16 @@ describe("preset no-op egress disclosure (#7179)", () => { 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"]); + }); }); From 40144dd006f4dfe354c3edaa89d1d18f543565d4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 19 Jul 2026 08:04:59 -0700 Subject: [PATCH 13/14] fix(policy): redact preset scope disclosure --- src/lib/policy/preset-scope-render.test.ts | 26 ++++++++++++++++++++ src/lib/policy/preset-scope-render.ts | 28 ++++++++++++++-------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts index 893f56c594c..92be27e08bc 100644 --- a/src/lib/policy/preset-scope-render.test.ts +++ b/src/lib/policy/preset-scope-render.test.ts @@ -156,4 +156,30 @@ describe("renderPresetScope (#7179)", () => { 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 index 8fea8552b36..4328af5ee69 100644 --- a/src/lib/policy/preset-scope-render.ts +++ b/src/lib/policy/preset-scope-render.ts @@ -2,6 +2,8 @@ // 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 = { @@ -52,6 +54,12 @@ export function escapeTerminalText(value: string): string { .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); @@ -135,20 +143,20 @@ function extractPresetScope(content: string): PresetScope | null { } function formatEndpoint(endpoint: EndpointScope): string[] { - const port = escapeTerminalText(String(endpoint.port ?? "?")); + const port = renderTerminalText(String(endpoint.port ?? "?")); const modeBits: string[] = []; - if (endpoint.access) modeBits.push(`access: ${escapeTerminalText(endpoint.access)}`); - if (endpoint.protocol) modeBits.push(`protocol: ${escapeTerminalText(endpoint.protocol)}`); - if (endpoint.tls) modeBits.push(`tls: ${escapeTerminalText(endpoint.tls)}`); + 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: ${escapeTerminalText(endpoint.enforcement)}`); + modeBits.push(`enforcement: ${renderTerminalText(endpoint.enforcement)}`); } const modeSuffix = modeBits.length > 0 ? ` (${modeBits.join(", ")})` : ""; - const header = ` - ${escapeTerminalText(endpoint.host)}:${port}${modeSuffix}`; + 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(escapeTerminalText).join(", "); - const paths = rule.paths.map(escapeTerminalText).join(", "); + const methods = rule.methods.map(renderTerminalText).join(", "); + const paths = rule.paths.map(renderTerminalText).join(", "); return ` ${rule.action}: ${methods} ${paths}`; }); return [header, ...ruleLines]; @@ -162,7 +170,7 @@ export function renderPresetScope( 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 '${escapeTerminalText(policy.name)}':`); + lines.push(` policy '${renderTerminalText(policy.name)}':`); if (policy.endpoints.length === 0) { lines.push(" (no endpoints declared)"); } else { @@ -173,7 +181,7 @@ export function renderPresetScope( if (policy.binaries.length > 0) { lines.push(" binaries:"); for (const bin of policy.binaries) { - lines.push(` - ${escapeTerminalText(bin)}`); + lines.push(` - ${renderTerminalText(bin)}`); } } } From 297c9a18c03d72f0c8c0a0a470411893327f542f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 19 Jul 2026 08:24:59 -0700 Subject: [PATCH 14/14] chore(ci): refresh policy disclosure checks