From 1daba5240bb27e61f6bc4ee44287512284f7f181 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Tue, 5 May 2026 17:09:59 -0700 Subject: [PATCH] fix(memory): enforce wiki session visibility (#75722) * fix(memory): enforce wiki session visibility Co-authored-by: zsx * fix(memory): cover wiki visibility follow-ups # Conflicts: # CHANGELOG.md * fix(memory): tighten wiki session visibility reads * docs(changelog): add memory wiki visibility entry --------- Co-authored-by: zsx Co-authored-by: Devin Robison Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + extensions/memory-wiki/index.ts | 2 + extensions/memory-wiki/src/query.test.ts | 296 ++++++++++++++++++++++- extensions/memory-wiki/src/query.ts | 219 ++++++++++++++++- extensions/memory-wiki/src/tool.ts | 3 + 5 files changed, 502 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f0ef67969..a04c0019f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -369,6 +369,7 @@ Docs: https://docs.openclaw.ai - Agents/messaging: surface CLI subprocess watchdog/turn timeout messages to chat users when verbose failures are off, instead of collapsing them into generic external-run failure copy. Fixes #77007. (#77015) Thanks @neeravmakwana. - Agents/sessions: after embedded Pi runs, append assistant-visible reply text to session JSONL only when Pi did not already persist an equivalent tail assistant entry, without re-mirroring the user prompt Pi owns. Fixes #77823. (#77839) Thanks @neeravmakwana. - Plugins/CLI: load the install-records ledger when listing channel-catalog entries, so npm-installed third-party channel plugins resolve through `openclaw channels login`/`channels add` instead of failing with `Unsupported channel`. (#77269) Thanks @pumpkinxing1. +- Memory wiki/Security: enforce session visibility on shared-memory `wiki_search` and `wiki_get` so sandboxed subagents cannot read transcript content from sibling or parent sessions. Fixes GHSA-72fw-cqh5-f324. Thanks @zsxsoft. ## 2026.5.3-1 diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index f347e554cd2..ed1cb7bbe8e 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -33,6 +33,7 @@ export default definePluginEntry({ createWikiSearchTool(config, api.config, { agentId: ctx.agentId, agentSessionKey: ctx.sessionKey, + sandboxed: ctx.sandboxed, }), { name: "wiki_search" }, ); @@ -41,6 +42,7 @@ export default definePluginEntry({ createWikiGetTool(config, api.config, { agentId: ctx.agentId, agentSessionKey: ctx.sessionKey, + sandboxed: ctx.sandboxed, }), { name: "wiki_get" }, ); diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 4968141a704..e6a000c0296 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -6,17 +6,22 @@ import type { OpenClawConfig } from "../api.js"; import { compileMemoryWikiVault } from "./compile.js"; import type { MemoryWikiPluginConfig } from "./config.js"; import { renderWikiMarkdown } from "./markdown.js"; -import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; +import { getMemoryWikiPage, isSessionMemoryPath, searchMemoryWiki } from "./query.js"; import { createMemoryWikiTestHarness } from "./test-helpers.js"; -const { getActiveMemorySearchManagerMock, resolveDefaultAgentIdMock, resolveSessionAgentIdMock } = - vi.hoisted(() => ({ - getActiveMemorySearchManagerMock: vi.fn(), - resolveDefaultAgentIdMock: vi.fn(() => "main"), - resolveSessionAgentIdMock: vi.fn(({ sessionKey }: { sessionKey?: string }) => - sessionKey === "agent:secondary:thread" ? "secondary" : "main", - ), - })); +const { + getActiveMemorySearchManagerMock, + loadCombinedSessionStoreForGatewayMock, + resolveDefaultAgentIdMock, + resolveSessionAgentIdMock, +} = vi.hoisted(() => ({ + getActiveMemorySearchManagerMock: vi.fn(), + loadCombinedSessionStoreForGatewayMock: vi.fn(), + resolveDefaultAgentIdMock: vi.fn(() => "main"), + resolveSessionAgentIdMock: vi.fn(({ sessionKey }: { sessionKey?: string }) => + sessionKey === "agent:secondary:thread" ? "secondary" : "main", + ), +})); vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({ getActiveMemorySearchManager: getActiveMemorySearchManagerMock, @@ -27,6 +32,15 @@ vi.mock("openclaw/plugin-sdk/memory-host-core", () => ({ resolveSessionAgentId: resolveSessionAgentIdMock, })); +vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: loadCombinedSessionStoreForGatewayMock, + }; +}); + const { createVault } = createMemoryWikiTestHarness(); let suiteRoot = ""; let caseIndex = 0; @@ -34,6 +48,8 @@ let caseIndex = 0; beforeEach(() => { getActiveMemorySearchManagerMock.mockReset(); getActiveMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "unavailable" }); + loadCombinedSessionStoreForGatewayMock.mockReset(); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ storePath: "(test)", store: {} }); resolveDefaultAgentIdMock.mockClear(); resolveSessionAgentIdMock.mockClear(); }); @@ -68,6 +84,36 @@ function createAppConfig(): OpenClawConfig { } as OpenClawConfig; } +function createSessionVisibilityAppConfig(): OpenClawConfig { + return { + agents: { + defaults: { sandbox: { sessionToolsVisibility: "all" } }, + list: [{ id: "main", default: true }], + }, + tools: { + sessions: { visibility: "self" }, + }, + } as OpenClawConfig; +} + +function mockSessionTranscriptStore() { + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(test)", + store: { + "agent:main:child-session": { + sessionId: "child-session", + updatedAt: 1, + sessionFile: "/tmp/openclaw/child-session.jsonl", + }, + "agent:main:sibling-session": { + sessionId: "sibling-session", + updatedAt: 2, + sessionFile: "/tmp/openclaw/sibling-session.jsonl", + }, + }, + }); +} + function createMemoryManager(overrides?: { searchResults?: Array<{ path: string; @@ -95,6 +141,29 @@ function createMemoryManager(overrides?: { }; } +describe("isSessionMemoryPath", () => { + it("classifies all current session storage layouts", () => { + for (const relPath of [ + "sessions/child-session.jsonl", + "qmd/sessions/child-session.md", + "qmd/sessions-main/child-session.md", + "qmd\\sessions-main\\child-session.md", + "qmd/sessions", + ]) { + expect(isSessionMemoryPath(relPath)).toBe(true); + } + + for (const relPath of [ + "sessionsx/child-session.jsonl", + "qmd/sessionsxxx", + "wiki/sessions/foo.md", + "wiki\\sessions\\foo.md", + ]) { + expect(isSessionMemoryPath(relPath)).toBe(false); + } + }); +}); + describe("searchMemoryWiki", () => { it("finds wiki pages by title and body", async () => { const { rootDir, config } = await createQueryVault({ @@ -634,6 +703,132 @@ describe("searchMemoryWiki", () => { expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 }); }); + it("filters session memory hits outside the caller visibility policy", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + mockSessionTranscriptStore(); + const manager = createMemoryManager({ + searchResults: [ + { + path: "sessions/child-session.jsonl", + startLine: 1, + endLine: 2, + score: 30, + snippet: "caller transcript", + source: "sessions", + }, + { + path: "qmd/sessions-main/sibling-session.md", + startLine: 3, + endLine: 4, + score: 20, + snippet: "sibling transcript", + source: "sessions", + }, + { + path: "MEMORY.md", + startLine: 5, + endLine: 6, + score: 10, + snippet: "durable memory", + source: "memory", + }, + ], + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const results = await searchMemoryWiki({ + config, + appConfig: createSessionVisibilityAppConfig(), + agentSessionKey: "agent:main:child-session", + sandboxed: true, + query: "transcript", + maxResults: 10, + }); + + expect(results.map((result) => result.path)).toEqual([ + "sessions/child-session.jsonl", + "MEMORY.md", + ]); + expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + }); + + it("filters session memory hits for session-bound non-sandboxed callers", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + mockSessionTranscriptStore(); + const manager = createMemoryManager({ + searchResults: [ + { + path: "sessions/child-session.jsonl", + startLine: 1, + endLine: 2, + score: 30, + snippet: "caller transcript", + source: "sessions", + }, + { + path: "qmd/sessions-main/sibling-session.md", + startLine: 3, + endLine: 4, + score: 20, + snippet: "sibling transcript", + source: "sessions", + }, + { + path: "MEMORY.md", + startLine: 5, + endLine: 6, + score: 10, + snippet: "durable memory", + source: "memory", + }, + ], + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const results = await searchMemoryWiki({ + config, + appConfig: createSessionVisibilityAppConfig(), + agentSessionKey: "agent:main:child-session", + sandboxed: false, + query: "transcript", + maxResults: 10, + }); + + expect(results.map((result) => result.path)).toEqual([ + "sessions/child-session.jsonl", + "MEMORY.md", + ]); + expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + }); + + it("requires appConfig for session-bound shared memory searches", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + + await expect( + searchMemoryWiki({ + config, + agentSessionKey: "agent:main:child-session", + sandboxed: true, + query: "transcript", + }), + ).rejects.toThrow(/wiki_search requires appConfig/); + }); + it("uses the active session agent for shared memory search", async () => { const { config } = await createQueryVault({ initialize: true, @@ -902,6 +1097,89 @@ describe("getMemoryWikiPage", () => { }); }); + it("skips session memory reads outside the caller visibility policy", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + mockSessionTranscriptStore(); + const manager = createMemoryManager({ + readResult: { + path: "qmd/sessions-main/sibling-session.md", + text: "sibling transcript content", + }, + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const result = await getMemoryWikiPage({ + config, + appConfig: createSessionVisibilityAppConfig(), + agentSessionKey: "agent:main:child-session", + sandboxed: true, + lookup: "qmd/sessions-main/sibling-session.md", + }); + + expect(result).toBeNull(); + expect(manager.readFile).not.toHaveBeenCalled(); + }); + + it("permits session memory reads inside the caller visibility policy", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + mockSessionTranscriptStore(); + const manager = createMemoryManager({ + readResult: { + path: "qmd/sessions-main/child-session.md", + text: "own transcript content", + }, + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const result = await getMemoryWikiPage({ + config, + appConfig: createSessionVisibilityAppConfig(), + agentSessionKey: "agent:main:child-session", + sandboxed: true, + lookup: "qmd/sessions-main/child-session.md", + }); + + expect(result).toMatchObject({ + corpus: "memory", + path: "qmd/sessions-main/child-session.md", + content: "own transcript content", + }); + expect(manager.readFile).toHaveBeenCalledTimes(1); + expect(manager.readFile).toHaveBeenCalledWith({ + relPath: "qmd/sessions-main/child-session.md", + from: 1, + lines: 200, + }); + }); + + it("requires appConfig for session-bound shared memory reads", async () => { + const { config } = await createQueryVault({ + initialize: true, + config: { + search: { backend: "shared", corpus: "memory" }, + }, + }); + + await expect( + getMemoryWikiPage({ + config, + agentSessionKey: "agent:main:child-session", + sandboxed: true, + lookup: "sessions/child-session.jsonl", + }), + ).rejects.toThrow(/wiki_get requires appConfig/); + }); + it("uses the active session agent for shared memory reads", async () => { const { config } = await createQueryVault({ initialize: true, diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 0dbd5680714..d8b9bb73638 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -3,6 +3,16 @@ import path from "node:path"; import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-host-files"; import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search"; +import { + extractTranscriptStemFromSessionsMemoryHit, + loadCombinedSessionStoreForGateway, + resolveTranscriptStemToSessionKeys, +} from "openclaw/plugin-sdk/session-transcript-hit"; +import { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, +} from "openclaw/plugin-sdk/session-visibility"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { OpenClawConfig } from "../api.js"; import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js"; @@ -952,6 +962,51 @@ function buildLookupCandidates(lookup: string): string[] { return [...new Set([normalized, withExtension])]; } +function shouldEnforceSessionVisibility(params: { + agentSessionKey?: string; + sandboxed?: boolean; +}): boolean { + return params.sandboxed === true || Boolean(params.agentSessionKey?.trim()); +} + +function shouldSearchSharedMemoryCorpus(config: ResolvedMemoryWikiConfig): boolean { + return config.search.corpus === "memory" || config.search.corpus === "all"; +} + +function shouldUseSharedMemory(config: ResolvedMemoryWikiConfig): boolean { + return config.search.backend === "shared" && shouldSearchSharedMemoryCorpus(config); +} + +function assertSessionVisibilityAppConfig(params: { + config: ResolvedMemoryWikiConfig; + appConfig?: OpenClawConfig; + agentSessionKey?: string; + sandboxed?: boolean; + operation: string; +}): void { + if ( + shouldUseSharedMemory(params.config) && + shouldEnforceSessionVisibility(params) && + !params.appConfig + ) { + throw new Error( + `${params.operation} requires appConfig to enforce session visibility for session-bound shared memory calls.`, + ); + } +} + +const SESSION_MEMORY_PATH_PREFIXES = ["sessions/", "qmd/sessions/", "qmd/sessions-"] as const; +const SESSION_MEMORY_ROOT_PATHS = ["qmd/sessions"] as const; + +// Keep these path shapes aligned with source: "sessions" hits in session-search-visibility and session-transcript-hit. +export function isSessionMemoryPath(relPath: string): boolean { + const normalized = relPath.replace(/\\/g, "/"); + return ( + SESSION_MEMORY_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)) || + SESSION_MEMORY_ROOT_PATHS.some((rootPath) => normalized === rootPath) + ); +} + function shouldSearchWiki(config: ResolvedMemoryWikiConfig): boolean { return config.search.corpus === "wiki" || config.search.corpus === "all"; } @@ -960,11 +1015,7 @@ function shouldSearchSharedMemory( config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig, ): boolean { - return ( - config.search.backend === "shared" && - appConfig !== undefined && - (config.search.corpus === "memory" || config.search.corpus === "all") - ); + return shouldUseSharedMemory(config) && appConfig !== undefined; } function resolveActiveMemoryAgentId(params: { @@ -1152,6 +1203,104 @@ function toMemoryWikiSearchResult( }; } +async function filterMemoryWikiSearchHitsBySessionVisibility(params: { + cfg: OpenClawConfig; + requesterSessionKey: string | undefined; + sandboxed: boolean; + hits: MemorySearchResult[]; +}): Promise { + if (!params.hits.some((hit) => hit.source === "sessions")) { + return params.hits; + } + + const canReadSessionPath = await createSessionMemoryPathVisibilityChecker({ + cfg: params.cfg, + requesterSessionKey: params.requesterSessionKey, + sandboxed: params.sandboxed, + }); + return filterMemoryWikiSearchHitsWithSessionVisibility({ + canReadSessionPath, + hits: params.hits, + }); +} + +type SessionMemoryPathVisibilityChecker = (relPath: string) => boolean; + +async function createSessionMemoryPathVisibilityChecker(params: { + cfg: OpenClawConfig; + requesterSessionKey: string | undefined; + sandboxed: boolean; +}): Promise { + const visibility = resolveEffectiveSessionToolsVisibility({ + cfg: params.cfg, + sandboxed: params.sandboxed, + }); + const a2aPolicy = createAgentToAgentPolicy(params.cfg); + const guard = params.requesterSessionKey + ? await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: params.requesterSessionKey, + visibility, + a2aPolicy, + }) + : null; + if (!guard) { + return () => false; + } + + const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg); + return (relPath) => { + const stem = extractTranscriptStemFromSessionsMemoryHit(relPath); + if (!stem) { + return false; + } + const keys = resolveTranscriptStemToSessionKeys({ + store: combinedSessionStore, + stem, + }); + return keys.some((key) => guard.check(key).allowed); + }; +} + +function filterMemoryWikiSearchHitsWithSessionVisibility(params: { + canReadSessionPath: SessionMemoryPathVisibilityChecker; + hits: MemorySearchResult[]; +}): MemorySearchResult[] { + const next: MemorySearchResult[] = []; + for (const hit of params.hits) { + if (hit.source !== "sessions") { + next.push(hit); + continue; + } + + if (params.canReadSessionPath(hit.path)) { + next.push(hit); + } + } + return next; +} + +function canReadSessionMemoryPath(params: { + canReadSessionPath: SessionMemoryPathVisibilityChecker; + relPath: string; +}): boolean { + // Reuses the search filter with a synthetic hit; update this if the filter needs more than path/source. + const filtered = filterMemoryWikiSearchHitsWithSessionVisibility({ + canReadSessionPath: params.canReadSessionPath, + hits: [ + { + path: params.relPath, + startLine: 1, + endLine: 1, + score: 0, + snippet: "", + source: "sessions", + }, + ], + }); + return filtered.length > 0; +} + async function searchWikiCorpus(params: { rootDir: string; query: string; @@ -1223,6 +1372,7 @@ export async function searchMemoryWiki(params: { appConfig?: OpenClawConfig; agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; query: string; maxResults?: number; searchBackend?: WikiSearchBackend; @@ -1230,6 +1380,13 @@ export async function searchMemoryWiki(params: { mode?: WikiSearchMode; }): Promise { const effectiveConfig = applySearchOverrides(params.config, params); + assertSessionVisibilityAppConfig({ + config: effectiveConfig, + appConfig: params.appConfig, + agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, + operation: "wiki_search", + }); await initializeMemoryWikiVault(effectiveConfig); const maxResults = Math.max(1, params.maxResults ?? 10); const mode = params.mode ?? "auto"; @@ -1250,11 +1407,22 @@ export async function searchMemoryWiki(params: { agentSessionKey: params.agentSessionKey, }) : null; - const memoryResults = sharedMemoryManager - ? (await sharedMemoryManager.search(params.query, { maxResults })).map((result) => - toMemoryWikiSearchResult(result, mode), - ) + let rawMemoryResults = sharedMemoryManager + ? await sharedMemoryManager.search(params.query, { maxResults }) : []; + if ( + params.appConfig && + shouldEnforceSessionVisibility(params) && + rawMemoryResults.some((hit) => hit.source === "sessions") + ) { + rawMemoryResults = await filterMemoryWikiSearchHitsBySessionVisibility({ + cfg: params.appConfig, + requesterSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed === true, + hits: rawMemoryResults, + }); + } + const memoryResults = rawMemoryResults.map((result) => toMemoryWikiSearchResult(result, mode)); return mergeWikiSearchCorpusResults({ wikiResults, @@ -1269,6 +1437,7 @@ export async function getMemoryWikiPage(params: { appConfig?: OpenClawConfig; agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; lookup: string; fromLine?: number; lineCount?: number; @@ -1276,6 +1445,13 @@ export async function getMemoryWikiPage(params: { searchCorpus?: WikiSearchCorpus; }): Promise { const effectiveConfig = applySearchOverrides(params.config, params); + assertSessionVisibilityAppConfig({ + config: effectiveConfig, + appConfig: params.appConfig, + agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, + operation: "wiki_get", + }); await initializeMemoryWikiVault(effectiveConfig); const fromLine = Math.max(1, params.fromLine ?? 1); const lineCount = Math.max(1, params.lineCount ?? 200); @@ -1327,7 +1503,30 @@ export async function getMemoryWikiPage(params: { return null; } - for (const relPath of buildLookupCandidates(params.lookup)) { + const lookupCandidates = buildLookupCandidates(params.lookup); + const canReadSessionPath = + params.appConfig && + shouldEnforceSessionVisibility(params) && + lookupCandidates.some((relPath) => isSessionMemoryPath(relPath)) + ? await createSessionMemoryPathVisibilityChecker({ + cfg: params.appConfig, + requesterSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed === true, + }) + : null; + + for (const relPath of lookupCandidates) { + if ( + canReadSessionPath && + isSessionMemoryPath(relPath) && + !canReadSessionMemoryPath({ + canReadSessionPath, + relPath, + }) + ) { + continue; + } + try { const result = await manager.readFile({ relPath, diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index f96ac7b4ad8..65d9647205a 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -89,6 +89,7 @@ async function syncImportedSourcesIfNeeded( type WikiToolMemoryContext = { agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }; export function createWikiStatusTool( @@ -139,6 +140,7 @@ export function createWikiSearchTool( appConfig, agentId: memoryContext.agentId, agentSessionKey: memoryContext.agentSessionKey, + sandboxed: memoryContext.sandboxed, query: params.query, maxResults: params.maxResults, ...(params.backend ? { searchBackend: params.backend } : {}), @@ -255,6 +257,7 @@ export function createWikiGetTool( appConfig, agentId: memoryContext.agentId, agentSessionKey: memoryContext.agentSessionKey, + sandboxed: memoryContext.sandboxed, lookup: params.lookup, fromLine: params.fromLine, lineCount: params.lineCount,