diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 51c9a7848f100..e110b53fc562c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -127,6 +127,38 @@ describe("tailClaudeTaskOutput", () => { assert.deepEqual(chunks, ["one\n", "two\n"]); }).pipe(Effect.provide(NodeServices.layer)), ); + + 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; + 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); + 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)), + ); }); describe("claudeTaskOutputRoot", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index 7eac250d7afde..55db935792b88 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( @@ -65,6 +67,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),