// @effect-diagnostics nodeBuiltinImport:off /** * Read-only access to persisted workflow scripts for the Agents surface's * "{} script" affordance. * * Containment rules (lifted from the reviewed #3650 inspection service): * - the resolved realpath must live under ~/.claude/projects (where the * Claude harness persists workflow scripts) — realpath re-containment * defeats symlink escapes, including a symlinked leaf file; * - only .js leaf files are served; * - reads are size-capped rather than failed, with a truncation marker. * * The client-supplied path is a hint from the workflow's runHandles; it is * never trusted beyond these checks. */ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; const SCRIPT_BYTE_CAP = 256 * 1024; function scriptsRoot(): string { return NodePath.join(NodeOS.homedir(), ".claude", "projects"); } export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { readonly scriptPath: string; }) { const requested = input.scriptPath; if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { return yield* Effect.fail( new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), ); } const root = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(scriptsRoot()), catch: (cause) => new OrchestrationGetWorkflowScriptError({ reason: "root-unavailable", scriptPath: requested, cause, }), }); // Realpath the FILE itself (not just its directory): a symlink named // like a script inside a contained directory must not escape. const resolved = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(requested), catch: (cause) => new OrchestrationGetWorkflowScriptError({ reason: "not-found", scriptPath: requested, cause, }), }); if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { return yield* Effect.fail( new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), ); } if (NodePath.extname(resolved) !== ".js") { return yield* Effect.fail( new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), ); } // TOCTOU-safe read (review finding): open FIRST, then verify what was // actually opened via the file descriptor. Re-checking the path after // open would race against a swap; fstat on the handle cannot. The two // containment checks fail with their own tagged reasons (not manufactured // Errors folded into read-failed); "read-failed" is reserved for genuine // platform failures with the real cause attached. const read = yield* Effect.tryPromise({ try: async () => { const handle = await NodeFSP.open(resolved, "r"); try { const stat = await handle.stat(); if (!stat.isFile()) { return { failure: "not-regular-file" as const }; } // The opened inode must be the same one realpath resolved to: a // process swapping the path between realpath and open changes the // inode, which this comparison catches. const pathStat = await NodeFSP.lstat(resolved); if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { return { failure: "changed-during-read" as const }; } const truncated = stat.size > SCRIPT_BYTE_CAP; const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); return { contents: buffer.subarray(0, bytesRead).toString("utf8"), truncated, }; } finally { await handle.close(); } }, catch: (cause) => new OrchestrationGetWorkflowScriptError({ reason: "read-failed", scriptPath: resolved, cause, }), }); if ("failure" in read) { return yield* new OrchestrationGetWorkflowScriptError({ reason: read.failure, scriptPath: resolved, }); } return { scriptPath: resolved, contents: read.contents, truncated: read.truncated, }; });