Showing the changes to apps/server/src/orchestration/workflowScriptQuery.ts (1 of 46 files). Show all
Unified · Split
| @@ -0,0 +1,124 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | ||
| 3 | * Read-only access to persisted workflow scripts for the Agents surface's | ||
| 4 | * "{} script" affordance. | ||
| 5 | * | ||
| 6 | * Containment rules (lifted from the reviewed #3650 inspection service): | ||
| 7 | * - the resolved realpath must live under ~/.claude/projects (where the | ||
| 8 | * Claude harness persists workflow scripts) — realpath re-containment | ||
| 9 | * defeats symlink escapes, including a symlinked leaf file; | ||
| 10 | * - only .js leaf files are 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 NodeFSP from "node:fs/promises"; | ||
| 17 | import * as NodeOS from "node:os"; | ||
| 18 | import * as NodePath from "node:path"; | ||
| 19 | |||
| 20 | import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | |||
| 23 | const SCRIPT_BYTE_CAP = 256 * 1024; | ||
| 24 | |||
| 25 | function scriptsRoot(): string { | ||
| 26 | return NodePath.join(NodeOS.homedir(), ".claude", "projects"); | ||
| 27 | } | ||
| 28 | |||
| 29 | export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { | ||
| 30 | readonly scriptPath: string; | ||
| 31 | }) { | ||
| 32 | const requested = input.scriptPath; | ||
| 33 | |||
| 34 | if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { | ||
| 35 | return yield* Effect.fail( | ||
| 36 | new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | const root = yield* Effect.tryPromise({ | ||
| 41 | try: () => NodeFSP.realpath(scriptsRoot()), | ||
| 42 | catch: (cause) => | ||
| 43 | new OrchestrationGetWorkflowScriptError({ | ||
| 44 | reason: "root-unavailable", | ||
| 45 | scriptPath: requested, | ||
| 46 | cause, | ||
| 47 | }), | ||
| 48 | }); | ||
| 49 | |||
| 50 | // Realpath the FILE itself (not just its directory): a symlink named | ||
| 51 | // like a script inside a contained directory must not escape. | ||
| 52 | const resolved = yield* Effect.tryPromise({ | ||
| 53 | try: () => NodeFSP.realpath(requested), | ||
| 54 | catch: (cause) => | ||
| 55 | new OrchestrationGetWorkflowScriptError({ | ||
| 56 | reason: "not-found", | ||
| 57 | scriptPath: requested, | ||
| 58 | cause, | ||
| 59 | }), | ||
| 60 | }); | ||
| 61 | |||
| 62 | if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { | ||
| 63 | return yield* Effect.fail( | ||
| 64 | new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | if (NodePath.extname(resolved) !== ".js") { | ||
| 68 | return yield* Effect.fail( | ||
| 69 | new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | // TOCTOU-safe read (review finding): open FIRST, then verify what was | ||
| 74 | // actually opened via the file descriptor. Re-checking the path after | ||
| 75 | // open would race against a swap; fstat on the handle cannot. The two | ||
| 76 | // containment checks fail with their own tagged reasons (not manufactured | ||
| 77 | // Errors folded into read-failed); "read-failed" is reserved for genuine | ||
| 78 | // platform failures with the real cause attached. | ||
| 79 | const read = yield* Effect.tryPromise({ | ||
| 80 | try: async () => { | ||
| 81 | const handle = await NodeFSP.open(resolved, "r"); | ||
| 82 | try { | ||
| 83 | const stat = await handle.stat(); | ||
| 84 | if (!stat.isFile()) { | ||
| 85 | return { failure: "not-regular-file" as const }; | ||
| 86 | } | ||
| 87 | // The opened inode must be the same one realpath resolved to: a | ||
| 88 | // process swapping the path between realpath and open changes the | ||
| 89 | // inode, which this comparison catches. | ||
| 90 | const pathStat = await NodeFSP.lstat(resolved); | ||
| 91 | if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { | ||
| 92 | return { failure: "changed-during-read" as const }; | ||
| 93 | } | ||
| 94 | const truncated = stat.size > SCRIPT_BYTE_CAP; | ||
| 95 | const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); | ||
| 96 | const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); | ||
| 97 | return { | ||
| 98 | contents: buffer.subarray(0, bytesRead).toString("utf8"), | ||
| 99 | truncated, | ||
| 100 | }; | ||
| 101 | } finally { | ||
| 102 | await handle.close(); | ||
| 103 | } | ||
| 104 | }, | ||
| 105 | catch: (cause) => | ||
| 106 | new OrchestrationGetWorkflowScriptError({ | ||
| 107 | reason: "read-failed", | ||
| 108 | scriptPath: resolved, | ||
| 109 | cause, | ||
| 110 | }), | ||
| 111 | }); | ||
| 112 | if ("failure" in read) { | ||
| 113 | return yield* new OrchestrationGetWorkflowScriptError({ | ||
| 114 | reason: read.failure, | ||
| 115 | scriptPath: resolved, | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | |||
| 119 | return { | ||
| 120 | scriptPath: resolved, | ||
| 121 | contents: read.contents, | ||
| 122 | truncated: read.truncated, | ||
| 123 | }; | ||
| 124 | }); | ||