mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
feat: add per-agent message cross-context policy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user