feat: add per-agent message cross-context policy

This commit is contained in:
Peter Steinberger
2026-05-11 04:59:14 +01:00
parent 110e9b0d42
commit b978b53dbb
11 changed files with 201 additions and 63 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- Build: pin explicit oxfmt defaults in the shared formatter config to keep formatting behavior stable across upgrades.
- TypeScript: enable stricter compiler checks for implicit returns, side-effect imports, overrides, and unused production code.
- Agents: allow `session.agentToAgent.maxPingPongTurns` up to 20 while keeping the default at 5 for longer agent-to-agent reply chains. Fixes #52382. (#52400) Thanks @thirumaleshp.
- Agents: add per-agent `tools.message.crossContext` overrides so sandboxed/public agents can restrict message sends to the current conversation without changing the global bot policy.
- Build: upgrade workspace package management to pnpm 11 and keep Docker, install, update, and release workflows on the pnpm 11 config surface. (#79414) Thanks @altaywtf.
- Models: add provider-level `localService` startup for on-demand local model servers before OpenAI-compatible requests, including one-shot model probes.
- Agents: trim default system prompt guidance and send-only message tool schemas to reduce prompt tokens while preserving GPT-5 personality guidance.

View File

@@ -50,8 +50,14 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store at `~/.ope
"scope": "agent"
},
"tools": {
"allow": ["read"],
"deny": ["exec", "write", "edit", "apply_patch", "process", "browser"]
"allow": ["read", "message"],
"deny": ["exec", "write", "edit", "apply_patch", "process", "browser"],
"message": {
"crossContext": {
"allowWithinProvider": false,
"allowAcrossProviders": false
}
}
}
}
]
@@ -75,7 +81,7 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store at `~/.ope
**Result:**
- `main` agent: runs on host, full tool access.
- `family` agent: runs in Docker (one container per agent), only `read` tool.
- `family` agent: runs in Docker (one container per agent), only `read` and current-conversation message sends.
</Accordion>
<Accordion title="Example 2: Work agent with shared sandbox">

View File

@@ -49,4 +49,26 @@ describe("config: tools.alsoAllow", () => {
expect(res.ok).toBe(true);
});
it("allows per-agent message tool cross-context policy", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "sandbox",
tools: {
message: {
crossContext: {
allowWithinProvider: false,
allowAcrossProviders: false,
},
},
},
},
],
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -539,6 +539,8 @@ const CHANNELS_AGENTS_TARGET_KEYS = [
"agents.defaults.workspace",
"agents.list[].tools.alsoAllow",
"agents.list[].tools.byProvider",
"agents.list[].tools.message.crossContext.allowAcrossProviders",
"agents.list[].tools.message.crossContext.allowWithinProvider",
"agents.list[].tools.profile",
"channels.mattermost",
] as const;

View File

