From a2c26a6d7ae6e0d127181bf8e4b09b7107860803 Mon Sep 17 00:00:00 2001 From: Alexander Kirilin Date: Tue, 21 Jul 2026 12:03:05 -0400 Subject: [PATCH] fix(host): contain discovered session transcripts --- packages/host-service/src/discovery.ts | 28 +++++++++++++++++--- packages/host-service/test/discovery.test.ts | 28 +++++++++----------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/host-service/src/discovery.ts b/packages/host-service/src/discovery.ts index 2ef456a8..a39abea9 100644 --- a/packages/host-service/src/discovery.ts +++ b/packages/host-service/src/discovery.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { lstatSync, readdirSync, realpathSync } from "node:fs"; -import { chmod, mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { chmod, lstat, mkdir, open, readdir, readFile, realpath, stat, unlink } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; import type { ArtifactDescriptor, DurableEntry, @@ -49,6 +49,8 @@ type DiscoveryFileSystem = FileSystem & { maxBytes: number, expectedIdentity?: string, ) => Promise; + realpath?: (path: string) => Promise; + isSymbolicLink?: (path: string) => Promise; }; const OBSERVER_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const OBSERVER_TAIL_ANCHOR_BYTES = 4 * 1024; @@ -66,6 +68,8 @@ const realFs: DiscoveryFileSystem = { }, stat: async path => await stat(path), readdir: async path => (await readdir(path, { withFileTypes: true })).map(entry => join(path, entry.name)), + realpath: async path => await realpath(path), + isSymbolicLink: async path => (await lstat(path)).isSymbolicLink(), readFile: async path => await readFile(path), readFileSlice: async (path, maxBytes) => { const handle = await open(path, "r"); @@ -1449,6 +1453,7 @@ function isEncodedProjectDirectory(path: string): boolean { export class FileSessionDiscovery implements SessionDiscovery { private readonly index = new Map(); private rootMisses = 0; + private canonicalRoot?: string; readonly page?: SessionDiscovery["page"]; constructor( @@ -1467,6 +1472,18 @@ export class FileSessionDiscovery implements SessionDiscovery { this.page = (session, args) => pageReader.page(session, args); } } + private async rootPath(): Promise { + if (this.canonicalRoot !== undefined) return this.canonicalRoot; + this.canonicalRoot = this.fs.realpath ? await this.fs.realpath(this.root) : resolve(this.root); + return this.canonicalRoot; + } + private async containedPath(path: string): Promise { + const canonical = this.fs.realpath ? await this.fs.realpath(path) : resolve(path); + const fromRoot = relative(await this.rootPath(), canonical); + return fromRoot === "" || (!fromRoot.startsWith("..") && !isAbsolute(fromRoot)) + ? canonical + : null; + } private async parse(path: string, size: number, metadataOnly: boolean): Promise { if (size > MAX_TRANSCRIPT_BYTES) { const readSlice = this.fs.readFileSlice; @@ -1508,6 +1525,7 @@ export class FileSessionDiscovery implements SessionDiscovery { const children = await this.fs.readdir(path); const output: string[] = []; for (const child of children.sort()) { + if (this.fs.isSymbolicLink && (await this.fs.isSymbolicLink(child).catch(() => true))) continue; const info = await this.fs.stat(child).catch(() => null); if (!info) continue; if (info.isFile() && child.endsWith(".jsonl")) { @@ -1556,9 +1574,11 @@ export class FileSessionDiscovery implements SessionDiscovery { for (const path of files) { let identity: string; try { - identity = realpathSync.native(path); + const contained = await this.containedPath(path); + if (contained === null) continue; + identity = contained; } catch { - identity = resolve(path); + continue; } if (seen.has(identity)) continue; seen.add(identity); diff --git a/packages/host-service/test/discovery.test.ts b/packages/host-service/test/discovery.test.ts index a787e08f..8da87b42 100644 --- a/packages/host-service/test/discovery.test.ts +++ b/packages/host-service/test/discovery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { decodeServerFrame, entryId, hostId, parseBounded, projectId, sessionId } from "@t4-code/host-wire"; @@ -1167,11 +1167,9 @@ test("incrementally indexes a large corpus and evicts only deleted or changed fi expect(reads).toBe(0); }); -test("rebinds a canonical cached transcript when its live symlink alias changes", async () => { - const root = await mkdtemp(join(tmpdir(), "omp-discovery-alias-")); - const target = join(root, "session-target.jsonl"); - const firstAlias = join(root, "alias-a.jsonl"); - const secondAlias = join(root, "alias-b.jsonl"); +test("rejects transcript symlinks even when their targets are readable", async () => { + const root = await mkdtemp(join(tmpdir(), "omp-discovery-root-")); + const outside = await mkdtemp(join(tmpdir(), "omp-discovery-outside-")); const contents = JSON.stringify({ type: "session", version: 3, @@ -1180,18 +1178,18 @@ test("rebinds a canonical cached transcript when its live symlink alias changes" timestamp: stamp, }); try { - await writeFile(target, contents); - await symlink(target, firstAlias); + const internalTarget = join(root, "session-target.data"); + const externalTarget = join(outside, "session-target.jsonl"); + await writeFile(internalTarget, contents); + await writeFile(externalTarget, contents); + await symlink(internalTarget, join(root, "internal-alias.jsonl")); + await symlink(externalTarget, join(root, "external-alias.jsonl")); + const discovery = new FileSessionDiscovery(root, undefined, host); - const first = await discovery.list(); - expect(first[0]?.path).toBe(firstAlias); - await rm(firstAlias); - await symlink(target, secondAlias); - const warmAlias = await discovery.list(); - expect(warmAlias[0]?.path).toBe(secondAlias); - await expect(stat(warmAlias[0]!.path)).resolves.toBeDefined(); + await expect(discovery.list()).resolves.toEqual([]); } finally { await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); } });