From 1318cdac8029435b9f7d990a5666325ffa72c5a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:10:40 -0700 Subject: [PATCH] fix(ai-vault): reject invalid Cursor sidecar UTF-8 --- src/relay/cursor-sidecar-scan.test.ts | 24 +++++++++++++++++++ src/shared/node-verified-bounded-text-file.ts | 8 ++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/relay/cursor-sidecar-scan.test.ts b/src/relay/cursor-sidecar-scan.test.ts index 6de8ebeef8c..16ac0e5d0a2 100644 --- a/src/relay/cursor-sidecar-scan.test.ts +++ b/src/relay/cursor-sidecar-scan.test.ts @@ -289,6 +289,30 @@ describe('scanCursorSidecars', () => { ) }) + it('isolates invalid UTF-8 metadata without rejecting other sidecars', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const bucket = 'abababababababababababababababab' + const validContent = JSON.stringify({ createdAtMs: 10, title: 'valid' }) + await Promise.all([ + addSession(chatsRoot, bucket, 'aaa-invalid'), + addSession(chatsRoot, bucket, 'zzz-valid', validContent) + ]) + const invalidPath = join(chatsRoot, bucket, 'aaa-invalid', 'meta.json') + await writeFile(invalidPath, Buffer.alloc(100_000, 0x80)) + + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), + context + ) + + expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['zzz-valid']) + expect(result.counters.returnedBytes).toBe(Buffer.byteLength(validContent)) + expect(result.issues).toContainEqual( + expect.objectContaining({ path: invalidPath, message: 'invalid_utf8' }) + ) + }) + it('normalizes scope truncation deterministically inside the owning-host scan', async () => { const root = await createRoot() const chatsRoot = join(root, 'chats') diff --git a/src/shared/node-verified-bounded-text-file.ts b/src/shared/node-verified-bounded-text-file.ts index d24b1bbfd34..1e2252f44bf 100644 --- a/src/shared/node-verified-bounded-text-file.ts +++ b/src/shared/node-verified-bounded-text-file.ts @@ -5,6 +5,7 @@ import type { FileHandle } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, sep } from 'node:path' const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 +const STRICT_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) export type VerifiedBoundedTextFileOptions = { maxBytes: number @@ -28,7 +29,12 @@ export async function readVerifiedBoundedTextFile( if (!opened.isFile() || !sameFileIdentity(beforeOpen, opened)) { throw new Error('verified_file_changed') } - return (await readBoundedFileHandle(handle, maxBytes)).toString('utf8') + const bytes = await readBoundedFileHandle(handle, maxBytes) + try { + return STRICT_UTF8_DECODER.decode(bytes) + } catch { + throw new Error('invalid_utf8') + } } finally { await handle.close() }