@@ -716,6 +716,10 @@ export const FIELD_HELP: Record<string, string> = {
"Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.",
"agents.list[].tools.byProvider":
"Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.",
"agents.list[].tools.message.crossContext.allowWithinProvider":
"Per-agent message guard for sending to other conversations on the same provider. Set false for current-conversation-only public agents.",
"agents.list[].tools.message.crossContext.allowAcrossProviders":
"Per-agent message guard for sending across providers. Keep false for public or sandboxed agents.",
"tools.exec.approvalRunningNoticeMs":
"Delay in milliseconds before showing an in-progress notice after an exec approval is granted. Increase to reduce flicker for fast commands, or lower for quicker operator feedback.",
"tools.links.enabled":

View File

@@ -211,6 +211,10 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"agents.list[].tools.message.crossContext.allowWithinProvider":
"Agent Cross-Context Messaging (Same Provider)",
"agents.list[].tools.message.crossContext.allowAcrossProviders":
"Agent Cross-Context Messaging (Across Providers)",
"tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",

View File

@@ -349,6 +349,8 @@ export type AgentToolsConfig = {
fs?: FsToolsConfig;
/** Runtime loop detection for repetitive/ stuck tool-call patterns. */
loopDetection?: ToolLoopDetectionConfig;
/** Message tool configuration for this agent. */
message?: MessageToolsConfig;
sandbox?: {
tools?: {
allow?: string[];
@@ -611,32 +613,7 @@ export type ToolsConfig = {
media?: MediaToolsConfig;
links?: LinkToolsConfig;
/** Message tool configuration. */
message?: {
/**
* @deprecated Use tools.message.crossContext settings.
* Allows cross-context sends across providers.
*/
allowCrossContextSend?: boolean;
crossContext?: {
/** Allow sends to other channels within the same provider (default: true). */
allowWithinProvider?: boolean;
/** Allow sends across different providers (default: false). */
allowAcrossProviders?: boolean;
/** Cross-context marker configuration. */
marker?: {
/** Enable origin markers for cross-context sends (default: true). */
enabled?: boolean;
/** Text prefix template, supports {channel}. */
prefix?: string;
/** Text suffix template, supports {channel}. */
suffix?: string;
};
};
broadcast?: {
/** Enable broadcast action (default: true). */
enabled?: boolean;
};
};
message?: MessageToolsConfig;
agentToAgent?: {
/** Enable agent-to-agent messaging tools. Default: false. */
enabled?: boolean;
@@ -699,3 +676,30 @@ export type ToolsConfig = {
planTool?: boolean;
};
};
export type MessageToolsConfig = {
/**
* @deprecated Use tools.message.crossContext settings.
* Allows cross-context sends across providers.
*/
allowCrossContextSend?: boolean;
crossContext?: {
/** Allow sends to other channels within the same provider (default: true). */
allowWithinProvider?: boolean;
/** Allow sends across different providers (default: false). */
allowAcrossProviders?: boolean;
/** Cross-context marker configuration. */
marker?: {
/** Enable origin markers for cross-context sends (default: true). */
enabled?: boolean;
/** Text prefix template, supports {channel}. */
prefix?: string;
/** Text suffix template, supports {channel}. */
suffix?: string;
};
};
broadcast?: {
/** Enable broadcast action (default: true). */
enabled?: boolean;
};
};

View File

@@ -622,6 +622,34 @@ const CommonToolPolicyFields = {
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
};
const MessageToolConfigSchema = z
.object({
allowCrossContextSend: z.boolean().optional(),
crossContext: z
.object({
allowWithinProvider: z.boolean().optional(),
allowAcrossProviders: z.boolean().optional(),
marker: z
.object({
enabled: z.boolean().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
broadcast: z
.object({
enabled: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict()
.optional();
const AgentToolsSchema = z
.object({
...CommonToolPolicyFields,
@@ -635,6 +663,7 @@ const AgentToolsSchema = z
exec: AgentToolExecSchema,
fs: ToolFsSchema,
loopDetection: ToolLoopDetectionSchema,
message: MessageToolConfigSchema,
sandbox: z
.object({
tools: ToolPolicySchema,
@@ -939,33 +968,7 @@ export const ToolsSchema = z
.optional(),
loopDetection: ToolLoopDetectionSchema,
toolSearch: ToolSearchSchema,
message: z
.object({
allowCrossContextSend: z.boolean().optional(),
crossContext: z
.object({
allowWithinProvider: z.boolean().optional(),
allowAcrossProviders: z.boolean().optional(),
marker: z
.object({
enabled: z.boolean().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
broadcast: z
.object({
enabled: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
message: MessageToolConfigSchema,
agentToAgent: z
.object({
enabled: z.boolean().optional(),

View File

@@ -71,6 +71,7 @@ import {
buildCrossContextDecoration,
type CrossContextDecoration,
enforceCrossContextPolicy,
resolveEffectiveMessageToolsConfig,
shouldApplyCrossContextMarker,
} from "./outbound-policy.js";
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
@@ -254,6 +255,7 @@ async function maybeApplyCrossContextMarker(params: {
target: string;
toolContext?: ChannelThreadingToolContext;
accountId?: string | null;
agentId?: string | null;
args: Record<string, unknown>;
message: string;
preferPresentation: boolean;
@@ -267,6 +269,7 @@ async function maybeApplyCrossContextMarker(params: {
target: params.target,
toolContext: params.toolContext,
accountId: params.accountId ?? undefined,
agentId: params.agentId ?? undefined,
});
if (!decoration) {
return params.message;
@@ -512,7 +515,9 @@ async function handleBroadcastAction(
params: Record<string, unknown>,
): Promise<MessageActionRunResult> {
throwIfAborted(input.abortSignal);
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
const broadcastEnabled =
resolveEffectiveMessageToolsConfig({ cfg: input.cfg, agentId: input.agentId })?.broadcast
?.enabled !== false;
if (!broadcastEnabled) {
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
}
@@ -672,6 +677,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
target: to,
toolContext: input.toolContext,
accountId,
agentId,
args: params,
message,
preferPresentation: true,
@@ -851,6 +857,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
target: to,
toolContext: input.toolContext,
accountId,
agentId,
args: params,
message: base,
preferPresentation: false,
@@ -1122,6 +1129,7 @@ export async function runMessageAction(
args: params,
toolContext: input.toolContext,
cfg,
agentId: resolvedAgentId,
});
if (action === "send" && hasPollCreationParams(params)) {

View File

@@ -95,6 +95,7 @@ function expectCrossContextPolicyResult(params: {
to: string;
currentChannelId: string;
currentChannelProvider: string;
agentId?: string;
expected: "allow" | RegExp;
}) {
const run = () =>
@@ -107,6 +108,7 @@ function expectCrossContextPolicyResult(params: {
currentChannelId: params.currentChannelId,
currentChannelProvider: params.currentChannelProvider,
},
agentId: params.agentId,
});
if (params.expected === "allow") {
expect(run()).toBeUndefined();
@@ -181,6 +183,32 @@ describe("outbound policy helpers", () => {
currentChannelProvider: "workspace",
expected: /target="C999" while bound to "C123"/,
},
{
cfg: {
...workspaceConfig,
agents: {
list: [
{
id: "sandbox",
tools: {
message: {
crossContext: {
allowWithinProvider: false,
},
},
},
},
],
},
} as OpenClawConfig,
channel: "workspace",
action: "send" as const,
to: "C999",
currentChannelId: "C123",
currentChannelProvider: "workspace",
agentId: "sandbox",
expected: /target="C999" while bound to "C123"/,
},
])("enforces cross-context policy for %j", (params) => {
expectCrossContextPolicyResult(params);
});

View File

@@ -5,6 +5,7 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { MessageToolsConfig } from "../../config/types.tools.js";
import type { MessagePresentation } from "../../interactive/payload.js";
import { normalizeTargetForProvider } from "./target-normalization.js";
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
@@ -88,12 +89,61 @@ function isCrossContextTarget(params: {
return normalizedTarget !== normalizedCurrent;
}
function resolveAgentMessageToolsConfig(
cfg: OpenClawConfig,
agentId?: string | null,
): MessageToolsConfig | undefined {
const trimmedAgentId = agentId?.trim();
const globalConfig = cfg.tools?.message;
if (!trimmedAgentId) {
return globalConfig;
}
const agentConfig = cfg.agents?.list?.find((entry) => entry.id === trimmedAgentId)?.tools
?.message;
if (!agentConfig) {
return globalConfig;
}
return {
...globalConfig,
...agentConfig,
crossContext:
globalConfig?.crossContext || agentConfig.crossContext
? {
...globalConfig?.crossContext,
...agentConfig.crossContext,
marker:
globalConfig?.crossContext?.marker || agentConfig.crossContext?.marker
? {
...globalConfig?.crossContext?.marker,
...agentConfig.crossContext?.marker,
}
: undefined,
}
: undefined,
broadcast:
globalConfig?.broadcast || agentConfig.broadcast
? {
...globalConfig?.broadcast,
...agentConfig.broadcast,
}
: undefined,
};
}
export function resolveEffectiveMessageToolsConfig(params: {
cfg: OpenClawConfig;
agentId?: string | null;
}): MessageToolsConfig | undefined {
return resolveAgentMessageToolsConfig(params.cfg, params.agentId);
}
export function enforceCrossContextPolicy(params: {
channel: ChannelId;
action: ChannelMessageActionName;
args: Record<string, unknown>;
toolContext?: ChannelThreadingToolContext;
cfg: OpenClawConfig;
agentId?: string | null;
}): void {
const currentTarget = params.toolContext?.currentChannelId?.trim();
if (!currentTarget) {
@@ -103,15 +153,17 @@ export function enforceCrossContextPolicy(params: {
return;
}
if (params.cfg.tools?.message?.allowCrossContextSend) {
const messageConfig = resolveEffectiveMessageToolsConfig({
cfg: params.cfg,
agentId: params.agentId,
});
if (messageConfig?.allowCrossContextSend) {
return;
}
const currentProvider = params.toolContext?.currentChannelProvider;
const allowWithinProvider =
params.cfg.tools?.message?.crossContext?.allowWithinProvider !== false;
const allowAcrossProviders =
params.cfg.tools?.message?.crossContext?.allowAcrossProviders === true;
const allowWithinProvider = messageConfig?.crossContext?.allowWithinProvider !== false;
const allowAcrossProviders = messageConfig?.crossContext?.allowAcrossProviders === true;
if (currentProvider && currentProvider !== params.channel) {
if (!allowAcrossProviders) {
@@ -146,6 +198,7 @@ export async function buildCrossContextDecoration(params: {
target: string;
toolContext?: ChannelThreadingToolContext;
accountId?: string | null;
agentId?: string | null;
}): Promise<CrossContextDecoration | null> {
if (!params.toolContext?.currentChannelId) {
return null;
@@ -158,7 +211,10 @@ export async function buildCrossContextDecoration(params: {
return null;
}
const markerConfig = params.cfg.tools?.message?.crossContext?.marker;
const markerConfig = resolveEffectiveMessageToolsConfig({
cfg: params.cfg,
agentId: params.agentId,
})?.crossContext?.marker;
if (markerConfig?.enabled === false) {
return null;
}