fix(memory): enforce wiki session visibility (#75722)

* fix(memory): enforce wiki session visibility

Co-authored-by: zsx <git@zsxsoft.com>

* 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 <git@zsxsoft.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
This commit is contained in:
Agustin Rivera
2026-05-05 17:09:59 -07:00
committed by GitHub
parent 58c706451e
commit 1daba5240b
5 changed files with 502 additions and 19 deletions

View File

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

View File

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

View File

@@ -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<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
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,

View File

@@ -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<MemorySearchResult[]> {
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<SessionMemoryPathVisibilityChecker> {
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<WikiSearchResult[]> {
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<WikiGetResult | null> {
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,

View File

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