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 | +}); | |