VYPR
Moderate severityNVD Advisory· Published Feb 19, 2026· Updated Feb 20, 2026

OpenClaw session tool visibility hardening and Telegram webhook secret fallback

CVE-2026-27004

Description

OpenClaw is a personal AI assistant. Prior to version 2026.2.15, in some shared-agent deployments, OpenClaw session tools (sessions_list, sessions_history, sessions_send) allowed broader session targeting than some operators intended. This is primarily a configuration/visibility-scoping issue in multi-user environments where peers are not equally trusted. In Telegram webhook mode, monitor startup also did not fall back to per-account webhookSecret when only the account-level secret was configured. In shared-agent, multi-user, less-trusted environments: session-tool access could expose transcript content across peer sessions. In single-agent or trusted environments, practical impact is limited. In Telegram webhook mode, account-level secret wiring could be missed unless an explicit monitor webhook secret override was provided. Version 2026.2.15 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.152026.2.15

Affected products

1

Patches

1
c6c53437f7da

fix(security): scope session tools and webhook secret fallback

https://github.com/openclaw/openclawPeter SteinbergerFeb 16, 2026via ghsa
21 files changed · +796 22
  • CHANGELOG.md+1 0 modified
    @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent.
     - CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
     - CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
     - WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
    
  • docs/concepts/session-tool.md+21 1 modified
    @@ -176,12 +176,24 @@ Behavior:
     
     ## Sandbox Session Visibility
     
    -Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
    +Session tools can be scoped to reduce cross-session access.
    +
    +Default behavior:
    +
    +- `tools.sessions.visibility` defaults to `tree` (current session + spawned subagent sessions).
    +- For sandboxed sessions, `agents.defaults.sandbox.sessionToolsVisibility` can hard-clamp visibility.
     
     Config:
     
     ```json5
     {
    +  tools: {
    +    sessions: {
    +      // "self" | "tree" | "agent" | "all"
    +      // default: "tree"
    +      visibility: "tree",
    +    },
    +  },
       agents: {
         defaults: {
           sandbox: {
    @@ -192,3 +204,11 @@ Config:
       },
     }
     ```
    +
    +Notes:
    +
    +- `self`: only the current session key.
    +- `tree`: current session + sessions spawned by the current session.
    +- `agent`: any session belonging to the current agent id.
    +- `all`: any session (cross-agent access still requires `tools.agentToAgent`).
    +- When a session is sandboxed and `sessionToolsVisibility="spawned"`, OpenClaw clamps visibility to `tree` even if you set `tools.sessions.visibility="all"`.
    
  • docs/gateway/configuration-reference.md+25 0 modified
    @@ -1508,6 +1508,31 @@ Provider auth follows standard order: auth profiles → env vars → `models.pro
     }
     ```
     
    +### `tools.sessions`
    +
    +Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).
    +
    +Default: `tree` (current session + sessions spawned by it, such as subagents).
    +
    +```json5
    +{
    +  tools: {
    +    sessions: {
    +      // "self" | "tree" | "agent" | "all"
    +      visibility: "tree",
    +    },
    +  },
    +}
    +```
    +
    +Notes:
    +
    +- `self`: only the current session key.
    +- `tree`: current session + sessions spawned by the current session (subagents).
    +- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
    +- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
    +- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
    +
     ### `tools.subagents`
     
     ```json5
    
  • docs/gateway/security/index.md+4 0 modified
    @@ -710,7 +710,11 @@ Common use cases:
               scope: "agent",
               workspaceAccess: "none",
             },
    +        // Session tools can reveal sensitive data from transcripts. By default OpenClaw limits these tools
    +        // to the current session + spawned subagent sessions, but you can clamp further if needed.
    +        // See `tools.sessions.visibility` in the configuration reference.
             tools: {
    +          sessions: { visibility: "tree" }, // self | tree | agent | all
               allow: [
                 "sessions_list",
                 "sessions_history",
    
  • docs/tools/index.md+2 0 modified
    @@ -442,12 +442,14 @@ Notes:
     
     - `main` is the canonical direct-chat key; global/unknown are hidden.
     - `messageLimit > 0` fetches last N messages per session (tool messages filtered).
    +- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
     - `sessions_send` waits for final completion when `timeoutSeconds > 0`.
     - Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
     - `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
     - `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
     - `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
     - After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
    +- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
     
     ### `agents_list`
     
    
  • docs/tools/multi-agent-sandbox-tools.md+1 0 modified
    @@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
     ```json
     {
       "tools": {
    +    "sessions": { "visibility": "tree" },
         "allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
         "deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
       }
    
  • src/agents/openclaw-tools.sessions.e2e.test.ts+4 0 modified
    @@ -20,6 +20,10 @@ vi.mock("../config/config.js", async (importOriginal) => {
             scope: "per-sender",
             agentToAgent: { maxPingPongTurns: 2 },
           },
    +      tools: {
    +        // Keep sessions tools permissive in this suite; dedicated visibility tests cover defaults.
    +        sessions: { visibility: "all" },
    +      },
         }),
         resolveGatewayPort: () => 18789,
       };
    
  • src/agents/openclaw-tools.sessions-visibility.e2e.test.ts+125 0 added
    @@ -0,0 +1,125 @@
    +import { describe, expect, it, vi } from "vitest";
    +
    +const callGatewayMock = vi.fn();
    +vi.mock("../gateway/call.js", () => ({
    +  callGateway: (opts: unknown) => callGatewayMock(opts),
    +}));
    +
    +let mockConfig: Record<string, unknown> = {
    +  session: { mainKey: "main", scope: "per-sender" },
    +};
    +vi.mock("../config/config.js", async (importOriginal) => {
    +  const actual = await importOriginal<typeof import("../config/config.js")>();
    +  return {
    +    ...actual,
    +    loadConfig: () => mockConfig,
    +    resolveGatewayPort: () => 18789,
    +  };
    +});
    +
    +import "./test-helpers/fast-core-tools.js";
    +import { createOpenClawTools } from "./openclaw-tools.js";
    +
    +describe("sessions tools visibility", () => {
    +  it("defaults to tree visibility (self + spawned) for sessions_history", async () => {
    +    mockConfig = {
    +      session: { mainKey: "main", scope: "per-sender" },
    +      tools: { agentToAgent: { enabled: false } },
    +    };
    +    callGatewayMock.mockReset();
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const req = opts as { method?: string; params?: Record<string, unknown> };
    +      if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
    +        return { sessions: [{ key: "subagent:child-1" }] };
    +      }
    +      if (req.method === "sessions.resolve") {
    +        const key = typeof req.params?.key === "string" ? String(req.params?.key) : "";
    +        return { key };
    +      }
    +      if (req.method === "chat.history") {
    +        return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
    +      (candidate) => candidate.name === "sessions_history",
    +    );
    +    expect(tool).toBeDefined();
    +    if (!tool) {
    +      throw new Error("missing sessions_history tool");
    +    }
    +
    +    const denied = await tool.execute("call1", {
    +      sessionKey: "agent:main:discord:direct:someone-else",
    +    });
    +    expect(denied.details).toMatchObject({ status: "forbidden" });
    +
    +    const allowed = await tool.execute("call2", { sessionKey: "subagent:child-1" });
    +    expect(allowed.details).toMatchObject({
    +      sessionKey: "subagent:child-1",
    +    });
    +  });
    +
    +  it("allows broader access when tools.sessions.visibility=all", async () => {
    +    mockConfig = {
    +      session: { mainKey: "main", scope: "per-sender" },
    +      tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: false } },
    +    };
    +    callGatewayMock.mockReset();
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const req = opts as { method?: string; params?: Record<string, unknown> };
    +      if (req.method === "chat.history") {
    +        return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
    +      (candidate) => candidate.name === "sessions_history",
    +    );
    +    expect(tool).toBeDefined();
    +    if (!tool) {
    +      throw new Error("missing sessions_history tool");
    +    }
    +
    +    const result = await tool.execute("call3", {
    +      sessionKey: "agent:main:discord:direct:someone-else",
    +    });
    +    expect(result.details).toMatchObject({
    +      sessionKey: "agent:main:discord:direct:someone-else",
    +    });
    +  });
    +
    +  it("clamps sandboxed sessions to tree when agents.defaults.sandbox.sessionToolsVisibility=spawned", async () => {
    +    mockConfig = {
    +      session: { mainKey: "main", scope: "per-sender" },
    +      tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } },
    +      agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } },
    +    };
    +    callGatewayMock.mockReset();
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const req = opts as { method?: string; params?: Record<string, unknown> };
    +      if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
    +        return { sessions: [] };
    +      }
    +      if (req.method === "chat.history") {
    +        return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({ agentSessionKey: "main", sandboxed: true }).find(
    +      (candidate) => candidate.name === "sessions_history",
    +    );
    +    expect(tool).toBeDefined();
    +    if (!tool) {
    +      throw new Error("missing sessions_history tool");
    +    }
    +
    +    const denied = await tool.execute("call4", {
    +      sessionKey: "agent:other:main",
    +    });
    +    expect(denied.details).toMatchObject({ status: "forbidden" });
    +  });
    +});
    
  • src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts+377 0 added
    @@ -0,0 +1,377 @@
    +import { beforeEach, describe, expect, it, vi } from "vitest";
    +import { emitAgentEvent } from "../infra/agent-events.js";
    +import { createOpenClawTools } from "./openclaw-tools.js";
    +import "./test-helpers/fast-core-tools.js";
    +import { resetSubagentRegistryForTests } from "./subagent-registry.js";
    +
    +type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
    +
    +const hoisted = vi.hoisted(() => {
    +  const callGatewayMock = vi.fn();
    +  const defaultConfigOverride = {
    +    session: {
    +      mainKey: "main",
    +      scope: "per-sender",
    +    },
    +  } as SessionsSpawnTestConfig;
    +  const state = { configOverride: defaultConfigOverride };
    +  return { callGatewayMock, defaultConfigOverride, state };
    +});
    +
    +const callGatewayMock = hoisted.callGatewayMock;
    +
    +function resetConfigOverride() {
    +  hoisted.state.configOverride = hoisted.defaultConfigOverride;
    +}
    +
    +function setConfigOverride(next: SessionsSpawnTestConfig) {
    +  hoisted.state.configOverride = next;
    +}
    +
    +vi.mock("../gateway/call.js", () => ({
    +  callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
    +}));
    +// Some tools import callGateway via "../../gateway/call.js" (from nested folders). Mock that too.
    +vi.mock("../../gateway/call.js", () => ({
    +  callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
    +}));
    +
    +vi.mock("../config/config.js", async (importOriginal) => {
    +  const actual = await importOriginal<typeof import("../config/config.js")>();
    +  return {
    +    ...actual,
    +    loadConfig: () => hoisted.state.configOverride,
    +    resolveGatewayPort: () => 18789,
    +  };
    +});
    +
    +// Same module, different specifier (used by tools under src/agents/tools/*).
    +vi.mock("../../config/config.js", async (importOriginal) => {
    +  const actual = await importOriginal<typeof import("../config/config.js")>();
    +  return {
    +    ...actual,
    +    loadConfig: () => hoisted.state.configOverride,
    +    resolveGatewayPort: () => 18789,
    +  };
    +});
    +
    +describe("openclaw-tools: subagents", () => {
    +  beforeEach(() => {
    +    resetConfigOverride();
    +  });
    +
    +  it("sessions_spawn normalizes allowlisted agent ids", async () => {
    +    resetSubagentRegistryForTests();
    +    callGatewayMock.mockReset();
    +    setConfigOverride({
    +      session: {
    +        mainKey: "main",
    +        scope: "per-sender",
    +      },
    +      agents: {
    +        list: [
    +          {
    +            id: "main",
    +            subagents: {
    +              allowAgents: ["Research"],
    +            },
    +          },
    +        ],
    +      },
    +    });
    +
    +    let childSessionKey: string | undefined;
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const request = opts as { method?: string; params?: unknown };
    +      if (request.method === "agent") {
    +        const params = request.params as { sessionKey?: string } | undefined;
    +        childSessionKey = params?.sessionKey;
    +        return { runId: "run-1", status: "accepted", acceptedAt: 5200 };
    +      }
    +      if (request.method === "agent.wait") {
    +        return { status: "timeout" };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({
    +      agentSessionKey: "main",
    +      agentChannel: "whatsapp",
    +    }).find((candidate) => candidate.name === "sessions_spawn");
    +    if (!tool) {
    +      throw new Error("missing sessions_spawn tool");
    +    }
    +
    +    const result = await tool.execute("call10", {
    +      task: "do thing",
    +      agentId: "research",
    +    });
    +
    +    expect(result.details).toMatchObject({
    +      status: "accepted",
    +      runId: "run-1",
    +    });
    +    expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true);
    +  });
    +  it("sessions_spawn forbids cross-agent spawning when not allowed", async () => {
    +    resetSubagentRegistryForTests();
    +    callGatewayMock.mockReset();
    +    setConfigOverride({
    +      session: {
    +        mainKey: "main",
    +        scope: "per-sender",
    +      },
    +      agents: {
    +        list: [
    +          {
    +            id: "main",
    +            subagents: {
    +              allowAgents: ["alpha"],
    +            },
    +          },
    +        ],
    +      },
    +    });
    +
    +    const tool = createOpenClawTools({
    +      agentSessionKey: "main",
    +      agentChannel: "whatsapp",
    +    }).find((candidate) => candidate.name === "sessions_spawn");
    +    if (!tool) {
    +      throw new Error("missing sessions_spawn tool");
    +    }
    +
    +    const result = await tool.execute("call9", {
    +      task: "do thing",
    +      agentId: "beta",
    +    });
    +    expect(result.details).toMatchObject({
    +      status: "forbidden",
    +    });
    +    expect(callGatewayMock).not.toHaveBeenCalled();
    +  });
    +
    +  it("sessions_spawn runs cleanup via lifecycle events", async () => {
    +    resetSubagentRegistryForTests();
    +    callGatewayMock.mockReset();
    +    const calls: Array<{ method?: string; params?: unknown }> = [];
    +    let agentCallCount = 0;
    +    let deletedKey: string | undefined;
    +    let childRunId: string | undefined;
    +    let childSessionKey: string | undefined;
    +    const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
    +
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const request = opts as { method?: string; params?: unknown };
    +      calls.push(request);
    +      if (request.method === "agent") {
    +        agentCallCount += 1;
    +        const runId = `run-${agentCallCount}`;
    +        const params = request.params as {
    +          message?: string;
    +          sessionKey?: string;
    +          channel?: string;
    +          timeout?: number;
    +          lane?: string;
    +        };
    +        if (params?.lane === "subagent") {
    +          childRunId = runId;
    +          childSessionKey = params?.sessionKey ?? "";
    +          expect(params?.channel).toBe("discord");
    +          expect(params?.timeout).toBe(1);
    +        }
    +        return {
    +          runId,
    +          status: "accepted",
    +          acceptedAt: 1000 + agentCallCount,
    +        };
    +      }
    +      if (request.method === "agent.wait") {
    +        const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
    +        waitCalls.push(params ?? {});
    +        return {
    +          runId: params?.runId ?? "run-1",
    +          status: "ok",
    +          startedAt: 1000,
    +          endedAt: 2000,
    +        };
    +      }
    +      if (request.method === "sessions.delete") {
    +        const params = request.params as { key?: string } | undefined;
    +        deletedKey = params?.key;
    +        return { ok: true };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({
    +      agentSessionKey: "discord:group:req",
    +      agentChannel: "discord",
    +    }).find((candidate) => candidate.name === "sessions_spawn");
    +    if (!tool) {
    +      throw new Error("missing sessions_spawn tool");
    +    }
    +
    +    const result = await tool.execute("call1", {
    +      task: "do thing",
    +      runTimeoutSeconds: 1,
    +      cleanup: "delete",
    +    });
    +    expect(result.details).toMatchObject({
    +      status: "accepted",
    +      runId: "run-1",
    +    });
    +
    +    if (!childRunId) {
    +      throw new Error("missing child runId");
    +    }
    +    vi.useFakeTimers();
    +    try {
    +      emitAgentEvent({
    +        runId: childRunId,
    +        stream: "lifecycle",
    +        data: {
    +          phase: "end",
    +          startedAt: 1234,
    +          endedAt: 2345,
    +        },
    +      });
    +
    +      await vi.runAllTimersAsync();
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +
    +    const childWait = waitCalls.find((call) => call.runId === childRunId);
    +    expect(childWait?.timeoutMs).toBe(1000);
    +
    +    const agentCalls = calls.filter((call) => call.method === "agent");
    +    const spawnCalls = agentCalls.filter((call) => {
    +      const params = call.params as { lane?: string } | undefined;
    +      return params?.lane === "subagent";
    +    });
    +    expect(spawnCalls).toHaveLength(1);
    +    const announceCalls = agentCalls.filter((call) => {
    +      const params = call.params as { sessionKey?: string; deliver?: boolean } | undefined;
    +      return params?.deliver === true && params?.sessionKey === "discord:group:req";
    +    });
    +    expect(announceCalls).toHaveLength(1);
    +
    +    const first = spawnCalls[0]?.params as
    +      | {
    +          lane?: string;
    +          deliver?: boolean;
    +          sessionKey?: string;
    +          channel?: string;
    +        }
    +      | undefined;
    +    expect(first?.lane).toBe("subagent");
    +    expect(first?.deliver).toBe(false);
    +    expect(first?.channel).toBe("discord");
    +    expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
    +    expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
    +
    +    const second = announceCalls[0]?.params as
    +      | {
    +          sessionKey?: string;
    +          message?: string;
    +          deliver?: boolean;
    +        }
    +      | undefined;
    +    expect(second?.sessionKey).toBe("discord:group:req");
    +    expect(second?.deliver).toBe(true);
    +    expect(second?.message).toContain("subagent task");
    +
    +    const sendCalls = calls.filter((c) => c.method === "send");
    +    expect(sendCalls.length).toBe(0);
    +
    +    expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
    +  });
    +
    +  it("sessions_spawn announces with requester accountId", async () => {
    +    resetSubagentRegistryForTests();
    +    callGatewayMock.mockReset();
    +    const calls: Array<{ method?: string; params?: unknown }> = [];
    +    let agentCallCount = 0;
    +    let childRunId: string | undefined;
    +
    +    callGatewayMock.mockImplementation(async (opts: unknown) => {
    +      const request = opts as { method?: string; params?: unknown };
    +      calls.push(request);
    +      if (request.method === "agent") {
    +        agentCallCount += 1;
    +        const runId = `run-${agentCallCount}`;
    +        const params = request.params as { lane?: string; sessionKey?: string } | undefined;
    +        if (params?.lane === "subagent") {
    +          childRunId = runId;
    +        }
    +        return {
    +          runId,
    +          status: "accepted",
    +          acceptedAt: 4000 + agentCallCount,
    +        };
    +      }
    +      if (request.method === "agent.wait") {
    +        const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
    +        return {
    +          runId: params?.runId ?? "run-1",
    +          status: "ok",
    +          startedAt: 1000,
    +          endedAt: 2000,
    +        };
    +      }
    +      if (request.method === "sessions.delete" || request.method === "sessions.patch") {
    +        return { ok: true };
    +      }
    +      return {};
    +    });
    +
    +    const tool = createOpenClawTools({
    +      agentSessionKey: "main",
    +      agentChannel: "whatsapp",
    +      agentAccountId: "kev",
    +    }).find((candidate) => candidate.name === "sessions_spawn");
    +    if (!tool) {
    +      throw new Error("missing sessions_spawn tool");
    +    }
    +
    +    const result = await tool.execute("call2", {
    +      task: "do thing",
    +      runTimeoutSeconds: 1,
    +      cleanup: "keep",
    +    });
    +    expect(result.details).toMatchObject({
    +      status: "accepted",
    +      runId: "run-1",
    +    });
    +
    +    if (!childRunId) {
    +      throw new Error("missing child runId");
    +    }
    +    vi.useFakeTimers();
    +    try {
    +      emitAgentEvent({
    +        runId: childRunId,
    +        stream: "lifecycle",
    +        data: {
    +          phase: "end",
    +          startedAt: 1000,
    +          endedAt: 2000,
    +        },
    +      });
    +
    +      await vi.runAllTimersAsync();
    +    } finally {
    +      vi.useRealTimers();
    +    }
    +
    +    const agentCalls = calls.filter((call) => call.method === "agent");
    +    expect(agentCalls).toHaveLength(2);
    +    const announceParams = agentCalls[1]?.params as
    +      | { accountId?: string; channel?: string; deliver?: boolean }
    +      | undefined;
    +    expect(announceParams?.deliver).toBe(true);
    +    expect(announceParams?.channel).toBe("whatsapp");
    +    expect(announceParams?.accountId).toBe("kev");
    +  });
    +});
    
  • src/agents/tools/sessions-helpers.ts+56 0 modified
    @@ -44,6 +44,62 @@ export type SessionListRow = {
       messages?: unknown[];
     };
     
    +export type SessionToolsVisibility = "self" | "tree" | "agent" | "all";
    +
    +export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility {
    +  const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions
    +    ?.visibility;
    +  const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
    +  if (value === "self" || value === "tree" || value === "agent" || value === "all") {
    +    return value;
    +  }
    +  return "tree";
    +}
    +
    +export function resolveEffectiveSessionToolsVisibility(params: {
    +  cfg: OpenClawConfig;
    +  sandboxed: boolean;
    +}): SessionToolsVisibility {
    +  const visibility = resolveSessionToolsVisibility(params.cfg);
    +  if (!params.sandboxed) {
    +    return visibility;
    +  }
    +  const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
    +  if (sandboxClamp === "spawned" && visibility !== "tree") {
    +    return "tree";
    +  }
    +  return visibility;
    +}
    +
    +export async function listSpawnedSessionKeys(params: {
    +  requesterSessionKey: string;
    +  limit?: number;
    +}): Promise<Set<string>> {
    +  const limit =
    +    typeof params.limit === "number" && Number.isFinite(params.limit)
    +      ? Math.max(1, Math.floor(params.limit))
    +      : 500;
    +  try {
    +    const list = await callGateway<{ sessions: Array<{ key?: unknown }> }>({
    +      method: "sessions.list",
    +      params: {
    +        includeGlobal: false,
    +        includeUnknown: false,
    +        limit,
    +        spawnedBy: params.requesterSessionKey,
    +      },
    +    });
    +    const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
    +    const keys = sessions
    +      .map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
    +      .map((value) => value.trim())
    +      .filter(Boolean);
    +    return new Set(keys);
    +  } catch {
    +    return new Set();
    +  }
    +}
    +
     function normalizeKey(value?: string) {
       const trimmed = value?.trim();
       return trimmed ? trimmed : undefined;
    
  • src/agents/tools/sessions-history-tool.ts+40 5 modified
    @@ -8,6 +8,8 @@ import { truncateUtf16Safe } from "../../utils.js";
     import { jsonResult, readStringParam } from "./common.js";
     import {
       createAgentToAgentPolicy,
    +  listSpawnedSessionKeys,
    +  resolveEffectiveSessionToolsVisibility,
       resolveSessionReference,
       SessionListRow,
       resolveSandboxedSessionToolContext,
    @@ -167,7 +169,6 @@ async function isSpawnedSessionAllowed(params: {
         return false;
       }
     }
    -
     export function createSessionsHistoryTool(opts?: {
       agentSessionKey?: string;
       sandboxed?: boolean;
    @@ -189,11 +190,12 @@ export function createSessionsHistoryTool(opts?: {
               agentSessionKey: opts?.agentSessionKey,
               sandboxed: opts?.sandboxed,
             });
    +      const effectiveRequesterKey = requesterInternalKey ?? alias;
           const resolvedSession = await resolveSessionReference({
             sessionKey: sessionKeyParam,
             alias,
             mainKey,
    -        requesterInternalKey,
    +        requesterInternalKey: effectiveRequesterKey,
             restrictToSpawned,
           });
           if (!resolvedSession.ok) {
    @@ -203,9 +205,9 @@ export function createSessionsHistoryTool(opts?: {
           const resolvedKey = resolvedSession.key;
           const displayKey = resolvedSession.displayKey;
           const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
    -      if (restrictToSpawned && requesterInternalKey && !resolvedViaSessionId) {
    +      if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
             const ok = await isSpawnedSessionAllowed({
    -          requesterSessionKey: requesterInternalKey,
    +          requesterSessionKey: effectiveRequesterKey,
               targetSessionKey: resolvedKey,
             });
             if (!ok) {
    @@ -215,11 +217,22 @@ export function createSessionsHistoryTool(opts?: {
               });
             }
           }
    +      const visibility = resolveEffectiveSessionToolsVisibility({
    +        cfg,
    +        sandboxed: opts?.sandboxed === true,
    +      });
     
           const a2aPolicy = createAgentToAgentPolicy(cfg);
    -      const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
    +      const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
           const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
           const isCrossAgent = requesterAgentId !== targetAgentId;
    +      if (isCrossAgent && visibility !== "all") {
    +        return jsonResult({
    +          status: "forbidden",
    +          error:
    +            "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
    +        });
    +      }
           if (isCrossAgent) {
             if (!a2aPolicy.enabled) {
               return jsonResult({
    @@ -236,6 +249,28 @@ export function createSessionsHistoryTool(opts?: {
             }
           }
     
    +      if (!isCrossAgent) {
    +        if (visibility === "self" && resolvedKey !== effectiveRequesterKey) {
    +          return jsonResult({
    +            status: "forbidden",
    +            error:
    +              "Session history visibility is restricted to the current session (tools.sessions.visibility=self).",
    +          });
    +        }
    +        if (visibility === "tree" && resolvedKey !== effectiveRequesterKey) {
    +          const spawned = await listSpawnedSessionKeys({
    +            requesterSessionKey: effectiveRequesterKey,
    +          });
    +          if (!spawned.has(resolvedKey)) {
    +            return jsonResult({
    +              status: "forbidden",
    +              error:
    +                "Session history visibility is restricted to the current session tree (tools.sessions.visibility=tree).",
    +            });
    +          }
    +        }
    +      }
    +
           const limit =
             typeof params.limit === "number" && Number.isFinite(params.limit)
               ? Math.max(1, Math.floor(params.limit))
    
  • src/agents/tools/sessions-list-tool.ts+27 4 modified
    @@ -10,7 +10,9 @@ import {
       createAgentToAgentPolicy,
       classifySessionKind,
       deriveChannel,
    +  listSpawnedSessionKeys,
       resolveDisplaySessionKey,
    +  resolveEffectiveSessionToolsVisibility,
       resolveInternalSessionKey,
       resolveSandboxedSessionToolContext,
       type SessionListRow,
    @@ -42,6 +44,11 @@ export function createSessionsListTool(opts?: {
               agentSessionKey: opts?.agentSessionKey,
               sandboxed: opts?.sandboxed,
             });
    +      const effectiveRequesterKey = requesterInternalKey ?? alias;
    +      const visibility = resolveEffectiveSessionToolsVisibility({
    +        cfg,
    +        sandboxed: opts?.sandboxed === true,
    +      });
     
           const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
             value.trim().toLowerCase(),
    @@ -72,15 +79,19 @@ export function createSessionsListTool(opts?: {
               activeMinutes,
               includeGlobal: !restrictToSpawned,
               includeUnknown: !restrictToSpawned,
    -          spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
    +          spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined,
             },
           });
     
           const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
           const storePath = typeof list?.path === "string" ? list.path : undefined;
           const a2aPolicy = createAgentToAgentPolicy(cfg);
    -      const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
    +      const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
           const rows: SessionListRow[] = [];
    +      const spawnedKeys =
    +        visibility === "tree"
    +          ? await listSpawnedSessionKeys({ requesterSessionKey: effectiveRequesterKey })
    +          : null;
     
           for (const entry of sessions) {
             if (!entry || typeof entry !== "object") {
    @@ -93,8 +104,20 @@ export function createSessionsListTool(opts?: {
     
             const entryAgentId = resolveAgentIdFromSessionKey(key);
             const crossAgent = entryAgentId !== requesterAgentId;
    -        if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) {
    -          continue;
    +        if (crossAgent) {
    +          if (visibility !== "all") {
    +            continue;
    +          }
    +          if (!a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) {
    +            continue;
    +          }
    +        } else {
    +          if (visibility === "self" && key !== effectiveRequesterKey) {
    +            continue;
    +          }
    +          if (visibility === "tree" && key !== effectiveRequesterKey && !spawnedKeys?.has(key)) {
    +            continue;
    +          }
             }
     
             if (key === "unknown") {
    
  • src/agents/tools/sessions-send-tool.ts+48 9 modified
    @@ -18,6 +18,8 @@ import { jsonResult, readStringParam } from "./common.js";
     import {
       createAgentToAgentPolicy,
       extractAssistantText,
    +  listSpawnedSessionKeys,
    +  resolveEffectiveSessionToolsVisibility,
       resolveInternalSessionKey,
       resolveMainSessionAlias,
       resolveSessionReference,
    @@ -51,21 +53,25 @@ export function createSessionsSendTool(opts?: {
           const cfg = loadConfig();
           const { mainKey, alias } = resolveMainSessionAlias(cfg);
           const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
    -      const requesterInternalKey =
    +      const requesterKeyInput =
             typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
    -          ? resolveInternalSessionKey({
    -              key: opts.agentSessionKey,
    -              alias,
    -              mainKey,
    -            })
    -          : undefined;
    +          ? opts.agentSessionKey
    +          : "main";
    +      const requesterInternalKey = resolveInternalSessionKey({
    +        key: requesterKeyInput,
    +        alias,
    +        mainKey,
    +      });
           const restrictToSpawned =
             opts?.sandboxed === true &&
             visibility === "spawned" &&
    -        !!requesterInternalKey &&
             !isSubagentSessionKey(requesterInternalKey);
     
           const a2aPolicy = createAgentToAgentPolicy(cfg);
    +      const sessionVisibility = resolveEffectiveSessionToolsVisibility({
    +        cfg,
    +        sandboxed: opts?.sandboxed === true,
    +      });
     
           const sessionKeyParam = readStringParam(params, "sessionKey");
           const labelParam = readStringParam(params, "label")?.trim() || undefined;
    @@ -199,7 +205,7 @@ export function createSessionsSendTool(opts?: {
           const displayKey = resolvedSession.displayKey;
           const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
     
    -      if (restrictToSpawned && !resolvedViaSessionId) {
    +      if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== requesterInternalKey) {
             const sessions = await listSessions({
               includeGlobal: false,
               includeUnknown: false,
    @@ -227,6 +233,15 @@ export function createSessionsSendTool(opts?: {
           const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
           const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
           const isCrossAgent = requesterAgentId !== targetAgentId;
    +      if (isCrossAgent && sessionVisibility !== "all") {
    +        return jsonResult({
    +          runId: crypto.randomUUID(),
    +          status: "forbidden",
    +          error:
    +            "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
    +          sessionKey: displayKey,
    +        });
    +      }
           if (isCrossAgent) {
             if (!a2aPolicy.enabled) {
               return jsonResult({
    @@ -245,6 +260,30 @@ export function createSessionsSendTool(opts?: {
                 sessionKey: displayKey,
               });
             }
    +      } else {
    +        if (sessionVisibility === "self" && resolvedKey !== requesterInternalKey) {
    +          return jsonResult({
    +            runId: crypto.randomUUID(),
    +            status: "forbidden",
    +            error:
    +              "Session send visibility is restricted to the current session (tools.sessions.visibility=self).",
    +            sessionKey: displayKey,
    +          });
    +        }
    +        if (sessionVisibility === "tree" && resolvedKey !== requesterInternalKey) {
    +          const spawned = await listSpawnedSessionKeys({
    +            requesterSessionKey: requesterInternalKey,
    +          });
    +          if (!spawned.has(resolvedKey)) {
    +            return jsonResult({
    +              runId: crypto.randomUUID(),
    +              status: "forbidden",
    +              error:
    +                "Session send visibility is restricted to the current session tree (tools.sessions.visibility=tree).",
    +              sessionKey: displayKey,
    +            });
    +          }
    +        }
           }
     
           const agentMessageContext = buildAgentToAgentMessageContext({
    
  • src/config/schema.help.ts+2 0 modified
    @@ -71,6 +71,8 @@ export const FIELD_HELP: Record<string, string> = {
         "Allow stdin-only safe binaries to run without explicit allowlist entries.",
       "tools.fs.workspaceOnly":
         "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
    +  "tools.sessions.visibility":
    +    'Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. ("tree" default = current session + spawned subagent sessions; "self" = only current; "agent" = any session in the current agent id; "all" = any session; cross-agent still requires tools.agentToAgent).',
       "tools.message.allowCrossContextSend":
         "Legacy override: allow cross-context sends across all providers.",
       "tools.message.crossContext.allowWithinProvider":
    
  • src/config/schema.labels.ts+1 0 modified
    @@ -74,6 +74,7 @@ export const FIELD_LABELS: Record<string, string> = {
       "tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only",
       "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
       "tools.fs.workspaceOnly": "Workspace-only FS tools",
    +  "tools.sessions.visibility": "Session Tools Visibility",
       "tools.exec.notifyOnExit": "Exec Notify On Exit",
       "tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
       "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
    
  • src/config/types.agent-defaults.ts+1 1 modified
    @@ -258,7 +258,7 @@ export type AgentDefaultsConfig = {
         workspaceAccess?: "none" | "ro" | "rw";
         /**
          * Session tools visibility for sandboxed sessions.
    -     * - "spawned": only allow session tools to target sessions spawned from this session (default)
    +     * - "spawned": only allow session tools to target the current session and sessions spawned from it (default)
          * - "all": allow session tools to target any session
          */
         sessionToolsVisibility?: "spawned" | "all";
    
  • src/config/types.tools.ts+17 0 modified
    @@ -138,6 +138,8 @@ export type MediaToolsConfig = {
     
     export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
     
    +export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all";
    +
     export type ToolPolicyConfig = {
       allow?: string[];
       /**
    @@ -453,6 +455,21 @@ export type ToolsConfig = {
         /** Allowlist of agent ids or patterns (implementation-defined). */
         allow?: string[];
       };
    +  /**
    +   * Session tool visibility controls which sessions can be targeted by session tools
    +   * (sessions_list, sessions_history, sessions_send).
    +   *
    +   * Default: "tree" (current session + spawned subagent sessions).
    +   */
    +  sessions?: {
    +    /**
    +     * - "self": only the current session
    +     * - "tree": current session + sessions spawned by this session (default)
    +     * - "agent": any session belonging to the current agent id (can include other users)
    +     * - "all": any session (cross-agent still requires tools.agentToAgent)
    +     */
    +    visibility?: SessionsToolsVisibility;
    +  };
       /** Elevated exec permissions for the host machine. */
       elevated?: {
         /** Enable or disable elevated mode (default: true). */
    
  • src/config/zod-schema.agent-runtime.ts+6 0 modified
    @@ -551,6 +551,12 @@ export const ToolsSchema = z
         web: ToolsWebSchema,
         media: ToolsMediaSchema,
         links: ToolsLinksSchema,
    +    sessions: z
    +      .object({
    +        visibility: z.enum(["self", "tree", "agent", "all"]).optional(),
    +      })
    +      .strict()
    +      .optional(),
         message: z
           .object({
             allowCrossContextSend: z.boolean().optional(),
    
  • src/gateway/server.sessions-send.e2e.test.ts+14 1 modified
    @@ -1,7 +1,6 @@
     import fs from "node:fs/promises";
     import path from "node:path";
     import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
    -import { createOpenClawTools } from "../agents/openclaw-tools.js";
     import { resolveSessionTranscriptPath } from "../config/sessions.js";
     import { emitAgentEvent } from "../infra/agent-events.js";
     import { captureEnv } from "../test-utils/env.js";
    @@ -13,6 +12,8 @@ import {
       testState,
     } from "./test-helpers.js";
     
    +const { createOpenClawTools } = await import("../agents/openclaw-tools.js");
    +
     installGatewayTestHooks({ scope: "suite" });
     
     let server: Awaited<ReturnType<typeof startGatewayServer>>;
    @@ -111,6 +112,18 @@ describe("sessions_send gateway loopback", () => {
     
     describe("sessions_send label lookup", () => {
       it("finds session by label and sends message", { timeout: 60_000 }, async () => {
    +    // This is an operator feature; enable broader session tool targeting for this test.
    +    const configPath = process.env.OPENCLAW_CONFIG_PATH;
    +    if (!configPath) {
    +      throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
    +    }
    +    await fs.mkdir(path.dirname(configPath), { recursive: true });
    +    await fs.writeFile(
    +      configPath,
    +      JSON.stringify({ tools: { sessions: { visibility: "all" } } }, null, 2) + "\n",
    +      "utf-8",
    +    );
    +
         const spy = vi.mocked(agentCommand);
         spy.mockImplementation(async (opts) => {
           const params = opts as {
    
  • src/telegram/monitor.test.ts+23 0 modified
    @@ -219,4 +219,27 @@ describe("monitorTelegramProvider (grammY)", () => {
         );
         expect(runSpy).not.toHaveBeenCalled();
       });
    +
    +  it("falls back to configured webhookSecret when not passed explicitly", async () => {
    +    await monitorTelegramProvider({
    +      token: "tok",
    +      useWebhook: true,
    +      webhookUrl: "https://example.test/telegram",
    +      config: {
    +        agents: { defaults: { maxConcurrent: 2 } },
    +        channels: {
    +          telegram: {
    +            webhookSecret: "secret-from-config",
    +          },
    +        },
    +      },
    +    });
    +
    +    expect(startTelegramWebhookSpy).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        secret: "secret-from-config",
    +      }),
    +    );
    +    expect(runSpy).not.toHaveBeenCalled();
    +  });
     });
    
  • src/telegram/monitor.ts+1 1 modified
    @@ -158,7 +158,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
             config: cfg,
             path: opts.webhookPath,
             port: opts.webhookPort,
    -        secret: opts.webhookSecret,
    +        secret: opts.webhookSecret ?? account.config.webhookSecret,
             host: opts.webhookHost ?? account.config.webhookHost,
             runtime: opts.runtime as RuntimeEnv,
             fetch: proxyFetch,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.