Explorar o código

fix(web): validate search hit provenance (round 2)

Hypatia May hai 2 meses
pai
achega
222096e3cf

+ 2 - 2
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

+ 8 - 4
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,

+ 37 - 0
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['bestMatch']>,
+    ): 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))