Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,38 @@ describe("tailClaudeTaskOutput", () => {
}).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<string> = [];
const stop = yield* Deferred.make<void>();
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -65,6 +67,7 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct
readonly stop: Deferred.Deferred<void>;
readonly onChunk: (chunk: string) => Effect.Effect<void>;
}) {
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),
Expand Down
Loading