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

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