From 2a66c8d67667ba6f0d6db5c733b130d61777aeec Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 22 Feb 2026 00:39:16 -0800 Subject: [PATCH] Agents/Subagents: honor subagent alsoAllow grants --- CHANGELOG.md | 1 + src/agents/pi-tools.policy.e2e.test.ts | 57 ++++++++++++++++++++++++++ src/agents/pi-tools.policy.ts | 12 +++++- src/config/types.tools.ts | 2 + 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96854f495..53af93257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. - Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue. +- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. - Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. - Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. diff --git a/src/agents/pi-tools.policy.e2e.test.ts b/src/agents/pi-tools.policy.e2e.test.ts index 6a8d0e70f..77bc99dc9 100644 --- a/src/agents/pi-tools.policy.e2e.test.ts +++ b/src/agents/pi-tools.policy.e2e.test.ts @@ -54,6 +54,63 @@ describe("resolveSubagentToolPolicy depth awareness", () => { agents: { defaults: { subagents: { maxSpawnDepth: 1 } } }, } as unknown as OpenClawConfig; + it("applies subagent tools.alsoAllow to re-enable default-denied tools", () => { + const cfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + tools: { subagents: { tools: { alsoAllow: ["sessions_send"] } } }, + } as unknown as OpenClawConfig; + const policy = resolveSubagentToolPolicy(cfg, 1); + expect(isToolAllowedByPolicyName("sessions_send", policy)).toBe(true); + expect(isToolAllowedByPolicyName("cron", policy)).toBe(false); + }); + + it("applies subagent tools.allow to re-enable default-denied tools", () => { + const cfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + tools: { subagents: { tools: { allow: ["sessions_send"] } } }, + } as unknown as OpenClawConfig; + const policy = resolveSubagentToolPolicy(cfg, 1); + expect(isToolAllowedByPolicyName("sessions_send", policy)).toBe(true); + }); + + it("merges subagent tools.alsoAllow into tools.allow when both are set", () => { + const cfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + tools: { + subagents: { tools: { allow: ["sessions_spawn"], alsoAllow: ["sessions_send"] } }, + }, + } as unknown as OpenClawConfig; + const policy = resolveSubagentToolPolicy(cfg, 1); + expect(policy.allow).toEqual(["sessions_spawn", "sessions_send"]); + }); + + it("keeps configured deny precedence over allow and alsoAllow", () => { + const cfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + tools: { + subagents: { + tools: { + allow: ["sessions_send"], + alsoAllow: ["sessions_send"], + deny: ["sessions_send"], + }, + }, + }, + } as unknown as OpenClawConfig; + const policy = resolveSubagentToolPolicy(cfg, 1); + expect(isToolAllowedByPolicyName("sessions_send", policy)).toBe(false); + }); + + it("does not create a restrictive allowlist when only alsoAllow is configured", () => { + const cfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + tools: { subagents: { tools: { alsoAllow: ["sessions_send"] } } }, + } as unknown as OpenClawConfig; + const policy = resolveSubagentToolPolicy(cfg, 1); + expect(policy.allow).toBeUndefined(); + expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true); + }); + it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_spawn", () => { const policy = resolveSubagentToolPolicy(baseCfg, 1); expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 3c363ac41..9564d1554 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -88,9 +88,17 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1; const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth); - const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])]; const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; - return { allow, deny }; + const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined; + const explicitAllow = new Set( + [...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)), + ); + const deny = [ + ...baseDeny.filter((toolName) => !explicitAllow.has(normalizeToolName(toolName))), + ...(Array.isArray(configured?.deny) ? configured.deny : []), + ]; + const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow; + return { allow: mergedAllow, deny }; } export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index f8ad8dc1d..c50b95a86 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -520,6 +520,8 @@ export type ToolsConfig = { model?: string | { primary?: string; fallbacks?: string[] }; tools?: { allow?: string[]; + /** Additional allowlist entries merged into allow and/or default sub-agent denylist. */ + alsoAllow?: string[]; deny?: string[]; }; };