| 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 | */ |
| 16 | import * as NodeFS from "node:fs"; |
| 17 | import * as NodeFSP from "node:fs/promises"; |
| 18 | import * as NodePath from "node:path"; |
| 19 | |
| 20 | import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | |
| 24 | function workflowFilesRoot(home: string, configDir?: string): string { |
| 25 | return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects"); |
| 26 | } |
| 27 | |
| 28 | export 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 | ); |