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

This commit is contained in:
Peter Steinberger
2026-02-16 03:43:51 +01:00
parent fbe6d7c701
commit c6c53437f7
21 changed files with 796 additions and 22 deletions

View File

@@ -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.

View File

@@ -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"`.

View File

@@ -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

View File

@@ -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",

View File

@@ -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 replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, 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`

View File

@@ -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"]
}

View File

@@ -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" });
});
});

View File

@@ -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,
};

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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))

View File

@@ -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") {

View File

@@ -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({

View File

@@ -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":

View File

@@ -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)",

View File

@@ -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";

View File

@@ -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). */

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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();
});
});

View File

@@ -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,