124 lines · 4.3 KB · TypeScript
RawDownload
// @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,  };});