From c6c53437f7da033b94a01d492e904974e7bda74c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:43:51 +0100 Subject: [PATCH] fix(security): scope session tools and webhook secret fallback --- CHANGELOG.md | 1 + docs/concepts/session-tool.md | 22 +- docs/gateway/configuration-reference.md | 25 ++ docs/gateway/security/index.md | 4 + docs/tools/index.md | 2 + docs/tools/multi-agent-sandbox-tools.md | 1 + ...claw-tools.sessions-visibility.e2e.test.ts | 125 ++++++ .../openclaw-tools.sessions.e2e.test.ts | 4 + ...rmalizes-allowlisted-agent-ids.e2e.test.ts | 377 ++++++++++++++++++ src/agents/tools/sessions-helpers.ts | 56 +++ src/agents/tools/sessions-history-tool.ts | 45 ++- src/agents/tools/sessions-list-tool.ts | 31 +- src/agents/tools/sessions-send-tool.ts | 57 ++- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.agent-defaults.ts | 2 +- src/config/types.tools.ts | 17 + src/config/zod-schema.agent-runtime.ts | 6 + src/gateway/server.sessions-send.e2e.test.ts | 15 +- src/telegram/monitor.test.ts | 23 ++ src/telegram/monitor.ts | 2 +- 21 files changed, 796 insertions(+), 22 deletions(-) create mode 100644 src/agents/openclaw-tools.sessions-visibility.e2e.test.ts create mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a0b10914..695961abe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 945f3883f6..1dc5fb8cca 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -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"`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index d94551ca81..c7f3d29f21 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index b0ea264c4a..9f7639a6f0 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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", diff --git a/docs/tools/index.md b/docs/tools/index.md index f1496a5982..71c210bfbb 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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` diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index e7de9caf8d..dc49d94a29 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -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"] } diff --git a/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts new file mode 100644 index 0000000000..6f4cfdd03b --- /dev/null +++ b/src/agents/openclaw-tools.sessions-visibility.e2e.test.ts @@ -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 = { + session: { mainKey: "main", scope: "per-sender" }, +}; +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + 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 }; + 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 }; + 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 }; + 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" }); + }); +}); diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index b9a9c56dd2..14e0ffc1e9 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -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, }; diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts new file mode 100644 index 0000000000..dff8556269 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts @@ -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(); + 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(); + 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"); + }); +}); diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 1b399de5a8..15df1df8b7 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -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> { + 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; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index a2b9741d63..fa9d8eac6f 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -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)) diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index abbb6b4958..ba05a93a59 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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") { diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index e871847fb6..dd8ea3f762 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -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({ diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 2305da6f9e..b246c1ea6d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -71,6 +71,8 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ed966d28c2..e7fc90854c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -74,6 +74,7 @@ export const FIELD_LABELS: Record = { "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)", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 851965e825..2774105fb2 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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"; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index e6fa1eec10..4f02166d7a 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -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). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 1d2702acd8..52f768ff24 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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(), diff --git a/src/gateway/server.sessions-send.e2e.test.ts b/src/gateway/server.sessions-send.e2e.test.ts index af3b14361d..dd72f28995 100644 --- a/src/gateway/server.sessions-send.e2e.test.ts +++ b/src/gateway/server.sessions-send.e2e.test.ts @@ -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>; @@ -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 { diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index bc36774d3d..59fe773109 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -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(); + }); }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index f5f015d024..d243ca37fa 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -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,