1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Contained reads of files the Claude harness persisted for a workflow run.
4 *
5 * Containment rules (lifted from the reviewed #3650 inspection service):
6 * - the resolved realpath must live under the configured Claude projects
7 * directory (where the harness persists workflow scripts and transcripts) —
8 * realpath re-containment defeats symlink escapes, including a symlinked
9 * leaf file;
10 * - only the caller's expected extension is served;
11 * - reads are size-capped rather than failed, with a truncation marker.
12 *
13 * The client-supplied path is a hint from the workflow's runHandles; it is
14 * never trusted beyond these checks.
15 */
16import * as NodeFS from "node:fs";
17import * as NodeFSP from "node:fs/promises";
18import * as NodePath from "node:path";
19
20import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23
24function workflowFilesRoot(home: string, configDir?: string): string {
25 return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects");
26}
27
28export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")(
29 function* (input: {
30 readonly path: string;
31 readonly extension: string;
32 readonly byteCap: number;
33 readonly tail?: boolean;
34 readonly configDir?: string;
35 }) {
36 if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) {
37 return yield* new OrchestrationWorkflowFileError({
38 reason: "invalid-path",
39 path: input.path,
40 });
41 }
42
43 const home = yield* HostProcess.HomeDirectory;
44 const root = yield* Effect.tryPromise({
45 try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)),
46 catch: (cause) =>
47 new OrchestrationWorkflowFileError({
48 reason: "root-unavailable",
49 path: input.path,
50 cause,
51 }),
52 });
53
54 // Realpath the FILE itself (not just its directory): a symlink named
55 // like a transcript inside a contained directory must not escape.
56 const resolved = yield* Effect.tryPromise({
57 try: () => NodeFSP.realpath(input.path),
58 catch: (cause) =>
59 new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }),
60 });
61
62 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
63 return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved });
64 }
65 if (NodePath.extname(resolved) !== input.extension) {
66 return yield* new OrchestrationWorkflowFileError({
67 reason: "wrong-extension",
68 path: resolved,
69 });
70 }
71
72 // fd inode vs path inode catches a swapped leaf, not a swapped intermediate
73 // dir; root is 0700 ~/.claude, so that already needs the uid we run as.
74 // Both containment checks fail with their own tagged reason; "read-failed"
75 // is reserved for genuine platform failures with the real cause attached.
76 const read = yield* Effect.tryPromise({
77 try: async () => {
78 const handle = await NodeFSP.open(
79 resolved,
80 NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
81 );
82 try {
83 const stat = await handle.stat();
84 if (!stat.isFile()) {
85 return { failure: "not-regular-file" as const };
86 }
87 const pathStat = await NodeFSP.lstat(resolved);
88 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
89 return { failure: "changed-during-read" as const };
90 }
91 const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0)));
92 const truncated = stat.size > buffer.length;
93 const offset = input.tail ? stat.size - buffer.length : 0;
94 const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
95 return {
96 contents: buffer.subarray(0, bytesRead).toString("utf8"),
97 truncated,
98 };
99 } finally {
100 await handle.close();
101 }
102 },
103 catch: (cause) =>
104 new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }),
105 });
106 if ("failure" in read) {
107 return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved });
108 }
109
110 return { path: resolved, contents: read.contents, truncated: read.truncated };
111 },
112);