diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 3d54a051797..122e53489b4 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -821,6 +821,8 @@ nemohermes my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. +Messaging channel presets such as `telegram`, `discord`, `slack`, `wechat`, and `whatsapp` apply only to agents that support those channels. +On a terminal-runtime agent such as DeepAgents, which has no inbound messaging gateway, `policy-add` rejects the preset with a clear error before any endpoint disclosure or prompt, matching `channels add`. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 926127f9078..fec8d71cb6a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1125,6 +1125,8 @@ $$nemoclaw my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. +Messaging channel presets such as `telegram`, `discord`, `slack`, `wechat`, and `whatsapp` apply only to agents that support those channels. +On a terminal-runtime agent such as DeepAgents, which has no inbound messaging gateway, `policy-add` rejects the preset with a clear error before any endpoint disclosure or prompt, matching `channels add`. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. diff --git a/src/lib/actions/sandbox/policy-add-agent-gate.test.ts b/src/lib/actions/sandbox/policy-add-agent-gate.test.ts new file mode 100644 index 00000000000..b11495de097 --- /dev/null +++ b/src/lib/actions/sandbox/policy-add-agent-gate.test.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const requireSource = createRequire(import.meta.url); +const D = (p: string) => requireSource(`../../${p}`); + +const registry = D("state/registry.js"); +const defs = D("agent/defs.js"); +const policy = D("policy/index.js"); +const store = D("credentials/store.js"); +const onboardSession = D("state/onboard-session.js"); +const contextRefresh = D("actions/sandbox/policy-context-refresh.js"); + +const { addSandboxPolicy } = D("actions/sandbox/policy-channel.js") as { + addSandboxPolicy: ( + name: string, + options?: { + preset?: string; + dryRun?: boolean; + yes?: boolean; + force?: boolean; + fromFile?: string; + fromDir?: string; + }, + ) => Promise; +}; + +const MESSAGING_POLICY_KEYS = [ + ["telegram_bot", "api.telegram.org"], + ["discord", "discord.com"], + ["slack", "api.slack.com"], + ["wechat_bridge", "api.weixin.qq.com"], + ["whatsapp", "graph.facebook.com"], + ["teams", "graph.microsoft.com"], +] as const; + +const MESSAGING_CHANNELS = ["telegram", "discord", "slack", "wechat", "whatsapp"] as const; + +const PRESETS = [ + { name: "pypi", description: "Python Package Index access" }, + { name: "telegram", description: "Telegram API access" }, + { name: "discord", description: "Discord API access" }, + { name: "slack", description: "Slack API access" }, + { name: "wechat", description: "WeChat API access" }, + { name: "whatsapp", description: "WhatsApp API access" }, +]; + +let errSpy: MockInstance; +let logSpy: MockInstance; +let applyPresetMock: MockInstance; +let selectFromListMock: MockInstance; +let promptMock: MockInstance; + +function exitCodeFromError(err: unknown): number | null { + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/^process\.exit\((\d+)\)$/); + return match ? Number(match[1]) : null; +} + +function errorText(): string { + return (errSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" ")).join("\n"); +} + +function logText(): string { + return (logSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" ")).join("\n"); +} + +async function captureExit(action: () => Promise): Promise { + try { + await action(); + } catch (err) { + return exitCodeFromError(err); + } + return null; +} + +beforeEach(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "da-test", + agent: "langchain-deepagents-code", + policies: [], + }); + vi.spyOn(policy, "listPresets").mockReturnValue(PRESETS); + vi.spyOn(policy, "listCustomPresets").mockReturnValue([]); + vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policy, "loadPreset").mockImplementation((name: unknown) => { + const presetName = String(name); + return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; + }); + applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true); + selectFromListMock = vi.spyOn(policy, "selectFromList").mockResolvedValue("pypi"); + promptMock = vi.spyOn(store, "prompt").mockResolvedValue("y"); + + vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); + vi.spyOn(onboardSession, "updateSession").mockImplementation(() => undefined); + vi.spyOn(contextRefresh, "refreshSandboxPolicyContextFile").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("addSandboxPolicy channel-agent gate", () => { + it.each( + MESSAGING_CHANNELS, + )("refuses the '%s' channel preset on a terminal-runtime agent before any disclosure, prompt, or apply", async (channel) => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); + + const code = await captureExit(() => + addSandboxPolicy("da-test", { preset: channel, yes: true }), + ); + + expect(code).toBe(1); + expect(errorText()).toMatch( + new RegExp(`Channel '${channel}' does not support agent 'langchain-deepagents-code'`), + ); + expect(errorText()).toMatch(/Channel-supported agents: openclaw, hermes/); + expect(errorText()).toMatch( + /Channels supported by agent 'langchain-deepagents-code': \(none\)/, + ); + expect(logText()).not.toContain("Endpoints that would be opened"); + expect(promptMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }); + + it("still applies a non-messaging preset on a terminal-runtime agent", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); + + await addSandboxPolicy("da-test", { preset: "pypi", yes: true }); + + expect(errorText()).not.toMatch(/does not support agent/); + expect(applyPresetMock).toHaveBeenCalledWith("da-test", "pypi"); + }); + + it("does not gate a messaging-capable agent (openclaw applies a channel preset)", async () => { + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "oc-test", + agent: "openclaw", + policies: [], + }); + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw" }); + + await addSandboxPolicy("oc-test", { preset: "telegram", yes: true }); + + expect(errorText()).not.toMatch(/does not support agent/); + expect(applyPresetMock).toHaveBeenCalledWith("oc-test", "telegram"); + }); + + it("omits unsupported channel presets from the interactive picker for a terminal-runtime agent", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); + + await addSandboxPolicy("da-test"); + + expect(selectFromListMock).toHaveBeenCalledTimes(1); + const offered = (selectFromListMock.mock.calls[0][0] as Array<{ name: string }>).map( + (preset) => preset.name, + ); + expect(offered).toContain("pypi"); + for (const channel of MESSAGING_CHANNELS) { + expect(offered).not.toContain(channel); + } + }); +}); + +describe("addSandboxPolicy custom preset (--from-file) agent gate", () => { + it.each( + MESSAGING_POLICY_KEYS, + )("rejects a custom preset with a '%s' policy key on a terminal-runtime agent before any disclosure, prompt, or apply", async (policyKey, host) => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); + vi.spyOn(policy, "loadPresetFromFile").mockReturnValue({ + presetName: "my-custom", + content: `preset:\n name: my-custom\nnetwork_policies:\n ${policyKey}:\n host: ${host}\n`, + }); + const applyPresetContentMock = vi.spyOn(policy, "applyPresetContent"); + + const code = await captureExit(() => + addSandboxPolicy("da-test", { fromFile: "/tmp/my-custom.yaml", yes: true }), + ); + + expect(code).toBe(1); + expect(errorText()).toMatch(/does not support agent 'langchain-deepagents-code'/); + expect(logText()).not.toContain("Endpoints that would be opened"); + expect(promptMock).not.toHaveBeenCalled(); + expect(applyPresetContentMock).not.toHaveBeenCalled(); + }); + + it("still applies a non-messaging custom preset on a terminal-runtime agent", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); + vi.spyOn(policy, "loadPresetFromFile").mockReturnValue({ + presetName: "my-pypi-mirror", + content: + "preset:\n name: my-pypi-mirror\nnetwork_policies:\n pypi_mirror:\n host: pypi.example.com\n", + }); + const applyPresetContentMock = vi.spyOn(policy, "applyPresetContent").mockReturnValue(true); + + await addSandboxPolicy("da-test", { fromFile: "/tmp/my-pypi-mirror.yaml", yes: true }); + + expect(errorText()).not.toMatch(/does not support agent/); + expect(applyPresetContentMock).toHaveBeenCalledWith( + "da-test", + "my-pypi-mirror", + expect.stringContaining("pypi_mirror"), + { custom: { sourcePath: expect.stringContaining("my-pypi-mirror.yaml") } }, + ); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 5e471864f75..ec9b8a9697c 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; -import { type AgentDefinition, loadAgent } from "../../agent/defs"; +import type { AgentDefinition } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { prompt as askPrompt, getCredential } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; @@ -18,6 +18,7 @@ import { getMessagingManifestAvailabilityContext, isMessagingChannelSupportedByAgent, isMessagingHookConflictError, + listMessagingPolicyPresetMetadata, MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, @@ -28,6 +29,7 @@ import { tryGetMessagingAgentId, } from "../../messaging"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; +import { resolveAgentForSandbox } from "../../sandbox/version"; import { hashCredential } from "../../security/credential-hash"; import { getSandboxTargetGatewayName } from "./gateway-target"; @@ -138,12 +140,31 @@ export async function addSandboxPolicy( } const sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; - const allPresets = filterSetupPolicyPresetsForAgent(policies.listPresets(), sandboxAgent); + const agent = resolveAgentForSandbox(sandboxName); + const allPresets = filterSetupPolicyPresetsForAgent(policies.listPresets(), sandboxAgent).filter( + (preset: { name: string }) => { + const manifest = resolveChannelManifest(preset.name); + return !manifest || channelSupportedByAgent(manifest, agent); + }, + ); const applied = policies.getAppliedPresets(sandboxName); let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); + const channelManifest = resolveChannelManifest(normalized); + if (channelManifest && !channelSupportedByAgent(channelManifest, agent)) { + console.error( + ` Channel '${channelManifest.id}' does not support agent '${agent.name}' for sandbox '${sandboxName}'.`, + ); + console.error( + ` Channel-supported agents: ${formatSupportedMessagingAgentIds(channelManifest.supportedAgents)}.`, + ); + console.error( + ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, + ); + process.exit(1); + } const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`); @@ -222,6 +243,21 @@ async function applyExternalPreset( } if (!loaded) return false; + const agent = resolveAgentForSandbox(sandboxName); + const unsupportedChannel = unsupportedMessagingChannelForPresetContent(loaded.content, agent); + if (unsupportedChannel) { + console.error( + ` Preset '${loaded.presetName}' targets the '${unsupportedChannel.id}' channel, which does not support agent '${agent.name}' for sandbox '${sandboxName}'.`, + ); + console.error( + ` Channel-supported agents: ${formatSupportedMessagingAgentIds(unsupportedChannel.supportedAgents)}.`, + ); + console.error( + ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, + ); + return false; + } + const endpoints = policies.getPresetEndpoints(loaded.content); if (endpoints.length > 0) { console.log(` [${loaded.presetName}] Endpoints that would be opened: ${endpoints.join(", ")}`); @@ -311,12 +347,6 @@ export function listSandboxPolicies(sandboxName: string) { // ── Messaging channels ─────────────────────────────────────────── -function resolveAgentForSandbox(sandboxName: string): AgentDefinition { - const entry = registry.getSandbox(sandboxName); - const agentName = entry?.agent || "openclaw"; - return loadAgent(agentName); -} - function knownManifestChannelNames(): string[] { return messagingManifestRegistry.list().map((manifest) => manifest.id); } @@ -335,6 +365,30 @@ function channelSupportedByAgent(manifest: ChannelManifest, agent: AgentDefiniti return isMessagingChannelSupportedByAgent(manifest, agent); } +// Custom presets (--from-file / --from-dir) have no channel identity of +// their own, so the built-in name-based gate above cannot see them. Detect +// a messaging channel by content instead: match the preset's network_policies +// keys against every channel's known policy keys, then apply the same +// agent-support gate as the built-in path. +function unsupportedMessagingChannelForPresetContent( + content: string, + agent: AgentDefinition, +): ChannelManifest | null { + if (typeof content !== "string") return null; + const policyKeys = new Set(policies.parsePresetPolicyKeys(content)); + if (policyKeys.size === 0) return null; + for (const preset of listMessagingPolicyPresetMetadata()) { + const channelPolicyKeys = [ + ...preset.policyKeys, + ...Object.values(preset.agentPolicyKeys).flatMap((keys) => keys ?? []), + ]; + if (!channelPolicyKeys.some((key) => policyKeys.has(key))) continue; + const manifest = resolveChannelManifest(preset.channelId); + if (manifest && !channelSupportedByAgent(manifest, agent)) return manifest; + } + return null; +} + export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); const availableChannels = availableManifestChannelsForAgent(agent); diff --git a/src/lib/sandbox/version.ts b/src/lib/sandbox/version.ts index 9f6ec42e943..adf16bd9363 100644 --- a/src/lib/sandbox/version.ts +++ b/src/lib/sandbox/version.ts @@ -73,7 +73,7 @@ export interface VersionCheckOptions { * Resolve the agent definition for a sandbox. * Falls back to "openclaw" when the sandbox has no agent set. */ -function resolveAgentForSandbox(sandboxName: string): ReturnType { +export function resolveAgentForSandbox(sandboxName: string): ReturnType { const sb = registry.getSandbox(sandboxName); const agentName = sb?.agent || "openclaw"; return loadAgent(agentName); diff --git a/test/policy-add-deepagents-rejection.test.ts b/test/policy-add-deepagents-rejection.test.ts new file mode 100644 index 00000000000..7f695004144 --- /dev/null +++ b/test/policy-add-deepagents-rejection.test.ts @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); + +const MESSAGING_CHANNELS = ["telegram", "discord", "slack", "wechat", "whatsapp"] as const; + +function runScript( + scriptBody: string, + extraFiles: Record = {}, +): SpawnSyncReturns { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-6185-")); + const scriptPath = path.join(tmpDir, "script.js"); + fs.writeFileSync(scriptPath, scriptBody); + for (const [name, content] of Object.entries(extraFiles)) { + fs.writeFileSync(path.join(tmpDir, name), content); + } + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +function parseResultPayload>( + result: SpawnSyncReturns, +): T { + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok( + marker >= 0, + `no __RESULT__ marker in stdout:\n${result.stdout}\n---stderr---\n${result.stderr}`, + ); + return JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()) as T; +} + +function buildPreamble(agentName: string): string { + const d = (p: string) => + JSON.stringify(path.join(repoRoot, "src", "lib", p.replace(/\.js$/, ".ts"))); + return String.raw` +const onboard = require(${d("onboard.js")}); +onboard.isNonInteractive = () => true; + +const credentials = require(${d("credentials/store.js")}); +const promptCalls = []; +credentials.prompt = async (msg) => { promptCalls.push(msg); return ""; }; + +const registry = require(${d("state/registry.js")}); +registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(agentName)} }); + +const agentDefs = require(${d("agent/defs.js")}); +agentDefs.loadAgent = () => ({ name: ${JSON.stringify(agentName)} }); + +const policies = require(${d("policy/index.js")}); +const policyCalls = { loadPreset: [], applyPreset: [] }; +policies.listPresets = () => [ + { name: "pypi", description: "Python Package Index access" }, + { name: "telegram", description: "Telegram API access" }, + { name: "discord", description: "Discord API access" }, + { name: "slack", description: "Slack API access" }, + { name: "wechat", description: "WeChat API access" }, + { name: "whatsapp", description: "WhatsApp API access" }, +]; +policies.getAppliedPresets = () => []; +policies.loadPreset = (name) => { policyCalls.loadPreset.push(name); return "network_policies:\n stub: {}\n"; }; +policies.getPresetEndpoints = () => ["api.telegram.org"]; +policies.getPresetValidationWarning = () => null; +policies.applyPreset = (name, preset) => { policyCalls.applyPreset.push({ name, preset }); return true; }; +policies.selectFromList = async () => null; + +const policyModule = require(${d("actions/sandbox/policy-channel.js")}); + +let exitCode = null; +process.exit = (code) => { exitCode = code; throw new Error("__INTERCEPTED_EXIT__:" + code); }; + +const logs = []; +console.log = (...args) => { logs.push(args.map(String).join(" ")); }; +const errors = []; +console.error = (...args) => { errors.push(args.map(String).join(" ")); }; + +module.exports = { + policyModule, + policyCalls, + promptCalls, + logs, + errors, + getExitCode: () => exitCode, +}; +`; +} + +function runPolicyAdd(agentName: string, preset: string) { + const script = `${buildPreamble(agentName)} +const ctx = module.exports; +(async () => { + let caught = null; + try { + await ctx.policyModule.addSandboxPolicy("test-sb", { preset: ${JSON.stringify(preset)}, yes: true }); + } catch (err) { + if (!String(err && err.message).startsWith("__INTERCEPTED_EXIT__")) { + caught = { message: String(err && err.message), stack: err && err.stack }; + } + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + exitCode: ctx.getExitCode(), + logs: ctx.logs, + errors: ctx.errors, + policyCalls: ctx.policyCalls, + promptCalls: ctx.promptCalls, + unexpectedError: caught, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script crashed: ${result.stderr}\n${result.stdout}`); + return parseResultPayload<{ + exitCode: number; + logs: string[]; + errors: string[]; + policyCalls: { loadPreset: string[]; applyPreset: unknown[] }; + promptCalls: string[]; + unexpectedError: { message: string; stack: string } | null; + }>(result); +} + +const MESSAGING_POLICY_KEYS = [ + ["telegram_bot", "api.telegram.org"], + ["discord", "discord.com"], + ["slack", "api.slack.com"], + ["wechat_bridge", "api.weixin.qq.com"], + ["whatsapp", "graph.facebook.com"], + ["teams", "graph.microsoft.com"], +] as const; + +function runPolicyAddFromFile(agentName: string, presetYamlContent: string) { + const script = `${buildPreamble(agentName)} +const path = require("node:path"); +const ctx = module.exports; +(async () => { + let caught = null; + try { + const filePath = path.join(process.env.HOME, "custom-preset.yaml"); + await ctx.policyModule.addSandboxPolicy("test-sb", { fromFile: filePath, yes: true }); + } catch (err) { + if (!String(err && err.message).startsWith("__INTERCEPTED_EXIT__")) { + caught = { message: String(err && err.message), stack: err && err.stack }; + } + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + exitCode: ctx.getExitCode(), + logs: ctx.logs, + errors: ctx.errors, + policyCalls: ctx.policyCalls, + promptCalls: ctx.promptCalls, + unexpectedError: caught, + }) + "\\n"); +})(); +`; + const result = runScript(script, { "custom-preset.yaml": presetYamlContent }); + assert.equal(result.status, 0, `script crashed: ${result.stderr}\n${result.stdout}`); + return parseResultPayload<{ + exitCode: number; + logs: string[]; + errors: string[]; + policyCalls: { loadPreset: string[]; applyPreset: unknown[] }; + promptCalls: string[]; + unexpectedError: { message: string; stack: string } | null; + }>(result); +} + +describe("addSandboxPolicy custom preset (--from-file) channel/agent gate (behaviour)", () => { + it.each( + MESSAGING_POLICY_KEYS, + )("DeepAgents policy-add --from-file with a '%s' policy key exits nonzero before any disclosure, prompt, or apply", (policyKey, host) => { + const presetYaml = `preset:\n name: my-custom-${policyKey.replace(/_/g, "-")}\nnetwork_policies:\n ${policyKey}:\n host: ${host}\n`; + const payload = runPolicyAddFromFile("langchain-deepagents-code", presetYaml); + + assert.equal( + payload.unexpectedError, + null, + `unexpected exception: ${payload.unexpectedError?.stack}`, + ); + assert.equal(payload.exitCode, 1, "expected addSandboxPolicy to exit with code 1"); + assert.ok( + payload.errors.some((msg) => /does not support agent 'langchain-deepagents-code'/.test(msg)), + `missing unsupported channel-agent error in stderr: ${JSON.stringify(payload.errors)}`, + ); + assert.ok( + payload.logs.every((msg) => !/Endpoints that would be opened/.test(msg)), + `endpoint disclosure must not print before the gate: ${JSON.stringify(payload.logs)}`, + ); + assert.deepEqual(payload.promptCalls, [], "prompt must not run before the gate"); + }); +}); + +describe("addSandboxPolicy channel/agent gate (behaviour)", () => { + it.each( + MESSAGING_CHANNELS, + )("DeepAgents policy-add %s exits nonzero before any disclosure, prompt, or apply", (channel) => { + const payload = runPolicyAdd("langchain-deepagents-code", channel); + + assert.equal( + payload.unexpectedError, + null, + `unexpected exception: ${payload.unexpectedError?.stack}`, + ); + assert.equal(payload.exitCode, 1, "expected addSandboxPolicy to exit with code 1"); + assert.ok( + payload.errors.some((msg) => + new RegExp(`Channel '${channel}' does not support agent 'langchain-deepagents-code'`).test( + msg, + ), + ), + `missing unsupported channel-agent error in stderr: ${JSON.stringify(payload.errors)}`, + ); + assert.ok( + payload.logs.every((msg) => !/Endpoints that would be opened/.test(msg)), + `endpoint disclosure must not print before the gate: ${JSON.stringify(payload.logs)}`, + ); + assert.deepEqual(payload.promptCalls, [], "prompt must not run before the gate"); + assert.deepEqual( + payload.policyCalls.applyPreset, + [], + "applyPreset must not run before the gate", + ); + assert.deepEqual(payload.policyCalls.loadPreset, [], "loadPreset must not run before the gate"); + }); +});