import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway as defaultCallGateway } from "../gateway/call.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; type GatewayCaller = typeof defaultCallGateway; let callGatewayForListSpawned: GatewayCaller = defaultCallGateway; /** Test hook: must stay aligned with `sessions-resolution` `testing.setDepsForTest`. */ export const sessionVisibilityGatewayTesting = { setCallGatewayForListSpawned(overrides?: GatewayCaller) { callGatewayForListSpawned = overrides ?? defaultCallGateway; }, }; export type SessionToolsVisibility = "self" | "tree" | "agent" | "all"; export type AgentToAgentPolicy = { enabled: boolean; matchesAllow: (agentId: string) => boolean; isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; }; export type SessionAccessAction = "history" | "send" | "list" | "status"; export type SessionAccessResult = | { allowed: true } | { allowed: false; error: string; status: "forbidden" }; export type SessionVisibilityRow = { key: string; agentId?: string; ownerSessionKey?: string; spawnedBy?: string; parentSessionKey?: string; }; 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)) : undefined; try { const list = await callGatewayForListSpawned<{ sessions: Array<{ key?: unknown }> }>({ method: "sessions.list", params: { includeGlobal: false, includeUnknown: false, ...(limit !== undefined ? { limit } : {}), spawnedBy: params.requesterSessionKey, }, }); const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const keys = sessions.map((entry) => normalizeOptionalString(entry?.key) ?? "").filter(Boolean); return new Set(keys); } catch { return new Set(); } } export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility { const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions ?.visibility; const value = normalizeLowercaseStringOrEmpty(raw); 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 function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; } export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy { const routingA2A = cfg.tools?.agentToAgent; const enabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; const matchesAllow = (agentId: string) => { if (allowPatterns.length === 0) { return true; } return allowPatterns.some((pattern) => { const raw = normalizeOptionalString(typeof pattern === "string" ? pattern : String(pattern ?? "")) ?? ""; if (!raw) { return false; } if (raw === "*") { return true; } if (!raw.includes("*")) { return raw === agentId; } const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); return re.test(agentId); }); }; const isAllowed = (requesterAgentId: string, targetAgentId: string) => { if (requesterAgentId === targetAgentId) { return true; } if (!enabled) { return false; } return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); }; return { enabled, matchesAllow, isAllowed }; } function actionPrefix(action: SessionAccessAction): string { if (action === "history") { return "Session history"; } if (action === "send") { return "Session send"; } if (action === "status") { return "Session status"; } return "Session list"; } function a2aDisabledMessage(action: SessionAccessAction): string { if (action === "history") { return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; } if (action === "send") { return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends."; } if (action === "status") { return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; } return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility."; } function a2aDeniedMessage(action: SessionAccessAction): string { if (action === "history") { return "Agent-to-agent history denied by tools.agentToAgent.allow."; } if (action === "send") { return "Agent-to-agent messaging denied by tools.agentToAgent.allow."; } if (action === "status") { return "Agent-to-agent status denied by tools.agentToAgent.allow."; } return "Agent-to-agent listing denied by tools.agentToAgent.allow."; } function crossVisibilityMessage(action: SessionAccessAction): string { if (action === "history") { return "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; } if (action === "send") { return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; } if (action === "status") { return "Session status visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; } return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; } function selfVisibilityMessage(action: SessionAccessAction): string { return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`; } function treeVisibilityMessage(action: SessionAccessAction): string { return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; } export function createSessionVisibilityChecker(params: { action: SessionAccessAction; requesterSessionKey: string; visibility: SessionToolsVisibility; a2aPolicy: AgentToAgentPolicy; spawnedKeys: Set | null; }): { check: (targetSessionKey: string) => SessionAccessResult } { const spawnedKeys = params.spawnedKeys; const rowChecker = createSessionVisibilityRowChecker({ action: params.action, requesterSessionKey: params.requesterSessionKey, visibility: params.visibility, a2aPolicy: params.a2aPolicy, }); const check = (targetSessionKey: string): SessionAccessResult => { const isSpawnedSession = spawnedKeys?.has(targetSessionKey) === true; return rowChecker.check({ key: targetSessionKey, spawnedBy: isSpawnedSession ? params.requesterSessionKey : undefined, }); }; return { check }; } function rowOwnedByRequester(row: SessionVisibilityRow, requesterSessionKey: string): boolean { return ( row.ownerSessionKey === requesterSessionKey || row.spawnedBy === requesterSessionKey || row.parentSessionKey === requesterSessionKey ); } export function createSessionVisibilityRowChecker(params: { action: SessionAccessAction; requesterSessionKey: string; visibility: SessionToolsVisibility; a2aPolicy: AgentToAgentPolicy; }): { check: (row: SessionVisibilityRow) => SessionAccessResult } { const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey); const check = (row: SessionVisibilityRow): SessionAccessResult => { const targetSessionKey = row.key; const targetAgentId = row.agentId ?? resolveAgentIdFromSessionKey(targetSessionKey); const isRequesterSession = targetSessionKey === params.requesterSessionKey || targetSessionKey === "current"; const isRequesterOwned = rowOwnedByRequester(row, params.requesterSessionKey); // Row ownership is stronger than agent ids: ACP children may use a backend // agent id while still belonging to the requester that spawned them. if ( !isRequesterSession && isRequesterOwned && (params.visibility === "tree" || params.visibility === "all") ) { return { allowed: true }; } const isCrossAgent = targetAgentId !== requesterAgentId; if (isCrossAgent) { if (params.visibility !== "all") { return { allowed: false, status: "forbidden", error: crossVisibilityMessage(params.action), }; } if (!params.a2aPolicy.enabled) { return { allowed: false, status: "forbidden", error: a2aDisabledMessage(params.action), }; } if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { return { allowed: false, status: "forbidden", error: a2aDeniedMessage(params.action), }; } return { allowed: true }; } if (params.visibility === "self" && !isRequesterSession) { return { allowed: false, status: "forbidden", error: selfVisibilityMessage(params.action), }; } if (params.visibility === "tree" && !isRequesterSession && !isRequesterOwned) { return { allowed: false, status: "forbidden", error: treeVisibilityMessage(params.action), }; } return { allowed: true }; }; return { check }; } export async function createSessionVisibilityGuard(params: { action: SessionAccessAction; requesterSessionKey: string; visibility: SessionToolsVisibility; a2aPolicy: AgentToAgentPolicy; }): Promise<{ check: (targetSessionKey: string) => SessionAccessResult; }> { // Listing already has row ownership metadata; direct key actions still need // this lookup until every caller can pass a normalized session row. const spawnedKeys = params.action !== "list" && (params.visibility === "tree" || params.visibility === "all") ? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey }) : null; return createSessionVisibilityChecker({ action: params.action, requesterSessionKey: params.requesterSessionKey, visibility: params.visibility, a2aPolicy: params.a2aPolicy, spawnedKeys, }); }