Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a2b6296
fix(policy): disclose full messaging-preset egress before channels apply
laitingsheng Jul 19, 2026
a7b18a7
fix(policy): read real preset fixtures in --from-dir egress test and …
laitingsheng Jul 19, 2026
4cef61d
fix(policy): disclose preset scope before lifecycle effects
apurvvkumaria Jul 19, 2026
5bd1f73
refactor(onboard): keep policy disclosure out of entrypoint
apurvvkumaria Jul 19, 2026
2d73e77
fix(policy): sanitize disclosure output and cover every preset-applic…
laitingsheng Jul 19, 2026
fc8ed4c
fix(policy): preserve owned preset refresh transactions
apurvvkumaria Jul 19, 2026
d868b11
style(policy): format owned preset refresh
apurvvkumaria Jul 19, 2026
feed1fa
refactor(policy): consolidate channel scope disclosure
apurvvkumaria Jul 19, 2026
15578d3
Merge remote-tracking branch 'origin/fix/7179-disclose-preset-egress-…
laitingsheng Jul 19, 2026
888cb91
Merge remote-tracking branch 'origin/main' into fix/7179-disclose-pre…
laitingsheng Jul 19, 2026
990d0c4
Merge remote-tracking branch 'origin/fix/7179-disclose-preset-egress-…
laitingsheng Jul 19, 2026
ab1e3c9
test(policy): mock live policy read in start dry-run disclosure test
laitingsheng Jul 19, 2026
a835d7a
fix(policy): avoid duplicate egress disclosure
apurvvkumaria Jul 19, 2026
4b12426
docs(policy): clarify no-op egress disclosure
apurvvkumaria Jul 19, 2026
13fb5bf
fix(policy): revalidate disclosure state before mutation
cv Jul 19, 2026
f6518c3
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
25aa91b
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
40144dd
fix(policy): redact preset scope disclosure
cv Jul 19, 2026
297c9a1
chore(ci): refresh policy disclosure checks
cv Jul 19, 2026
5b894f5
Merge branch 'main' into fix/7179-disclose-preset-egress-before-channels
cv Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"test/nemoclaw-start.test.ts": 4826,
"test/onboard-messaging.test.ts": 2049,
"test/onboard-selection.test.ts": 4769,
"test/policies.test.ts": 1531
"test/policies.test.ts": 1530
}
}
9 changes: 5 additions & 4 deletions src/lib/actions/sandbox/policy-channel-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ beforeEach(() => {
selectForRemovalMock = vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi");
vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => {
const presetName = String(name);
return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`;
return `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: ${presetName}.example.com\n port: 443\n protocol: rest\n rules:\n - allow: { method: GET, path: "/**" }\n`;
});
loadPresetForSandboxMock = vi
.spyOn(policies, "loadPresetForSandbox")
.mockImplementation((_sandboxName: unknown, name: unknown) => {
const presetName = String(name);
return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`;
return `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: ${presetName}.example.com\n port: 443\n protocol: rest\n rules:\n - allow: { method: GET, path: "/**" }\n`;
});
applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true);
removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true);
Expand Down Expand Up @@ -140,7 +140,8 @@ describe("addSandboxPolicy", () => {

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

Expand Down Expand Up @@ -242,7 +243,7 @@ describe("addSandboxPolicy", () => {
expect(output).not.toContain("not supported for agent");
expect(output).not.toContain("Channels supported by agent");
expect(output).not.toContain("Preset not found");
expect(output).not.toContain("Endpoints that would be opened");
expect(output).not.toContain("Effective egress that would be opened");
expect(promptMock).not.toHaveBeenCalled();
expect(loadPresetForSandboxMock).not.toHaveBeenCalled();
expect(applyPresetMock).not.toHaveBeenCalled();
Expand Down
104 changes: 104 additions & 0 deletions src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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;
});
});
13 changes: 6 additions & 7 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`,
);
Expand Down Expand Up @@ -974,6 +972,7 @@ async function addSandboxChannelUnlocked(
}

if (dryRun) {
policies.logPresetScope(presetContent);
Comment thread
apurvvkumaria marked this conversation as resolved.
Outdated
console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/messaging/channels/whatsapp/policy/hermes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lib/messaging/channels/whatsapp/policy/openclaw.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 8 additions & 11 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Comment thread
apurvvkumaria marked this conversation as resolved.
Outdated

// 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).
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -1538,6 +1533,7 @@ export {
loadPreset,
loadPresetForSandbox,
loadPresetFromFile,
logPresetScope,
mergePresetIntoPolicy,
mergePresetNamesIntoPolicy,
networkPoliciesHasAllowedIps,
Expand All @@ -1549,6 +1545,7 @@ export {
removeBuiltinPresetAttribution,
removePreset,
removePresetFromPolicy,
renderPresetScope,
resolvePermissivePolicyPath,
selectForRemoval,
selectFromList,
Expand Down
131 changes: 131 additions & 0 deletions src/lib/policy/preset-scope-render.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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"));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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)");
});
});
Loading
Loading