Skip to content

Commit f0105aa

Browse files
committed
fix(skills): cover inline dispatch policy gaps
1 parent fe8fd30 commit f0105aa

2 files changed

Lines changed: 173 additions & 3 deletions

File tree

src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
14
import { beforeEach, describe, expect, it, vi } from "vitest";
25
import type { SkillCommandSpec } from "../../agents/skills.js";
36
import type { SessionEntry } from "../../config/sessions.js";
@@ -47,6 +50,16 @@ const createTypingController = (): TypingController => ({
4750
cleanup: vi.fn(),
4851
});
4952

53+
async function writeSessionStore(
54+
storeTemplate: string,
55+
agentId: string,
56+
entries: Record<string, unknown>,
57+
) {
58+
const storePath = storeTemplate.replaceAll("{agentId}", agentId);
59+
await fs.mkdir(path.dirname(storePath), { recursive: true });
60+
await fs.writeFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
61+
}
62+
5063
const createHandleInlineActionsInput = (params: {
5164
ctx: ReturnType<typeof buildTestCtx>;
5265
typing: TypingController;
@@ -807,4 +820,144 @@ describe("handleInlineActions", () => {
807820
expect(messageExecute).not.toHaveBeenCalled();
808821
expect(sessionsExecute).not.toHaveBeenCalled();
809822
});
823+
824+
it("applies subagent policy to ACP envelope inline dispatch sessions", async () => {
825+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-inline-acp-policy-"));
826+
try {
827+
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
828+
await writeSessionStore(storeTemplate, "main", {
829+
"agent:main:acp:leaf": {
830+
sessionId: "session-acp-leaf",
831+
updatedAt: Date.now(),
832+
spawnedBy: "agent:main:subagent:parent",
833+
spawnDepth: 2,
834+
subagentRole: "leaf",
835+
subagentControlScope: "none",
836+
},
837+
});
838+
839+
const typing = createTypingController();
840+
const toolExecute = vi.fn(async () => ({ content: "spawned" }));
841+
createOpenClawToolsMock.mockReturnValue([
842+
{
843+
name: "sessions_spawn",
844+
execute: toolExecute,
845+
},
846+
]);
847+
848+
const ctx = buildTestCtx({
849+
Body: "/spawn_subagent investigate",
850+
CommandBody: "/spawn_subagent investigate",
851+
});
852+
const skillCommands: SkillCommandSpec[] = [
853+
{
854+
name: "spawn_subagent",
855+
skillName: "spawn-subagent",
856+
description: "Spawn a subagent",
857+
dispatch: {
858+
kind: "tool",
859+
toolName: "sessions_spawn",
860+
argMode: "raw",
861+
},
862+
sourceFilePath: "/tmp/plugin/commands/spawn-subagent.md",
863+
},
864+
];
865+
866+
const result = await handleInlineActions(
867+
createHandleInlineActionsInput({
868+
ctx,
869+
typing,
870+
cleanedBody: "/spawn_subagent investigate",
871+
command: {
872+
isAuthorizedSender: true,
873+
senderId: "sender-1",
874+
senderIsOwner: true,
875+
abortKey: "sender-1",
876+
rawBodyNormalized: "/spawn_subagent investigate",
877+
commandBodyNormalized: "/spawn_subagent investigate",
878+
},
879+
overrides: {
880+
cfg: {
881+
commands: { text: true },
882+
session: { store: storeTemplate },
883+
agents: { defaults: { subagents: { maxSpawnDepth: 2 } } },
884+
},
885+
sessionKey: "agent:main:acp:leaf",
886+
allowTextCommands: true,
887+
skillCommands,
888+
},
889+
}),
890+
);
891+
892+
expect(result).toEqual({
893+
kind: "reply",
894+
reply: { text: "❌ Tool not available: sessions_spawn" },
895+
});
896+
expect(toolExecute).not.toHaveBeenCalled();
897+
} finally {
898+
await fs.rm(tmpDir, { recursive: true, force: true });
899+
}
900+
});
901+
902+
it("passes sandboxed runtime state into inline tool construction", async () => {
903+
const typing = createTypingController();
904+
const toolExecute = vi.fn(async () => ({ content: "listed" }));
905+
createOpenClawToolsMock.mockReturnValue([
906+
{
907+
name: "sessions_list",
908+
execute: toolExecute,
909+
},
910+
]);
911+
912+
const ctx = buildTestCtx({
913+
Body: "/list_sessions now",
914+
CommandBody: "/list_sessions now",
915+
});
916+
const skillCommands: SkillCommandSpec[] = [
917+
{
918+
name: "list_sessions",
919+
skillName: "list-sessions",
920+
description: "List sessions",
921+
dispatch: {
922+
kind: "tool",
923+
toolName: "sessions_list",
924+
argMode: "raw",
925+
},
926+
sourceFilePath: "/tmp/plugin/commands/list-sessions.md",
927+
},
928+
];
929+
930+
const result = await handleInlineActions(
931+
createHandleInlineActionsInput({
932+
ctx,
933+
typing,
934+
cleanedBody: "/list_sessions now",
935+
command: {
936+
isAuthorizedSender: true,
937+
senderId: "sender-1",
938+
senderIsOwner: true,
939+
abortKey: "sender-1",
940+
rawBodyNormalized: "/list_sessions now",
941+
commandBodyNormalized: "/list_sessions now",
942+
},
943+
overrides: {
944+
cfg: {
945+
commands: { text: true },
946+
agents: { defaults: { sandbox: { mode: "all" } } },
947+
},
948+
sessionKey: "agent:main:thread",
949+
allowTextCommands: true,
950+
skillCommands,
951+
},
952+
}),
953+
);
954+
955+
expect(result).toEqual({ kind: "reply", reply: { text: "✅ Done." } });
956+
expect(createOpenClawToolsMock).toHaveBeenCalledWith(
957+
expect.objectContaining({
958+
sandboxed: true,
959+
}),
960+
);
961+
expect(toolExecute).toHaveBeenCalled();
962+
});
810963
});

