diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 2d4095cb7c14..5b3114fcd285 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -573,14 +573,16 @@ Sub-agents use the same profile and tool-policy pipeline as the parent or target agent first. After that, OpenClaw applies the sub-agent restriction layer. -Sub-agents always lose `gateway`, `agents_list`, `session_status`, and -`cron` regardless of depth or role (system-level/interactive tools, or -tools the main agent should coordinate). Leaf sub-agents (default depth-1 -behavior, and always at depth 2) additionally lose `subagents`, -`sessions_list`, `sessions_history`, and `sessions_spawn`. Sub-agents never -get the `message` tool — it is disabled at spawn time, not filtered by -this deny list — and `sessions_send` stays denied so sub-agents -communicate only through the announce chain. +Sub-agents always lose `gateway`, `agents_list`, `session_status`, `cron`, +`message`, `sessions_send`, and the `conversations_*` tools regardless of +depth or role (system-level/interactive tools, direct delivery surfaces, or +tools the main agent should coordinate). This hard-deny layer is derived from +the persisted sub-agent session envelope on every turn, including resumed and +visible dashboard sessions; ordinary `allow`/`alsoAllow` entries cannot override +it. Hidden launches also disable `message` before tool construction as defense in +depth. Leaf sub-agents (default depth-1 behavior, and always at depth 2) +additionally lose `subagents`, `sessions_list`, `sessions_history`, and +`sessions_spawn`, so sub-agent communication stays on the announce chain. `sessions_history` remains a bounded, sanitized recall view here too — it is not a raw transcript dump. diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index bec2bc9cb121..b0c6df57804e 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -1497,6 +1497,41 @@ describe("createOpenClawCodingTools", () => { expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["pdf"]); }); + it("removes message from persisted visible child sessions on every turn", async () => { + const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-visible-subagent-message-")); + const storeTemplate = path.join(storeDir, "{agentId}", "sessions.json"); + const agentId = "visible-subagent-message"; + const childSessionKey = `agent:${agentId}:dashboard:child`; + const rootSessionKey = `agent:${agentId}:dashboard:root`; + try { + await writeSessionStore(storeTemplate, agentId, { + [childSessionKey]: { + sessionId: "visible-child", + updatedAt: Date.now(), + spawnDepth: 1, + spawnedBy: `agent:${agentId}:main`, + subagentRole: "leaf", + subagentControlScope: "none", + }, + [rootSessionKey]: { + sessionId: "root-dashboard", + updatedAt: Date.now(), + spawnDepth: 0, + }, + }); + + const firstChildTurn = createToolsForStoredSession(storeTemplate, childSessionKey); + const resumedChildTurn = createToolsForStoredSession(storeTemplate, childSessionKey); + const rootTurn = createToolsForStoredSession(storeTemplate, rootSessionKey); + + expect(toolNameList(firstChildTurn)).not.toContain("message"); + expect(toolNameList(resumedChildTurn)).not.toContain("message"); + expect(toolNameList(rootTurn)).toContain("message"); + } finally { + await fs.rm(storeDir, { recursive: true, force: true }); + } + }); + it("passes inherited allowlist entries to OpenClaw plugin discovery", async () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/agent-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts index 1a4be9157614..176d02dc881f 100644 --- a/src/agents/agent-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -313,6 +313,64 @@ describe("resolveSubagentToolPolicyForSession", () => { expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(true); }); + it.each(["allow", "alsoAllow"] as const)( + "does not let configured %s entries re-enable hard-denied tools", + async (allowField) => { + const storePath = createSessionStorePath(`openclaw-subagent-hard-deny-${allowField}`); + const sessionKeys = { + leaf: "agent:main:subagent:hard-deny-leaf", + orchestrator: "agent:main:subagent:hard-deny-orchestrator", + } as const; + await writeSessionEntries(storePath, { + [sessionKeys.leaf]: { + sessionId: "hard-deny-leaf", + updatedAt: Date.now(), + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + }, + [sessionKeys.orchestrator]: { + sessionId: "hard-deny-orchestrator", + updatedAt: Date.now(), + spawnDepth: 1, + subagentRole: "orchestrator", + subagentControlScope: "children", + }, + }); + const hardDeniedTools = [ + "gateway", + "agents_list", + "session_status", + "automations", + "cron", + "message", + "sessions_send", + "conversations_list", + "conversations_send", + "conversations_turn", + ]; + const cfg = { + ...baseCfg, + session: { store: storePath }, + tools: { + subagents: { + tools: { + [allowField]: [...hardDeniedTools, "memory_search"], + }, + }, + }, + } as unknown as OpenClawConfig; + + for (const sessionKey of Object.values(sessionKeys)) { + const policy = resolveSubagentToolPolicyForSession(cfg, sessionKey); + for (const toolName of hardDeniedTools) { + expect(isToolAllowedByPolicyName(toolName, policy), toolName).toBe(false); + } + expect(isToolAllowedByPolicyName("memory_search", policy)).toBe(true); + } + }, + ); + it("resolves inherited tool denies from stored subagent sessions", async () => { const storePath = createSessionStorePath("openclaw-subagent-inherited-deny"); await writeSessionEntries(storePath, { diff --git a/src/agents/agent-tools.policy.ts b/src/agents/agent-tools.policy.ts index d29f9f5ecbbb..169a34e5b409 100644 --- a/src/agents/agent-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -36,11 +36,7 @@ import { type SubagentSessionRole, } from "./subagent-capabilities.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; -import { - mergeAlsoAllowPolicy, - normalizeToolName, - resolveToolProfilePolicy, -} from "./tool-policy.js"; +import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js"; import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js"; export { resolveProviderToolPolicy }; @@ -56,7 +52,8 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [ // Status/scheduling - main agent coordinates "session_status", AUTOMATIONS_TOOL_NAME, - // Direct session sends - subagents communicate through announce chain + // Direct user/session sends - subagents communicate through announce chain + "message", "sessions_send", "conversations_list", "conversations_send", @@ -105,13 +102,8 @@ export function resolveSubagentToolPolicyForSession( }); const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined; - const explicitAllow = new Set( - [...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)), - ); const deny = [ - ...resolveSubagentDenyListForRole(capabilities.role).filter( - (toolName) => !explicitAllow.has(normalizeToolName(toolName)), - ), + ...resolveSubagentDenyListForRole(capabilities.role), ...(Array.isArray(configured?.deny) ? configured.deny : []), ]; const mergedAllow = mergeConfiguredSubagentAllow(allow, alsoAllow); diff --git a/src/agents/sandbox/workspace-authority.test.ts b/src/agents/sandbox/workspace-authority.test.ts index 0b40aeb99b37..da1fd6c7b782 100644 --- a/src/agents/sandbox/workspace-authority.test.ts +++ b/src/agents/sandbox/workspace-authority.test.ts @@ -102,6 +102,9 @@ describe("resolveSandboxWorkspaceAuthority", () => { }); expect(elevated.confinementError).toContain("elevated execution"); + // Config-driven delegation cannot happen anymore: the subagent hard-deny + // list is non-overridable, so alsoAllow cannot re-enable sessions_spawn. + // The worker stays confined (no error) instead of being rejected. const delegatingConfig = configWithSandbox({ mode: "all", workspaceAccess: "rw" }); delegatingConfig.tools!.sandbox!.tools!.allow = [...SAFE_WORKBOARD_TOOLS, "sessions_spawn"]; delegatingConfig.tools!.subagents = { tools: { alsoAllow: ["sessions_spawn"] } }; @@ -110,7 +113,8 @@ describe("resolveSandboxWorkspaceAuthority", () => { agentId: "main", sessionKey: "agent:main:subagent:workboard-card", }); - expect(delegating.confinementError).toContain("sessions_spawn"); + expect(delegating.confinementError).toBeUndefined(); + expect(delegating.sandboxed).toBe(true); }); it("uses the runtime session visibility clamp", () => {