refactor: remove sender owner tool gating

This commit is contained in:
Peter Steinberger
2026-05-21 13:13:35 +01:00
parent 159b3002e4
commit 02182d5a30
187 changed files with 418 additions and 1802 deletions
+1
View File
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
### Fixes
@@ -105,7 +105,6 @@ describe("ClickClack account resolution", () => {
enabled: true,
reconnectMs: 1_500,
replyMode: "agent",
senderIsOwner: false,
token: "ccb_live",
workspace: "wsp_1",
});
@@ -125,7 +124,6 @@ describe("ClickClack account resolution", () => {
replyMode: "model",
model: "openai/gpt-5.4-mini",
toolsAllow: ["web_search"],
senderIsOwner: true,
},
},
},
@@ -144,7 +142,6 @@ describe("ClickClack account resolution", () => {
enabled: true,
model: "openai/gpt-5.4-mini",
replyMode: "model",
senderIsOwner: true,
token: "ccb_peter",
toolsAllow: ["web_search"],
workspace: "wsp_1",
@@ -155,7 +152,6 @@ describe("ClickClack account resolution", () => {
model: "openai/gpt-5.4-mini",
reconnectMs: 1_500,
replyMode: "model",
senderIsOwner: true,
token: "ccb_peter",
toolsAllow: ["web_search"],
workspace: "wsp_1",
-1
View File
@@ -126,7 +126,6 @@ export function resolveClickClackAccount(params: {
systemPrompt: normalizeOptionalString(merged.systemPrompt),
timeoutSeconds: merged.timeoutSeconds,
toolsAllow: merged.toolsAllow,
senderIsOwner: merged.senderIsOwner === true,
defaultTo: merged.defaultTo?.trim() || "channel:general",
allowFrom: merged.allowFrom ?? ["*"],
reconnectMs: merged.reconnectMs ?? DEFAULT_RECONNECT_MS,
@@ -16,7 +16,6 @@ const ClickClackAccountConfigSchema = z
systemPrompt: z.string().optional(),
timeoutSeconds: z.number().int().min(1).max(3_600).optional(),
toolsAllow: z.array(z.string()).optional(),
senderIsOwner: z.boolean().optional(),
defaultTo: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
reconnectMs: z.number().int().min(100).max(60_000).optional(),
@@ -94,7 +94,6 @@ describe("handleClickClackInbound", () => {
agentId: "service-bot",
replyMode: "model",
model: "openai/gpt-5.4-mini",
senderIsOwner: false,
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
-2
View File
@@ -13,7 +13,6 @@ export type ClickClackAccountConfig = {
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
senderIsOwner?: boolean;
defaultTo?: string;
allowFrom?: string[];
reconnectMs?: number;
@@ -45,7 +44,6 @@ export type ResolvedClickClackAccount = {
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
senderIsOwner: boolean;
defaultTo: string;
allowFrom: string[];
reconnectMs: number;
@@ -3343,7 +3343,6 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding:
params.allowGatewaySubagentBinding || isForcedPrivateQaCodexRuntime(),
...sessionKeys,
@@ -104,8 +104,6 @@ export class AgentComponentButton extends Button {
enqueueSystemEvent(eventText, {
sessionKey: route.sessionKey,
contextKey: `discord:agent-button:${channelId}:${componentId}:${userId}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
await ackComponentInteraction({ interaction, replyOpts, label: "agent button" });
@@ -198,8 +196,6 @@ export class AgentSelectMenu extends StringSelectMenu {
enqueueSystemEvent(eventText, {
sessionKey: route.sessionKey,
contextKey: `discord:agent-select:${channelId}:${componentId}:${userId}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
await ackComponentInteraction({ interaction, replyOpts, label: "agent select" });
@@ -501,8 +501,6 @@ async function handleDiscordReactionEvent(
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey,
forceSenderIsOwnerFalse: true,
trusted: false,
});
};
const shouldNotifyReaction = (options: {
@@ -724,8 +724,6 @@ export async function preflightDiscordMessage(
enqueueSystemEvent(systemText, {
sessionKey: effectiveRoute.sessionKey,
contextKey: `discord:system:${messageChannelId}:${message.id}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
return null;
}
@@ -140,8 +140,6 @@ describe("agent components", () => {
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello:123456789",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
if (params.expectPairingStoreRead) {
@@ -269,8 +267,6 @@ describe("agent components", () => {
{
sessionKey: defaultGroupDmSessionKey,
contextKey: "discord:agent-button:group-dm-channel:hello:123456789",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(peekSystemEvents(defaultDmSessionKey)).toStrictEqual([]);
@@ -351,8 +347,6 @@ describe("agent components", () => {
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-select:dm-channel:hello:123456789",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
@@ -376,8 +370,6 @@ describe("agent components", () => {
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello_cid:123456789",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
@@ -401,8 +393,6 @@ describe("agent components", () => {
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello%2G:123456789",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
-1
View File
@@ -139,7 +139,6 @@ export async function runDiscordVoiceAgentTurn(params: {
messageChannel: "discord",
messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER,
extraSystemPrompt: context.extraSystemPrompt,
senderIsOwner: context.senderIsOwner,
allowModelOverride: Boolean(voiceModel),
model: voiceModel,
toolsAllow: params.toolsAllow,
@@ -2000,7 +2000,6 @@ describe("DiscordVoiceManager", () => {
ownerTurn?.sendInputAudio(Buffer.alloc(8));
await new Promise((resolve) => setTimeout(resolve, 260));
expect(lastAgentCommandArgs().senderIsOwner).toBe(false);
expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled();
expectUserMessageIncludes("non-owner answer");
});
@@ -2053,10 +2052,8 @@ describe("DiscordVoiceManager", () => {
const guestCommandArgs = agentCommandArgsAt(0);
expect(guestCommandArgs.message).toContain("guest question");
expect(guestCommandArgs.senderIsOwner).toBe(false);
const ownerCommandArgs = agentCommandArgsAt(1);
expect(ownerCommandArgs.message).toContain("owner question");
expect(ownerCommandArgs.senderIsOwner).toBe(true);
expectUserMessageIncludes("guest answer");
expectUserMessageIncludes("owner answer");
});
@@ -2420,10 +2417,8 @@ describe("DiscordVoiceManager", () => {
const ownerCommandArgs = agentCommandArgsAt(0);
expect(ownerCommandArgs.message).toContain("owner question");
expect(ownerCommandArgs.senderIsOwner).toBe(true);
const guestCommandArgs = agentCommandArgsAt(1);
expect(guestCommandArgs.message).toContain("guest question");
expect(guestCommandArgs.senderIsOwner).toBe(false);
expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-owner", {
text: "owner answer",
});
@@ -2652,7 +2647,6 @@ describe("DiscordVoiceManager", () => {
expect(agentCommandMock).toHaveBeenCalledTimes(2);
const followupCommandArgs = agentCommandArgsAt(1);
expect(followupCommandArgs.message).toContain("guest followup");
expect(followupCommandArgs.senderIsOwner).toBe(false);
expectUserMessageIncludes("guest answer");
});
@@ -2788,7 +2782,6 @@ describe("DiscordVoiceManager", () => {
bridgeParams?.onTranscript?.("user", "guest question", true);
await new Promise((resolve) => setTimeout(resolve, 260));
expect(lastAgentCommandArgs().senderIsOwner).toBe(false);
expectUserMessageIncludes("guest answer");
});
@@ -2880,7 +2873,6 @@ describe("DiscordVoiceManager", () => {
);
expect(workingToolResultCall?.[2]).toEqual({ willContinue: true });
const commandArgs = lastAgentCommandArgs();
expect(commandArgs.senderIsOwner).toBe(true);
expect(commandArgs.toolsAllow).toEqual([
"read",
"web_search",
@@ -3074,7 +3066,6 @@ describe("DiscordVoiceManager", () => {
await Promise.resolve();
const commandArgs = lastAgentCommandArgs();
expect(commandArgs.senderIsOwner).toBe(false);
expect(commandArgs.toolsAllow).toEqual([
"read",
"web_search",
@@ -3147,7 +3138,6 @@ describe("DiscordVoiceManager", () => {
await Promise.resolve();
const commandArgs = lastAgentCommandArgs();
expect(commandArgs.senderIsOwner).toBe(false);
expect(commandArgs.toolsAllow).toEqual([
"read",
"web_search",
@@ -3447,7 +3437,7 @@ describe("DiscordVoiceManager", () => {
}
});
it("passes senderIsOwner=true for allowlisted voice speakers", async () => {
it("accepts allowlisted voice speakers", async () => {
const client = createClient();
client.fetchMember.mockResolvedValue({
nickname: "Owner Nick",
@@ -3460,12 +3450,9 @@ describe("DiscordVoiceManager", () => {
});
const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client);
await processVoiceSegment(manager, "u-owner");
const commandArgs = lastAgentCommandArgs() as { senderIsOwner?: boolean } | undefined;
expect(commandArgs?.senderIsOwner).toBe(true);
});
it("passes senderIsOwner=false for non-owner voice speakers", async () => {
it("accepts open-policy voice speakers", async () => {
const client = createClient();
client.fetchMember.mockResolvedValue({
nickname: "Guest Nick",
@@ -3480,9 +3467,6 @@ describe("DiscordVoiceManager", () => {
commands: { useAccessGroups: false },
});
await processVoiceSegment(manager, "u-guest");
const commandArgs = lastAgentCommandArgs() as { senderIsOwner?: boolean } | undefined;
expect(commandArgs?.senderIsOwner).toBe(false);
});
it("passes configured model override to agent command in voice flow", async () => {
@@ -33,8 +33,6 @@ describe("enqueueIMessageReactionSystemEvent", () => {
{
sessionKey: "agent:main:main",
contextKey: "imessage:reaction:added:3:lobster-reply-guid:+15555550123:👎",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(runtime.log).toHaveBeenCalledWith(
@@ -23,8 +23,6 @@ export function enqueueIMessageReactionSystemEvent(params: {
const queued = enqueueSystemEvent(decision.text, {
sessionKey: decision.route.sessionKey,
contextKey: decision.contextKey,
forceSenderIsOwnerFalse: true,
trusted: false,
});
runtime.log?.(
`imessage: reaction system event ${queued ? "queued" : "deduped"} session=${
@@ -97,8 +97,8 @@ describe("matrixMessageActions account propagation", () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: true,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
@@ -115,53 +115,46 @@ describe("matrixMessageActions account propagation", () => {
expect(call.options).toEqual({ mediaLocalRoots: undefined });
});
it("rejects self-profile updates for non-owner callers", async () => {
try {
await matrixMessageActions.handleAction?.(
it("rejects self-profile updates without sender owner context", async () => {
await expect(
matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: false,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
);
throw new Error("expected non-owner self-profile update to reject");
} catch (error) {
expect((error as Error).name).toBe("ToolAuthorizationError");
expect((error as Error).message).toBe("Matrix profile updates require owner access.");
}
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
),
).rejects.toThrow("Matrix profile updates require owner access.");
});
it("rejects self-profile updates when owner status is unknown", async () => {
try {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
params: {
displayName: "Ops Bot",
},
}),
);
throw new Error("expected unknown-owner self-profile update to reject");
} catch (error) {
expect((error as Error).name).toBe("ToolAuthorizationError");
expect((error as Error).message).toBe("Matrix profile updates require owner access.");
}
it("dispatches self-profile updates with sender owner context", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
accountId: "ops",
senderIsOwner: true,
params: {
displayName: "Ops Bot",
},
}),
);
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
const call = matrixActionCall();
expect(call.input).toMatchObject({
action: "setProfile",
accountId: "ops",
displayName: "Ops Bot",
});
});
it("forwards local avatar paths for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: profileAction,
senderIsOwner: true,
accountId: "ops",
senderIsOwner: true,
params: {
path: "/tmp/avatar.jpg",
},
+1 -15
View File
@@ -108,20 +108,7 @@ describe("matrixMessageActions", () => {
expect(properties.avatarPath).toHaveProperty("type", "string");
});
it("hides self-profile updates for non-owner discovery", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
senderIsOwner: false,
} as never);
if (!discovery) {
throw new Error("describeMessageTool returned null");
}
expect(discovery.actions).not.toContain(profileAction);
expect(discovery.schema).toBeNull();
});
it("hides self-profile updates when owner status is unknown", () => {
it("hides self-profile updates without owner identity context", () => {
const discovery = matrixMessageActions.describeMessageTool({
cfg: createConfiguredMatrixConfig(),
} as never);
@@ -130,7 +117,6 @@ describe("matrixMessageActions", () => {
}
expect(discovery.actions).not.toContain(profileAction);
expect(discovery.schema).toBeNull();
});
it("hides gated actions when the default Matrix account disables them", () => {
@@ -645,10 +645,7 @@ describe("msteams monitor handler authz", () => {
if (!systemEventCall) {
throw new Error("expected skipped Teams message system event");
}
expect(systemEventCall[1]).toMatchObject({
forceSenderIsOwnerFalse: true,
trusted: false,
});
expect(systemEventCall[1]).toMatchObject({});
});
it("keeps dispatched primary message system events owner-neutral", async () => {
@@ -690,12 +687,6 @@ describe("msteams monitor handler authz", () => {
if (!systemEventCall) {
throw new Error("expected active Teams message system event");
}
expect(systemEventCall[1]).not.toMatchObject({
forceSenderIsOwnerFalse: true,
});
expect(systemEventCall[1]).not.toMatchObject({
trusted: false,
});
});
it("authorizes text control commands from static access groups", async () => {
@@ -43,8 +43,6 @@ describe("msteams thread parent context injection", () => {
{
sessionKey: string;
contextKey?: string;
forceSenderIsOwnerFalse?: boolean;
trusted?: boolean;
},
];
@@ -102,10 +100,7 @@ describe("msteams thread parent context injection", () => {
expect(parentCall[0]).toBe("Replying to @Alice: Can someone investigate the latency spike?");
expect(parentCall[1]?.contextKey).toContain("msteams:thread-parent:");
expect(parentCall[1]?.contextKey).toContain("thread-root-123");
expect(parentCall[1]).toMatchObject({
forceSenderIsOwnerFalse: true,
trusted: false,
});
expect(parentCall[1]).toMatchObject({});
});
it("caches parent fetches across thread replies in the same session", async () => {
@@ -498,14 +498,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
const enqueuePrimaryMessageSystemEvent = (opts?: {
forceSenderIsOwnerFalse?: boolean;
trusted?: boolean;
}) =>
const enqueuePrimaryMessageSystemEvent = () =>
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
...opts,
});
const channelId = conversationId;
@@ -541,10 +537,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
enqueuePrimaryMessageSystemEvent({
forceSenderIsOwnerFalse: true,
trusted: false,
});
enqueuePrimaryMessageSystemEvent();
createChannelHistoryWindow({ historyMap: conversationHistories }).record({
historyKey: conversationId,
limit: historyLimit,
@@ -675,8 +668,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
core.system.enqueueSystemEvent(formatParentContextEvent(parentSummary), {
sessionKey: route.sessionKey,
contextKey: `msteams:thread-parent:${conversationId}:${activity.replyToId}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
markParentContextInjected(route.sessionKey, activity.replyToId);
}
@@ -207,8 +207,6 @@ describe("createMSTeamsReactionHandler", () => {
expect(label).toContain("added");
expect(meta.sessionKey).toBe("test-session");
expect(meta.contextKey).toContain("added");
expect(meta.forceSenderIsOwnerFalse).toBe(true);
expect(meta.trusted).toBe(false);
});
it("enqueues system event for reactionsRemoved", async () => {
@@ -116,8 +116,6 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) {
core.system.enqueueSystemEvent(label, {
sessionKey: route.sessionKey,
contextKey: `msteams:reaction:${conversationId}:${targetMessageId}:${senderId}:${reactionType}:${direction}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
}
};
@@ -33,15 +33,9 @@ describe("bridge/tools/remind", () => {
callGatewayToolMock.mockResolvedValue({ ok: true });
});
it("marks qqbot_remind as owner-only", () => {
const tool = createRemindTool();
expect(tool.ownerOnly).toBe(true);
});
it("schedules reminders directly through Gateway cron with ambient QQ delivery context", async () => {
callGatewayToolMock.mockResolvedValue({ id: "job-1" });
const tool = createRemindTool({
senderIsOwner: true,
deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
});
@@ -73,7 +67,7 @@ describe("bridge/tools/remind", () => {
});
it("routes list and remove through Gateway cron without exposing generic cron to the model", async () => {
const tool = createRemindTool({ senderIsOwner: true });
const tool = createRemindTool({});
await tool.execute("tool-call-1", { action: "list" });
await tool.execute("tool-call-2", { action: "remove", jobId: "job-1" });
@@ -91,7 +85,6 @@ describe("bridge/tools/remind", () => {
const callCron = vi.fn(async (_params: unknown) => ({ id: "job-1" }));
const tool = createRemindTool(
{
senderIsOwner: true,
deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
},
{ callCron },
@@ -117,7 +110,7 @@ describe("bridge/tools/remind", () => {
expect(callGatewayToolMock).not.toHaveBeenCalled();
});
it("does not schedule when sender ownership is missing", async () => {
it("schedules when sender ownership is missing", async () => {
const callCron = vi.fn(async (_params: unknown) => ({ id: "job-1" }));
const tool = createRemindTool(
{
@@ -126,16 +119,13 @@ describe("bridge/tools/remind", () => {
{ callCron },
);
const result = await tool.execute("tool-call-1", {
await tool.execute("tool-call-1", {
action: "add",
content: "drink water",
time: "5m",
});
expect(callCron).not.toHaveBeenCalled();
expect(callCron).toHaveBeenCalledTimes(1);
expect(callGatewayToolMock).not.toHaveBeenCalled();
expect(result.details).toEqual({
error: "QQ reminders require an owner-authorized sender.",
});
});
});
@@ -49,7 +49,6 @@ export function createRemindTool(
return {
name: "qqbot_remind",
label: "QQBot Reminder",
ownerOnly: true,
description:
"Create, list, and remove QQ reminders. " +
"This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" +
@@ -60,19 +59,6 @@ export function createRemindTool(
'Time examples: "5m", "1h", "0 8 * * *"',
parameters: RemindSchema,
async execute(_toolCallId, params) {
if (toolContext.senderIsOwner !== true) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
error: "QQ reminders require an owner-authorized sender.",
}),
},
],
details: { error: "QQ reminders require an owner-authorized sender." },
};
}
const ctx = getRequestContext();
return await executeScheduledRemind(
params as RemindParams,
@@ -75,9 +75,7 @@ function hasQueuedReactionEventFor(sender: string) {
typeof options === "object" &&
options !== null &&
"sessionKey" in options &&
(options as { sessionKey?: string; forceSenderIsOwnerFalse?: boolean }).sessionKey ===
route.sessionKey &&
(options as { forceSenderIsOwnerFalse?: boolean }).forceSenderIsOwnerFalse === true
(options as { sessionKey?: string }).sessionKey === route.sessionKey
);
});
}
@@ -518,8 +518,6 @@ describe("signal createSignalEventHandler inbound context", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("reaction added", {
sessionKey: "agent:main:signal:group:g1",
contextKey: "signal:reaction:added:1700000000000:+15550001111:+1:g1",
forceSenderIsOwnerFalse: true,
trusted: false,
});
});
@@ -490,8 +490,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey,
forceSenderIsOwnerFalse: true,
trusted: false,
});
return true;
}
@@ -81,8 +81,6 @@ describe("registerSlackChannelEvents", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Slack channel created: #general.", {
sessionKey: "agent:main:main",
contextKey: "slack:channel:created:C1",
forceSenderIsOwnerFalse: true,
trusted: false,
});
});
});
@@ -46,8 +46,6 @@ export function registerSlackChannelEvents(params: {
enqueueSystemEvent(`Slack channel ${params.kind}: ${label}.`, {
sessionKey,
contextKey: `slack:channel:${params.kind}:${params.channelId ?? params.channelName ?? "unknown"}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
};
@@ -794,8 +794,6 @@ function enqueueSlackBlockActionEvent(params: {
accountId: params.ctx.accountId,
threadId: params.parsed.threadTs,
},
forceSenderIsOwnerFalse: true,
trusted: false,
});
if (queued) {
requestHeartbeat({
@@ -764,8 +764,6 @@ describe("registerSlackInteractionEvents", () => {
to: "channel:C1",
},
sessionKey: "agent:ops:slack:channel:C1",
forceSenderIsOwnerFalse: true,
trusted: false,
},
);
expect(resolveSessionKey).toHaveBeenCalledWith({
@@ -230,8 +230,6 @@ describe("registerSlackReactionEvents", () => {
expect(reactionQueueMock).toHaveBeenCalledWith(expect.any(String), {
sessionKey: "agent:main:main",
contextKey: "slack:reaction:added:D1:123.456:U1:thumbsup",
forceSenderIsOwnerFalse: true,
trusted: false,
});
});
@@ -88,8 +88,6 @@ export function registerSlackReactionEvents(params: {
enqueueSystemEvent(text, {
sessionKey: ingressContext.sessionKey,
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
} catch (err) {
ctx.runtime.error?.(danger(`slack reaction handler failed: ${formatErrorMessage(err)}`));
@@ -164,8 +164,6 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Slack DM from Alice: hi", {
sessionKey: prepared.ctxPayload.SessionKey,
contextKey: "slack:message:D123:1.000",
forceSenderIsOwnerFalse: true,
trusted: false,
});
});
@@ -1116,8 +1116,6 @@ export async function prepareSlackMessage(params: {
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
forceSenderIsOwnerFalse: true,
trusted: false,
});
const envelopeFrom =
@@ -12,7 +12,6 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",
ownerOnly: true,
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
@@ -521,7 +521,6 @@ export async function monitorWebChannel(
});
enqueueSystemEvent(`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`, {
sessionKey: connectRoute.sessionKey,
trusted: true,
});
const normalizedAccountId = normalizeReconnectAccountId(account.accountId);
@@ -615,7 +614,6 @@ export async function monitorWebChannel(
`WhatsApp gateway disconnected (status ${decision.normalized.statusLabel})`,
{
sessionKey: connectRoute.sessionKey,
trusted: true,
},
);
+1 -57
View File
@@ -2695,62 +2695,6 @@ rules:
detector-bucket: precise
source-run: 2026-04-17T07-37-10Z
source-rule-id: replay-key-derived-from-idempotency-header
- id: ghsa-gfmx-pph7-g46x.openclaw.system-event-missing-explicit-trust
languages:
- typescript
- javascript
severity: ERROR
message: |
enqueueSystemEvent() is called with interpolated or variable text without an explicit owner downgrade. External content — channel messages, user IDs, event payloads, exec output — MUST be explicitly downgraded with `forceSenderIsOwnerFalse: true` (or legacy `trusted: false`) to prevent prompt injection. See GHSA-GFMX-PPH7-G46X.
TRIAGE NOTE: If ALL interpolated values in the template literal are boolean flags or enum/const expressions (e.g. `${x ? "on" : "off"}`), or if the variable text is formatted from fully-internal state (not external channel content), the finding may be low-risk. Add `trusted: true` explicitly to self-document that the text is intentionally trusted.
metadata:
category: security
cwe:
- CWE-74
- CWE-77
ghsas:
- GHSA-GFMX-PPH7-G46X
ghsa: GHSA-GFMX-PPH7-G46X
advisory-url: https://github.com/openclaw/openclaw/security/advisories/GHSA-GFMX-PPH7-G46X
detector-bucket: precise
source-run: 2026-04-17T07-37-10Z
source-rule-id: openclaw.system-event-missing-explicit-trust
paths:
exclude:
- "**/*.test.*"
- "**/*.spec.*"
- src/auto-reply/reply/directive-handling.impl.ts
- src/auto-reply/reply/directive-handling.persist.ts
- src/auto-reply/reply/get-reply-directives-apply.ts
- src/gateway/config-recovery-notice.ts
- src/gateway/server-restart-sentinel.ts
- src/infra/session-maintenance-warning.ts
pattern-either:
- patterns:
- pattern: |
enqueueSystemEvent(`...${$X}...`, $OPTS)
- pattern-not: |
enqueueSystemEvent(`...${$X}...`, { ..., trusted: $V, ... })
- pattern-not: |
enqueueSystemEvent(`...${$X}...`, { ..., forceSenderIsOwnerFalse: true, ... })
- pattern-not-inside: |
enqueueSystemEvent(`...${$X}...`, { ..., trusted: $V, ... })
- pattern-not-inside: |
enqueueSystemEvent(`...${$X}...`, { ..., forceSenderIsOwnerFalse: true, ... })
- patterns:
- pattern: |
enqueueSystemEvent($TEXT, $OPTS)
- pattern-not: |
enqueueSystemEvent($TEXT, { ..., trusted: $V, ... })
- pattern-not: |
enqueueSystemEvent($TEXT, { ..., forceSenderIsOwnerFalse: true, ... })
- pattern-not-inside: |
enqueueSystemEvent($TEXT, { ..., trusted: $V, ... })
- pattern-not-inside: |
enqueueSystemEvent($TEXT, { ..., forceSenderIsOwnerFalse: true, ... })
- metavariable-regex:
metavariable: $TEXT
regex: ^[a-zA-Z_$][a-zA-Z0-9_$]*$
- id: ghsa-gg9v-mgcp-v6m7.openclaw-bootstrap-token-legacy-acceptance-without-profile-record
languages:
- typescript
@@ -4802,7 +4746,7 @@ rules:
source-run: 2026-04-17T07-37-10Z
source-rule-id: gateway-http-json-endpoint-missing-required-operator-method
- id: ghsa-x2m8-53h4-6hch.discord-voice-ingress-missing-authorize-review
message: Discord voice ingress forwards senderIsOwner to agentCommandFromIngress without calling authorizeDiscordVoiceIngress in the same file. See GHSA-X2M8-53H4-6HCH.
message: Discord voice ingress calls agentCommandFromIngress without calling authorizeDiscordVoiceIngress in the same file. See GHSA-X2M8-53H4-6HCH.
severity: ERROR
languages:
- typescript
+1 -1
View File
@@ -84,7 +84,7 @@ describe("classifyAcpToolApproval", () => {
expectedClass: "exec_capable",
},
] as const)(
"classifies shared owner-only ACP backstops for $expectedToolName",
"classifies shared ACP backstop tools for $expectedToolName",
({ title, rawInput, expectedToolName, expectedClass }) => {
expect(
classify({
+8 -6
View File
@@ -2,7 +2,6 @@ import { homedir } from "node:os";
import path from "node:path";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import { isMutatingToolCall } from "../agents/tool-mutation.js";
import { resolveOwnerOnlyToolApprovalClass } from "../agents/tool-policy.js";
import { isPathInside } from "../infra/path-guards.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -19,8 +18,15 @@ const EXEC_CAPABLE_TOOL_IDS = new Set([
"bash",
"process",
"code_execution",
"nodes",
]);
const CONTROL_PLANE_TOOL_IDS = new Set([
"cron",
"gateway",
"sessions_spawn",
"sessions_send",
"session_status",
]);
const CONTROL_PLANE_TOOL_IDS = new Set(["sessions_spawn", "sessions_send", "session_status"]);
export type AcpApprovalClass =
| "readonly_scoped"
@@ -209,10 +215,6 @@ export function classifyAcpToolApproval(params: {
if (SAFE_SEARCH_TOOL_IDS.has(toolName) && isTrustedToolId) {
return { toolName, approvalClass: "readonly_search", autoApprove: true };
}
const ownerOnlyApprovalClass = resolveOwnerOnlyToolApprovalClass(toolName);
if (ownerOnlyApprovalClass) {
return { toolName, approvalClass: ownerOnlyApprovalClass, autoApprove: false };
}
if (EXEC_CAPABLE_TOOL_IDS.has(toolName)) {
return { toolName, approvalClass: "exec_capable", autoApprove: false };
}
+1 -1
View File
@@ -437,7 +437,7 @@ describe("resolvePermissionRequest", () => {
},
},
] as const)(
"prompts for shared owner-only backstop tools: $toolName",
"prompts for shared backstop tools: $toolName",
async ({ toolName, title, rawInput }) => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
+1 -12
View File
@@ -145,8 +145,6 @@ describe("startAcpSpawnParentStreamRelay", () => {
contextKey?: string;
sessionKey?: string;
deliveryContext?: unknown;
forceSenderIsOwnerFalse?: boolean;
trusted?: boolean;
},
]
>;
@@ -155,30 +153,22 @@ describe("startAcpSpawnParentStreamRelay", () => {
contextKey: options.contextKey,
sessionKey: options.sessionKey,
deliveryContext: options.deliveryContext,
forceSenderIsOwnerFalse: options.forceSenderIsOwnerFalse,
trusted: options.trusted,
})),
).toEqual([
{
contextKey: "acp-spawn:run-1:start",
sessionKey: "agent:main:main",
deliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
},
{
contextKey: "acp-spawn:run-1:progress",
sessionKey: "agent:main:main",
deliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
},
{
contextKey: "acp-spawn:run-1:done",
sessionKey: "agent:main:main",
deliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
},
]);
const heartbeatCalls = requestHeartbeatMock.mock.calls as Array<
@@ -233,11 +223,10 @@ describe("startAcpSpawnParentStreamRelay", () => {
);
expect(progressEvent?.[0]).toContain("codex: hello from child");
const progressOptions = progressEvent?.[1] as
| { contextKey?: unknown; sessionKey?: unknown; forceSenderIsOwnerFalse?: unknown }
| { contextKey?: unknown; sessionKey?: unknown }
| undefined;
expect(progressOptions?.contextKey).toBe("acp-spawn:run-cron:progress");
expect(progressOptions?.sessionKey).toBe("global");
expect(progressOptions?.forceSenderIsOwnerFalse).toBe(true);
const heartbeatOptions = firstMockCall(requestHeartbeatMock, "heartbeat request")[0] as
| { agentId?: string; reason?: string }
| undefined;
-2
View File
@@ -234,8 +234,6 @@ export function startAcpSpawnParentStreamRelay(params: {
sessionKey: resolveEventSessionKey(parentSessionKey, params.mainKey, params.sessionScope),
contextKey,
deliveryContext: params.deliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
});
wake();
};
@@ -706,7 +706,6 @@ async function runBasicAgentCommand() {
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
});
}
@@ -855,7 +854,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
thinking: "xhigh",
});
@@ -914,7 +912,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
await agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
thinking: "xhigh",
});
@@ -1029,7 +1026,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
await agentCommand({
message: "internal handoff",
to: "+1234567890",
senderIsOwner: true,
suppressPromptPersistence: true,
});
@@ -1045,7 +1041,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
agentCommand({
message: "hello",
to: "+1234567890",
senderIsOwner: true,
}),
).rejects.toThrow("provider down");
@@ -1372,7 +1367,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
INTERNAL_RUNTIME_CONTEXT_END,
].join("\n"),
sessionKey: "agent:main",
senderIsOwner: true,
internalEvents: [
{
type: "task_completion",
@@ -1423,7 +1417,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
await agentCommand({
message: "bootstrap ACP child",
sessionKey: "agent:main",
senderIsOwner: true,
acpTurnSource: "manual_spawn",
});
@@ -1448,7 +1441,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
agentCommand({
message: "automatic ACP turn",
sessionKey: "agent:main",
senderIsOwner: true,
}),
).rejects.toThrow("ACP dispatch is disabled");
+4 -17
View File
@@ -300,10 +300,7 @@ function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model")
return trimmed;
}
async function prepareAgentCommandExecution(
opts: AgentCommandOpts & { senderIsOwner: boolean },
runtime: RuntimeEnv,
) {
async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) {
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
const message = opts.message ?? "";
if (!message.trim()) {
@@ -483,7 +480,7 @@ async function prepareAgentCommandExecution(
}
async function agentCommandInternal(
opts: AgentCommandOpts & { senderIsOwner: boolean },
opts: AgentCommandOpts,
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
) {
@@ -1474,7 +1471,6 @@ async function agentCommandInternal(
skillsSnapshot,
messageChannel,
agentAccountId: runContext.accountId,
senderIsOwner: opts.senderIsOwner,
thinkLevel: resolvedThinkLevel,
extraSystemPrompt: opts.extraSystemPrompt,
});
@@ -1606,7 +1602,7 @@ export async function agentCommand(
{
...opts,
// agentCommand is the trusted-operator entrypoint used by CLI/local flows.
// Ingress callers must opt into owner semantics explicitly via
// Ingress callers must opt into owner identity explicitly via
// agentCommandFromIngress so network-facing paths cannot inherit this default by accident.
senderIsOwner: opts.senderIsOwner ?? true,
// Local/CLI callers are trusted by default for per-run model overrides.
@@ -1623,20 +1619,11 @@ export async function agentCommandFromIngress(
runtime: RuntimeEnv = defaultRuntime,
deps?: CliDeps,
) {
if (typeof opts.senderIsOwner !== "boolean") {
// HTTP/WS ingress must declare the trust level explicitly at the boundary.
// This keeps network-facing callers from silently picking up the local trusted default.
throw new Error("senderIsOwner must be explicitly set for ingress agent runs.");
}
if (typeof opts.allowModelOverride !== "boolean") {
throw new Error("allowModelOverride must be explicitly set for ingress agent runs.");
}
return await agentCommandInternal(
{
...opts,
senderIsOwner: opts.senderIsOwner,
allowModelOverride: opts.allowModelOverride,
},
{ ...opts, senderIsOwner: opts.senderIsOwner === true },
runtime,
deps,
);
@@ -197,7 +197,7 @@ async function runAuthContractAttempt(params: {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId,
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -489,8 +489,6 @@ describe("emitExecSystemEvent", () => {
to: "telegram:-100123:topic:47",
threadId: 47,
},
forceSenderIsOwnerFalse: true,
trusted: false,
});
const heartbeat = requireHeartbeatCall();
expect(heartbeat.coalesceMs).toBe(0);
@@ -508,8 +506,6 @@ describe("emitExecSystemEvent", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "agent:ops:primary",
contextKey: "exec:run-cron",
forceSenderIsOwnerFalse: true,
trusted: false,
});
expect(requestHeartbeatMock).toHaveBeenCalledTimes(1);
const [[heartbeatParams]] = requestHeartbeatMock.mock.calls as unknown as Array<
@@ -530,8 +526,6 @@ describe("emitExecSystemEvent", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "global",
contextKey: "exec:run-global",
forceSenderIsOwnerFalse: true,
trusted: false,
});
expect(requestHeartbeatMock).toHaveBeenCalledTimes(1);
const [[heartbeatParams]] = requestHeartbeatMock.mock.calls as unknown as Array<
@@ -552,8 +546,6 @@ describe("emitExecSystemEvent", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "global",
contextKey: "exec:run-global",
forceSenderIsOwnerFalse: true,
trusted: false,
});
const heartbeat = requireHeartbeatCall();
expect(heartbeat.coalesceMs).toBe(0);
@@ -580,8 +572,6 @@ describe("emitExecSystemEvent", () => {
sessionKey: "agent:main:subagent:abc-123",
contextKey: "exec:run-sub",
deliveryContext: undefined,
forceSenderIsOwnerFalse: true,
trusted: false,
});
expect(requestHeartbeatMock).not.toHaveBeenCalled();
});
-4
View File
@@ -343,8 +343,6 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile
enqueueSystemEvent(summary, {
sessionKey: resolveEventSessionKey(sessionKey, session.mainKey, session.sessionScope),
deliveryContext: session.notifyDeliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
});
// Subagent sessions receive exec results via process poll and announce flow;
// the heartbeat would fall back to the main session and cause spurious wakes.
@@ -447,8 +445,6 @@ export function emitExecSystemEvent(
sessionKey: resolveEventSessionKey(sessionKey, opts.mainKey, opts.sessionScope),
contextKey: opts.contextKey,
deliveryContext: opts.deliveryContext,
forceSenderIsOwnerFalse: true,
trusted: false,
});
// Subagent sessions receive exec results via process poll and announce flow;
// the heartbeat would fall back to the main session and cause spurious wakes.
+2 -2
View File
@@ -773,7 +773,7 @@ describe("exec notifyOnExit", () => {
expect(finished?.status).toBe(PROCESS_STATUS_COMPLETED);
expect(finished?.exitCode).toBe(0);
expect(hasEvent).toBe(true);
expect(queuedEvent?.forceSenderIsOwnerFalse).toBe(true);
expect(queuedEvent).toBeDefined();
expect(formatted).toBeUndefined();
});
@@ -793,7 +793,7 @@ describe("exec notifyOnExit", () => {
event.text.includes(sessionId.slice(0, 8)),
);
expect(queuedEvent?.forceSenderIsOwnerFalse).toBe(true);
expect(queuedEvent).toBeDefined();
expect(queuedEvent?.deliveryContext?.channel).toBe("telegram");
expect(queuedEvent?.deliveryContext?.to).toBe("telegram:-1003774691294:topic:47");
expect(queuedEvent?.deliveryContext?.threadId).toBe("47");
-1
View File
@@ -31,7 +31,6 @@ type ChannelMessageActionDiscoveryParams = {
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
senderIsOwner?: boolean;
};
const channelAgentToolMeta = new WeakMap<ChannelAgentTool, ChannelAgentToolMeta>();
-16
View File
@@ -571,22 +571,6 @@ describe("runCliAgent spawn path", () => {
expect(JSON.stringify(params)).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
});
it("forwards senderIsOwner through the compat wrapper", () => {
const params = buildRunClaudeCliAgentParams({
sessionId: "openclaw-session",
sessionKey: "agent:main:matrix:room:123",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
model: "opus",
timeoutMs: 1_000,
runId: "run-claude-owner-wrapper",
senderIsOwner: false,
});
expect(params.senderIsOwner).toBe(false);
});
it("forwards channel context through the compat wrapper", () => {
const params = buildRunClaudeCliAgentParams({
sessionId: "openclaw-session",
-2
View File
@@ -564,7 +564,6 @@ export async function runPreparedCliAgent(
}),
channelId: hookContext.channelId,
accountId: params.agentAccountId,
senderIsOwner: params.senderIsOwner,
},
buildAgentHookContext(hookContext),
);
@@ -725,7 +724,6 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
images: params.images,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
senderIsOwner: params.senderIsOwner,
};
}
+1 -6
View File
@@ -206,7 +206,6 @@ function buildCliEnvMcpLog(childEnv: Record<string, string>): string {
`agentId=${childEnv.OPENCLAW_MCP_AGENT_ID || "<empty>"}`,
`accountId=${childEnv.OPENCLAW_MCP_ACCOUNT_ID || "<empty>"}`,
`messageChannel=${childEnv.OPENCLAW_MCP_MESSAGE_CHANNEL || "<empty>"}`,
`senderIsOwner=${childEnv.OPENCLAW_MCP_SENDER_IS_OWNER || "<empty>"}`,
].join(" ");
}
@@ -427,11 +426,7 @@ export async function executePreparedCliRun(
});
cliBackendLog.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`);
cliBackendLog.info(`cli env auth: ${buildCliEnvAuthLog(env)}`);
if (
env.OPENCLAW_MCP_TOKEN ||
env.OPENCLAW_MCP_SESSION_KEY ||
env.OPENCLAW_MCP_SENDER_IS_OWNER
) {
if (env.OPENCLAW_MCP_TOKEN || env.OPENCLAW_MCP_SESSION_KEY || env.OPENCLAW_MCP_AGENT_ID) {
cliBackendLog.info(`cli env mcp: ${buildCliEnvMcpLog(env)}`);
}
}
+13 -11
View File
@@ -198,6 +198,9 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn((runtime, senderIsOwner) =>
senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken,
),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })),
});
@@ -955,8 +958,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
@@ -1000,8 +1003,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
);
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
@@ -1067,7 +1070,6 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
messageProvider: undefined,
accountId: undefined,
inboundEventKind: undefined,
senderIsOwner: undefined,
});
expect(context.systemPrompt).toContain("## Memory Recall");
expect(context.systemPrompt).toContain("tools=memory_search");
@@ -1167,8 +1169,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
@@ -1225,8 +1227,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
@@ -1260,8 +1262,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
+6 -4
View File
@@ -5,6 +5,7 @@ import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js";
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackBearerToken,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
@@ -68,6 +69,7 @@ const prepareDeps = {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
resolveMcpLoopbackBearerToken,
resolveMcpLoopbackScopedTools,
resolveOpenClawReferencePaths: async (
params: Parameters<typeof import("../docs-path.js").resolveOpenClawReferencePaths>[0],
@@ -223,10 +225,10 @@ export async function prepareCliRunContext(
: undefined,
env: mcpLoopbackRuntime
? {
OPENCLAW_MCP_TOKEN:
params.senderIsOwner === true
? mcpLoopbackRuntime.ownerToken
: mcpLoopbackRuntime.nonOwnerToken,
OPENCLAW_MCP_TOKEN: prepareDeps.resolveMcpLoopbackBearerToken(
mcpLoopbackRuntime,
params.senderIsOwner === true,
),
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
+1
View File
@@ -61,6 +61,7 @@ export type RunCliAgentParams = {
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
/** Trusted sender identity bit for channel action auth. */
senderIsOwner?: boolean;
/** Runtime tool allow-list. CLI harnesses fail closed when this is set. */
toolsAllow?: string[];
@@ -172,7 +172,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: params.runId,
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -270,7 +270,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-cli-expired",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -489,7 +489,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-codex-cli-auth-alias",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -894,7 +894,6 @@ describe("CLI attempt execution", () => {
timeoutMs: 1_000,
runId: "run-cli-channel-context",
opts: {
senderIsOwner: false,
messageProvider: "discord-voice",
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
@@ -945,7 +944,6 @@ describe("CLI attempt execution", () => {
timeoutMs: 1_000,
runId: "run-cli-tools-allow",
opts: {
senderIsOwner: true,
toolsAllow: ["read", "web_search"],
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
@@ -1001,7 +999,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-canonical-claude-cli",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "telegram",
@@ -1056,7 +1054,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-shorthand-claude-cli",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "telegram",
@@ -1118,7 +1116,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-canonical-codex-cli",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "telegram",
@@ -1196,7 +1194,7 @@ describe("CLI attempt execution", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-openai-codex-api-key-backup",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1256,7 +1254,6 @@ describe("CLI attempt execution", () => {
timeoutMs: 1_000,
runId: "run-model-run-raw",
opts: {
senderIsOwner: false,
modelRun: true,
promptMode: "none",
messageProvider: "discord-voice",
@@ -1328,7 +1325,6 @@ describe("CLI attempt execution", () => {
timeoutMs: 1_000,
runId: "run-elevated-followup",
opts: {
senderIsOwner: false,
bashElevated,
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
@@ -1378,7 +1374,6 @@ describe("CLI attempt execution", () => {
timeoutMs: 1_000,
runId: "run-cleanup-claude-cli",
opts: {
senderIsOwner: false,
cleanupBundleMcpOnRunEnd: true,
cleanupCliLiveSessionOnRunEnd: true,
} as Parameters<typeof runAgentAttempt>[0]["opts"],
@@ -1441,7 +1436,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-legacy-pi-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1482,7 +1477,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-mixed-provider-auto-runtime",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1523,7 +1518,6 @@ describe("embedded attempt harness pinning", () => {
timeoutMs: 1_000,
runId: "run-tools-allow",
opts: {
senderIsOwner: true,
toolsAllow: ["read", "web_search"],
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
@@ -1575,7 +1569,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-codex-no-pi-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1639,7 +1633,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-codex-auto-auth-profile",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1686,7 +1680,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-fresh-no-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1727,7 +1721,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-stale-openai-pi-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1782,7 +1776,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-openai-pi-codex-oauth",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
@@ -1835,7 +1829,7 @@ describe("embedded attempt harness pinning", () => {
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-openai-fallback-with-cli-runtime",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
opts: {} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
+1 -1
View File
@@ -381,7 +381,7 @@ export function runAgentAttempt(params: {
fastMode?: boolean;
timeoutMs: number;
runId: string;
opts: AgentCommandOpts & { senderIsOwner: boolean };
opts: AgentCommandOpts;
runContext: ReturnType<typeof resolveAgentRunContext>;
spawnedBy: string | undefined;
messageChannel: ReturnType<typeof resolveMessageChannel>;
-4
View File
@@ -127,7 +127,6 @@ async function compactCliTranscript(params: {
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
agentAccountId?: string;
senderIsOwner?: boolean;
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
extraSystemPrompt?: string;
}) {
@@ -142,7 +141,6 @@ async function compactCliTranscript(params: {
agentDir: params.agentDir,
config: params.cfg,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider: params.provider,
modelId: params.model,
thinkLevel: params.thinkLevel,
@@ -211,7 +209,6 @@ export async function runCliTurnCompactionLifecycle(params: {
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
agentAccountId?: string;
senderIsOwner?: boolean;
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
extraSystemPrompt?: string;
}): Promise<SessionEntry | undefined> {
@@ -275,7 +272,6 @@ export async function runCliTurnCompactionLifecycle(params: {
skillsSnapshot: params.skillsSnapshot,
messageChannel: params.messageChannel,
agentAccountId: params.agentAccountId,
senderIsOwner: params.senderIsOwner,
thinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
});
+3 -3
View File
@@ -83,7 +83,7 @@ export type AgentCommandOpts = {
runContext?: AgentRunContext;
/** Internal trusted exec approval follow-up elevated defaults. */
bashElevated?: ExecElevatedDefaults;
/** Whether this caller is authorized for owner-only tools (defaults true for local CLI calls). */
/** Trusted sender identity bit for command/channel-action auth; defaults true for local CLI calls. */
senderIsOwner?: boolean;
/** Whether this caller is authorized to use provider/model per-run overrides. */
allowModelOverride?: boolean;
@@ -134,8 +134,8 @@ export type AgentCommandIngressOpts = Omit<
AgentCommandOpts,
"senderIsOwner" | "allowModelOverride" | "resultMetaOverrides"
> & {
/** Ingress callsites must always pass explicit owner-tool authorization state. */
senderIsOwner: boolean;
/** Trusted sender identity bit for command/channel-action auth; defaults false for ingress. */
senderIsOwner?: boolean;
/** Ingress callsites must always pass explicit model-override authorization state. */
allowModelOverride: boolean;
};
-5
View File
@@ -164,11 +164,6 @@ describe("gateway tool", () => {
});
});
it("marks gateway as owner-only", () => {
const tool = requireGatewayTool();
expect(tool.ownerOnly).toBe(true);
});
it("exposes restart and config actions in the gateway tool schema", () => {
const tool = requireGatewayTool();
const parameters = tool.parameters as {
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isOpenClawOwnerOnlyCoreToolName,
OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES,
} from "./tools/owner-only-tools.js";
describe("createOpenClawTools owner authorization", () => {
it("marks owner-only core tool names", () => {
expect(OPENCLAW_OWNER_ONLY_CORE_TOOL_NAMES).toEqual(["cron", "gateway", "nodes"]);
expect(isOpenClawOwnerOnlyCoreToolName("cron")).toBe(true);
expect(isOpenClawOwnerOnlyCoreToolName("gateway")).toBe(true);
expect(isOpenClawOwnerOnlyCoreToolName("nodes")).toBe(true);
});
it("keeps canvas non-owner-only", () => {
expect(isOpenClawOwnerOnlyCoreToolName("canvas")).toBe(false);
});
});
@@ -10,12 +10,10 @@ describe("openclaw plugin tool context", () => {
options: {
config: {} as never,
requesterSenderId: "trusted-sender",
senderIsOwner: true,
},
});
expect(result.context.requesterSenderId).toBe("trusted-sender");
expect(result.context.senderIsOwner).toBe(true);
});
it("forwards fs policy for plugin tool sandbox enforcement", () => {
@@ -20,7 +20,6 @@ export type OpenClawPluginToolOptions = {
modelId?: string;
requesterSenderId?: string | null;
requesterAgentIdOverride?: string;
senderIsOwner?: boolean;
sessionId?: string;
sandboxBrowserBridgeUrl?: string;
allowHostBrowserControl?: boolean;
@@ -82,7 +81,6 @@ export function resolveOpenClawPluginToolInputs(params: {
agentAccountId: options?.agentAccountId,
deliveryContext,
requesterSenderId: options?.requesterSenderId ?? undefined,
senderIsOwner: options?.senderIsOwner ?? undefined,
sandboxed: options?.sandboxed,
},
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
+2 -2
View File
@@ -116,6 +116,8 @@ export function createOpenClawTools(
allowMediaInvokeCommands?: boolean;
/** Explicit agent ID override for cron/hook sessions. */
requesterAgentIdOverride?: string;
/** Trusted sender identity bit for channel action auth. */
senderIsOwner?: boolean;
/** Restrict the cron tool to self-removing this active cron job. */
cronSelfRemoveOnlyJobId?: string;
/** Require explicit message targets (no implicit last-route sends). */
@@ -143,8 +145,6 @@ export function createOpenClawTools(
requesterSenderId?: string | null;
/** Auth profiles already loaded for this run; used for prompt-time tool availability. */
authProfileStore?: AuthProfileStore;
/** Whether the requesting sender is an owner. */
senderIsOwner?: boolean;
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
/**
@@ -371,7 +371,6 @@ function buildCompactionContextEngineRuntimeContext(params: {
agentDir: params.agentDir,
config: params.params.config,
skillsSnapshot: params.params.skillsSnapshot,
senderIsOwner: params.params.senderIsOwner,
senderId: params.params.senderId,
provider: params.params.provider,
modelId: params.params.model,
-3
View File
@@ -742,7 +742,6 @@ async function compactEmbeddedPiSessionDirectOnce(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
agentDir,
workspaceDir: effectiveWorkspace,
@@ -809,7 +808,6 @@ async function compactEmbeddedPiSessionDirectOnce(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
warn: (message) => log.warn(message),
});
const normalizedBundledTools =
@@ -852,7 +850,6 @@ async function compactEmbeddedPiSessionDirectOnce(
sessionId: params.sessionId,
agentId: sessionAgentId,
senderId: params.senderId,
senderIsOwner: params.senderIsOwner,
}),
)
: undefined;
@@ -33,8 +33,6 @@ export type CompactEmbeddedPiSessionParams = {
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
sessionFile: string;
/** Optional caller-observed live prompt tokens used for compaction diagnostics. */
currentTokenCount?: number;
@@ -25,7 +25,6 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
config: {} as OpenClawConfig,
senderIsOwner: true,
senderId: "user-123",
provider: "openai-codex",
modelId: "gpt-5.4",
@@ -21,7 +21,6 @@ export type EmbeddedCompactionRuntimeContext = {
agentDir: string;
config?: OpenClawConfig;
skillsSnapshot?: SkillSnapshot;
senderIsOwner?: boolean;
senderId?: string;
provider?: string;
model?: string;
@@ -89,7 +88,6 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
agentDir: string;
config?: OpenClawConfig;
skillsSnapshot?: SkillSnapshot;
senderIsOwner?: boolean;
senderId?: string | null;
provider?: string | null;
modelId?: string | null;
@@ -127,7 +125,6 @@ export function buildEmbeddedCompactionRuntimeContext(params: {
agentDir: params.agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId ?? undefined,
provider: resolved.provider,
model: resolved.model,
@@ -7,13 +7,12 @@ import { providerAliasCases } from "../test-helpers/provider-alias-cases.js";
import type { AnyAgentTool } from "../tools/common.js";
import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js";
function makeTool(name: string, ownerOnly = false): AnyAgentTool {
function makeTool(name: string): AnyAgentTool {
return {
name,
label: name,
description: name,
parameters: { type: "object", properties: {} },
ownerOnly,
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
}
@@ -152,16 +151,6 @@ describe("applyFinalEffectiveToolPolicy", () => {
expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]);
});
it("applies owner-only filtering to bundled tools", () => {
const filtered = applyFinalEffectiveToolPolicy({
bundledTools: [makeTool("mcp__bundle__read"), makeTool("mcp__bundle__admin", true)],
senderIsOwner: false,
warn: () => {},
});
expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]);
});
it("returns the empty array unchanged when there are no bundled tools", () => {
const filtered = applyFinalEffectiveToolPolicy({
bundledTools: [],
@@ -17,11 +17,7 @@ import {
buildDefaultToolPolicyPipelineSteps,
type ToolPolicyPipelineStep,
} from "../tool-policy-pipeline.js";
import {
applyOwnerOnlyToolPolicy,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "../tool-policy.js";
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../tool-policy.js";
import type { AnyAgentTool } from "../tools/common.js";
/**
@@ -36,7 +32,7 @@ import type { AnyAgentTool } from "../tools/common.js";
*/
type FinalEffectiveToolPolicyParams = {
// Tools appended to the core tool set after `createOpenClawCodingTools()`
// has already applied owner-only and tool-policy filtering (e.g. bundled
// has already applied the shared tool-policy pipeline (e.g. bundled
// MCP/LSP tools). Only these are filtered here; re-running the pipeline over
// the already-filtered core tools would drop plugin tools whose WeakMap
// metadata no longer survives core-tool wrapping/normalization.
@@ -57,8 +53,6 @@ type FinalEffectiveToolPolicyParams = {
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
ownerOnlyToolAllowlist?: string[];
warn: (message: string) => void;
};
@@ -144,11 +138,6 @@ export function applyFinalEffectiveToolPolicy(
store: subagentStore,
},
);
const ownerFiltered = applyOwnerOnlyToolPolicy(
params.bundledTools,
params.senderIsOwner === true,
params.ownerOnlyToolAllowlist,
);
// Suppress unavailable-core-tool warnings on every step of this pass.
// `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array
// it's filtering, and this pass only sees the bundled MCP/LSP subset.
@@ -180,7 +169,7 @@ export function applyFinalEffectiveToolPolicy(
{ policy: inheritedToolPolicy, label: "inherited tools" },
].map((step) => Object.assign({}, step, { suppressUnavailableCoreToolWarning: true }));
return applyToolPolicyPipeline({
tools: ownerFiltered,
tools: params.bundledTools,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: params.warn,
steps: pipelineSteps,
@@ -14,7 +14,6 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
sessionId: "session-1",
agentId: "main",
senderId: "user-123",
senderIsOwner: false,
}),
).toEqual({
cfg: undefined,
@@ -27,7 +26,6 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
sessionId: "session-1",
agentId: "main",
requesterSenderId: "user-123",
senderIsOwner: false,
});
});
@@ -43,7 +41,6 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
sessionId: null,
agentId: null,
senderId: null,
senderIsOwner: false,
}),
).toEqual({
cfg: undefined,
@@ -56,28 +53,6 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
sessionId: undefined,
agentId: undefined,
requesterSenderId: undefined,
senderIsOwner: false,
});
});
it("preserves owner authorization for downstream channel action gating", () => {
expect(
buildEmbeddedMessageActionDiscoveryInput({
channel: "matrix",
senderIsOwner: true,
}),
).toEqual({
cfg: undefined,
channel: "matrix",
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
accountId: undefined,
sessionKey: undefined,
sessionId: undefined,
agentId: undefined,
requesterSenderId: undefined,
senderIsOwner: true,
});
});
});
@@ -11,7 +11,7 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: {
sessionId?: string | null;
agentId?: string | null;
senderId?: string | null;
senderIsOwner?: boolean;
senderIsOwner?: boolean | null;
}) {
return {
cfg: params.cfg,
@@ -24,6 +24,6 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: {
sessionId: params.sessionId ?? undefined,
agentId: params.agentId ?? undefined,
requesterSenderId: params.senderId ?? undefined,
senderIsOwner: params.senderIsOwner,
senderIsOwner: params.senderIsOwner ?? undefined,
};
}
@@ -48,7 +48,6 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) {
runId: "forward-attempt-params",
params: {
toolsAllow: ["exec", "read"],
ownerOnlyToolAllowlist: ["cron"],
bootstrapContextMode: "lightweight",
bootstrapContextRunKind: "cron",
disableMessageTool: true,
@@ -58,7 +57,6 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) {
},
expected: {
toolsAllow: ["exec", "read"],
ownerOnlyToolAllowlist: ["cron"],
bootstrapContextMode: "lightweight",
bootstrapContextRunKind: "cron",
disableMessageTool: true,
@@ -49,7 +49,6 @@ type CompactRuntimeContext = {
currentThreadTs?: string;
currentMessageId?: string;
senderId?: string;
senderIsOwner?: boolean;
authProfileId?: string;
};
@@ -240,7 +239,6 @@ describe("timeout-triggered compaction", () => {
currentThreadTs: "thread-1",
currentMessageId: "message-1",
senderId: "sender-1",
senderIsOwner: true,
});
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
@@ -252,7 +250,6 @@ describe("timeout-triggered compaction", () => {
expect(compactParams.runtimeContext?.currentThreadTs).toBe("thread-1");
expect(compactParams.runtimeContext?.currentMessageId).toBe("message-1");
expect(compactParams.runtimeContext?.senderId).toBe("sender-1");
expect(compactParams.runtimeContext?.senderIsOwner).toBe(true);
});
it("falls through to normal handling when timeout compaction fails", async () => {
-4
View File
@@ -1403,7 +1403,6 @@ export async function runEmbeddedPiAgent(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
@@ -1495,7 +1494,6 @@ export async function runEmbeddedPiAgent(
bootstrapContextRunKind: params.bootstrapContextRunKind,
jobId: params.jobId,
toolsAllow: params.toolsAllow,
ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist,
disableMessageTool: params.disableMessageTool,
forceMessageTool: params.forceMessageTool,
enableHeartbeatTool: params.enableHeartbeatTool,
@@ -1733,7 +1731,6 @@ export async function runEmbeddedPiAgent(
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId,
provider,
modelId,
@@ -1916,7 +1913,6 @@ export async function runEmbeddedPiAgent(
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId,
provider,
modelId,
@@ -503,7 +503,6 @@ type AfterTurnRuntimeContextAttempt = Pick<
| "currentMessageId"
| "config"
| "skillsSnapshot"
| "senderIsOwner"
| "senderId"
| "provider"
| "modelId"
@@ -542,7 +541,6 @@ export function buildAfterTurnRuntimeContext(params: {
agentDir: params.agentDir,
config: params.attempt.config,
skillsSnapshot: params.attempt.skillsSnapshot,
senderIsOwner: params.attempt.senderIsOwner,
senderId: params.attempt.senderId,
provider: params.attempt.provider,
modelId: params.attempt.modelId,
@@ -113,7 +113,7 @@ describe("embedded attempt context injection", () => {
expect(resolver).toHaveBeenCalledTimes(1);
});
it("forwards senderIsOwner into embedded message-action discovery", () => {
it("builds embedded message-action discovery routing context", () => {
const input = buildEmbeddedMessageActionDiscoveryInput({
cfg: {},
channel: "matrix",
@@ -125,7 +125,6 @@ describe("embedded attempt context injection", () => {
sessionId: "session",
agentId: "main",
senderId: "@alice:example.org",
senderIsOwner: false,
});
expect(input.channel).toBe("matrix");
@@ -137,7 +136,6 @@ describe("embedded attempt context injection", () => {
expect(input.sessionId).toBe("session");
expect(input.agentId).toBe("main");
expect(input.requesterSenderId).toBe("@alice:example.org");
expect(input.senderIsOwner).toBe(false);
});
it("never skips heartbeat bootstrap filtering", async () => {
@@ -1204,7 +1204,6 @@ export async function createContextEngineAttemptRunner(params: {
authProfileStore: { version: 1, profiles: {} },
modelRegistry: {} as never,
thinkLevel: "off",
senderIsOwner: true,
disableTools: true,
disableMessageTool: true,
contextTokenBudget: 2048,
@@ -3627,7 +3627,6 @@ describe("buildAfterTurnRuntimeContext", () => {
sessionId: "session-123",
config: {} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.4",
thinkLevel: "off",
@@ -3666,7 +3665,6 @@ describe("buildAfterTurnRuntimeContext", () => {
authProfileId: "openai:p1",
config: {} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.4",
thinkLevel: "off",
@@ -3700,7 +3698,6 @@ describe("buildAfterTurnRuntimeContext", () => {
},
} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.4",
thinkLevel: "off",
@@ -3739,7 +3736,6 @@ describe("buildAfterTurnRuntimeContext", () => {
authProfileId: "openai:p1",
config: { plugins: { slots: { contextEngine: "lossless-claw" } } } as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.4",
thinkLevel: "off",
@@ -3782,7 +3778,6 @@ describe("buildAfterTurnRuntimeContext", () => {
authProfileId: "openai:p1",
config: { plugins: { slots: { contextEngine: "lossless-claw" } } } as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
provider: "openai-codex",
modelId: "gpt-5.4",
thinkLevel: "off",
@@ -3814,7 +3809,6 @@ describe("buildAfterTurnRuntimeContext", () => {
authProfileId: "openai:p1",
config: {} as OpenClawConfig,
skillsSnapshot: undefined,
senderIsOwner: true,
senderId: "user-123",
provider: "openai-codex",
modelId: "gpt-5.4",
@@ -1394,7 +1394,6 @@ export async function runEmbeddedAttempt(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
sessionKey: sandboxSessionKey,
// When sandboxSessionKey differs from the real run session key (e.g. Telegram
@@ -1685,8 +1684,6 @@ export async function runEmbeddedAttempt(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist,
warn: (message) => log.warn(message),
});
const normalizedBundledTools =
+1 -6
View File
@@ -70,13 +70,8 @@ export type RunEmbeddedPiAgentParams = {
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Whether the sender is an owner (required for owner-only tools). */
/** Trusted sender identity bit for command/channel-action auth. */
senderIsOwner?: boolean;
/**
* Additional owner-only tools authorized by a server-side runtime grant.
* This must stay narrow; it does not make the sender an owner.
*/
ownerOnlyToolAllowlist?: string[];
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
-1
View File
@@ -187,7 +187,6 @@ describe("Agent-specific tool filtering", () => {
sessionKey: "main",
workspaceDir: "/tmp/test",
agentDir: "/tmp/agent-local",
senderIsOwner: true,
modelProvider: "lmstudio",
modelId: "gemma-4-e4b-it",
});
@@ -9,7 +9,6 @@ vi.mock("./channel-tools.js", () => {
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
ownerOnly: true,
execute: vi.fn(),
});
return {
@@ -19,18 +18,9 @@ vi.mock("./channel-tools.js", () => {
};
});
describe("owner-only tool gating", () => {
it("removes owner-only tools for unauthorized senders", () => {
const tools = createOpenClawCodingTools({ senderIsOwner: false });
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).not.toContain("plugin_login");
expect(toolNames).not.toContain("cron");
expect(toolNames).not.toContain("gateway");
expect(toolNames).not.toContain("nodes");
});
it("keeps owner-only tools for authorized senders", () => {
const tools = createOpenClawCodingTools({ senderIsOwner: true });
describe("tool availability", () => {
it("keeps control-plane tools available", () => {
const tools = createOpenClawCodingTools();
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain("plugin_login");
expect(toolNames).toContain("cron");
@@ -38,24 +28,14 @@ describe("owner-only tool gating", () => {
expect(toolNames).toContain("nodes");
});
it("keeps canvas available to unauthorized senders by current trust model", () => {
const tools = createOpenClawCodingTools({ senderIsOwner: false });
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain("canvas");
});
it("defaults to removing owner-only tools when owner status is unknown", () => {
it("keeps canvas available by current trust model", () => {
const tools = createOpenClawCodingTools();
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).not.toContain("plugin_login");
expect(toolNames).not.toContain("cron");
expect(toolNames).not.toContain("gateway");
expect(toolNames).not.toContain("nodes");
expect(toolNames).toContain("canvas");
});
it("restricts node-originated runs to the node-safe tool subset", () => {
const tools = createOpenClawCodingTools({ messageProvider: "node", senderIsOwner: false });
const tools = createOpenClawCodingTools({ messageProvider: "node" });
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain("canvas");
expect(toolNames).not.toContain("exec");
@@ -157,7 +157,7 @@ describe("createOpenClawCodingTools", () => {
});
it("exposes gateway config and restart actions to owner sessions", () => {
const tools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const tools = createOpenClawCodingTools({ config: testConfig });
const gateway = requireTool(tools, "gateway");
const parameters = gateway.parameters as {
@@ -313,51 +313,30 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("exec")).toBe(false);
});
it("exposes only an explicitly authorized owner-only tool to non-owner sessions", () => {
it("exposes control-plane tools to configured sessions", () => {
const tools = createOpenClawCodingTools({
config: testConfig,
senderIsOwner: false,
ownerOnlyToolAllowlist: ["cron"],
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("cron")).toBe(true);
expect(names.has("gateway")).toBe(false);
expect(names.has("nodes")).toBe(false);
expect(names.has("gateway")).toBe(true);
expect(names.has("nodes")).toBe(true);
});
it("resolves isolated cron runtime toolsAllow after the cron owner-only grant", () => {
const withoutGrant = applyRuntimeToolsAllow(
it("resolves isolated cron runtime toolsAllow", () => {
const allowed = applyRuntimeToolsAllow(
createOpenClawCodingTools({
config: testConfig,
senderIsOwner: false,
}),
["cron"],
);
const errorWithoutGrant = buildEmptyExplicitToolAllowlistError({
sources: [{ label: "runtime toolsAllow", entries: ["cron"] }],
callableToolNames: withoutGrant.map((tool) => tool.name),
toolsEnabled: true,
});
expect(errorWithoutGrant?.message).toContain(
"No callable tools remain after resolving explicit tool allowlist (runtime toolsAllow: cron); no registered tools matched.",
);
const withGrant = applyRuntimeToolsAllow(
createOpenClawCodingTools({
config: testConfig,
senderIsOwner: false,
ownerOnlyToolAllowlist: ["cron"],
}),
["cron"],
);
expect(withGrant.map((tool) => tool.name)).toEqual(["cron"]);
expect(allowed.map((tool) => tool.name)).toEqual(["cron"]);
expect(
buildEmptyExplicitToolAllowlistError({
sources: [{ label: "runtime toolsAllow", entries: ["cron"] }],
callableToolNames: withGrant.map((tool) => tool.name),
callableToolNames: allowed.map((tool) => tool.name),
toolsEnabled: true,
}),
).toBeNull();
@@ -656,7 +635,6 @@ describe("createOpenClawCodingTools", () => {
createOpenClawCodingTools({
config: testConfig,
recordToolPrepStage: (name) => stages.push(name),
senderIsOwner: true,
});
expectListIncludes(stages, [
@@ -683,7 +661,7 @@ describe("createOpenClawCodingTools", () => {
});
it("preserves action enums in normalized schemas", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const defaultTools = createOpenClawCodingTools({ config: testConfig });
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];
const missingNames = toolNames.filter(
(name) => !defaultTools.some((candidate) => candidate.name === name),
@@ -707,7 +685,7 @@ describe("createOpenClawCodingTools", () => {
});
it("enforces apply_patch availability and canonical names across model/provider constraints", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const defaultTools = createOpenClawCodingTools({ config: testConfig });
expect(toolNameList(defaultTools)).toContain("exec");
expect(toolNameList(defaultTools)).toContain("process");
expect(toolNameList(defaultTools)).not.toContain("apply_patch");
@@ -956,7 +934,6 @@ describe("createOpenClawCodingTools", () => {
browser: { enabled: true },
plugins: { entries: { browser: { enabled: true } } },
} as OpenClawConfig,
senderIsOwner: true,
});
const names = new Set(tools.map((tool) => tool.name));
// full profile must not filter any tools — browser, canvas, etc. must be present.
@@ -966,23 +943,20 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("message")).toBe(true);
});
it("includes browser tool with full profile for non-owner senders (#76507)", () => {
it("includes browser tool with full profile (#76507)", () => {
const tools = createOpenClawCodingTools({
config: {
tools: { profile: "full" },
browser: { enabled: true },
plugins: { entries: { browser: { enabled: true } } },
} as OpenClawConfig,
senderIsOwner: false,
});
const names = new Set(tools.map((tool) => tool.name));
// browser is NOT owner-only; it must be available to non-owner senders.
expect(names.has("browser")).toBe(true);
expect(names.has("canvas")).toBe(true);
// owner-only tools should be filtered for non-owners
expect(names.has("gateway")).toBe(false);
expect(names.has("cron")).toBe(false);
expect(names.has("nodes")).toBe(false);
expect(names.has("gateway")).toBe(true);
expect(names.has("cron")).toBe(true);
expect(names.has("nodes")).toBe(true);
});
it("includes browser tool without explicit profile (defaults to no filtering) (#76507)", () => {
@@ -1156,7 +1130,6 @@ describe("createOpenClawCodingTools", () => {
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const googleTools = createOpenClawCodingTools({
modelProvider: "google",
senderIsOwner: true,
});
for (const tool of googleTools) {
const violations = findUnsupportedSchemaKeywords(
@@ -1177,7 +1150,6 @@ describe("createOpenClawCodingTools", () => {
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
senderIsOwner: true,
});
expect(toolNameList(xaiTools)).not.toContain("web_search");
+5 -9
View File
@@ -2,13 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AnyAgentTool } from "./tools/common.js";
const mocks = vi.hoisted(() => {
const stubTool = (name: string, ownerOnly = false) =>
const stubTool = (name: string) =>
({
name,
label: name,
displaySummary: name,
description: name,
ownerOnly,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
}) satisfies AnyAgentTool;
@@ -22,7 +21,7 @@ const mocks = vi.hoisted(() => {
vi.mock("./openclaw-tools.js", () => ({
createOpenClawTools: (options: unknown) => {
mocks.createOpenClawToolsOptions(options);
return [mocks.stubTool("cron", true)];
return [mocks.stubTool("cron")];
},
}));
@@ -41,23 +40,20 @@ describe("createOpenClawCodingTools cron scope", () => {
mocks.createOpenClawToolsOptions.mockClear();
});
it("scopes the cron owner-only runtime grant to self-removal", () => {
it("scopes cron-triggered jobs to self-removal", () => {
const tools = createOpenClawCodingTools({
trigger: "cron",
jobId: "job-current",
senderIsOwner: false,
ownerOnlyToolAllowlist: ["cron"],
});
expect(tools.map((tool) => tool.name)).toContain("cron");
expect(firstOpenClawToolsOptions()?.cronSelfRemoveOnlyJobId).toBe("job-current");
});
it("does not scope ordinary owner cron sessions", () => {
it("does not scope non-cron sessions", () => {
createOpenClawCodingTools({
trigger: "cron",
trigger: "user",
jobId: "job-current",
senderIsOwner: true,
});
expect(firstOpenClawToolsOptions()?.cronSelfRemoveOnlyJobId).toBeUndefined();
@@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest";
import { applyDeferredFollowupToolDescriptions } from "./pi-tools.deferred-followup.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
function findToolDescription(toolName: string, senderIsOwner: boolean) {
function findToolDescription(toolName: string, includeCron: boolean) {
const tools = applyDeferredFollowupToolDescriptions([
{ name: "exec", description: "exec base" },
{ name: "process", description: "process base" },
...(senderIsOwner ? [{ name: "cron", description: "cron base" }] : []),
...(includeCron ? [{ name: "cron", description: "cron base" }] : []),
] as AnyAgentTool[]);
const tool = tools.find((entry) => entry.name === toolName);
return {
+6 -22
View File
@@ -79,7 +79,6 @@ import {
buildDefaultToolPolicyPipelineSteps,
} from "./tool-policy-pipeline.js";
import {
applyOwnerOnlyToolPolicy,
collectExplicitAllowlist,
collectExplicitDenylist,
expandToolGroups,
@@ -458,13 +457,8 @@ export function createOpenClawCodingTools(options?: {
toolSearchCatalogRef?: ToolSearchCatalogRef;
/** Limits which tool families are materialized before the shared policy pipeline runs. */
toolConstructionPlan?: OpenClawCodingToolConstructionPlan;
/** Whether the sender is an owner (required for owner-only tools). */
/** Trusted sender identity bit for command/channel-action auth; does not filter model tools. */
senderIsOwner?: boolean;
/**
* Additional owner-only tools authorized by a server-side runtime grant.
* Keep this narrowly scoped; it is not a replacement for sender ownership.
*/
ownerOnlyToolAllowlist?: string[];
/** Auth profiles already loaded for this run; used for prompt-time tool availability. */
authProfileStore?: AuthProfileStore;
/** Callback invoked when sessions_yield tool is called. */
@@ -484,11 +478,7 @@ export function createOpenClawCodingTools(options?: {
}
const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined;
const cronSelfRemoveOnlyJobId =
options?.trigger === "cron" &&
options.jobId?.trim() &&
options.ownerOnlyToolAllowlist?.some((toolName) => normalizeToolName(toolName) === "cron")
? options.jobId.trim()
: undefined;
options?.trigger === "cron" && options.jobId?.trim() ? options.jobId.trim() : undefined;
const {
agentId,
globalPolicy,
@@ -854,7 +844,6 @@ export function createOpenClawCodingTools(options?: {
config: options?.config,
fsPolicy,
requesterSenderId: options?.senderId,
senderIsOwner: options?.senderIsOwner,
sessionId: options?.sessionId,
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
@@ -965,8 +954,8 @@ export function createOpenClawCodingTools(options?: {
...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}),
requesterAgentIdOverride: agentId,
requesterSenderId: options?.senderId,
authProfileStore: options?.authProfileStore,
senderIsOwner: options?.senderIsOwner,
authProfileStore: options?.authProfileStore,
sessionId: options?.sessionId,
inheritedToolAllowlist,
inheritedToolDenylist,
@@ -1022,15 +1011,10 @@ export function createOpenClawCodingTools(options?: {
suppressManagedWebSearch: options?.suppressManagedWebSearch,
});
options?.recordToolPrepStage?.("model-provider-policy");
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(
toolsForModelProvider,
senderIsOwner,
options?.ownerOnlyToolAllowlist,
);
// Sender identity is carried for command/channel-action auth; tool visibility
// comes from configured tool policies, not per-turn sender ownership.
const subagentFiltered = applyToolPolicyPipeline({
tools: toolsByAuthorization,
tools: toolsForModelProvider,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: logWarn,
steps: [
+1 -2
View File
@@ -352,8 +352,7 @@ describe("spawnSubagentDirect seam flow", () => {
// Admin-only methods must be pinned to operator.admin.
expect(call.scopes).toEqual(["operator.admin"]);
} else {
// Non-admin methods (e.g. "agent") must NOT be forced to admin scope
// so the gateway preserves least-privilege and senderIsOwner stays false.
// Non-admin methods (e.g. "agent") must NOT be forced to admin scope.
expect(call.scopes).toBeUndefined();
}
}
+1 -2
View File
@@ -205,8 +205,7 @@ async function callSubagentGateway(
// complete interactively, causing close(1008) "pairing required" (#59428).
//
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
// "agent" write) keep their least-privilege scope so that the gateway does
// not treat the caller as owner (senderIsOwner) and expose owner-only tools.
// "agent" -> write) keep their least-privilege scope.
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
return await subagentSpawnDeps.callGateway({
...params,
-112
View File
@@ -1,47 +1,18 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox/types.js";
import { isToolAllowedByPolicyName } from "./tool-policy-match.js";
import { TOOL_POLICY_CONFORMANCE } from "./tool-policy.conformance.js";
import {
applyOwnerOnlyToolPolicy,
collectExplicitAllowlist,
DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY,
expandToolGroups,
isOwnerOnlyToolName,
normalizeToolName,
resolveOwnerOnlyToolApprovalClass,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy.js";
import type { AnyAgentTool } from "./tools/common.js";
function createOwnerPolicyTools() {
return [
{
name: "read",
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "cron",
ownerOnly: true,
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "gateway",
ownerOnly: true,
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "nodes",
ownerOnly: true,
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
}
describe("tool-policy", () => {
it("expands groups and normalizes aliases", () => {
@@ -79,70 +50,6 @@ describe("tool-policy", () => {
expect(normalizeToolName("READ")).toBe("read");
});
it("identifies owner-only tools", () => {
expect(isOwnerOnlyToolName("cron")).toBe(true);
expect(isOwnerOnlyToolName("gateway")).toBe(true);
expect(isOwnerOnlyToolName("nodes")).toBe(true);
expect(isOwnerOnlyToolName("read")).toBe(false);
});
it("exposes stable approval classes for shared owner-only fallbacks", () => {
expect(resolveOwnerOnlyToolApprovalClass("cron")).toBe("control_plane");
expect(resolveOwnerOnlyToolApprovalClass("gateway")).toBe("control_plane");
expect(resolveOwnerOnlyToolApprovalClass("nodes")).toBe("exec_capable");
expect(resolveOwnerOnlyToolApprovalClass("read")).toBeUndefined();
});
it("keeps ACP owner-only backstops aligned with the HTTP deny list", () => {
const sharedBackstops = DEFAULT_GATEWAY_HTTP_TOOL_DENY.flatMap((name) => {
const approvalClass = resolveOwnerOnlyToolApprovalClass(name);
return approvalClass ? ([[name, approvalClass]] as const) : [];
});
expect(Object.fromEntries(sharedBackstops)).toEqual({
cron: "control_plane",
gateway: "control_plane",
nodes: "exec_capable",
});
});
it("strips owner-only tools for non-owner senders", () => {
const tools = createOwnerPolicyTools();
const filtered = applyOwnerOnlyToolPolicy(tools, false);
expect(filtered.map((t) => t.name)).toEqual(["read"]);
});
it("keeps owner-only tools for the owner sender", () => {
const tools = createOwnerPolicyTools();
const filtered = applyOwnerOnlyToolPolicy(tools, true);
expect(filtered.map((t) => t.name)).toEqual(["read", "cron", "gateway", "nodes"]);
});
it("keeps only explicitly authorized owner-only tools for non-owner senders", async () => {
const tools = createOwnerPolicyTools();
const filtered = applyOwnerOnlyToolPolicy(tools, false, ["cron"]);
expect(filtered.map((t) => t.name)).toEqual(["read", "cron"]);
await expect(
filtered.find((tool) => tool.name === "cron")?.execute?.("call_1", {}),
).resolves.toEqual({
content: [],
details: {},
});
});
it("honors ownerOnly metadata for custom tool names", () => {
const tools = [
{
name: "custom_admin_tool",
ownerOnly: true,
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
expect(applyOwnerOnlyToolPolicy(tools, false)).toStrictEqual([]);
expect(applyOwnerOnlyToolPolicy(tools, true)).toHaveLength(1);
});
it("collects explicit allowlist entries", () => {
expect(
collectExplicitAllowlist([
@@ -169,25 +76,6 @@ describe("tool-policy", () => {
"*",
]);
});
it("strips nodes for non-owner senders via fallback policy", () => {
const tools = [
{
name: "read",
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "nodes",
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
expect(applyOwnerOnlyToolPolicy(tools, false).map((tool) => tool.name)).toEqual(["read"]);
expect(applyOwnerOnlyToolPolicy(tools, true).map((tool) => tool.name)).toEqual([
"read",
"nodes",
]);
});
});
describe("TOOL_POLICY_CONFORMANCE", () => {
-62
View File
@@ -1,7 +1,6 @@
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW } from "./sandbox-tool-policy.js";
import { expandToolGroups, normalizeToolList, normalizeToolName } from "./tool-policy-shared.js";
import type { AnyAgentTool } from "./tools/common.js";
export {
expandToolGroups,
normalizeToolList,
@@ -11,67 +10,6 @@ export {
} from "./tool-policy-shared.js";
export type { ToolProfileId } from "./tool-policy-shared.js";
export type OwnerOnlyToolApprovalClass = "control_plane" | "exec_capable" | "interactive";
// Keep tool-policy browser-safe: do not import tools/common at runtime.
function wrapOwnerOnlyToolExecution(tool: AnyAgentTool, authorized: boolean): AnyAgentTool {
if (tool.ownerOnly !== true || authorized || !tool.execute) {
return tool;
}
return {
...tool,
execute: async () => {
throw new Error("Tool restricted to owner senders.");
},
};
}
const OWNER_ONLY_TOOL_APPROVAL_CLASS_FALLBACKS = new Map<string, OwnerOnlyToolApprovalClass>([
["cron", "control_plane"],
["gateway", "control_plane"],
["nodes", "exec_capable"],
]);
export function resolveOwnerOnlyToolApprovalClass(
name: string,
): OwnerOnlyToolApprovalClass | undefined {
return OWNER_ONLY_TOOL_APPROVAL_CLASS_FALLBACKS.get(normalizeToolName(name));
}
export function isOwnerOnlyToolName(name: string) {
return resolveOwnerOnlyToolApprovalClass(name) !== undefined;
}
function isOwnerOnlyTool(tool: AnyAgentTool) {
return tool.ownerOnly === true || isOwnerOnlyToolName(tool.name);
}
/**
* Filters owner-only tools unless the sender is an owner or a server-side
* runtime grant authorizes a specific owner-only tool for this run.
*/
export function applyOwnerOnlyToolPolicy(
tools: AnyAgentTool[],
senderIsOwner: boolean,
ownerOnlyToolAllowlist?: string[],
) {
const allowedOwnerOnlyTools = new Set(
ownerOnlyToolAllowlist?.map((name) => normalizeToolName(name)) ?? [],
);
const isAuthorized = (tool: AnyAgentTool) =>
senderIsOwner || allowedOwnerOnlyTools.has(normalizeToolName(tool.name));
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyTool(tool)) {
return tool;
}
return wrapOwnerOnlyToolExecution(tool, isAuthorized(tool));
});
if (senderIsOwner) {
return withGuard;
}
return withGuard.filter((tool) => !isOwnerOnlyTool(tool) || isAuthorized(tool));
}
export type ToolPolicyLike = {
allow?: string[];
deny?: string[];
-1
View File
@@ -216,7 +216,6 @@ export function resolveEffectiveToolInventory(
modelId: params.modelId,
modelCompat,
messageProvider: params.messageProvider,
senderIsOwner: params.senderIsOwner,
senderId: params.senderId,
senderName: params.senderName ?? undefined,
senderUsername: params.senderUsername ?? undefined,
@@ -41,7 +41,6 @@ export type ResolveEffectiveToolInventoryParams = {
workspaceDir?: string;
agentDir?: string;
messageProvider?: string;
senderIsOwner?: boolean;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
-1
View File
@@ -74,7 +74,6 @@ export async function runAgentStep(params: {
runId: stepIdem,
extraSystemPrompt: params.extraSystemPrompt,
inputProvenance,
senderIsOwner: false,
allowModelOverride: false,
});
await retireSessionMcpRuntimeForSessionKey({
-19
View File
@@ -14,7 +14,6 @@ export type AgentToolWithMeta<TParameters extends TSchema, TResult> = AgentTool<
TParameters,
TResult
> & {
ownerOnly?: boolean;
displaySummary?: string;
};
@@ -30,7 +29,6 @@ type ErasedAgentToolExecute = {
export type AnyAgentTool = Omit<AgentTool<TSchema, unknown>, "execute"> &
ErasedAgentToolExecute & {
ownerOnly?: boolean;
displaySummary?: string;
};
@@ -52,8 +50,6 @@ export type ActionGate<T extends Record<string, boolean | undefined>> = (
defaultValue?: boolean,
) => boolean;
export const OWNER_ONLY_TOOL_ERROR = "Tool restricted to owner senders.";
export class ToolInputError extends Error {
readonly status: number = 400;
@@ -303,21 +299,6 @@ export function jsonResult(payload: unknown): AgentToolResult<unknown> {
return textResult(JSON.stringify(payload, null, 2), payload);
}
export function wrapOwnerOnlyToolExecution(
tool: AnyAgentTool,
senderIsOwner: boolean,
): AnyAgentTool {
if (tool.ownerOnly !== true || senderIsOwner || !tool.execute) {
return tool;
}
return {
...tool,
execute: async () => {
throw new Error(OWNER_ONLY_TOOL_ERROR);
},
};
}
export async function imageResult(params: {
label: string;
path: string;
-5
View File
@@ -171,11 +171,6 @@ describe("cron tool", () => {
extractDeliveryInfoMock.mockReturnValue({ deliveryContext: undefined, threadId: undefined });
});
it("marks cron as owner-only", () => {
const tool = createTestCronTool();
expect(tool.ownerOnly).toBe(true);
});
it("allows scoped isolated cron runs to remove the current job", async () => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
-2
View File
@@ -13,7 +13,6 @@ import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
// We spell out job/patch properties so that LLMs know what fields to send.
@@ -492,7 +491,6 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
return {
label: "Cron",
name: "cron",
ownerOnly: isOpenClawOwnerOnlyCoreToolName("cron"),
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
description: `Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.
+4 -6
View File
@@ -19,15 +19,14 @@ import { normalizeOptionalString, readStringValue } from "../../shared/string-co
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
const log = createSubsystemLogger("gateway-tool");
const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000;
// Security: the agent-facing `gateway` tool is owner-only, but per SECURITY.md the model/agent
// itself is not a trusted principal. `assertGatewayConfigMutationAllowed` is the explicit
// model -> operator trust-boundary control on `config.apply`/`config.patch`, so the runtime
// tool must fail closed and allow only a narrow set of agent-tunable paths.
// Per SECURITY.md the model/agent itself is not a trusted principal.
// `assertGatewayConfigMutationAllowed` is the explicit model -> operator
// trust-boundary control on `config.apply`/`config.patch`, so the runtime tool
// must fail closed and allow only a narrow set of agent-tunable paths.
const ALLOWED_GATEWAY_CONFIG_PATHS = [
// Agent prompt/model tuning.
"agents.defaults.systemPromptOverride",
@@ -370,7 +369,6 @@ export function createGatewayTool(opts?: {
return {
label: "Gateway",
name: "gateway",
ownerOnly: isOpenClawOwnerOnlyCoreToolName("gateway"),
description:
"Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
parameters: GatewayToolSchema,

Some files were not shown because too many files have changed in this diff Show More