Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
218 changes: 218 additions & 0 deletions src/lib/actions/sandbox/policy-add-agent-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

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<void>): Promise<number | null> {
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") } },
);
});
});
70 changes: 62 additions & 8 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +18,7 @@ import {
getMessagingManifestAvailabilityContext,
isMessagingChannelSupportedByAgent,
isMessagingHookConflictError,
listMessagingPolicyPresetMetadata,
MessagingHostStateApplier,
MessagingSetupApplier,
MessagingWorkflowPlanner,
Expand All @@ -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";

Expand Down Expand Up @@ -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}'.`);
Expand Down Expand Up @@ -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(", ")}`);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sandbox/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loadAgent> {
export function resolveAgentForSandbox(sandboxName: string): ReturnType<typeof loadAgent> {
const sb = registry.getSandbox(sandboxName);
const agentName = sb?.agent || "openclaw";
return loadAgent(agentName);
Expand Down
Loading
Loading