src/auto-reply/reply/skill-tool-dispatch.runtime.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
} from "../../agents/pi-tools.policy.js";
77
import type { AnyAgentTool } from "../../agents/pi-tools.types.js";
88
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
9+
import {
10+
isSubagentEnvelopeSession,
11+
resolveSubagentCapabilityStore,
12+
} from "../../agents/subagent-capabilities.js";
913
import {
1014
applyToolPolicyPipeline,
1115
buildDefaultToolPolicyPipelineSteps,
@@ -21,11 +25,15 @@ import type { SessionEntry } from "../../config/sessions.js";
2125
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2226
import { logVerbose } from "../../globals.js";
2327
import { getPluginToolMeta } from "../../plugins/tools.js";
24-
import { isSubagentSessionKey } from "../../routing/session-key.js";
2528
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
2629
import type { MsgContext } from "../templating.js";
2730
import { extractExplicitGroupId } from "./group-id.js";
2831

32+
/**
33+
* Policy-enforcement seam for skill `command-dispatch: tool` invocations.
34+
* Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2
35+
* stays closed across allow/deny, group, sandbox, and subagent policy layers.
36+
*/
2937
export function resolveSkillDispatchTools(params: {
3038
ctx: MsgContext;
3139
cfg: OpenClawConfig;
@@ -87,8 +95,16 @@ export function resolveSkillDispatchTools(params: {
8795
sessionKey: params.sessionKey,
8896
});
8997
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
90-
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
91-
? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey)
98+
const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
99+
cfg: params.cfg,
100+
});
101+
const subagentPolicy = isSubagentEnvelopeSession(params.sessionKey, {
102+
cfg: params.cfg,
103+
store: subagentStore,
104+
})
105+
? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey, {
106+
store: subagentStore,
107+
})
92108
: undefined;
93109
const tools = createOpenClawTools({
94110
agentSessionKey: params.sessionKey,
@@ -104,6 +120,7 @@ export function resolveSkillDispatchTools(params: {
104120
workspaceDir: params.workspaceDir,
105121
config: params.cfg,
106122
allowGatewaySubagentBinding: true,
123+
sandboxed: sandboxRuntime.sandboxed,
107124
requesterAgentIdOverride: params.agentId,
108125
requesterSenderId: params.senderId,
109126
senderIsOwner: params.senderIsOwner,

0 commit comments

Comments
 (0)