From 222096e3cf50ea1fbe182f129caa96e942fa540d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:27:11 +0800 Subject: [PATCH] fix(web): validate search hit provenance (round 2) --- .../lifecycle-chrome/hero.expected.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 12 ++++-- .../apiproxy/tests/api-proxy-search.spec.ts | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index f280e35fc6..39fc49c023 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -8,9 +8,9 @@ - img - button "Create workspace": - img -- button "Search sessions": +- button "搜索会话": - img -- textbox "Search name, keywords..." +- textbox "搜索名称或关键词…" - tree "Sessions": - treeitem "workspace 1 session" [expanded]: - img diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a5055ef1d8..d6456acc67 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -630,10 +630,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro limit: SESSION_SEARCH_LIMIT, }, { signal }) if (isAborted(signal)) return cancelled() - // The id filter is the authorization boundary. Re-check the provider - // projection before emitting it so a backend regression cannot leak - // a session that `session.list` withheld. - const authorized = page.items.filter(hit => visibleIds.has(hit.header.id)) + // The filters are the authorization boundary. Re-check the complete + // provider provenance before emitting its snippet so a backend + // regression cannot pair an allowed header with excluded content. + const authorized = page.items.filter(hit => + visibleIds.has(hit.header.id) + && hit.bestMatch.sessionId === hit.header.id + && hit.bestMatch.surface === 'current' + && MESSAGE_TYPES.has(hit.bestMatch.type)) return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ sessionId: hit.header.id, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 5f8060a696..fcb29fdbcc 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -160,6 +160,43 @@ describe('session.search', () => { expect(searchSessions).not.toHaveBeenCalled() }) + it('rejects snippets whose provider provenance violates the Host filters', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + const withBestMatch = ( + index: number, + bestMatch: Partial, + ): SessionSearchHit => { + const base = hit('visible', index) + return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } } + } + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ + items: [ + withBestMatch(0, { sessionId: sid('hidden') }), + withBestMatch(1, { surface: 'shadowed' }), + withBestMatch(2, { type: 'tool/result' }), + withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }), + ], + nextCursor: 'more', + }), + } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('match'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], + hasMore: true, + }, + }) + }) + it('enforces the 20-item Host boundary even if a provider overproduces', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))