From bb84a796c00b7efd9d301229453e903a06ea78bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 13:58:52 +0000 Subject: [PATCH 1/4] test(server): reproduce #26489 Claude task output tail following traversing session ids Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeTaskOutputTail.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 87992bab89169..1e5332f2e8e48 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -102,4 +102,32 @@ describe("tailClaudeTaskOutput", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it.live("reads nothing when the session id leaves the Claude task directory", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "claude-task-output-" }); + const root = path.join(base, "claude-root"); + yield* fileSystem.makeDirectory(path.join(root, "-private-tmp-project"), { + recursive: true, + }); + const outside = path.join(base, "outside", "tasks"); + yield* fileSystem.makeDirectory(outside, { recursive: true }); + yield* fileSystem.writeFileString(path.join(outside, `${TASK_ID}.output`), "secret\n"); + const chunks: Array = []; + const stop = yield* Deferred.make(); + yield* Deferred.succeed(stop, undefined); + yield* tailClaudeTaskOutput({ + root, + sessionId: "../../outside", + taskId: TASK_ID, + stop, + onChunk: (chunk) => Effect.sync(() => chunks.push(chunk)), + }); + assert.deepEqual(chunks, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); }); From 1f68440d375dd486809c5e355b193bace43a92df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 14:00:43 +0000 Subject: [PATCH 2/4] fix(server): Claude task output tail ignores ids that are not a single path segment The tail joined the SDK's task_started session_id and task_id into a filesystem path unchecked, so an id containing `..` could stream a file outside the Claude task directory as command output. Skip tailing unless both ids match `^[\w-]+$`. Fixes pingdotgg/t3code#26489 Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index ff096cdad2913..ec9b72b3be7a7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -12,6 +12,8 @@ const CLAUDE_TASK_OUTPUT_POLL_MS = 250; const MAX_LOCATE_ATTEMPTS = 40; /** Skip ahead of fast writers; only the tail is displayed. */ const MAX_READ_BYTES = 256 * 1024; +/** Ids come from the SDK stream and are joined into a path; one plain segment keeps them inside root. */ +const SAFE_PATH_SEGMENT = /^[\w-]+$/; /** Claude Code's per-user temp root, or none where it has no uid (Windows). */ export function claudeTaskOutputRoot( @@ -55,6 +57,7 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct readonly stop: Deferred.Deferred; readonly onChunk: (chunk: string) => Effect.Effect; }) { + if (!SAFE_PATH_SEGMENT.test(input.sessionId) || !SAFE_PATH_SEGMENT.test(input.taskId)) return; const fileSystem = yield* FileSystem.FileSystem; const pause = Deferred.await(input.stop).pipe( Effect.as(true), From 2dbfb2a14fc3ccebb3dba16d7ed56516de689c98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:34:06 +0000 Subject: [PATCH 3/4] test(server): run the session traversal case on the test clock AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 9020c71c128c4..a0a69d2981a5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -129,7 +129,7 @@ describe("tailClaudeTaskOutput", () => { ), ); - it.live("reads nothing when the session id leaves the Claude task directory", () => + it.effect("reads nothing when the session id leaves the Claude task directory", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; From e0db6594b9081ba1718b62cf65312fba7cedf614 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:06:04 +0000 Subject: [PATCH 4/4] test(server): cover task ids that leave the Claude task directory AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeTaskOutputTail.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index a0a69d2981a5b..f6784b462641b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -129,7 +129,7 @@ describe("tailClaudeTaskOutput", () => { ), ); - it.effect("reads nothing when the session id leaves the Claude task directory", () => + it.effect("reads nothing when a session or task id leaves the Claude task directory", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -145,13 +145,17 @@ describe("tailClaudeTaskOutput", () => { const chunks: Array = []; const stop = yield* Deferred.make(); yield* Deferred.succeed(stop, undefined); - yield* tailClaudeTaskOutput({ - root, - sessionId: "../../outside", - taskId: TASK_ID, - stop, - onChunk: (chunk) => Effect.sync(() => chunks.push(chunk)), - }); + for (const ids of [ + { sessionId: "../../outside", taskId: TASK_ID }, + { sessionId: SESSION_ID, taskId: `../../../../outside/tasks/${TASK_ID}` }, + ]) { + yield* tailClaudeTaskOutput({ + root, + ...ids, + stop, + onChunk: (chunk) => Effect.sync(() => chunks.push(chunk)), + }); + } assert.deepEqual(chunks, []); }), ).pipe(Effect.provide(NodeServices.layer)),