VYPR
Medium severity4.3NVD Advisory· Published May 11, 2026· Updated May 13, 2026

CVE-2026-44997

CVE-2026-44997

Description

OpenClaw before 2026.4.22 contains a security envelope constraint bypass vulnerability allowing restricted subagents to spawn ACP child sessions that fail to inherit depth, child-count limits, control scope, or target-agent restrictions. Attackers can exploit this by spawning child sessions that bypass subagent-only constraints, potentially escalating privileges or accessing restricted resources.

Affected products

1

Patches

1
31160dc069b7

fix(agents): enforce subagent envelope inheritance on ACP child sessions [AI-assisted] (#69383)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 21, 2026via nvd-ref
18 files changed · +996 29
  • src/agents/acp-spawn.test.ts+344 0 modified
    @@ -23,6 +23,14 @@ function createDefaultSpawnConfig(): OpenClawConfig {
           backend: "acpx",
           allowedAgents: ["codex"],
         },
    +    agents: {
    +      defaults: {
    +        subagents: {
    +          allowAgents: ["codex"],
    +          maxSpawnDepth: 2,
    +        },
    +      },
    +    },
         session: {
           mainKey: "main",
           scope: "per-sender",
    @@ -61,6 +69,9 @@ const hoisted = vi.hoisted(() => {
       });
       const cleanupFailedAcpSpawnMock = vi.fn();
       const createRunningTaskRunMock = vi.fn();
    +  const countActiveRunsForSessionMock = vi.fn();
    +  const getSubagentRunByChildSessionKeyMock = vi.fn();
    +  const listTasksForOwnerKeyMock = vi.fn();
       const state = {
         cfg: createDefaultSpawnConfig(),
       };
    @@ -84,6 +95,9 @@ const hoisted = vi.hoisted(() => {
         normalizeChannelIdMock,
         cleanupFailedAcpSpawnMock,
         createRunningTaskRunMock,
    +    countActiveRunsForSessionMock,
    +    getSubagentRunByChildSessionKeyMock,
    +    listTasksForOwnerKeyMock,
         state,
       };
     });
    @@ -110,6 +124,11 @@ vi.mock("../config/sessions/store.js", () => ({
       loadSessionStore: hoisted.loadSessionStoreMock,
     }));
     
    +vi.mock("../config/sessions.js", () => ({
    +  loadSessionStore: hoisted.loadSessionStoreMock,
    +  resolveStorePath: hoisted.resolveStorePathMock,
    +}));
    +
     vi.mock("../config/sessions/transcript.js", () => ({
       resolveSessionTranscriptFile: hoisted.resolveSessionTranscriptFileMock,
     }));
    @@ -131,6 +150,15 @@ vi.mock("./acp-spawn-parent-stream.js", () => ({
       startAcpSpawnParentStreamRelay: hoisted.startAcpSpawnParentStreamRelayMock,
     }));
     
    +vi.mock("./subagent-registry.js", () => ({
    +  countActiveRunsForSession: hoisted.countActiveRunsForSessionMock,
    +  getSubagentRunByChildSessionKey: hoisted.getSubagentRunByChildSessionKeyMock,
    +}));
    +
    +vi.mock("../tasks/runtime-internal.js", () => ({
    +  listTasksForOwnerKey: hoisted.listTasksForOwnerKeyMock,
    +}));
    +
     const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await import("./acp-spawn.js");
     type SpawnRequest = Parameters<typeof spawnAcpDirect>[0];
     type SpawnContext = Parameters<typeof spawnAcpDirect>[1];
    @@ -490,6 +518,9 @@ describe("spawnAcpDirect", () => {
         hoisted.getLoadedChannelPluginMock.mockReset().mockReturnValue(undefined);
         hoisted.cleanupFailedAcpSpawnMock.mockReset().mockResolvedValue(undefined);
         hoisted.createRunningTaskRunMock.mockReset().mockReturnValue(undefined);
    +    hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
    +    hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null);
    +    hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]);
     
         hoisted.callGatewayMock.mockReset();
         hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => {
    @@ -687,6 +718,244 @@ describe("spawnAcpDirect", () => {
         expect(transcriptCalls[1]?.threadId).toBe("child-thread");
       });
     
    +  it("inherits subagent envelope fields onto ACP children", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxSpawnDepth: 2,
    +          },
    +        },
    +      },
    +    });
    +
    +    const result = await spawnAcpDirect(createSpawnRequest(), {
    +      ...createRequesterContext(),
    +      agentSessionKey: "agent:main:subagent:parent",
    +    });
    +
    +    const accepted = expectAcceptedSpawn(result);
    +    const patchCall = hoisted.callGatewayMock.mock.calls
    +      .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
    +      .find((request) => request.method === "sessions.patch");
    +    expect(patchCall?.params).toMatchObject({
    +      key: accepted.childSessionKey,
    +      spawnedBy: "agent:main:subagent:parent",
    +      spawnDepth: 2,
    +      subagentRole: "leaf",
    +      subagentControlScope: "none",
    +    });
    +  });
    +
    +  it("rejects ACP spawns that exceed subagent max depth", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxSpawnDepth: 2,
    +          },
    +        },
    +      },
    +    });
    +
    +    const result = await spawnAcpDirect(createSpawnRequest(), {
    +      ...createRequesterContext(),
    +      agentSessionKey: "agent:main:subagent:parent:subagent:leaf",
    +    });
    +
    +    const failed = expectFailedSpawn(result, "forbidden");
    +    expect(failed.errorCode).toBe("subagent_policy");
    +    expect(failed.error).toContain("current depth: 2, max: 2");
    +  });
    +
    +  it("rejects ACP spawns that exceed subagent child caps", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxChildrenPerAgent: 1,
    +          },
    +        },
    +      },
    +    });
    +    hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1);
    +
    +    const result = await spawnAcpDirect(createSpawnRequest(), {
    +      ...createRequesterContext(),
    +      agentSessionKey: "agent:main:subagent:parent",
    +    });
    +
    +    const failed = expectFailedSpawn(result, "forbidden");
    +    expect(failed.errorCode).toBe("subagent_policy");
    +    expect(failed.error).toContain("max active children");
    +  });
    +
    +  it('counts streamTo="parent" ACP runs toward subagent child caps', async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxChildrenPerAgent: 1,
    +          },
    +        },
    +      },
    +    });
    +    hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
    +      {
    +        runtime: "acp",
    +        status: "running",
    +        childSessionKey: "agent:codex:acp:existing-parent-stream",
    +      },
    +    ]);
    +
    +    const result = await spawnAcpDirect(
    +      createSpawnRequest({
    +        streamTo: "parent",
    +      }),
    +      {
    +        ...createRequesterContext(),
    +        agentSessionKey: "agent:main:subagent:parent",
    +      },
    +    );
    +
    +    const failed = expectFailedSpawn(result, "forbidden");
    +    expect(failed.errorCode).toBe("subagent_policy");
    +    expect(failed.error).toContain("max active children");
    +  });
    +
    +  it("does not double-count duplicate ACP task rows for the same child session", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxChildrenPerAgent: 2,
    +          },
    +        },
    +      },
    +    });
    +    hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
    +      {
    +        runtime: "acp",
    +        status: "running",
    +        childSessionKey: "agent:codex:acp:existing-parent-stream",
    +      },
    +      {
    +        runtime: "acp",
    +        status: "queued",
    +        childSessionKey: "agent:codex:acp:existing-parent-stream",
    +      },
    +    ]);
    +
    +    const result = await spawnAcpDirect(
    +      createSpawnRequest({
    +        streamTo: "parent",
    +      }),
    +      {
    +        ...createRequesterContext(),
    +        agentSessionKey: "agent:main:subagent:parent",
    +      },
    +    );
    +
    +    expectAcceptedSpawn(result);
    +  });
    +
    +  it("does not double-count ACP task rows for active registry-tracked ACP children", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          subagents: {
    +            ...hoisted.state.cfg.agents?.defaults?.subagents,
    +            maxChildrenPerAgent: 2,
    +          },
    +        },
    +      },
    +    });
    +    hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1);
    +    hoisted.getSubagentRunByChildSessionKeyMock.mockImplementationOnce((childSessionKey: string) =>
    +      childSessionKey === "agent:codex:acp:existing-parent-stream"
    +        ? {
    +            childSessionKey,
    +            createdAt: Date.now(),
    +          }
    +        : null,
    +    );
    +    hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
    +      {
    +        runtime: "acp",
    +        status: "running",
    +        childSessionKey: "agent:codex:acp:existing-parent-stream",
    +      },
    +    ]);
    +
    +    const result = await spawnAcpDirect(
    +      createSpawnRequest({
    +        streamTo: "parent",
    +      }),
    +      {
    +        ...createRequesterContext(),
    +        agentSessionKey: "agent:main:subagent:parent",
    +      },
    +    );
    +
    +    expectAcceptedSpawn(result);
    +  });
    +
    +  it("rejects ACP spawns to agents outside the subagent allowlist", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      acp: {
    +        ...hoisted.state.cfg.acp,
    +        allowedAgents: ["codex", "writer"],
    +      },
    +      agents: {
    +        ...hoisted.state.cfg.agents,
    +        list: [
    +          {
    +            id: "main",
    +            default: true,
    +            subagents: {
    +              allowAgents: ["codex"],
    +            },
    +          },
    +          {
    +            id: "writer",
    +          },
    +        ],
    +      },
    +    });
    +
    +    const result = await spawnAcpDirect(
    +      createSpawnRequest({
    +        agentId: "writer",
    +      }),
    +      {
    +        ...createRequesterContext(),
    +        agentSessionKey: "agent:main:subagent:parent",
    +      },
    +    );
    +
    +    const failed = expectFailedSpawn(result, "forbidden");
    +    expect(failed.errorCode).toBe("subagent_policy");
    +    expect(failed.error).toContain("agentId is not allowed");
    +  });
    +
       it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => {
         enableMatrixAcpThreadBindings();
         hoisted.sessionBindingBindMock.mockImplementationOnce(
    @@ -1522,6 +1791,7 @@ describe("spawnAcpDirect", () => {
           ...hoisted.state.cfg,
           agents: {
             defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
               sandbox: { mode: "all" },
             },
           },
    @@ -1623,6 +1893,7 @@ describe("spawnAcpDirect", () => {
           ...hoisted.state.cfg,
           agents: {
             defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
               heartbeat: {
                 every: "30m",
                 target: "last",
    @@ -1701,11 +1972,81 @@ describe("spawnAcpDirect", () => {
         expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
       });
     
    +  it("does not implicitly stream for ACP requester sessions inside a subagent envelope", async () => {
    +    replaceSpawnConfig({
    +      ...hoisted.state.cfg,
    +      agents: {
    +        defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
    +          heartbeat: {
    +            every: "30m",
    +            target: "last",
    +          },
    +        },
    +      },
    +    });
    +    hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
    +      const store: Record<
    +        string,
    +        {
    +          sessionId: string;
    +          updatedAt: number;
    +          deliveryContext?: unknown;
    +          spawnedBy?: string;
    +          spawnDepth?: number;
    +          subagentRole?: string;
    +          subagentControlScope?: string;
    +        }
    +      > = {
    +        "agent:main:acp:child": {
    +          sessionId: "parent-sess-1",
    +          updatedAt: Date.now(),
    +          deliveryContext: {
    +            channel: "discord",
    +            to: "channel:parent-channel",
    +            accountId: "default",
    +          },
    +          spawnedBy: "agent:main:subagent:parent",
    +          spawnDepth: 2,
    +          subagentRole: "leaf",
    +          subagentControlScope: "none",
    +        },
    +      };
    +      return new Proxy(store, {
    +        get(target, prop) {
    +          if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
    +            return { sessionId: "sess-123", updatedAt: Date.now() };
    +          }
    +          return target[prop as keyof typeof target];
    +        },
    +      });
    +    });
    +
    +    const result = await spawnAcpDirect(
    +      {
    +        task: "Investigate flaky tests",
    +        agentId: "codex",
    +      },
    +      {
    +        agentSessionKey: "agent:main:acp:child",
    +        agentChannel: "discord",
    +        agentAccountId: "default",
    +        agentTo: "channel:parent-channel",
    +      },
    +    );
    +
    +    const accepted = expectAcceptedSpawn(result);
    +    expect(accepted.mode).toBe("run");
    +    expect(accepted.streamLogPath).toBeUndefined();
    +    expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
    +  });
    +
       it("does not implicitly stream when heartbeat target is not session-local", async () => {
         replaceSpawnConfig({
           ...hoisted.state.cfg,
           agents: {
             defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
               heartbeat: {
                 every: "30m",
                 target: "discord",
    @@ -1740,6 +2081,7 @@ describe("spawnAcpDirect", () => {
           },
           agents: {
             defaults: {
    +          ...hoisted.state.cfg.agents?.defaults,
               heartbeat: {
                 every: "30m",
                 target: "last",
    @@ -1768,6 +2110,7 @@ describe("spawnAcpDirect", () => {
         replaceSpawnConfig({
           ...hoisted.state.cfg,
           agents: {
    +        ...hoisted.state.cfg.agents,
             list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }],
           },
         });
    @@ -1792,6 +2135,7 @@ describe("spawnAcpDirect", () => {
         replaceSpawnConfig({
           ...hoisted.state.cfg,
           agents: {
    +        ...hoisted.state.cfg.agents,
             list: [
               {
                 id: "research",
    
  • src/agents/acp-spawn.ts+168 0 modified
    @@ -33,6 +33,10 @@ import {
       resolveThreadBindingSpawnPolicy,
     } from "../channels/thread-bindings-policy.js";
     import { parseDurationMs } from "../cli/parse-duration.js";
    +import {
    +  DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
    +  DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
    +} from "../config/agent-limits.js";
     import { loadConfig } from "../config/config.js";
     import { resolveStorePath } from "../config/sessions/paths.js";
     import { loadSessionStore } from "../config/sessions/store.js";
    @@ -60,6 +64,7 @@ import {
       normalizeOptionalString,
     } from "../shared/string-coerce.js";
     import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
    +import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
     import {
       deliveryContextFromSession,
       formatConversationTarget,
    @@ -75,6 +80,14 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
     import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
     import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
     import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js";
    +import {
    +  isSubagentEnvelopeSession,
    +  resolveSubagentCapabilities,
    +  resolveSubagentCapabilityStore,
    +  type SessionCapabilityStore,
    +} from "./subagent-capabilities.js";
    +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
    +import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js";
     import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
     
     const log = createSubsystemLogger("agents/acp-spawn");
    @@ -117,6 +130,7 @@ export const ACP_SPAWN_ERROR_CODES = [
       "acp_disabled",
       "requester_session_required",
       "runtime_policy",
    +  "subagent_policy",
       "thread_required",
       "target_agent_required",
       "agent_forbidden",
    @@ -216,6 +230,52 @@ type AcpSpawnStreamPlan = {
       effectiveStreamToParent: boolean;
     };
     
    +type AcpSubagentEnvelopeState = {
    +  childSessionPatch?: {
    +    spawnDepth: number;
    +    subagentRole: "orchestrator" | "leaf" | null;
    +    subagentControlScope: "children" | "none";
    +  };
    +  error?: string;
    +};
    +
    +function isActiveTaskStatus(status: string | undefined): boolean {
    +  return status === "queued" || status === "running";
    +}
    +
    +function countUntrackedActiveAcpRunsForOwner(ownerKey: string | undefined): number {
    +  const normalizedOwnerKey = normalizeOptionalString(ownerKey);
    +  if (!normalizedOwnerKey) {
    +    return 0;
    +  }
    +  const tasks = listTasksForOwnerKey(normalizedOwnerKey);
    +  const trackedChildSessionKeys = new Set(
    +    tasks
    +      .filter(
    +        (task) =>
    +          task.runtime === "subagent" &&
    +          isActiveTaskStatus(task.status) &&
    +          normalizeOptionalString(task.childSessionKey),
    +      )
    +      .map((task) => normalizeOptionalString(task.childSessionKey) as string),
    +  );
    +  const activeAcpChildSessionKeys = new Set(
    +    tasks.flatMap((task) => {
    +      const childSessionKey = normalizeOptionalString(task.childSessionKey);
    +      const trackedRun = childSessionKey ? getSubagentRunByChildSessionKey(childSessionKey) : null;
    +      const hasActiveRegistryRun = Boolean(trackedRun && typeof trackedRun.endedAt !== "number");
    +      return task.runtime === "acp" &&
    +        isActiveTaskStatus(task.status) &&
    +        childSessionKey !== undefined &&
    +        !hasActiveRegistryRun &&
    +        !trackedChildSessionKeys.has(childSessionKey)
    +        ? [childSessionKey]
    +        : [];
    +    }),
    +  );
    +  return activeAcpChildSessionKeys.size;
    +}
    +
     type AcpSpawnBootstrapDeliveryPlan = {
       useInlineDelivery: boolean;
       channel?: string;
    @@ -658,6 +718,7 @@ function resolveAcpSpawnRequesterState(params: {
       parentSessionKey?: string;
       targetAgentId: string;
       ctx: SpawnAcpContext;
    +  subagentStore?: SessionCapabilityStore;
     }): AcpSpawnRequesterState {
       const bindingService = getSessionBindingService();
       const requesterParsedSession = parseAgentSessionKey(params.parentSessionKey);
    @@ -706,6 +767,94 @@ function resolveAcpSpawnRequesterState(params: {
       };
     }
     
    +function resolveAcpSubagentEnvelopeState(params: {
    +  cfg: OpenClawConfig;
    +  requesterSessionKey?: string;
    +  targetAgentId: string;
    +  requestedAgentId?: string;
    +  subagentStore?: SessionCapabilityStore;
    +}): AcpSubagentEnvelopeState {
    +  const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
    +  if (!requesterSessionKey) {
    +    return {};
    +  }
    +  if (
    +    !isSubagentEnvelopeSession(requesterSessionKey, {
    +      cfg: params.cfg,
    +      store: params.subagentStore,
    +    })
    +  ) {
    +    return {};
    +  }
    +
    +  const callerDepth = getSubagentDepthFromSessionStore(requesterSessionKey, {
    +    cfg: params.cfg,
    +  });
    +  const maxSpawnDepth =
    +    params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
    +  if (callerDepth >= maxSpawnDepth) {
    +    return {
    +      error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`,
    +    };
    +  }
    +
    +  const maxChildren =
    +    params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ??
    +    DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
    +  const activeChildren =
    +    countActiveRunsForSession(requesterSessionKey) +
    +    countUntrackedActiveAcpRunsForOwner(requesterSessionKey);
    +  if (activeChildren >= maxChildren) {
    +    return {
    +      error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`,
    +    };
    +  }
    +
    +  const requesterAgentId = normalizeAgentId(parseAgentSessionKey(requesterSessionKey)?.agentId);
    +  const requireAgentId =
    +    resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.requireAgentId ??
    +    params.cfg.agents?.defaults?.subagents?.requireAgentId ??
    +    false;
    +  if (requireAgentId && !params.requestedAgentId?.trim()) {
    +    return {
    +      error:
    +        "sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
    +    };
    +  }
    +
    +  if (params.targetAgentId !== requesterAgentId) {
    +    const allowAgents =
    +      resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
    +      params.cfg.agents?.defaults?.subagents?.allowAgents ??
    +      [];
    +    const allowAny = allowAgents.some((value) => value.trim() === "*");
    +    const normalizedTargetId = normalizeOptionalLowercaseString(params.targetAgentId) ?? "";
    +    const allowSet = new Set(
    +      allowAgents
    +        .filter((value) => value.trim() && value.trim() !== "*")
    +        .map((value) => normalizeOptionalLowercaseString(normalizeAgentId(value)) ?? ""),
    +    );
    +    if (!allowAny && !allowSet.has(normalizedTargetId)) {
    +      const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
    +      return {
    +        error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
    +      };
    +    }
    +  }
    +
    +  const childCapabilities = resolveSubagentCapabilities({
    +    depth: callerDepth + 1,
    +    maxSpawnDepth,
    +  });
    +  return {
    +    childSessionPatch: {
    +      spawnDepth: childCapabilities.depth,
    +      subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
    +      subagentControlScope: childCapabilities.controlScope,
    +    },
    +  };
    +}
    +
     function resolveAcpSpawnStreamPlan(params: {
       spawnMode: SpawnAcpMode;
       requestThreadBinding: boolean;
    @@ -1006,12 +1155,30 @@ export async function spawnAcpDirect(
           error: agentPolicyError.message,
         });
       }
    +  const subagentStore = resolveSubagentCapabilityStore(parentSessionKey, {
    +    cfg,
    +  });
       const requesterState = resolveAcpSpawnRequesterState({
         cfg,
         parentSessionKey,
         targetAgentId,
         ctx,
    +    subagentStore,
    +  });
    +  const subagentEnvelopeState = resolveAcpSubagentEnvelopeState({
    +    cfg,
    +    requesterSessionKey: requesterInternalKey,
    +    targetAgentId,
    +    requestedAgentId: params.agentId,
    +    subagentStore,
       });
    +  if (subagentEnvelopeState.error) {
    +    return createAcpSpawnFailure({
    +      status: "forbidden",
    +      errorCode: "subagent_policy",
    +      error: subagentEnvelopeState.error,
    +    });
    +  }
       const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({
         spawnMode,
         requestThreadBinding,
    @@ -1070,6 +1237,7 @@ export async function spawnAcpDirect(
           params: {
             key: sessionKey,
             spawnedBy: requesterInternalKey,
    +        ...subagentEnvelopeState.childSessionPatch,
             ...(params.label ? { label: params.label } : {}),
           },
           timeoutMs: 10_000,
    
  • src/agents/pi-embedded-runner/effective-tool-policy.ts+15 3 modified
    @@ -1,12 +1,15 @@
     import type { OpenClawConfig } from "../../config/types.openclaw.js";
     import { getPluginToolMeta } from "../../plugins/tools.js";
    -import { isSubagentSessionKey } from "../../routing/session-key.js";
     import {
       resolveEffectiveToolPolicy,
       resolveGroupContextFromSessionKey,
       resolveGroupToolPolicy,
       resolveSubagentToolPolicyForSession,
     } from "../pi-tools.policy.js";
    +import {
    +  isSubagentEnvelopeSession,
    +  resolveSubagentCapabilityStore,
    +} from "../subagent-capabilities.js";
     import {
       applyToolPolicyPipeline,
       buildDefaultToolPolicyPipelineSteps,
    @@ -133,9 +136,18 @@ export function applyFinalEffectiveToolPolicy(
         providerProfilePolicy,
         providerProfileAlsoAllow,
       );
    +  const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
    +    cfg: params.config,
    +  });
       const subagentPolicy =
    -    isSubagentSessionKey(params.sessionKey) && params.sessionKey
    -      ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey)
    +    params.sessionKey &&
    +    isSubagentEnvelopeSession(params.sessionKey, {
    +      cfg: params.config,
    +      store: subagentStore,
    +    })
    +      ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey, {
    +          store: subagentStore,
    +        })
           : undefined;
       const ownerFiltered = applyOwnerOnlyToolPolicy(
         params.bundledTools,
    
  • src/agents/pi-tools.create-openclaw-coding-tools.test.ts+177 0 modified
    @@ -238,6 +238,183 @@ describe("createOpenClawCodingTools", () => {
         }
       });
     
    +  it("applies subagent tool policy to ACP children spawned under a subagent envelope", async () => {
    +    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-subagent-policy-"));
    +    try {
    +      const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
    +      const mainStorePath = storeTemplate.replaceAll("{agentId}", "main");
    +      const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer");
    +      await fs.writeFile(
    +        mainStorePath,
    +        JSON.stringify(
    +          {
    +            "agent:main:acp:child": {
    +              sessionId: "session-acp-child",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:subagent:parent",
    +              spawnDepth: 2,
    +              subagentRole: "leaf",
    +              subagentControlScope: "none",
    +            },
    +            "agent:main:acp:plain": {
    +              sessionId: "session-acp-plain",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:main",
    +            },
    +            "agent:main:acp:parent": {
    +              sessionId: "session-acp-parent",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:subagent:parent",
    +            },
    +          },
    +          null,
    +          2,
    +        ),
    +        "utf-8",
    +      );
    +      await fs.writeFile(
    +        writerStorePath,
    +        JSON.stringify(
    +          {
    +            "agent:writer:acp:child": {
    +              sessionId: "session-acp-cross-agent-child",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:acp:parent",
    +            },
    +          },
    +          null,
    +          2,
    +        ),
    +        "utf-8",
    +      );
    +
    +      const persistedEnvelopeTools = createOpenClawCodingTools({
    +        sessionKey: "agent:main:acp:child",
    +        config: {
    +          session: {
    +            store: storeTemplate,
    +          },
    +          agents: {
    +            defaults: {
    +              subagents: {
    +                maxSpawnDepth: 2,
    +              },
    +            },
    +          },
    +        },
    +      });
    +      const persistedEnvelopeNames = new Set(persistedEnvelopeTools.map((tool) => tool.name));
    +      expect(persistedEnvelopeNames.has("sessions_spawn")).toBe(false);
    +      expect(persistedEnvelopeNames.has("sessions_list")).toBe(false);
    +      expect(persistedEnvelopeNames.has("sessions_history")).toBe(false);
    +      expect(persistedEnvelopeNames.has("subagents")).toBe(false);
    +
    +      const restrictedTools = createOpenClawCodingTools({
    +        sessionKey: "agent:main:acp:plain",
    +        config: {
    +          session: {
    +            store: storeTemplate,
    +          },
    +          agents: {
    +            defaults: {
    +              subagents: {
    +                maxSpawnDepth: 2,
    +              },
    +            },
    +          },
    +        },
    +      });
    +      const restrictedNames = new Set(restrictedTools.map((tool) => tool.name));
    +      expect(restrictedNames.has("sessions_spawn")).toBe(true);
    +      expect(restrictedNames.has("subagents")).toBe(true);
    +
    +      const ancestryTools = createOpenClawCodingTools({
    +        sessionKey: "agent:writer:acp:child",
    +        config: {
    +          session: {
    +            store: storeTemplate,
    +          },
    +          agents: {
    +            defaults: {
    +              subagents: {
    +                maxSpawnDepth: 2,
    +              },
    +            },
    +          },
    +        },
    +      });
    +      const ancestryNames = new Set(ancestryTools.map((tool) => tool.name));
    +      expect(ancestryNames.has("sessions_spawn")).toBe(false);
    +      expect(ancestryNames.has("sessions_list")).toBe(false);
    +      expect(ancestryNames.has("sessions_history")).toBe(false);
    +      expect(ancestryNames.has("subagents")).toBe(false);
    +    } finally {
    +      await fs.rm(tmpDir, { recursive: true, force: true });
    +    }
    +  });
    +
    +  it("applies leaf tool policy for cross-agent subagent sessions when spawnDepth is missing", async () => {
    +    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cross-agent-subagent-"));
    +    try {
    +      const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
    +      const mainStorePath = storeTemplate.replaceAll("{agentId}", "main");
    +      const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer");
    +      await fs.writeFile(
    +        mainStorePath,
    +        JSON.stringify(
    +          {
    +            "agent:main:subagent:parent": {
    +              sessionId: "session-main-parent",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:main",
    +            },
    +          },
    +          null,
    +          2,
    +        ),
    +        "utf-8",
    +      );
    +      await fs.writeFile(
    +        writerStorePath,
    +        JSON.stringify(
    +          {
    +            "agent:writer:subagent:child": {
    +              sessionId: "session-writer-child",
    +              updatedAt: Date.now(),
    +              spawnedBy: "agent:main:subagent:parent",
    +            },
    +          },
    +          null,
    +          2,
    +        ),
    +        "utf-8",
    +      );
    +
    +      const tools = createOpenClawCodingTools({
    +        sessionKey: "agent:writer:subagent:child",
    +        config: {
    +          session: {
    +            store: storeTemplate,
    +          },
    +          agents: {
    +            defaults: {
    +              subagents: {
    +                maxSpawnDepth: 2,
    +              },
    +            },
    +          },
    +        },
    +      });
    +      const names = new Set(tools.map((tool) => tool.name));
    +      expect(names.has("sessions_spawn")).toBe(false);
    +      expect(names.has("sessions_list")).toBe(false);
    +      expect(names.has("sessions_history")).toBe(false);
    +      expect(names.has("subagents")).toBe(false);
    +    } finally {
    +      await fs.rm(tmpDir, { recursive: true, force: true });
    +    }
    +  });
    +
       it("supports allow-only sub-agent tool policy", () => {
         const tools = createOpenClawCodingTools({
           sessionKey: "agent:main:subagent:test",
    
  • src/agents/pi-tools.policy.ts+13 1 modified
    @@ -19,7 +19,9 @@ import type { AnyAgentTool } from "./pi-tools.types.js";
     import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
     import type { SandboxToolPolicy } from "./sandbox.js";
     import {
    +  resolveSubagentCapabilityStore,
       resolveStoredSubagentCapabilities,
    +  type SessionCapabilityStore,
       type SubagentSessionRole,
     } from "./subagent-capabilities.js";
     import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";
    @@ -100,9 +102,19 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
     export function resolveSubagentToolPolicyForSession(
       cfg: OpenClawConfig | undefined,
       sessionKey: string,
    +  opts?: {
    +    store?: SessionCapabilityStore;
    +  },
     ): SandboxToolPolicy {
       const configured = cfg?.tools?.subagents?.tools;
    -  const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg });
    +  const store = resolveSubagentCapabilityStore(sessionKey, {
    +    cfg,
    +    store: opts?.store,
    +  });
    +  const capabilities = resolveStoredSubagentCapabilities(sessionKey, {
    +    cfg,
    +    store,
    +  });
       const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
       const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined;
       const explicitAllow = new Set(
    
  • src/agents/pi-tools.ts+15 3 modified
    @@ -5,7 +5,6 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
     import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
     import { logWarn } from "../logger.js";
     import { getPluginToolMeta } from "../plugins/tools.js";
    -import { isSubagentSessionKey } from "../routing/session-key.js";
     import {
       normalizeLowercaseStringOrEmpty,
       normalizeOptionalLowercaseString,
    @@ -49,6 +48,10 @@ import {
     import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
     import type { AnyAgentTool } from "./pi-tools.types.js";
     import type { SandboxContext } from "./sandbox.js";
    +import {
    +  isSubagentEnvelopeSession,
    +  resolveSubagentCapabilityStore,
    +} from "./subagent-capabilities.js";
     import {
       EXEC_TOOL_DISPLAY_SUMMARY,
       PROCESS_TOOL_DISPLAY_SUMMARY,
    @@ -395,9 +398,18 @@ export function createOpenClawCodingTools(options?: {
       // Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts).
       const scopeKey =
         options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
    +  const subagentStore = resolveSubagentCapabilityStore(options?.sessionKey, {
    +    cfg: options?.config,
    +  });
       const subagentPolicy =
    -    isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
    -      ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey)
    +    options?.sessionKey &&
    +    isSubagentEnvelopeSession(options.sessionKey, {
    +      cfg: options.config,
    +      store: subagentStore,
    +    })
    +      ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey, {
    +          store: subagentStore,
    +        })
           : undefined;
       const allowBackground = isToolAllowedByPolicies("process", [
         profilePolicyWithAlsoAllow,
    
  • src/agents/subagent-capabilities.ts+152 12 modified
    @@ -1,7 +1,11 @@
     import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
     import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
     import type { OpenClawConfig } from "../config/types.openclaw.js";
    -import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js";
    +import {
    +  isAcpSessionKey,
    +  isSubagentSessionKey,
    +  parseAgentSessionKey,
    +} from "../routing/session-key.js";
     import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
     import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
     import { normalizeSubagentSessionKey } from "./subagent-session-key.js";
    @@ -12,13 +16,16 @@ export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number];
     export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
     export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
     
    -type SessionCapabilityEntry = {
    +export type SessionCapabilityEntry = {
       sessionId?: unknown;
       spawnDepth?: unknown;
       subagentRole?: unknown;
       subagentControlScope?: unknown;
    +  spawnedBy?: unknown;
     };
     
    +export type SessionCapabilityStore = Record<string, SessionCapabilityEntry>;
    +
     function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined {
       const trimmed = normalizeOptionalLowercaseString(value);
       return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed);
    @@ -29,6 +36,20 @@ function normalizeSubagentControlScope(value: unknown): SubagentControlScope | u
       return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed);
     }
     
    +function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean {
    +  return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey);
    +}
    +
    +function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean {
    +  const leftAgentId = normalizeOptionalLowercaseString(
    +    parseAgentSessionKey(leftSessionKey)?.agentId,
    +  );
    +  const rightAgentId = normalizeOptionalLowercaseString(
    +    parseAgentSessionKey(rightSessionKey)?.agentId,
    +  );
    +  return Boolean(leftAgentId) && leftAgentId === rightAgentId;
    +}
    +
     function readSessionStore(storePath: string): Record<string, SessionCapabilityEntry> {
       try {
         return loadSessionStore(storePath);
    @@ -38,7 +59,7 @@ function readSessionStore(storePath: string): Record<string, SessionCapabilityEn
     }
     
     function findEntryBySessionId(
    -  store: Record<string, SessionCapabilityEntry>,
    +  store: SessionCapabilityStore,
       sessionId: string,
     ): SessionCapabilityEntry | undefined {
       const normalizedSessionId = normalizeSubagentSessionKey(sessionId);
    @@ -57,7 +78,7 @@ function findEntryBySessionId(
     function resolveSessionCapabilityEntry(params: {
       sessionKey: string;
       cfg?: OpenClawConfig;
    -  store?: Record<string, SessionCapabilityEntry>;
    +  store?: SessionCapabilityStore;
     }): SessionCapabilityEntry | undefined {
       if (params.store) {
         return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey);
    @@ -74,6 +95,31 @@ function resolveSessionCapabilityEntry(params: {
       return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey);
     }
     
    +export function resolveSubagentCapabilityStore(
    +  sessionKey: string | undefined | null,
    +  opts?: {
    +    cfg?: OpenClawConfig;
    +    store?: SessionCapabilityStore;
    +  },
    +): SessionCapabilityStore | undefined {
    +  const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
    +  if (!normalizedSessionKey) {
    +    return opts?.store;
    +  }
    +  if (opts?.store) {
    +    return opts.store;
    +  }
    +  if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
    +    return undefined;
    +  }
    +  const parsed = parseAgentSessionKey(normalizedSessionKey);
    +  if (!parsed?.agentId) {
    +    return undefined;
    +  }
    +  const storePath = resolveStorePath(opts.cfg.session?.store, { agentId: parsed.agentId });
    +  return readSessionStore(storePath);
    +}
    +
     export function resolveSubagentRoleForDepth(params: {
       depth: number;
       maxSpawnDepth?: number;
    @@ -107,28 +153,122 @@ export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDep
       };
     }
     
    +function isStoredSubagentEnvelopeSession(
    +  params: {
    +    sessionKey: string;
    +    cfg?: OpenClawConfig;
    +    store?: SessionCapabilityStore;
    +    entry?: SessionCapabilityEntry;
    +  },
    +  visited = new Set<string>(),
    +): boolean {
    +  const normalizedSessionKey = normalizeSubagentSessionKey(params.sessionKey);
    +  if (!normalizedSessionKey || visited.has(normalizedSessionKey)) {
    +    return false;
    +  }
    +  visited.add(normalizedSessionKey);
    +
    +  if (isSubagentSessionKey(normalizedSessionKey)) {
    +    return true;
    +  }
    +  if (!isAcpSessionKey(normalizedSessionKey)) {
    +    return false;
    +  }
    +
    +  const entry =
    +    params.entry ??
    +    resolveSessionCapabilityEntry({
    +      sessionKey: normalizedSessionKey,
    +      cfg: params.cfg,
    +      store: params.store,
    +    });
    +  if (
    +    normalizeSubagentRole(entry?.subagentRole) ||
    +    normalizeSubagentControlScope(entry?.subagentControlScope)
    +  ) {
    +    return true;
    +  }
    +
    +  const spawnedBy = normalizeSubagentSessionKey(entry?.spawnedBy);
    +  if (!spawnedBy) {
    +    return false;
    +  }
    +  const parentStore = isSameAgentSessionStore(normalizedSessionKey, spawnedBy)
    +    ? params.store
    +    : undefined;
    +  return isStoredSubagentEnvelopeSession(
    +    {
    +      sessionKey: spawnedBy,
    +      cfg: params.cfg,
    +      store: parentStore,
    +    },
    +    visited,
    +  );
    +}
    +
    +export function isSubagentEnvelopeSession(
    +  sessionKey: string | undefined | null,
    +  opts?: {
    +    cfg?: OpenClawConfig;
    +    store?: SessionCapabilityStore;
    +    entry?: SessionCapabilityEntry;
    +  },
    +): boolean {
    +  const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
    +  if (!normalizedSessionKey) {
    +    return false;
    +  }
    +  if (isSubagentSessionKey(normalizedSessionKey)) {
    +    return true;
    +  }
    +  if (!isAcpSessionKey(normalizedSessionKey)) {
    +    return false;
    +  }
    +  const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
    +  return isStoredSubagentEnvelopeSession({
    +    sessionKey: normalizedSessionKey,
    +    cfg: opts?.cfg,
    +    store,
    +    entry: opts?.entry,
    +  });
    +}
    +
     export function resolveStoredSubagentCapabilities(
       sessionKey: string | undefined | null,
       opts?: {
         cfg?: OpenClawConfig;
    -    store?: Record<string, SessionCapabilityEntry>;
    +    store?: SessionCapabilityStore;
       },
     ) {
       const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
       const maxSpawnDepth =
         opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
    +  if (!normalizedSessionKey) {
    +    return resolveSubagentCapabilities({ depth: 0, maxSpawnDepth });
    +  }
    +  if (!shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
    +    const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
    +      cfg: opts?.cfg,
    +      store: opts?.store,
    +    });
    +    return resolveSubagentCapabilities({ depth, maxSpawnDepth });
    +  }
    +  const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
    +  const entry = normalizedSessionKey
    +    ? resolveSessionCapabilityEntry({
    +        sessionKey: normalizedSessionKey,
    +        cfg: opts?.cfg,
    +        store,
    +      })
    +    : undefined;
    +  const depthStore = opts?.cfg && typeof entry?.spawnDepth !== "number" ? undefined : store;
       const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
         cfg: opts?.cfg,
    -    store: opts?.store,
    +    store: depthStore,
       });
    -  if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) {
    +  if (!isSubagentEnvelopeSession(normalizedSessionKey, { ...opts, store, entry })) {
         return resolveSubagentCapabilities({ depth, maxSpawnDepth });
       }
    -  const entry = resolveSessionCapabilityEntry({
    -    sessionKey: normalizedSessionKey,
    -    cfg: opts?.cfg,
    -    store: opts?.store,
    -  });
       const storedRole = normalizeSubagentRole(entry?.subagentRole);
       const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope);
       const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth });
    
  • src/agents/subagent-spawn.runtime.ts+4 1 modified
    @@ -1,5 +1,8 @@
     export { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
    -export { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
    +export {
    +  DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
    +  DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
    +} from "../config/agent-limits.js";
     export { loadConfig } from "../config/config.js";
     export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
     export { callGateway } from "../gateway/call.js";
    
  • src/agents/subagent-spawn.test-helpers.ts+1 0 modified
    @@ -159,6 +159,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
           params.emitSessionLifecycleEventMock?.(...args),
         formatThinkingLevels: (levels: string[]) => levels.join(", "),
         normalizeThinkLevel: (level: unknown) => normalizeOptionalString(level),
    +    DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT: 5,
         DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH: 3,
         ADMIN_SCOPE: "operator.admin",
         AGENT_LANE_SUBAGENT: "subagent",
    
  • src/agents/subagent-spawn.ts+3 1 modified
    @@ -36,6 +36,7 @@ import {
     import {
       ADMIN_SCOPE,
       AGENT_LANE_SUBAGENT,
    +  DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
       DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
       buildSubagentSystemPrompt,
       callGateway,
    @@ -436,7 +437,8 @@ export async function spawnSubagentDirect(
         };
       }
     
    -  const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5;
    +  const maxChildren =
    +    cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
       const activeChildren = countActiveRunsForSession(requesterInternalKey);
       if (activeChildren >= maxChildren) {
         return {
    
  • src/cli/plugins-cli.list.test.ts+9 1 modified
    @@ -29,7 +29,15 @@ describe("plugins cli list", () => {
     
         await runPluginsCommand(["plugins", "list", "--json"]);
     
    -    expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
    +    expect(buildPluginSnapshotReport).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        logger: expect.objectContaining({
    +          info: expect.any(Function),
    +          warn: expect.any(Function),
    +          error: expect.any(Function),
    +        }),
    +      }),
    +    );
     
         expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({
           workspaceDir: "/workspace",
    
  • src/cli/plugins-cli.ts+17 2 modified
    @@ -16,6 +16,7 @@ import {
       buildPluginSnapshotReport,
       formatPluginCompatibilityNotice,
     } from "../plugins/status.js";
    +import type { PluginLogger } from "../plugins/types.js";
     import {
       resolveUninstallChannelConfigKeys,
       resolveUninstallDirectoryTarget,
    @@ -66,6 +67,13 @@ export type PluginUninstallOptions = {
       dryRun?: boolean;
     };
     
    +const quietPluginJsonLogger: PluginLogger = {
    +  debug: () => undefined,
    +  info: () => undefined,
    +  warn: () => undefined,
    +  error: () => undefined,
    +};
    +
     function formatInspectSection(title: string, lines: string[]): string[] {
       if (lines.length === 0) {
         return [];
    @@ -144,7 +152,9 @@ export function registerPluginsCli(program: Command) {
         .option("--enabled", "Only show enabled plugins", false)
         .option("--verbose", "Show detailed entries", false)
         .action((opts: PluginsListOptions) => {
    -      const report = buildPluginSnapshotReport();
    +      const report = buildPluginSnapshotReport(
    +        opts.json ? { logger: quietPluginJsonLogger } : undefined,
    +      );
           const list = opts.enabled
             ? report.plugins.filter((p) => p.status === "loaded")
             : report.plugins;
    @@ -246,14 +256,18 @@ export function registerPluginsCli(program: Command) {
         .option("--json", "Print JSON")
         .action((id: string | undefined, opts: PluginInspectOptions) => {
           const cfg = loadConfig();
    -      const report = buildPluginDiagnosticsReport({ config: cfg });
    +      const report = buildPluginDiagnosticsReport({
    +        config: cfg,
    +        ...(opts.json ? { logger: quietPluginJsonLogger } : {}),
    +      });
           if (opts.all) {
             if (id) {
               defaultRuntime.error("Pass either a plugin id or --all, not both.");
               return defaultRuntime.exit(1);
             }
             const inspectAll = buildAllPluginInspectReports({
               config: cfg,
    +          ...(opts.json ? { logger: quietPluginJsonLogger } : {}),
               report,
             });
             const inspectAllWithInstall = inspectAll.map((inspect) => ({
    @@ -322,6 +336,7 @@ export function registerPluginsCli(program: Command) {
           const inspect = buildPluginInspectReport({
             id,
             config: cfg,
    +        ...(opts.json ? { logger: quietPluginJsonLogger } : {}),
             report,
           });
           if (!inspect) {
    
  • src/config/agent-limits.ts+1 0 modified
    @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "./types.js";
     
     export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
     export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
    +export const DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = 5;
     // Keep depth-1 subagents as leaves unless config explicitly opts into nesting.
     export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1;
     
    
  • src/gateway/tool-resolution.ts+15 4 modified
    @@ -3,8 +3,12 @@ import { createOpenClawTools } from "../agents/openclaw-tools.js";
     import {
       resolveEffectiveToolPolicy,
       resolveGroupToolPolicy,
    -  resolveSubagentToolPolicy,
    +  resolveSubagentToolPolicyForSession,
     } from "../agents/pi-tools.policy.js";
    +import {
    +  isSubagentEnvelopeSession,
    +  resolveSubagentCapabilityStore,
    +} from "../agents/subagent-capabilities.js";
     import {
       applyToolPolicyPipeline,
       buildDefaultToolPolicyPipelineSteps,
    @@ -18,7 +22,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
     import type { OpenClawConfig } from "../config/types.openclaw.js";
     import { logWarn } from "../logger.js";
     import { getPluginToolMeta } from "../plugins/tools.js";
    -import { isSubagentSessionKey } from "../routing/session-key.js";
     import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
     
     export type GatewayScopedToolSurface = "http" | "loopback";
    @@ -61,8 +64,16 @@ export function resolveGatewayScopedTools(params: {
         messageProvider: params.messageProvider,
         accountId: params.accountId ?? null,
       });
    -  const subagentPolicy = isSubagentSessionKey(params.sessionKey)
    -    ? resolveSubagentToolPolicy(params.cfg)
    +  const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
    +    cfg: params.cfg,
    +  });
    +  const subagentPolicy = isSubagentEnvelopeSession(params.sessionKey, {
    +    cfg: params.cfg,
    +    store: subagentStore,
    +  })
    +    ? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey, {
    +        store: subagentStore,
    +      })
         : undefined;
       const workspaceDir = resolveAgentWorkspaceDir(
         params.cfg,
    
  • src/plugins/runtime/metadata-registry-loader.test.ts+23 0 modified
    @@ -79,6 +79,29 @@ describe("loadPluginMetadataRegistrySnapshot", () => {
         );
       });
     
    +  it("forwards an explicit logger through metadata snapshots", () => {
    +    const logger = {
    +      info: vi.fn(),
    +      warn: vi.fn(),
    +      error: vi.fn(),
    +    };
    +
    +    loadPluginMetadataRegistrySnapshot({
    +      config: { plugins: {} },
    +      logger,
    +      workspaceDir: "/workspace",
    +    });
    +
    +    expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        config: { plugins: {} },
    +        logger,
    +        workspaceDir: "/workspace",
    +        mode: "validate",
    +      }),
    +    );
    +  });
    +
       it("preserves explicit empty plugin scopes on metadata snapshots", () => {
         loadPluginMetadataRegistrySnapshot({
           config: { plugins: {} },
    
  • src/plugins/runtime/metadata-registry-loader.ts+2 0 modified
    @@ -2,12 +2,14 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
     import { loadOpenClawPlugins } from "../loader.js";
     import { hasExplicitPluginIdScope } from "../plugin-scope.js";
     import type { PluginRegistry } from "../registry.js";
    +import type { PluginLogger } from "../types.js";
     import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js";
     
     export function loadPluginMetadataRegistrySnapshot(options?: {
       config?: OpenClawConfig;
       activationSourceConfig?: OpenClawConfig;
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
       workspaceDir?: string;
       onlyPluginIds?: string[];
       loadModules?: boolean;
    
  • src/plugins/status.test.ts+25 0 modified
    @@ -111,6 +111,7 @@ function expectPluginLoaderCall(params: {
       autoEnabledReasons?: Record<string, string[]>;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: unknown;
       loadModules?: boolean;
     }) {
       expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
    @@ -124,6 +125,7 @@ function expectPluginLoaderCall(params: {
             : {}),
           ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
           ...(params.env ? { env: params.env } : {}),
    +      ...(params.logger !== undefined ? { logger: params.logger } : {}),
           ...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
         }),
       );
    @@ -134,6 +136,7 @@ function expectMetadataSnapshotLoaderCall(params: {
       activationSourceConfig?: unknown;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: unknown;
       loadModules?: boolean;
     }) {
       expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith(
    @@ -144,6 +147,7 @@ function expectMetadataSnapshotLoaderCall(params: {
             : {}),
           ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
           ...(params.env ? { env: params.env } : {}),
    +      ...(params.logger !== undefined ? { logger: params.logger } : {}),
           ...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
         }),
       );
    @@ -367,6 +371,27 @@ describe("plugin status reports", () => {
         });
       });
     
    +  it("forwards an explicit logger to plugin loading", () => {
    +    const logger = {
    +      info: vi.fn(),
    +      warn: vi.fn(),
    +      error: vi.fn(),
    +    };
    +
    +    buildPluginSnapshotReport({
    +      config: {},
    +      logger,
    +      workspaceDir: "/workspace",
    +    });
    +
    +    expectMetadataSnapshotLoaderCall({
    +      config: {},
    +      logger,
    +      workspaceDir: "/workspace",
    +      loadModules: false,
    +    });
    +  });
    +
       it("uses a metadata snapshot load for snapshot reports", () => {
         buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" });
     
    
  • src/plugins/status.ts+12 1 modified
    @@ -26,7 +26,7 @@ import {
       resolvePluginRuntimeLoadContext,
     } from "./runtime/load-context.js";
     import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js";
    -import type { PluginHookName } from "./types.js";
    +import type { PluginHookName, PluginLogger } from "./types.js";
     
     export type PluginStatusReport = PluginRegistry & {
       workspaceDir?: string;
    @@ -134,6 +134,7 @@ type PluginReportParams = {
       workspaceDir?: string;
       /** Use an explicit env when plugin roots should resolve independently from process.env. */
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
     };
     
     function buildPluginReport(
    @@ -143,6 +144,7 @@ function buildPluginReport(
       const baseContext = resolvePluginRuntimeLoadContext({
         config: params?.config ?? loadConfig(),
         env: params?.env,
    +    logger: params?.logger,
         workspaceDir: params?.workspaceDir,
       });
       const workspaceDir = baseContext.workspaceDir ?? resolveDefaultAgentWorkspaceDir();
    @@ -193,6 +195,7 @@ function buildPluginReport(
             activationSourceConfig: rawConfig,
             workspaceDir,
             env: params?.env,
    +        logger: params?.logger,
             loadModules: false,
           });
       const importedPluginIds = new Set([
    @@ -230,18 +233,21 @@ export function buildPluginInspectReport(params: {
       config?: OpenClawConfig;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
       report?: PluginStatusReport;
     }): PluginInspectReport | null {
       const rawConfig = params.config ?? loadConfig();
       const config = resolvePluginRuntimeLoadContext({
         config: rawConfig,
         env: params.env,
    +    logger: params.logger,
         workspaceDir: params.workspaceDir,
       }).config;
       const report =
         params.report ??
         buildPluginDiagnosticsReport({
           config: rawConfig,
    +      logger: params.logger,
           workspaceDir: params.workspaceDir,
           env: params.env,
         });
    @@ -355,13 +361,15 @@ export function buildAllPluginInspectReports(params?: {
       config?: OpenClawConfig;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
       report?: PluginStatusReport;
     }): PluginInspectReport[] {
       const rawConfig = params?.config ?? loadConfig();
       const report =
         params?.report ??
         buildPluginDiagnosticsReport({
           config: rawConfig,
    +      logger: params?.logger,
           workspaceDir: params?.workspaceDir,
           env: params?.env,
         });
    @@ -371,6 +379,7 @@ export function buildAllPluginInspectReports(params?: {
           buildPluginInspectReport({
             id: plugin.id,
             config: rawConfig,
    +        logger: params?.logger,
             report,
           }),
         )
    @@ -381,6 +390,7 @@ export function buildPluginCompatibilityWarnings(params?: {
       config?: OpenClawConfig;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
       report?: PluginStatusReport;
     }): string[] {
       return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice);
    @@ -390,6 +400,7 @@ export function buildPluginCompatibilityNotices(params?: {
       config?: OpenClawConfig;
       workspaceDir?: string;
       env?: NodeJS.ProcessEnv;
    +  logger?: PluginLogger;
       report?: PluginStatusReport;
     }): PluginCompatibilityNotice[] {
       return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

3

News mentions

0

No linked articles in our index yet.