diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afab40e..ee1c1f8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,4 +49,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm release:check + env: + HUMANISH_PUBLIC_DENYLIST_PATTERN: ${{ secrets.HUMANISH_PUBLIC_DENYLIST_PATTERN }} - run: npm publish --access public diff --git a/README.md b/README.md index 8aae8cd..8f67865 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,11 @@ npx humanish watch --json --no-open | `humanish lab preflight ` | Check lab routing and optional target reachability before actor/model spend. | | `humanish lab run ` | Run a lab manifest in human or JSON mode. | | `humanish verify` | Validate a run bundle and public-safety gates. | -| `humanish cleanup` | Clean resources explicitly recorded as owned by a run and write `cleanup.json`. | +| `humanish cleanup` | Inspect recorded resource evidence and write `cleanup.json`; stored IDs do not authorize provider mutation. | | `humanish review` | Read review evidence for a run. | | `humanish runs` | List local runs and latest pointers. | | `humanish feedback issue` | Print a public-safe GitHub issue draft without API mutation. | -| `humanish lab run oss` | Repo-maintainer dogfood example: Observer-of-Observers for headed authorized-repo app setup attempts. | +| `humanish lab run oss` | Repo-maintainer contract example: dry-run Observer-of-Observers for authorized repo selections. | | `humanish lab run oss-smoke` | Repo-maintainer dogfood example: disposable clone smoke test against public OSS repos. | ## Exit Codes @@ -283,16 +283,22 @@ selected lane ids, and previous lane statuses; the source run's verdict is left This is intentionally not automatic retry; a passing rerun is evidence of a nondeterminism candidate, not permission to erase the original red lane. -**Run-owned cleanup.** Live providers can record exact owned resources in `run.json`. -After a run, reclaim only those resources and write a durable receipt: +**Run-owned cleanup.** Live providers can record resource evidence in `run.json`. +Stored bundle IDs are mutable evidence, not provider-mutation authority. The +cleanup command writes a durable inspection receipt until Humanish has a +verified resource-lease contract. Resources already recorded as killed become +`already_clean`; recorded live or unknown resources become `failed`, which +makes cleanup and verification fail closed: ```bash npx humanish cleanup --run latest npx humanish verify --run latest ``` -Cleanup is exact-id only; Humanish does not enumerate or bulk-delete provider -accounts from this command. +Humanish does not enumerate or bulk-delete provider accounts from this command. +Same-process teardown uses trusted in-memory provider handles. The separate OSS +orphan sweep is maintainer-only, opt-in, and verifies provider metadata before +calling provider cleanup. Trust note: `serve` commands run inside the disposable sandbox with the declared subject env provisioned — the same trust class as a repo's package.json scripts. @@ -358,43 +364,36 @@ and `waitForSelector`. Supported expectations are `text`, `selectorVisible`, ## Maintainer OSS Meta-Lab Example -This repository includes an experimental authorized-repo dogfood lab: +This repository includes a contract-only authorized-repo dogfood lab: ```bash pnpm humanish -- watch oss -pnpm humanish -- lab run oss --repos CorentinTh/it-tools,drawdb-io/drawdb,maciekt07/TodoApp,lissy93/dashy +pnpm humanish -- lab run oss --dry-run --repos CorentinTh/it-tools,drawdb-io/drawdb,maciekt07/TodoApp,lissy93/dashy ``` Default lab targets are intentionally app/tool-like repos with visible, locally runnable user surfaces. Avoid libraries and frameworks for public dogfood unless the scenario is explicitly testing developer experience. -With `E2B_API_KEY` and `OPENAI_API_KEY` present, Humanish launches headed E2B -desktop lanes, uploads the local package tarball, clones each assigned -repository inside the sandbox, initializes Humanish, runs nested proof commands, -starts the target app when a runnable script is present, opens desktop/mobile -app windows plus the nested Observer in the sandbox browser, and starts a -nonblocking Codex actor attempt. -Install the optional desktop substrate first: - -```bash -npm i -D @e2b/desktop -``` - -The contract-safe path for agents and CI is: +The bundled manifest defaults to dry-run and creates contract evidence without +cloning repos, launching a provider sandbox, or forwarding credentials. Use: ```bash pnpm humanish -- lab run oss --dry-run --json --no-open ``` -The `oss` lab accepts GitHub `owner/repo` slugs. Private repositories are -maintainer-only and should be supplied from ignored local lab manifests with an -authorized `GH_TOKEN` or `GITHUB_TOKEN` loaded via `--env-file`. When a GitHub -token is present, durable run artifacts redact repo labels by default; pass -`--no-redact-repos` only for public-safe repo selections. Live E2B stream URLs -are runtime-only for the attached Observer server and are not persisted to -`run.json` or `observer-data.json`. Local bundles remain ignored under -`.humanish/`; do not publish private screenshots, logs, or upstream details. +Live OSS meta-lab execution is unavailable until repository-derived instructions +have an isolated credential boundary. A live manifest fails closed with +`HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED` before callbacks, filesystem writes, +network access, or provider launch. + +The `oss` lab accepts GitHub `owner/repo` slugs. A CLI `--repos` override redacts +repo labels in durable artifacts by default; pass `--no-redact-repos` only for a +public-safe selection. Dry-run does not access or clone repositories and does +not need or use private-repository credentials. Private-repository execution +remains unavailable while the live lane is disabled. Local bundles remain +ignored under `.humanish/`; do not publish private screenshots, logs, or +upstream details. ## Development @@ -416,13 +415,16 @@ pnpm humanish:lab:list ## Docs +Start with the current safety and capability state. Dated design documents may +preserve historical mechanisms and carry explicit amendments near the top. + +- [Current safety state and goals](docs/goals/current.md) - [Ramp for future contributors and agents](docs/ramp/README.md) -- [Current goals](docs/goals/current.md) - [Project layout](docs/architecture/project-layout.md) - [Observer architecture](docs/architecture/observer.md) - [Actor contract (pluggable harnesses)](docs/architecture/actor-contract.md) - [State-driven executor (drive a local app, no E2B/vision)](docs/architecture/state-driven-executor.md) -- [OSS lab POC](docs/architecture/oss-lab-poc.md) +- [OSS lab design record (historical; see its current safety amendment)](docs/architecture/oss-lab-poc.md) - [Feedback contract](docs/contracts/feedback.md) - [Open-source install experience](docs/product/open-source-install-experience.md) - [Self-driving harness principles](docs/principles/self-driving-harness.md) diff --git a/docs/architecture/oss-lab-poc.md b/docs/architecture/oss-lab-poc.md index e6289c5..7b860a9 100644 --- a/docs/architecture/oss-lab-poc.md +++ b/docs/architecture/oss-lab-poc.md @@ -5,6 +5,14 @@ Date: 2026-06-01 Status: implemented as an experimental repo-owned lab manifest plus compatibility aliases. +Safety amendment (2026-07-14): beginning with `0.15.1`, the bundled `oss` +manifest is a contract-only dry-run. A direct live OSS meta-lab request fails +with `HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED` before callbacks, filesystem +or network side effects, credential forwarding, or provider launch. The +historical design and evidence description below is preserved as a record; it +is not current execution guidance. The separate `oss-smoke` clone/discard lane +remains available for public repositories. + ## Decision `humanish/labs/oss.yaml` is this repo's authorized-repo meta-simulation diff --git a/docs/contracts/policy.md b/docs/contracts/policy.md index dcbfeeb..f3762bb 100644 --- a/docs/contracts/policy.md +++ b/docs/contracts/policy.md @@ -5,6 +5,12 @@ Date: 2026-06-02 Status: v0 draft contract for credential, network, spend, redaction, and assisted-run boundaries. +Safety amendment (2026-07-14): beginning with `0.15.1`, stored provider IDs are +evidence and never authorize core provider mutation. The bundled OSS meta-lab +is dry-run only; a live request fails before side effects until +repository-derived instructions have an isolated credential boundary. Any +historical live-OSS examples below do not override that fail-closed behavior. + ## Purpose Policy defines what a run may access, what it may persist, and what it may @@ -74,7 +80,7 @@ for credentials. | `local_only` | Localhost and loopback only. | Observer, local fixtures | | `public_oss` | Public GitHub clone/fetch of owner/repo slugs only. | disposable OSS smoke | | `authorized_private` | Token-backed clone/fetch of repos the maintainer is already authorized to access, with repo labels redacted by default. | local maintainer dogfood only | -| `provider_substrate` | Explicit provider substrate such as hosted desktop streams. | live OSS lab with keys | +| `provider_substrate` | Explicit provider substrate such as hosted desktop streams. | live routes with an isolated credential boundary and in-process resource handles | | `custom_allowlist` | Adapter-declared public hosts. | target-specific adapters | Synthetic fixture: diff --git a/docs/goals/current.md b/docs/goals/current.md index f7636cd..ec563df 100644 --- a/docs/goals/current.md +++ b/docs/goals/current.md @@ -1,6 +1,6 @@ # Current Goals -Status date: 2026-07-08 (rev 13) +Status date: 2026-07-14 (rev 14) This page is the current public-safe operating goal for `humanish`. Keep it short enough to reread before a coding session and concrete enough that future @@ -16,6 +16,21 @@ Humanish should be the open-source CLI that lets a maintainer ask: The answer should be observable, verifiable, public-safe, and easy to turn into actionable feedback. +## Current Safety State (`0.15.1`) + +- Managed run, Observer, feedback, lab, actor-output, and source-archive paths + bind to validated physical filesystem identities and fail closed on unsafe + traversal, link, special-file, or retargeting states. +- Provider IDs stored in `run.json` are mutable evidence, not cleanup + authority. `humanish cleanup` writes an inspection receipt; same-process + teardown continues to use the provider handles that created the resources. +- The bundled `oss` manifest defaults to dry-run. Live OSS meta-lab execution + fails with `HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED` before side effects + until repository-derived instructions have an isolated credential boundary. +- Ordinary Git repositories and verified linked worktrees remain supported. + Git metadata that cannot pass containment validation is recorded as + unavailable rather than followed. + ## Definition Of Awesome A world-class Humanish run should eventually provide: @@ -341,6 +356,10 @@ Minimum acceptance: Make the maintainer `oss` lab report nested lane health back into the top-level Observer instead of relying on a human watching the desktops. +The `0.15.1` safety state above governs this lane. The completed bullets below +record prior capability and evidence shape; they do not mean the live +entrypoint is currently enabled. + Minimum acceptance: - each lane records setup status; `done` diff --git a/docs/product/open-source-install-experience.md b/docs/product/open-source-install-experience.md index c836d87..dac10a9 100644 --- a/docs/product/open-source-install-experience.md +++ b/docs/product/open-source-install-experience.md @@ -4,6 +4,14 @@ Date: 2026-06-01 Status: product target for the first world-class `humanish` implementation. +Safety amendment (2026-07-14): the `0.15.1` package binds managed run and +output storage to validated physical paths, treats provider IDs persisted in a +run bundle as evidence rather than cleanup authority, and disables live OSS +meta-lab execution until repository-derived instructions have an isolated +credential boundary. The historical product target below remains useful for +intent and sequencing, but current behavior is defined by the README and +[`docs/goals/current.md`](../goals/current.md). + ## Product Promise Drop Humanish into an app and let a coding agent set up realistic persona @@ -171,7 +179,7 @@ Suggested scripts: | `humanish lab inspect ` | Read a lab manifest | Print the parsed lab config, origin, path, and warnings without executing | | `humanish lab preflight ` | Check lab readiness before spend | Validate routing and optionally probe declared targets from a hosted desktop without launching actors | | `humanish lab run ` | Run a lab manifest | Human or JSON execution path for synthetic, OSS meta, and smoke labs | -| `humanish lab run oss` | Maintainer dogfood example | Open the Observer-of-Observers with headed desktop lanes assigned by `--repos`, target app windows, nested Observers, runtime-only stream URLs, and redacted durable evidence for token-backed runs | +| `humanish lab run oss` | Maintainer contract example | Render a dry-run Observer-of-Observers contract for selected repo labels; live execution fails closed pending credential isolation | | `humanish lab run oss-smoke` | Maintainer smoke example | Shallow clone lightweight GitHub repos, run setup/proof/verify, report, and remove clones | | `humanish feedback issue` | Produce public-safe issue draft | Print Markdown or prefilled issue URL, no GitHub API mutation | diff --git a/docs/ramp/README.md b/docs/ramp/README.md index 48a509a..adc2568 100644 --- a/docs/ramp/README.md +++ b/docs/ramp/README.md @@ -2,6 +2,11 @@ Status: public-safe contributor and agent ramp. +Current safety state: `0.15.1` (2026-07-14). Managed run and output paths bind +to validated physical filesystem identities. Stored provider IDs are evidence, +not cleanup authority. The bundled OSS meta-lab is dry-run only until +repository-derived instructions have an isolated credential boundary. + Use this page when you are starting cold on `humanish`. It is meant to be useful without chat history, private notes, local machine paths, or maintainer context. @@ -71,9 +76,12 @@ Implemented: drives a lab-owner loopback app in a hosted desktop, and `subject.source: clone` + `serve` clones, installs, and serves a real app in-sandbox from config before the actor drives it (`src/cua-actor-lab.ts`); -- experimental maintainer OSS meta-lab and disposable OSS smoke harness; -- OSS dogfood setup-quality filesystem artifacts rendered from the Observer Files - tab with private-run previews suppressed by default. +- containment checks for managed run storage, Observer and feedback reads, + actor artifacts, lab discovery, Git metadata, and source archives; +- an OSS meta-lab dry-run contract and a separate disposable public-repo OSS + smoke harness; +- cleanup inspection receipts that do not treat mutable run-bundle IDs as + provider-mutation authority. Still not good enough: @@ -81,10 +89,9 @@ Still not good enough: `browser.steps` in `humanish/scenarios/*.yaml`, with headed provider-backed public proof against two app/tool targets; - live PTY and Codex UI lanes need stronger completion health; -- OSS lab lanes can report nested Observer health, target app readiness, actor - evidence, setup-quality filesystem checks, and nested browser step summaries - when a target app starts, but need repeated fresh-agent trials across more - disposable public apps; +- live OSS meta-lab execution remains disabled until repository-derived + instructions have an isolated credential boundary; historical headed-lane + evidence does not make the current entrypoint available; - Observer evidence has real screenshots/traces for browser app proof; richer multi-step product journeys and broader multi-persona matrices remain the next gap. diff --git a/humanish/labs/oss.yaml b/humanish/labs/oss.yaml index 2aa4084..fa3d0f3 100644 --- a/humanish/labs/oss.yaml +++ b/humanish/labs/oss.yaml @@ -1,7 +1,7 @@ schema: humanish.lab.v2 id: oss title: OSS meta-lab dogfood -description: Experimental Observer-of-Observers for authorized repos; useful for maintainer dogfood and disposable public app trials. +description: Contract-only Observer-of-Observers for authorized repo selections; live execution is gated pending credential isolation. subject: source: clone repos: @@ -16,6 +16,6 @@ actors: execution: target: e2b-desktop scenario: - mode: live + mode: dry-run defaults: open: true diff --git a/package.json b/package.json index e7d8442..17babb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "humanish", - "version": "0.15.0", + "version": "0.15.1", "description": "Open-source-safe CLI for persona simulation, observer review, and public-safe feedback drafts.", "author": "Daniel G Wilson ", "keywords": [ diff --git a/src/claude-agent-sdk.ts b/src/claude-agent-sdk.ts index 6beed93..3129aaf 100644 --- a/src/claude-agent-sdk.ts +++ b/src/claude-agent-sdk.ts @@ -1,4 +1,3 @@ -import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { @@ -12,6 +11,13 @@ import { type ActorTraceItem } from "./actor-contract.js"; import { redactText } from "./redaction.js"; +import { + prepareContainedOutputDirectory, + prepareContainedOutputFile, + prepareSelectedOutputDirectory, + type PreparedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; // This module holds both halves of the Claude adapter: // - the PURE mapper (claudeSessionToActorTrace) over a locally-declared @@ -349,7 +355,7 @@ const CLAUDE_ARTIFACT_DIR = "claude-agent-sdk"; // Map the session through the pure mapper and write the three evidence artifacts. // Shared by the normal path and the load-failure path so both always leave a bundle. async function finishClaudeSession( - runRoot: string, + runRoot: PreparedOutputDirectory, persona: ActorPersonaRef, session: ClaudeSessionResult, envelopeLines: string[] @@ -359,10 +365,11 @@ async function finishClaudeSession( const transcriptPath = path.join(CLAUDE_ARTIFACT_DIR, "transcript.txt"); const trace = claudeSessionToActorTrace(session, persona); const transcript = renderClaudeTranscript(trace); - await writeFile(path.join(runRoot, eventsPath), envelopeLines.length > 0 ? `${envelopeLines.join("\n")}\n` : "", "utf8"); - await writeFile(path.join(runRoot, tracePath), `${JSON.stringify(trace, null, 2)}\n`, "utf8"); - await writeFile( - path.join(runRoot, transcriptPath), + await writeContainedOutputFile(runRoot, eventsPath, envelopeLines.length > 0 ? `${envelopeLines.join("\n")}\n` : "", "utf8"); + await writeContainedOutputFile(runRoot, tracePath, `${JSON.stringify(trace, null, 2)}\n`, "utf8"); + await writeContainedOutputFile( + runRoot, + transcriptPath, transcript.length > 0 ? transcript : "No Claude Agent SDK transcript output captured.\n", "utf8" ); @@ -386,7 +393,14 @@ async function finishClaudeSession( * timeout still produces a (failed/timed_out) bundle rather than throwing. */ export async function runClaudeAgentSession(options: ClaudeAgentSessionOptions): Promise { - await mkdir(path.join(options.runRoot, CLAUDE_ARTIFACT_DIR), { recursive: true }); + const preparedRunRoot = await prepareSelectedOutputDirectory(process.cwd(), options.runRoot); + const runRoot = preparedRunRoot; + await prepareContainedOutputDirectory(runRoot, CLAUDE_ARTIFACT_DIR); + await Promise.all([ + prepareContainedOutputFile(runRoot, path.join(CLAUDE_ARTIFACT_DIR, "events.ndjson")), + prepareContainedOutputFile(runRoot, path.join(CLAUDE_ARTIFACT_DIR, "summary.json")), + prepareContainedOutputFile(runRoot, path.join(CLAUDE_ARTIFACT_DIR, "transcript.txt")) + ]); const startedAt = new Date().toISOString(); const startedMs = Date.now(); @@ -402,7 +416,7 @@ export async function runClaudeAgentSession(options: ClaudeAgentSessionOptions): completedAt: new Date().toISOString(), messages: [{ type: "result", subtype: "error_during_execution", is_error: true, duration_ms: Date.now() - startedMs, result: reason }] }; - return finishClaudeSession(options.runRoot, options.persona, session, [JSON.stringify({ at: startedAt, error: reason })]); + return finishClaudeSession(runRoot, options.persona, session, [JSON.stringify({ at: startedAt, error: reason })]); } const queryOptions: Record = { @@ -486,5 +500,5 @@ export async function runClaudeAgentSession(options: ClaudeAgentSessionOptions): }); } - return finishClaudeSession(options.runRoot, options.persona, { messages, startedAt, completedAt }, envelopeLines); + return finishClaudeSession(runRoot, options.persona, { messages, startedAt, completedAt }, envelopeLines); } diff --git a/src/codex-app-server-ui.ts b/src/codex-app-server-ui.ts index 2d85d34..a0f3403 100644 --- a/src/codex-app-server-ui.ts +++ b/src/codex-app-server-ui.ts @@ -1,13 +1,24 @@ import { createServer, type Server, type ServerResponse } from "node:http"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { - runCodexAppServerSession, + runCodexAppServerSessionInPreparedRoot, type CodexAppServerRunOptions, type CodexAppServerRunResult, type CodexAppServerStatus } from "./codex-app-server.js"; +import { + prepareContainedOutputDirectory, + prepareContainedOutputFile, + prepareManagedHumanishOutputDirectory, + prepareSelectedOutputDirectory, + prepareSelectedOutputFile, + readContainedRegularFile, + type PreparedOutputDirectory, + type PreparedSelectedOutputFile, + writeContainedOutputFile, + writePreparedSelectedOutputFile +} from "./selected-output-paths.js"; export const CODEX_APP_SERVER_UI_SCHEMA = "humanish.codex-app-server-ui.v1"; @@ -20,7 +31,7 @@ export interface CodexAppServerUiOptions { model?: string; port?: number; prompt: string; - runRoot: string; + runRoot?: string; sandbox?: CodexAppServerRunOptions["sandbox"]; serviceName?: string; stateFile?: string; @@ -52,12 +63,24 @@ export interface CodexAppServerUiController { export async function startCodexAppServerUi(options: CodexAppServerUiOptions): Promise { const cwd = path.resolve(options.cwd); - const runRoot = path.resolve(cwd, options.runRoot); - const stateFile = path.resolve(cwd, options.stateFile ?? path.join(options.runRoot, "state.json")); - const publicRunRoot = path.relative(cwd, runRoot) || "."; + const preparedRunRoot = options.runRoot === undefined + ? await prepareManagedHumanishOutputDirectory(cwd, "codex-app-server-ui") + : await prepareSelectedOutputDirectory(cwd, options.runRoot); + const preparedStateFile: PreparedSelectedOutputFile | undefined = options.stateFile === undefined + ? undefined + : await prepareSelectedOutputFile(cwd, options.stateFile); + const stateFile = preparedStateFile?.requestedPath ?? path.join(preparedRunRoot.requestedPath, "state.json"); + if (!preparedStateFile) { + await prepareContainedOutputFile(preparedRunRoot, "state.json"); + } + await prepareContainedOutputDirectory(preparedRunRoot, "codex-app-server"); + await Promise.all([ + prepareContainedOutputFile(preparedRunRoot, path.join("codex-app-server", "events.ndjson")), + prepareContainedOutputFile(preparedRunRoot, path.join("codex-app-server", "summary.json")), + prepareContainedOutputFile(preparedRunRoot, path.join("codex-app-server", "transcript.txt")) + ]); + const publicRunRoot = path.relative(cwd, preparedRunRoot.requestedPath) || "."; const publicStateFile = path.relative(cwd, stateFile) || path.basename(stateFile); - await mkdir(runRoot, { recursive: true }); - await mkdir(path.dirname(stateFile), { recursive: true }); let state: CodexAppServerUiState = { schema: CODEX_APP_SERVER_UI_SCHEMA, @@ -73,7 +96,12 @@ export async function startCodexAppServerUi(options: CodexAppServerUiOptions): P }; const persistState = async (): Promise => { - await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + const contents = `${JSON.stringify(state, null, 2)}\n`; + if (preparedStateFile) { + await writePreparedSelectedOutputFile(preparedStateFile, contents, "utf8"); + } else { + await writeContainedOutputFile(preparedRunRoot, "state.json", contents, "utf8"); + } }; const server = createServer(async (request, response) => { @@ -87,11 +115,18 @@ export async function startCodexAppServerUi(options: CodexAppServerUiOptions): P } if (request.url?.startsWith("/artifact/")) { + let requestPath: string; + try { + requestPath = decodeURIComponent(request.url.slice("/artifact/".length)); + } catch { + response.writeHead(404); + response.end("not found"); + return; + } await serveArtifact({ - cwd, - requestPath: decodeURIComponent(request.url.slice("/artifact/".length)), + requestPath, response, - runRoot + runRoot: preparedRunRoot }); return; } @@ -111,12 +146,17 @@ export async function startCodexAppServerUi(options: CodexAppServerUiOptions): P updatedAt: new Date().toISOString(), url }; - await persistState(); + try { + await persistState(); + } catch (error) { + await closeServer(server); + throw error; + } - const completion = runCodexAppServerSession({ + const sessionOptions: CodexAppServerRunOptions = { cwd, prompt: options.prompt, - runRoot, + runRoot: preparedRunRoot.physicalPath, timeoutMs: options.timeoutMs, ...(options.actorCommand ? { actorCommand: ["bash", "-lc", options.actorCommand] } : {}), approvalPolicy: "never", @@ -124,32 +164,44 @@ export async function startCodexAppServerUi(options: CodexAppServerUiOptions): P ...(options.model === undefined ? {} : { model: options.model }), sandbox: options.sandbox ?? "read-only", serviceName: options.serviceName ?? "humanish" - }).then(async (result): Promise => { - state = { - ...state, - reason: result.reason, - result, - status: result.status, - updatedAt: new Date().toISOString() - }; - await persistState(); - if (options.keepOpen !== true) { - await closeServer(server); - } - return state; - }).catch(async (error: unknown): Promise => { - state = { - ...state, - reason: error instanceof Error ? error.message : String(error), - status: "blocked", - updatedAt: new Date().toISOString() - }; - await persistState(); - if (options.keepOpen !== true) { + }; + const completion = runCodexAppServerSessionInPreparedRoot(sessionOptions, preparedRunRoot).then( + async (result): Promise => { + state = { + ...state, + reason: result.reason, + result, + status: result.status, + updatedAt: new Date().toISOString() + }; + try { + await persistState(); + } catch (error) { + await closeServer(server); + throw error; + } + if (options.keepOpen !== true) { + await closeServer(server); + } + return state; + }, + async (error: unknown): Promise => { + state = { + ...state, + reason: error instanceof Error ? error.message : String(error), + status: "blocked", + updatedAt: new Date().toISOString() + }; + try { + await persistState(); + } catch (persistError) { + await closeServer(server); + throw persistError; + } await closeServer(server); + return state; } - return state; - }); + ); return { close: async () => closeServer(server), @@ -161,36 +213,21 @@ export async function startCodexAppServerUi(options: CodexAppServerUiOptions): P } async function serveArtifact(args: { - cwd: string; requestPath: string; response: ServerResponse; - runRoot: string; + runRoot: PreparedOutputDirectory; }): Promise { - const safePath = args.requestPath.replace(/^\/+/, ""); - if (!safePath || safePath.includes("..") || safePath.includes("://")) { - args.response.writeHead(404); - args.response.end("not found"); - return; - } - - const absolute = path.resolve(args.runRoot, safePath); - if (!absolute.startsWith(args.runRoot)) { - args.response.writeHead(404); - args.response.end("not found"); - return; - } - - try { - const text = await readFile(absolute, "utf8"); + const body = await readContainedRegularFile(args.runRoot, args.requestPath); + if (body) { args.response.writeHead(200, { "cache-control": "no-store", - "content-type": contentTypeFor(absolute) + "content-type": contentTypeFor(args.requestPath) }); - args.response.end(text); - } catch { - args.response.writeHead(404); - args.response.end("not found"); + args.response.end(body); + return; } + args.response.writeHead(404); + args.response.end("not found"); } function contentTypeFor(filePath: string): string { diff --git a/src/codex-app-server.ts b/src/codex-app-server.ts index 6427b17..c279023 100644 --- a/src/codex-app-server.ts +++ b/src/codex-app-server.ts @@ -1,9 +1,15 @@ import { spawn } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; import { digestText, publicPathForTrace, redactText, tailText } from "./redaction.js"; +import { + prepareContainedOutputDirectory, + prepareContainedOutputFile, + prepareSelectedOutputDirectory, + type PreparedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; export const CODEX_APP_SERVER_TRACE_SCHEMA = "humanish.codex-app-server-trace.v1"; @@ -163,6 +169,15 @@ const pathLikeKey = /^(cwd|path|writableRoots|workspaceRoot)$/i; export async function runCodexAppServerSession( options: CodexAppServerRunOptions +): Promise { + const preparedRunRoot = await prepareSelectedOutputDirectory(process.cwd(), options.runRoot); + return runCodexAppServerSessionInPreparedRoot(options, preparedRunRoot); +} + +/** Internal UI seam: the selected root was already prepared and must not be re-authorized. */ +export async function runCodexAppServerSessionInPreparedRoot( + options: CodexAppServerRunOptions, + runRoot: PreparedOutputDirectory ): Promise { const startedAt = new Date(); const startedMs = Date.now(); @@ -170,9 +185,12 @@ export async function runCodexAppServerSession( const eventsPath = path.join(relativeDir, "events.ndjson"); const tracePath = path.join(relativeDir, "summary.json"); const transcriptPath = path.join(relativeDir, "transcript.txt"); - const absoluteEventsPath = path.join(options.runRoot, eventsPath); - const absoluteTracePath = path.join(options.runRoot, tracePath); - const absoluteTranscriptPath = path.join(options.runRoot, transcriptPath); + await prepareContainedOutputDirectory(runRoot, relativeDir); + await Promise.all([ + prepareContainedOutputFile(runRoot, eventsPath), + prepareContainedOutputFile(runRoot, tracePath), + prepareContainedOutputFile(runRoot, transcriptPath) + ]); const commandParts = resolveAppServerCommand(options.actorCommand); const childEnv = resolveCodexAppServerEnv(process.env); const apiKey = appServerApiKeyForLogin(childEnv); @@ -206,8 +224,6 @@ export async function runCodexAppServerSession( resolve: (value: JsonObject) => void; }>(); - await mkdir(path.dirname(absoluteEventsPath), { recursive: true }); - const appendEnvelope = (direction: "client" | "server", message: unknown): void => { const redacted = redactCodexEnvelope(message, options.cwd); recorder.observeEnvelope(direction, redacted); @@ -348,9 +364,14 @@ export async function runCodexAppServerSession( status }); const transcriptText = recorder.renderTranscript(); - await writeFile(absoluteEventsPath, `${envelopes.join("\n")}${envelopes.length > 0 ? "\n" : ""}`, "utf8"); - await writeFile(absoluteTracePath, `${JSON.stringify(trace, null, 2)}\n`, "utf8"); - await writeFile(absoluteTranscriptPath, transcriptText.length > 0 ? transcriptText : "No Codex app-server transcript output captured.\n", "utf8"); + await writeContainedOutputFile(runRoot, eventsPath, `${envelopes.join("\n")}${envelopes.length > 0 ? "\n" : ""}`, "utf8"); + await writeContainedOutputFile(runRoot, tracePath, `${JSON.stringify(trace, null, 2)}\n`, "utf8"); + await writeContainedOutputFile( + runRoot, + transcriptPath, + transcriptText.length > 0 ? transcriptText : "No Codex app-server transcript output captured.\n", + "utf8" + ); return { status, reason, diff --git a/src/concurrent-shared-world-lab.ts b/src/concurrent-shared-world-lab.ts index 7647508..f62d198 100644 --- a/src/concurrent-shared-world-lab.ts +++ b/src/concurrent-shared-world-lab.ts @@ -33,7 +33,6 @@ // requires the author attestation subject.exposure: synthetic. This is author-trust + a provenance // gate, NOT a no-real-data guarantee (Humanish cannot tell synthetic from real data). -import { mkdir, writeFile } from "node:fs/promises"; import { randomBytes } from "node:crypto"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -80,6 +79,12 @@ import { type ObserverRuntimeStreamUrl } from "./observer.js"; import { redactText } from "./redaction.js"; +import { + prepareRunArtifactPaths, + validatePreparedRunArtifactPaths, + type PreparedRunArtifactPaths +} from "./run-paths.js"; +import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js"; import { combineCheckpointDigest, runCheckpointSnapshot, @@ -335,30 +340,30 @@ function buildActorSpec(config: LabConfig, role: LabActorLane, index: number): C } async function writeConcurrentRunArtifacts( - cwd: string, - artifactRoot: string, - bundle: RunBundle + bundle: RunBundle, + preparedRunPaths: PreparedRunArtifactPaths ): Promise { + const runPaths = await validatePreparedRunArtifactPaths(preparedRunPaths); const publicBundle: RunBundle = { ...bundle, cwd: PUBLIC_TARGET_CWD }; - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderConcurrentReviewMarkdown(publicBundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); - await mkdir(path.join(artifactRoot, "observer"), { recursive: true }); - await writeFile( - path.join(artifactRoot, "observer", "observer-data.json"), + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderConcurrentReviewMarkdown(publicBundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile( + runPaths, + "observer/observer-data.json", `${JSON.stringify(buildObserverData(publicBundle), null, 2)}\n`, "utf8" ); - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), + await writePreparedRunLatestPointer( + runPaths, `${JSON.stringify({ schema: "humanish.latest-run.v1", runId: publicBundle.runId, - path: path.join(".humanish", "runs", publicBundle.runId), + path: runPaths.relativeRunRoot, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8" @@ -464,7 +469,9 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld } const runId = options.runId ?? makeRunId(); - const artifactRoot = path.join(cwd, ".humanish", "runs", runId); + const runPaths = await prepareRunArtifactPaths(cwd, runId); + const artifactRoot = runPaths.absoluteRunRoot; + const physicalArtifactRoot = runPaths.physicalRunRoot; const createdAt = new Date().toISOString(); const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000); @@ -474,7 +481,6 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld const proberCadenceMs = hooks.proberCadenceMs ?? DEFAULT_PROBER_CADENCE_MS; const seedDigest = seedRecipeDigest(config); - await mkdir(artifactRoot, { recursive: true }); const source = await buildRunSource({ capturedAt: createdAt, cwd, humanishSource: "present", packageName: "humanish" }); const warnings: string[] = []; @@ -655,7 +661,7 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld ...(inProgressPlaneCommit === undefined ? {} : { subjectCommit: inProgressPlaneCommit }), hostDigest: hostOriginDigest(getHostUrl!) }); - await writeConcurrentRunArtifacts(cwd, artifactRoot, inProgressBundle); + await writeConcurrentRunArtifacts(inProgressBundle, runPaths); liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [ "Live concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted." ]); @@ -703,7 +709,7 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld perLaneSandboxMs: timeoutMs + SANDBOX_TIMEOUT_BUFFER_MS, timeoutMs, laneCount: roles.length, - artifactRoot, + artifactRoot: runPaths, redactScreenshots, scrubKnownValues, runSession, @@ -786,7 +792,7 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld bundle, context: { bundle, - runDir: artifactRoot, + runDir: physicalArtifactRoot, labId: config.id, runId, actor: descriptor.id, @@ -799,7 +805,7 @@ export async function runConcurrentSharedWorld(options: RunConcurrentSharedWorld hookLabel: "sharedWorldHooks" }); - await writeConcurrentRunArtifacts(cwd, artifactRoot, bundle); + await writeConcurrentRunArtifacts(bundle, runPaths); const observer = await render(cwd, runId, { open: options.open === true }); if (observer.ok && liveObserver) { diff --git a/src/core/git-state.ts b/src/core/git-state.ts index 102f385..fabddb8 100644 --- a/src/core/git-state.ts +++ b/src/core/git-state.ts @@ -1,4 +1,10 @@ import { spawn } from "node:child_process"; +import os from "node:os"; + +import { + inspectVerifiedGitWorkspace, + type VerifiedGitWorkspace +} from "./git-workspace.js"; export const GIT_STATE_SCHEMA = "humanish.git-state.v1"; @@ -28,21 +34,40 @@ export interface GitCommandResult { exitCode: number | null; stdout: string; stderr: string; + timedOut?: boolean; } export type GitCommandRunner = (args: string[], cwd: string) => Promise; +const DEFAULT_GIT_COMMAND_TIMEOUT_MS = 15_000; + export async function captureGitState( cwd: string, options: { capturedAt?: Date | string; + commandTimeoutMs?: number; runner?: GitCommandRunner; } = {} ): Promise { const capturedAt = toIsoString(options.capturedAt ?? new Date()); - const runner = options.runner ?? runGitCommand; - const inside = await runner(["rev-parse", "--is-inside-work-tree"], cwd); + const inspection = await inspectVerifiedGitWorkspace(cwd); + if (inspection.status === "unsafe") { + return unavailableState(capturedAt, inspection.note); + } + if (inspection.status === "missing") { + return missingState(capturedAt); + } + const commandTimeoutMs = normalizeCommandTimeout(options.commandTimeoutMs); + const runner: GitCommandRunner = options.runner + ? (args, commandCwd) => runGitRunnerWithDeadline(options.runner!, args, commandCwd, commandTimeoutMs) + : (args) => runGitCommand(args, inspection.workspace, commandTimeoutMs); + const commandCwd = inspection.workspace.worktreeRoot; + const inside = await runner(["rev-parse", "--is-inside-work-tree"], commandCwd); + + if (inside.timedOut) { + return unavailableState(capturedAt, "Git work-tree detection timed out."); + } if (inside.exitCode === null) { return unavailableState(capturedAt, "Git command could not be started."); } @@ -61,7 +86,15 @@ export async function captureGitState( }; } - const statusOutput = await runner(["status", "--porcelain=v1"], cwd); + const statusOutput = await runner([ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignore-submodules=all" + ], commandCwd); + if (statusOutput.timedOut) { + return unavailableState(capturedAt, "Git status capture timed out."); + } if (statusOutput.exitCode === null) { return unavailableState(capturedAt, "Git status command could not be started."); } @@ -70,9 +103,24 @@ export async function captureGitState( return unavailableState(capturedAt, "Git status could not be captured."); } - const headOutput = await runner(["rev-parse", "--short=12", "HEAD"], cwd); + const headOutput = await runner(["rev-parse", "--short=12", "HEAD"], commandCwd); + if (headOutput.timedOut) { + return unavailableState(capturedAt, "Git HEAD capture timed out."); + } + if (headOutput.exitCode === null) { + return unavailableState(capturedAt, "Git HEAD command could not be started."); + } const shortSha = headOutput.exitCode === 0 ? normalizeNullableText(headOutput.stdout) : null; - const refState = await captureRefState(runner, cwd, shortSha); + const symbolicRef = await runner(["symbolic-ref", "--quiet", "HEAD"], commandCwd); + if (symbolicRef.timedOut) { + return unavailableState(capturedAt, "Git ref-state capture timed out."); + } + if (symbolicRef.exitCode === null) { + return unavailableState(capturedAt, "Git ref-state command could not be started."); + } + const refState: GitRefState = symbolicRef.exitCode === 0 + ? "attached" + : shortSha === null ? "unborn" : "detached"; const changes = summarizePorcelainStatus(statusOutput.stdout); const status: GitStateStatus = changes.total === 0 ? "clean" : "dirty"; @@ -123,42 +171,66 @@ export function summarizePorcelainStatus(output: string): GitStateChangeSummary }; } -async function captureRefState( - runner: GitCommandRunner, - cwd: string, - shortSha: string | null -): Promise { - const symbolicRef = await runner(["symbolic-ref", "--quiet", "HEAD"], cwd); - - if (symbolicRef.exitCode === 0) { - return "attached"; - } - - return shortSha === null ? "unborn" : "detached"; -} - -async function runGitCommand(args: string[], cwd: string): Promise { +async function runGitCommand( + args: string[], + workspace: VerifiedGitWorkspace, + timeoutMs: number +): Promise { return await new Promise((resolve) => { - const child = spawn("git", args, { - cwd, + const child = spawn("git", [ + `--git-dir=${workspace.gitDir}`, + `--work-tree=${workspace.worktreeRoot}`, + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + `core.excludesFile=${os.devNull}`, + "-c", + `core.attributesFile=${os.devNull}`, + "-c", + "core.alternateRefsCommand=", + "-c", + "core.alternateRefsPrefixes=", + ...workspace.configOverrides.flatMap((override) => ["-c", override]), + ...args + ], { + cwd: workspace.worktreeRoot, + env: isolatedGitEnvironment(process.env), stdio: ["ignore", "pipe", "pipe"] }); const stdout: string[] = []; const stderr: string[] = []; + let settled = false; + const finish = (result: GitCommandResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish({ + exitCode: null, + stdout: stdout.join(""), + stderr: "Git command timed out.", + timedOut: true + }); + }, timeoutMs); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => stdout.push(chunk)); child.stderr.on("data", (chunk: string) => stderr.push(chunk)); child.on("error", (error: Error) => { - resolve({ + finish({ exitCode: null, stdout: stdout.join(""), stderr: error.message }); }); child.on("close", (exitCode) => { - resolve({ + finish({ exitCode, stdout: stdout.join(""), stderr: stderr.join("") @@ -167,6 +239,68 @@ async function runGitCommand(args: string[], cwd: string): Promise { + return await new Promise((resolve) => { + let settled = false; + const finish = (result: GitCommandResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + finish({ + exitCode: null, + stdout: "", + stderr: "Git command timed out.", + timedOut: true + }); + }, timeoutMs); + + void runner(args, cwd).then(finish, (error: unknown) => { + finish({ + exitCode: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error) + }); + }); + }); +} + +function isolatedGitEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const isolated: NodeJS.ProcessEnv = {}; + for (const [name, value] of Object.entries(source)) { + if (!name.toUpperCase().startsWith("GIT_")) { + isolated[name] = value; + } + } + return { + ...isolated, + GIT_ATTR_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_NO_LAZY_FETCH: "1", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_OPTIONAL_LOCKS: "0", + GIT_TERMINAL_PROMPT: "0" + }; +} + +function normalizeCommandTimeout(value: number | undefined): number { + if (value === undefined) { + return DEFAULT_GIT_COMMAND_TIMEOUT_MS; + } + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error("Git command timeout must be a positive safe integer."); + } + return value; +} + function unavailableState(capturedAt: string, note: string): CapturedGitState { return { schema: GIT_STATE_SCHEMA, @@ -181,6 +315,20 @@ function unavailableState(capturedAt: string, note: string): CapturedGitState { }; } +function missingState(capturedAt: string): CapturedGitState { + return { + schema: GIT_STATE_SCHEMA, + status: "missing", + capturedAt, + head: { + shortSha: null, + refState: "unknown" + }, + changes: emptyChanges(), + note: "No git work tree was detected." + }; +} + function emptyChanges(): GitStateChangeSummary { return { staged: 0, diff --git a/src/core/git-workspace.ts b/src/core/git-workspace.ts new file mode 100644 index 0000000..3bc33bb --- /dev/null +++ b/src/core/git-workspace.ts @@ -0,0 +1,511 @@ +import { constants } from "node:fs"; +import { lstat, open, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +export const GIT_METADATA_INSPECTION_FAILED_NOTE = "Git metadata could not be inspected safely."; +export const GIT_METADATA_CONTAINMENT_FAILED_NOTE = "Git metadata failed containment validation."; + +interface FileIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +export interface VerifiedGitWorkspace { + readonly commonDir: string; + readonly configOverrides: readonly string[]; + readonly gitDir: string; + readonly kind: "directory" | "linked-worktree"; + readonly trustRoot: string; + readonly worktreeRoot: string; +} + +export type GitWorkspaceInspection = + | { readonly status: "missing" } + | { + readonly note: typeof GIT_METADATA_INSPECTION_FAILED_NOTE | typeof GIT_METADATA_CONTAINMENT_FAILED_NOTE; + readonly status: "unsafe"; + readonly worktreeRoot: string; + } + | { readonly status: "verified"; readonly workspace: VerifiedGitWorkspace }; + +/** + * Resolve one physical Git worktree without trusting ambient Git discovery. + * + * A regular `.git` file is accepted only for Git's exact linked-worktree + * topology: `/.git/worktrees/` plus single-link `commondir` and + * `gitdir` backpointer files. Arbitrary `gitdir:` redirects (including valid + * separate-git-dir repositories) are deliberately unavailable because they + * delegate metadata authority outside the selected project without a + * verifiable backlink. + */ +export async function inspectVerifiedGitWorkspace(cwdInput: string): Promise { + let current: string; + try { + current = await realpath(path.resolve(cwdInput)); + const cwdStats = await lstat(current); + if (cwdStats.isSymbolicLink() || !cwdStats.isDirectory()) { + return unsafeInspection(current, GIT_METADATA_INSPECTION_FAILED_NOTE); + } + } catch { + return unsafeInspection(path.resolve(cwdInput), GIT_METADATA_INSPECTION_FAILED_NOTE); + } + + while (true) { + const dotGitPath = path.join(current, ".git"); + let dotGitStats; + try { + dotGitStats = await lstat(dotGitPath, { bigint: true }); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + return unsafeInspection(current, GIT_METADATA_INSPECTION_FAILED_NOTE); + } + const parent = path.dirname(current); + if (parent === current) { + return { status: "missing" }; + } + current = parent; + continue; + } + + if (dotGitStats.isSymbolicLink()) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + if (dotGitStats.isDirectory()) { + try { + if (await realpath(dotGitPath) !== dotGitPath) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + // A normal worktree does not need commondir. Accepting one here would + // let a contained `.git/` silently redirect config, refs, and objects. + if (await pathExists(path.join(dotGitPath, "commondir"))) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + const configOverrides: string[] = []; + const workspace: VerifiedGitWorkspace = { + commonDir: dotGitPath, + configOverrides, + gitDir: dotGitPath, + kind: "directory", + trustRoot: current, + worktreeRoot: current + }; + return await validateCriticalGitMetadata(workspace) + ? { status: "verified", workspace: freezeVerifiedWorkspace(workspace, configOverrides) } + : unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } catch { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + } + + if (!dotGitStats.isFile() || dotGitStats.nlink !== 1n) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + try { + const dotGit = await readSingleLinkRegularFile(dotGitPath); + const declaredGitDir = dotGit ? parseGitdirFile(dotGit.text) : null; + if (!dotGit || !declaredGitDir) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const gitDir = await realpath( + path.isAbsolute(declaredGitDir) + ? declaredGitDir + : path.resolve(current, declaredGitDir) + ); + if (!await isPhysicalDirectory(gitDir)) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const commondir = await readSingleLinkRegularFile(path.join(gitDir, "commondir")); + const backpointer = await readSingleLinkRegularFile(path.join(gitDir, "gitdir")); + const declaredCommonDir = commondir ? parseSinglePathLine(commondir.text) : null; + const declaredBackpointer = backpointer ? parseSinglePathLine(backpointer.text) : null; + if (!commondir || !backpointer || !declaredCommonDir || !declaredBackpointer) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const commonDir = await realpath(path.resolve(gitDir, declaredCommonDir)); + if ( + !await isPhysicalDirectory(commonDir) + || path.basename(commonDir) !== ".git" + || path.dirname(gitDir) !== path.join(commonDir, "worktrees") + || path.basename(gitDir).length === 0 + ) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const physicalBackpointer = await realpath( + path.isAbsolute(declaredBackpointer) + ? declaredBackpointer + : path.resolve(gitDir, declaredBackpointer) + ); + if (physicalBackpointer !== dotGitPath) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const trustRoot = path.dirname(commonDir); + if ( + !await isPhysicalDirectory(trustRoot) + || await realpath(path.join(trustRoot, ".git")) !== commonDir + || !await stillSameSingleLinkFile(dotGitPath, dotGit.identity) + || !await stillSameSingleLinkFile(path.join(gitDir, "commondir"), commondir.identity) + || !await stillSameSingleLinkFile(path.join(gitDir, "gitdir"), backpointer.identity) + ) { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + + const configOverrides: string[] = []; + const workspace: VerifiedGitWorkspace = { + commonDir, + configOverrides, + gitDir, + kind: "linked-worktree", + trustRoot, + worktreeRoot: current + }; + return await validateCriticalGitMetadata(workspace) + ? { status: "verified", workspace: freezeVerifiedWorkspace(workspace, configOverrides) } + : unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } catch { + return unsafeInspection(current, GIT_METADATA_CONTAINMENT_FAILED_NOTE); + } + } +} + +async function validateCriticalGitMetadata(workspace: VerifiedGitWorkspace): Promise { + if ( + !await isPhysicalDirectory(workspace.gitDir) + || !await isPhysicalDirectory(workspace.commonDir) + || !await isPhysicalDirectory(workspace.worktreeRoot) + ) { + return false; + } + + const criticalFiles = new Set([ + path.join(workspace.gitDir, "HEAD"), + path.join(workspace.gitDir, "index"), + path.join(workspace.gitDir, "config.worktree"), + path.join(workspace.commonDir, "config"), + path.join(workspace.commonDir, "config.worktree"), + path.join(workspace.commonDir, "info", "attributes"), + path.join(workspace.commonDir, "info", "exclude"), + path.join(workspace.commonDir, "packed-refs") + ]); + for (const filePath of criticalFiles) { + if (!await isSingleLinkRegularFileOrMissing(filePath)) { + return false; + } + } + + // Split indexes are selected by the contained index but read as sibling + // metadata. Do not let one redirect Git through a symlink or hardlink. + const gitDirEntries = await readdir(workspace.gitDir).catch(() => []); + for (const entry of gitDirEntries) { + if (/^sharedindex\.[0-9a-f]+$/i.test(entry)) { + if (!await isSingleLinkRegularFileOrMissing(path.join(workspace.gitDir, entry))) { + return false; + } + } + } + + for (const directory of [ + path.join(workspace.commonDir, "objects"), + path.join(workspace.commonDir, "refs") + ]) { + if (!await isPhysicalDirectoryOrMissing(directory)) { + return false; + } + } + + const head = await readSingleLinkRegularFile(path.join(workspace.gitDir, "HEAD"), true); + if (head && !await validateHeadReference(workspace, head.text)) { + return false; + } + + const alternatesPath = path.join(workspace.commonDir, "objects", "info", "alternates"); + if (!await isSingleLinkRegularFileOrMissing(alternatesPath)) { + return false; + } + const alternates = await readSingleLinkRegularFile(alternatesPath, true); + if (alternates && !await alternatesStayInsideCommonDir(workspace.commonDir, alternates.text)) { + return false; + } + + const overrides = await collectExecutableConfigOverrides(workspace); + if (!overrides) { + return false; + } + (workspace.configOverrides as string[]).push(...overrides); + return true; +} + +async function collectExecutableConfigOverrides(workspace: VerifiedGitWorkspace): Promise { + const filters = new Set(); + const diffs = new Set(); + for (const configPath of new Set([ + path.join(workspace.commonDir, "config"), + path.join(workspace.gitDir, "config.worktree") + ])) { + const config = await readSingleLinkRegularFile(configPath, true); + if (!config) continue; + for (const line of config.text.split(/\r?\n/)) { + if (/^\s*\[\s*include(?:\s*\]|if\b)/i.test(line)) { + // Includes can introduce executable config from an unbound path after + // this file was inspected. Provenance capture does not need them. + return null; + } + const sectionStart = /^\s*\[\s*(filter|diff)\b/i.exec(line); + if (!sectionStart) continue; + const section = /^\s*\[\s*(filter|diff)\s+(?:"((?:\\.|[^"\\])*)"|\.\s*([^\]\s]+))\s*\]\s*(?:[#;].*)?$/i.exec(line); + if (!section) return null; + const rawDriver = section[2] === undefined ? section[3] : unescapeGitConfigSubsection(section[2]); + if (!rawDriver || !/^[A-Za-z0-9_.-]+$/.test(rawDriver)) return null; + if (section[1]?.toLowerCase() === "filter") filters.add(rawDriver); + else diffs.add(rawDriver); + } + } + + const overrides: string[] = []; + for (const driver of [...filters].sort()) { + overrides.push( + `filter.${driver}.clean=`, + `filter.${driver}.smudge=`, + `filter.${driver}.process=`, + `filter.${driver}.required=false` + ); + } + for (const driver of [...diffs].sort()) { + overrides.push(`diff.${driver}.command=`, `diff.${driver}.textconv=`); + } + return overrides; +} + +function unescapeGitConfigSubsection(value: string): string | null { + let result = ""; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char !== "\\") { + result += char; + continue; + } + const escaped = value[index + 1]; + if (escaped !== "\\" && escaped !== "\"") return null; + result += escaped; + index += 1; + } + return result; +} + +function freezeVerifiedWorkspace( + workspace: VerifiedGitWorkspace, + configOverrides: string[] +): VerifiedGitWorkspace { + Object.freeze(configOverrides); + return Object.freeze(workspace); +} + +async function validateHeadReference(workspace: VerifiedGitWorkspace, headText: string): Promise { + const value = headText.trim(); + if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(value)) { + return true; + } + if (!value.startsWith("ref: ")) { + return false; + } + const ref = value.slice("ref: ".length); + if (!isSafeGitRef(ref)) { + return false; + } + + // Normal branch refs live under the common directory. Per-worktree refs may + // live under the admin directory. Validate either existing path without + // requiring an unborn branch to have a loose ref. + const candidates = [path.join(workspace.commonDir, ...ref.split("/"))]; + if (workspace.gitDir !== workspace.commonDir) { + candidates.push(path.join(workspace.gitDir, ...ref.split("/"))); + } + for (const candidate of candidates) { + if (!await validateOptionalContainedFileChain( + candidate.startsWith(`${workspace.gitDir}${path.sep}`) ? workspace.gitDir : workspace.commonDir, + candidate + )) { + return false; + } + } + return true; +} + +async function validateOptionalContainedFileChain(root: string, filePath: string): Promise { + const relative = path.relative(root, filePath); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return false; + } + let current = root; + const segments = relative.split(path.sep); + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + let stats; + try { + stats = await lstat(current, { bigint: true }); + } catch (error) { + return isNodeError(error) && error.code === "ENOENT"; + } + if (stats.isSymbolicLink()) return false; + if (index < segments.length - 1) { + if (!stats.isDirectory()) return false; + } else if (!stats.isFile() || stats.nlink !== 1n) { + return false; + } + } + return true; +} + +async function alternatesStayInsideCommonDir(commonDir: string, text: string): Promise { + const objectDir = path.join(commonDir, "objects"); + for (const line of text.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)) { + const candidate = path.isAbsolute(line) ? line : path.resolve(objectDir, line); + let physical: string; + try { + physical = await realpath(candidate); + } catch { + return false; + } + const relative = path.relative(commonDir, physical); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return false; + } + } + return true; +} + +function parseGitdirFile(text: string): string | null { + const match = /^gitdir:\s*([^\r\n]+)\r?\n?$/.exec(text); + return match?.[1]?.trim() || null; +} + +function parseSinglePathLine(text: string): string | null { + const match = /^([^\r\n]+)\r?\n?$/.exec(text); + return match?.[1]?.trim() || null; +} + +function isSafeGitRef(value: string): boolean { + return value.startsWith("refs/") + && !value.includes("\\") + && !value.includes("\0") + && !value.includes("//") + && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); +} + +async function readSingleLinkRegularFile( + filePath: string, + allowMissing = false +): Promise<{ readonly identity: FileIdentity; readonly text: string } | null> { + let before; + try { + before = await lstat(filePath, { bigint: true }); + } catch (error) { + if (allowMissing && isNodeError(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1n) { + return null; + } + const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat({ bigint: true }); + if ( + !opened.isFile() + || opened.nlink !== 1n + || opened.dev !== before.dev + || opened.ino !== before.ino + ) { + return null; + } + const text = await handle.readFile("utf8"); + return Object.freeze({ + identity: Object.freeze({ dev: opened.dev, ino: opened.ino }), + text + }); + } finally { + await handle.close(); + } +} + +async function isSingleLinkRegularFileOrMissing(filePath: string): Promise { + let before; + try { + before = await lstat(filePath, { bigint: true }); + } catch (error) { + return isNodeError(error) && error.code === "ENOENT"; + } + if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1n) { + return false; + } + const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null); + if (!handle) return false; + try { + const opened = await handle.stat({ bigint: true }); + return opened.isFile() + && opened.nlink === 1n + && opened.dev === before.dev + && opened.ino === before.ino; + } finally { + await handle.close(); + } +} + +async function stillSameSingleLinkFile(filePath: string, identity: FileIdentity): Promise { + try { + const stats = await lstat(filePath, { bigint: true }); + return !stats.isSymbolicLink() + && stats.isFile() + && stats.nlink === 1n + && stats.dev === identity.dev + && stats.ino === identity.ino; + } catch { + return false; + } +} + +async function isPhysicalDirectory(directory: string): Promise { + try { + const stats = await lstat(directory); + return !stats.isSymbolicLink() && stats.isDirectory() && await realpath(directory) === directory; + } catch { + return false; + } +} + +async function isPhysicalDirectoryOrMissing(directory: string): Promise { + try { + const stats = await lstat(directory); + return !stats.isSymbolicLink() && stats.isDirectory() && await realpath(directory) === directory; + } catch (error) { + return isNodeError(error) && error.code === "ENOENT"; + } +} + +async function pathExists(filePath: string): Promise { + try { + await lstat(filePath); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return false; + throw error; + } +} + +function unsafeInspection( + worktreeRoot: string, + note: typeof GIT_METADATA_INSPECTION_FAILED_NOTE | typeof GIT_METADATA_CONTAINMENT_FAILED_NOTE +): GitWorkspaceInspection { + return { note, status: "unsafe", worktreeRoot }; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/cua-actor-lab.ts b/src/cua-actor-lab.ts index 7075e54..83d7372 100644 --- a/src/cua-actor-lab.ts +++ b/src/cua-actor-lab.ts @@ -22,7 +22,7 @@ // run's actual mode ("raw" | "blurred" | "n/a") — every label downstream derives from it. import { randomBytes } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { runDesktopCommandOrThrow, toErrorMessage } from "./command-failure.js"; import { pathToFileURL } from "node:url"; @@ -83,6 +83,20 @@ import { type ObserverRuntimeStreamUrl } from "./observer.js"; import { containsSensitive, digestText, redactedTail, redactText } from "./redaction.js"; +import { + assertPreparedSelectedOutputDirectory, + assertSafeOutputPathSegment, + prepareContainedOutputDirectory, + prepareSelectedOutputDirectory, + type PreparedOutputDirectory, + writeContainedOutputFile, + writePreparedRunLatestPointer +} from "./selected-output-paths.js"; +import { + prepareRunArtifactPaths, + validatePreparedRunArtifactPaths, + type PreparedRunArtifactPaths +} from "./run-paths.js"; import { createLocalTreeArchive, type LocalTreeArchive } from "./source-archive.js"; import type { StopWhen } from "./stop-conditions.js"; import { @@ -860,7 +874,7 @@ export interface CuaLaneDeps { perLaneSandboxMs: number; timeoutMs: number; laneCount: number; - artifactRoot: string; + artifactRoot: PreparedOutputDirectory; redactScreenshots: boolean; scrubKnownValues: (text: string) => string; runSession: (options: CuaActorSessionOptions) => Promise; @@ -898,17 +912,20 @@ export interface LaneRunOutcome { * the relative path the trace references (screenshots/ at N=1; screenshots// * at N>1). */ export function makeLaneWriteScreenshot( - artifactRoot: string, + artifactRoot: PreparedOutputDirectory, spec: { screenshotDir: string }, screenshots: string[] ): (name: string, bytes: Buffer) => Promise { + if (spec.screenshotDir) { + assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot lane id"); + } const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"]; const relPrefix = spec.screenshotDir ? path.posix.join("screenshots", spec.screenshotDir) : "screenshots"; return async (name: string, bytes: Buffer): Promise => { + assertSafeOutputPathSegment(name, "Screenshot name"); const rel = path.posix.join(relPrefix, name); assertScreenshotEvidence(rel, bytes); - await mkdir(path.join(artifactRoot, ...dirParts), { recursive: true }); - await writeFile(path.join(artifactRoot, ...dirParts, name), bytes); + await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), bytes); screenshots.push(rel); return rel; }; @@ -1453,8 +1470,7 @@ export async function runCuaLane(spec: CuaLaneSpec, deps: CuaLaneDeps): Promise< } if (session) { - await mkdir(path.dirname(path.join(deps.artifactRoot, spec.traceArtifactPath)), { recursive: true }); - await writeFile(path.join(deps.artifactRoot, spec.traceArtifactPath), `${JSON.stringify(session.trace, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(deps.artifactRoot, spec.traceArtifactPath, `${JSON.stringify(session.trace, null, 2)}\n`, "utf8"); if (session.trace.redaction.screenshots === "raw") { warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle."); } @@ -1526,8 +1542,7 @@ async function runInProcessLane(spec: CuaLaneSpec, deps: CuaLaneDeps): Promise { const { config, dryRun } = options; - const cwd = path.resolve(options.cwd); + // Capture the physical project before reading or invoking any caller hook. A supported + // symlink cwd remains valid, but retargeting that alias from a hook cannot redirect source + // reads, local-tree packing, managed run storage, or Observer output into another project. + const physicalCwd = await realpath(path.resolve(options.cwd)); + const projectRoot = await prepareSelectedOutputDirectory(path.dirname(physicalCwd), physicalCwd); + const cwd = projectRoot.physicalPath; const hooks = options.hooks ?? {}; let liveObserver: (ObserverResult & { ok: true }) | undefined; const runtimeStreamUrls: ObserverRuntimeStreamUrl[] = []; @@ -1965,6 +1985,7 @@ export async function runCuaActorLab(options: RunCuaActorLabOptions): Promise 0) { @@ -2385,31 +2408,31 @@ function buildLaneSummary(outcomes: LaneRunOutcome[] | undefined, laneCount: num } async function writeCuaRunArtifacts( - cwd: string, - artifactRoot: string, bundle: RunBundle, - updatedAt: string + updatedAt: string, + preparedRunPaths: PreparedRunArtifactPaths ): Promise { + const runPaths = await validatePreparedRunArtifactPaths(preparedRunPaths); const publicBundle: RunBundle = { ...bundle, cwd: PUBLIC_TARGET_CWD }; - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderCuaReviewMarkdown(publicBundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); - await mkdir(path.join(artifactRoot, "observer"), { recursive: true }); - await writeFile( - path.join(artifactRoot, "observer", "observer-data.json"), + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderCuaReviewMarkdown(publicBundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile( + runPaths, + "observer/observer-data.json", `${JSON.stringify(buildObserverData(publicBundle), null, 2)}\n`, "utf8" ); - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), + await writePreparedRunLatestPointer( + runPaths, `${JSON.stringify({ schema: "humanish.latest-run.v1", runId: publicBundle.runId, - path: path.join(".humanish", "runs", publicBundle.runId), + path: runPaths.relativeRunRoot, updatedAt }, null, 2)}\n`, "utf8" diff --git a/src/e2b-terminal-lab.ts b/src/e2b-terminal-lab.ts index ef3fabc..9e4355f 100644 --- a/src/e2b-terminal-lab.ts +++ b/src/e2b-terminal-lab.ts @@ -38,7 +38,7 @@ // sandbox it did not create. A live run that cannot prove teardown fails closed. import { randomBytes, randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { realpath } from "node:fs/promises"; import path from "node:path"; import type { ActorCompletionReason, ActorPersonaRef, ActorStatus, ActorTrace, ActorTraceItem } from "./actor-contract.js"; @@ -54,6 +54,8 @@ import { } from "./e2b-desktop-launch.js"; import { renderObserver, type ObserverResult } from "./observer.js"; import { digestText, redactedTail, redactText } from "./redaction.js"; +import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js"; +import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js"; import { buildRunSource, extractLocalActorVerdict, @@ -326,12 +328,12 @@ export async function runTerminalProductLab(options: RunTerminalProductLabOption const persona: ActorPersonaRef = { id: personaId, traitsApplied: [], promptDigest }; const runId = options.runId ?? makeTerminalRunId(); - const artifactRoot = path.join(cwd, ".humanish", "runs", runId); + const physicalCwd = await realpath(cwd); + const runPaths = await prepareRunArtifactPaths(physicalCwd, runId); const createdAt = new Date().toISOString(); - await mkdir(artifactRoot, { recursive: true }); const source = await buildRunSource({ capturedAt: createdAt, - cwd, + cwd: physicalCwd, humanishSource: "present", packageName: "humanish" }); @@ -359,23 +361,24 @@ export async function runTerminalProductLab(options: RunTerminalProductLabOption source }); - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderTerminalReviewMarkdown(bundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderTerminalReviewMarkdown(bundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); // Keep `verify --run latest` honest: point it at THIS run (mirrors run.ts's RunPointer). - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), + await writePreparedRunLatestPointer( + runPaths, `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, - path: path.join(".humanish", "runs", runId), + path: runPaths.relativeRunRoot, updatedAt: createdAt }, null, 2)}\n`, "utf8" ); - const observer = await render(cwd, runId, { open: options.open === true }); + const observer = await render(physicalCwd, runId, { open: options.open === true }); + await validatePreparedRunArtifactPaths(runPaths); const ok = observer.ok; return { @@ -897,10 +900,10 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise const sanitize = (text: string): string => redactText(scrubKnownValues(text)); const runId = options.runId ?? makeTerminalRunId(); - const artifactRoot = path.join(cwd, ".humanish", "runs", runId); + const physicalCwd = await realpath(cwd); + const runPaths = await prepareRunArtifactPaths(physicalCwd, runId); const createdAt = nowIso(); - await mkdir(artifactRoot, { recursive: true }); - const source = await buildRunSource({ capturedAt: createdAt, cwd, humanishSource: "present", packageName: "humanish" }); + const source = await buildRunSource({ capturedAt: createdAt, cwd: physicalCwd, humanishSource: "present", packageName: "humanish" }); const e2bApiKey = env.E2B_API_KEY?.trim() ?? ""; @@ -940,6 +943,7 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise try { sandboxModule = await (hooks.loadModule ?? loadE2BDesktopModule)(); + await validatePreparedRunArtifactPaths(runPaths); // SAFETY CONTRACT ITEM 1 (enforced HERE): Sandbox.create carries metadata (positive allowlist) // + lifecycle kill-on-timeout, and DELIBERATELY NO `envs` — the runtime key is NEVER passed // sandbox-global. It is injected ONLY into the per-command codex `envs` below. @@ -952,6 +956,7 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise // NOTE: no `envs` key — see the credential boundary above. (A sandbox-global key would leak // into every process in the sandbox; command-scoped bounds it to the codex invocation.) }); + await validatePreparedRunArtifactPaths(runPaths); sandboxId = sandbox.sandboxId; recordLifecycle("terminal-lab.sandbox.created", `E2B shell sandbox ${sandboxId} created with positive-allowlist metadata and kill-on-timeout; NO sandbox-global env (runtime key is command-scoped).`); @@ -1116,6 +1121,7 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise // slice (no signal yet — SLICE 4). The costProbe hook lets the deterministic test inject KNOWN // spend to exercise the fail-closed cap without a real billable run. const injectedLines = hooks.costProbe?.({ ...(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd }) }); + if (hooks.costProbe) await validatePreparedRunArtifactPaths(runPaths); const cost = buildCostLedger({ ...(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd }), ...(injectedLines ? { injectedLines } : {}) @@ -1158,14 +1164,15 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise noSpendProof }; - await writeFile( - path.join(artifactRoot, TERMINAL_EVENTS_ARTIFACT), + await writeContainedOutputFile( + runPaths, + TERMINAL_EVENTS_ARTIFACT, `${terminalEvents.map((e) => JSON.stringify(e)).join("\n")}${terminalEvents.length > 0 ? "\n" : ""}`, "utf8" ); - await writeFile(path.join(artifactRoot, TERMINAL_TRANSCRIPT_ARTIFACT), `${normalizedTranscript}\n`, "utf8"); - await writeFile(path.join(artifactRoot, TERMINAL_LEDGERS_ARTIFACT), `${JSON.stringify(ledgers, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "actor.json"), `${JSON.stringify(trace, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, TERMINAL_TRANSCRIPT_ARTIFACT, `${normalizedTranscript}\n`, "utf8"); + await writeContainedOutputFile(runPaths, TERMINAL_LEDGERS_ARTIFACT, `${JSON.stringify(ledgers, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "actor.json", `${JSON.stringify(trace, null, 2)}\n`, "utf8"); const bundle = buildLiveTerminalProductBundle({ actorId: descriptorId, @@ -1203,18 +1210,20 @@ async function runLiveTerminalSession(args: RunLiveTerminalSessionArgs): Promise // the rest of the bundle does (the adapter is trusted in-repo code, but the harness never relies // on that for secret values) and are validated fail-closed by the bundle verifier downstream. await applyAdapterExtensionSeam({ hooks, bundle, trace, ledgers, product: product.name, labId: config.id, runId, sanitize, warnings }); - - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderTerminalReviewMarkdown(bundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), - `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, path: path.join(".humanish", "runs", runId), updatedAt: createdAt }, null, 2)}\n`, + await validatePreparedRunArtifactPaths(runPaths); + + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderTerminalReviewMarkdown(bundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, path: runPaths.relativeRunRoot, updatedAt: createdAt }, null, 2)}\n`, "utf8" ); - const observer = await render(cwd, runId, { open: options.open === true }); + const observer = await render(physicalCwd, runId, { open: options.open === true }); + await validatePreparedRunArtifactPaths(runPaths); // The lab's exit code: verified evidence AND no harness error AND proven cleanup. A blocked/ // timed-out agent run is STILL ok-as-evidence at the bundle level (the failure is the evidence), @@ -1351,27 +1360,95 @@ function isAdapterScoreShape(value: unknown): value is RunAdapterScore { && typeof (value as RunAdapterScore).summary === "string"; } -/** Structural guard for an adapter-returned feedback candidate. Requires the core shape AND (when an - * adapter block is present) a non-empty namespace + data record — so a malformed product-noun block - * fails closed at the seam. */ +/** Structural guard for an adapter-returned feedback candidate. This mirrors run.ts's full + * isRunFeedbackCandidate predicate, including its local evidence-path contract, so a malformed + * candidate is dropped at the extension seam instead of poisoning the persisted bundle. */ function isAdapterFeedbackCandidateShape(value: unknown): value is RunFeedbackCandidate { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const candidate = value as Partial; - const baseOk = candidate.schema === "humanish.feedback-candidate.v1" - && typeof candidate.id === "string" - && typeof candidate.summary === "string" && candidate.summary.trim().length > 0 - && Array.isArray(candidate.evidence) - && typeof candidate.redaction === "object" && candidate.redaction !== null && candidate.redaction.status === "passed"; - if (!baseOk) return false; - if (candidate.adapter !== undefined) { - const adapter = candidate.adapter; - if (typeof adapter !== "object" || adapter === null - || typeof adapter.namespace !== "string" || adapter.namespace.trim().length === 0 - || typeof adapter.data !== "object" || adapter.data === null || Array.isArray(adapter.data)) { - return false; - } - } - return true; + return isAdapterRecord(value) + && value.schema === "humanish.feedback-candidate.v1" + && typeof value.id === "string" + && typeof value.run_id === "string" + && (typeof value.stream_id === "string" || value.stream_id === undefined) + && typeof value.adapter_id === "string" + && typeof value.scenario_id === "string" + && typeof value.persona_id === "string" + && isAdapterFeedbackActor(value.actor) + && isAdapterFeedbackSubstrate(value.substrate) + && isAdapterFeedbackFailureOwner(value.failure_owner) + && typeof value.summary === "string" + && value.summary.trim().length > 0 + && typeof value.expected === "string" + && typeof value.actual === "string" + && Array.isArray(value.evidence) + && value.evidence.every(isAdapterFeedbackEvidence) + && isAdapterRecord(value.redaction) + && value.redaction.status === "passed" + && typeof value.redaction.notes === "string" + && typeof value.idempotency_key === "string" + && isAdapterFeedbackNextState(value.proposed_next_state) + && Array.isArray(value.acceptance_proof) + && value.acceptance_proof.every((item) => typeof item === "string") + && (value.adapter === undefined || ( + isAdapterRecord(value.adapter) + && typeof value.adapter.namespace === "string" + && value.adapter.namespace.trim().length > 0 + && isAdapterRecord(value.adapter.data) + )); +} + +function isAdapterFeedbackEvidence(value: unknown): value is RunFeedbackCandidate["evidence"][number] { + return isAdapterRecord(value) + && typeof value.path === "string" + && value.path.length > 0 + && !path.isAbsolute(value.path) + && !value.path.includes("://") + && !value.path.includes("..") + && ( + value.kind === "review" + || value.kind === "state" + || value.kind === "log" + || value.kind === "trace" + || value.kind === "screenshot" + || value.kind === "filesystem" + ) + && typeof value.note === "string"; +} + +function isAdapterFeedbackActor(value: unknown): value is RunFeedbackCandidate["actor"] { + return value === "codex-tui" + || value === "codex-exec" + || value === "codex-app-server" + || value === "synthetic-dry-run" + || value === "unknown"; +} + +function isAdapterFeedbackSubstrate(value: unknown): value is RunFeedbackCandidate["substrate"] { + return value === "e2b-desktop" + || value === "e2b-terminal" + || value === "local-filesystem" + || value === "codex-app-server" + || value === "unknown"; +} + +function isAdapterFeedbackFailureOwner(value: unknown): value is RunFeedbackCandidate["failure_owner"] { + return value === "harness" + || value === "target-app" + || value === "actor" + || value === "environment" + || value === "unknown"; +} + +function isAdapterFeedbackNextState(value: unknown): value is RunFeedbackCandidate["proposed_next_state"] { + return value === "watch" + || value === "adapter-hardening" + || value === "target-app-setup" + || value === "actor-auth" + || value === "setup-quality-review" + || value === "study-quality-review"; +} + +function isAdapterRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } /** diff --git a/src/feedback.ts b/src/feedback.ts index f422e83..a30bab9 100644 --- a/src/feedback.ts +++ b/src/feedback.ts @@ -1,8 +1,20 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { realpath } from "node:fs/promises"; import path from "node:path"; -import { loadRunBundle, verifyRun } from "./run.js"; +import { loadRunBundlePrepared, verifyRunPrepared } from "./run.js"; import type { RunBundle, RunFeedbackCandidate, VerifyResult } from "./run.js"; +import { + bindExistingRunArtifactPaths, + isSafeRunIdSegment, + resolveLatestRunDirectory, + type PreparedRunArtifactPaths, + validatePreparedRunArtifactPaths +} from "./run-paths.js"; +import { + bindExistingManagedHumanishOutputDirectory, + readContainedRegularFile, + writeContainedOutputFile +} from "./selected-output-paths.js"; export const FEEDBACK_SCHEMA = "humanish.feedback.v1"; export const FEEDBACK_RESULT_SCHEMA = "humanish.feedback-result.v1"; @@ -56,12 +68,31 @@ export interface FeedbackResult { }; } +type LoadedRunBundle = NonNullable>>; + +interface FeedbackRunContext { + cwd: string; + loaded: LoadedRunBundle; + physicalCwd: string; + preparedRunPaths: PreparedRunArtifactPaths; + storedRunId: string; +} + +interface BoundFeedbackResult { + context?: FeedbackRunContext; + result: FeedbackResult; +} + export async function draftFeedback(cwdInput: string, runInput: string): Promise { + return (await draftFeedbackBound(cwdInput, runInput)).result; +} + +async function draftFeedbackBound(cwdInput: string, runInput: string): Promise { const cwd = path.resolve(cwdInput); - const loaded = await loadRunBundle(cwd, runInput); + const context = await resolveFeedbackRunContext(cwd, runInput); - if (!loaded) { - return { + if (!context) { + return { result: { schema: FEEDBACK_RESULT_SCHEMA, ok: false, cwd, @@ -70,12 +101,17 @@ export async function draftFeedback(cwdInput: string, runInput: string): Promise code: "HUMANISH_RUN_NOT_FOUND", message: `Run not found: ${runInput}` } - }; + } }; } - const verified = await verifyRun(cwd, runInput); + const verified = await verifyRunPrepared( + context.physicalCwd, + context.storedRunId, + context.preparedRunPaths + ); + await validatePreparedRunArtifactPaths(context.preparedRunPaths); if (!verified.ok) { - return { + return { context, result: { schema: FEEDBACK_RESULT_SCHEMA, ok: false, cwd, @@ -84,11 +120,11 @@ export async function draftFeedback(cwdInput: string, runInput: string): Promise code: "HUMANISH_INVALID_RUN_BUNDLE", message: verified.error?.message ?? "Run bundle failed verification." } - }; + } }; } if (verified.shareSafety.status !== "share_ready") { - return { + return { context, result: { schema: FEEDBACK_RESULT_SCHEMA, ok: false, cwd, @@ -98,76 +134,82 @@ export async function draftFeedback(cwdInput: string, runInput: string): Promise code: "HUMANISH_FEEDBACK_SHARE_SAFETY_BLOCKED", message: `Run is ${verified.shareSafety.status}, not share_ready: ${verified.shareSafety.reasons.map((reason) => reason.code).join(", ")}` } - }; + } }; } - const draft = buildDraft(loaded.bundle, loaded.bundlePath); - const feedbackDir = path.join(loaded.runDir, "feedback"); - const draftPath = path.join(feedbackDir, "draft.json"); - await mkdir(feedbackDir, { recursive: true }); - await writeJson(draftPath, draft); + const draft = buildDraft(context.loaded.bundle, context.loaded.bundlePath); + const draftPath = path.join(context.preparedRunPaths.relativeRunRoot, "feedback", "draft.json"); + await writeJson(context.preparedRunPaths, path.join("feedback", "draft.json"), draft); - return { + return { context, result: { schema: FEEDBACK_RESULT_SCHEMA, ok: true, cwd, run: runInput, - draftPath: path.relative(cwd, draftPath), + draftPath, draft - }; + } }; } export async function verifyFeedback(cwdInput: string, runInput: string): Promise { - const drafted = await draftFeedback(cwdInput, runInput); + return (await verifyFeedbackBound(cwdInput, runInput)).result; +} + +async function verifyFeedbackBound(cwdInput: string, runInput: string): Promise { + const drafted = await draftFeedbackBound(cwdInput, runInput); - if (!drafted.ok || !drafted.draft) { + if (!drafted.result.ok || !drafted.result.draft || !drafted.context) { return drafted; } const missingEvidence = []; - for (const evidence of drafted.draft.evidence) { - if (!await fileExists(path.join(drafted.cwd, evidence.path))) { + for (const evidence of drafted.result.draft.evidence) { + if (!await isSafeFeedbackEvidenceFile(drafted.context, evidence.path)) { missingEvidence.push(evidence.path); } } if (missingEvidence.length > 0) { - return { - ...drafted, + return { context: drafted.context, result: { + ...drafted.result, ok: false, error: { code: "HUMANISH_INVALID_FEEDBACK_DRAFT", message: `Feedback evidence missing: ${missingEvidence.join(", ")}` } - }; + } }; } return drafted; } +async function isSafeFeedbackEvidenceFile(context: FeedbackRunContext, evidencePath: string): Promise { + const absolute = path.resolve(context.physicalCwd, evidencePath); + const relative = path.relative(context.preparedRunPaths.physicalRunRoot, absolute); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return false; + } + return await readContainedRegularFile(context.preparedRunPaths, relative) !== null; +} + export async function renderIssueMarkdown( cwdInput: string, runInput: string, repo: string ): Promise { - const verified = await verifyFeedback(cwdInput, runInput); - - if (!verified.ok || !verified.draft || !verified.draftPath) { - return verified; - } + const verified = await verifyFeedbackBound(cwdInput, runInput); - const loaded = await loadRunBundle(verified.cwd, runInput); - if (!loaded) { - return verified; + if (!verified.result.ok || !verified.result.draft || !verified.result.draftPath || !verified.context) { + return verified.result; } - const issueMarkdown = renderMarkdown(verified.draft, repo); - const issuePath = path.join(loaded.runDir, "feedback", "issue.md"); - await writeFile(issuePath, issueMarkdown, "utf8"); + const issueMarkdown = renderMarkdown(verified.result.draft, repo); + const issuePath = path.join(verified.context.preparedRunPaths.relativeRunRoot, "feedback", "issue.md"); + await writeContainedOutputFile(verified.context.preparedRunPaths, path.join("feedback", "issue.md"), issueMarkdown, "utf8"); return { - ...verified, - issuePath: path.relative(verified.cwd, issuePath), + ...verified.result, + issuePath, issueMarkdown }; } @@ -188,9 +230,9 @@ export async function renderIssueUrl(cwdInput: string, runInput: string, repo: s export async function listFeedback(cwdInput: string, runInput: string): Promise { const cwd = path.resolve(cwdInput); - const loaded = await loadRunBundle(cwd, runInput); + const context = await resolveFeedbackRunContext(cwd, runInput); - if (!loaded) { + if (!context) { return { schema: FEEDBACK_RESULT_SCHEMA, ok: false, @@ -203,19 +245,66 @@ export async function listFeedback(cwdInput: string, runInput: string): Promise< }; } - const draftPath = path.join(loaded.runDir, "feedback", "draft.json"); - const draftText = await readTextIfExists(draftPath); - const draft = draftText === null ? undefined : JSON.parse(draftText) as FeedbackDraft; + const draftBytes = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json")); + const draft = draftBytes === null ? undefined : JSON.parse(draftBytes.toString("utf8")) as FeedbackDraft; + const draftPath = path.join(context.preparedRunPaths.relativeRunRoot, "feedback", "draft.json"); return { schema: FEEDBACK_RESULT_SCHEMA, ok: true, cwd, run: runInput, - ...(draft ? { draftPath: path.relative(cwd, draftPath), draft } : {}) + ...(draft ? { draftPath, draft } : {}) }; } +async function resolveFeedbackRunContext(cwd: string, runInput: string): Promise { + let physicalCwd: string; + try { + physicalCwd = await realpath(cwd); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } + let storedRunId = runInput; + let preparedRunPaths: PreparedRunArtifactPaths; + if (runInput === "latest") { + const runsRoot = await bindExistingManagedHumanishOutputDirectory(physicalCwd, "runs"); + if (!runsRoot) return null; + const pointerBytes = await readContainedRegularFile(runsRoot, "latest.json"); + if (!pointerBytes) return null; + const pointer = JSON.parse(pointerBytes.toString("utf8")) as { path?: unknown; runId?: unknown }; + if ( + typeof pointer.runId !== "string" + || typeof pointer.path !== "string" + || !resolveLatestRunDirectory(physicalCwd, { path: pointer.path, runId: pointer.runId }) + ) { + return null; + } + storedRunId = pointer.runId; + preparedRunPaths = await bindExistingRunArtifactPaths(physicalCwd, storedRunId); + if ( + preparedRunPaths.physicalRunsRoot !== runsRoot.physicalPath + || preparedRunPaths.runsRootIdentity.birthtimeNs !== runsRoot.identity.birthtimeNs + || preparedRunPaths.runsRootIdentity.dev !== runsRoot.identity.dev + || preparedRunPaths.runsRootIdentity.ino !== runsRoot.identity.ino + ) { + throw new Error("Feedback runs root changed physical destination."); + } + } else { + if (!isSafeRunIdSegment(runInput)) return null; + const boundRunPaths = await bindExistingRunArtifactPaths(physicalCwd, storedRunId).catch(() => null); + if (!boundRunPaths) return null; + preparedRunPaths = boundRunPaths; + } + const loaded = await loadRunBundlePrepared(physicalCwd, preparedRunPaths); + if (!loaded) return null; + await validatePreparedRunArtifactPaths(preparedRunPaths); + return { cwd, loaded, physicalCwd, preparedRunPaths, storedRunId }; +} + function buildDraft(bundle: RunBundle, bundlePath: string): FeedbackDraft { const candidate = bundle.feedbackCandidates.find((item): item is RunFeedbackCandidate => isUsableFeedbackCandidate(item)); if (candidate) { @@ -375,30 +464,6 @@ function encodeGitHubRepoPath(repo: string): string { return repo.split("/").map((part) => encodeURIComponent(part)).join("/"); } -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -async function readTextIfExists(filePath: string): Promise { - try { - return await readFile(filePath, "utf8"); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return null; - } - - throw error; - } -} - -async function fileExists(filePath: string): Promise { - try { - return (await stat(filePath)).isFile(); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false; - } - - throw error; - } +async function writeJson(root: PreparedRunArtifactPaths, relativePath: string, value: unknown): Promise { + await writeContainedOutputFile(root, relativePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } diff --git a/src/init.ts b/src/init.ts index d2289c3..e70c60d 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { lstat, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { @@ -6,6 +6,14 @@ import { runtimeDirectories, starterFiles } from "./init-templates.js"; +import { + assertPreparedSelectedOutputDirectory, + prepareContainedOutputDirectory, + prepareSelectedOutputDirectory, + readContainedRegularFile, + type PreparedSelectedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; export const INIT_RESPONSE_SCHEMA = "humanish.init-result.v1"; @@ -35,7 +43,8 @@ export interface InitResult { code: | "HUMANISH_CONFIRMATION_REQUIRED" | "HUMANISH_INVALID_CWD" - | "HUMANISH_INVALID_PACKAGE_JSON"; + | "HUMANISH_INVALID_PACKAGE_JSON" + | "HUMANISH_UNSAFE_PROJECT_PATH"; message: string; }; } @@ -55,29 +64,43 @@ interface PackagePlan { } export async function runInit(options: InitOptions): Promise { - const cwd = path.resolve(options.cwd); + const requestedCwd = path.resolve(options.cwd); const mode = getMode(options); const warnings: string[] = []; const changes: InitChange[] = []; const writes: PlannedWrite[] = []; const dirs: Array<{ absolutePath: string; relativePath: string }> = []; - const cwdCheck = await validateCwd(cwd); + const cwdCheck = await validateCwd(requestedCwd); if (cwdCheck) { return { schema: INIT_RESPONSE_SCHEMA, ok: false, mode, - cwd, + cwd: requestedCwd, changes, warnings, error: cwdCheck }; } + const cwd = await realpath(requestedCwd); + const preparedProjectRoot = await prepareSelectedOutputDirectory(path.dirname(cwd), cwd); + const initialPathCheck = await validateInitProjectPaths(cwd); + if (initialPathCheck) { + return { + schema: INIT_RESPONSE_SCHEMA, + ok: false, + mode, + cwd: requestedCwd, + changes, + warnings, + error: initialPathCheck + }; + } for (const file of starterFiles) { const absolutePath = path.join(cwd, file.path); - const existing = await readTextIfExists(absolutePath); + const existing = await readTextIfExists(preparedProjectRoot, file.path); if (existing === null) { changes.push({ @@ -112,7 +135,7 @@ export async function runInit(options: InitOptions): Promise { for (const directory of runtimeDirectories) { const absolutePath = path.join(cwd, directory.path); - const exists = await pathExists(absolutePath); + const exists = await pathExists(preparedProjectRoot, directory.path); changes.push({ path: directory.path, @@ -126,14 +149,14 @@ export async function runInit(options: InitOptions): Promise { } } - const gitignorePlan = await planGitignore(cwd); + const gitignorePlan = await planGitignore(preparedProjectRoot, cwd); changes.push(gitignorePlan.change); if (gitignorePlan.write) { writes.push(gitignorePlan.write); } - const packagePlan = await planPackageJson(cwd); + const packagePlan = await planPackageJson(preparedProjectRoot, cwd); changes.push(packagePlan.change); warnings.push(...packagePlan.warnings); @@ -142,7 +165,7 @@ export async function runInit(options: InitOptions): Promise { schema: INIT_RESPONSE_SCHEMA, ok: false, mode, - cwd, + cwd: requestedCwd, changes, warnings, error: packagePlan.error @@ -158,7 +181,7 @@ export async function runInit(options: InitOptions): Promise { schema: INIT_RESPONSE_SCHEMA, ok: false, mode, - cwd, + cwd: requestedCwd, changes, warnings, error: { @@ -169,13 +192,26 @@ export async function runInit(options: InitOptions): Promise { } if (mode === "applied") { + await assertPreparedSelectedOutputDirectory(preparedProjectRoot); + const applyPathCheck = await validateInitProjectPaths(cwd); + if (applyPathCheck) { + return { + schema: INIT_RESPONSE_SCHEMA, + ok: false, + mode, + cwd: requestedCwd, + changes, + warnings, + error: applyPathCheck + }; + } + for (const directory of dirs) { - await mkdir(directory.absolutePath, { recursive: true }); + await prepareContainedOutputDirectory(preparedProjectRoot, directory.relativePath); } for (const write of writes) { - await mkdir(path.dirname(write.absolutePath), { recursive: true }); - await writeFile(write.absolutePath, write.contents, "utf8"); + await writeContainedOutputFile(preparedProjectRoot, write.relativePath, write.contents, "utf8"); } } @@ -183,12 +219,61 @@ export async function runInit(options: InitOptions): Promise { schema: INIT_RESPONSE_SCHEMA, ok: true, mode, - cwd, + cwd: requestedCwd, changes, warnings }; } +async function validateInitProjectPaths(cwd: string): Promise { + const targets = [ + ...starterFiles.map((file) => ({ path: file.path, kind: "file" as const })), + ...runtimeDirectories.map((directory) => ({ path: directory.path, kind: "directory" as const })), + { path: ".gitignore", kind: "file" as const }, + { path: "package.json", kind: "file" as const } + ]; + + for (const targetSpec of targets) { + const relativePath = targetSpec.path; + const target = path.resolve(cwd, relativePath); + if (!isPathInside(cwd, target)) { + return unsafeProjectPath(relativePath); + } + + const parts = path.relative(cwd, target).split(path.sep).filter(Boolean); + let current = cwd; + for (const [index, part] of parts.entries()) { + current = path.join(current, part); + try { + const stats = await lstat(current); + const isLeaf = index === parts.length - 1; + if ( + stats.isSymbolicLink() + || (!isLeaf && !stats.isDirectory()) + || (isLeaf && targetSpec.kind === "file" && (!stats.isFile() || stats.nlink > 1)) + || (isLeaf && targetSpec.kind === "directory" && !stats.isDirectory()) + ) { + return unsafeProjectPath(relativePath); + } + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + break; + } + return unsafeProjectPath(relativePath); + } + } + } + + return null; +} + +function unsafeProjectPath(relativePath: string): NonNullable { + return { + code: "HUMANISH_UNSAFE_PROJECT_PATH", + message: `Init target must stay inside the project, use the expected regular-file or directory kind, and not traverse symbolic links or hardlinked files: ${relativePath}` + }; +} + function getMode(options: InitOptions): InitMode { if (options.dryRun) { return "dry-run"; @@ -201,10 +286,13 @@ function getMode(options: InitOptions): InitMode { return "needs-confirmation"; } -async function planGitignore(cwd: string): Promise<{ write?: PlannedWrite; change: InitChange }> { +async function planGitignore( + projectRoot: PreparedSelectedOutputDirectory, + cwd: string +): Promise<{ write?: PlannedWrite; change: InitChange }> { const relativePath = ".gitignore"; const absolutePath = path.join(cwd, relativePath); - const existing = await readTextIfExists(absolutePath); + const existing = await readTextIfExists(projectRoot, relativePath); const currentLines = existing?.split(/\r?\n/) ?? []; const envIndex = currentLines.lastIndexOf(".env*"); const envExampleIndex = currentLines.lastIndexOf("!.env.example"); @@ -250,10 +338,10 @@ async function planGitignore(cwd: string): Promise<{ write?: PlannedWrite; chang }; } -async function planPackageJson(cwd: string): Promise { +async function planPackageJson(projectRoot: PreparedSelectedOutputDirectory, cwd: string): Promise { const relativePath = "package.json"; const absolutePath = path.join(cwd, relativePath); - const existing = await readTextIfExists(absolutePath); + const existing = await readTextIfExists(projectRoot, relativePath); if (existing === null) { return { @@ -371,21 +459,37 @@ async function planPackageJson(cwd: string): Promise { }; } -async function readTextIfExists(filePath: string): Promise { +async function readTextIfExists( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + const bytes = await readContainedRegularFile(projectRoot, relativePath); + if (bytes !== null) { + return bytes.toString("utf8"); + } + const target = path.join(projectRoot.physicalPath, relativePath); try { - return await readFile(filePath, "utf8"); + await lstat(target); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { return null; } - throw error; } + throw new Error(unsafeProjectPath(relativePath).message); } -async function pathExists(filePath: string): Promise { +async function pathExists( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + await assertPreparedSelectedOutputDirectory(projectRoot); + const filePath = path.join(projectRoot.physicalPath, relativePath); try { - await stat(filePath); + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(unsafeProjectPath(relativePath).message); + } return true; } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { @@ -428,6 +532,12 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" + || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } diff --git a/src/lab-config.ts b/src/lab-config.ts index 299362a..f3f0a13 100644 --- a/src/lab-config.ts +++ b/src/lab-config.ts @@ -919,6 +919,10 @@ export function cuaLaneCount(config: LabConfig): number { export function cuaLaneValidationReason(config: LabConfig): string | null { const actor = config.actors[0]; const lanes = actor?.lanes; + const structuralReason = laneRosterStructuralValidationReason(config); + if (structuralReason) { + return structuralReason; + } // clone.fanout is a DECLARED behavior change: rejected on the cua route (was inert-warned). // Fan-out is declared via actors[0].count/lanes; subject.clone.fanout never applied here. if (config.subject.clone?.fanout !== undefined) { @@ -960,6 +964,37 @@ export function cuaLaneValidationReason(config: LabConfig): string | null { return null; } +/** + * Engine-level path-token validation for configs supplied directly through the + * public TypeScript/JavaScript API instead of parseLabConfig. + */ +export function laneRosterStructuralValidationReason(config: LabConfig): string | null { + const lanes = config.actors[0]?.lanes; + const seenIds = new Set(); + if (lanes !== undefined) { + if (!Array.isArray(lanes) || lanes.length === 0) { + return "actors[0].lanes must be a non-empty array when set."; + } + for (const [index, lane] of lanes.entries()) { + if (!lane || typeof lane !== "object" || Array.isArray(lane)) { + return `actors[0].lanes[${index}] must be an object.`; + } + const id = lane.id; + if (id === undefined) { + continue; + } + if (typeof id !== "string" || !LANE_ID_PATTERN.test(id) || id.length > LANE_ID_MAX_CHARS) { + return `actors[0].lanes[${index}].id must be a public-safe path token matching ${LANE_ID_PATTERN} and at most ${LANE_ID_MAX_CHARS} chars.`; + } + if (seenIds.has(id)) { + return `actors[0].lanes ids must be unique (duplicate "${id}").`; + } + seenIds.add(id); + } + } + return null; +} + function declaredLaneTargets(config: LabConfig): string[] { return (config.actors[0]?.lanes ?? []) .map((lane) => lane.target) @@ -976,6 +1011,10 @@ function declaredLaneTargets(config: LabConfig): string[] { * never silently downgraded. */ export function sharedWorldValidationReason(config: LabConfig): string | null { + const structuralReason = laneRosterStructuralValidationReason(config); + if (structuralReason) { + return structuralReason; + } if (config.subject.source !== "clone" && config.subject.source !== "local-tree") { return "`subject.topology: shared-world` requires `subject.source: clone` or `subject.source: local-tree` - the shared world is ONE provisioned, served, seeded plane (#164)."; } diff --git a/src/labs.ts b/src/labs.ts index 09183f6..2c08cb1 100644 --- a/src/labs.ts +++ b/src/labs.ts @@ -1,9 +1,17 @@ -import { readdir, readFile, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { lstat, open, readdir, realpath } from "node:fs/promises"; import path from "node:path"; import { parse } from "yaml"; import { parseLabConfig, type LabConfig } from "./lab-config.js"; +import { + assertPreparedSelectedOutputDirectory, + assertSafeOutputPathSegment, + prepareSelectedOutputDirectory, + readContainedRegularFile, + type PreparedSelectedOutputDirectory +} from "./selected-output-paths.js"; export { LAB_CONFIG_SCHEMA } from "./lab-config.js"; export type { LabConfig } from "./lab-config.js"; @@ -63,6 +71,23 @@ export interface LabInspectResult { warnings: string[]; } +type ManifestReadResult = + | { status: "missing" } + | { status: "unsafe"; message: string } + | { status: "ok"; contents: string }; + +interface ManagedDirectoryBinding { + birthtimeNs: bigint; + dev: bigint; + ino: bigint; + physicalPath: string; +} + +type ManagedDirectoryResult = + | { status: "missing" } + | { status: "unsafe"; message: string } + | { status: "ok"; binding: ManagedDirectoryBinding }; + const committedLabsDir = path.join("humanish", "labs"); const ignoredLabsDirs = [ path.join(".humanish", "labs"), @@ -72,74 +97,136 @@ const ignoredLabsDirs = [ export async function resolveLabManifest(cwd: string, lab: string): Promise { const resolvedCwd = path.resolve(cwd); const warnings: string[] = []; - const candidates = labLooksLikePath(lab) - ? [{ origin: "explicit" as const, path: path.resolve(resolvedCwd, lab) }] - : [ - { origin: "committed" as const, path: path.join(resolvedCwd, committedLabsDir, `${lab}.yaml`) }, - { origin: "committed" as const, path: path.join(resolvedCwd, committedLabsDir, `${lab}.yml`) }, - ...ignoredLabsDirs.flatMap((dir) => [ - { origin: "ignored" as const, path: path.join(resolvedCwd, dir, `${lab}.yaml`) }, - { origin: "ignored" as const, path: path.join(resolvedCwd, dir, `${lab}.yml`) } - ]) - ]; + const projectRoot = await bindProjectRoot(resolvedCwd); + if (!projectRoot) { + return invalidLab({ cwd: resolvedCwd, lab, warnings }, "Project root failed containment validation."); + } + + if (labLooksLikePath(lab)) { + const requestedPath = path.resolve(resolvedCwd, lab); + const read = await readExplicitManifest(projectRoot, requestedPath); + if (read.status === "missing") { + return labNotFound(resolvedCwd, lab, warnings); + } + if (read.status === "unsafe") { + return invalidLab({ cwd: resolvedCwd, lab, warnings }, read.message); + } + return parseResolvedLab({ + cwd: resolvedCwd, + lab, + origin: "explicit", + path: requestedPath, + warnings, + contents: read.contents + }); + } + + const candidates = [ + { origin: "committed" as const, relativePath: path.join(committedLabsDir, `${lab}.yaml`) }, + { origin: "committed" as const, relativePath: path.join(committedLabsDir, `${lab}.yml`) }, + ...ignoredLabsDirs.flatMap((dir) => [ + { origin: "ignored" as const, relativePath: path.join(dir, `${lab}.yaml`) }, + { origin: "ignored" as const, relativePath: path.join(dir, `${lab}.yml`) } + ]) + ]; for (const candidate of candidates) { - if (!await fileExists(candidate.path)) { + const requestedPath = path.join(resolvedCwd, candidate.relativePath); + const read = await readManagedManifest(projectRoot, candidate.relativePath); + if (read.status === "missing") { continue; } - + if (read.status === "unsafe") { + return invalidLab({ cwd: resolvedCwd, lab, warnings }, read.message); + } return parseResolvedLab({ cwd: resolvedCwd, lab, origin: candidate.origin, - path: candidate.path, - warnings + path: requestedPath, + warnings, + contents: read.contents }); } - return { - ok: false, - cwd: resolvedCwd, - lab, - error: { - code: "HUMANISH_LAB_NOT_FOUND", - message: `Lab not found: ${lab}. Look in humanish/labs/ or pass a .yaml path.` - }, - warnings - }; + return labNotFound(resolvedCwd, lab, warnings); } export async function listLabManifests(cwd: string): Promise { const resolvedCwd = path.resolve(cwd); const warnings: string[] = []; const labs = new Map(); + const projectRoot = await bindProjectRoot(resolvedCwd); + if (!projectRoot) { + return { + schema: LAB_LIST_SCHEMA, + ok: true, + cwd: resolvedCwd, + labs: [], + warnings: ["Project root failed containment validation; managed lab manifests were skipped."] + }; + } + const dirs = [ - { origin: "committed" as const, dir: path.join(resolvedCwd, committedLabsDir) }, - ...ignoredLabsDirs.map((dir) => ({ origin: "ignored" as const, dir: path.join(resolvedCwd, dir) })) + { origin: "committed" as const, relativeDir: committedLabsDir }, + ...ignoredLabsDirs.map((relativeDir) => ({ origin: "ignored" as const, relativeDir })) ]; for (const entry of dirs) { - const names = await safeReadDir(entry.dir); + const directory = await bindManagedDirectory(projectRoot, entry.relativeDir); + if (directory.status === "missing") { + continue; + } + if (directory.status === "unsafe") { + warnings.push(`${entry.relativeDir}: ${directory.message}`); + continue; + } + + let names: string[]; + try { + names = await readdir(directory.binding.physicalPath); + await assertManagedDirectoryBinding(projectRoot, entry.relativeDir, directory.binding); + } catch { + warnings.push(`${entry.relativeDir}: unsafe managed lab directory; skipped.`); + continue; + } + for (const name of names.filter((value) => value.endsWith(".yaml") || value.endsWith(".yml"))) { - const candidatePath = path.join(entry.dir, name); - const parsed = await parseResolvedLab({ + let relativePath: string; + try { + assertSafeOutputPathSegment(name, "Lab manifest name"); + relativePath = path.join(entry.relativeDir, name); + } catch { + warnings.push(`${entry.relativeDir}: unsafe lab manifest name; skipped.`); + continue; + } + + const requestedPath = path.join(resolvedCwd, relativePath); + const read = await readManagedManifest(projectRoot, relativePath); + if (read.status !== "ok") { + warnings.push(`${relativeToCwd(resolvedCwd, requestedPath)}: ${read.status === "unsafe" ? read.message : "manifest changed while it was listed; skipped."}`); + continue; + } + + const parsed = parseResolvedLab({ cwd: resolvedCwd, lab: name.replace(/\.(?:ya?ml)$/i, ""), origin: entry.origin, - path: candidatePath, - warnings: [] + path: requestedPath, + warnings: [], + contents: read.contents }); if (!parsed.ok) { - warnings.push(`${relativeToCwd(resolvedCwd, candidatePath)}: ${parsed.error.message}`); + warnings.push(`${relativeToCwd(resolvedCwd, requestedPath)}: ${parsed.error.message}`); continue; } - const key = `${parsed.config.id}:${entry.origin}:${relativeToCwd(resolvedCwd, candidatePath)}`; + const key = `${parsed.config.id}:${entry.origin}:${relativeToCwd(resolvedCwd, requestedPath)}`; labs.set(key, { id: parsed.config.id, source: parsed.config.subject.source, origin: entry.origin, - path: relativeToCwd(resolvedCwd, candidatePath), + path: relativeToCwd(resolvedCwd, requestedPath), ...(parsed.config.title ? { title: parsed.config.title } : {}) }); } @@ -179,16 +266,17 @@ export async function inspectLabManifest(cwd: string, lab: string): Promise { + contents: string; +}): LabResolveResult { let raw: unknown; try { - raw = parse(await readFile(args.path, "utf8")); + raw = parse(args.contents); } catch (error: unknown) { return invalidLab(args, error instanceof Error ? error.message : "Lab YAML could not be parsed."); } @@ -212,6 +300,182 @@ async function parseResolvedLab(args: { }; } +async function bindProjectRoot(cwd: string): Promise { + try { + const lexical = await lstat(cwd); + if (!lexical.isDirectory() && !lexical.isSymbolicLink()) { + return null; + } + return await prepareSelectedOutputDirectory(path.dirname(cwd), cwd); + } catch { + return null; + } +} + +async function readManagedManifest( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + const inspected = await inspectManagedPath(projectRoot, relativePath, "file"); + if (inspected.status !== "ok") { + return inspected; + } + const contents = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/")); + if (!contents) { + return { status: "unsafe", message: "Managed lab manifest changed or failed containment validation." }; + } + return { status: "ok", contents: contents.toString("utf8") }; +} + +async function readExplicitManifest( + projectRoot: PreparedSelectedOutputDirectory, + requestedPath: string +): Promise { + try { + await assertPreparedSelectedOutputDirectory(projectRoot); + try { + await lstat(requestedPath); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { status: "missing" }; + } + throw error; + } + + const physicalPath = await realpath(requestedPath); + const before = await lstat(physicalPath, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n) { + return { status: "unsafe", message: "Explicit lab manifest must resolve to a single-link regular file." }; + } + + const handle = await open(physicalPath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat({ bigint: true }); + if ( + !opened.isFile() + || opened.nlink !== 1n + || opened.dev !== before.dev + || opened.ino !== before.ino + ) { + return { status: "unsafe", message: "Explicit lab manifest changed before it could be read safely." }; + } + const contents = await handle.readFile(); + const [currentPhysicalPath, after] = await Promise.all([ + realpath(requestedPath), + lstat(physicalPath, { bigint: true }) + ]); + await assertPreparedSelectedOutputDirectory(projectRoot); + if ( + currentPhysicalPath !== physicalPath + || !after.isFile() + || after.isSymbolicLink() + || after.nlink !== 1n + || after.dev !== before.dev + || after.ino !== before.ino + ) { + return { status: "unsafe", message: "Explicit lab manifest changed while it was being read." }; + } + return { status: "ok", contents: contents.toString("utf8") }; + } finally { + await handle.close(); + } + } catch { + return { status: "unsafe", message: "Explicit lab manifest failed containment validation." }; + } +} + +async function bindManagedDirectory( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + const inspected = await inspectManagedPath(projectRoot, relativePath, "directory"); + if (inspected.status !== "ok") { + return inspected; + } + return { + status: "ok", + binding: { + birthtimeNs: inspected.birthtimeNs, + dev: inspected.dev, + ino: inspected.ino, + physicalPath: inspected.physicalPath + } + }; +} + +async function inspectManagedPath( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string, + expectedKind: "directory" | "file" +): Promise< + | { status: "missing" } + | { status: "unsafe"; message: string } + | { status: "ok"; birthtimeNs: bigint; dev: bigint; ino: bigint; physicalPath: string } +> { + try { + await assertPreparedSelectedOutputDirectory(projectRoot); + const segments = relativePath.replace(/\\/g, "/").split("/"); + let current = projectRoot.physicalPath; + for (const [index, segment] of segments.entries()) { + assertSafeOutputPathSegment(segment, "Managed lab path segment"); + current = path.join(current, segment); + let stats; + try { + stats = await lstat(current, { bigint: true }); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { status: "missing" }; + } + throw error; + } + const leaf = index === segments.length - 1; + if (stats.isSymbolicLink()) { + return { status: "unsafe", message: "Managed lab paths must not contain symbolic links." }; + } + if (!leaf && !stats.isDirectory()) { + return { status: "unsafe", message: "Managed lab path parents must be directories." }; + } + if (leaf && expectedKind === "directory" && !stats.isDirectory()) { + return { status: "unsafe", message: "Managed lab directory has an unsafe file type." }; + } + if (leaf && expectedKind === "file" && (!stats.isFile() || stats.nlink !== 1n)) { + return { status: "unsafe", message: "Managed lab manifest must be a single-link regular file." }; + } + if (leaf) { + await assertPreparedSelectedOutputDirectory(projectRoot); + return { + status: "ok", + birthtimeNs: stats.birthtimeNs, + dev: stats.dev, + ino: stats.ino, + physicalPath: current + }; + } + } + } catch { + return { status: "unsafe", message: "Managed lab path failed containment validation." }; + } + return { status: "missing" }; +} + +async function assertManagedDirectoryBinding( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string, + binding: ManagedDirectoryBinding +): Promise { + await assertPreparedSelectedOutputDirectory(projectRoot); + const current = await inspectManagedPath(projectRoot, relativePath, "directory"); + if ( + current.status !== "ok" + || current.physicalPath !== binding.physicalPath + || current.birthtimeNs !== binding.birthtimeNs + || current.dev !== binding.dev + || current.ino !== binding.ino + ) { + throw new Error("Managed lab directory identity changed after it was bound."); + } +} + function invalidLab(args: { cwd: string; lab: string; @@ -229,6 +493,19 @@ function invalidLab(args: { }; } +function labNotFound(cwd: string, lab: string, warnings: string[]): LabResolveFailure { + return { + ok: false, + cwd, + lab, + error: { + code: "HUMANISH_LAB_NOT_FOUND", + message: `Lab not found: ${lab}. Look in humanish/labs/ or pass a .yaml path.` + }, + warnings + }; +} + function labLooksLikePath(lab: string): boolean { return lab.endsWith(".yaml") || lab.endsWith(".yml") @@ -237,23 +514,11 @@ function labLooksLikePath(lab: string): boolean { || lab.startsWith("."); } -async function fileExists(filePath: string): Promise { - try { - return (await stat(filePath)).isFile(); - } catch { - return false; - } -} - -async function safeReadDir(dir: string): Promise { - try { - return await readdir(dir); - } catch { - return []; - } -} - function relativeToCwd(cwd: string, filePath: string): string { const relative = path.relative(cwd, filePath); return relative && !relative.startsWith("..") ? relative : filePath; } + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/observer-static.ts b/src/observer-static.ts index c7fc64b..6065391 100644 --- a/src/observer-static.ts +++ b/src/observer-static.ts @@ -1,6 +1,7 @@ +import { constants as fsConstants } from "node:fs"; import { createServer } from "node:http"; import type { IncomingMessage, Server, ServerResponse } from "node:http"; -import { readFile, stat } from "node:fs/promises"; +import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; // Loopback-only host for the Observer static server. We never bind 0.0.0.0: @@ -62,6 +63,18 @@ export interface ObserverStaticServer { close(): Promise; } +interface PinnedStaticRoot { + readonly birthtimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly physicalPath: string; +} + +interface PinnedStaticFileIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + export function observerStaticContentType(filePath: string): string { return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; } @@ -77,10 +90,28 @@ export async function respondToObserverStaticRequest( request: Pick, response: ServerResponse ): Promise { - const root = path.resolve(options.root); + try { + const root = await pinStaticRoot(options.root); + await respondToPinnedObserverStaticRequest(root, options, request, response); + } catch { + if (response.headersSent) { + response.end(); + return; + } + writeText(response, 500, "Observer request failed"); + } +} + +async function respondToPinnedObserverStaticRequest( + root: PinnedStaticRoot, + options: ObserverStaticHandlerOptions, + request: Pick, + response: ServerResponse +): Promise { const indexRelative = options.indexPath ?? "index.html"; try { + await assertPinnedStaticRoot(root); const method = request.method ?? "GET"; if (method !== "GET" && method !== "HEAD") { writeText(response, 405, "Method Not Allowed"); @@ -125,33 +156,35 @@ export async function respondToObserverStaticRequest( relative = `${relative}${indexRelative}`; } - const target = path.resolve(root, relative); - if (!isPathInside(root, target)) { + const target = path.resolve(root.physicalPath, relative); + if (!isPathInside(root.physicalPath, target)) { writeText(response, 403, "Forbidden"); return; } let info; try { - info = await stat(target); + info = await lstat(target); } catch { writeText(response, 404, "Not Found"); return; } + if (info.isSymbolicLink()) { + writeText(response, 403, "Forbidden"); + return; + } let filePath = target; if (info.isDirectory()) { filePath = path.resolve(target, indexRelative); - if (!isPathInside(root, filePath)) { + if (!isPathInside(root.physicalPath, filePath)) { writeText(response, 403, "Forbidden"); return; } } - let body: Buffer; - try { - body = await readFile(filePath); - } catch { + const body = await readContainedRegularFile(root, filePath); + if (!body) { writeText(response, 404, "Not Found"); return; } @@ -166,30 +199,85 @@ export async function respondToObserverStaticRequest( return; } response.end(body); - } catch (error) { + } catch { if (response.headersSent) { response.end(); return; } - writeText(response, 500, error instanceof Error ? error.message : String(error)); + writeText(response, 500, "Observer request failed"); + } +} + +async function readContainedRegularFile(root: PinnedStaticRoot, filePathInput: string): Promise { + const filePath = path.resolve(filePathInput); + if (!isPathInside(root.physicalPath, filePath)) return null; + try { + await assertPinnedStaticRoot(root); + const expectedStats = await inspectContainedStaticFile(root, filePath); + if (!expectedStats) { + return null; + } + + const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const openedStats = await handle.stat({ bigint: true }); + if ( + !openedStats.isFile() + || openedStats.nlink !== 1n + || openedStats.dev !== expectedStats.dev + || openedStats.ino !== expectedStats.ino + ) { + return null; + } + const recheckedStats = await inspectContainedStaticFile(root, filePath); + if ( + !recheckedStats + || recheckedStats.dev !== expectedStats.dev + || recheckedStats.ino !== expectedStats.ino + ) { + return null; + } + await assertPinnedStaticRoot(root); + const body = await handle.readFile(); + await assertPinnedStaticRoot(root); + return body; + } finally { + await handle.close(); + } + } catch { + return null; } } export function createObserverStaticHandler( options: ObserverStaticHandlerOptions ): (request: IncomingMessage, response: ServerResponse) => void { + let rootPromise: Promise | undefined; return (request, response) => { - void respondToObserverStaticRequest(options, request, response); + rootPromise ??= pinStaticRoot(options.root); + void rootPromise + .then((root) => respondToPinnedObserverStaticRequest(root, options, request, response)) + .catch(() => { + if (response.headersSent) { + response.end(); + return; + } + writeText(response, 500, "Observer request failed"); + }); }; } export async function serveObserverStatic(options: ObserverStaticServeOptions): Promise { const entryPath = options.entryPath?.replace(/^\/+/, "") ?? ""; - const handler = createObserverStaticHandler({ + const handlerOptions = { root: options.root, ...(options.indexPath === undefined ? {} : { indexPath: options.indexPath }), ...(entryPath ? { redirectRootTo: entryPath } : {}) - }); + }; + const root = await pinStaticRoot(options.root); + const handler = (request: IncomingMessage, response: ServerResponse) => { + void respondToPinnedObserverStaticRequest(root, handlerOptions, request, response); + }; const server = createServer(handler); const port = await listen(server, options.port ?? 0); return { @@ -212,7 +300,59 @@ function writeText(response: ServerResponse, status: number, message: string): v function isPathInside(root: string, candidate: string): boolean { const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return relative === "" + || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +async function inspectContainedStaticFile( + root: PinnedStaticRoot, + filePath: string +): Promise { + const relative = path.relative(root.physicalPath, filePath); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return null; + } + + const segments = relative.split(path.sep).filter(Boolean); + let current = root.physicalPath; + let fileIdentity: PinnedStaticFileIdentity | null = null; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + const stats = await lstat(current, { bigint: true }); + if (stats.isSymbolicLink()) return null; + if (index < segments.length - 1) { + if (!stats.isDirectory()) return null; + } else { + if (!stats.isFile() || stats.nlink !== 1n) return null; + fileIdentity = { dev: stats.dev, ino: stats.ino }; + } + } + + if (await realpath(filePath) !== filePath) return null; + return fileIdentity; +} + +async function pinStaticRoot(rootInput: string): Promise { + const physicalPath = await realpath(path.resolve(rootInput)); + const stats = await lstat(physicalPath, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("Observer static roots must be physical directories."); + } + return Object.freeze({ birthtimeNs: stats.birthtimeNs, dev: stats.dev, ino: stats.ino, physicalPath }); +} + +async function assertPinnedStaticRoot(root: PinnedStaticRoot): Promise { + const stats = await lstat(root.physicalPath, { bigint: true }); + if ( + stats.isSymbolicLink() + || !stats.isDirectory() + || stats.birthtimeNs !== root.birthtimeNs + || stats.dev !== root.dev + || stats.ino !== root.ino + || await realpath(root.physicalPath) !== root.physicalPath + ) { + throw new Error("Observer static root identity changed."); + } } function listen(server: Server, port: number): Promise { diff --git a/src/observer.ts b/src/observer.ts index 9b48b13..6c5bf89 100644 --- a/src/observer.ts +++ b/src/observer.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; import { createServer, type Server, type ServerResponse } from "node:http"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -8,6 +9,17 @@ import { buildObserverData } from "./observer-data.js"; import type { ObserverData } from "./observer-data.js"; import { observerClientJs, observerCss } from "./observer-assets.js"; import { listRuns, loadRunBundle, verifyRun } from "./run.js"; +import { + bindExistingRunArtifactPaths, + isPathInside, + isSafeRunIdSegment, + resolveLatestRunDirectory, + type PreparedRunArtifactPaths, + validatePreparedRunArtifactPaths +} from "./run-paths.js"; +import { + writeContainedOutputFile +} from "./selected-output-paths.js"; export const OBSERVER_SCHEMA = "humanish.observer-result.v1"; @@ -53,7 +65,20 @@ export interface ObserverRuntimeStreamUrl { url: string; } +interface PinnedDirectory { + readonly birthtimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly physicalPath: string; +} + +interface PinnedFileIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + const observerRuntimeStreamUrls = new WeakMap(); +const observerPreparedRunPaths = new WeakMap(); export function attachObserverRuntimeStreamUrls(result: ObserverResult, streams: ObserverRuntimeStreamUrl[]): void { observerRuntimeStreamUrls.set(result, streams.filter((stream) => stream.streamId && stream.url)); @@ -65,59 +90,95 @@ export async function renderObserver( options: ObserverOptions = {} ): Promise { const cwd = path.resolve(cwdInput); - const verified = await verifyRun(cwd, runInput); + let selection: ObserverRunSelection | null; + try { + selection = await resolveObserverRunSelection(cwd, runInput); + } catch { + selection = null; + } + + if (!selection) { + return observerRunError(cwd, runInput, "HUMANISH_RUN_NOT_FOUND", `Run not found: ${runInput}`); + } + + let preparedRunPaths; + try { + const selectedPhysicalCwd = path.dirname(path.dirname(selection.runsRoot.physicalPath)); + preparedRunPaths = await bindExistingRunArtifactPaths(selectedPhysicalCwd, selection.runId); + if ( + preparedRunPaths.physicalRunsRoot !== selection.runsRoot.physicalPath + || preparedRunPaths.physicalRunRoot !== selection.runRoot.physicalPath + || preparedRunPaths.runsRootIdentity.birthtimeNs !== selection.runsRoot.birthtimeNs + || preparedRunPaths.runsRootIdentity.dev !== selection.runsRoot.dev + || preparedRunPaths.runsRootIdentity.ino !== selection.runsRoot.ino + || preparedRunPaths.runRootIdentity.birthtimeNs !== selection.runRoot.birthtimeNs + || preparedRunPaths.runRootIdentity.dev !== selection.runRoot.dev + || preparedRunPaths.runRootIdentity.ino !== selection.runRoot.ino + ) { + throw new Error("Observer run selection changed physical identity."); + } + } catch { + return observerRunError(cwd, runInput, "HUMANISH_INVALID_RUN_BUNDLE", "Observer run storage is unavailable or unsafe."); + } + + await validatePreparedRunArtifactPaths(preparedRunPaths); + const selectedPhysicalCwd = path.dirname(path.dirname(selection.runsRoot.physicalPath)); + const verified = await verifyRun(selectedPhysicalCwd, selection.runId); + await validatePreparedRunArtifactPaths(preparedRunPaths); if (!verified.ok) { - return { - schema: OBSERVER_SCHEMA, - ok: false, + return observerRunError( cwd, - run: runInput, - warnings: [], - error: { - code: verified.error?.code === "HUMANISH_RUN_NOT_FOUND" ? "HUMANISH_RUN_NOT_FOUND" : "HUMANISH_INVALID_RUN_BUNDLE", - message: verified.error?.message ?? "Run bundle failed verification." - } - }; + runInput, + verified.error?.code === "HUMANISH_RUN_NOT_FOUND" ? "HUMANISH_RUN_NOT_FOUND" : "HUMANISH_INVALID_RUN_BUNDLE", + verified.error?.message ?? "Run bundle failed verification." + ); } - const loaded = await loadRunBundle(cwd, runInput); + const loaded = await loadRunBundle(selectedPhysicalCwd, selection.runId); + await validatePreparedRunArtifactPaths(preparedRunPaths); if (!loaded) { - return { - schema: OBSERVER_SCHEMA, - ok: false, - cwd, - run: runInput, - warnings: [], - error: { - code: "HUMANISH_RUN_NOT_FOUND", - message: `Run not found: ${runInput}` - } - }; + return observerRunError(cwd, runInput, "HUMANISH_RUN_NOT_FOUND", `Run not found: ${runInput}`); + } + + if ( + loaded.bundle.runId !== selection.runId + || await realpath(loaded.runDir) !== preparedRunPaths.physicalRunRoot + ) { + throw new Error("Observer output directory does not match the selected run."); } - const observerDir = path.join(loaded.runDir, "observer"); - const observerPath = path.join(observerDir, "index.html"); - const observerDataPath = path.join(observerDir, "observer-data.json"); - const eventsPath = path.join(loaded.runDir, "events.ndjson"); + const observerPath = path.join(preparedRunPaths.physicalRunRoot, "observer", "index.html"); const observerData = buildObserverData(loaded.bundle); - await mkdir(observerDir, { recursive: true }); - await writeJson(observerDataPath, observerData); - await writeFile(observerPath, renderObserverHtml(observerData), "utf8"); + await writeContainedOutputFile( + preparedRunPaths, + path.join("observer", "observer-data.json"), + `${JSON.stringify(observerData, null, 2)}\n`, + "utf8" + ); + await writeContainedOutputFile( + preparedRunPaths, + path.join("observer", "index.html"), + renderObserverHtml(observerData), + "utf8" + ); + await validatePreparedRunArtifactPaths(preparedRunPaths); - const relativeObserverPath = path.relative(cwd, observerPath); + const relativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html"); + const relativeObserverDataPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "observer-data.json"); + const relativeEventsPath = path.join(preparedRunPaths.relativeRunRoot, "events.ndjson"); const observerUrl = pathToFileURL(observerPath).href; const openResult = options.open === true ? openTarget(observerPath) : { opened: false }; - return { + const result: ObserverResult = { schema: OBSERVER_SCHEMA, ok: true, cwd, run: loaded.bundle.runId, observerPath: relativeObserverPath, - observerDataPath: path.relative(cwd, observerDataPath), - eventsPath: path.relative(cwd, eventsPath), + observerDataPath: relativeObserverDataPath, + eventsPath: relativeEventsPath, observerUrl, bundlePath: loaded.bundlePath, opened: openResult.opened, @@ -130,6 +191,53 @@ export async function renderObserver( ...(openResult.warning ? [openResult.warning] : []) ] }; + observerPreparedRunPaths.set(result, preparedRunPaths); + return result; +} + +function observerRunError( + cwd: string, + run: string, + code: NonNullable["code"], + message: string +): ObserverResult { + return { + schema: OBSERVER_SCHEMA, + ok: false, + cwd, + run, + warnings: [], + error: { code, message } + }; +} + +interface ObserverRunSelection { + readonly runId: string; + readonly runRoot: PinnedDirectory; + readonly runsRoot: PinnedDirectory; +} + +async function resolveObserverRunSelection(cwd: string, runInput: string): Promise { + const runsRoot = await pinDirectory(path.join(cwd, ".humanish", "runs")); + if (runInput !== "latest") { + const runRoot = isSafeRunIdSegment(runInput) + ? await pinDirectChildDirectory(runsRoot, runInput) + : null; + return runRoot ? { runId: runInput, runRoot, runsRoot } : null; + } + + const latestBytes = await readContainedFile(runsRoot, path.join(runsRoot.physicalPath, "latest.json")); + if (!latestBytes) return null; + const pointer = JSON.parse(latestBytes.toString("utf8")) as { path?: unknown; runId?: unknown }; + if (typeof pointer.runId !== "string" || typeof pointer.path !== "string" || !isSafeRunIdSegment(pointer.runId)) { + return null; + } + const declared = resolveLatestRunDirectory(cwd, { path: pointer.path, runId: pointer.runId }); + if (!declared) return null; + const expected = path.join(runsRoot.physicalPath, pointer.runId); + const runRoot = await pinDirectChildDirectory(runsRoot, pointer.runId); + if (!runRoot || runRoot.physicalPath !== expected) return null; + return { runId: pointer.runId, runRoot, runsRoot }; } export async function serveObserver( @@ -141,9 +249,24 @@ export async function serveObserver( } const cwd = path.resolve(result.cwd); - const observerPath = path.join(cwd, result.observerPath); - const runRoot = path.dirname(path.dirname(observerPath)); - const proofRoot = path.dirname(runRoot); + const retainedRunPaths = observerPreparedRunPaths.get(result); + const preparedRunPaths = retainedRunPaths + ? await validatePreparedRunArtifactPaths(retainedRunPaths) + : await bindExistingRunArtifactPaths(cwd, result.run); + const runRoot = await pinDirectory(preparedRunPaths.physicalRunRoot); + const proofRoot = await pinDirectory(preparedRunPaths.physicalRunsRoot); + const expectedRelativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html"); + if (result.observerPath !== expectedRelativeObserverPath) { + throw new Error("Observer path does not match the selected run."); + } + // The live server renders observer/index.html from the pinned run bundle on + // each request, so an attached in-progress Observer legitimately has no + // static index file yet. The exact lexical result path was checked above; + // keep serving from the identity-bound physical run root. + const observerPath = path.join(preparedRunPaths.physicalRunRoot, "observer", "index.html"); + if (observerPath !== path.join(runRoot.physicalPath, "observer", "index.html")) { + throw new Error("Observer path does not match the selected run."); + } const runtimeStreamUrls = () => observerRuntimeStreamUrls.get(result) ?? []; const server = createServer(async (request, response) => { try { @@ -156,21 +279,29 @@ export async function serveObserver( } if (url.pathname === "/_humanish/history.json") { - const history = await buildHistoryIndex(cwd); + const history = await buildHistoryIndex(proofRoot); writeResponse(response, 200, JSON.stringify(history, null, 2), "application/json; charset=utf-8"); return; } - const runRoute = matchRunRoute(url.pathname); - if (runRoute) { - const targetRoot = path.join(proofRoot, runRoute.runId); + if (url.pathname.startsWith("/_humanish/runs/")) { + const runRoute = matchRunRoute(url.pathname); + if (!runRoute) { + writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8"); + return; + } + const targetRoot = await pinDirectChildDirectory(proofRoot, runRoute.runId); + if (!targetRoot) { + writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8"); + return; + } await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runtimeStreamUrls()); return; } await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls()); - } catch (error) { - writeResponse(response, 500, error instanceof Error ? error.message : String(error), "text/plain; charset=utf-8"); + } catch { + writeResponse(response, 500, "Observer request failed", "text/plain; charset=utf-8"); } }); @@ -212,12 +343,12 @@ function renderObserverHtml(data: ObserverData): string { } async function serveRunPath( - runRoot: string, + runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls: ObserverRuntimeStreamUrl[] = [] ): Promise { - const root = path.resolve(runRoot); + const root = runRoot.physicalPath; const cleanedRelativePath = relativePath === "" ? "observer/index.html" : relativePath; const filePath = path.resolve(root, cleanedRelativePath); @@ -227,7 +358,7 @@ async function serveRunPath( } if (cleanedRelativePath === "observer/index.html") { - const observerData = await readObserverData(root, runtimeStreamUrls); + const observerData = await readObserverData(runRoot, runtimeStreamUrls); if (!observerData) { writeResponse(response, 404, "Observer data not found", "text/plain; charset=utf-8"); return; @@ -237,7 +368,7 @@ async function serveRunPath( } if (cleanedRelativePath === "observer/observer-data.json") { - const observerData = await readObserverData(root, runtimeStreamUrls); + const observerData = await readObserverData(runRoot, runtimeStreamUrls); if (!observerData) { writeResponse(response, 404, "Observer data not found", "text/plain; charset=utf-8"); return; @@ -247,7 +378,11 @@ async function serveRunPath( } try { - const body = await readFile(filePath); + const body = await readContainedFile(runRoot, filePath); + if (!body) { + writeResponse(response, 404, "Not found", "text/plain; charset=utf-8"); + return; + } response.writeHead(200, { "cache-control": "no-store", "content-type": contentTypeForPath(filePath) @@ -259,7 +394,7 @@ async function serveRunPath( } async function readObserverData( - runRoot: string, + runRoot: PinnedDirectory, runtimeStreamUrls: ObserverRuntimeStreamUrl[] = [] ): Promise { // Best-effort load from either source. Both reads swallow all errors on @@ -268,13 +403,20 @@ async function readObserverData( // by observer-data.json. A transient failure just falls through to the next // source, or to null -> a 404 the poller retries; it must not surface a 500. try { - const bundle = JSON.parse(await readFile(path.join(runRoot, "run.json"), "utf8")) as Parameters[0]; + const bundleBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "run.json")); + if (!bundleBytes) throw new Error("run.json unavailable"); + const bundle = JSON.parse(bundleBytes.toString("utf8")) as Parameters[0]; return withRuntimeStreamUrls(buildObserverData(bundle), runtimeStreamUrls); } catch {} try { + const observerBytes = await readContainedFile( + runRoot, + path.join(runRoot.physicalPath, "observer", "observer-data.json") + ); + if (!observerBytes) throw new Error("observer-data.json unavailable"); return withRuntimeStreamUrls( - JSON.parse(await readFile(path.join(runRoot, "observer", "observer-data.json"), "utf8")) as ObserverData, + JSON.parse(observerBytes.toString("utf8")) as ObserverData, runtimeStreamUrls ); } catch {} @@ -310,15 +452,17 @@ function withRuntimeStreamUrls(data: ObserverData, runtimeStreamUrls: ObserverRu }; } -async function buildHistoryIndex(cwd: string): Promise<{ +async function buildHistoryIndex(proofRoot: PinnedDirectory): Promise<{ latestRunId: string | null; runs: Array<{ runId: string; createdAt: string | null; mode: string | null; href: string; status: string; streamCount: number }>; }> { - const listed = await listRuns(cwd); + await assertPinnedDirectory(proofRoot); + const physicalCwd = path.dirname(path.dirname(proofRoot.physicalPath)); + const listed = await listRuns(physicalCwd); const runs = await Promise.all( listed.runs.slice(0, 80).map(async (run) => { - const root = path.join(cwd, run.path); - const data = await readObserverData(root); + const root = await pinDirectChildDirectory(proofRoot, run.runId); + const data = root ? await readObserverData(root) : null; return { runId: run.runId, createdAt: run.createdAt, @@ -330,20 +474,137 @@ async function buildHistoryIndex(cwd: string): Promise<{ }) ); - return { latestRunId: listed.latest, runs }; + await assertPinnedDirectory(proofRoot); + return { + latestRunId: listed.latest && isSafeRunIdSegment(listed.latest) ? listed.latest : null, + runs + }; } function matchRunRoute(pathname: string): { runId: string; relativePath: string } | null { const match = pathname.match(/^\/_humanish\/runs\/([^/]+)(?:\/(.*))?$/); if (!match) return null; - return { - runId: decodeURIComponent(match[1] ?? ""), - relativePath: decodeURIComponent(match[2] || "observer/index.html") - }; + try { + const runId = decodeURIComponent(match[1] ?? ""); + if (!isSafeRunIdSegment(runId)) return null; + return { + runId, + relativePath: decodeURIComponent(match[2] || "observer/index.html") + }; + } catch { + return null; + } } -function writeJson(filePath: string, value: unknown): Promise { - return writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +async function readContainedFile(root: PinnedDirectory, filePathInput: string): Promise { + const filePath = path.resolve(filePathInput); + if (!isPathInside(root.physicalPath, filePath)) { + return null; + } + + try { + await assertPinnedDirectory(root); + const expectedStats = await inspectContainedRegularFile(root, filePath); + if (!expectedStats) { + return null; + } + + const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const openedStats = await handle.stat({ bigint: true }); + if ( + !openedStats.isFile() + || openedStats.nlink !== 1n + || openedStats.dev !== expectedStats.dev + || openedStats.ino !== expectedStats.ino + ) { + return null; + } + const recheckedStats = await inspectContainedRegularFile(root, filePath); + if ( + !recheckedStats + || recheckedStats.dev !== expectedStats.dev + || recheckedStats.ino !== expectedStats.ino + ) { + return null; + } + await assertPinnedDirectory(root); + const body = await handle.readFile(); + await assertPinnedDirectory(root); + return body; + } finally { + await handle.close(); + } + } catch { + return null; + } +} + +async function inspectContainedRegularFile( + root: PinnedDirectory, + filePath: string +): Promise { + const relative = path.relative(root.physicalPath, filePath); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return null; + } + + const segments = relative.split(path.sep).filter(Boolean); + let current = root.physicalPath; + let fileIdentity: PinnedFileIdentity | null = null; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + const stats = await lstat(current, { bigint: true }); + if (stats.isSymbolicLink()) return null; + if (index < segments.length - 1) { + if (!stats.isDirectory()) return null; + } else { + if (!stats.isFile() || stats.nlink !== 1n) return null; + fileIdentity = { dev: stats.dev, ino: stats.ino }; + } + } + + if (await realpath(filePath) !== filePath) return null; + return fileIdentity; +} + +async function pinDirectory(directoryInput: string): Promise { + const physicalPath = await realpath(path.resolve(directoryInput)); + const stats = await lstat(physicalPath, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("Observer roots must be physical directories."); + } + return Object.freeze({ birthtimeNs: stats.birthtimeNs, dev: stats.dev, ino: stats.ino, physicalPath }); +} + +async function pinDirectChildDirectory(root: PinnedDirectory, name: string): Promise { + if (!isSafeRunIdSegment(name)) return null; + try { + await assertPinnedDirectory(root); + const candidate = path.join(root.physicalPath, name); + const pinned = await pinDirectory(candidate); + if (pinned.physicalPath !== candidate || path.dirname(pinned.physicalPath) !== root.physicalPath) { + return null; + } + await assertPinnedDirectory(root); + return pinned; + } catch { + return null; + } +} + +async function assertPinnedDirectory(root: PinnedDirectory): Promise { + const stats = await lstat(root.physicalPath, { bigint: true }); + if ( + stats.isSymbolicLink() + || !stats.isDirectory() + || stats.birthtimeNs !== root.birthtimeNs + || stats.dev !== root.dev + || stats.ino !== root.ino + || await realpath(root.physicalPath) !== root.physicalPath + ) { + throw new Error("Observer root identity changed."); + } } function writeResponse( @@ -428,11 +689,6 @@ function closeServer(server: Server): Promise { }); } -function isPathInside(root: string, filePath: string): boolean { - const relative = path.relative(root, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => { switch (char) { diff --git a/src/openai-responses-cu.ts b/src/openai-responses-cu.ts index 19ce955..059f6ea 100644 --- a/src/openai-responses-cu.ts +++ b/src/openai-responses-cu.ts @@ -1,9 +1,12 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - import type { ActorCapabilities } from "./actor-contract.js"; import type { CuaAction, CuaProvider, CuaSafetyCheck, CuaTurn, CuaTurnRequest } from "./computer-use.js"; import { redactText } from "./redaction.js"; +import { + prepareContainedOutputFile, + prepareSelectedOutputDirectory, + type PreparedSelectedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; // A public-safe re-derivation of the OpenAI Responses API computer-use provider, // behind the CuaProvider port from src/computer-use.ts. It mirrors the @@ -500,16 +503,27 @@ export function createOpenAiResponsesProvider(options: OpenAiResponsesProviderOp // zero behavior change. The counter is per-provider, so file order is call order. const captureDir = optionalString((options.env ?? process.env)[WIRE_CAPTURE_ENV]?.trim()); let captureCount = 0; + let preparedCaptureRoot: Promise | undefined; + + const prepareNextCapture = async (): Promise => { + if (captureDir === undefined) return undefined; + preparedCaptureRoot ??= prepareSelectedOutputDirectory(process.cwd(), captureDir); + const captureRoot = await preparedCaptureRoot; + await prepareContainedOutputFile(captureRoot, wireCaptureFileName(captureCount + 1)); + return captureRoot; + }; // Persist one successful RESPONSE body, redacted and pretty-printed. Fails loud: // a silent capture failure would mean missing turns in a fixture refresh — the // exact "fixtures drift from the wire" pathology capture exists to prevent. const captureResponse = async (raw: unknown): Promise => { if (captureDir === undefined) return; + const captureRoot = await prepareNextCapture(); + if (!captureRoot) return; captureCount += 1; - await mkdir(captureDir, { recursive: true }); - await writeFile( - path.join(captureDir, wireCaptureFileName(captureCount)), + await writeContainedOutputFile( + captureRoot, + wireCaptureFileName(captureCount), `${JSON.stringify(redactWireJson(raw), null, 2)}\n`, "utf8" ); @@ -532,6 +546,9 @@ export function createOpenAiResponsesProvider(options: OpenAiResponsesProviderOp // ZdrError; any other non-ok status throws with the STATUS ONLY (never the // body, which can echo the input/screenshot). const post = async (body: Record, signal: AbortSignal | undefined): Promise => { + // Preflight the deterministic next capture leaf before any network side + // effect. A hostile generated path must fail with zero provider calls. + await prepareNextCapture(); const headers: Record = { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" @@ -596,6 +613,7 @@ export function createOpenAiResponsesProvider(options: OpenAiResponsesProviderOp // (harness_error) when a state-only executor is paired with it (provider-authoring contract). requiresFrame: true, async nextTurn(req: CuaTurnRequest, signal: AbortSignal): Promise { + await prepareNextCapture(); const ctx = buildContext(req.instructions); const isFirstTurn = lastResponseId === undefined && pendingCallIds.length === 0; diff --git a/src/oss-lab.ts b/src/oss-lab.ts index c1253c3..03852fb 100644 --- a/src/oss-lab.ts +++ b/src/oss-lab.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { lstat, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; @@ -8,6 +8,16 @@ import { runInit } from "./init.js"; import type { InitResult } from "./init.js"; import { renderObserver } from "./observer.js"; import type { ObserverResult } from "./observer.js"; +import { + prepareExclusiveHumanishStorageDirectory, + prepareReusableHumanishStorageDirectory, + resolveHumanishStorageDirectory +} from "./run-paths.js"; +import { + assertPreparedSelectedOutputDirectory, + type PreparedSelectedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; import { doctor, runDryRun, @@ -115,8 +125,8 @@ export async function runOssLab(options: OssLabOptions): Promise { const cwd = path.resolve(options.cwd); const startedAt = new Date().toISOString(); const runId = options.runId ?? makeRunId(); - const reportRoot = path.join(cwd, ".humanish", "lab", "oss", runId); - const sandboxPath = path.join(cwd, ".humanish", "tmp", "oss-lab", runId); + const plannedSandboxPath = resolveHumanishStorageDirectory(cwd, "tmp", "oss-lab", runId); + let sandboxPath: string; const warnings: string[] = []; const repos = normalizeOssRepoSlugs(options.repos); const limit = options.limit ?? repos.length; @@ -134,7 +144,7 @@ export async function runOssLab(options: OssLabOptions): Promise { }, repos: [], runId, - sandboxPath: relativeToCwd(cwd, sandboxPath), + sandboxPath: relativeToCwd(cwd, plannedSandboxPath), startedAt, warnings }; @@ -155,14 +165,19 @@ export async function runOssLab(options: OssLabOptions): Promise { }, repos: [], runId, - sandboxPath: relativeToCwd(cwd, sandboxPath), + sandboxPath: relativeToCwd(cwd, plannedSandboxPath), startedAt, warnings }; } - await mkdir(reportRoot, { recursive: true }); - await mkdir(sandboxPath, { recursive: true }); + const preparedReportRoot = await prepareReusableHumanishStorageDirectory(cwd, "lab", "oss", runId); + const reportRootToken = await pinOssLabDirectory(preparedReportRoot); + const preparedSandboxPath = await prepareExclusiveHumanishStorageDirectory(cwd, "tmp", "oss-lab", runId); + const sandboxToken = await pinOssLabDirectory(preparedSandboxPath); + sandboxPath = sandboxToken.physicalPath; + const publicReportRoot = relativeToCwd(cwd, preparedReportRoot); + const publicSandboxPath = relativeToCwd(cwd, preparedSandboxPath); const repoResults: OssLabRepoResult[] = []; for (const repo of selectedRepos) { @@ -171,10 +186,17 @@ export async function runOssLab(options: OssLabOptions): Promise { let sandboxRemoved = false; if (!options.keep) { - await rm(sandboxPath, { force: true, recursive: true }); + if (!await validatePinnedOssLabDirectory(sandboxToken)) { + throw new Error("OSS lab cleanup must stay inside its prepared storage root."); + } + await rm(sandboxToken.physicalPath, { force: true, recursive: true }); sandboxRemoved = true; } + if (!await validatePinnedOssLabDirectory(reportRootToken)) { + throw new Error("OSS lab report root changed physical identity."); + } + const completedAt = new Date().toISOString(); const result: OssLabResult = { schema: OSS_LAB_SCHEMA, @@ -182,24 +204,46 @@ export async function runOssLab(options: OssLabOptions): Promise { cleanup: { kept: Boolean(options.keep), sandboxRemoved }, completedAt, cwd, - reportJsonPath: relativeToCwd(cwd, path.join(reportRoot, "report.json")), - reportMarkdownPath: relativeToCwd(cwd, path.join(reportRoot, "report.md")), + reportJsonPath: path.join(publicReportRoot, "report.json"), + reportMarkdownPath: path.join(publicReportRoot, "report.md"), repos: repoResults.map((repo) => ({ ...repo, - clonePath: relativeToCwd(cwd, repo.clonePath) + clonePath: path.join(publicSandboxPath, path.relative(sandboxPath, repo.clonePath)) })), runId, - sandboxPath: relativeToCwd(cwd, sandboxPath), + sandboxPath: publicSandboxPath, startedAt, warnings }; - await writeFile(path.join(reportRoot, "report.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); - await writeFile(path.join(reportRoot, "report.md"), renderOssLabMarkdown(result), "utf8"); + await writeContainedOutputFile(reportRootToken, "report.json", `${JSON.stringify(result, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(reportRootToken, "report.md", renderOssLabMarkdown(result), "utf8"); return result; } +async function pinOssLabDirectory(directoryInput: string): Promise { + const physicalPath = await realpath(path.resolve(directoryInput)); + const stats = await lstat(physicalPath, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OSS lab managed roots must be physical directories."); + } + return Object.freeze({ + identity: Object.freeze({ birthtimeNs: stats.birthtimeNs, dev: stats.dev, ino: stats.ino }), + physicalPath, + requestedPath: physicalPath + }); +} + +async function validatePinnedOssLabDirectory(directory: PreparedSelectedOutputDirectory): Promise { + try { + await assertPreparedSelectedOutputDirectory(directory); + return true; + } catch { + return false; + } +} + function makeRunId(): string { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); return `oss-lab-${stamp}-${randomBytes(4).toString("hex")}`; @@ -256,6 +300,18 @@ async function runRepoTrial(args: { }); steps.push(init); + if (!init.ok) { + return { + changedFiles: [], + clonePath, + ok: false, + repo: args.repo, + steps, + url, + warnings + }; + } + const readiness = await measureStep("humanish doctor", async () => { const result: DoctorResult = await doctor(clonePath); return { diff --git a/src/oss-meta-lab.ts b/src/oss-meta-lab.ts index 4346b66..0fbb0a5 100644 --- a/src/oss-meta-lab.ts +++ b/src/oss-meta-lab.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { runDesktopCommandOrThrow } from "./command-failure.js"; @@ -23,6 +23,16 @@ import { validateOssRepoSlug } from "./oss-lab.js"; import { redactOssRemoteTelemetryText } from "./oss-remote-telemetry.js"; +import { + bindExistingRunArtifactPaths, + prepareExclusiveHumanishStorageDirectory, + prepareReusableHumanishStorageDirectory, + type PreparedRunArtifactPaths, + validatePreparedRunArtifactPaths +} from "./run-paths.js"; +import { + writeContainedOutputFile +} from "./selected-output-paths.js"; import { buildRunSource, PUBLIC_TARGET_CWD, @@ -266,6 +276,7 @@ export interface OssMetaLabResult { code: | "HUMANISH_INVALID_OSS_COUNT" | "HUMANISH_INVALID_OSS_REPO" + | "HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED" | "HUMANISH_META_RUN_FAILED"; message: string; }; @@ -291,7 +302,7 @@ export interface OssMetaLabResult { } interface OssMetaLabRuntime { - artifactRoot: string; + artifactRoot: PreparedRunArtifactPaths; assignments: OssMetaLabAssignment[]; createdAt: string; cwd: string; @@ -300,6 +311,7 @@ interface OssMetaLabRuntime { liveRequested: boolean; missingKeys: string[]; persistScreenshots: boolean; + physicalCwd: string; redactRepoNames: boolean; runId: string; source: RunBundle["source"]; @@ -344,6 +356,13 @@ interface OssMetaLabOutcome { verdict: ReviewSummary["verdict"]; } +interface PinnedCleanupDirectory { + readonly birthtimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly physicalPath: string; +} + export function buildOssRepoAssignments(repos: string[], count: number): OssMetaLabAssignment[] { return Array.from({ length: count }, (_, index) => { const repo = repos[index % repos.length]; @@ -372,44 +391,10 @@ export function collectOssMetaLabRemoteEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - const codexApiKey = env.CODEX_API_KEY?.trim() || env.OPENAI_API_KEY?.trim(); - const codexAccessToken = env.CODEX_ACCESS_TOKEN?.trim(); - const codexAppServerUrl = env.HUMANISH_OSS_META_CODEX_APP_SERVER_URL?.trim() - || env.CODEX_APP_SERVER_CLIENT_URL?.trim() - || env.CODEX_APP_SERVER_URL?.trim(); - const githubToken = githubTokenFromEnv(env); - - if (codexApiKey) { - result.HUMANISH_CODEX_API_KEY = codexApiKey; - } - if (codexAccessToken) { - result.HUMANISH_CODEX_ACCESS_TOKEN = codexAccessToken; - } - if (codexAppServerUrl) { - result.HUMANISH_CODEX_APP_SERVER_URL = codexAppServerUrl; - } - if (githubToken) { - result.HUMANISH_GITHUB_TOKEN = githubToken; - } - - return result; -} - function githubTokenFromEnv(env: NodeJS.ProcessEnv): string { return env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim() || env.GITHUB_PAT?.trim() || ""; } -async function withGitHubAskPassEnv( - root: string, - env: NodeJS.ProcessEnv, - callback: (gitEnv: NodeJS.ProcessEnv) => Promise -): Promise { - const gitEnv = await createGitHubAskPassEnv(root, env); - return callback(gitEnv); -} - async function createGitHubAskPassEnv(root: string, env: NodeJS.ProcessEnv): Promise { await mkdir(root, { recursive: true }); const askPassPath = path.join(root, `git-askpass-${randomBytes(4).toString("hex")}.sh`); @@ -486,11 +471,13 @@ export async function preflightOssMetaRepoAccess(args: { }): Promise { const execImpl = args.execFileImpl ?? execFileAsync; const tokenPresent = Boolean(githubTokenFromEnv(args.env)); - const root = path.join(args.cwd, ".humanish", "tmp", `repo-access-${randomBytes(4).toString("hex")}`); + const rootId = `repo-access-${randomBytes(4).toString("hex")}`; + const preparedRoot = await prepareExclusiveHumanishStorageDirectory(args.cwd, "tmp", rootId); + const root = await pinCleanupDirectory(preparedRoot); try { - await mkdir(root, { recursive: true }); - const tokenGitEnv = tokenPresent ? await createGitHubAskPassEnv(root, args.env) : undefined; + await mkdir(root.physicalPath, { recursive: true }); + const tokenGitEnv = tokenPresent ? await createGitHubAskPassEnv(root.physicalPath, args.env) : undefined; const anonymousGitEnv = gitEnvWithoutGitHubToken(args.env); const results: OssMetaLabRepoAccessPreflight[] = []; @@ -499,7 +486,7 @@ export async function preflightOssMetaRepoAccess(args: { let anonymousError: unknown; try { - await runGitRepoAccessProbe(execImpl, root, repoUrl, anonymousGitEnv, args.env); + await runGitRepoAccessProbe(execImpl, root.physicalPath, repoUrl, anonymousGitEnv, args.env); results.push({ ok: true, reason: tokenPresent @@ -517,7 +504,7 @@ export async function preflightOssMetaRepoAccess(args: { let tokenError: unknown; if (tokenGitEnv) { try { - await runGitRepoAccessProbe(execImpl, root, repoUrl, tokenGitEnv, args.env); + await runGitRepoAccessProbe(execImpl, root.physicalPath, repoUrl, tokenGitEnv, args.env); results.push({ ok: true, reason: "GitHub repo clone access preflight passed with token auth after anonymous clone access failed.", @@ -548,10 +535,31 @@ export async function preflightOssMetaRepoAccess(args: { return results; } finally { - await rm(root, { recursive: true, force: true }).catch(() => undefined); + if (await validatePinnedCleanupDirectory(root).catch(() => false)) { + await rm(root.physicalPath, { recursive: true, force: true }).catch(() => undefined); + } } } +async function pinCleanupDirectory(directoryInput: string): Promise { + const physicalPath = await realpath(path.resolve(directoryInput)); + const stats = await lstat(physicalPath, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OSS meta-lab cleanup roots must be physical directories."); + } + return Object.freeze({ birthtimeNs: stats.birthtimeNs, dev: stats.dev, ino: stats.ino, physicalPath }); +} + +async function validatePinnedCleanupDirectory(directory: PinnedCleanupDirectory): Promise { + const stats = await lstat(directory.physicalPath, { bigint: true }); + return !stats.isSymbolicLink() + && stats.isDirectory() + && stats.birthtimeNs === directory.birthtimeNs + && stats.dev === directory.dev + && stats.ino === directory.ino + && await realpath(directory.physicalPath) === directory.physicalPath; +} + export async function preflightOssMetaActorApiKey(args: { env: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; @@ -622,186 +630,15 @@ function actorRequired(env: NodeJS.ProcessEnv): boolean { return env.HUMANISH_OSS_META_REQUIRE_ACTOR === "1"; } -async function createHostActorPlans(args: { +async function createHostActorPlans(_args: { assignments: OssMetaLabAssignment[]; cwd: string; redactRepoNames: boolean; runId: string; }): Promise { - return Promise.all(args.assignments.map((assignment) => - createHostActorPlan({ - assignment, - cwd: args.cwd, - redactRepoNames: args.redactRepoNames, - runId: args.runId - }) - )); -} - -async function createHostActorPlan(args: { - assignment: OssMetaLabAssignment; - cwd: string; - redactRepoNames: boolean; - runId: string; -}): Promise { - const token = repoSlug(args.assignment.repo); - const actorRoot = path.join(args.cwd, ".humanish", "runs", args.runId, "host-actors", token); - const artifactPath = path.join("host-actors", token, "actor-plan.json"); - const tmpRoot = path.join(args.cwd, ".humanish", "tmp", "host-actors", args.runId, token); - const repoDir = path.join(tmpRoot, "repo"); - const planPath = path.join(actorRoot, "actor-plan.json"); - const schemaPath = path.join(tmpRoot, "actor-plan.schema.json"); - await mkdir(actorRoot, { recursive: true }); - - if (args.redactRepoNames) { - const plan = failedHostActorPlan({ - repo: "[redacted-authorized-repo]", - status: "blocked", - summary: "Host Codex actor plans are public-safe artifacts and require non-redacted public repo labels." - }); - await writeJson(planPath, plan); - return { - artifactPath, - error: plan.summary, - plan, - planPath, - repo: args.assignment.repo, - streamId: args.assignment.streamId, - worktreePath: repoDir - }; - } - - try { - await mkdir(tmpRoot, { recursive: true }); - await withGitHubAskPassEnv(tmpRoot, process.env, async (gitEnv) => { - await execFileAsync("git", ["clone", "--depth=1", `https://github.com/${args.assignment.repo}.git`, repoDir], { - cwd: tmpRoot, - env: gitEnv, - maxBuffer: 10 * 1024 * 1024, - timeout: readPositiveInt(process.env.HUMANISH_OSS_META_HOST_CLONE_TIMEOUT_MS, 90_000) - }); - }); - await writeJson(schemaPath, hostActorPlanJsonSchema()); - - const repoContext = await readHostActorRepoContext(repoDir); - const outputPath = path.join(tmpRoot, "codex-last-message.json"); - const codexEnv = hostCodexEnv(process.env); - const codexCommand = [ - "codex exec", - "--ephemeral", - "--ignore-user-config", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "-C", - shellQuote(repoDir), - "--output-schema", - shellQuote(schemaPath), - "--output-last-message", - shellQuote(outputPath), - shellQuote(buildHostActorPrompt(args.assignment.repo, repoContext)), - "< /dev/null" - ].join(" "); - const codexResult = await execFileAsync("bash", ["-lc", codexCommand], { - cwd: repoDir, - env: codexEnv, - maxBuffer: 10 * 1024 * 1024, - timeout: readPositiveInt(process.env.HUMANISH_OSS_META_HOST_ACTOR_TIMEOUT_MS, 240_000) - }); - - const rawPlan = await readFile(outputPath, "utf8").catch(() => { - const stdout = typeof codexResult.stdout === "string" ? codexResult.stdout : ""; - const stderr = typeof codexResult.stderr === "string" ? codexResult.stderr : ""; - return [stdout, stderr].filter((value) => value.trim()).join("\n"); - }); - await writeFile(path.join(actorRoot, "codex-output.txt"), `${sanitizeRemoteLog(rawPlan)}\n`, "utf8"); - const plan = normalizeHostActorPlan(rawPlan, args.assignment.repo); - await writeJson(planPath, plan); - return { - artifactPath, - ...(plan.status === "passed" ? {} : { error: plan.summary }), - plan, - planPath, - repo: args.assignment.repo, - streamId: args.assignment.streamId, - worktreePath: repoDir - }; - } catch (error) { - const plan = failedHostActorPlan({ - repo: args.assignment.repo, - status: "failed", - summary: compactError(error) - }); - await writeJson(planPath, plan); - return { - artifactPath, - error: plan.summary, - plan, - planPath, - repo: args.assignment.repo, - streamId: args.assignment.streamId, - worktreePath: repoDir - }; - } finally { - await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined); - } -} - -function hostCodexEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const allowed = [ - "CODEX_HOME", - "HOME", - "LANG", - "LC_ALL", - "LC_CTYPE", - "LOGNAME", - "PATH", - "SHELL", - "TERM", - "TMP", - "TMPDIR", - "TEMP", - "USER", - "XDG_CACHE_HOME", - "XDG_CONFIG_HOME" - ]; - return Object.fromEntries(allowed.flatMap((name) => { - const value = env[name]; - return value === undefined ? [] : [[name, value]]; - })); -} - -async function readHostActorRepoContext(repoDir: string): Promise { - const packageText = await readFile(path.join(repoDir, "package.json"), "utf8").catch(() => ""); - const readmeText = await readFile(path.join(repoDir, "README.md"), "utf8").catch(() => ""); - const indexText = await readFile(path.join(repoDir, "index.html"), "utf8").catch(() => ""); - let packageSummary = "package.json missing"; - try { - const pkg = JSON.parse(packageText) as { - dependencies?: Record; - devDependencies?: Record; - name?: string; - packageManager?: string; - scripts?: Record; - }; - packageSummary = JSON.stringify({ - name: pkg.name, - packageManager: pkg.packageManager, - scripts: pkg.scripts ?? {}, - dependencies: Object.keys(pkg.dependencies ?? {}).slice(0, 20), - devDependencies: Object.keys(pkg.devDependencies ?? {}).slice(0, 20) - }, null, 2); - } catch {} - - return [ - "package_summary:", - packageSummary.slice(0, 2_500), - "", - "readme_excerpt:", - readmeText.replace(/\s+/g, " ").trim().slice(0, 2_000) || "(missing)", - "", - "index_excerpt:", - indexText.replace(/\s+/g, " ").trim().slice(0, 800) || "(missing)" - ].join("\n"); + throw new Error( + "Host actor planning is disabled until live OSS meta-lab execution has an isolated credential boundary." + ); } function repoAccessFailureReason(args: { @@ -872,152 +709,6 @@ function blockedLiveDesktopsForRepoAccess(args: { }); } -function buildHostActorPrompt(repo: string, repoContext: string): string { - return [ - "You are a public-safe Humanish host actor.", - "Use the bounded public repository context below to author a compact Humanish setup plan.", - "Do not print secrets, environment values, private data, or long source snippets.", - "Do not commit, push, file issues, or mutate remotes.", - "Return only JSON matching the supplied schema.", - "", - `Repository: ${repo}`, - "", - "Plan requirements:", - "- status must be passed if you can infer useful public-safe personas/scenarios.", - "- Include exactly 1 or 2 synthetic personas.", - "- Include exactly 1 or 2 desktop/mobile browser scenarios.", - "- Scenario steps must be concise and public-safe.", - "- recommendedProof should name the strongest Humanish command shape for this repo.", - "- Current Humanish supports `humanish run --app-url --sims 2`; do not invent --browser, --viewport, --persona, or --scenario flags.", - "", - "Bounded public repo context:", - repoContext - ].join("\n"); -} - -function hostActorPlanJsonSchema(): Record { - return { - type: "object", - additionalProperties: false, - required: ["status", "summary", "personas", "scenarios", "recommendedProof"], - properties: { - status: { type: "string", enum: ["passed", "blocked", "failed"] }, - summary: { type: "string" }, - personas: { - type: "array", - minItems: 1, - maxItems: 2, - items: { - type: "object", - additionalProperties: false, - required: ["id", "name", "intent", "traits"], - properties: { - id: { type: "string" }, - name: { type: "string" }, - intent: { type: "string" }, - traits: { - type: "array", - minItems: 1, - maxItems: 5, - items: { type: "string" } - } - } - } - }, - recommendedProof: { type: "string" }, - scenarios: { - type: "array", - minItems: 1, - maxItems: 2, - items: { - type: "object", - additionalProperties: false, - required: ["id", "title", "goal", "steps"], - properties: { - id: { type: "string" }, - title: { type: "string" }, - goal: { type: "string" }, - steps: { - type: "array", - minItems: 2, - maxItems: 8, - items: { type: "string" } - } - } - } - } - } - }; -} - -function normalizeHostActorPlan(raw: string, repo: string): OssMetaLabHostActorPlan { - const parsed = parseJsonObject(raw); - if (!parsed) { - return failedHostActorPlan({ - repo, - status: "failed", - summary: "Host Codex actor did not return parseable JSON." - }); - } - - const personas = Array.isArray(parsed.personas) - ? parsed.personas.map(normalizeHostActorPersona).filter((persona): persona is OssMetaLabHostActorPlan["personas"][number] => persona !== null).slice(0, 2) - : []; - const scenarios = Array.isArray(parsed.scenarios) - ? parsed.scenarios.map(normalizeHostActorScenario).filter((scenario): scenario is OssMetaLabHostActorPlan["scenarios"][number] => scenario !== null).slice(0, 2) - : []; - const status = parsed.status === "blocked" || parsed.status === "failed" ? parsed.status : "passed"; - if (status === "passed" && (personas.length === 0 || scenarios.length === 0)) { - return failedHostActorPlan({ - repo, - status: "failed", - summary: "Host Codex actor plan lacked usable personas or scenarios." - }); - } - - return { - schema: "humanish.oss-host-actor-plan.v1", - generatedAt: new Date().toISOString(), - personas, - recommendedProof: normalizeHostActorRecommendedProof(parsed.recommendedProof), - repo, - scenarios, - source: "local-codex-exec", - status, - summary: cleanHostActorText(parsed.summary, status === "passed" ? "Host Codex actor authored a public-safe Humanish plan." : "Host Codex actor could not author a complete plan.") - }; -} - -function normalizeHostActorPersona(value: unknown): OssMetaLabHostActorPlan["personas"][number] | null { - if (!value || typeof value !== "object") return null; - const candidate = value as Record; - const id = safeArtifactToken(cleanHostActorText(candidate.id, "host-actor-persona")).slice(0, 80) || "host-actor-persona"; - const traits = Array.isArray(candidate.traits) - ? candidate.traits.map((trait) => cleanHostActorText(trait, "")).filter(Boolean).slice(0, 5) - : []; - return { - id, - name: cleanHostActorText(candidate.name, "Host Actor Persona"), - intent: cleanHostActorText(candidate.intent, "Evaluate the app with a public-safe synthetic goal."), - traits: traits.length > 0 ? traits : ["public_safe", "synthetic_user"] - }; -} - -function normalizeHostActorScenario(value: unknown): OssMetaLabHostActorPlan["scenarios"][number] | null { - if (!value || typeof value !== "object") return null; - const candidate = value as Record; - const steps = Array.isArray(candidate.steps) - ? candidate.steps.map((step) => cleanHostActorText(step, "")).filter(Boolean).slice(0, 8) - : []; - if (steps.length === 0) return null; - return { - id: safeArtifactToken(cleanHostActorText(candidate.id, "host-actor-scenario")).slice(0, 80) || "host-actor-scenario", - title: cleanHostActorText(candidate.title, "Host Actor Scenario"), - goal: cleanHostActorText(candidate.goal, "Exercise the primary public-safe app workflow."), - steps - }; -} - export function normalizeHostActorRecommendedProof(value: unknown): string { const proof = cleanHostActorText(value, ""); if (!/\bhumanish\s+run\b/.test(proof)) { @@ -1031,58 +722,6 @@ export function normalizeHostActorRecommendedProof(value: unknown): string { return proof; } -function failedHostActorPlan(args: { - repo: string; - status: "failed" | "blocked"; - summary: string; -}): OssMetaLabHostActorPlan { - return { - schema: "humanish.oss-host-actor-plan.v1", - generatedAt: new Date().toISOString(), - personas: [], - recommendedProof: "Host actor plan was not available.", - repo: args.repo, - scenarios: [], - source: "local-codex-exec", - status: args.status, - summary: cleanHostActorText(args.summary, "Host Codex actor plan failed.") - }; -} - -function parseJsonObject(raw: string): Record | null { - for (const line of raw.split(/\r?\n/).map((value) => value.trim()).filter(Boolean).reverse()) { - if (line.startsWith("{") && line.endsWith("}")) { - try { - const parsed = JSON.parse(line) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record; - } catch {} - } - } - - const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw); - if (fence?.[1]) { - try { - const parsed = JSON.parse(fence[1].trim()) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record; - } catch {} - } - - try { - const parsed = JSON.parse(raw) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; - } catch { - const lastClose = raw.lastIndexOf("}"); - if (lastClose === -1) return null; - for (let start = raw.lastIndexOf("{", lastClose); start >= 0; start = raw.lastIndexOf("{", start - 1)) { - try { - const parsed = JSON.parse(raw.slice(start, lastClose + 1)) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record; - } catch {} - } - return null; - } -} - function cleanHostActorText(value: unknown, fallback: string): string { const text = String(typeof value === "string" ? value : fallback) .replace(/sk-[A-Za-z0-9_-]{20,}/g, "[redacted-openai-key]") @@ -1099,7 +738,6 @@ export async function runOssMetaLab(options: OssMetaLabOptions): Promise 0) { - warnings.push(`Live E2B/Codex launch is waiting on env vars: ${missingKeys.join(", ")}.`); - warnings.push("Observer lanes stay in the live waiting state until keys are present."); - } - if (liveRequested && !githubTokenFromEnv(process.env)) { - warnings.push("No GH_TOKEN, GITHUB_TOKEN, or GITHUB_PAT is present; public repos can clone, but private GitHub repos will fail access preflight."); - } - const assignments = buildOssRepoAssignments(repos, count); const publicAssignments = redactAssignments(assignments, redactRepoNames); const publicRepos = redactRepoNames ? publicAssignments.map((assignment) => assignment.repo) : repos; + + if (liveRequested) { + return { + schema: OSS_META_LAB_SCHEMA, + ok: false, + assignments: publicAssignments, + count, + cwd, + dryRun, + error: { + code: "HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED", + message: "Live OSS meta-lab execution is unavailable because repository-derived instructions require an isolated credential boundary. Use --dry-run." + }, + liveRequested, + repos: publicRepos, + sandboxes: [], + warnings + }; + } + + const codexAppServerMode = codexAppServerModeRequested(process.env, options.codexAppServer === true); + const hostActorMode = liveRequested && hostCodexActorRequested(process.env); + const missingKeys = missingLiveKeys(process.env); const runId = options.runId ?? makeMetaRunId(); + const physicalCwd = await realpath(cwd); const runResult: RunResult = await runDryRun({ - cwd, + cwd: physicalCwd, dryRun: true, runId, simCount: count @@ -1183,19 +835,18 @@ export async function runOssMetaLab(options: OssMetaLabOptions): Promise { +async function writeMetaBundleArtifacts(artifactRoot: PreparedRunArtifactPaths, bundle: RunBundle): Promise { const publicBundle = publicSafeOssMetaBundle(bundle); - await writeJson(path.join(artifactRoot, "run.json"), publicBundle); - await writeJson(path.join(artifactRoot, "review.json"), publicBundle.review); - await writeFile(path.join(artifactRoot, "review.md"), renderMetaReviewMarkdown(publicBundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeJson(artifactRoot, "run.json", publicBundle); + await writeJson(artifactRoot, "review.json", publicBundle.review); + await writeContainedOutputFile(artifactRoot, "review.md", renderMetaReviewMarkdown(publicBundle), "utf8"); + await writeContainedOutputFile(artifactRoot, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); } export function publicSafeOssMetaBundle(bundle: RunBundle): RunBundle { @@ -1587,35 +1240,16 @@ export function startOssMetaLabLiveRefresh( function cleanupOssMetaLabLiveDesktops( liveDesktops: OssMetaLabLiveDesktop[], options: { - includeProviderReadback?: boolean; killSandbox?: (sandboxId: string, requestTimeoutMs: number) => Promise; - listSandboxes?: (request: OssMetaLabProviderListRequest) => Promise; requestTimeoutMs?: number; } = {} ): Promise { - const result = { - sandboxes: liveDesktops.map((desktop) => ({ - repo: desktop.repo, - ...(desktop.sandboxId ? { sandboxId: desktop.sandboxId } : {}), - streamId: desktop.streamId, - urlPresent: Boolean(desktop.url) - })) - }; - - // BY-ID ONLY by default -- never Sandbox.list. This run's own sandboxIds (including ones whose - // bootstrap failed after Sandbox.create succeeded; see launchLiveDesktops above) are already - // tracked in `liveDesktops`, so no account-wide discovery is needed to reclaim what THIS run - // created. Explicit `includeProviderReadback: true` additionally sweeps stale provider-tagged - // sandboxes left by a crashed prior process; only the maintainer-only `humanish lab cleanup oss` - // command (cleanupStaleOssMetaLabSandboxes) needs that, and its own Sandbox.list call is - // further gated behind HUMANISH_OSS_META_ALLOW_PROVIDER_LIST (see listOssMetaLabProviderSandboxIds). - return options.includeProviderReadback === true - ? cleanupOssMetaLabSandboxesAndProviderMatches(result, options) - : cleanupOssMetaLabSandboxes(result, options); -} - -export function sandboxIdsForOssMetaLabCleanup(result: Pick): string[] { - return [...new Set(result.sandboxes.flatMap((sandbox) => sandbox.sandboxId ? [sandbox.sandboxId] : []))]; + const ids = [...new Set(liveDesktops.flatMap((entry) => entry.desktop?.sandboxId ? [entry.desktop.sandboxId] : []))]; + return killOssMetaLabProviderSandboxIds(ids, { + ...(options.killSandbox === undefined ? {} : { killSandbox: options.killSandbox }), + ...(options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: options.requestTimeoutMs }), + skipped: liveDesktops.length - ids.length + }); } export interface OssMetaLabProviderListRequest { @@ -1644,18 +1278,11 @@ export async function cleanupOssMetaLabSandboxesAndProviderMatches( ...(options.listSandboxes === undefined ? {} : { listSandboxes: options.listSandboxes }), requestTimeoutMs }); - const ids = [...new Set([...sandboxIdsForOssMetaLabCleanup(result), ...listed.ids])]; - const cleanup = await cleanupOssMetaLabSandboxes({ - sandboxes: ids.map((sandboxId, index) => ({ - repo: "oss-meta-lab", - sandboxId, - streamId: `provider-${String(index + 1).padStart(2, "0")}`, - urlPresent: false - })) - }, { + const cleanup = await killOssMetaLabProviderSandboxIds(listed.ids, { ...(options.killSandbox === undefined ? {} : { killSandbox: options.killSandbox }), redactIds: true, - requestTimeoutMs + requestTimeoutMs, + skipped: listed.skipped }); const remaining = listed.errors.length > 0 || cleanup.errors.length > 0 ? undefined @@ -1666,10 +1293,16 @@ export async function cleanupOssMetaLabSandboxesAndProviderMatches( return { killed: cleanup.killed, - matched: ids.length, + matched: listed.ids.length, ...(remaining === undefined ? {} : { remaining }), - skipped: result.sandboxes.length - sandboxIdsForOssMetaLabCleanup(result).length + listed.skipped + cleanup.skipped, - errors: [...listed.errors, ...cleanup.errors] + skipped: result.sandboxes.length + cleanup.skipped, + errors: [ + ...(result.sandboxes.length > 0 + ? ["Stored OSS meta-lab sandbox IDs were not used; cleanup requires verified provider metadata."] + : []), + ...listed.errors, + ...cleanup.errors + ] }; } @@ -1680,9 +1313,8 @@ async function listOssMetaLabProviderSandboxIds(options: { let listSandboxes = options.listSandboxes; if (!listSandboxes) { // humanish never enumerates an operator's E2B account by default (see - // docs/principles/invariants-and-defaults.md): reclaiming THIS run's own sandboxes stays - // by-id-only (sandboxIdsForOssMetaLabCleanup / cleanupOssMetaLabSandboxes). Real, account-wide - // Sandbox.list discovery exists ONLY for a maintainer's explicit orphan sweep of a crashed + // docs/principles/invariants-and-defaults.md). Account-wide Sandbox.list discovery exists + // ONLY for a maintainer's explicit orphan sweep of a crashed // prior process (`humanish lab cleanup oss`) and requires this opt-in, set deliberately by the // maintainer running it against their own account -- never a default, never for a shared key. if (process.env.HUMANISH_OSS_META_ALLOW_PROVIDER_LIST !== "1") { @@ -1777,14 +1409,32 @@ function isCleanupEligibleOssMetaLabSandbox(sandbox: E2BSandboxInfo): boolean { export async function cleanupOssMetaLabSandboxes( result: Pick, + _options: { + killSandbox?: (sandboxId: string, requestTimeoutMs: number) => Promise; + redactIds?: boolean; + requestTimeoutMs?: number; + } = {} +): Promise { + return { + killed: 0, + skipped: result.sandboxes.length, + errors: result.sandboxes.some((sandbox) => sandbox.sandboxId) + ? ["Stored OSS meta-lab sandbox IDs cannot authorize provider mutation; use the explicit metadata-verified orphan sweep."] + : [] + }; +} + +async function killOssMetaLabProviderSandboxIds( + idsInput: string[], options: { killSandbox?: (sandboxId: string, requestTimeoutMs: number) => Promise; redactIds?: boolean; requestTimeoutMs?: number; + skipped?: number; } = {} ): Promise { - const ids = sandboxIdsForOssMetaLabCleanup(result); - const skipped = result.sandboxes.length - ids.length; + const ids = [...new Set(idsInput.filter((id) => id.trim()))]; + const skipped = options.skipped ?? 0; if (ids.length === 0) { return { killed: 0, skipped, errors: [] }; } @@ -2862,11 +2512,8 @@ async function launchLiveDesktops( return Promise.all(assignments.map(async (assignment) => { const repoLabel = options.redactRepoNames ? repoArtifactLabel(assignment) : assignment.repo; const hostActorPlanResult = options.hostActorPlansByStream?.get(assignment.streamId); - // Hoisted OUTSIDE the try so a failure after Sandbox.create succeeds (bootstrap/stream) still - // lets the catch branch report the sandboxId it already created. Without this, a live sandbox - // that failed to bootstrap had no id anywhere in this run's own result, and the ONLY way to - // reclaim it was Sandbox.list account-wide discovery. Capturing it here is what lets cleanup - // stay by-id-only for this run (see cleanupOssMetaLabLiveDesktops below). + // Retain the provider object in process memory so attached cleanup can use a trusted handle. + // Durable result IDs are evidence only and never authorize provider mutation. let desktop: E2BDesktopSandbox | undefined; try { desktop = await desktopModule.Sandbox.create({ @@ -2880,8 +2527,7 @@ async function launchLiveDesktops( }, envs: { ...collectOssMetaLabRemoteEnv(process.env), - ...(options.codexAppServerMode ? { HUMANISH_OSS_META_CODEX_APP_SERVER: "1" } : {}), - ...collectOssMetaLabPrivateEnv(process.env) + ...(options.codexAppServerMode ? { HUMANISH_OSS_META_CODEX_APP_SERVER: "1" } : {}) }, resolution: [1440, 960], dpi: 96, @@ -2919,12 +2565,12 @@ async function launchLiveDesktops( } catch (error) { return { error: compactError(error), + ...(desktop ? { desktop } : {}), ...(hostActorPlanResult?.plan ? { hostActorPlan: hostActorPlanResult.plan } : {}), ...(hostActorPlanResult?.artifactPath ? { hostActorPlanPath: hostActorPlanResult.artifactPath } : {}), repo: repoLabel, - // The sandbox may have been created before the failure (e.g. bootstrap threw): if so, - // its id rides the result too, so THIS run's own cleanup can reclaim it by id without - // ever listing the account. + // Keep the id as public-safe lifecycle evidence only. Cleanup authority comes from the + // in-memory provider object above or a separately verified provider-metadata sweep. ...(desktop?.sandboxId ? { sandboxId: desktop.sandboxId } : {}), simId: assignment.simId, streamId: assignment.streamId @@ -3027,7 +2673,7 @@ async function refreshOssMetaLabLiveRuntime( source: runtime.source }); await writeMetaBundleArtifacts(runtime.artifactRoot, bundle); - await renderObserver(runtime.cwd, runtime.runId, { open: false }); + await renderObserver(runtime.physicalCwd, runtime.runId, { open: false }); } async function refreshLiveDesktopProgress( @@ -3104,7 +2750,7 @@ async function readRemoteLogTail( } async function captureLiveDesktopScreenshots( - artifactRoot: string, + artifactRoot: PreparedRunArtifactPaths, liveDesktops: OssMetaLabLiveDesktop[], options: { redactRepoNames: boolean } = { redactRepoNames: false } ): Promise<{ warnings: string[] }> { @@ -3113,8 +2759,6 @@ async function captureLiveDesktopScreenshots( return { warnings: [] }; } - const screenshotRoot = path.join(artifactRoot, "screenshots"); - await mkdir(screenshotRoot, { recursive: true }); const warnings: string[] = []; await Promise.all(candidates.map(async (desktop) => { @@ -3131,8 +2775,7 @@ async function captureLiveDesktopScreenshots( await desktop.desktop.wait(readPositiveInt(process.env.HUMANISH_OSS_META_SCREENSHOT_SETTLE_MS, 2_500)).catch(() => undefined); const bytes = await desktop.desktop.screenshot("bytes"); const fileName = `${safeArtifactToken(desktop.streamId)}.png`; - const screenshotPath = path.join(screenshotRoot, fileName); - await writeFile(screenshotPath, Buffer.from(bytes)); + await writeContainedOutputFile(artifactRoot, path.join("screenshots", fileName), Buffer.from(bytes)); desktop.screenshot = { capturedAt: new Date().toISOString(), observerUrl: `../screenshots/${fileName}`, @@ -3155,7 +2798,7 @@ async function captureLiveDesktopScreenshots( } async function writeActorEvidenceArtifacts( - artifactRoot: string, + artifactRoot: PreparedRunArtifactPaths, liveDesktops: OssMetaLabLiveDesktop[], options: { assignments: OssMetaLabAssignment[]; @@ -3175,12 +2818,6 @@ async function writeActorEvidenceArtifacts( return { warnings: [] }; } - const actorEvidenceRoot = path.join(artifactRoot, "actor-evidence"); - const nestedEvidenceRoot = path.join(artifactRoot, "nested-evidence"); - const setupQualityRoot = path.join(artifactRoot, "setup-quality"); - await mkdir(actorEvidenceRoot, { recursive: true }); - await mkdir(nestedEvidenceRoot, { recursive: true }); - await mkdir(setupQualityRoot, { recursive: true }); let written = 0; for (const desktop of candidates) { @@ -3191,8 +2828,9 @@ async function writeActorEvidenceArtifacts( if (desktop.completion?.actorLastMessageTail) { const relativePath = path.join("actor-evidence", `${baseName}-actor-last-message-tail.txt`); - await writeFile( - path.join(artifactRoot, relativePath), + await writeContainedOutputFile( + artifactRoot, + relativePath, renderPublicSafeActorEvidenceText("actor-last-message", desktop.streamId, desktop.completion.actorLastMessageTail, { providerRuntimeId: options.redactRepoNames ? desktop.sandboxId : undefined, repo: repoForRedaction @@ -3205,8 +2843,9 @@ async function writeActorEvidenceArtifacts( if (desktop.completion?.actorLogTail) { const relativePath = path.join("actor-evidence", `${baseName}-actor-log-tail.txt`); - await writeFile( - path.join(artifactRoot, relativePath), + await writeContainedOutputFile( + artifactRoot, + relativePath, renderPublicSafeActorEvidenceText("actor-log", desktop.streamId, desktop.completion.actorLogTail, { providerRuntimeId: options.redactRepoNames ? desktop.sandboxId : undefined, repo: repoForRedaction @@ -3222,7 +2861,7 @@ async function writeActorEvidenceArtifacts( const snapshot = options.redactRepoNames ? redactSetupQualityRepoMentions(suppressSetupQualityPreviews(desktop.completion.setupQuality), repoForRedaction) : desktop.completion.setupQuality; - await writeJson(path.join(artifactRoot, relativePath), snapshot); + await writeJson(artifactRoot, relativePath, snapshot); actorEvidence.setupQualityPath = relativePath; written += 1; } @@ -3233,7 +2872,7 @@ async function writeActorEvidenceArtifacts( || desktop.completion.nestedVerifyPassed !== undefined )) { const relativePath = path.join("nested-evidence", `${baseName}-nested-proof.json`); - await writeJson(path.join(artifactRoot, relativePath), { + await writeJson(artifactRoot, relativePath, { schema: "humanish.oss-meta-nested-proof.v1", streamId: desktop.streamId, redaction: { @@ -3258,9 +2897,9 @@ async function writeActorEvidenceArtifacts( const tracePath = path.join("codex-app-server", `${baseName}-summary.json`); const eventsPath = path.join("codex-app-server", `${baseName}-events.ndjson`); const transcriptPath = path.join("codex-app-server", `${baseName}-transcript.txt`); - await mkdir(path.join(artifactRoot, "codex-app-server"), { recursive: true }); await writeJson( - path.join(artifactRoot, tracePath), + artifactRoot, + tracePath, isRecord(evidence.traceJson) ? evidence.traceJson : { @@ -3271,8 +2910,8 @@ async function writeActorEvidenceArtifacts( traceText: evidence.traceText ?? "" } ); - await writeFile(path.join(artifactRoot, eventsPath), evidence.eventsText ?? "No app-server event envelope tail captured.\n", "utf8"); - await writeFile(path.join(artifactRoot, transcriptPath), evidence.transcriptText ?? "No app-server transcript tail captured.\n", "utf8"); + await writeContainedOutputFile(artifactRoot, eventsPath, evidence.eventsText ?? "No app-server event envelope tail captured.\n", "utf8"); + await writeContainedOutputFile(artifactRoot, transcriptPath, evidence.transcriptText ?? "No app-server transcript tail captured.\n", "utf8"); evidence.tracePath = tracePath; evidence.eventsPath = eventsPath; evidence.transcriptPath = transcriptPath; @@ -5585,8 +5224,8 @@ async function runDesktopCommand( async function packLocalHumanishPackage(cwd: string, runId: string): Promise { const packageRoot = moduleRoot; - const packDir = path.join(cwd, ".humanish", "tmp", "oss-meta", runId, "package"); - await mkdir(packDir, { recursive: true }); + await prepareReusableHumanishStorageDirectory(cwd, "tmp", "oss-meta", runId); + const packDir = await prepareReusableHumanishStorageDirectory(cwd, "tmp", "oss-meta", runId, "package"); await execFileAsync("pnpm", ["build"], { cwd: packageRoot, env: process.env, @@ -5714,8 +5353,8 @@ function safeArtifactToken(value: string): string { return token || "artifact"; } -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +async function writeJson(artifactRoot: PreparedRunArtifactPaths, relativePath: string, value: unknown): Promise { + await writeContainedOutputFile(artifactRoot, relativePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } // E2B desktop interfaces + the optional-peer loader now live in ./e2b-desktop-launch.js diff --git a/src/program.ts b/src/program.ts index 5f68c62..3f88f9c 100644 --- a/src/program.ts +++ b/src/program.ts @@ -473,8 +473,8 @@ function registerVerifyCommand(parent: Command, io: CliIo): void { function registerCleanupCommand(parent: Command, io: CliIo): void { parent .command("cleanup") - .description("Clean run-owned provider resources by exact recorded id and write a cleanup receipt.") - .summary("Clean run-owned provider resources by exact id.") + .description("Inspect recorded resource evidence and write cleanup.json; stored ids do not authorize provider mutation.") + .summary("Write a resource cleanup inspection receipt.") .option("--run ", "Run id or latest pointer.", "latest") .option("--cwd ", "Target project directory.", ".") .option("--json", JSON_OPTION_DESCRIPTION) @@ -526,7 +526,7 @@ function registerCodexCommands(parent: Command, io: CliIo): void { .option("--cwd ", "Target project directory.", ".") .option("--prompt ", "Prompt to submit to Codex app-server.") .option("--prompt-file ", "Read the Codex app-server prompt from a file.") - .option("--run-root ", "Artifact directory for redacted app-server evidence.", ".humanish/codex-app-server-ui") + .option("--run-root ", "Artifact directory for redacted app-server evidence.") .option("--state-file ", "State JSON file for external observers.") .option("--timeout-ms ", "Actor timeout in milliseconds.", String(240_000)) .option("--port ", "Local browser UI port.", "0") @@ -544,7 +544,7 @@ function registerCodexCommands(parent: Command, io: CliIo): void { port: string; prompt?: string; promptFile?: string; - runRoot: string; + runRoot?: string; sandbox: "read-only" | "workspace-write" | "danger-full-access"; stateFile?: string; timeoutMs: string; @@ -579,7 +579,7 @@ function registerCodexCommands(parent: Command, io: CliIo): void { ...(options.model === undefined ? {} : { model: options.model }), port, prompt, - runRoot: options.runRoot, + ...(options.runRoot === undefined ? {} : { runRoot: options.runRoot }), sandbox: options.sandbox, ...(options.stateFile === undefined ? {} : { stateFile: options.stateFile }), timeoutMs @@ -1221,7 +1221,7 @@ function registerLabCommands(parent: Command, io: CliIo): void { .argument("", "Lab id or .yaml path.") .description("Run a Humanish lab manifest.") .option("--env-file ", "Load a local env file for this lab without persisting values.") - .option("--dry-run", "Render contract evidence without live provider spend.") + .option("--dry-run", "Render contract evidence without live provider spend. The bundled OSS lab defaults to this mode.") .option("--codex-app-server", "Meta only: use Codex app-server client mode for headed desktop actor surfaces.") .option("--open", "Open the observer in the default browser.") .option("--no-open", "Render without opening a browser.") @@ -1252,7 +1252,10 @@ function registerLabCommands(parent: Command, io: CliIo): void { "", "Human watch path:", " humanish watch first-run", - " humanish watch --lab .humanish/labs/local.yaml" + " humanish watch --lab .humanish/labs/local.yaml", + "", + "OSS safety:", + " Live OSS meta-lab manifests fail closed pending credential isolation." ].join("\n") ) .action(async (labName: string, options: LabCommandOptions, command) => { @@ -1276,15 +1279,15 @@ function registerLabCommands(parent: Command, io: CliIo): void { lab .command("oss", { hidden: true }) - .description("Alias: run the bundled OSS meta-lab manifest.") + .description("Alias: run the bundled OSS meta-lab dry-run contract.") .option("--env-file ", "Load a local env file for this lab without persisting values.") .option("--repos ", "Comma-separated GitHub repo slugs.") .option("--repo ", "GitHub repo slug. Repeatable.", collectRepeated, []) - .option("--count ", "Number of headed desktop sims to assign.", String(DEFAULT_OSS_REPOS.length)) + .option("--count ", "Number of contract lanes to assign.", String(DEFAULT_OSS_REPOS.length)) .option("--sims ", "Alias for --count.") .option("--run-id ", "Explicit lab run id.") .option("--cwd ", "Host directory for ignored .humanish lab report.", ".") - .option("--dry-run", "Render the Observer-of-Observers contract without provider spend or live E2B launch.") + .option("--dry-run", "Render the Observer-of-Observers contract without provider spend or live E2B launch (default).") .option("--open", "Open the observer in the default browser.") .option("--no-open", "Render without opening a browser.") .option("--detach", "Render/open once and exit without attached watch server.") @@ -1301,7 +1304,7 @@ function registerLabCommands(parent: Command, io: CliIo): void { "", "Preferred paths:", " humanish watch oss", - " humanish lab run oss", + " humanish lab run oss --dry-run", "", "Repo selection:", " humanish watch --lab .humanish/labs/local-oss.yaml", @@ -1316,15 +1319,13 @@ function registerLabCommands(parent: Command, io: CliIo): void { " humanish lab oss-smoke --limit 1 --keep", "", "Shape:", - " The top-level Observer shows headed E2B desktop lanes. Each desktop clones", - " its assigned authorized repo, sets up Humanish, starts the target app where", - " feasible, opens desktop/mobile app windows plus the nested Observer, and", - " starts a nonblocking Codex actor attempt.", + " The top-level Observer shows contract-only lanes for the selected repo labels.", + " No repo clone, provider sandbox, credential forwarding, or Codex actor runs.", "", "Safety:", - " Only GitHub owner/repo slugs are accepted. Live stream auth URLs are", - " runtime-only. Repo labels are redacted by default when a GitHub token", - " is present; pass --no-redact-repos only for public-safe runs." + " Only GitHub owner/repo slugs are accepted. Live OSS meta-lab execution", + " fails closed pending credential isolation. Repo labels are redacted by", + " default when overridden; use --no-redact-repos only for public-safe repos." ].join("\n") ) .action(async (options: { @@ -1362,6 +1363,7 @@ function registerLabCommands(parent: Command, io: CliIo): void { const countInput = options.sims ?? options.count; const count = parsePositiveInteger(countInput); + const dryRun = options.dryRun ?? true; const port = parseObserverPort(options.port); if (port === null) { const result: OssMetaLabResult = { @@ -1369,12 +1371,12 @@ function registerLabCommands(parent: Command, io: CliIo): void { ok: false, assignments: [], cwd: options.cwd, - dryRun: options.dryRun === true, + dryRun, error: { code: "HUMANISH_META_RUN_FAILED", message: "--port must be an integer between 0 and 65535." }, - liveRequested: options.dryRun !== true, + liveRequested: !dryRun, repos: [...options.repo, ...(options.repos ? [options.repos] : [])], sandboxes: [], warnings: [] @@ -1386,7 +1388,7 @@ function registerLabCommands(parent: Command, io: CliIo): void { const wantsMachine = wantsJson(command); const shouldOpen = options.open === false ? false : options.open === true ? true : !wantsMachine && process.stdout.isTTY === true; - const wantsFollow = !wantsMachine && options.detach !== true && options.dryRun !== true; + const wantsFollow = !wantsMachine && options.detach !== true && !dryRun; const repoOverrideRequested = options.repo.length > 0 || options.repos !== undefined; const redactRepoNames = options.redactRepos ?? (repoOverrideRequested ? true : undefined); let server: ObserverServer | null = null; @@ -1410,7 +1412,7 @@ function registerLabCommands(parent: Command, io: CliIo): void { ...(redactRepoNames === undefined ? {} : { redactRepoNames }), repos: [...options.repo, ...(options.repos ? [options.repos] : [])], ...(count === null ? { count: Number.NaN } : { count }), - ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }), + dryRun, ...(options.runId === undefined ? {} : { runId: options.runId }) }); } catch (error) { diff --git a/src/run-paths.ts b/src/run-paths.ts new file mode 100644 index 0000000..9c84985 --- /dev/null +++ b/src/run-paths.ts @@ -0,0 +1,441 @@ +import { lstat, mkdir, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +export const RUNS_RELATIVE_ROOT = path.join(".humanish", "runs"); +export const LATEST_RUN_RELATIVE_PATH = path.join(RUNS_RELATIVE_ROOT, "latest.json"); + +export interface RunArtifactPaths { + absoluteRunRoot: string; + relativeRunRoot: string; + absoluteLatestPointer: string; + relativeLatestPointer: string; +} + +export interface PreparedRunArtifactPaths extends RunArtifactPaths { + readonly physicalLatestPointer: string; + readonly physicalRunRoot: string; + readonly physicalRunsRoot: string; + readonly runRootIdentity: FileIdentity; + readonly runsRootIdentity: FileIdentity; +} + +interface FileIdentity { + readonly birthtimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; +} + +export interface LatestRunPathPointer { + runId: string; + path: string; +} + +/** + * Run ids are directory names, not paths. Keep the accepted grammar broad so + * existing runs remain readable while rejecting every path-shaped input. + */ +export function isSafeRunIdSegment(runId: string): boolean { + return runId.length > 0 + && runId !== "." + && runId !== ".." + && !runId.includes("/") + && !runId.includes("\\") + && !runId.includes("\0"); +} + +export function resolveRunsRoot(cwdInput: string): string { + return path.resolve(cwdInput, RUNS_RELATIVE_ROOT); +} + +export function resolveRunDirectory(cwdInput: string, runId: string): string { + if (!isSafeRunIdSegment(runId)) { + throw new Error("Run id must be one non-empty path segment."); + } + + const runsRoot = resolveRunsRoot(cwdInput); + const runRoot = path.resolve(runsRoot, runId); + if (!isPathInside(runsRoot, runRoot) || path.dirname(runRoot) !== runsRoot) { + throw new Error("Run directory must stay inside the Humanish runs root."); + } + + return runRoot; +} + +export function tryResolveRunDirectory(cwdInput: string, runId: string): string | null { + try { + return resolveRunDirectory(cwdInput, runId); + } catch { + return null; + } +} + +export function resolveRunArtifactPaths(cwdInput: string, runId: string): RunArtifactPaths { + if (runId === "latest.json") { + throw new Error("Run id is reserved; choose a different run id."); + } + const absoluteRunRoot = resolveRunDirectory(cwdInput, runId); + return { + absoluteRunRoot, + relativeRunRoot: path.join(RUNS_RELATIVE_ROOT, runId), + absoluteLatestPointer: path.resolve(cwdInput, LATEST_RUN_RELATIVE_PATH), + relativeLatestPointer: LATEST_RUN_RELATIVE_PATH + }; +} + +export async function prepareRunArtifactPaths(cwdInput: string, runId: string): Promise { + const paths = resolveRunArtifactPaths(cwdInput, runId); + const prepared = await prepareHumanishStorageDirectory(cwdInput, "runs", runId); + if (prepared !== paths.absoluteRunRoot) { + throw new Error("Run directory resolved outside the expected storage root."); + } + await assertNoSymlinkDescendants(prepared); + await assertRegularFileOrMissing(paths.absoluteLatestPointer); + return validatePreparedRunArtifactPaths(await capturePreparedRunArtifactPaths(paths, prepared)); +} + +export async function validatePreparedRunArtifactPaths( + prepared: PreparedRunArtifactPaths +): Promise { + await validatePreparedRunRootIdentity(prepared); + await assertNoSymlinkDescendants(prepared.physicalRunRoot); + await assertRegularFileOrMissing(prepared.physicalLatestPointer); + return prepared; +} + +/** Cheap revalidation for repeated contained reads/writes within one prepared run. */ +export async function validatePreparedRunRootIdentity( + prepared: PreparedRunArtifactPaths +): Promise { + const [lexicalRunsRoot, lexicalRunRoot] = await Promise.all([ + realpath(path.dirname(prepared.absoluteRunRoot)), + realpath(prepared.absoluteRunRoot) + ]); + if (lexicalRunsRoot !== prepared.physicalRunsRoot || lexicalRunRoot !== prepared.physicalRunRoot) { + throw new Error("Prepared Humanish run storage changed physical destination."); + } + await Promise.all([ + assertDirectoryIdentity(prepared.physicalRunsRoot, prepared.runsRootIdentity), + assertDirectoryIdentity(prepared.physicalRunRoot, prepared.runRootIdentity) + ]); + return prepared; +} + +/** Bind an already-existing safe run as a new identity; this is not prior-preparation proof. */ +export async function bindExistingRunArtifactPaths( + cwdInput: string, + runId: string +): Promise { + const paths = resolveRunArtifactPaths(cwdInput, runId); + const existing = await resolveExistingRunDirectory(cwdInput, runId); + if (!existing || existing !== paths.absoluteRunRoot) { + throw new Error("Run directory is not an existing Humanish run directory."); + } + await assertNoSymlinkDescendants(existing); + await assertRegularFileOrMissing(paths.absoluteLatestPointer); + return validatePreparedRunArtifactPaths(await capturePreparedRunArtifactPaths(paths, existing)); +} + +export async function resolveExistingRunDirectory(cwdInput: string, runId: string): Promise { + const expected = tryResolveRunDirectory(cwdInput, runId); + if (!expected) { + return null; + } + + const existing = await resolveExistingHumanishStorageDirectory(cwdInput, "runs", runId); + return existing === expected ? existing : null; +} + +/** + * A latest pointer is valid only when both fields identify the same direct + * child of .humanish/runs. Callers must never use pointer.path directly. + */ +export function resolveLatestRunDirectory( + cwdInput: string, + pointer: LatestRunPathPointer +): string | null { + const expected = tryResolveRunDirectory(cwdInput, pointer.runId); + if (!expected || !isProjectRelativePath(pointer.path)) { + return null; + } + + const declared = path.resolve(cwdInput, pointer.path); + return declared === expected ? expected : null; +} + +export async function resolveExistingLatestRunDirectory( + cwdInput: string, + pointer: LatestRunPathPointer +): Promise { + const expected = resolveLatestRunDirectory(cwdInput, pointer); + if (!expected) { + return null; + } + + const existing = await resolveExistingRunDirectory(cwdInput, pointer.runId); + return existing === expected ? existing : null; +} + +export async function isRegularHumanishStorageFile( + cwdInput: string, + ...segments: string[] +): Promise { + const parent = await resolveExistingHumanishStorageDirectory(cwdInput, ...segments.slice(0, -1)); + const fileName = segments.at(-1); + if (!parent || !fileName || !isSafeStorageSegment(fileName)) { + return false; + } + + try { + const stats = await lstat(path.join(parent, fileName)); + return stats.isFile() && !stats.isSymbolicLink() && stats.nlink === 1; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +export async function prepareHumanishStorageDirectory( + cwdInput: string, + ...segments: string[] +): Promise { + const prepared = await walkHumanishStorage(cwdInput, segments, true); + if (!prepared) { + throw new Error("Humanish storage directory could not be created."); + } + return prepared; +} + +export async function prepareReusableHumanishStorageDirectory( + cwdInput: string, + ...segments: string[] +): Promise { + const prepared = await prepareHumanishStorageDirectory(cwdInput, ...segments); + await assertNoSymlinkDescendants(prepared); + return prepared; +} + +export async function prepareExclusiveHumanishStorageDirectories( + cwdInput: string, + segmentSets: string[][] +): Promise { + const targets: string[] = []; + for (const segments of segmentSets) { + assertSafeStorageSegments(segments); + const name = segments.at(-1); + if (!name) { + throw new Error("Exclusive Humanish storage directory needs a leaf segment."); + } + const parent = await prepareHumanishStorageDirectory(cwdInput, ...segments.slice(0, -1)); + const target = path.join(parent, name); + try { + await lstat(target); + throw new Error("Humanish storage id already exists; choose a new id."); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + throw error; + } + } + targets.push(target); + } + + for (const target of targets) { + try { + await mkdir(target); + } catch (error) { + if (isNodeError(error) && error.code === "EEXIST") { + throw new Error("Humanish storage id already exists; choose a new id."); + } + throw error; + } + } + return targets; +} + +export async function prepareExclusiveHumanishStorageDirectory( + cwdInput: string, + ...segments: string[] +): Promise { + const [target] = await prepareExclusiveHumanishStorageDirectories(cwdInput, [segments]); + if (!target) { + throw new Error("Exclusive Humanish storage directory was not created."); + } + return target; +} + +export async function prepareHumanishStorageFile( + cwdInput: string, + ...segments: string[] +): Promise { + const fileName = segments.at(-1); + if (!fileName || !isSafeStorageSegment(fileName)) { + throw new Error("Humanish storage file must use a non-empty path segment."); + } + const parent = await prepareHumanishStorageDirectory(cwdInput, ...segments.slice(0, -1)); + const filePath = path.join(parent, fileName); + await assertRegularFileOrMissing(filePath); + return filePath; +} + +export function resolveHumanishStorageDirectory(cwdInput: string, ...segments: string[]): string { + assertSafeStorageSegments(segments); + const humanishRoot = path.resolve(cwdInput, ".humanish"); + const resolved = path.resolve(humanishRoot, ...segments); + if (!isPathInside(humanishRoot, resolved)) { + throw new Error("Humanish storage directory must stay inside .humanish."); + } + return resolved; +} + +export async function resolveExistingHumanishStorageDirectory( + cwdInput: string, + ...segments: string[] +): Promise { + return walkHumanishStorage(cwdInput, segments, false); +} + +export function isPathInside(rootInput: string, candidateInput: string): boolean { + const root = path.resolve(rootInput); + const candidate = path.resolve(candidateInput); + const relative = path.relative(root, candidate); + return relative === "" + || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function isProjectRelativePath(value: string): boolean { + const parts = value.replace(/\\/g, "/").split("/"); + return value.length > 0 + && !path.isAbsolute(value) + && !path.posix.isAbsolute(value) + && !path.win32.isAbsolute(value) + && !value.includes("\0") + && !parts.some((part) => part === "." || part === ".."); +} + +async function walkHumanishStorage( + cwdInput: string, + segments: string[], + create: boolean +): Promise { + assertSafeStorageSegments(segments); + let current = path.resolve(cwdInput); + + for (const segment of [".humanish", ...segments]) { + current = path.join(current, segment); + let stats; + try { + stats = await lstat(current); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + throw error; + } + if (!create) { + return null; + } + try { + await mkdir(current); + } catch (mkdirError) { + if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") { + throw mkdirError; + } + } + stats = await lstat(current); + } + + if (stats.isSymbolicLink()) { + throw new Error("Humanish storage directories must not be symbolic links."); + } + if (!stats.isDirectory()) { + throw new Error("ENOTDIR: Humanish storage path must be a directory."); + } + } + + return current; +} + +function assertSafeStorageSegments(segments: string[]): void { + if (!segments.every(isSafeStorageSegment)) { + throw new Error("Humanish storage paths must use non-empty path segments."); + } +} + +function isSafeStorageSegment(segment: string): boolean { + return segment.length > 0 + && segment !== "." + && segment !== ".." + && !segment.includes("/") + && !segment.includes("\\") + && !segment.includes("\0"); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function assertRegularFileOrMissing(filePath: string): Promise { + try { + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink > 1) { + throw new Error("Humanish storage files must be single-link regular files, not symbolic links or hardlinks."); + } + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return; + } + throw error; + } +} + +async function assertNoSymlinkDescendants(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const child = path.join(directory, entry.name); + const stats = await lstat(child); + if (stats.isSymbolicLink()) { + throw new Error("Humanish run directories must not contain symbolic links."); + } + if (stats.isDirectory()) { + await assertNoSymlinkDescendants(child); + } else if (!stats.isFile() || stats.nlink > 1) { + throw new Error("Humanish run leaves must be single-link regular files, not hardlinks or special files."); + } + } +} + +async function capturePreparedRunArtifactPaths( + paths: RunArtifactPaths, + runRoot: string +): Promise { + const physicalRunRoot = await realpath(runRoot); + const physicalRunsRoot = await realpath(path.dirname(runRoot)); + const [runStats, runsStats] = await Promise.all([ + lstat(physicalRunRoot, { bigint: true }), + lstat(physicalRunsRoot, { bigint: true }) + ]); + if (!runStats.isDirectory() || runStats.isSymbolicLink() || !runsStats.isDirectory() || runsStats.isSymbolicLink()) { + throw new Error("Prepared Humanish run storage must use physical directories."); + } + return Object.freeze({ + ...paths, + physicalLatestPointer: path.join(physicalRunsRoot, "latest.json"), + physicalRunRoot, + physicalRunsRoot, + runRootIdentity: Object.freeze({ birthtimeNs: runStats.birthtimeNs, dev: runStats.dev, ino: runStats.ino }), + runsRootIdentity: Object.freeze({ birthtimeNs: runsStats.birthtimeNs, dev: runsStats.dev, ino: runsStats.ino }) + }); +} + +async function assertDirectoryIdentity(directory: string, identity: FileIdentity): Promise { + const stats = await lstat(directory, { bigint: true }); + if ( + stats.isSymbolicLink() + || !stats.isDirectory() + || stats.birthtimeNs !== identity.birthtimeNs + || stats.dev !== identity.dev + || stats.ino !== identity.ino + || await realpath(directory) !== directory + ) { + throw new Error("Prepared Humanish run storage identity changed."); + } +} diff --git a/src/run.ts b/src/run.ts index 55a1891..5a4b53f 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import type { Dirent } from "node:fs"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -22,6 +21,7 @@ import { } from "./scripted-browser-actor.js"; import { CODEX_APP_SERVER_TRACE_SCHEMA, + runCodexAppServerSessionInPreparedRoot, type CodexAppServerRunResult, type CodexAppServerTrace } from "./codex-app-server.js"; @@ -29,12 +29,37 @@ import { getActor } from "./actor-registry.js"; import { artifactReferenceIfWritten, hasWrittenScreenshot } from "./artifact-reference.js"; import { ACTOR_TRACE_SCHEMA, type ActorTrace } from "./actor-contract.js"; import { captureGitState, GIT_STATE_SCHEMA, type CapturedGitState } from "./core/git-state.js"; +import { inspectVerifiedGitWorkspace } from "./core/git-workspace.js"; import { mapWithConcurrency } from "./concurrency.js"; import { screenshotEvidenceError } from "./image-evidence.js"; import { buildObserverData } from "./observer-data.js"; import { parseResolvedPersona, personaToDirectives, renderPersonaPromptSection, type ResolvedPersona } from "./persona.js"; import { containsSensitive, digestText, redactText, redactToSecretLabel, tailText } from "./redaction.js"; -import { loadE2BDesktopModule, type E2BDesktopModule } from "./e2b-desktop-launch.js"; +import type { E2BDesktopModule } from "./e2b-desktop-launch.js"; +import { + bindExistingRunArtifactPaths, + RUNS_RELATIVE_ROOT, + isSafeRunIdSegment, + prepareRunArtifactPaths, + resolveExistingRunDirectory, + resolveLatestRunDirectory, + resolveRunsRoot, + validatePreparedRunArtifactPaths, + type PreparedRunArtifactPaths +} from "./run-paths.js"; +import { + assertPreparedSelectedOutputDirectory, + assertSafeOutputPathSegment, + bindExistingManagedHumanishOutputDirectory, + prepareContainedOutputDirectory, + prepareContainedOutputDirectoryRoot, + prepareContainedOutputFile, + prepareSelectedOutputDirectory, + readContainedRegularFile, + type PreparedSelectedOutputDirectory, + writeContainedOutputFile, + writePreparedRunLatestPointer +} from "./selected-output-paths.js"; export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v1"; export const SHARED_WORLD_SCHEMA = "humanish.shared-world.v1"; @@ -46,8 +71,17 @@ export const CLEANUP_SCHEMA = "humanish.cleanup-result.v1"; export const PUBLIC_TARGET_CWD = "[target-cwd]"; const SAFE_GIT_NOTES = new Set([ "Git command could not be started.", + "Git HEAD capture timed out.", + "Git HEAD command could not be started.", + "Git metadata could not be inspected safely.", "Git status command could not be captured.", "Git status command could not be started.", + "Git status could not be captured.", + "Git status capture timed out.", + "Git metadata failed containment validation.", + "Git ref-state capture timed out.", + "Git ref-state command could not be started.", + "Git work-tree detection timed out.", "Git work tree had changes; only counts were captured, not branch names, remotes, paths, or file names.", "Git work tree was clean; branch names, remotes, paths, and file names were not captured.", "No git work tree was detected.", @@ -682,9 +716,9 @@ export interface RunBundle { */ adapterArtifacts?: RunAdapterArtifact[]; /** - * Provider resources this run owns and may clean up later by exact recorded id. - * Optional + additive: absent means the producer did not record any run-owned - * remote resources. Core cleanup never enumerates provider accounts. + * Evidence about mutable provider resources observed during this run. Stored ids + * are not cleanup authority: automatic provider mutation requires a verified + * resource lease. Optional + additive; core never enumerates provider accounts. */ providerResources?: RunProviderResource[]; } @@ -844,6 +878,7 @@ export interface CleanupResult { } export interface RunCleanupHooks { + /** @deprecated Ignored. Stored provider ids are not authority to load or mutate a provider. */ loadDesktopModule?: () => Promise; cleanupAdapterResources?: (ctx: { cwd: string; @@ -955,6 +990,8 @@ export async function runDryRun(options: RunOptions): Promise { }; } + const projectRoot = await prepareSelectedOutputDirectory(path.dirname(cwd), cwd); + if (options.appUrl !== undefined) { if (options.dryRun) { return { @@ -982,19 +1019,19 @@ export async function runDryRun(options: RunOptions): Promise { }; } - return runBrowserAppProof({ ...options, appUrl: options.appUrl, cwd, simCount }); + return runBrowserAppProof({ ...options, appUrl: options.appUrl, cwd, projectRoot, simCount }); } if (!options.dryRun) { const actor = resolveRequestedLocalCodexActor(options.actor); if (actor === "codex-tui") { - return runLocalCodexTui({ ...options, actor, cwd, simCount }); + return runLocalCodexTui({ ...options, actor, cwd, projectRoot, simCount }); } if (actor === "codex-exec") { - return runLocalCodexExec({ ...options, actor, cwd, simCount }); + return runLocalCodexExec({ ...options, actor, cwd, projectRoot, simCount }); } if (actor === "codex-app-server") { - return runLocalCodexAppServer({ ...options, actor, cwd, simCount }); + return runLocalCodexAppServer({ ...options, actor, cwd, projectRoot, simCount }); } return { @@ -1012,12 +1049,13 @@ export async function runDryRun(options: RunOptions): Promise { const now = new Date(); const createdAt = now.toISOString(); const runId = options.runId ?? `dryrun-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; - const artifactRoot = path.join(".humanish", "runs", runId); - const absoluteArtifactRoot = path.join(cwd, artifactRoot); - const packageName = await readPackageName(cwd); - const humanishSource = await directoryExists(path.join(cwd, "humanish")) ? "present" : "missing"; + const packageName = await readPackageName(projectRoot); + const humanishSource = await implicitProjectDirectoryExists(projectRoot, "humanish") ? "present" : "missing"; const source = await buildRunSource({ cwd, capturedAt: createdAt, humanishSource, packageName }); - const selection = await loadDryRunSelection(cwd, humanishSource); + const selection = await loadDryRunSelection(projectRoot, humanishSource); + await assertPreparedSelectedOutputDirectory(projectRoot); + const runPaths = await prepareRunArtifactPaths(cwd, runId); + const artifactRoot = runPaths.relativeRunRoot; if (humanishSource === "missing") { warnings.push("Committed humanish/ source was not found; using built-in synthetic dry-run defaults."); @@ -1082,14 +1120,17 @@ export async function runDryRun(options: RunOptions): Promise { feedbackCandidates: [] }; - await mkdir(absoluteArtifactRoot, { recursive: true }); - await writeRunBundleArtifacts(absoluteArtifactRoot, bundle); - await writeJson(path.join(cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: createdAt - } satisfies RunPointer); + await writeRunBundleArtifacts(runPaths, bundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: createdAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); return { schema: "humanish.run-result.v1", @@ -1101,7 +1142,7 @@ export async function runDryRun(options: RunOptions): Promise { artifactRoot, bundlePath: path.join(artifactRoot, "run.json"), reviewPath: path.join(artifactRoot, "review.md"), - latestPath: path.join(".humanish", "runs", "latest.json"), + latestPath: runPaths.relativeLatestPointer, warnings }; } @@ -1109,6 +1150,7 @@ export async function runDryRun(options: RunOptions): Promise { async function runBrowserAppProof(options: RunOptions & { appUrl: string; cwd: string; + projectRoot: PreparedSelectedOutputDirectory; simCount: number; }): Promise { const warnings: string[] = []; @@ -1143,12 +1185,13 @@ async function runBrowserAppProof(options: RunOptions & { const now = new Date(); const createdAt = now.toISOString(); const runId = options.runId ?? `browser-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; - const artifactRoot = path.join(".humanish", "runs", runId); - const absoluteArtifactRoot = path.join(options.cwd, artifactRoot); - const packageName = await readPackageName(options.cwd); - const humanishSource = await directoryExists(path.join(options.cwd, "humanish")) ? "present" : "missing"; + const packageName = await readPackageName(options.projectRoot); + const humanishSource = await implicitProjectDirectoryExists(options.projectRoot, "humanish") ? "present" : "missing"; const source = await buildRunSource({ cwd: options.cwd, capturedAt: createdAt, humanishSource, packageName }); - const selection = await loadDryRunSelection(options.cwd, humanishSource); + const selection = await loadDryRunSelection(options.projectRoot, humanishSource); + await assertPreparedSelectedOutputDirectory(options.projectRoot); + const runPaths = await prepareRunArtifactPaths(options.cwd, runId); + const artifactRoot = runPaths.relativeRunRoot; if (selection.browserJourneyFailure) { return { schema: "humanish.run-result.v1", @@ -1171,18 +1214,19 @@ async function runBrowserAppProof(options: RunOptions & { } warnings.push(...selection.warnings); - await mkdir(path.join(absoluteArtifactRoot, "screenshots"), { recursive: true }); - await mkdir(path.join(absoluteArtifactRoot, "traces"), { recursive: true }); + await prepareContainedOutputDirectory(runPaths, "screenshots"); + await prepareContainedOutputDirectory(runPaths, "traces"); const surfaces = browserSurfaces.slice(0, options.simCount); const captures = await Promise.all(surfaces.map((surface) => captureBrowserSurface({ - absoluteArtifactRoot, + absoluteArtifactRoot: runPaths, appUrl, browserCommand, browserJourney, surface, timeoutMs: options.timeoutMs ?? BROWSER_APP_DEFAULT_TIMEOUT_MS }))); + await validatePreparedRunArtifactPaths(runPaths); const completedAt = new Date().toISOString(); const events = buildBrowserAppEvents({ appUrl, captures, createdAt }); const allPassed = captures.every((capture) => capture.ok); @@ -1319,13 +1363,17 @@ async function runBrowserAppProof(options: RunOptions & { feedbackCandidates: [] }; - await writeRunBundleArtifacts(absoluteArtifactRoot, bundle); - await writeJson(path.join(options.cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: completedAt - } satisfies RunPointer); + await writeRunBundleArtifacts(runPaths, bundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: completedAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); return { schema: "humanish.run-result.v1", @@ -1337,7 +1385,7 @@ async function runBrowserAppProof(options: RunOptions & { artifactRoot, bundlePath: path.join(artifactRoot, "run.json"), reviewPath: path.join(artifactRoot, "review.md"), - latestPath: path.join(".humanish", "runs", "latest.json"), + latestPath: runPaths.relativeLatestPointer, warnings, ...(allPassed ? {} @@ -1444,6 +1492,7 @@ function resolveRequestedLocalCodexActor(actor: string | undefined): LocalCodexA async function runLocalCodexTui(options: RunOptions & { actor: "codex-tui"; cwd: string; + projectRoot: PreparedSelectedOutputDirectory; simCount: number; }): Promise { const warnings: string[] = []; @@ -1478,15 +1527,28 @@ async function runLocalCodexTui(options: RunOptions & { const now = new Date(); const createdAt = now.toISOString(); const runId = options.runId ?? `codex-tui-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; - const artifactRoot = path.join(".humanish", "runs", runId); - const absoluteArtifactRoot = path.join(options.cwd, artifactRoot); - const transcriptPath = path.join(absoluteArtifactRoot, "transcripts", "codex-tui-sanitized.txt"); - const actorTracePath = path.join(absoluteArtifactRoot, "actor.json"); - const eventsPath = path.join(absoluteArtifactRoot, "events.ndjson"); - const packageName = await readPackageName(options.cwd); - const humanishSource = await directoryExists(path.join(options.cwd, "humanish")) ? "present" : "missing"; - const source = await buildRunSource({ cwd: options.cwd, capturedAt: createdAt, humanishSource, packageName }); - const selection = await loadDryRunSelection(options.cwd, humanishSource); + const usesDefaultCodexCommand = options.actorCommand === undefined && process.env.HUMANISH_CODEX_ACTOR_COMMAND === undefined; + const trustPreflight = usesDefaultCodexCommand ? await checkCodexWorkspaceTrust(options.cwd) : { ok: true as const }; + const packageName = await readPackageName(options.projectRoot); + const humanishSource = await implicitProjectDirectoryExists(options.projectRoot, "humanish") ? "present" : "missing"; + const source: RunBundle["source"] = !trustPreflight.ok && trustPreflight.unsafeMetadata + ? { + packageName, + humanishSource, + git: { + schema: GIT_STATE_SCHEMA, + status: "unavailable", + capturedAt: createdAt, + head: { shortSha: null, refState: "unknown" }, + changes: { staged: 0, unstaged: 0, untracked: 0, total: 0 }, + note: "Git metadata failed containment validation." + } + } + : await buildRunSource({ cwd: options.cwd, capturedAt: createdAt, humanishSource, packageName }); + const selection = await loadDryRunSelection(options.projectRoot, humanishSource); + await assertPreparedSelectedOutputDirectory(options.projectRoot); + const runPaths = await prepareRunArtifactPaths(options.cwd, runId); + const artifactRoot = runPaths.relativeRunRoot; if (humanishSource === "missing") { warnings.push("Committed humanish/ source was not found; using built-in synthetic local actor defaults."); } @@ -1495,7 +1557,6 @@ async function runLocalCodexTui(options: RunOptions & { const prompt = buildLocalCodexTuiPrompt(selection, verdictNonce); const promptDigest = digestText(prompt); const command = resolveLocalCodexTuiCommand(options.cwd, prompt, options.actorCommand); - const usesDefaultCodexCommand = options.actorCommand === undefined && process.env.HUMANISH_CODEX_ACTOR_COMMAND === undefined; const simId = "sim-01"; const streamId = "sim-01-codex-tui"; const events: RunEvent[] = []; @@ -1513,13 +1574,10 @@ async function runLocalCodexTui(options: RunOptions & { simId, streamId }); - await writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); }; - await mkdir(path.dirname(transcriptPath), { recursive: true }); - let actor: LocalActorCommandResult; - const trustPreflight = usesDefaultCodexCommand ? await checkCodexWorkspaceTrust(options.cwd) : { ok: true as const }; if (!trustPreflight.ok) { actor = { durationMs: 0, @@ -1635,13 +1693,17 @@ async function runLocalCodexTui(options: RunOptions & { review: createLocalActorRunningReviewSummary("Codex TUI"), feedbackCandidates: [] }; - await writeRunBundleArtifacts(absoluteArtifactRoot, runningBundle); - await writeJson(path.join(options.cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: runningAt - } satisfies RunPointer); + await writeRunBundleArtifacts(runPaths, runningBundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: runningAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); actor = await executeLocalActorCommand(command, { cwd: options.cwd, @@ -1649,6 +1711,7 @@ async function runLocalCodexTui(options: RunOptions & { verdictNonce }); } + await validatePreparedRunArtifactPaths(runPaths); const completedAt = new Date().toISOString(); const redactedTranscript = redactSensitiveText(actor.transcript); const tail = tailText(redactedTranscript, 6_000); @@ -1659,8 +1722,13 @@ async function runLocalCodexTui(options: RunOptions & { // still show the real path, but the public-safe bundle must not. const verdictReason = redactSensitiveText(actor.reason); - await writeFile(transcriptPath, redactedTranscript.length > 0 ? redactedTranscript : "No transcript output captured.\n", "utf8"); - await writeJson(actorTracePath, { + await writeContainedOutputFile( + runPaths, + "transcripts/codex-tui-sanitized.txt", + redactedTranscript.length > 0 ? redactedTranscript : "No transcript output captured.\n", + "utf8" + ); + await writeContainedOutputFile(runPaths, "actor.json", `${JSON.stringify({ schema: "humanish.local-codex-tui-actor.v1", actor: "codex-tui", commandName: command.name, @@ -1676,7 +1744,7 @@ async function runLocalCodexTui(options: RunOptions & { transcriptBytes: actor.transcriptBytes, transcriptPath: "transcripts/codex-tui-sanitized.txt", redaction: "passed" - }); + }, null, 2)}\n`, "utf8"); await appendEvent( "actor.observation", @@ -1797,13 +1865,17 @@ async function runLocalCodexTui(options: RunOptions & { feedbackCandidates: [] }; - await writeRunBundleArtifacts(absoluteArtifactRoot, bundle); - await writeJson(path.join(options.cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: completedAt - } satisfies RunPointer); + await writeRunBundleArtifacts(runPaths, bundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: completedAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); return { schema: "humanish.run-result.v1", @@ -1815,7 +1887,7 @@ async function runLocalCodexTui(options: RunOptions & { artifactRoot, bundlePath: path.join(artifactRoot, "run.json"), reviewPath: path.join(artifactRoot, "review.md"), - latestPath: path.join(".humanish", "runs", "latest.json"), + latestPath: runPaths.relativeLatestPointer, warnings, ...(status === "passed" ? {} @@ -1937,6 +2009,7 @@ function buildLocalCodexExecBundle(args: { async function runLocalCodexExec(options: RunOptions & { actor: "codex-exec"; cwd: string; + projectRoot: PreparedSelectedOutputDirectory; simCount: number; }): Promise { const warnings: string[] = []; @@ -1973,13 +2046,13 @@ async function runLocalCodexExec(options: RunOptions & { const now = new Date(); const createdAt = now.toISOString(); const runId = options.runId ?? `codex-exec-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; - const artifactRoot = path.join(".humanish", "runs", runId); - const absoluteArtifactRoot = path.join(options.cwd, artifactRoot); - const eventsPath = path.join(absoluteArtifactRoot, "events.ndjson"); - const packageName = await readPackageName(options.cwd); - const humanishSource = await directoryExists(path.join(options.cwd, "humanish")) ? "present" : "missing"; + const packageName = await readPackageName(options.projectRoot); + const humanishSource = await implicitProjectDirectoryExists(options.projectRoot, "humanish") ? "present" : "missing"; const source = await buildRunSource({ cwd: options.cwd, capturedAt: createdAt, humanishSource, packageName }); - const selection = await loadDryRunSelection(options.cwd, humanishSource); + const selection = await loadDryRunSelection(options.projectRoot, humanishSource); + await assertPreparedSelectedOutputDirectory(options.projectRoot); + const runPaths = await prepareRunArtifactPaths(options.cwd, runId); + const artifactRoot = runPaths.relativeRunRoot; if (humanishSource === "missing") { warnings.push("Committed humanish/ source was not found; using built-in synthetic local actor defaults."); } @@ -2004,21 +2077,23 @@ async function runLocalCodexExec(options: RunOptions & { }); }; - await mkdir(path.join(absoluteArtifactRoot, "transcripts"), { recursive: true }); - await mkdir(path.join(absoluteArtifactRoot, "actors"), { recursive: true }); - await mkdir(path.join(absoluteArtifactRoot, "observer"), { recursive: true }); - await writeJson(path.join(options.cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: createdAt - } satisfies RunPointer); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: createdAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); interface ExecLaneResult { actor: LocalActorCommandResult; command: LocalActorCommand; focus: LocalCodexExecFocus; promptDigest: string; + redactedTranscript: string; simId: string; streamId: string; tail: string; @@ -2064,7 +2139,7 @@ async function runLocalCodexExec(options: RunOptions & { lane.streamId ); } - await writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); const baseLifecycle: RunBundle["lifecycle"] = [ { @@ -2117,7 +2192,7 @@ async function runLocalCodexExec(options: RunOptions & { events, review: createLocalActorRunningReviewSummary(options.simCount === 1 ? "Codex exec" : "Codex exec fanout") }); - await writeRunBundleArtifacts(absoluteArtifactRoot, runningBundle); + await writeRunBundleArtifacts(runPaths, runningBundle); const laneResults = await mapWithConcurrency(lanes, maxConcurrency, async (lane): Promise => { const actor = await executeLocalActorCommand(lane.command, { @@ -2131,34 +2206,12 @@ async function runLocalCodexExec(options: RunOptions & { ? "transcripts/codex-exec-sanitized.jsonl" : `transcripts/${lane.streamId}-sanitized.jsonl`; const tracePath = options.simCount === 1 ? "actor.json" : `actors/${lane.streamId}.json`; - await writeFile( - path.join(absoluteArtifactRoot, transcriptPath), - redactedTranscript.length > 0 ? redactedTranscript : "No transcript output captured.\n", - "utf8" - ); - await writeJson(path.join(absoluteArtifactRoot, tracePath), { - schema: "humanish.local-codex-exec-actor.v1", - actor: "codex-exec", - commandName: lane.command.name, - focusId: lane.focus.id, - promptDigest: lane.promptDigest, - verdictNonce, - startedAt: createdAt, - completedAt: new Date().toISOString(), - durationMs: actor.durationMs, - exitCode: actor.exitCode, - signal: actor.signal, - status: actor.status, - timeoutMs, - transcriptBytes: actor.transcriptBytes, - transcriptPath, - redaction: "passed" - }); return { actor, command: lane.command, focus: lane.focus, promptDigest: lane.promptDigest, + redactedTranscript, simId: lane.simId, streamId: lane.streamId, tail, @@ -2168,6 +2221,33 @@ async function runLocalCodexExec(options: RunOptions & { }); const completedAt = new Date().toISOString(); + await validatePreparedRunArtifactPaths(runPaths); + for (const result of laneResults) { + await writeContainedOutputFile( + runPaths, + result.transcriptPath, + result.redactedTranscript.length > 0 ? result.redactedTranscript : "No transcript output captured.\n", + "utf8" + ); + await writeContainedOutputFile(runPaths, result.tracePath, `${JSON.stringify({ + schema: "humanish.local-codex-exec-actor.v1", + actor: "codex-exec", + commandName: result.command.name, + focusId: result.focus.id, + promptDigest: result.promptDigest, + verdictNonce, + startedAt: createdAt, + completedAt, + durationMs: result.actor.durationMs, + exitCode: result.actor.exitCode, + signal: result.actor.signal, + status: result.actor.status, + timeoutMs, + transcriptBytes: result.actor.transcriptBytes, + transcriptPath: result.transcriptPath, + redaction: "passed" + }, null, 2)}\n`, "utf8"); + } const laneStatuses = laneResults.map((result) => result.actor.status); const status = aggregateActorStatus(laneStatuses); const verdictReason = options.simCount === 1 @@ -2205,7 +2285,7 @@ async function runLocalCodexExec(options: RunOptions & { result.streamId ); } - await writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); const bundle = buildLocalCodexExecBundle({ runId, @@ -2250,7 +2330,17 @@ async function runLocalCodexExec(options: RunOptions & { review: createLocalActorReviewSummary(options.simCount === 1 ? "Codex exec" : "Codex exec fanout", status, verdictReason) }); - await writeRunBundleArtifacts(absoluteArtifactRoot, bundle); + await writeRunBundleArtifacts(runPaths, bundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: completedAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); return { schema: "humanish.run-result.v1", @@ -2262,7 +2352,7 @@ async function runLocalCodexExec(options: RunOptions & { artifactRoot, bundlePath: path.join(artifactRoot, "run.json"), reviewPath: path.join(artifactRoot, "review.md"), - latestPath: path.join(".humanish", "runs", "latest.json"), + latestPath: runPaths.relativeLatestPointer, warnings, ...(status === "passed" ? {} @@ -2420,6 +2510,7 @@ function buildLocalCodexAppServerBundle(args: { async function runLocalCodexAppServer(options: RunOptions & { actor: "codex-app-server"; cwd: string; + projectRoot: PreparedSelectedOutputDirectory; simCount: number; }): Promise { const warnings: string[] = []; @@ -2440,13 +2531,13 @@ async function runLocalCodexAppServer(options: RunOptions & { const now = new Date(); const createdAt = now.toISOString(); const runId = options.runId ?? `codex-app-server-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; - const artifactRoot = path.join(".humanish", "runs", runId); - const absoluteArtifactRoot = path.join(options.cwd, artifactRoot); - const eventsPath = path.join(absoluteArtifactRoot, "events.ndjson"); - const packageName = await readPackageName(options.cwd); - const humanishSource = await directoryExists(path.join(options.cwd, "humanish")) ? "present" : "missing"; + const packageName = await readPackageName(options.projectRoot); + const humanishSource = await implicitProjectDirectoryExists(options.projectRoot, "humanish") ? "present" : "missing"; const source = await buildRunSource({ cwd: options.cwd, capturedAt: createdAt, humanishSource, packageName }); - const selection = await loadDryRunSelection(options.cwd, humanishSource); + const selection = await loadDryRunSelection(options.projectRoot, humanishSource); + await assertPreparedSelectedOutputDirectory(options.projectRoot); + const runPaths = await prepareRunArtifactPaths(options.cwd, runId); + const artifactRoot = runPaths.relativeRunRoot; if (humanishSource === "missing") { warnings.push("Committed humanish/ source was not found; using built-in synthetic Codex app-server actor defaults."); } @@ -2471,14 +2562,16 @@ async function runLocalCodexAppServer(options: RunOptions & { }); }; - await mkdir(path.join(absoluteArtifactRoot, "observer"), { recursive: true }); - await mkdir(path.join(absoluteArtifactRoot, "actors"), { recursive: true }); - await writeJson(path.join(options.cwd, ".humanish", "runs", "latest.json"), { - schema: "humanish.latest-run.v1", - runId, - path: artifactRoot, - updatedAt: createdAt - } satisfies RunPointer); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: createdAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); const lanes: LocalCodexAppServerLane[] = Array.from({ length: options.simCount }, (_, index) => { const focus = localCodexExecFocus(index); @@ -2523,7 +2616,7 @@ async function runLocalCodexAppServer(options: RunOptions & { lane.streamId ); } - await writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); const baseLifecycle: RunBundle["lifecycle"] = [ { @@ -2577,14 +2670,16 @@ async function runLocalCodexAppServer(options: RunOptions & { events, review: createLocalActorRunningReviewSummary("Codex app-server") }); - await writeRunBundleArtifacts(absoluteArtifactRoot, runningBundle); + await writeRunBundleArtifacts(runPaths, runningBundle); const laneResults = await mapWithConcurrency(lanes, options.simCount, async (lane) => { - const laneRunRoot = lane.prefix ? path.join(absoluteArtifactRoot, lane.prefix) : absoluteArtifactRoot; - const result = await getActor("codex-app-server").runSession({ + const laneRunRoot = lane.prefix + ? await prepareContainedOutputDirectoryRoot(runPaths, lane.prefix) + : runPaths; + const sessionOptions: import("./codex-app-server.js").CodexAppServerRunOptions = { cwd: options.cwd, prompt: lane.prompt, - runRoot: laneRunRoot, + runRoot: "physicalRunRoot" in laneRunRoot ? laneRunRoot.physicalRunRoot : laneRunRoot.physicalPath, timeoutMs, ...(options.actorCommand === undefined ? {} : { actorCommand: options.actorCommand }), approvalPolicy: "never", @@ -2592,13 +2687,15 @@ async function runLocalCodexAppServer(options: RunOptions & { ...(process.env.HUMANISH_CODEX_APP_SERVER_MODEL ? { model: process.env.HUMANISH_CODEX_APP_SERVER_MODEL } : {}), sandbox: readCodexAppServerSandboxFromEnv(), serviceName: "humanish" - }); + }; + const result = await runCodexAppServerSessionInPreparedRoot(sessionOptions, laneRunRoot); return { lane, result: prefixCodexAppServerResultPaths(result, lane.prefix) }; }); + await validatePreparedRunArtifactPaths(runPaths); const completedAt = new Date().toISOString(); const statuses = laneResults.map((entry) => entry.result.status); const status = aggregateActorStatus(statuses); @@ -2621,7 +2718,7 @@ async function runLocalCodexAppServer(options: RunOptions & { entry.lane.streamId ); } - await writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); const bundle = buildLocalCodexAppServerBundle({ runId, @@ -2666,7 +2763,17 @@ async function runLocalCodexAppServer(options: RunOptions & { review: createLocalActorReviewSummary(options.simCount === 1 ? "Codex app-server" : "Codex app-server fanout", status, verdictReason) }); - await writeRunBundleArtifacts(absoluteArtifactRoot, bundle); + await writeRunBundleArtifacts(runPaths, bundle); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId, + path: artifactRoot, + updatedAt: completedAt + } satisfies RunPointer, null, 2)}\n`, + "utf8" + ); return { schema: "humanish.run-result.v1", @@ -2678,7 +2785,7 @@ async function runLocalCodexAppServer(options: RunOptions & { artifactRoot, bundlePath: path.join(artifactRoot, "run.json"), reviewPath: path.join(artifactRoot, "review.md"), - latestPath: path.join(".humanish", "runs", "latest.json"), + latestPath: runPaths.relativeLatestPointer, warnings, ...(status === "passed" ? {} @@ -2885,6 +2992,7 @@ type CodexTrustPreflight = message: string; recoveryCommand: string; trustRoot: string; + unsafeMetadata?: true; }; function resolveLocalCodexTuiCommand( @@ -3188,15 +3296,25 @@ async function checkCodexWorkspaceTrust(cwd: string): Promise { - const worktreeRoot = await findGitWorktreeRoot(cwd); - if (!worktreeRoot) { +async function detectCodexTrustRoot(cwd: string): Promise< + | { unsafe: false; trustRoot: string } + | { unsafe: true; worktreeRoot: string } + | null +> { + const inspection = await inspectVerifiedGitWorkspace(cwd); + if (inspection.status === "missing") { return null; } - - const dotGitPath = path.join(worktreeRoot, ".git"); - if (await directoryExists(dotGitPath)) { - return worktreeRoot; - } - - const gitFile = await readTextIfExists(dotGitPath); - if (!gitFile?.startsWith("gitdir:")) { - return worktreeRoot; - } - - const gitDir = gitFile.slice("gitdir:".length).trim(); - const absoluteGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreeRoot, gitDir); - const commonDirText = await readTextIfExists(path.join(absoluteGitDir, "commondir")); - if (!commonDirText) { - return worktreeRoot; - } - - const commonDir = commonDirText.trim(); - const absoluteCommonDir = path.resolve(absoluteGitDir, commonDir); - return path.basename(absoluteCommonDir) === ".git" ? path.dirname(absoluteCommonDir) : worktreeRoot; -} - -async function findGitWorktreeRoot(cwd: string): Promise { - let current = path.resolve(cwd); - - while (true) { - if (await fileExists(path.join(current, ".git")) || await directoryExists(path.join(current, ".git"))) { - return current; - } - - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; + if (inspection.status === "unsafe") { + return { unsafe: true, worktreeRoot: inspection.worktreeRoot }; } + return { unsafe: false, trustRoot: inspection.workspace.trustRoot }; } -function codexConfigTrustsProject(configText: string, trustRoot: string): boolean { +async function codexConfigTrustsProject(configText: string, trustRoot: string): Promise { const sectionPattern = /^\[projects\."((?:\\.|[^"\\])*)"\]\s*$/gm; let sectionMatch: RegExpExecArray | null; @@ -3262,7 +3351,7 @@ function codexConfigTrustsProject(configText: string, trustRoot: string): boolea const nextSectionIndex = afterSection.search(/^\[/m); const sectionBody = nextSectionIndex === -1 ? afterSection : afterSection.slice(0, nextSectionIndex); - if (/^trust_level\s*=\s*"trusted"\s*$/m.test(sectionBody) && isSamePath(projectPath, trustRoot)) { + if (/^trust_level\s*=\s*"trusted"\s*$/m.test(sectionBody) && await isSamePhysicalPath(projectPath, trustRoot)) { return true; } } @@ -3274,10 +3363,16 @@ function unescapeTomlString(value: string): string { return value.replace(/\\(["\\])/g, "$1"); } -function isSamePath(candidatePath: string, targetPath: string): boolean { - const candidate = path.resolve(candidatePath); - const target = path.resolve(targetPath); - return candidate === target; +async function isSamePhysicalPath(candidatePath: string, targetPath: string): Promise { + try { + const [candidate, target] = await Promise.all([ + realpath(path.resolve(candidatePath)), + realpath(path.resolve(targetPath)) + ]); + return candidate === target; + } catch { + return false; + } } function normalizeActorTimeout(value: number | undefined): number | null { @@ -3580,10 +3675,46 @@ function normalizeSimCount(value: number | undefined): number | null { export async function verifyRun(cwdInput: string, runInput: string): Promise { const cwd = path.resolve(cwdInput); + let runPaths: PreparedRunArtifactPaths | null; + try { + runPaths = await resolveRunPath(cwd, runInput); + } catch { + return invalidRunStorageVerifyResult(cwd, runInput); + } + return verifyPreparedRun(cwd, runInput, runPaths); +} + +function invalidRunStorageVerifyResult(cwd: string, runInput: string): VerifyResult { + return { + schema: VERIFY_SCHEMA, + ok: false, + cwd, + run: runInput, + checks: [{ + name: "run storage containment", + ok: false, + message: "run storage must contain only identity-bound directories and single-link regular files" + }], + shareSafety: { + status: "blocked", + reasons: [{ code: "VERIFY_FAILED", message: "Run storage failed containment validation." }] + }, + warnings: [], + error: { + code: "HUMANISH_INVALID_RUN_BUNDLE", + message: "Run storage failed containment validation." + } + }; +} + +async function verifyPreparedRun( + cwd: string, + runInput: string, + runPaths: PreparedRunArtifactPaths | null +): Promise { const checks: VerifyResult["checks"] = []; - const resolved = await resolveRunPath(cwd, runInput); - if (!resolved) { + if (!runPaths) { return { schema: VERIFY_SCHEMA, ok: false, @@ -3607,11 +3738,11 @@ export async function verifyRun(cwdInput: string, runInput: string): Promise { const cwd = path.resolve(cwdInput); const checkedAt = (hooks.now ?? (() => new Date()))().toISOString(); - const resolved = await resolveRunPath(cwd, runInput); + let resolved: PreparedRunArtifactPaths | null; + try { + resolved = await resolveRunPath(cwd, runInput); + } catch { + return { + schema: CLEANUP_SCHEMA, + ok: false, + cwd, + run: runInput, + checkedAt, + summary: { resources: 0, killed: 0, alreadyClean: 0, failed: 0, skipped: 0 }, + resources: [], + adapterResults: [], + warnings: [], + error: { + code: "HUMANISH_INVALID_RUN_BUNDLE", + message: "Run storage failed containment validation." + } + }; + } if (!resolved) { return { @@ -3790,9 +3940,19 @@ export async function cleanupRun(cwdInput: string, runInput: string, hooks: RunC }; } - const bundlePath = path.join(resolved, "run.json"); - const cleanupPath = path.join(resolved, "cleanup.json"); - const bundle = await readJsonIfExists(bundlePath); + const runPaths = resolved; + const bundlePath = path.join(runPaths.absoluteRunRoot, "run.json"); + const cleanupPath = path.join(runPaths.absoluteRunRoot, "cleanup.json"); + await prepareContainedOutputFile(runPaths, "cleanup.json"); + const bundleBytes = await readContainedRegularFile(runPaths, "run.json"); + let bundle: unknown = null; + if (bundleBytes) { + try { + bundle = JSON.parse(bundleBytes.toString("utf8")) as unknown; + } catch { + bundle = null; + } + } if (!isRunBundle(bundle)) { return { @@ -3815,7 +3975,6 @@ export async function cleanupRun(cwdInput: string, runInput: string, hooks: RunC const resources: CleanupResourceResult[] = []; const warnings: string[] = []; - let desktopModule: E2BDesktopModule | null = null; const providerResources = bundle.providerResources ?? []; for (const resource of providerResources) { @@ -3841,42 +4000,23 @@ export async function cleanupRun(cwdInput: string, runInput: string, hooks: RunC continue; } - try { - desktopModule ??= await (hooks.loadDesktopModule ?? loadE2BDesktopModule)(); - if (typeof desktopModule.Sandbox.kill !== "function") { - resources.push({ - provider: resource.provider, - kind: resource.kind, - id: resource.id, - status: "failed", - message: "installed @e2b/desktop SDK does not expose Sandbox.kill" - }); - continue; - } - - await desktopModule.Sandbox.kill(resource.id, { requestTimeoutMs: 60_000 }); - resources.push({ - provider: resource.provider, - kind: resource.kind, - id: resource.id, - status: "killed", - message: "resource killed by exact recorded id" - }); - } catch (error) { - resources.push({ - provider: resource.provider, - kind: resource.kind, - id: resource.id, - status: "failed", - message: error instanceof Error ? error.message : String(error) - }); - } + resources.push({ + provider: resource.provider, + kind: resource.kind, + id: resource.id, + status: "failed", + message: "automatic provider cleanup requires a verified resource lease" + }); } let adapterResults: CleanupAdapterResult[] = []; if (hooks.cleanupAdapterResources) { try { - adapterResults = await hooks.cleanupAdapterResources({ cwd, runDir: resolved, bundle }); + adapterResults = await hooks.cleanupAdapterResources({ + cwd, + runDir: runPaths.physicalRunRoot, + bundle + }); } catch (error) { adapterResults = [{ id: "adapter-cleanup", @@ -3884,10 +4024,11 @@ export async function cleanupRun(cwdInput: string, runInput: string, hooks: RunC message: error instanceof Error ? error.message : String(error) }]; } + await validatePreparedRunArtifactPaths(runPaths); } if (providerResources.length === 0 && adapterResults.length === 0) { - warnings.push("Run bundle recorded no run-owned provider resources; nothing to clean."); + warnings.push("Run bundle recorded no provider resource evidence; nothing to inspect."); } const summary = { @@ -3912,7 +4053,8 @@ export async function cleanupRun(cwdInput: string, runInput: string, hooks: RunC adapterResults, warnings }; - await writeJson(cleanupPath, result); + await validatePreparedRunArtifactPaths(runPaths); + await writeContainedOutputFile(runPaths, "cleanup.json", `${JSON.stringify(result, null, 2)}\n`, "utf8"); return result; } @@ -3921,14 +4063,24 @@ export async function loadRunBundle( runInput: string ): Promise<{ bundle: RunBundle; bundlePath: string; runDir: string } | null> { const cwd = path.resolve(cwdInput); - const resolved = await resolveRunPath(cwd, runInput); + const runPaths = await resolveRunPath(cwd, runInput).catch(() => null); - if (!resolved) { + if (!runPaths) { return null; } - const bundlePath = path.join(resolved, "run.json"); - const bundle = await readJsonIfExists(bundlePath); + return loadRunBundlePrepared(cwd, runPaths); +} + +/** Internal continuity seam for callers that already bound one run identity. */ +export async function loadRunBundlePrepared( + cwdInput: string, + runPaths: PreparedRunArtifactPaths +): Promise<{ bundle: RunBundle; bundlePath: string; runDir: string } | null> { + const cwd = path.resolve(cwdInput); + await validatePreparedRunArtifactPaths(runPaths); + const bundlePath = path.join(runPaths.absoluteRunRoot, "run.json"); + const bundle = await readRunJsonIfExists(runPaths, "run.json"); if (!isRunBundle(bundle)) { return null; @@ -3937,20 +4089,40 @@ export async function loadRunBundle( return { bundle, bundlePath: path.relative(cwd, bundlePath), - runDir: resolved + runDir: runPaths.absoluteRunRoot }; } +/** Internal continuity seam for callers that already bound one run identity. */ +export async function verifyRunPrepared( + cwdInput: string, + runInput: string, + runPaths: PreparedRunArtifactPaths +): Promise { + const cwd = path.resolve(cwdInput); + try { + await validatePreparedRunArtifactPaths(runPaths); + } catch { + return invalidRunStorageVerifyResult(cwd, runInput); + } + return verifyPreparedRun(cwd, runInput, runPaths); +} + export async function listRuns(cwdInput: string): Promise { const cwd = path.resolve(cwdInput); - const runsRoot = path.join(cwd, ".humanish", "runs"); + const runsRootPath = resolveRunsRoot(cwd); // ENOENT (no .humanish/runs yet) is a normal empty state: ok:true, no runs. Any // other readdir failure (e.g. permission denied) is a real I/O failure and must // not be swallowed into a false "no runs" report. - let entries: Dirent[]; + let entries: string[]; + let runsRoot: import("./selected-output-paths.js").PreparedSelectedOutputDirectory | null = null; try { - entries = await readdir(runsRoot, { withFileTypes: true }); + runsRoot = await bindExistingManagedHumanishOutputDirectory(cwd, "runs"); + entries = runsRoot ? await readdir(runsRoot.physicalPath) : []; + if (runsRoot) { + await assertPreparedSelectedOutputDirectory(runsRoot); + } } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { entries = []; @@ -3961,26 +4133,53 @@ export async function listRuns(cwdInput: string): Promise { let latest: RunPointer | null; try { - latest = await readLatest(cwd); + latest = runsRoot ? await readLatest(runsRoot) : null; } catch (error) { return runsUnavailableResult(cwd, error); } const runs = []; - for (const entry of entries) { - if (!entry.isDirectory()) { + for (const entryName of entries) { + if (entryName === "latest.json" || !isSafeRunIdSegment(entryName)) { continue; } - - const bundle = await readJsonIfExists(path.join(runsRoot, entry.name, "run.json")); + const entryPath = path.join(runsRootPath, entryName); + const entryStats = await lstat(entryPath, { bigint: true }).catch(() => null); + if (!entryStats) { + continue; + } + if (entryStats.isSymbolicLink() || (!entryStats.isDirectory() && !entryStats.isFile()) || (entryStats.isFile() && entryStats.nlink > 1n)) { + return runsUnavailableResult(cwd, new Error(`Unsafe Humanish runs entry: ${entryName}`)); + } + if (!entryStats.isDirectory()) { + continue; + } + let entryRunPaths: PreparedRunArtifactPaths; + try { + entryRunPaths = await bindExistingRunArtifactPaths(cwd, entryName); + } catch (error) { + return runsUnavailableResult(cwd, error); + } + if (runsRoot && entryRunPaths.physicalRunsRoot !== runsRoot.physicalPath) { + return runsUnavailableResult(cwd, new Error("Humanish runs root changed physical destination.")); + } + const bundle = await readRunJsonIfExists(entryRunPaths, "run.json"); runs.push({ - runId: entry.name, + runId: entryName, createdAt: isRecord(bundle) && typeof bundle.createdAt === "string" ? bundle.createdAt : null, mode: isRecord(bundle) && typeof bundle.mode === "string" ? bundle.mode : null, - path: path.join(".humanish", "runs", entry.name) + path: path.join(RUNS_RELATIVE_ROOT, entryName) }); } + if (runsRoot) { + try { + await assertPreparedSelectedOutputDirectory(runsRoot); + } catch (error) { + return runsUnavailableResult(cwd, error); + } + } + return { schema: RUNS_SCHEMA, ok: true, @@ -4005,15 +4204,20 @@ function runsUnavailableResult(cwd: string, error: unknown): RunsResult { } export async function readReview(cwdInput: string, runInput: string): Promise { - const verified = await verifyRun(cwdInput, runInput); + const cwd = path.resolve(cwdInput); + let runPaths: PreparedRunArtifactPaths | null; + try { + runPaths = await resolveRunPath(cwd, runInput); + } catch { + return invalidRunStorageVerifyResult(cwd, runInput); + } + const verified = await verifyPreparedRun(cwd, runInput, runPaths); if (!verified.ok || !verified.bundlePath) { return verified; } - const cwd = path.resolve(cwdInput); - const runDir = path.dirname(path.join(cwd, verified.bundlePath)); - const review = await readJsonIfExists(path.join(runDir, "review.json")); + const review = runPaths ? await readRunJsonIfExists(runPaths, "review.json") : null; if (!isReviewSummary(review)) { return { @@ -4028,33 +4232,64 @@ export async function readReview(cwdInput: string, runInput: string): Promise { const cwd = path.resolve(cwdInput); + const cwdOk = await validateCwd(cwd).then((error) => error === null).catch(() => false); + if (!cwdOk) { + const checks = [ + { name: "target cwd", ok: false, message: "target directory exists" }, + { name: "package.json", ok: false, message: "package.json is present and safe to read" }, + { name: "humanish source", ok: false, message: "committed humanish/ source directory is present and safe to read" }, + { name: "runtime ignore", ok: false, message: ".gitignore safely contains .humanish/" } + ]; + return { schema: DOCTOR_SCHEMA, ok: false, cwd, checks }; + } + + let projectRoot: PreparedSelectedOutputDirectory; + try { + projectRoot = await prepareSelectedOutputDirectory(path.dirname(cwd), cwd); + } catch { + const checks = [ + { name: "target cwd", ok: false, message: "target directory failed containment validation" }, + { name: "package.json", ok: false, message: "package.json is present and safe to read" }, + { name: "humanish source", ok: false, message: "committed humanish/ source directory is present and safe to read" }, + { name: "runtime ignore", ok: false, message: ".gitignore safely contains .humanish/" } + ]; + return { schema: DOCTOR_SCHEMA, ok: false, cwd, checks }; + } + + const safeCheck = async (check: () => Promise): Promise => { + try { + return await check(); + } catch { + return false; + } + }; const checks = [ { name: "target cwd", - ok: await directoryExists(cwd), + ok: true, message: "target directory exists" }, { name: "package.json", - ok: await fileExists(path.join(cwd, "package.json")), - message: "package.json is present" + ok: await safeCheck(async () => await readImplicitProjectFile(projectRoot, "package.json") !== null), + message: "package.json is present and safe to read" }, { name: "humanish source", - ok: await directoryExists(path.join(cwd, "humanish")), - message: "committed humanish/ source directory is present" + ok: await safeCheck(() => implicitProjectDirectoryExists(projectRoot, "humanish")), + message: "committed humanish/ source directory is present and safe to read" }, { name: "runtime ignore", - ok: (await readTextIfExists(path.join(cwd, ".gitignore")))?.includes(".humanish/") ?? false, - message: ".gitignore contains .humanish/" + ok: await safeCheck(async () => (await readImplicitProjectFile(projectRoot, ".gitignore"))?.includes(".humanish/") ?? false), + message: ".gitignore safely contains .humanish/" } ]; @@ -4117,8 +4352,99 @@ function createLocalActorReviewSummary(actorLabel: string, status: LocalActorTer }; } +async function inspectImplicitProjectPath( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +) { + const segments = relativePath.replace(/\\/g, "/").split("/"); + if (segments.length === 0 || segments.some((segment) => segment.length === 0)) { + throw new Error("Implicit project path must be a non-empty relative path."); + } + await assertPreparedSelectedOutputDirectory(projectRoot); + let current = projectRoot.physicalPath; + for (const [index, segment] of segments.entries()) { + assertSafeOutputPathSegment(segment, "Implicit project path segment"); + current = path.join(current, segment); + let stats; + try { + stats = await lstat(current, { bigint: true }); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + if (stats.isSymbolicLink()) { + throw new Error(`Implicit project path must not contain symbolic links: ${relativePath}`); + } + if (!stats.isDirectory() && !stats.isFile()) { + throw new Error(`Implicit project path must contain only regular files and directories: ${relativePath}`); + } + if (stats.isFile() && stats.nlink > 1n) { + throw new Error(`Implicit project files must be single-link regular files: ${relativePath}`); + } + if (index < segments.length - 1 && !stats.isDirectory()) { + throw new Error(`Implicit project path parent must be a directory: ${relativePath}`); + } + if (index === segments.length - 1) { + await assertPreparedSelectedOutputDirectory(projectRoot); + return stats; + } + } + return null; +} + +async function implicitProjectDirectoryExists( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + const stats = await inspectImplicitProjectPath(projectRoot, relativePath); + if (!stats) { + return false; + } + if (!stats.isDirectory()) { + throw new Error(`Implicit project directory has the wrong type: ${relativePath}`); + } + return true; +} + +async function readImplicitProjectFile( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + const stats = await inspectImplicitProjectPath(projectRoot, relativePath); + if (!stats) { + return null; + } + if (!stats.isFile() || stats.nlink !== 1n) { + throw new Error(`Implicit project file must be a single-link regular file: ${relativePath}`); + } + const bytes = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/")); + if (!bytes) { + throw new Error(`Implicit project file changed while it was being read: ${relativePath}`); + } + return bytes.toString("utf8"); +} + +async function listImplicitProjectDirectory( + projectRoot: PreparedSelectedOutputDirectory, + relativePath: string +): Promise { + if (!await implicitProjectDirectoryExists(projectRoot, relativePath)) { + return []; + } + const directory = path.join(projectRoot.physicalPath, ...relativePath.replace(/\\/g, "/").split("/")); + const names = await readdir(directory); + await assertPreparedSelectedOutputDirectory(projectRoot); + for (const name of names) { + assertSafeOutputPathSegment(name, "Implicit project directory entry"); + await inspectImplicitProjectPath(projectRoot, `${relativePath.replace(/\\/g, "/")}/${name}`); + } + return names; +} + async function loadDryRunSelection( - cwd: string, + projectRoot: PreparedSelectedOutputDirectory, humanishSource: "present" | "missing" ): Promise<{ browserJourney?: BrowserPersonaJourney; @@ -4141,9 +4467,9 @@ async function loadDryRunSelection( const personaPath = "humanish/personas/synthetic-new-user.yaml"; const scenarioPath = "humanish/scenarios/first-run-smoke.yaml"; - const personaText = await readTextIfExists(path.join(cwd, personaPath)); - const scenarioText = await readTextIfExists(path.join(cwd, scenarioPath)); - const browserJourneySelection = await loadBrowserPersonaJourneySelection(cwd); + const personaText = await readImplicitProjectFile(projectRoot, personaPath); + const scenarioText = await readImplicitProjectFile(projectRoot, scenarioPath); + const browserJourneySelection = await loadBrowserPersonaJourneySelection(projectRoot); if (personaText === null) { warnings.push(`${personaPath} was not found; using built-in persona defaults.`); @@ -4192,19 +4518,13 @@ async function loadDryRunSelection( }; } -async function loadBrowserPersonaJourneySelection(cwd: string): Promise<{ +async function loadBrowserPersonaJourneySelection(projectRoot: PreparedSelectedOutputDirectory): Promise<{ failure?: string; journey?: BrowserPersonaJourney; warnings: string[]; }> { const warnings: string[] = []; - const scenarioDir = path.join(cwd, "humanish", "scenarios"); - const names = await readdir(scenarioDir).catch((error: unknown) => { - if (isNodeError(error) && error.code === "ENOENT") { - return [] as string[]; - } - throw error; - }); + const names = await listImplicitProjectDirectory(projectRoot, "humanish/scenarios"); const files = names .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) .sort((left, right) => { @@ -4215,8 +4535,7 @@ async function loadBrowserPersonaJourneySelection(cwd: string): Promise<{ for (const name of files) { const relativePath = path.join("humanish", "scenarios", name); - const absolutePath = path.join(cwd, relativePath); - const text = await readTextIfExists(absolutePath); + const text = await readImplicitProjectFile(projectRoot, relativePath); if (text === null) { continue; } @@ -4295,38 +4614,80 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -async function resolveRunPath(cwd: string, runInput: string): Promise { +async function resolveRunPath(cwd: string, runInput: string): Promise { if (runInput === "latest") { - const latest = await readLatest(cwd); - return latest ? path.join(cwd, latest.path) : null; + const runsRoot = await bindExistingManagedHumanishOutputDirectory(cwd, "runs"); + if (!runsRoot) { + return null; + } + const latest = await readLatest(runsRoot); + const expected = latest ? resolveLatestRunDirectory(cwd, latest) : null; + if (!latest || !expected) { + return null; + } + const runPaths = await bindExistingRunArtifactPaths(cwd, latest.runId); + if ( + runPaths.absoluteRunRoot !== expected + || runPaths.physicalRunsRoot !== runsRoot.physicalPath + ) { + throw new Error("Latest run pointer changed physical runs root."); + } + await assertPreparedSelectedOutputDirectory(runsRoot); + return runPaths; } - const direct = path.join(cwd, ".humanish", "runs", runInput); - return await directoryExists(direct) ? direct : null; + if (!isSafeRunIdSegment(runInput) || !await resolveExistingRunDirectory(cwd, runInput)) { + return null; + } + return bindExistingRunArtifactPaths(cwd, runInput); } -async function readLatest(cwd: string): Promise { - const latest = await readJsonIfExists(path.join(cwd, ".humanish", "runs", "latest.json")); - - if (isRunPointer(latest)) { - return latest; +async function readLatest(runsRoot: import("./selected-output-paths.js").PreparedSelectedOutputDirectory): Promise { + const latestPath = path.join(runsRoot.physicalPath, "latest.json"); + let latestStats; + try { + latestStats = await lstat(latestPath, { bigint: true }); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + if (latestStats.isSymbolicLink() || !latestStats.isFile() || latestStats.nlink !== 1n) { + throw new Error("Latest run pointer must be a single-link regular file."); + } + const bytes = await readContainedRegularFile(runsRoot, "latest.json"); + if (!bytes) { + throw new Error("Latest run pointer changed while it was being read."); + } + let latest: unknown; + try { + latest = JSON.parse(bytes.toString("utf8")) as unknown; + } catch { + return null; } - return null; + return isRunPointer(latest) ? latest : null; } -async function readPackageName(cwd: string): Promise { - const packageJson = await readJsonIfExists(path.join(cwd, "package.json")); - return isRecord(packageJson) && typeof packageJson.name === "string" ? packageJson.name : null; +async function readPackageName(projectRoot: PreparedSelectedOutputDirectory): Promise { + const text = await readImplicitProjectFile(projectRoot, "package.json"); + if (text === null) { + return null; + } + try { + const packageJson = JSON.parse(text) as unknown; + return isRecord(packageJson) && typeof packageJson.name === "string" ? packageJson.name : null; + } catch { + return null; + } } -async function readJsonIfExists(filePath: string): Promise { - const text = await readTextIfExists(filePath); - +async function readRunJsonIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise { + const text = await readRunTextIfExists(runPaths, ...segments); if (text === null) { return null; } - try { return JSON.parse(text) as unknown; } catch { @@ -4334,6 +4695,43 @@ async function readJsonIfExists(filePath: string): Promise { } } +async function readRunTextIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise { + const bytes = await readContainedRegularFile(runPaths, segments.join("/")); + return bytes?.toString("utf8") ?? null; +} + +async function readSafeRunArtifactBytes( + runPaths: PreparedRunArtifactPaths, + relativePath: string +): Promise { + const normalized = relativePath.replace(/\\/g, "/"); + const segments = normalized.split("/"); + if ( + path.isAbsolute(relativePath) + || path.win32.isAbsolute(relativePath) + || segments.length === 0 + || segments.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + return null; + } + return readContainedRegularFile(runPaths, normalized); +} + +async function readSafeRunArtifactJson( + runPaths: PreparedRunArtifactPaths, + relativePath: string +): Promise { + const bytes = await readSafeRunArtifactBytes(runPaths, relativePath); + if (!bytes) { + return null; + } + try { + return JSON.parse(bytes.toString("utf8")) as unknown; + } catch { + return null; + } +} + async function readTextIfExists(filePath: string): Promise { try { return await readFile(filePath, "utf8"); @@ -4346,24 +4744,19 @@ async function readTextIfExists(filePath: string): Promise { } } -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -async function writeRunBundleArtifacts(absoluteArtifactRoot: string, bundle: RunBundle): Promise { +async function writeRunBundleArtifacts(runPaths: PreparedRunArtifactPaths, bundle: RunBundle): Promise { const publicBundle: RunBundle = { ...bundle, cwd: PUBLIC_TARGET_CWD }; - await writeJson(path.join(absoluteArtifactRoot, "run.json"), publicBundle); - await writeJson(path.join(absoluteArtifactRoot, "review.json"), publicBundle.review); - await writeFile(path.join(absoluteArtifactRoot, "review.md"), renderReviewMarkdown(publicBundle), "utf8"); - await writeFile(path.join(absoluteArtifactRoot, "events.ndjson"), `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); - await mkdir(path.join(absoluteArtifactRoot, "observer"), { recursive: true }); - await writeJson(path.join(absoluteArtifactRoot, "observer", "observer-data.json"), buildObserverData(publicBundle)); + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderReviewMarkdown(publicBundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "observer/observer-data.json", `${JSON.stringify(buildObserverData(publicBundle), null, 2)}\n`, "utf8"); } -async function missingLocalEvidenceArtifacts(runRoot: string, bundle: RunBundle): Promise { +async function missingLocalEvidenceArtifacts(runPaths: PreparedRunArtifactPaths, bundle: RunBundle): Promise { const requiredPaths = new Map(); const addRequiredPath = (artifactPath: string, options: { screenshot?: boolean } = {}): void => { const existing = requiredPaths.get(artifactPath); @@ -4400,16 +4793,14 @@ async function missingLocalEvidenceArtifacts(runRoot: string, bundle: RunBundle) const missing: string[] = []; for (const [artifactPath, requirements] of requiredPaths) { - const absolutePath = path.join(runRoot, artifactPath); - const stats = await stat(absolutePath).catch(() => null); - if (!stats?.isFile() || stats.size <= 0) { + const bytes = await readSafeRunArtifactBytes(runPaths, artifactPath); + if (!bytes || bytes.length === 0) { missing.push(artifactPath); continue; } if (requirements.screenshot) { - const bytes = await readFile(absolutePath).catch(() => null); - const imageError = bytes ? screenshotEvidenceError(artifactPath, bytes) : "could not read screenshot bytes"; + const imageError = screenshotEvidenceError(artifactPath, bytes); if (imageError) { missing.push(`${artifactPath} (${imageError})`); } @@ -4476,7 +4867,7 @@ const TERMINAL_TRANSCRIPT_FILE = "terminal-transcript.txt"; * file is already caught by scanRunPublicSafetyArtifacts; this check enforces the STRUCTURAL * evidence + the proven-teardown invariant. Dry-run/contract bundles are exempt (mode !== live). */ -async function validateTerminalProductEvidence(runRoot: string, bundle: RunBundle): Promise { +async function validateTerminalProductEvidence(runPaths: PreparedRunArtifactPaths, bundle: RunBundle): Promise { if (bundle.mode !== "live") { return []; } @@ -4492,7 +4883,7 @@ async function validateTerminalProductEvidence(runRoot: string, bundle: RunBundl } // The lane writes exactly one terminal run's ledgers/evidence at fixed paths in the run root. - const ledgers = await readJsonIfExists(path.join(runRoot, TERMINAL_LEDGERS_FILE)); + const ledgers = await readSafeRunArtifactJson(runPaths, TERMINAL_LEDGERS_FILE); if (!isRecord(ledgers) || ledgers.schema !== "humanish.terminal-ledgers.v1") { findings.push(`missing or malformed ${TERMINAL_LEDGERS_FILE} (humanish.terminal-ledgers.v1)`); return findings; @@ -4533,10 +4924,10 @@ async function validateTerminalProductEvidence(runRoot: string, bundle: RunBundl // The redacted exec-stream + normalized transcript artifacts must be WRITTEN (the producer // always writes them on the live path, even empty for a no-output blocked run — so absence is a // real evidence gap, while emptiness is legitimate and keeps blocked runs verifiable). - if (!(await fileExists(path.join(runRoot, TERMINAL_EVENTS_FILE)))) { + if (!(await readSafeRunArtifactBytes(runPaths, TERMINAL_EVENTS_FILE))) { findings.push(`missing terminal event stream artifact (${TERMINAL_EVENTS_FILE})`); } - if (!(await fileExists(path.join(runRoot, TERMINAL_TRANSCRIPT_FILE)))) { + if (!(await readSafeRunArtifactBytes(runPaths, TERMINAL_TRANSCRIPT_FILE))) { findings.push(`missing normalized terminal transcript artifact (${TERMINAL_TRANSCRIPT_FILE})`); } @@ -4544,7 +4935,7 @@ async function validateTerminalProductEvidence(runRoot: string, bundle: RunBundl for (const stream of terminalStreams) { const traceArtifact = stream.artifacts.find((artifact) => artifact.kind === "trace"); const tracePath = traceArtifact?.path ?? "actor.json"; - const trace = await readJsonIfExists(path.join(runRoot, tracePath)); + const trace = await readSafeRunArtifactJson(runPaths, tracePath); if (!isRecord(trace) || trace.lane !== "terminal") { findings.push(`${stream.id} missing terminal-lane actor trace`); continue; @@ -4639,7 +5030,7 @@ function validateTerminalCostEvidence(ledgers: Record): string[ return findings; } -async function validateCodexAppServerEvidence(runRoot: string, bundle: RunBundle): Promise { +async function validateCodexAppServerEvidence(runPaths: PreparedRunArtifactPaths, bundle: RunBundle): Promise { if (bundle.mode !== "live") { return []; } @@ -4678,7 +5069,7 @@ async function validateCodexAppServerEvidence(runRoot: string, bundle: RunBundle continue; } - const trace = await readJsonIfExists(path.join(runRoot, traceArtifact.path)); + const trace = await readSafeRunArtifactJson(runPaths, traceArtifact.path); if (!isRecord(trace) || ![CODEX_APP_SERVER_TRACE_SCHEMA, CODEX_APP_SERVER_PROJECTED_TRACE_SCHEMA].includes(String(trace.schema))) { findings.push(`${stream.id} trace artifact must use ${CODEX_APP_SERVER_TRACE_SCHEMA} or ${CODEX_APP_SERVER_PROJECTED_TRACE_SCHEMA}`); } @@ -5390,37 +5781,53 @@ const riskyPublicArtifactPathSegments = new Set([ "profiles" ]); -async function scanRunPublicSafetyArtifacts(runRoot: string): Promise { +async function scanRunPublicSafetyArtifacts(runPaths: PreparedRunArtifactPaths): Promise { const findings: string[] = []; - await scanRunPublicSafetyDirectory(runRoot, runRoot, findings); + await validatePreparedRunArtifactPaths(runPaths); + await scanRunPublicSafetyDirectory(runPaths, "", findings); + await validatePreparedRunArtifactPaths(runPaths); return findings; } -async function scanRunPublicSafetyDirectory(root: string, current: string, findings: string[]): Promise { +async function scanRunPublicSafetyDirectory( + runPaths: PreparedRunArtifactPaths, + relativeDirectory: string, + findings: string[] +): Promise { if (findings.length >= 50) { return; } - const entries = await readdir(current, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - const absolutePath = path.join(current, entry.name); - const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); + const current = relativeDirectory + ? path.join(runPaths.physicalRunRoot, ...relativeDirectory.split("/")) + : runPaths.physicalRunRoot; + const entries = await readdir(current).catch(() => []); + for (const entryName of entries) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${entryName}` : entryName; if (isRiskyPublicArtifactPath(relativePath) || containsSensitivePattern(relativePath)) { findings.push(`risky artifact path ${relativePath}`); if (findings.length >= 50) return; } - if (entry.isDirectory()) { - await scanRunPublicSafetyDirectory(root, absolutePath, findings); + const stats = await lstat(path.join(current, entryName), { bigint: true }).catch(() => null); + if (!stats || stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile()) || (stats.isFile() && stats.nlink > 1n)) { + findings.push(`unsafe artifact leaf ${relativePath}`); if (findings.length >= 50) return; continue; } - if (!entry.isFile() || !shouldScanTextArtifact(relativePath)) { + if (stats.isDirectory()) { + await scanRunPublicSafetyDirectory(runPaths, relativePath, findings); + if (findings.length >= 50) return; continue; } - const text = await readFile(absolutePath, "utf8").catch(() => null); + if (!shouldScanTextArtifact(relativePath)) { + continue; + } + + const bytes = await readSafeRunArtifactBytes(runPaths, relativePath); + const text = bytes?.toString("utf8") ?? null; if (text !== null && containsSensitivePattern(text)) { findings.push(`sensitive text ${relativePath}`); if (findings.length >= 50) return; @@ -5485,30 +5892,6 @@ async function validateCwd(cwd: string): Promise { } } -async function directoryExists(directoryPath: string): Promise { - try { - return (await stat(directoryPath)).isDirectory(); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - return false; - } - - throw error; - } -} - -async function fileExists(filePath: string): Promise { - try { - return (await stat(filePath)).isFile(); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - return false; - } - - throw error; - } -} - function containsSensitivePattern(text: string): boolean { return containsSensitive(text); } diff --git a/src/scripted-browser-actor.ts b/src/scripted-browser-actor.ts index f252f1c..3f98f89 100644 --- a/src/scripted-browser-actor.ts +++ b/src/scripted-browser-actor.ts @@ -19,7 +19,7 @@ // affirmative $0 declaration that is TRUE by mechanism. import { execFile, spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -36,7 +36,18 @@ import { type ActorTraceItem } from "./actor-contract.js"; import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS } from "./browser-evidence-hygiene.js"; +import { assertScreenshotEvidence } from "./image-evidence.js"; import { digestText, redactText, redactToSecretLabel } from "./redaction.js"; +import { + assertPreparedSelectedOutputDirectory, + assertSafeOutputPathSegment, + prepareContainedOutputDirectory, + prepareContainedOutputFile, + prepareSelectedOutputDirectory, + readContainedRegularFile, + type PreparedOutputDirectory, + writeContainedOutputFile +} from "./selected-output-paths.js"; const execFileAsync = promisify(execFile); @@ -220,7 +231,7 @@ export const browserSurfaces: BrowserSurface[] = [ // --------------------------------------------------------------------------- export async function captureBrowserSurface(args: { - absoluteArtifactRoot: string; + absoluteArtifactRoot: PreparedOutputDirectory; appUrl: string; browserCommand: string; browserJourney: BrowserPersonaJourney; @@ -235,7 +246,7 @@ export async function captureBrowserSurface(args: { } export async function captureBrowserSurfaceFixture(args: { - absoluteArtifactRoot: string; + absoluteArtifactRoot: PreparedOutputDirectory; appUrl: string; browserCommand: string; browserJourney: BrowserPersonaJourney; @@ -244,7 +255,7 @@ export async function captureBrowserSurfaceFixture(args: { }): Promise { const started = Date.now(); const tracePath = path.join("traces", `${args.surface.id}.json`); - const absoluteTracePath = path.join(args.absoluteArtifactRoot, tracePath); + await assertScriptedOutputRoot(args.absoluteArtifactRoot); const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000)); const capturedAt = new Date().toISOString(); const profileDir = await mkdtemp(path.join(os.tmpdir(), "humanish-browser-profile-")); @@ -258,18 +269,16 @@ export async function captureBrowserSurfaceFixture(args: { } const stepStarted = Date.now(); const screenshotPath = screenshotPathForBrowserStep(args.surface, step); - const absoluteScreenshotPath = path.join(args.absoluteArtifactRoot, screenshotPath); - await captureScreenshotWithBrowser({ - args: browserScreenshotArgs({ - appUrl: currentUrl, - profileDir, - screenshotPath: absoluteScreenshotPath, - surface: args.surface - }), + await prepareContainedOutputFile(args.absoluteArtifactRoot, screenshotPath); + const screenshotBytes = await captureBrowserCommandScreenshot({ + appUrl: currentUrl, browserCommand: args.browserCommand, - screenshotPath: absoluteScreenshotPath, + profileDir, + surface: args.surface, timeoutMs: args.timeoutMs }); + assertScreenshotEvidence(screenshotPath, screenshotBytes); + await writeContainedOutputFile(args.absoluteArtifactRoot, screenshotPath, screenshotBytes); const assertions = fixtureAssertionsForBrowserStep(step, httpProbe.ok); steps.push({ action: step.action, @@ -296,7 +305,7 @@ export async function captureBrowserSurfaceFixture(args: { timestamp: capturedAt }); const blockedScreenshotPath = surfaceScreenshotPath(blockedSteps); - await writeJson(absoluteTracePath, buildBrowserTrace({ + await writeContainedOutputFile(args.absoluteArtifactRoot, tracePath, `${JSON.stringify(buildBrowserTrace({ appUrl: args.appUrl, browserCommand: path.basename(args.browserCommand), browserJourney: args.browserJourney, @@ -308,7 +317,7 @@ export async function captureBrowserSurfaceFixture(args: { ...(blockedScreenshotPath === undefined ? {} : { screenshotPath: blockedScreenshotPath }), steps: blockedSteps, surface: args.surface - })); + }), null, 2)}\n`, "utf8"); return { capturedAt, durationMs: Date.now() - started, @@ -328,7 +337,11 @@ export async function captureBrowserSurfaceFixture(args: { // screenshotPath. A step claiming success whose screenshot is missing or empty // must still drag the capture out of `ok` — the strict verifier then catches it. const screenshotStats = await Promise.all( - steps.map((step) => (step.screenshotPath ? stat(path.join(args.absoluteArtifactRoot, step.screenshotPath)) : Promise.resolve(null)).catch(() => null)) + steps.map(async (step) => { + if (!step.screenshotPath) return null; + const screenshotFile = await prepareContainedOutputFile(args.absoluteArtifactRoot, step.screenshotPath); + return stat(screenshotFile); + }).map((result) => result.catch(() => null)) ); const screenshotsOk = screenshotStats.every((stats) => stats?.isFile() && stats.size > 0); const ok = Boolean(screenshotsOk && httpProbe.ok && steps.every((step) => step.status === "passed")); @@ -341,7 +354,7 @@ export async function captureBrowserSurfaceFixture(args: { const durationMs = Date.now() - started; const fixtureScreenshotPath = surfaceScreenshotPath(steps); - await writeJson(absoluteTracePath, buildBrowserTrace({ + await writeContainedOutputFile(args.absoluteArtifactRoot, tracePath, `${JSON.stringify(buildBrowserTrace({ appUrl: args.appUrl, browserCommand: path.basename(args.browserCommand), browserJourney: args.browserJourney, @@ -353,7 +366,7 @@ export async function captureBrowserSurfaceFixture(args: { ...(fixtureScreenshotPath === undefined ? {} : { screenshotPath: fixtureScreenshotPath }), steps, surface: args.surface - })); + }), null, 2)}\n`, "utf8"); return { capturedAt: completedAt, @@ -369,7 +382,7 @@ export async function captureBrowserSurfaceFixture(args: { } export async function captureBrowserSurfaceWithPlaywright(args: { - absoluteArtifactRoot: string; + absoluteArtifactRoot: PreparedOutputDirectory; appUrl: string; browserCommand: string; browserJourney: BrowserPersonaJourney; @@ -378,7 +391,7 @@ export async function captureBrowserSurfaceWithPlaywright(args: { }): Promise { const started = Date.now(); const tracePath = path.join("traces", `${args.surface.id}.json`); - const absoluteTracePath = path.join(args.absoluteArtifactRoot, tracePath); + await assertScriptedOutputRoot(args.absoluteArtifactRoot); const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000)); let browser: ScriptedBrowserLike | null = null; let page: ScriptedPageLike | null = null; @@ -451,7 +464,7 @@ export async function captureBrowserSurfaceWithPlaywright(args: { : `${args.surface.label} browser persona journey blocked: ${steps.find((step) => step.status !== "passed")?.reason ?? httpProbe.reason}`; const playwrightScreenshotPath = surfaceScreenshotPath(steps); - await writeJson(absoluteTracePath, buildBrowserTrace({ + await writeContainedOutputFile(args.absoluteArtifactRoot, tracePath, `${JSON.stringify(buildBrowserTrace({ appUrl: args.appUrl, browserCommand: path.basename(args.browserCommand), browserJourney: args.browserJourney, @@ -463,7 +476,7 @@ export async function captureBrowserSurfaceWithPlaywright(args: { ...(playwrightScreenshotPath === undefined ? {} : { screenshotPath: playwrightScreenshotPath }), steps, surface: args.surface - })); + }), null, 2)}\n`, "utf8"); return { capturedAt: completedAt, @@ -479,7 +492,7 @@ export async function captureBrowserSurfaceWithPlaywright(args: { } export async function executeBrowserPersonaStep(args: { - absoluteArtifactRoot: string; + absoluteArtifactRoot: PreparedOutputDirectory; appUrl: string; browserJourney: BrowserPersonaJourney; page: ScriptedPageLike; @@ -537,10 +550,10 @@ export async function executeBrowserPersonaStep(args: { const afterState = await browserPersonaPageState(args.page, urlPolicy); const screenshotPath = screenshotPathForBrowserStep(args.surface, args.step); - await args.page.screenshot({ - path: path.join(args.absoluteArtifactRoot, screenshotPath), - fullPage: true - }); + await prepareContainedOutputFile(args.absoluteArtifactRoot, screenshotPath); + const screenshotBytes = await captureScriptedPageScreenshot(args.page); + assertScreenshotEvidence(screenshotPath, screenshotBytes); + await writeContainedOutputFile(args.absoluteArtifactRoot, screenshotPath, screenshotBytes); const assertions = await evaluateBrowserStepExpectations({ afterState, beforeState, @@ -690,9 +703,25 @@ function fixtureAssertionsForBrowserStep(step: BrowserPersonaStepManifest, httpO } export function screenshotPathForBrowserStep(surface: BrowserSurface, step: BrowserPersonaStepManifest | undefined): string { + assertSafeOutputPathSegment(surface.id, "Browser surface id"); + if (step) { + assertSafeOutputPathSegment(step.id, "Browser journey step id"); + } return path.join("screenshots", `${surface.id}-${step?.id ?? "step"}.png`); } +function tracePathForBrowserSurface(surface: BrowserSurface): string { + assertSafeOutputPathSegment(surface.id, "Browser surface id"); + return path.join("traces", `${surface.id}.json`); +} + +function assertScriptedSessionPathIds(options: ScriptedBrowserSessionOptions): void { + assertSafeOutputPathSegment(options.surface.id, "Browser surface id"); + for (const step of options.journey.steps) { + assertSafeOutputPathSegment(step.id, "Browser journey step id"); + } +} + /** * Best-effort screenshot of a blocked step. The step is blocked, so its failure is * the evidence; the shot is a bonus. Returns the relative path plus whether the @@ -702,14 +731,23 @@ export function screenshotPathForBrowserStep(surface: BrowserSurface, step: Brow */ async function captureBlockedStepScreenshot( page: ScriptedPageLike | null, - artifactRoot: string, + artifactRoot: PreparedOutputDirectory, surface: BrowserSurface, step: BrowserPersonaStepManifest ): Promise<{ screenshotPath: string; written: boolean }> { const screenshotPath = screenshotPathForBrowserStep(surface, step); - await page?.screenshot({ path: path.join(artifactRoot, screenshotPath), fullPage: true }).catch(() => undefined); - const stats = await stat(path.join(artifactRoot, screenshotPath)).catch(() => null); - return { screenshotPath, written: Boolean(stats?.isFile() && stats.size > 0) }; + await prepareContainedOutputFile(artifactRoot, screenshotPath); + if (!page) { + return { screenshotPath, written: false }; + } + try { + const screenshotBytes = await captureScriptedPageScreenshot(page); + assertScreenshotEvidence(screenshotPath, screenshotBytes); + await writeContainedOutputFile(artifactRoot, screenshotPath, screenshotBytes); + return { screenshotPath, written: true }; + } catch { + return { screenshotPath, written: false }; + } } /** @@ -877,6 +915,40 @@ export async function captureScreenshotWithBrowser(args: { ); } +async function captureBrowserCommandScreenshot(args: { + appUrl: string; + browserCommand: string; + profileDir: string; + surface: BrowserSurface; + timeoutMs: number; +}): Promise { + const stagingPath = await mkdtemp(path.join(os.tmpdir(), "humanish-browser-command-shot-")); + const stagingRoot = await prepareSelectedOutputDirectory(path.dirname(stagingPath), stagingPath); + try { + const screenshotPath = path.join(stagingRoot.physicalPath, "capture.png"); + await captureScreenshotWithBrowser({ + args: browserScreenshotArgs({ + appUrl: args.appUrl, + profileDir: args.profileDir, + screenshotPath, + surface: args.surface + }), + browserCommand: args.browserCommand, + screenshotPath, + timeoutMs: args.timeoutMs + }); + const bytes = await readContainedRegularFile(stagingRoot, "capture.png"); + if (!bytes) { + throw new Error("Browser screenshot command did not write a single-link staging file."); + } + return bytes; + } finally { + await assertPreparedSelectedOutputDirectory(stagingRoot) + .then(() => rm(stagingRoot.physicalPath, { force: true, recursive: true })) + .catch(() => undefined); + } +} + function terminateProcessGroup(pid: number | undefined, force = false): void { if (!pid) { return; @@ -1240,6 +1312,26 @@ class ScriptedJourneyTimeoutError extends Error { * approvals exist on a deterministic replay) — asserted in tests. */ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionOptions): Promise { + assertScriptedSessionPathIds(options); + const preparedArtifactRoot = await prepareSelectedOutputDirectory(process.cwd(), options.artifactRoot); + return runScriptedBrowserSessionInPreparedRoot(options, preparedArtifactRoot); +} + +/** Internal lab seam: the run root is already prepared and must stay bound to that identity. */ +export async function runScriptedBrowserSessionInPreparedRoot( + options: ScriptedBrowserSessionOptions, + preparedArtifactRoot: PreparedOutputDirectory +): Promise { + assertScriptedSessionPathIds(options); + await prepareContainedOutputDirectory(preparedArtifactRoot, "screenshots"); + await prepareContainedOutputDirectory(preparedArtifactRoot, "traces"); + await prepareContainedOutputFile(preparedArtifactRoot, tracePathForBrowserSurface(options.surface)); + await Promise.all( + options.journey.steps.map((step) => + prepareContainedOutputFile(preparedArtifactRoot, screenshotPathForBrowserStep(options.surface, step)) + ) + ); + await assertScriptedOutputRoot(preparedArtifactRoot); const now = options.now ?? (() => Date.now()); const startedAtMs = now(); const startedAt = new Date(startedAtMs).toISOString(); @@ -1248,9 +1340,6 @@ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionO const evidenceAppUrl = options.evidenceAppUrl ?? options.appUrl; const urlPolicy = options.urlPolicy ?? LOOPBACK_EVIDENCE_URL_POLICY; - await mkdir(path.join(options.artifactRoot, "screenshots"), { recursive: true }); - await mkdir(path.join(options.artifactRoot, "traces"), { recursive: true }); - const finish = async (args: { capture: BrowserSurfaceCapture; executedSteps: number; @@ -1260,7 +1349,7 @@ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionO }): Promise => { const completedAtMs = now(); const trace = await projectScriptedActorTrace({ - artifactRoot: options.artifactRoot, + artifactRoot: preparedArtifactRoot, capture: args.capture, completedAt: new Date(completedAtMs).toISOString(), completionReason: args.completionReason, @@ -1289,7 +1378,7 @@ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionO const reason = `Scripted browser launch failed: ${compactBrowserError(error)}`; const capture = await persistScriptedFailureCapture({ appUrl: options.appUrl, - artifactRoot: options.artifactRoot, + artifactRoot: preparedArtifactRoot, browserCommand, evidenceAppUrl, journey: options.journey, @@ -1299,10 +1388,16 @@ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionO }); return finish({ capture, executedSteps: 0, status: "failed", completionReason: "harness_error", reason }); } + try { + await assertScriptedOutputRoot(preparedArtifactRoot); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } const journeyRun = await runScriptedJourney({ appUrl: options.appUrl, - artifactRoot: options.artifactRoot, + artifactRoot: preparedArtifactRoot, browser, browserCommand, evidenceAppUrl, @@ -1346,7 +1441,7 @@ export async function runScriptedBrowserSession(options: ScriptedBrowserSessionO * semantics (partial blocked steps on error, trace written exactly once, browser closed). */ async function runScriptedJourney(args: { appUrl: string; - artifactRoot: string; + artifactRoot: PreparedOutputDirectory; browser: ScriptedBrowserLike; browserCommand: string; evidenceAppUrl: string; @@ -1357,8 +1452,7 @@ async function runScriptedJourney(args: { }): Promise<{ capture: BrowserSurfaceCapture; executedSteps: number; timedOut: boolean }> { const started = Date.now(); const deadline = started + args.timeoutMs; - const tracePath = path.join("traces", `${args.surface.id}.json`); - const absoluteTracePath = path.join(args.artifactRoot, tracePath); + const tracePath = tracePathForBrowserSurface(args.surface); const httpProbe = await probeAppUrl(args.appUrl, Math.min(args.timeoutMs, 15_000)); let page: ScriptedPageLike | null = null; const steps: BrowserPersonaStepCapture[] = []; @@ -1377,6 +1471,7 @@ async function runScriptedJourney(args: { page = await context.newPage(); for (const step of args.journey.steps) { + await assertScriptedOutputRoot(args.artifactRoot); executedSteps += 1; steps.push(await withJourneyDeadline( executeBrowserPersonaStep({ @@ -1394,6 +1489,7 @@ async function runScriptedJourney(args: { )); } } catch (error) { + await assertScriptedOutputRoot(args.artifactRoot); timedOut = error instanceof ScriptedJourneyTimeoutError; const now = new Date().toISOString(); const reason = compactBrowserError(error); @@ -1436,7 +1532,7 @@ async function runScriptedJourney(args: { : `${args.surface.label} scripted browser journey blocked: ${steps.find((step) => step.status !== "passed")?.reason ?? httpProbe.reason}`; const scriptedScreenshotPath = surfaceScreenshotPath(steps); - await writeJson(absoluteTracePath, buildBrowserTrace({ + await writeContainedOutputFile(args.artifactRoot, tracePath, `${JSON.stringify(buildBrowserTrace({ appUrl: args.evidenceAppUrl, browserCommand: path.basename(args.browserCommand || "injected-browser"), browserJourney: args.journey, @@ -1448,7 +1544,7 @@ async function runScriptedJourney(args: { ...(scriptedScreenshotPath === undefined ? {} : { screenshotPath: scriptedScreenshotPath }), steps, surface: args.surface - })); + }), null, 2)}\n`, "utf8"); return { capture: { @@ -1496,7 +1592,7 @@ async function withJourneyDeadline(promise: Promise, deadline: number, tim * journey actuation (browser launch crash). Mirrors the driver's failure shape. */ async function persistScriptedFailureCapture(args: { appUrl: string; - artifactRoot: string; + artifactRoot: PreparedOutputDirectory; browserCommand: string; evidenceAppUrl: string; journey: BrowserPersonaJourney; @@ -1505,7 +1601,7 @@ async function persistScriptedFailureCapture(args: { urlPolicy: ScriptedBrowserEvidenceUrlPolicy; }): Promise { const capturedAt = new Date().toISOString(); - const tracePath = path.join("traces", `${args.surface.id}.json`); + const tracePath = tracePathForBrowserSurface(args.surface); const blockedSteps = buildBlockedBrowserPersonaSteps({ browserJourney: args.journey, currentUrl: args.appUrl, @@ -1517,7 +1613,7 @@ async function persistScriptedFailureCapture(args: { // Pre-actuation failure: no screenshots were written, so the surface omits the // screenshot reference and the failure itself stands as the evidence. const screenshotPath = surfaceScreenshotPath(blockedSteps); - await writeJson(path.join(args.artifactRoot, tracePath), buildBrowserTrace({ + await writeContainedOutputFile(args.artifactRoot, tracePath, `${JSON.stringify(buildBrowserTrace({ appUrl: args.evidenceAppUrl, browserCommand: path.basename(args.browserCommand || "injected-browser"), browserJourney: args.journey, @@ -1528,7 +1624,7 @@ async function persistScriptedFailureCapture(args: { ...(screenshotPath === undefined ? {} : { screenshotPath }), steps: blockedSteps, surface: args.surface - })); + }), null, 2)}\n`, "utf8"); return { capturedAt, durationMs: 0, @@ -1545,7 +1641,7 @@ async function persistScriptedFailureCapture(args: { * for frames that actually exist on disk (honest counts; blocked-not-executed steps name a * path that was never written). */ async function projectScriptedActorTrace(args: { - artifactRoot: string; + artifactRoot: PreparedOutputDirectory; capture: BrowserSurfaceCapture; completedAt: string; completionReason: ActorCompletionReason; @@ -1562,7 +1658,8 @@ async function projectScriptedActorTrace(args: { if (!step.screenshotPath) { continue; } - const stats = await stat(path.join(args.artifactRoot, step.screenshotPath)).catch(() => null); + const screenshotFile = await prepareContainedOutputFile(args.artifactRoot, step.screenshotPath).catch(() => null); + const stats = screenshotFile ? await stat(screenshotFile).catch(() => null) : null; if (stats?.isFile() && stats.size > 0) { writtenScreenshots.add(step.screenshotPath); } @@ -1626,8 +1723,46 @@ async function projectScriptedActorTrace(args: { // the code that stayed behind — this module must remain a leaf). // --------------------------------------------------------------------------- -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +async function assertScriptedOutputRoot(root: PreparedOutputDirectory): Promise { + if ("physicalRunRoot" in root) { + await prepareContainedOutputDirectory(root, ""); + return root.physicalRunRoot; + } + await assertPreparedSelectedOutputDirectory(root); + return root.physicalPath; +} + +function browserScreenshotBytes(value: unknown): Buffer { + if (Buffer.isBuffer(value)) { + return value; + } + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + throw new Error("Browser screenshot did not return image bytes."); +} + +async function captureScriptedPageScreenshot(page: ScriptedPageLike): Promise { + const stagingPath = await mkdtemp(path.join(os.tmpdir(), "humanish-browser-shot-")); + const stagingRoot = await prepareSelectedOutputDirectory(path.dirname(stagingPath), stagingPath); + try { + const returned = await page.screenshot({ + path: path.join(stagingRoot.physicalPath, "capture.png"), + fullPage: true + }); + if (Buffer.isBuffer(returned) || returned instanceof Uint8Array) { + return browserScreenshotBytes(returned); + } + const stagedBytes = await readContainedRegularFile(stagingRoot, "capture.png"); + if (!stagedBytes) { + throw new Error("Browser screenshot did not return bytes or write a single-link staging file."); + } + return stagedBytes; + } finally { + await assertPreparedSelectedOutputDirectory(stagingRoot) + .then(() => rm(stagingRoot.physicalPath, { force: true, recursive: true })) + .catch(() => undefined); + } } function shellQuote(value: string): string { diff --git a/src/scripted-browser-lab.ts b/src/scripted-browser-lab.ts index 3023975..0186823 100644 --- a/src/scripted-browser-lab.ts +++ b/src/scripted-browser-lab.ts @@ -21,7 +21,7 @@ // plus a host digest while never writing the raw getHost URL or secret values into artifacts. import { randomBytes } from "node:crypto"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { realpath } from "node:fs/promises"; import path from "node:path"; import { parse as parseYaml } from "yaml"; @@ -44,6 +44,11 @@ import type { DetachedTimers } from "./e2b-detached.js"; import type { LabConfig } from "./lab-config.js"; import { renderObserver, type ObserverResult } from "./observer.js"; import { digestText, redactText } from "./redaction.js"; +import { + prepareRunArtifactPaths, + type PreparedRunArtifactPaths, + validatePreparedRunArtifactPaths +} from "./run-paths.js"; import { buildRunSource, PUBLIC_TARGET_CWD, @@ -62,6 +67,7 @@ import { normalizeLocalAppUrl, parseBrowserPersonaJourneyFromScenario, resolveBrowserCommand, + runScriptedBrowserSessionInPreparedRoot, type BrowserPersonaJourney, type BrowserSurface, type ScriptedBrowserEvidenceUrlPolicy, @@ -70,6 +76,13 @@ import { type ScriptedBrowserSessionOptions, type ScriptedBrowserSessionResult } from "./scripted-browser-actor.js"; +import { + prepareSelectedOutputDirectory, + readContainedRegularFile, + type PreparedSelectedOutputDirectory, + writeContainedOutputFile, + writePreparedRunLatestPointer +} from "./selected-output-paths.js"; export const SCRIPTED_BROWSER_LAB_SCHEMA = "humanish.scripted-lab-result.v1"; @@ -85,6 +98,13 @@ const DEFAULT_SURFACE_COUNT = 1; // it is interpolated into a repo path. const SCENARIO_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +class UnsafeScriptedSessionResultError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeScriptedSessionResultError"; + } +} + /** * Library-level hooks: DI seams so CI drives the full path (real engine, real projection) * with a fake browser at zero spend, plus the production browser resolution override. @@ -168,6 +188,8 @@ export interface ScriptedBrowserLabResult { export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOptions): Promise { const { config, dryRun } = options; const cwd = path.resolve(options.cwd); + const physicalCwd = await realpath(cwd); + const projectRoot = await prepareSelectedOutputDirectory(path.dirname(physicalCwd), physicalCwd); const hooks = options.hooks ?? {}; const render = hooks.renderObserverFn ?? renderObserver; const warnings: string[] = []; @@ -201,7 +223,7 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption `actors[0].type "${actorType}" is not a registered scripted-browser actor.` ); } - const runSession = hooks.runSession ?? descriptor.runSession; + const runSession = hooks.runSession; const provisionedRoute = config.subject.source === "clone"; const evidenceAppUrl = provisionedRoute ? "[provisioned-subject]" : normalizeLocalAppUrl(config.subject.appUrl ?? "") ?? ""; const urlPolicy: ScriptedBrowserEvidenceUrlPolicy = provisionedRoute @@ -242,7 +264,7 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption // Consume scenario.ref (fail-closed: invariant 6 — the steps ARE the actor; there is no // built-in journey fallback on the lab route). - const scenario = await resolveScriptedScenario(cwd, config.scenario?.ref); + const scenario = await resolveScriptedScenario(projectRoot, config.scenario?.ref); if (!scenario.ok) { return failed("HUMANISH_SCRIPTED_LAB_SCENARIO_INVALID", scenario.message, { actor: descriptor.id, appUrl: evidenceAppUrl }); } @@ -291,12 +313,12 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption } const runId = options.runId ?? makeScriptedRunId(); - const artifactRoot = path.join(cwd, ".humanish", "runs", runId); + const runPaths = await prepareRunArtifactPaths(physicalCwd, runId); + const artifactRoot = runPaths.physicalRunRoot; const createdAt = new Date().toISOString(); - await mkdir(path.join(artifactRoot, "screenshots"), { recursive: true }); const source = await buildRunSource({ capturedAt: createdAt, - cwd, + cwd: physicalCwd, humanishSource: "present", packageName: "humanish" }); @@ -317,6 +339,7 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000); const timers: DetachedTimers = hooks.detachedTimers ?? {}; subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)(); + await validatePreparedRunArtifactPaths(runPaths); subjectDesktop = await createDesktopSandbox(subjectModule, { apiKey: e2bApiKey, requestTimeoutMs, @@ -340,6 +363,7 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption if (hooks.prepareDesktop) { await hooks.prepareDesktop(subjectDesktop); + await validatePreparedRunArtifactPaths(runPaths); } subjectCommit = await provisionCloneSubject(subjectDesktop, { @@ -372,20 +396,32 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption } // One session per surface, in parallel — parity with `run --app-url`. - sessionResults = await Promise.all(surfaces.map((surface) => runSession({ - appUrl, - evidenceAppUrl, - urlPolicy, - journey, - surface, - persona, - timeoutMs, - artifactRoot, - ...(browserCommand === undefined ? {} : { browserCommand }), - ...(hooks.launchBrowser === undefined ? {} : { launchBrowser: hooks.launchBrowser }), - ...(hooks.now === undefined ? {} : { now: hooks.now }) - }))); + sessionResults = await Promise.all(surfaces.map((surface) => { + const sessionOptions: ScriptedBrowserSessionOptions = { + appUrl, + evidenceAppUrl, + urlPolicy, + journey, + surface, + persona, + timeoutMs, + artifactRoot, + ...(browserCommand === undefined ? {} : { browserCommand }), + ...(hooks.launchBrowser === undefined ? {} : { launchBrowser: hooks.launchBrowser }), + ...(hooks.now === undefined ? {} : { now: hooks.now }) + }; + return runSession + ? runSession(sessionOptions).then(async (result) => { + await validatePreparedRunArtifactPaths(runPaths); + validateScriptedSessionResult(surface, result); + return result; + }) + : runScriptedBrowserSessionInPreparedRoot(sessionOptions, runPaths); + })); } catch (error) { + if (error instanceof UnsafeScriptedSessionResultError) { + throw error; + } // The session itself maps launch failures to harness_error; reaching here means the // harness around it failed. Redacted at this boundary before persisting anywhere. sessionError = redactText(scrubKnownValues(toErrorMessage(error))); @@ -407,8 +443,9 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption for (const result of sessionResults) { // The backend writes the provider-neutral projection next to the session's native // traces/.json (cua's actor.json convention, pluralized per surface). - await writeFile( - path.join(artifactRoot, `actor-${result.capture.surface.id}.json`), + await writeContainedOutputFile( + runPaths, + `actor-${result.capture.surface.id}.json`, `${JSON.stringify(result.trace, null, 2)}\n`, "utf8" ); @@ -419,7 +456,7 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption for (const result of sessionResults) { screenshotsBySurface.set( result.capture.surface.id, - await existingScreenshots(artifactRoot, result) + await existingScreenshots(runPaths, result) ); } const subject: RunSubjectProvenance | undefined = provisionedRoute @@ -454,17 +491,17 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption ...(hostDigest === undefined ? {} : { hostDigest }) }); - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderScriptedReviewMarkdown(bundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderScriptedReviewMarkdown(bundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); // Keep `verify --run latest` honest: point it at THIS run (mirrors run.ts's RunPointer). - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), + await writePreparedRunLatestPointer( + runPaths, `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, - path: path.join(".humanish", "runs", runId), + path: runPaths.relativeRunRoot, updatedAt: createdAt }, null, 2)}\n`, "utf8" @@ -475,7 +512,8 @@ export async function runScriptedBrowserLab(options: RunScriptedBrowserLabOption warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. policies.redactScreenshots is not yet supported on the scripted route."); } - const observer = await render(cwd, runId, { open: options.open === true }); + const observer = await render(physicalCwd, runId, { open: options.open === true }); + await validatePreparedRunArtifactPaths(runPaths); const harnessError = sessionResults.some((result) => result.completionReason === "harness_error"); const ok = observer.ok @@ -540,7 +578,7 @@ interface ResolvedScriptedScenario { * humanish/scenarios/.yaml (then .yml). Every failure mode is fail-closed. */ async function resolveScriptedScenario( - cwd: string, + projectRoot: PreparedSelectedOutputDirectory, ref: string | undefined ): Promise { if (!ref || !ref.trim()) { @@ -554,8 +592,8 @@ async function resolveScriptedScenario( let absolutePath: string; let source: string; if (scenarioRefLooksLikePath(trimmed)) { - absolutePath = path.resolve(cwd, trimmed); - const relative = path.relative(cwd, absolutePath); + absolutePath = path.resolve(projectRoot.physicalPath, trimmed); + const relative = path.relative(projectRoot.physicalPath, absolutePath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { return { ok: false, @@ -574,7 +612,7 @@ async function resolveScriptedScenario( path.posix.join("humanish", "scenarios", `${trimmed}.yaml`), path.posix.join("humanish", "scenarios", `${trimmed}.yml`) ]; - const found = await firstExistingFile(cwd, candidates); + const found = await firstExistingFile(projectRoot, candidates); if (!found) { return { ok: false, @@ -582,15 +620,15 @@ async function resolveScriptedScenario( }; } source = found; - absolutePath = path.join(cwd, found); + absolutePath = path.join(projectRoot.physicalPath, found); } - let text: string; - try { - text = await readFile(absolutePath, "utf8"); - } catch { + const relativeScenarioPath = path.relative(projectRoot.physicalPath, absolutePath); + const scenarioBytes = await readContainedRegularFile(projectRoot, relativeScenarioPath); + if (!scenarioBytes) { return { ok: false, message: `scenario.ref "${trimmed}" could not be read (${source}).` }; } + const text = scenarioBytes.toString("utf8"); let raw: unknown; try { @@ -622,31 +660,63 @@ function scenarioRefLooksLikePath(ref: string): boolean { || ref.startsWith("."); } -async function firstExistingFile(cwd: string, candidates: string[]): Promise { +async function firstExistingFile(projectRoot: PreparedSelectedOutputDirectory, candidates: string[]): Promise { for (const candidate of candidates) { - const stats = await stat(path.join(cwd, candidate)).catch(() => null); - if (stats?.isFile()) { + if (await readContainedRegularFile(projectRoot, candidate) !== null) { return candidate; } } return null; } -async function existingScreenshots(artifactRoot: string, result: ScriptedBrowserSessionResult): Promise { +async function existingScreenshots(runPaths: PreparedRunArtifactPaths, result: ScriptedBrowserSessionResult): Promise { const existing: string[] = []; for (const step of result.capture.steps) { // Blocked steps whose evidence is the failure itself recorded no screenshot path. if (!step.screenshotPath) { continue; } - const stats = await stat(path.join(artifactRoot, step.screenshotPath)).catch(() => null); - if (stats?.isFile() && stats.size > 0) { + const screenshot = await readContainedRegularFile(runPaths, step.screenshotPath); + if (screenshot && screenshot.byteLength > 0) { existing.push(step.screenshotPath); } } return existing; } +function validateScriptedSessionResult( + expectedSurface: BrowserSurface, + result: ScriptedBrowserSessionResult +): void { + if (result.capture.surface.id !== expectedSurface.id || !isSafeOutputSegment(result.capture.surface.id)) { + throw new UnsafeScriptedSessionResultError("Scripted session returned an unexpected or unsafe surface id."); + } + const paths = [ + result.capture.tracePath, + ...(result.capture.screenshotPath ? [result.capture.screenshotPath] : []), + ...result.capture.steps.flatMap((step) => step.screenshotPath ? [step.screenshotPath] : []) + ]; + if (!paths.every(isSafeRelativeArtifactPath)) { + throw new UnsafeScriptedSessionResultError("Scripted session returned an unsafe artifact path."); + } +} + +function isSafeOutputSegment(value: string): boolean { + return value.length > 0 + && value !== "." + && value !== ".." + && !value.includes("/") + && !value.includes("\\") + && !value.includes("\0"); +} + +function isSafeRelativeArtifactPath(value: string): boolean { + if (!value || value.includes("\0") || path.isAbsolute(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { + return false; + } + return !value.replace(/\\/g, "/").split("/").some((part) => !part || part === "." || part === ".."); +} + /** * Project the scripted lab run into a humanish.run-bundle.v1 (no schema change — a new * producer only). The load-bearing line is `stream.actor = result.trace`: the provider-neutral diff --git a/src/selected-output-paths.ts b/src/selected-output-paths.ts new file mode 100644 index 0000000..62f7ec9 --- /dev/null +++ b/src/selected-output-paths.ts @@ -0,0 +1,486 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +import { + isPathInside, + prepareHumanishStorageDirectory, + resolveExistingHumanishStorageDirectory, + type PreparedRunArtifactPaths, + validatePreparedRunArtifactPaths, + validatePreparedRunRootIdentity +} from "./run-paths.js"; + +interface FileIdentity { + readonly birthtimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; +} + +export interface PreparedSelectedOutputDirectory { + readonly identity: FileIdentity; + readonly parentRun?: PreparedRunArtifactPaths; + readonly physicalPath: string; + readonly requestedPath: string; +} + +export interface PreparedSelectedOutputFile { + readonly parentIdentity: FileIdentity; + readonly physicalParent: string; + readonly physicalPath: string; + readonly requestedPath: string; +} + +export type PreparedOutputDirectory = PreparedSelectedOutputDirectory | PreparedRunArtifactPaths; +export type PreparedOutputRoot = PreparedOutputDirectory; + +/** + * Prepare an arbitrary caller-selected output directory without changing the + * caller's resolution contract. Relative values resolve against baseDir; + * absolute values keep their authority. Every spelling is caller authority: + * existing aliases in the selected directory or its parents are canonicalized + * once, then bound by physical path and directory identity. Managed defaults + * must use a strict storage preparer instead. + */ +export async function prepareSelectedOutputDirectory( + baseDir: string, + selectedPath: string +): Promise { + assertPathText(selectedPath, "Output directory"); + const requestedPath = path.resolve(baseDir, selectedPath); + const physicalPath = await prepareAbsoluteSelectedDirectory(requestedPath); + return captureSelectedOutputDirectory(requestedPath, physicalPath); +} + +/** Prepare a strict `.humanish`-managed directory, then bind its identity. */ +export async function prepareManagedHumanishOutputDirectory( + cwd: string, + ...segments: string[] +): Promise { + const requestedPath = path.resolve(cwd, ".humanish", ...segments); + const preparedPath = await prepareHumanishStorageDirectory(cwd, ...segments); + const physicalPath = await realpath(preparedPath); + return captureSelectedOutputDirectory(requestedPath, physicalPath); +} + +/** Bind an existing strict `.humanish` directory without creating storage. */ +export async function bindExistingManagedHumanishOutputDirectory( + cwd: string, + ...segments: string[] +): Promise { + const requestedPath = path.resolve(cwd, ".humanish", ...segments); + const existing = await resolveExistingHumanishStorageDirectory(cwd, ...segments); + if (!existing || existing !== requestedPath) { + return null; + } + return captureSelectedOutputDirectory(requestedPath, await realpath(existing)); +} + +/** Prepare an arbitrary caller-selected output file whose parent is independent. */ +export async function prepareSelectedOutputFile( + baseDir: string, + selectedPath: string +): Promise { + assertPathText(selectedPath, "Output file"); + const requestedPath = path.resolve(baseDir, selectedPath); + const fileName = path.basename(requestedPath); + if (!fileName || requestedPath === path.parse(requestedPath).root) { + throw new Error("Output file must name a regular file."); + } + + const requestedParent = path.dirname(requestedPath); + const physicalParent = await prepareAbsoluteSelectedDirectory(requestedParent); + const physicalPath = path.join(physicalParent, fileName); + await assertRegularFileOrMissing(physicalPath); + const parentIdentity = await captureDirectoryIdentity(physicalParent); + const prepared = Object.freeze({ + parentIdentity, + physicalParent, + physicalPath, + requestedPath + }); + await assertPreparedSelectedOutputFile(prepared); + return prepared; +} + +export async function assertPreparedSelectedOutputDirectory( + prepared: PreparedSelectedOutputDirectory +): Promise { + if (prepared.parentRun) { + await validatePreparedRunRootIdentity(prepared.parentRun); + } + const requestedPhysicalPath = await realpath(prepared.requestedPath); + if (requestedPhysicalPath !== prepared.physicalPath) { + throw new Error("Selected output root changed physical destination."); + } + await assertDirectoryIdentity(prepared.physicalPath, prepared.identity, "Selected output root"); +} + +export async function assertPreparedSelectedOutputFile( + prepared: PreparedSelectedOutputFile +): Promise { + const requestedPhysicalParent = await realpath(path.dirname(prepared.requestedPath)); + if (requestedPhysicalParent !== prepared.physicalParent) { + throw new Error("Selected output parent changed physical destination."); + } + await assertDirectoryIdentity(prepared.physicalParent, prepared.parentIdentity, "Selected output parent"); + await assertRegularFileOrMissing(prepared.physicalPath); +} + +export async function writePreparedSelectedOutputFile( + prepared: PreparedSelectedOutputFile, + data: string | Uint8Array, + encoding?: BufferEncoding +): Promise { + await atomicWriteOutputFile( + prepared.physicalParent, + prepared.physicalPath, + data, + encoding, + () => assertPreparedSelectedOutputFile(prepared) + ); +} + +/** Atomically write the sibling latest pointer bound by a prepared run token. */ +export async function writePreparedRunLatestPointer( + prepared: PreparedRunArtifactPaths, + data: string | Uint8Array, + encoding?: BufferEncoding +): Promise { + await atomicWriteOutputFile( + prepared.physicalRunsRoot, + prepared.physicalLatestPointer, + data, + encoding, + async () => { + await validatePreparedRunArtifactPaths(prepared); + } + ); +} + +export async function prepareContainedOutputDirectory( + rootInput: PreparedOutputRoot, + relativePath: string +): Promise { + assertSafeRelativeOutputPath(relativePath, true); + const root = await resolveOutputRoot(rootInput); + return prepareDirectoryWithinRoot(root, normalizeRelativeOutputPath(relativePath)); +} + +/** Prepare and identity-bind a generated child directory under a prepared root. */ +export async function prepareContainedOutputDirectoryRoot( + rootInput: PreparedOutputDirectory, + relativePath: string +): Promise { + const root = await resolveOutputRoot(rootInput); + const physicalPath = await prepareContainedOutputDirectory(rootInput, relativePath); + const revalidatedRoot = await resolveOutputRoot(rootInput); + if (revalidatedRoot !== root) { + throw new Error("Output root changed after it was prepared."); + } + const parentRun = "physicalRunRoot" in rootInput ? rootInput : rootInput.parentRun; + return captureSelectedOutputDirectory(physicalPath, physicalPath, parentRun); +} + +export async function prepareContainedOutputFile( + rootInput: PreparedOutputRoot, + relativePath: string +): Promise { + assertSafeRelativeOutputPath(relativePath, false); + const root = await resolveOutputRoot(rootInput); + const absolute = path.resolve(root, normalizeRelativeOutputPath(relativePath)); + if (!isPathInside(root, absolute) || absolute === root) { + throw new Error("Output file must stay inside its selected root."); + } + const parent = await prepareDirectoryWithinRoot(root, path.relative(root, path.dirname(absolute))); + const filePath = path.join(parent, path.basename(absolute)); + await assertRegularFileOrMissing(filePath); + return filePath; +} + +export async function writeContainedOutputFile( + rootInput: PreparedOutputRoot, + relativePath: string, + data: string | Uint8Array, + encoding?: BufferEncoding +): Promise { + const filePath = await prepareContainedOutputFile(rootInput, relativePath); + const root = await resolveOutputRoot(rootInput); + await atomicWriteOutputFile( + path.dirname(filePath), + filePath, + data, + encoding, + async () => { + const validatedRoot = await resolveOutputRoot(rootInput); + if (validatedRoot !== root) { + throw new Error("Output root changed after it was prepared."); + } + await assertContainedDirectoryChain(root, path.dirname(filePath)); + await assertRegularFileOrMissing(filePath); + } + ); +} + +/** Read one regular file only when both lexical and physical paths stay in root. */ +export async function readContainedRegularFile( + rootInput: PreparedOutputRoot, + relativePath: string +): Promise { + try { + assertSafeRelativeOutputPath(relativePath, false); + const root = await resolveOutputRoot(rootInput); + const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath)); + if (!isPathInside(root, candidate) || candidate === root) { + return null; + } + await assertContainedDirectoryChain(root, path.dirname(candidate)); + const before = await lstat(candidate, { bigint: true }); + if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1n) { + return null; + } + const physicalFile = await realpath(candidate); + if (!isPathInside(root, physicalFile)) { + return null; + } + const handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const after = await handle.stat({ bigint: true }); + if ( + !after.isFile() + || after.nlink > 1n + || after.dev !== before.dev + || after.ino !== before.ino + ) { + return null; + } + const revalidatedRoot = await resolveOutputRoot(rootInput); + if (revalidatedRoot !== root) { + return null; + } + await assertContainedDirectoryChain(root, path.dirname(candidate)); + return await handle.readFile(); + } finally { + await handle.close(); + } + } catch { + return null; + } +} + +export function assertSafeOutputPathSegment(value: string, label = "Output path segment"): void { + if ( + value.length === 0 + || value === "." + || value === ".." + || value.includes("/") + || value.includes("\\") + || value.includes("\0") + ) { + throw new Error(`${label} must be one non-empty path segment.`); + } +} + +function assertSafeRelativeOutputPath(value: string, allowEmpty: boolean): void { + if ((!allowEmpty && value.length === 0) || value.includes("\0")) { + throw new Error("Output path must be a non-empty relative path."); + } + if (value === "" && allowEmpty) { + return; + } + if (path.isAbsolute(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { + throw new Error("Output path must be relative to its selected root."); + } + const parts = value.replace(/\\/g, "/").split("/"); + if (parts.some((part) => part.length === 0 || part === "." || part === "..")) { + throw new Error("Output path must not contain empty or traversal segments."); + } +} + +function normalizeRelativeOutputPath(value: string): string { + return value.replace(/[\\/]+/g, path.sep); +} + +async function prepareDirectoryWithinRoot(root: string, relativePath: string): Promise { + const segments = relativePath === "" + ? [] + : relativePath.replace(/[\\/]+/g, path.sep).split(path.sep); + let current = root; + for (const segment of segments) { + assertSafeOutputPathSegment(segment); + current = path.join(current, segment); + await mkdirDirectoryLeaf(current); + } + const physical = await realpath(current); + if (!isPathInside(root, physical)) { + throw new Error("Output directory resolved outside its selected root."); + } + return physical; +} + +async function captureSelectedOutputDirectory( + requestedPath: string, + physicalPath: string, + parentRun?: PreparedRunArtifactPaths +): Promise { + const prepared = Object.freeze({ + identity: await captureDirectoryIdentity(physicalPath), + ...(parentRun === undefined ? {} : { parentRun }), + physicalPath, + requestedPath + }); + await assertPreparedSelectedOutputDirectory(prepared); + return prepared; +} + +async function prepareAbsoluteSelectedDirectory(absolutePath: string): Promise { + const resolved = path.resolve(absolutePath); + try { + const existing = await lstat(resolved); + if (!existing.isDirectory() && !existing.isSymbolicLink()) { + throw new Error("Selected output root must resolve to a directory."); + } + const physical = await realpath(resolved); + const physicalStats = await lstat(physical); + if (!physicalStats.isDirectory()) { + throw new Error("Selected output root must resolve to a directory."); + } + return physical; + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + throw error; + } + } + await mkdir(path.dirname(resolved), { recursive: true }); + const physicalParent = await resolveBaseDirectory(path.dirname(resolved), "Selected output parent"); + const selectedLeaf = path.join(physicalParent, path.basename(resolved)); + await mkdirDirectoryLeaf(selectedLeaf); + return realpath(selectedLeaf); +} + +async function mkdirDirectoryLeaf(directory: string): Promise { + try { + await mkdir(directory); + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") { + throw error; + } + } + const stats = await lstat(directory, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("Selected output directories must not be symbolic links or non-directories."); + } +} + +async function resolveOutputRoot(root: PreparedOutputRoot): Promise { + if ("physicalRunRoot" in root) { + await validatePreparedRunRootIdentity(root); + return root.physicalRunRoot; + } + await assertPreparedSelectedOutputDirectory(root); + return root.physicalPath; +} + +async function resolveBaseDirectory(directory: string, label: string): Promise { + const physical = await realpath(path.resolve(directory)); + const stats = await lstat(physical); + if (!stats.isDirectory()) { + throw new Error(`${label} must be a directory.`); + } + return physical; +} + +async function assertRegularFileOrMissing(filePath: string): Promise { + try { + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink > 1) { + throw new Error("Selected output files must be single-link regular files, not symbolic links or hardlinks."); + } + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return; + } + throw error; + } +} + +async function captureDirectoryIdentity(directory: string): Promise { + const stats = await lstat(directory, { bigint: true }); + if (stats.isSymbolicLink() || !stats.isDirectory() || await realpath(directory) !== directory) { + throw new Error("Prepared output root must use a physical directory."); + } + return Object.freeze({ birthtimeNs: stats.birthtimeNs, dev: stats.dev, ino: stats.ino }); +} + +async function assertDirectoryIdentity( + directory: string, + identity: FileIdentity, + label: string +): Promise { + const stats = await lstat(directory, { bigint: true }); + if ( + stats.isSymbolicLink() + || !stats.isDirectory() + || stats.birthtimeNs !== identity.birthtimeNs + || stats.dev !== identity.dev + || stats.ino !== identity.ino + || await realpath(directory) !== directory + ) { + throw new Error(`${label} identity changed after it was prepared.`); + } +} + +async function assertContainedDirectoryChain(root: string, directory: string): Promise { + if (!isPathInside(root, directory)) { + throw new Error("Output directory must stay inside its selected root."); + } + const relative = path.relative(root, directory); + let current = root; + for (const segment of relative === "" ? [] : relative.split(path.sep)) { + current = path.join(current, segment); + const stats = await lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("Selected output directories must not be symbolic links or non-directories."); + } + } +} + +async function atomicWriteOutputFile( + parent: string, + target: string, + data: string | Uint8Array, + encoding: BufferEncoding | undefined, + revalidate: () => Promise +): Promise { + await revalidate(); + const temporary = path.join(parent, `.humanish-write-${process.pid}-${randomUUID()}.tmp`); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + if (typeof data === "string") { + await handle.writeFile(data, encoding ?? "utf8"); + } else { + await handle.writeFile(data); + } + await handle.sync(); + await handle.close(); + handle = undefined; + await revalidate(); + await rename(temporary, target); + await assertRegularFileOrMissing(target); + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + } +} + +function assertPathText(value: string, label: string): void { + if (value.includes("\0")) { + throw new Error(`${label} must not contain a null byte.`); + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/shared-world-lab.ts b/src/shared-world-lab.ts index 359046f..e939fbf 100644 --- a/src/shared-world-lab.ts +++ b/src/shared-world-lab.ts @@ -21,7 +21,6 @@ // (composed into its persona context) — physical per-role geometry is the concurrent topology's // job. Each role's stream viewport records the sandbox's actual rendered resolution (honest). -import { mkdir, writeFile } from "node:fs/promises"; import { randomBytes } from "node:crypto"; import path from "node:path"; import { runDesktopCommandOrThrow, toErrorMessage } from "./command-failure.js"; @@ -72,6 +71,8 @@ import { } from "./lab-config.js"; import { renderObserver, type ObserverResult } from "./observer.js"; import { redactText } from "./redaction.js"; +import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js"; +import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js"; import type { LocalTreeArchive } from "./source-archive.js"; import type { StopWhen } from "./stop-conditions.js"; import { @@ -533,7 +534,8 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom } const runId = options.runId ?? makeSharedWorldRunId(); - const artifactRoot = path.join(cwd, ".humanish", "runs", runId); + const runPaths = await prepareRunArtifactPaths(cwd, runId); + const physicalArtifactRoot = runPaths.physicalRunRoot; const createdAt = new Date().toISOString(); const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000); @@ -549,7 +551,6 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom + (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0) + SANDBOX_TIMEOUT_BUFFER_MS; - await mkdir(artifactRoot, { recursive: true }); const source = await buildRunSource({ capturedAt: createdAt, cwd, humanishSource: "present", packageName: "humanish" }); const warnings: string[] = []; @@ -688,7 +689,7 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom } const screenshots: string[] = []; - const writeScreenshot = makeLaneWriteScreenshot(artifactRoot, { screenshotDir: spec.screenshotDir }, screenshots); + const writeScreenshot = makeLaneWriteScreenshot(runPaths, { screenshotDir: spec.screenshotDir }, screenshots); let session: CuaLoopResult | undefined; let sessionError: string | undefined; let desktopBrowser: DesktopBrowserEvidence | undefined; @@ -724,8 +725,7 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom } if (session) { - await mkdir(path.dirname(path.join(artifactRoot, spec.traceArtifactPath)), { recursive: true }); - await writeFile(path.join(artifactRoot, spec.traceArtifactPath), `${JSON.stringify(session.trace, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, spec.traceArtifactPath, `${JSON.stringify(session.trace, null, 2)}\n`, "utf8"); if (session.trace.redaction.screenshots === "raw") { warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle."); } @@ -853,7 +853,7 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom bundle, context: { bundle, - runDir: artifactRoot, + runDir: physicalArtifactRoot, labId: config.id, runId, actor: descriptor.id, @@ -866,13 +866,14 @@ export async function runSharedWorldLab(options: RunSharedWorldLabOptions): Prom hookLabel: "sharedWorldHooks" }); - await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); - await writeFile(path.join(artifactRoot, "review.md"), renderSharedWorldReviewMarkdown(bundle), "utf8"); - await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); - await writeFile( - path.join(cwd, ".humanish", "runs", "latest.json"), - `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, path: path.join(".humanish", "runs", runId), updatedAt: createdAt }, null, 2)}\n`, + await validatePreparedRunArtifactPaths(runPaths); + await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8"); + await writeContainedOutputFile(runPaths, "review.md", renderSharedWorldReviewMarkdown(bundle), "utf8"); + await writeContainedOutputFile(runPaths, "events.ndjson", `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8"); + await writePreparedRunLatestPointer( + runPaths, + `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, path: runPaths.relativeRunRoot, updatedAt: createdAt }, null, 2)}\n`, "utf8" ); diff --git a/src/source-archive.ts b/src/source-archive.ts index e7aab60..f86372d 100644 --- a/src/source-archive.ts +++ b/src/source-archive.ts @@ -104,6 +104,8 @@ export const LOCAL_TREE_DENYLIST_BASENAME_PATTERNS = [ /** Default upload size cap: 256 MiB. */ export const DEFAULT_LOCAL_TREE_MAX_ARCHIVE_BYTES = 256 * 1024 * 1024; +type SourceEntryStat = NonNullable>; + /** * Enumerate the packable entries of a local working tree. * @@ -288,6 +290,7 @@ function enumerateGitTree(root: string, extraExclude: readonly string[]): LocalT continue; } if (stat.isFile()) { + assertSingleLinkSourceFile(stat); entries.push({ relPath, kind: "file", size: stat.size }); } } @@ -319,6 +322,7 @@ function walkFallbackTree( continue; } if (stat.isFile()) { + assertSingleLinkSourceFile(stat); out.push({ relPath, kind: "file", size: stat.size }); } // Sockets, fifos, and device files are silently skipped: neither a @@ -400,6 +404,45 @@ function compareEntriesByRelPath(a: LocalTreeEntry, b: LocalTreeEntry): number { return Buffer.compare(Buffer.from(a.relPath, "utf8"), Buffer.from(b.relPath, "utf8")); } +function assertSingleLinkSourceFile(stat: SourceEntryStat): void { + if (stat.nlink > 1) { + throw new Error( + "Local tree contains a hardlinked regular file; hardlinked source files are not packable.", + ); + } +} + +function validateEntryForRead(root: string, entry: LocalTreeEntry): SourceEntryStat { + let stat: SourceEntryStat; + try { + stat = lstatSync(path.join(root, entry.relPath)); + } catch { + throw new Error("Local tree entry changed after enumeration; refusing to create an inconsistent archive."); + } + if (entry.kind === "symlink") { + if (!stat.isSymbolicLink()) { + throw new Error("Local tree entry changed kind after enumeration; refusing to create an inconsistent archive."); + } + return stat; + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== entry.size) { + throw new Error("Local tree entry changed after enumeration; refusing to create an inconsistent archive."); + } + assertSingleLinkSourceFile(stat); + return stat; +} + +function validateEntryIdentity( + root: string, + entry: LocalTreeEntry, + expected: SourceEntryStat, +): void { + const current = validateEntryForRead(root, entry); + if (current.dev !== expected.dev || current.ino !== expected.ino) { + throw new Error("Local tree entry changed physical identity while being read; refusing to create an inconsistent archive."); + } +} + /** * sha256 over the sorted sequence of records: * files: kind\0relPath\0size\0\0 @@ -415,13 +458,17 @@ function computeArchiveSha256( let totalBytes = 0; for (const entry of entries) { const absolutePath = path.join(root, entry.relPath); + const expected = validateEntryForRead(root, entry); if (entry.kind === "symlink") { const target = readlinkSync(absolutePath); + validateEntryIdentity(root, entry, expected); hash.update(`symlink\0${entry.relPath}\0${target}\0`); continue; } + const bytes = readFileSync(absolutePath); + validateEntryIdentity(root, entry, expected); hash.update(`file\0${entry.relPath}\0${entry.size}\0`); - hash.update(readFileSync(absolutePath)); + hash.update(bytes); hash.update("\0"); totalBytes += entry.size; } @@ -429,6 +476,12 @@ function computeArchiveSha256( } function writeTarArchive(root: string, entries: readonly LocalTreeEntry[], archivePath: string): void { + // Recheck immediately before tar reads the source tree. Enumeration and + // hashing already reject hardlinks; this closes the ordinary mutation gap + // between hashing and packing without dereferencing symlinks. + for (const entry of entries) { + validateEntryForRead(root, entry); + } const listDir = mkdtempSync(path.join(tmpdir(), "humanish-local-tree-list-")); const listFile = path.join(listDir, "files.list"); try { diff --git a/tests/actor-conformance.test.ts b/tests/actor-conformance.test.ts index 052bea5..b3928bd 100644 --- a/tests/actor-conformance.test.ts +++ b/tests/actor-conformance.test.ts @@ -215,8 +215,8 @@ function makeConformanceFakeBrowser(): ScriptedBrowserLike { waitForTimeout: async () => undefined, waitForFunction: async () => undefined, screenshot: async ({ path: screenshotPath }) => { - await writeFile(screenshotPath, PNG_1X1); - return undefined; + if (screenshotPath) await writeFile(screenshotPath, PNG_1X1); + return PNG_1X1; }, url: () => state.url, evaluate: async () => state.body as unknown as T diff --git a/tests/claude-agent-session.test.ts b/tests/claude-agent-session.test.ts index d62f1bb..a987e40 100644 --- a/tests/claude-agent-session.test.ts +++ b/tests/claude-agent-session.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -90,6 +90,57 @@ describe("runClaudeAgentSession (DI seam)", () => { }); }); + it("rejects generated-path aliases before query execution", async () => { + await withRunRoot(async (runRoot) => { + const outside = path.join(runRoot, "outside"); + const selected = path.join(runRoot, "selected"); + await mkdir(outside); + await mkdir(selected); + await writeFile(path.join(outside, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(outside, path.join(selected, "claude-agent-sdk"), "dir"); + let queryCalls = 0; + await expect(runClaudeAgentSession({ + cwd: runRoot, + runRoot: selected, + prompt: "go", + persona, + timeoutMs: 5000, + queryFn: fakeQuery(buildClaudeSession().messages, () => { queryCalls += 1; }) + })).rejects.toThrow(/symbolic links/i); + expect(queryCalls).toBe(0); + expect(await readFile(path.join(outside, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + }); + }); + + it("retains the selected-root identity across the query window", async () => { + await withRunRoot(async (runRoot) => { + const first = path.join(runRoot, "first"); + const second = path.join(runRoot, "second"); + const alias = path.join(runRoot, "selected-alias"); + await mkdir(first); + await mkdir(second); + await writeFile(path.join(second, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(first, alias, "dir"); + const queryFn: ClaudeQueryFn = () => (async function* () { + yield { type: "system", subtype: "init", session_id: "s", model: "m" }; + await rm(alias); + await symlink(second, alias, "dir"); + yield { type: "result", subtype: "success", duration_ms: 1, session_id: "s", result: "done" }; + })(); + + await expect(runClaudeAgentSession({ + cwd: runRoot, + runRoot: alias, + prompt: "go", + persona, + timeoutMs: 5000, + queryFn + })).rejects.toThrow(/changed physical destination/i); + expect(await readFile(path.join(second, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + await expect(access(path.join(second, "claude-agent-sdk", "summary.json"))).rejects.toThrow(); + }); + }); + it("binds the persona into a minimal system prompt and disables tools/settings", async () => { await withRunRoot(async (runRoot) => { let captured: Record | undefined; diff --git a/tests/codex-app-server-containment.test.ts b/tests/codex-app-server-containment.test.ts new file mode 100644 index 0000000..8e4777c --- /dev/null +++ b/tests/codex-app-server-containment.test.ts @@ -0,0 +1,246 @@ +import { access, link, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { startCodexAppServerUi } from "../src/codex-app-server-ui.js"; +import { runCodexAppServerSession } from "../src/codex-app-server.js"; + +describe("Codex app-server output containment", () => { + let root: string; + let project: string; + let fakeServer: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), "humanish-codex-containment-")); + project = path.join(root, "project"); + fakeServer = path.join(root, "fake-app-server.mjs"); + await mkdir(project); + await writeFile(fakeServer, [ + "import fs from 'node:fs';", + "import readline from 'node:readline';", + "const marker = process.argv[2];", + "const delay = Number(process.argv[3] || '0');", + "if (marker) fs.writeFileSync(marker, 'started\\n');", + "const rl = readline.createInterface({ input: process.stdin });", + "const send = (value) => process.stdout.write(JSON.stringify(value) + '\\n');", + "const thread = { id: 'thread-safe', sessionId: 'session-safe', model: 'test-model', cliVersion: 'test-cli' };", + "const turn = { id: 'turn-safe', status: 'inProgress' };", + "rl.on('line', (line) => {", + " const msg = JSON.parse(line);", + " if (msg.method === 'initialize') send({ id: msg.id, result: { userAgent: 'containment-fake' } });", + " if (msg.method === 'account/login/start') send({ id: msg.id, result: { type: 'apiKey' } });", + " if (msg.method === 'thread/start') { send({ id: msg.id, result: { thread } }); send({ method: 'thread/started', params: { thread } }); }", + " if (msg.method === 'turn/start') {", + " send({ id: msg.id, result: { turn } });", + " send({ method: 'turn/started', params: { threadId: thread.id, turn } });", + " setTimeout(() => {", + " send({ method: 'turn/completed', params: { threadId: thread.id, turn: { ...turn, status: 'completed' } } });", + " setTimeout(() => process.exit(0), 20);", + " }, delay);", + " }", + "});" + ].join("\n"), "utf8"); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("rejects an aliased implicit managed root before spawning the actor", async () => { + const outside = path.join(root, "outside"); + const marker = path.join(root, "actor-started"); + await mkdir(outside); + await symlink(outside, path.join(project, ".humanish"), "dir"); + + await expect(startCodexAppServerUi({ + actorCommand: actorCommand(marker), + cwd: project, + prompt: "test", + timeoutMs: 2_000 + })).rejects.toThrow(/symbolic links/i); + await expect(access(marker)).rejects.toThrow(); + }); + + it("authorizes the same explicit root and keeps omitted state inside that bound root", async () => { + const outside = path.join(root, "outside"); + const marker = path.join(root, "actor-started"); + await mkdir(outside); + await symlink(outside, path.join(project, ".humanish"), "dir"); + + const controller = await startCodexAppServerUi({ + actorCommand: actorCommand(marker), + cwd: project, + prompt: "test", + runRoot: ".humanish/codex-app-server-ui", + timeoutMs: 2_000 + }); + const completed = await controller.completion; + expect(completed.status).toBe("passed"); + expect(controller.stateFile).toBe(path.join(project, ".humanish", "codex-app-server-ui", "state.json")); + expect(await readFile(path.join(outside, "codex-app-server-ui", "state.json"), "utf8")).toContain('"status": "passed"'); + }); + + it("keeps an explicit independent state path separate and rejects its exact symlink leaf", async () => { + const runRoot = path.join(root, "selected-run"); + const stateParent = path.join(root, "selected-state-parent"); + const stateAlias = path.join(project, "state-parent-alias"); + const marker = path.join(root, "actor-started"); + await mkdir(runRoot); + await mkdir(stateParent); + await symlink(stateParent, stateAlias, "dir"); + + const controller = await startCodexAppServerUi({ + actorCommand: actorCommand(marker), + cwd: project, + prompt: "test", + runRoot, + stateFile: "state-parent-alias/controller.json", + timeoutMs: 2_000 + }); + await controller.completion; + expect(await readFile(path.join(stateParent, "controller.json"), "utf8")).toContain('"schema"'); + await expect(access(path.join(runRoot, "state.json"))).rejects.toThrow(); + + const sentinel = path.join(root, "state-sentinel.json"); + const linkedState = path.join(project, "linked-state.json"); + await writeFile(sentinel, "unchanged\n", "utf8"); + await symlink(sentinel, linkedState); + await expect(startCodexAppServerUi({ + actorCommand: actorCommand(path.join(root, "actor-should-not-start")), + cwd: project, + prompt: "test", + runRoot: path.join(root, "other-run"), + stateFile: linkedState, + timeoutMs: 2_000 + })).rejects.toThrow(/regular files/i); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + + it("rejects a selected-root retarget during the child window without mutating the new target", async () => { + const first = path.join(root, "first"); + const second = path.join(root, "second"); + const alias = path.join(project, "run-alias"); + const marker = path.join(root, "actor-started"); + await mkdir(first); + await mkdir(second); + await writeFile(path.join(second, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(first, alias, "dir"); + + const controller = await startCodexAppServerUi({ + actorCommand: actorCommand(marker, 250), + cwd: project, + keepOpen: true, + prompt: "test", + runRoot: "run-alias", + timeoutMs: 10_000 + }); + await waitForFile(marker); + await rm(alias); + await symlink(second, alias, "dir"); + try { + await expect(controller.completion).rejects.toThrow(/changed physical destination/i); + expect(await readFile(path.join(second, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + await expect(access(path.join(second, "codex-app-server", "summary.json"))).rejects.toThrow(); + await expect(fetch(controller.url)).rejects.toThrow(); + } finally { + await controller.close(); + } + }); + + it("serves only contained single-link artifacts and handles malformed encodings as 404", async () => { + const runRoot = path.join(root, "served-run"); + const marker = path.join(root, "actor-started"); + const outside = path.join(root, "outside"); + await mkdir(outside); + await writeFile(path.join(outside, "secret.txt"), "DO-NOT-SERVE\n", "utf8"); + const controller = await startCodexAppServerUi({ + actorCommand: actorCommand(marker), + cwd: project, + keepOpen: true, + prompt: "test", + runRoot, + timeoutMs: 2_000 + }); + await controller.completion; + await symlink(path.join(outside, "secret.txt"), path.join(runRoot, "leaf-link.txt")); + await symlink(outside, path.join(runRoot, "dir-link"), "dir"); + let hardlinkSupported = true; + try { + await link(path.join(outside, "secret.txt"), path.join(runRoot, "hard-link.txt")); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) hardlinkSupported = false; + else throw error; + } + try { + expect((await fetch(new URL("artifact/codex-app-server/summary.json", controller.url))).status).toBe(200); + for (const suffix of [ + "artifact/leaf-link.txt", + ...(hardlinkSupported ? ["artifact/hard-link.txt"] : []), + "artifact/dir-link%2Fsecret.txt", + "artifact/..%2Foutside%2Fsecret.txt", + "artifact/%ZZ" + ]) { + const response = await fetch(new URL(suffix, controller.url)); + expect(response.status, suffix).toBe(404); + expect(await response.text()).not.toContain("DO-NOT-SERVE"); + } + } finally { + await controller.close(); + } + }); + + it("preflights direct-session generated paths before actor spawn", async () => { + const selected = path.join(root, "direct-selected"); + const outside = path.join(root, "direct-outside"); + const marker = path.join(root, "direct-actor-started"); + await mkdir(selected); + await mkdir(outside); + await writeFile(path.join(outside, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(outside, path.join(selected, "codex-app-server"), "dir"); + + await expect(runCodexAppServerSession({ + actorCommand: [process.execPath, fakeServer, marker, "0"], + cwd: project, + prompt: "test", + runRoot: selected, + timeoutMs: 2_000 + })).rejects.toThrow(/symbolic links/i); + await expect(access(marker)).rejects.toThrow(); + expect(await readFile(path.join(outside, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + + await rm(path.join(selected, "codex-app-server")); + await mkdir(path.join(selected, "codex-app-server")); + try { + await link(path.join(outside, "sentinel.txt"), path.join(selected, "codex-app-server", "summary.json")); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + const hardlinkMarker = path.join(root, "direct-hardlink-actor-started"); + await expect(runCodexAppServerSession({ + actorCommand: [process.execPath, fakeServer, hardlinkMarker, "0"], + cwd: project, + prompt: "test", + runRoot: selected, + timeoutMs: 2_000 + })).rejects.toThrow(/hardlink|single-link/i); + await expect(access(hardlinkMarker)).rejects.toThrow(); + expect(await readFile(path.join(outside, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + }); + + function actorCommand(marker: string, delay = 0): string { + return [process.execPath, fakeServer, marker, String(delay)].map((part) => JSON.stringify(part)).join(" "); + } +}); + +async function waitForFile(filePath: string): Promise { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if (await access(filePath).then(() => true).catch(() => false)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${path.basename(filePath)}.`); +} diff --git a/tests/concurrent-shared-world-lab.test.ts b/tests/concurrent-shared-world-lab.test.ts index 89647fb..ba8aa65 100644 --- a/tests/concurrent-shared-world-lab.test.ts +++ b/tests/concurrent-shared-world-lab.test.ts @@ -858,6 +858,29 @@ describe("runConcurrentSharedWorld (local-tree route: subject.source: local-tree expect(result.error?.message).toContain("subject.serve"); }); + it("engine re-enforcement rejects path-shaped role ids before loading a desktop", async () => { + const valid = concurrentConfig(3, 3); + const actor = valid.actors[0]!; + const lanes = actor.lanes!.map((lane, index) => index === 0 ? { ...lane, id: "..\\escape" } : lane); + const broken: LabConfig = { ...valid, actors: [{ ...actor, lanes }] }; + let desktopLoads = 0; + const result = await runConcurrentSharedWorld({ + cwd, + config: broken, + dryRun: false, + hooks: { + loadDesktopModule: async () => { + desktopLoads += 1; + throw new Error("must not load"); + } + } + }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID"); + expect(result.runId).toBe("not-created"); + expect(desktopLoads).toBe(0); + }); + it("engine re-enforcement: a local-tree config declaring subject.localTree.keep on the concurrent route fails closed (would orphan the N actor sandboxes)", async () => { const valid = localTreeConcurrentConfig(); const broken: LabConfig = { ...valid, subject: { ...valid.subject, localTree: { keep: true } } }; diff --git a/tests/core-primitives.test.ts b/tests/core-primitives.test.ts index 27c6e47..36c5059 100644 --- a/tests/core-primitives.test.ts +++ b/tests/core-primitives.test.ts @@ -1,5 +1,18 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + access, + chmod, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + unlink, + utimes, + writeFile +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -206,8 +219,293 @@ describe("core git state", () => { await rm(tempRoot, { force: true, recursive: true }); } }); + + it("rejects a forged gitdir file without reading or refreshing the outside repository", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-forged-")); + const outside = path.join(tempRoot, "outside"); + const target = path.join(tempRoot, "target"); + + try { + await mkdir(outside); + await mkdir(target); + await initializeCommittedRepo(outside, "outside-only\n"); + await writeFile(path.join(target, ".git"), `gitdir: ${path.join(outside, ".git")}\n`, "utf8"); + const outsideIndexPath = path.join(outside, ".git", "index"); + const outsideIndexBefore = await readFile(outsideIndexPath); + let runnerCalls = 0; + + const injected = await captureGitState(target, { + capturedAt: "2026-06-02T10:00:00.000Z", + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: "", stdout: "true\n" }; + } + }); + const actual = await captureGitState(target, { + capturedAt: "2026-06-02T10:00:00.000Z" + }); + + expect(runnerCalls).toBe(0); + expect(injected.status).toBe("unavailable"); + expect(actual.status).toBe("unavailable"); + expect(actual.note).toBe("Git metadata failed containment validation."); + expect(await readFile(outsideIndexPath)).toEqual(outsideIndexBefore); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("captures an exact linked worktree through the verified admin/common/backpointer chain", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-worktree-")); + const canonical = path.join(tempRoot, "canonical"); + const linked = path.join(tempRoot, "linked"); + + try { + await mkdir(canonical); + await initializeCommittedRepo(canonical, "canonical\n"); + const expectedSha = await runGitOutput(["rev-parse", "--short=12", "HEAD"], canonical); + await runGit(["worktree", "add", "--detach", linked, "HEAD"], canonical); + + const state = await captureGitState(linked, { + capturedAt: "2026-06-02T10:00:00.000Z" + }); + + expect(state.status).toBe("clean"); + expect(state.head.shortSha).toBe(expectedSha); + expect(state.head.refState).toBe("detached"); + expect(state.changes.total).toBe(0); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("rejects an unsafe linked-worktree admin config.worktree leaf", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-worktree-config-")); + const canonical = path.join(tempRoot, "canonical"); + const linked = path.join(tempRoot, "linked"); + const outsideConfig = path.join(tempRoot, "outside-config"); + + try { + await mkdir(canonical); + await initializeCommittedRepo(canonical, "canonical\n"); + await runGit(["worktree", "add", "--detach", linked, "HEAD"], canonical); + const gitFile = await readFile(path.join(linked, ".git"), "utf8"); + const declaredGitDir = gitFile.match(/^gitdir:\s*(.+)\s*$/)?.[1]; + expect(declaredGitDir).toBeTruthy(); + const adminGitDir = await realpath(path.resolve(linked, declaredGitDir!)); + await writeFile(outsideConfig, "[core]\n\tfsmonitor = false\n", "utf8"); + await symlink(outsideConfig, path.join(adminGitDir, "config.worktree")); + let runnerCalls = 0; + + const state = await captureGitState(linked, { + capturedAt: "2026-06-02T10:00:00.000Z", + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: "", stdout: "true\n" }; + } + }); + + expect(runnerCalls).toBe(0); + expect(state.status).toBe("unavailable"); + expect(state.note).toBe("Git metadata failed containment validation."); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("keeps an ordinary unborn git init repository available", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-unborn-")); + + try { + await runGit(["init"], tempRoot); + const state = await captureGitState(tempRoot, { + capturedAt: "2026-06-02T10:00:00.000Z" + }); + + expect(state.status).toBe("clean"); + expect(state.head.shortSha).toBeNull(); + expect(state.changes.total).toBe(0); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("binds the default Git runner against inherited and config-derived outside authority", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-authority-")); + const outside = path.join(tempRoot, "outside"); + const target = path.join(tempRoot, "target"); + const marker = path.join(tempRoot, "fsmonitor-ran"); + const hook = path.join(tempRoot, "fsmonitor.sh"); + const gitEnvironment = { + GIT_ALTERNATE_OBJECT_DIRECTORIES: path.join(outside, ".git", "objects"), + GIT_ATTR_NOSYSTEM: "0", + GIT_COMMON_DIR: path.join(outside, ".git"), + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_GLOBAL: path.join(outside, ".git", "config"), + GIT_CONFIG_KEY_0: "core.fsmonitor", + GIT_CONFIG_NOSYSTEM: "0", + GIT_CONFIG_SYSTEM: path.join(outside, ".git", "config"), + GIT_CONFIG_VALUE_0: hook, + GIT_DIR: path.join(outside, ".git"), + GIT_EXEC_PATH: path.join(tempRoot, "missing-git-exec-path"), + GIT_INDEX_FILE: path.join(outside, ".git", "index"), + GIT_NO_LAZY_FETCH: "0", + GIT_NO_REPLACE_OBJECTS: "0", + GIT_OBJECT_DIRECTORY: path.join(outside, ".git", "objects"), + GIT_OPTIONAL_LOCKS: "1", + GIT_WORK_TREE: outside + } satisfies Record; + const previous = new Map(); + + try { + await mkdir(outside); + await mkdir(target); + await initializeCommittedRepo(outside, "outside\n"); + await initializeCommittedRepo(target, "target\n"); + await writeFile(hook, `#!/bin/sh\n: > ${JSON.stringify(marker)}\n`, "utf8"); + await chmod(hook, 0o755); + await writeFile(path.join(target, ".gitattributes"), "tracked.txt filter=evil\n", "utf8"); + await runGit(["config", "filter.evil.clean", hook], target); + await runGit(["config", "filter.evil.required", "true"], target); + await runGit(["add", ".gitattributes"], target); + await runGit([ + "-c", + "user.name=Humanish Test", + "-c", + "user.email=test@example.test", + "commit", + "-m", + "attributes" + ], target); + await runGit(["config", "core.worktree", outside], target); + await runGit(["config", "core.fsmonitor", hook], target); + await runGit(["config", "core.alternateRefsCommand", hook], target); + const outsideSha = await runGitOutput(["rev-parse", "--short=12", "HEAD"], outside); + const targetSha = await runGitOutput(["rev-parse", "--short=12", "HEAD"], target); + expect(targetSha).not.toBe(outsideSha); + await rm(marker, { force: true }); + + const targetIndexPath = path.join(target, ".git", "index"); + const outsideIndexPath = path.join(outside, ".git", "index"); + const targetIndexBefore = await readFile(targetIndexPath); + const outsideIndexBefore = await readFile(outsideIndexPath); + // A same-content mtime change normally invites Git to refresh index stat + // data. GIT_OPTIONAL_LOCKS=0 must keep the captured index byte-identical. + await utimes(path.join(target, "tracked.txt"), new Date("2030-01-01T00:00:00.000Z"), new Date("2030-01-01T00:00:00.000Z")); + await writeFile(path.join(outside, "tracked.txt"), "outside-dirty\n", "utf8"); + + for (const [name, value] of Object.entries(gitEnvironment)) { + previous.set(name, process.env[name]); + process.env[name] = value; + } + + const state = await captureGitState(target, { + capturedAt: "2026-06-02T10:00:00.000Z" + }); + + expect(state.status).toBe("clean"); + expect(state.head.shortSha).toBe(targetSha); + expect(state.head.shortSha).not.toBe(outsideSha); + expect(state.changes.total).toBe(0); + await expect(access(marker)).rejects.toThrow(); + expect(await readFile(targetIndexPath)).toEqual(targetIndexBefore); + expect(await readFile(outsideIndexPath)).toEqual(outsideIndexBefore); + } finally { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it.each([ + ["HEAD", "symlink"], + ["HEAD", "hardlink"], + ["HEAD", "fifo"], + ["index", "symlink"], + ["index", "hardlink"], + ["index", "fifo"], + ["config", "symlink"], + ["config", "hardlink"], + ["config", "fifo"] + ] as const)("rejects unsafe Git metadata leaf %s (%s) before invoking a runner", async (leafName, kind) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), `humanish-core-git-${leafName}-${kind}-`)); + const target = path.join(tempRoot, "target"); + const outside = path.join(tempRoot, `outside-${leafName}`); + + try { + await mkdir(target); + await initializeCommittedRepo(target, "target\n"); + const leaf = path.join(target, ".git", leafName); + const original = await readFile(leaf); + await unlink(leaf); + if (kind === "fifo") { + await execFileAsync("mkfifo", [leaf]); + } else { + await writeFile(outside, original); + if (kind === "symlink") await symlink(outside, leaf); + else await link(outside, leaf); + } + let runnerCalls = 0; + + const state = await captureGitState(target, { + capturedAt: "2026-06-02T10:00:00.000Z", + runner: async () => { + runnerCalls += 1; + return { exitCode: 0, stderr: "", stdout: "true\n" }; + } + }); + + expect(runnerCalls).toBe(0); + expect(state.status).toBe("unavailable"); + expect(state.note).toBe("Git metadata failed containment validation."); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("returns unavailable when a git command exceeds its deadline", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-core-git-timeout-")); + + try { + await mkdir(path.join(tempRoot, ".git")); + const startedAt = Date.now(); + const state = await captureGitState(tempRoot, { + capturedAt: "2026-06-02T10:00:00.000Z", + commandTimeoutMs: 25, + runner: async () => await new Promise(() => {}) + }); + + expect(state.status).toBe("unavailable"); + expect(state.note).toBe("Git work-tree detection timed out."); + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); }); async function runGit(args: string[], cwd: string): Promise { await execFileAsync("git", args, { cwd }); } + +async function runGitOutput(args: string[], cwd: string): Promise { + const result = await execFileAsync("git", args, { cwd }); + return result.stdout.trim(); +} + +async function initializeCommittedRepo(cwd: string, contents: string): Promise { + await runGit(["init"], cwd); + await writeFile(path.join(cwd, "tracked.txt"), contents, "utf8"); + await runGit(["add", "tracked.txt"], cwd); + await runGit([ + "-c", + "user.name=Humanish Test", + "-c", + "user.email=test@example.test", + "commit", + "-m", + "initial" + ], cwd); +} diff --git a/tests/cua-actor-lab.test.ts b/tests/cua-actor-lab.test.ts index 41e4514..9ef975f 100644 --- a/tests/cua-actor-lab.test.ts +++ b/tests/cua-actor-lab.test.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { symlinkSync, unlinkSync } from "node:fs"; +import { link, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -19,6 +20,7 @@ import type { import { CUA_ACTOR_LAB_PROVIDER_METADATA, buildCuaBundle, + makeLaneWriteScreenshot, runCuaActorLab, type CuaActorLabHooks } from "../src/cua-actor-lab.js"; @@ -38,6 +40,7 @@ import type { } from "../src/index.js"; import { containsSensitive } from "../src/redaction.js"; import { verifyRun } from "../src/run.js"; +import { prepareSelectedOutputDirectory } from "../src/selected-output-paths.js"; import type { LocalTreeArchive } from "../src/source-archive.js"; // --------------------------------------------------------------------------- @@ -386,6 +389,76 @@ describe("runCuaActorLab", () => { expect(bundle.cwd).toBe("[target-cwd]"); }); + it("pins a symlink cwd before onPreflight can retarget the alias", async () => { + const physicalA = path.join(cwd, "project-a"); + const physicalB = path.join(cwd, "project-b"); + const cwdAlias = path.join(cwd, "project-alias"); + const runId = "preflight-cwd-retarget"; + const decoyRuns = path.join(physicalB, ".humanish", "runs"); + const decoyLatest = path.join(decoyRuns, "latest.json"); + const sentinel = "outside sentinel must stay unchanged\n"; + + await mkdir(physicalA); + await mkdir(decoyRuns, { recursive: true }); + await writeFile(decoyLatest, sentinel, "utf8"); + symlinkSync(physicalA, cwdAlias, "dir"); + const pinnedA = await realpath(physicalA); + + let preflightCalls = 0; + const result = await runCuaActorLab({ + cwd: cwdAlias, + config: cuaConfig(), + dryRun: true, + runId, + hooks: { + onPreflight: () => { + preflightCalls += 1; + unlinkSync(cwdAlias); + symlinkSync(physicalB, cwdAlias, "dir"); + } + } + }); + + expect(preflightCalls).toBe(1); + expect(result.ok).toBe(true); + expect(result.cwd).toBe(pinnedA); + await expect(readFile(path.join(physicalA, ".humanish", "runs", runId, "run.json"), "utf8")) + .resolves.toContain(`"runId": "${runId}"`); + expect(JSON.parse(await readFile(path.join(physicalA, ".humanish", "runs", "latest.json"), "utf8")).runId) + .toBe(runId); + expect(await readFile(decoyLatest, "utf8")).toBe(sentinel); + expect(await readdir(decoyRuns)).toEqual(["latest.json"]); + + const verified = await verifyRun(physicalA, runId); + expect(verified.ok).toBe(true); + }); + + it("rejects path-shaped screenshot names and hardlinked leaves", async () => { + const artifactRoot = path.join(cwd, "screenshot-root"); + await mkdir(artifactRoot); + const preparedRoot = await prepareSelectedOutputDirectory(cwd, artifactRoot); + const screenshots: string[] = []; + const writer = makeLaneWriteScreenshot(preparedRoot, { screenshotDir: "lane-01" }, screenshots); + await expect(writer("../sentinel.png", makePng(1))).rejects.toThrow(/path segment/i); + await expect(writer("nested/frame.png", makePng(1))).rejects.toThrow(/path segment/i); + expect(() => makeLaneWriteScreenshot(preparedRoot, { screenshotDir: "../lane" }, screenshots)) + .toThrow(/path segment/i); + + const outside = path.join(cwd, "outside-frame.png"); + await writeFile(outside, "unchanged\n", "utf8"); + await mkdir(path.join(artifactRoot, "screenshots", "lane-01"), { recursive: true }); + try { + await link(outside, path.join(artifactRoot, "screenshots", "lane-01", "frame.png")); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + await expect(writer("frame.png", makePng(2))).rejects.toThrow(/hardlink|single-link/i); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + expect(screenshots).toEqual([]); + }); + it("live (with fakes): registry actor drives the REAL loop/provider/executor through the lab, fills stream.actor, and tears down", async () => { const config = cuaConfig(); const sandbox = makeFakeSandbox(); @@ -969,6 +1042,32 @@ describe("runCuaActorLab", () => { expect(result.error?.code).toBe("HUMANISH_CUA_LAB_ACTOR_UNSUPPORTED"); }); + it("rejects path-shaped runtime lane ids before provider or desktop hooks", async () => { + const config = cuaConfig(); + const actor = config.actors[0]!; + const { laneFocus: _laneFocus, ...actorWithoutLaneFocus } = actor; + const tampered: LabConfig = { + ...config, + actors: [{ ...actorWithoutLaneFocus, lanes: [{ id: "../escape" }] }] + }; + let desktopLoads = 0; + const result = await runCuaActorLab({ + cwd, + config: tampered, + dryRun: false, + hooks: { + loadDesktopModule: async () => { + desktopLoads += 1; + throw new Error("must not load"); + } + } + }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_CUA_LAB_FANOUT_INVALID"); + expect(result.runId).toBe("not-created"); + expect(desktopLoads).toBe(0); + }); + it("re-enforces the loopback entry boundary at the engine even if a config bypasses the parser", async () => { const config = cuaConfig(); const tampered = { ...config, subject: { source: "app-url" as const, appUrl: "https://example.com/" } }; @@ -2057,7 +2156,7 @@ describe("local-tree route (subject.source: local-tree, computer-use)", () => { // Packed exactly ONCE for the whole 2-lane fan-out, rooted at the lab resolution cwd. expect(packCalls).toHaveLength(1); - expect(packCalls[0]?.root).toBe(cwd); + expect(packCalls[0]?.root).toBe(await realpath(cwd)); // Every lane uploaded the SAME archive bytes to the SAME remote path, octet-stream. const uploads = sandbox.calls.filter( diff --git a/tests/feedback.test.ts b/tests/feedback.test.ts index 43c93b3..a136b37 100644 --- a/tests/feedback.test.ts +++ b/tests/feedback.test.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { cp, link, mkdir, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; +import { symlinkSync, unlinkSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -75,6 +76,66 @@ describe("feedback issue drafts", () => { }); }); + it("refuses a hardlinked feedback output without mutating its external inode", async () => { + await withFixtureCopy(async (cwd) => { + await runDryRun({ cwd, dryRun: true, runId: "feedback-hardlink" }); + const feedbackDir = path.join(cwd, ".humanish", "runs", "feedback-hardlink", "feedback"); + const external = path.join(path.dirname(cwd), "feedback-external-sentinel.json"); + const original = "{\"external\":true}\n"; + await mkdir(feedbackDir); + await writeFile(external, original, "utf8"); + await link(external, path.join(feedbackDir, "draft.json")); + + const drafted = await draftFeedback(cwd, "feedback-hardlink"); + expect(drafted.ok).toBe(false); + expect(await readFile(external, "utf8")).toBe(original); + }); + }); + + it("keeps latest selection, verification, evidence reads, and writes on one physical run token", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-feedback-continuity-")); + const physicalA = path.join(tempRoot, "physical-a"); + const physicalB = path.join(tempRoot, "physical-b"); + const cwdAlias = path.join(tempRoot, "cwd-alias"); + const originalJsonParse = JSON.parse; + let retargeted = false; + try { + await cp(path.resolve("fixtures/minimal-app"), physicalA, { recursive: true }); + await cp(path.resolve("fixtures/minimal-app"), physicalB, { recursive: true }); + await runDryRun({ cwd: physicalA, dryRun: true, runId: "feedback-a" }); + await runDryRun({ cwd: physicalB, dryRun: true, runId: "feedback-b" }); + await symlink(physicalA, cwdAlias, "dir"); + JSON.parse = ((text: string, reviver?: (this: unknown, key: string, value: unknown) => unknown) => { + const value = originalJsonParse(text, reviver); + if ( + !retargeted + && typeof value === "object" + && value !== null + && (value as { runId?: unknown }).runId === "feedback-a" + && (value as { path?: unknown }).path === ".humanish/runs/feedback-a" + ) { + unlinkSync(cwdAlias); + symlinkSync(physicalB, cwdAlias, "dir"); + retargeted = true; + } + return value; + }) as typeof JSON.parse; + + const drafted = await draftFeedback(cwdAlias, "latest"); + expect(retargeted).toBe(true); + expect(drafted.ok).toBe(true); + expect(drafted.draft?.run_id).toBe("feedback-a"); + expect(await stat(path.join(physicalA, ".humanish", "runs", "feedback-a", "feedback", "draft.json"))) + .toMatchObject({}); + await expect(stat(path.join(physicalB, ".humanish", "runs", "feedback-a", "feedback", "draft.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + } finally { + JSON.parse = originalJsonParse; + await unlink(cwdAlias).catch(() => undefined); + await rm(tempRoot, { force: true, recursive: true }); + } + }); + it("refuses public feedback drafts for valid local-only evidence", async () => { await withFixtureCopy(async (cwd) => { await runDryRun({ diff --git a/tests/fixture.test.ts b/tests/fixture.test.ts index 8506557..36d0a83 100644 --- a/tests/fixture.test.ts +++ b/tests/fixture.test.ts @@ -1,11 +1,14 @@ -import { cp, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { cp, link, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { runInit } from "../src/init.js"; const fixturePath = path.resolve("fixtures/minimal-app"); +const execFileAsync = promisify(execFile); describe("minimal target app fixture", () => { it("is public-safe source material for init dry-runs", async () => { @@ -43,6 +46,86 @@ describe("minimal target app fixture", () => { } }); + it("rejects a hardlinked init target without mutating the external inode", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-init-hardlink-")); + const tempApp = path.join(tempRoot, "app"); + const externalPackage = path.join(tempRoot, "external-package.json"); + const original = "{\"name\":\"external-sentinel\"}\n"; + + try { + await mkdir(tempApp); + await writeFile(externalPackage, original, "utf8"); + await link(externalPackage, path.join(tempApp, "package.json")); + + const result = await runInit({ cwd: tempApp, yes: true }); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_UNSAFE_PROJECT_PATH"); + expect(result.error?.message).toContain("hardlinked files"); + expect(await readFile(externalPackage, "utf8")).toBe(original); + await expect(stat(path.join(tempApp, "humanish"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("returns a structured error when init targets have the wrong filesystem kind", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-init-wrong-kind-")); + const packageDirectoryApp = path.join(tempRoot, "package-directory"); + const runtimeFileApp = path.join(tempRoot, "runtime-file"); + + try { + await mkdir(path.join(packageDirectoryApp, "package.json"), { recursive: true }); + const packageResult = await runInit({ cwd: packageDirectoryApp, yes: true }); + + expect(packageResult).toMatchObject({ + ok: false, + error: { code: "HUMANISH_UNSAFE_PROJECT_PATH" } + }); + expect(packageResult.error?.message).toContain("package.json"); + await expect(stat(path.join(packageDirectoryApp, "humanish"))).rejects.toMatchObject({ code: "ENOENT" }); + + await mkdir(runtimeFileApp, { recursive: true }); + await writeFile(path.join(runtimeFileApp, ".humanish"), "not-a-directory\n", "utf8"); + const runtimeResult = await runInit({ cwd: runtimeFileApp, yes: true }); + + expect(runtimeResult).toMatchObject({ + ok: false, + error: { code: "HUMANISH_UNSAFE_PROJECT_PATH" } + }); + expect(runtimeResult.error?.message).toContain(".humanish/runs"); + await expect(stat(path.join(runtimeFileApp, "humanish"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("rejects a FIFO init target without opening or blocking on it", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-init-fifo-")); + const tempApp = path.join(tempRoot, "app"); + + try { + await mkdir(tempApp); + await execFileAsync("mkfifo", [path.join(tempApp, ".gitignore")]); + + const result = await Promise.race([ + runInit({ cwd: tempApp, yes: true }), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("runInit blocked while inspecting a FIFO")), 1_000); + }) + ]); + + expect(result).toMatchObject({ + ok: false, + error: { code: "HUMANISH_UNSAFE_PROJECT_PATH" } + }); + expect(result.error?.message).toContain(".gitignore"); + await expect(stat(path.join(tempApp, "humanish"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + it("contains only synthetic, non-secret fixture content", async () => { const files = [ "README.md", diff --git a/tests/labs.test.ts b/tests/labs.test.ts index 2fdd10a..6ce79e9 100644 --- a/tests/labs.test.ts +++ b/tests/labs.test.ts @@ -1,6 +1,8 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { link, mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { @@ -9,6 +11,8 @@ import { resolveLabManifest } from "../src/labs.js"; +const execFileAsync = promisify(execFile); + describe("lab manifest resolution", () => { it("resolves committed, ignored, and explicit .yaml lab manifests", async () => { const cwd = await mkdtemp(path.join(tmpdir(), "humanish-labs-")); @@ -80,6 +84,148 @@ describe("lab manifest resolution", () => { expect(bad.ok).toBe(false); expect(bad.error?.code).toBe("HUMANISH_LAB_INVALID"); }); + + it.each(["symlink", "hardlink", "fifo"] as const)( + "rejects an unsafe higher-priority managed %s leaf without falling through or blocking", + async (kind) => { + const root = await mkdtemp(path.join(tmpdir(), "humanish-labs-unsafe-leaf-")); + const cwd = path.join(root, "project"); + const outside = path.join(root, `outside-${kind}.yaml`); + const candidate = path.join(cwd, "humanish", "labs", "priority.yaml"); + await mkdir(path.dirname(candidate), { recursive: true }); + await writeFile(outside, labYaml("outside"), "utf8"); + await writeLab(cwd, ".humanish/labs/priority.yaml", labYaml("fallback")); + + if (kind === "symlink") { + await symlink(outside, candidate); + } else if (kind === "hardlink") { + try { + await link(outside, candidate); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + } else { + await execFileAsync("mkfifo", [candidate]); + } + + const resolved = await withinOneSecond( + resolveLabManifest(cwd, "priority"), + `named resolution hung on a managed ${kind} manifest` + ); + const listed = await withinOneSecond( + listLabManifests(cwd), + `lab listing hung on a managed ${kind} manifest` + ); + + expect(resolved.ok).toBe(false); + expect(!resolved.ok && resolved.error.code).toBe("HUMANISH_LAB_INVALID"); + expect(!resolved.ok && resolved.error.message).toMatch(/managed lab|single-link|containment/i); + expect(listed.labs.map((lab) => `${lab.origin}:${lab.id}`)).toEqual(["ignored:fallback"]); + expect(listed.warnings.join("\n")).toContain("humanish/labs/priority.yaml"); + expect(await readFile(outside, "utf8")).toBe(labYaml("outside")); + } + ); + + it.each(["symlink", "fifo"] as const)( + "rejects an unsafe managed %s lab directory and lists other safe roots", + async (kind) => { + const root = await mkdtemp(path.join(tmpdir(), "humanish-labs-unsafe-dir-")); + const cwd = path.join(root, "project"); + const committedParent = path.join(cwd, "humanish"); + const committedLabs = path.join(committedParent, "labs"); + await mkdir(committedParent, { recursive: true }); + await writeLab(cwd, ".humanish/local/labs/priority.yaml", labYaml("safe-local")); + + if (kind === "symlink") { + const outsideLabs = path.join(root, "outside-labs"); + await writeLab(outsideLabs, "priority.yaml", labYaml("outside")); + await symlink(outsideLabs, committedLabs); + } else { + await execFileAsync("mkfifo", [committedLabs]); + } + + const resolved = await withinOneSecond( + resolveLabManifest(cwd, "priority"), + `named resolution hung on a managed ${kind} directory` + ); + const listed = await withinOneSecond( + listLabManifests(cwd), + `lab listing hung on a managed ${kind} directory` + ); + + expect(resolved.ok).toBe(false); + expect(!resolved.ok && resolved.error.code).toBe("HUMANISH_LAB_INVALID"); + expect(listed.labs.map((lab) => `${lab.origin}:${lab.id}`)).toEqual(["ignored:safe-local"]); + expect(listed.warnings.join("\n")).toMatch(/humanish[/\\]labs.*unsafe|symbolic links/i); + } + ); + + it("keeps explicit symlink aliases as caller-selected input authority", async () => { + const root = await mkdtemp(path.join(tmpdir(), "humanish-labs-explicit-alias-")); + const cwd = path.join(root, "project"); + const target = path.join(root, "outside", "selected.yaml"); + const alias = path.join(cwd, "aliases", "selected.yaml"); + await mkdir(path.dirname(alias), { recursive: true }); + await writeLab(root, "outside/selected.yaml", labYaml("explicit-alias")); + await symlink(target, alias); + + const resolved = await resolveLabManifest(cwd, "aliases/selected.yaml"); + + expect(resolved.ok).toBe(true); + expect(resolved.ok && resolved.origin).toBe("explicit"); + expect(resolved.ok && resolved.config.id).toBe("explicit-alias"); + expect(resolved.ok && resolved.path).toBe("aliases/selected.yaml"); + expect(resolved.ok && resolved.path).not.toContain("outside"); + }); + + it.each(["hardlink", "fifo"] as const)( + "rejects an explicit %s manifest without blocking", + async (kind) => { + const root = await mkdtemp(path.join(tmpdir(), "humanish-labs-explicit-unsafe-")); + const cwd = path.join(root, "project"); + const selected = path.join(cwd, `selected-${kind}.yaml`); + await mkdir(cwd, { recursive: true }); + if (kind === "hardlink") { + const outside = path.join(root, "outside.yaml"); + await writeFile(outside, labYaml("outside"), "utf8"); + try { + await link(outside, selected); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + } else { + await execFileAsync("mkfifo", [selected]); + } + + const resolved = await withinOneSecond( + resolveLabManifest(cwd, path.basename(selected)), + `explicit resolution hung on a ${kind} manifest` + ); + + expect(resolved.ok).toBe(false); + expect(!resolved.ok && resolved.error.code).toBe("HUMANISH_LAB_INVALID"); + expect(!resolved.ok && resolved.error.message).toMatch(/single-link|containment/i); + } + ); + + it("resolves managed manifests from a caller-selected symlink cwd alias", async () => { + const root = await mkdtemp(path.join(tmpdir(), "humanish-labs-cwd-alias-")); + const physicalCwd = path.join(root, "physical-project"); + const aliasCwd = path.join(root, "project-alias"); + await writeLab(physicalCwd, "humanish/labs/aliased.yaml", labYaml("aliased")); + await symlink(physicalCwd, aliasCwd); + + const resolved = await resolveLabManifest(aliasCwd, "aliased"); + const listed = await listLabManifests(aliasCwd); + + expect(resolved.ok).toBe(true); + expect(resolved.ok && resolved.path).toBe("humanish/labs/aliased.yaml"); + expect(listed.labs.map((lab) => lab.path)).toEqual(["humanish/labs/aliased.yaml"]); + }); }); async function writeLab(cwd: string, relativePath: string, contents: string): Promise { @@ -87,3 +233,24 @@ async function writeLab(cwd: string, relativePath: string, contents: string): Pr await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, `${contents}\n`, "utf8"); } + +function labYaml(id: string): string { + return [ + "schema: humanish.lab.v2", + `id: ${id}`, + "subject:", + " source: this-repo", + "actors:", + " - type: synthetic-persona", + "" + ].join("\n"); +} + +async function withinOneSecond(promise: Promise, message: string): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(message)), 1_000); + }) + ]); +} diff --git a/tests/observer-static.test.ts b/tests/observer-static.test.ts index 446b8dc..48a2ae1 100644 --- a/tests/observer-static.test.ts +++ b/tests/observer-static.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { link, mkdtemp, mkdir, rm, symlink, unlink, writeFile } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { OBSERVER_STATIC_HOST, + createObserverStaticHandler, observerStaticContentType, respondToObserverStaticRequest, serveObserverStatic @@ -82,6 +83,37 @@ async function callHandler(runDir: string, url: string, method = "GET"): Promise return captured; } +async function callCreatedHandler( + handler: (request: IncomingMessage, response: ServerResponse) => void, + url: string +): Promise { + return new Promise((resolve) => { + const captured: CapturedResponse = { statusCode: 0, headers: {}, body: "" }; + const response = { + headersSent: false, + writeHead(status: number, headers: Record) { + captured.statusCode = status; + captured.headers = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ); + this.headersSent = true; + return this; + }, + end(chunk?: Buffer | string) { + if (chunk !== undefined) { + captured.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; + } + resolve(captured); + return this; + } + }; + handler( + { method: "GET", url } as IncomingMessage, + response as unknown as ServerResponse + ); + }); +} + describe("observer static content type", () => { it("maps the asset kinds the Observer ships", () => { expect(observerStaticContentType("a/index.html")).toBe("text/html; charset=utf-8"); @@ -96,6 +128,22 @@ describe("observer static content type", () => { }); describe("observer static request handler", () => { + it("can be created before its root exists and pins that root on the first request", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-observer-static-lazy-")); + const runDir = path.join(tempRoot, "late-run"); + const handler = createObserverStaticHandler({ root: runDir }); + try { + await mkdir(runDir); + await writeFile(path.join(runDir, "index.html"), "Late Observer", "utf8"); + + const response = await callCreatedHandler(handler, "/"); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Late Observer"); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + it("redirects / to the observer entry page", async () => { await withRunDir(async ({ runDir }) => { const response = await callHandler(runDir, "/"); @@ -182,6 +230,37 @@ describe("observer static request handler", () => { }); }); + it("refuses symlinked files even when their names are inside the run dir", async () => { + await withRunDir(async ({ runDir, runsDir }) => { + await symlink(path.join(runsDir, "secret.txt"), path.join(runDir, "leak.txt")); + const response = await callHandler(runDir, "/leak.txt"); + expect([403, 404]).toContain(response.statusCode); + expect(response.body).not.toContain("TOP-SECRET"); + }); + }); + + it("refuses an intermediate symlink that resolves outside the run dir", async () => { + await withRunDir(async ({ runDir, runsDir }) => { + const outsideDir = path.join(runsDir, "outside-assets"); + await mkdir(outsideDir); + await writeFile(path.join(outsideDir, "secret.txt"), SECRET_BODY, "utf8"); + await symlink(outsideDir, path.join(runDir, "linked-assets")); + const response = await callHandler(runDir, "/linked-assets/secret.txt"); + expect([403, 404]).toContain(response.statusCode); + expect(response.body).not.toContain("TOP-SECRET"); + }); + }); + + it("supports a caller-supplied symlink root while enforcing physical containment", async () => { + await withRunDir(async ({ runDir, runsDir }) => { + const linkedRoot = path.join(runsDir, "linked-root"); + await symlink(runDir, linkedRoot); + const response = await callHandler(linkedRoot, `/${ENTRY}`); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Humanish Observer"); + }); + }); + it("rejects non-GET/HEAD methods", async () => { await withRunDir(async ({ runDir }) => { const response = await callHandler(runDir, `/${ENTRY}`, "DELETE"); @@ -225,4 +304,47 @@ describe("observer static server", () => { } }); }); + + it("stays pinned to the original physical root after its caller alias retargets", async () => { + await withRunDir(async ({ runDir, runsDir }) => { + const aliasRoot = path.join(runsDir, "served-root-alias"); + const decoyRoot = path.join(runsDir, "retargeted-root"); + await mkdir(path.join(decoyRoot, "observer"), { recursive: true }); + await writeFile( + path.join(decoyRoot, ENTRY), + "RETARGETED-B-SECRET", + "utf8" + ); + await symlink(runDir, aliasRoot, "dir"); + + const server = await serveObserverStatic({ root: aliasRoot, port: 0, entryPath: ENTRY }); + try { + await unlink(aliasRoot); + await symlink(decoyRoot, aliasRoot, "dir"); + + const response = await fetch(server.url); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("Humanish Observer"); + expect(body).not.toContain("RETARGETED-B-SECRET"); + } finally { + await server.close(); + await unlink(aliasRoot).catch(() => undefined); + } + }); + }); + + it("rejects hardlinked files created after the static root is pinned", async () => { + await withRunDir(async ({ runDir, runsDir }) => { + const server = await serveObserverStatic({ root: runDir, port: 0, entryPath: ENTRY }); + try { + await link(path.join(runsDir, "secret.txt"), path.join(runDir, "hardlink-secret.txt")); + const response = await fetch(`http://${server.host}:${server.port}/hardlink-secret.txt`); + expect(response.status).toBe(404); + expect(await response.text()).not.toContain("TOP-SECRET"); + } finally { + await server.close(); + } + }); + }); }); diff --git a/tests/observer.test.ts b/tests/observer.test.ts index 4f5c54d..ffc1840 100644 --- a/tests/observer.test.ts +++ b/tests/observer.test.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { cp, link, mkdir, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; +import { symlinkSync, unlinkSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { runInNewContext } from "node:vm"; @@ -838,6 +839,145 @@ describe("observer rendering", () => { }); }); + it("keeps a live Observer pinned to its original physical roots after a cwd alias retarget", async () => { + await withRunBundle(async (physicalCwd) => { + const tempRoot = path.dirname(physicalCwd); + const aliasCwd = path.join(tempRoot, "observer-cwd-alias"); + const decoyCwd = path.join(tempRoot, "retargeted-app"); + await cp(path.resolve("fixtures/minimal-app"), decoyCwd, { recursive: true }); + await runDryRun({ cwd: decoyCwd, dryRun: true, runId: "observer-proof" }); + await runDryRun({ cwd: decoyCwd, dryRun: true, runId: "retargeted-b-only" }); + + for (const [cwd, title] of [ + [physicalCwd, "PINNED-A-MARKER"], + [decoyCwd, "RETARGETED-B-SECRET"] + ] as const) { + const bundlePath = path.join(cwd, ".humanish", "runs", "observer-proof", "run.json"); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as { scenario: { title: string } }; + bundle.scenario.title = title; + await writeFile(bundlePath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + } + + await symlink(physicalCwd, aliasCwd, "dir"); + const rendered = await renderObserver(aliasCwd, "latest"); + const server = await serveObserver(rendered, { port: 0 }); + try { + await unlink(aliasCwd); + await symlink(decoyCwd, aliasCwd, "dir"); + + for (const url of [ + server.url, + new URL("/_humanish/runs/observer-proof/observer/index.html", server.url).href + ]) { + const response = await fetch(url); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("PINNED-A-MARKER"); + expect(body).not.toContain("RETARGETED-B-SECRET"); + } + + const history = await (await fetch(new URL("/_humanish/history.json", server.url))).json() as { + runs: Array<{ runId: string }>; + }; + expect(history.runs.map((run) => run.runId)).toContain("observer-proof"); + expect(history.runs.map((run) => run.runId)).not.toContain("retargeted-b-only"); + } finally { + await server.close(); + await unlink(aliasCwd).catch(() => undefined); + } + }); + }); + + it("retains the original runs-root token when a latest-pointer read retargets the cwd alias", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-observer-latest-bind-")); + const physicalA = path.join(tempRoot, "physical-a"); + const physicalB = path.join(tempRoot, "physical-b"); + const aliasCwd = path.join(tempRoot, "cwd-alias"); + const originalJsonParse = JSON.parse; + let retargeted = false; + + try { + await cp(path.resolve("fixtures/minimal-app"), physicalA, { recursive: true }); + await cp(path.resolve("fixtures/minimal-app"), physicalB, { recursive: true }); + await runDryRun({ cwd: physicalA, dryRun: true, runId: "latest-a" }); + await runDryRun({ cwd: physicalB, dryRun: true, runId: "latest-b" }); + await symlink(physicalA, aliasCwd, "dir"); + JSON.parse = ((text: string, reviver?: (this: unknown, key: string, value: unknown) => unknown) => { + const value = originalJsonParse(text, reviver); + if ( + !retargeted + && typeof value === "object" + && value !== null + && (value as { runId?: unknown }).runId === "latest-a" + && (value as { path?: unknown }).path === ".humanish/runs/latest-a" + ) { + unlinkSync(aliasCwd); + symlinkSync(physicalB, aliasCwd, "dir"); + retargeted = true; + } + return value; + }) as typeof JSON.parse; + + const rendered = await renderObserver(aliasCwd, "latest"); + expect(retargeted).toBe(true); + expect(rendered.ok).toBe(true); + expect(rendered.run).toBe("latest-a"); + expect(await stat(path.join(physicalA, ".humanish", "runs", "latest-a", "observer", "index.html"))) + .toMatchObject({}); + await expect(stat(path.join(physicalB, ".humanish", "runs", "latest-a", "observer", "index.html"))) + .rejects.toMatchObject({ code: "ENOENT" }); + + const server = await serveObserver(rendered, { open: false }); + try { + const response = await fetch(server.url); + expect(response.status).toBe(200); + expect(await response.text()).toContain("latest-a"); + } finally { + await server.close(); + } + } finally { + JSON.parse = originalJsonParse; + await unlink(aliasCwd).catch(() => undefined); + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("refuses to render over a hardlinked Observer output leaf", async () => { + await withRunBundle(async (cwd) => { + const observerDir = path.join(cwd, ".humanish", "runs", "observer-proof", "observer"); + const externalSentinel = path.join(path.dirname(cwd), "observer-output-sentinel.html"); + await mkdir(observerDir, { recursive: true }); + await writeFile(externalSentinel, "OUTSIDE-SENTINEL", "utf8"); + await link(externalSentinel, path.join(observerDir, "index.html")); + + const rendered = await renderObserver(cwd, "latest"); + expect(rendered).toMatchObject({ + ok: false, + error: { code: "HUMANISH_INVALID_RUN_BUNDLE" } + }); + expect(await readFile(externalSentinel, "utf8")).toBe("OUTSIDE-SENTINEL"); + }); + }); + + it("rejects hardlinked Observer artifact leaves created after server pinning", async () => { + await withRunBundle(async (cwd) => { + const rendered = await renderObserver(cwd, "latest"); + const server = await serveObserver(rendered, { port: 0 }); + const externalSecret = path.join(path.dirname(cwd), "hardlink-secret.txt"); + const linkedArtifact = path.join(cwd, ".humanish", "runs", "observer-proof", "hardlink-secret.txt"); + try { + await writeFile(externalSecret, "HARDLINK-SECRET", "utf8"); + await link(externalSecret, linkedArtifact); + + const response = await fetch(new URL("../hardlink-secret.txt", server.url)); + expect(response.status).toBe(404); + expect(await response.text()).not.toContain("HARDLINK-SECRET"); + } finally { + await server.close(); + } + }); + }); + it("exposes watch --no-open through the Commander CLI", async () => { await withRunBundle(async (cwd) => { const result = await runCli(["watch", "--run", "latest", "--cwd", cwd, "--no-open", "--json"]); diff --git a/tests/openai-responses-cu.capture.test.ts b/tests/openai-responses-cu.capture.test.ts index 8f26c74..a7b1053 100644 --- a/tests/openai-responses-cu.capture.test.ts +++ b/tests/openai-responses-cu.capture.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -183,6 +183,50 @@ describe("wire capture (opt-in, response-side, redacted)", () => { expect(await readFile(path.join(captureDir, "wire-001.json"), "utf8")).toContain("resp_2"); }); + it("rejects a generated capture leaf before making a provider call", async () => { + const captureDir = path.join(cwd, "wire-preflight"); + const outside = path.join(cwd, "outside.json"); + await mkdir(captureDir); + await writeFile(outside, "unchanged\n", "utf8"); + await symlink(outside, path.join(captureDir, "wire-001.json")); + let fetchCalls = 0; + const provider = createOpenAiResponsesProvider({ + apiKey: "test-key", + fetchFn: async () => { + fetchCalls += 1; + return { ok: true, status: 200, text: async () => "", json: async () => RESPONSE_ONE }; + }, + env: { [WIRE_CAPTURE_ENV]: captureDir } + }); + + await expect(provider.nextTurn(request(), neverAbort)).rejects.toThrow(/regular files/i); + expect(fetchCalls).toBe(0); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + }); + + it("retains capture-root identity across the fetch window", async () => { + const first = path.join(cwd, "first"); + const second = path.join(cwd, "second"); + const alias = path.join(cwd, "wire-alias"); + await mkdir(first); + await mkdir(second); + await writeFile(path.join(second, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(first, alias, "dir"); + const provider = createOpenAiResponsesProvider({ + apiKey: "test-key", + fetchFn: async () => { + await rm(alias); + await symlink(second, alias, "dir"); + return { ok: true, status: 200, text: async () => "", json: async () => RESPONSE_ONE }; + }, + env: { [WIRE_CAPTURE_ENV]: alias } + }); + + await expect(provider.nextTurn(request(), neverAbort)).rejects.toThrow(/changed physical destination/i); + expect(await readFile(path.join(second, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + await expect(access(path.join(second, "wire-001.json"))).rejects.toThrow(); + }); + it("never captures request material: no screenshots, no instructions", async () => { const captureDir = path.join(cwd, "wire"); const provider = createOpenAiResponsesProvider({ diff --git a/tests/oss-lab.test.ts b/tests/oss-lab.test.ts index 440a71a..a91c5ea 100644 --- a/tests/oss-lab.test.ts +++ b/tests/oss-lab.test.ts @@ -1,6 +1,6 @@ import { CommanderError } from "commander"; import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { link, mkdir, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_OSS_REPOS, normalizeOssRepoSlugs, + runOssLab, validateOssRepoSlug } from "../src/oss-lab.js"; import { buildObserverData } from "../src/observer-data.js"; @@ -18,14 +19,12 @@ import { buildOssRepoAssignments, cleanupOssMetaLabSandboxes, cleanupStaleOssMetaLabSandboxes, - collectOssMetaLabPrivateEnv, collectOssMetaLabRemoteEnv, normalizeHostActorRecommendedProof, preflightOssMetaActorApiKey, preflightOssMetaRepoAccess, publicSafeOssMetaBundle, - runOssMetaLab, - sandboxIdsForOssMetaLabCleanup + runOssMetaLab } from "../src/oss-meta-lab.js"; import type { OssMetaLabCompletion, OssMetaLabResult } from "../src/oss-meta-lab.js"; import { @@ -251,27 +250,6 @@ describe("OSS lab command", () => { }); }); - it("isolates provider secrets under Humanish-private remote env names", () => { - expect(collectOssMetaLabPrivateEnv({ - CODEX_ACCESS_TOKEN: "codex-access-token-test", - CODEX_APP_SERVER_CLIENT_URL: "https://codex-app-server.example/session/client-token-test", - E2B_API_KEY: "must-not-forward-to-remote-env", - GH_TOKEN: "github-token-test", - OPENAI_API_KEY: "openai-token-test" - })).toEqual({ - HUMANISH_CODEX_ACCESS_TOKEN: "codex-access-token-test", - HUMANISH_CODEX_API_KEY: "openai-token-test", - HUMANISH_CODEX_APP_SERVER_URL: "https://codex-app-server.example/session/client-token-test", - HUMANISH_GITHUB_TOKEN: "github-token-test" - }); - - expect(collectOssMetaLabPrivateEnv({ - GITHUB_PAT: "github-pat-test" - })).toEqual({ - HUMANISH_GITHUB_TOKEN: "github-pat-test" - }); - }); - it("preflights private GitHub repo clone access with askpass-scoped token auth after anonymous access fails", async () => { const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-repo-preflight-")); const [assignment] = buildOssRepoAssignments(["example/private-fixture"], 1); @@ -377,6 +355,45 @@ describe("OSS lab command", () => { } }); + it("cleans only its captured physical repo-preflight temp root after a cwd alias retarget", async () => { + const tempRoot = await mkdtemp(path.join(tmpdir(), "humanish-oss-repo-preflight-alias-")); + const physicalA = path.join(tempRoot, "physical-a"); + const physicalB = path.join(tempRoot, "physical-b"); + const cwdAlias = path.join(tempRoot, "cwd-alias"); + await mkdir(physicalA); + await mkdir(physicalB); + await symlink(physicalA, cwdAlias, "dir"); + const [assignment] = buildOssRepoAssignments(["example/public-fixture"], 1); + if (!assignment) throw new Error("Missing assignment."); + + let capturedRoot = ""; + let decoySentinel = ""; + try { + const result = await preflightOssMetaRepoAccess({ + assignments: [assignment], + cwd: cwdAlias, + env: {}, + execFileImpl: async (_file, _args, options) => { + capturedRoot = options.cwd ?? ""; + const rootId = path.basename(capturedRoot); + await unlink(cwdAlias); + await symlink(physicalB, cwdAlias, "dir"); + decoySentinel = path.join(physicalB, ".humanish", "tmp", rootId, "must-survive.txt"); + await mkdir(path.dirname(decoySentinel), { recursive: true }); + await writeFile(decoySentinel, "B-SENTINEL", "utf8"); + return { stderr: "", stdout: "" }; + } + }); + + expect(result[0]?.ok).toBe(true); + expect(capturedRoot).toContain(`${path.sep}physical-a${path.sep}`); + await expect(stat(capturedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(decoySentinel, "utf8")).toBe("B-SENTINEL"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + it("classifies missing GitHub clone auth without leaking private repo labels when redacted", async () => { const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-repo-preflight-fail-")); const [assignment] = buildOssRepoAssignments(["example/private-fixture"], 1); @@ -475,7 +492,7 @@ describe("OSS lab command", () => { })).toBe(false); }); - it("cleans up unique OSS meta-lab sandboxes from attached watch stop", async () => { + it("never authorizes provider cleanup from mutable result sandbox IDs", async () => { const result = liveMetaResult({ sandboxes: [ { repo: "repo-01", sandboxId: "sandbox-a", streamId: "oss-01-desktop", urlPresent: true }, @@ -486,19 +503,17 @@ describe("OSS lab command", () => { }); const killed: string[] = []; - expect(sandboxIdsForOssMetaLabCleanup(result)).toEqual(["sandbox-a", "sandbox-b"]); - await expect(cleanupOssMetaLabSandboxes(result, { killSandbox: async (sandboxId) => { killed.push(sandboxId); }, requestTimeoutMs: 123 })).resolves.toEqual({ - killed: 2, - skipped: 2, - errors: [] + killed: 0, + skipped: 4, + errors: ["Stored OSS meta-lab sandbox IDs cannot authorize provider mutation; use the explicit metadata-verified orphan sweep."] }); - expect(killed).toEqual(["sandbox-a", "sandbox-b"]); + expect(killed).toEqual([]); }); it("cleans up stale OSS meta-lab sandboxes by provider metadata without exposing ids (explicit listSandboxes DI = opted in)", async () => { @@ -568,18 +583,15 @@ describe("OSS lab command", () => { } }); - it("redacts provider ids from cleanup errors when requested", async () => { - const result = liveMetaResult({ - sandboxes: [ - { repo: "repo-01", sandboxId: "sandbox-secret", streamId: "oss-01-desktop", urlPresent: true } - ] - }); - - const cleanup = await cleanupOssMetaLabSandboxes(result, { + it("redacts metadata-verified provider ids from orphan-sweep errors", async () => { + const cleanup = await cleanupStaleOssMetaLabSandboxes({ + listSandboxes: async () => [{ + sandboxId: "sandbox-secret", + metadata: { mode: "oss-meta-lab", tool: "humanish" } + }], killSandbox: async () => { throw new Error("failed to kill sandbox-secret"); - }, - redactIds: true + } }); expect(cleanup.killed).toBe(0); @@ -746,10 +758,12 @@ describe("OSS lab command", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Usage: humanish lab oss"); - expect(result.stdout).toContain("Alias: run the bundled OSS meta-lab manifest"); + expect(result.stdout).toContain("Alias: run the bundled OSS meta-lab dry-run contract"); expect(result.stdout).toContain("--repos"); - expect(result.stdout).toContain("humanish lab run oss"); + expect(result.stdout).toContain("humanish lab run oss --dry-run"); expect(result.stdout).toContain("humanish lab oss-smoke"); + expect(result.stdout).toContain("No repo clone, provider sandbox, credential forwarding, or Codex actor runs"); + expect(result.stdout).toContain("fails closed pending credential isolation"); }); it("keeps disposable-clone safety on lab oss-smoke", async () => { @@ -762,12 +776,11 @@ describe("OSS lab command", () => { expect(result.stdout).toContain("removed by default"); }); - it("renders a no-network OSS meta-lab contract from --repos", async () => { + it("defaults the OSS alias to a no-network dry-run contract", async () => { const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-")); const result = await runCli([ "lab", "oss", - "--dry-run", "--json", "--no-open", "--cwd", @@ -783,10 +796,14 @@ describe("OSS lab command", () => { expect(result.exitCode).toBe(0); const json = JSON.parse(result.stdout) as { assignments: Array<{ repo: string }>; + dryRun: boolean; + liveRequested: boolean; observer: { observerPath: string }; schema: string; }; expect(json.schema).toBe("humanish.oss-meta-lab-result.v1"); + expect(json.dryRun).toBe(true); + expect(json.liveRequested).toBe(false); expect(json.assignments.map((assignment) => assignment.repo)).toEqual([ "repo-01", "repo-02", @@ -814,23 +831,8 @@ describe("OSS lab command", () => { const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-generic-")); await writeFile(path.join(cwd, "package.json"), JSON.stringify({ name: "fixture-app" }), "utf8"); await mkdir(path.join(cwd, "humanish", "labs"), { recursive: true }); - await writeFile(path.join(cwd, "humanish", "labs", "oss.yaml"), [ - "schema: humanish.lab.v2", - "id: oss", - "subject:", - " source: clone", - " repos:", - " - CorentinTh/it-tools", - " - drawdb-io/drawdb", - " clone:", - " fanout: 2", - "execution:", - " target: e2b-desktop", - "actors:", - " - type: codex-app-server", - "scenario:", - " mode: dry-run" - ].join("\n"), "utf8"); + const bundledManifest = await readFile(path.join(process.cwd(), "humanish", "labs", "oss.yaml"), "utf8"); + await writeFile(path.join(cwd, "humanish", "labs", "oss.yaml"), bundledManifest, "utf8"); const result = await runCli([ "lab", @@ -854,10 +856,102 @@ describe("OSS lab command", () => { expect(json.dryRun).toBe(true); expect(json.assignments.map((assignment) => assignment.repo)).toEqual([ "CorentinTh/it-tools", - "drawdb-io/drawdb" + "drawdb-io/drawdb", + "maciekt07/TodoApp", + "lissy93/dashy" ]); }); + it("stops the OSS smoke trial after init fails", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-smoke-init-failure-")); + const binDir = path.join(cwd, "bin"); + const gitLogPath = path.join(cwd, "git-calls.log"); + const previousPath = process.env.PATH; + const previousGitLog = process.env.HUMANISH_OSS_TEST_GIT_LOG; + await mkdir(binDir); + await writeFile(path.join(binDir, "git"), [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"$HUMANISH_OSS_TEST_GIT_LOG\"", + "if [ \"$1\" != clone ]; then exit 97; fi", + "for arg in \"$@\"; do clone_path=\"$arg\"; done", + "mkdir -p \"$clone_path\"", + "printf '{invalid-json' > \"$clone_path/package.json\"", + "exit 0", + "" + ].join("\n"), { encoding: "utf8", mode: 0o700 }); + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + process.env.HUMANISH_OSS_TEST_GIT_LOG = gitLogPath; + + try { + const result = await runOssLab({ + cwd, + keep: true, + repos: ["example/broken-init"], + runId: "init-failure-stop" + }); + + expect(result.ok).toBe(false); + expect(result.repos).toHaveLength(1); + expect(result.repos[0]?.steps.map((step) => step.name)).toEqual([ + "clone", + "humanish init" + ]); + expect(result.repos[0]?.steps[1]?.ok).toBe(false); + expect((await readFile(gitLogPath, "utf8")).trim().split("\n")).toHaveLength(1); + await expect(stat(path.join( + cwd, + ".humanish", + "tmp", + "oss-lab", + "init-failure-stop", + "example__broken-init", + ".humanish" + ))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousGitLog === undefined) delete process.env.HUMANISH_OSS_TEST_GIT_LOG; + else process.env.HUMANISH_OSS_TEST_GIT_LOG = previousGitLog; + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("rejects a hardlinked OSS report target without mutating the external inode", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-report-hardlink-")); + const runId = "report-hardlink"; + const reportRoot = path.join(cwd, ".humanish", "lab", "oss", runId); + const externalReport = path.join(cwd, "external-report.json"); + const original = "{\"sentinel\":true}\n"; + const binDir = path.join(cwd, "bin"); + const previousPath = process.env.PATH; + + await mkdir(reportRoot, { recursive: true }); + await mkdir(binDir); + await writeFile(externalReport, original, "utf8"); + await link(externalReport, path.join(reportRoot, "report.json")); + await writeFile(path.join(binDir, "git"), [ + "#!/bin/sh", + "exit 1", + "" + ].join("\n"), { encoding: "utf8", mode: 0o700 }); + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + + try { + await expect(runOssLab({ + cwd, + keep: true, + repos: ["example/unavailable"], + runId + })).rejects.toThrow(/single-link regular files|hardlinks/); + expect(await readFile(externalReport, "utf8")).toBe(original); + await expect(stat(path.join(reportRoot, "report.md"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await rm(cwd, { recursive: true, force: true }); + } + }); + it("forces repo-label redaction when repos are overridden on the CLI (privacy invariant)", async () => { // Regression: a CLI --repos override must force redactRepos=true so an authorized private // slug never reaches durable artifacts, even with no policies.redactRepos in the lab. @@ -889,246 +983,132 @@ describe("OSS lab command", () => { expect(result.stdout).not.toContain("example-private/secret-app"); }); - it("fails live launch closed into waiting lanes when E2B is absent", async () => { - const previousE2b = process.env.E2B_API_KEY; - const previousOpenai = process.env.OPENAI_API_KEY; - delete process.env.E2B_API_KEY; - delete process.env.OPENAI_API_KEY; + it("rejects live OSS meta-lab execution before callbacks, filesystem writes, or host commands", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-live-gate-")); + const binDir = path.join(cwd, "bin"); + const markerPath = path.join(cwd, "host-command-invoked"); + await mkdir(binDir); + await writeFile(path.join(binDir, "git"), [ + "#!/bin/sh", + `printf invoked > ${JSON.stringify(markerPath)}`, + "exit 97", + "" + ].join("\n"), { encoding: "utf8", mode: 0o700 }); + + const envKeys = [ + "CODEX_API_KEY", + "E2B_API_KEY", + "HUMANISH_OSS_META_HOST_CODEX_ACTOR", + "HUMANISH_OSS_META_REQUIRE_ACTOR", + "HUMANISH_OSS_META_SKIP_REPO_ACCESS_PREFLIGHT", + "PATH" + ] as const; + const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<(typeof envKeys)[number], string | undefined>; + process.env.CODEX_API_KEY = "test-codex-key"; + process.env.E2B_API_KEY = "test-e2b-key"; + process.env.HUMANISH_OSS_META_HOST_CODEX_ACTOR = "1"; + process.env.HUMANISH_OSS_META_REQUIRE_ACTOR = "1"; + process.env.HUMANISH_OSS_META_SKIP_REPO_ACCESS_PREFLIGHT = "1"; + process.env.PATH = `${binDir}${path.delimiter}${previous.PATH ?? ""}`; + let observerReady = false; try { - const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-waiting-")); - const result = await runCli([ - "lab", - "oss", - "--json", - "--no-open", - "--detach", - "--cwd", + const result = await runOssMetaLab({ + count: 1, cwd, - "--run-id", - "oss-meta-waiting-test", - "--repos", - "CorentinTh/it-tools", - "--count", - "1" - ]); - - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout) as { - sandboxes: Array<{ bootstrapStatus?: string; urlPresent: boolean }>; - warnings: string[]; - }; - expect(json.sandboxes).toEqual([]); - expect(json.warnings.join("\n")).toContain("waiting on env vars"); - - const bundle = JSON.parse(await readFile(path.join(cwd, ".humanish", "runs", "oss-meta-waiting-test", "run.json"), "utf8")) as { - cwd: string; - mode: string; - review: { verdict: string }; - simulations: Array<{ currentStep: string; status: string }>; - streams: Array<{ embed: { kind: string }; status: string }>; - }; - expect(bundle.cwd).toBe(PUBLIC_TARGET_CWD); - expect(bundle.mode).toBe("live"); - expect(bundle.review.verdict).toBe("blocked"); - expect(bundle.simulations[0]).toMatchObject({ - status: "blocked", - currentStep: "Waiting for E2B_API_KEY before launching repo-01." + onObserverReady: () => { + observerReady = true; + }, + repos: ["private-owner/private-repo"], + runId: "live-isolation-gate" }); - expect(bundle.streams[0]).toMatchObject({ - status: "blocked", - embed: { kind: "placeholder" } + + expect(result).toMatchObject({ + ok: false, + dryRun: false, + liveRequested: true, + error: { code: "HUMANISH_OSS_META_LIVE_ISOLATION_REQUIRED" }, + repos: ["repo-01"], + sandboxes: [] }); + expect(result.error?.message).toContain("Use --dry-run"); + expect(JSON.stringify(result)).not.toContain("private-owner/private-repo"); + expect(observerReady).toBe(false); + await expect(stat(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(path.join(cwd, ".humanish"))).rejects.toMatchObject({ code: "ENOENT" }); } finally { - if (previousE2b === undefined) { - delete process.env.E2B_API_KEY; - } else { - process.env.E2B_API_KEY = previousE2b; - } - if (previousOpenai === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = previousOpenai; + for (const key of envKeys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; } + await rm(cwd, { recursive: true, force: true }); } }); - it("renders an OSS meta-lab placeholder Observer before live substrate is available", async () => { - const previousE2b = process.env.E2B_API_KEY; - const previousOpenai = process.env.OPENAI_API_KEY; - delete process.env.E2B_API_KEY; - delete process.env.OPENAI_API_KEY; - + it("keeps the OSS meta-lab dry-run contract available behind the live isolation gate", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-dry-run-gate-")); + let observerReady = false; try { - const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-immediate-observer-")); - let readyObserverPath = ""; - let readyObserverDataPath = ""; const result = await runOssMetaLab({ count: 1, cwd, - onObserverReady: async (observer) => { - readyObserverPath = observer.observerPath ?? ""; - readyObserverDataPath = observer.observerDataPath ?? ""; - const observerData = JSON.parse(await readFile(path.join(cwd, readyObserverDataPath), "utf8")) as { - streams: Array<{ embed: { kind: string }; status: string }>; - }; - expect(observerData.streams[0]).toMatchObject({ - embed: { kind: "placeholder" }, - status: "blocked" - }); + dryRun: true, + onObserverReady: () => { + observerReady = true; }, - redactRepoNames: true, repos: ["CorentinTh/it-tools"], - runId: "oss-meta-immediate-observer-test" + runId: "dry-run-isolation-gate" }); expect(result.ok).toBe(true); - expect(readyObserverPath).toBe(".humanish/runs/oss-meta-immediate-observer-test/observer/index.html"); - expect(readyObserverDataPath).toBe(".humanish/runs/oss-meta-immediate-observer-test/observer/observer-data.json"); + expect(result.dryRun).toBe(true); + expect(observerReady).toBe(true); + expect(await stat(path.join(cwd, ".humanish", "runs", "dry-run-isolation-gate", "run.json"))) + .toMatchObject({}); } finally { - if (previousE2b === undefined) { - delete process.env.E2B_API_KEY; - } else { - process.env.E2B_API_KEY = previousE2b; - } - if (previousOpenai === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = previousOpenai; - } + await rm(cwd, { recursive: true, force: true }); } }); - it("fails actor-required live launch closed when Codex auth is absent", async () => { - const previousE2b = process.env.E2B_API_KEY; - const previousOpenai = process.env.OPENAI_API_KEY; - const previousCodexApiKey = process.env.CODEX_API_KEY; - const previousCodexAccessToken = process.env.CODEX_ACCESS_TOKEN; - const previousActorFirst = process.env.HUMANISH_OSS_META_ACTOR_FIRST; - const previousRequireActor = process.env.HUMANISH_OSS_META_REQUIRE_ACTOR; - process.env.E2B_API_KEY = "fake-e2b-key"; - delete process.env.OPENAI_API_KEY; - delete process.env.CODEX_API_KEY; - delete process.env.CODEX_ACCESS_TOKEN; - process.env.HUMANISH_OSS_META_ACTOR_FIRST = "1"; - process.env.HUMANISH_OSS_META_REQUIRE_ACTOR = "1"; + it("keeps OSS meta-lab writes on the captured physical run after an Observer-ready cwd retarget", async () => { + const tempRoot = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-callback-retarget-")); + const physicalA = path.join(tempRoot, "physical-a"); + const physicalB = path.join(tempRoot, "physical-b"); + const cwdAlias = path.join(tempRoot, "cwd-alias"); + const runId = "callback-retarget"; + await mkdir(physicalA); + await mkdir(physicalB); + await symlink(physicalA, cwdAlias, "dir"); try { - const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-actor-auth-waiting-")); - const result = await runCli([ - "lab", - "oss", - "--json", - "--no-open", - "--detach", - "--cwd", - cwd, - "--run-id", - "oss-meta-actor-auth-waiting-test", - "--repos", - "CorentinTh/it-tools", - "--count", - "1" - ]); - - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout) as { warnings: string[] }; - expect(json.warnings.join("\n")).toContain("CODEX_API_KEY or CODEX_ACCESS_TOKEN"); - - const bundle = JSON.parse(await readFile(path.join(cwd, ".humanish", "runs", "oss-meta-actor-auth-waiting-test", "run.json"), "utf8")) as { - simulations: Array<{ currentStep: string; status: string }>; - }; - expect(bundle.simulations[0]).toMatchObject({ - status: "blocked", - currentStep: "Waiting for CODEX_API_KEY or CODEX_ACCESS_TOKEN before launching repo-01." + const result = await runOssMetaLab({ + count: 1, + cwd: cwdAlias, + dryRun: true, + onObserverReady: async () => { + await unlink(cwdAlias); + await symlink(physicalB, cwdAlias, "dir"); + const decoyRun = path.join(physicalB, ".humanish", "runs", runId); + await mkdir(decoyRun, { recursive: true }); + await writeFile(path.join(decoyRun, "must-survive.txt"), "B-SENTINEL", "utf8"); + }, + repos: ["CorentinTh/it-tools"], + runId }); - } finally { - if (previousE2b === undefined) delete process.env.E2B_API_KEY; - else process.env.E2B_API_KEY = previousE2b; - if (previousOpenai === undefined) delete process.env.OPENAI_API_KEY; - else process.env.OPENAI_API_KEY = previousOpenai; - if (previousCodexApiKey === undefined) delete process.env.CODEX_API_KEY; - else process.env.CODEX_API_KEY = previousCodexApiKey; - if (previousCodexAccessToken === undefined) delete process.env.CODEX_ACCESS_TOKEN; - else process.env.CODEX_ACCESS_TOKEN = previousCodexAccessToken; - if (previousActorFirst === undefined) delete process.env.HUMANISH_OSS_META_ACTOR_FIRST; - else process.env.HUMANISH_OSS_META_ACTOR_FIRST = previousActorFirst; - if (previousRequireActor === undefined) delete process.env.HUMANISH_OSS_META_REQUIRE_ACTOR; - else process.env.HUMANISH_OSS_META_REQUIRE_ACTOR = previousRequireActor; - } - }); - - it("fails actor-required live launch closed before E2B when actor API quota preflight fails", async () => { - const previousE2b = process.env.E2B_API_KEY; - const previousOpenai = process.env.OPENAI_API_KEY; - const previousCodexApiKey = process.env.CODEX_API_KEY; - const previousCodexAccessToken = process.env.CODEX_ACCESS_TOKEN; - const previousActorFirst = process.env.HUMANISH_OSS_META_ACTOR_FIRST; - const previousRequireActor = process.env.HUMANISH_OSS_META_REQUIRE_ACTOR; - const previousFetch = globalThis.fetch; - const fakeOpenAiKey = `sk-${"testsecretvalue1234567890abcd"}`; - process.env.E2B_API_KEY = "fake-e2b-key"; - process.env.OPENAI_API_KEY = fakeOpenAiKey; - delete process.env.CODEX_API_KEY; - delete process.env.CODEX_ACCESS_TOKEN; - process.env.HUMANISH_OSS_META_ACTOR_FIRST = "1"; - process.env.HUMANISH_OSS_META_REQUIRE_ACTOR = "1"; - globalThis.fetch = (async () => new Response(JSON.stringify({ - error: { - code: "insufficient_quota", - message: `Quota exceeded for ${fakeOpenAiKey}.` - } - }), { status: 429 })) as typeof fetch; - - try { - const cwd = await mkdtemp(path.join(tmpdir(), "humanish-oss-meta-actor-preflight-")); - const result = await runCli([ - "lab", - "oss", - "--json", - "--no-open", - "--detach", - "--cwd", - cwd, - "--run-id", - "oss-meta-actor-preflight-test", - "--repos", - "CorentinTh/it-tools", - "--count", - "1" - ]); - - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout) as { warnings: string[] }; - expect(json.warnings.join("\n")).toContain("actor API-key preflight blocked"); - expect(json.warnings.join("\n")).toContain("[redacted-openai-key]"); - expect(json.warnings.join("\n")).not.toContain("Invalid API key format"); - expect(json.warnings.join("\n")).not.toContain("sk-testsecretvalue"); - const bundle = JSON.parse(await readFile(path.join(cwd, ".humanish", "runs", "oss-meta-actor-preflight-test", "run.json"), "utf8")) as { - review: { verdict: string }; - simulations: Array<{ currentStep: string; status: string }>; - }; - expect(bundle.review.verdict).toBe("blocked"); - expect(bundle.simulations[0]).toMatchObject({ - status: "blocked", - currentStep: "Waiting for Codex actor API quota/auth preflight before launching repo-01." - }); - await rm(cwd, { recursive: true, force: true }); + expect(result.ok).toBe(true); + expect(await readFile(path.join(physicalB, ".humanish", "runs", runId, "must-survive.txt"), "utf8")) + .toBe("B-SENTINEL"); + await expect(stat(path.join(physicalB, ".humanish", "runs", runId, "run.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + expect(await stat(path.join(physicalA, ".humanish", "runs", runId, "run.json"))) + .toMatchObject({}); + expect(await stat(path.join(physicalA, ".humanish", "runs", runId, "observer", "index.html"))) + .toMatchObject({}); } finally { - globalThis.fetch = previousFetch; - if (previousE2b === undefined) delete process.env.E2B_API_KEY; - else process.env.E2B_API_KEY = previousE2b; - if (previousOpenai === undefined) delete process.env.OPENAI_API_KEY; - else process.env.OPENAI_API_KEY = previousOpenai; - if (previousCodexApiKey === undefined) delete process.env.CODEX_API_KEY; - else process.env.CODEX_API_KEY = previousCodexApiKey; - if (previousCodexAccessToken === undefined) delete process.env.CODEX_ACCESS_TOKEN; - else process.env.CODEX_ACCESS_TOKEN = previousCodexAccessToken; - if (previousActorFirst === undefined) delete process.env.HUMANISH_OSS_META_ACTOR_FIRST; - else process.env.HUMANISH_OSS_META_ACTOR_FIRST = previousActorFirst; - if (previousRequireActor === undefined) delete process.env.HUMANISH_OSS_META_REQUIRE_ACTOR; - else process.env.HUMANISH_OSS_META_REQUIRE_ACTOR = previousRequireActor; + await unlink(cwdAlias).catch(() => undefined); + await rm(tempRoot, { recursive: true, force: true }); } }); diff --git a/tests/program.test.ts b/tests/program.test.ts index a835bd7..f0f648d 100644 --- a/tests/program.test.ts +++ b/tests/program.test.ts @@ -156,7 +156,7 @@ describe("humanish CLI scaffold", () => { doctor: "Explain project readiness and missing setup.", run: "Run a persona/scenario simulation or dry-run bundle.", verify: "Validate a run bundle and public-safety gates.", - cleanup: "Clean run-owned provider resources by exact id.", + cleanup: "Write a resource cleanup inspection receipt.", review: "Build a review packet from verified run evidence.", runs: "List local Humanish runs and latest pointers.", watch: "Run sims, open the observer, keep the shell attached.", @@ -179,7 +179,7 @@ describe("humanish CLI scaffold", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Set up humanish/ source and .humanish/ runtime state."); - expect(result.stdout).toContain("Clean run-owned provider resources by exact id."); + expect(result.stdout).toContain("Write a resource cleanup inspection receipt."); expect(result.stdout).toContain("Serve a finished run's Observer over loopback http."); expect(result.stdout).toContain("Create public-safe feedback drafts, no GitHub API."); }); diff --git a/tests/release.test.ts b/tests/release.test.ts index 0b1ceb3..0e513ec 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -19,7 +19,7 @@ describe("release readiness", () => { }; expect(packageJson.private).toBeUndefined(); - expect(packageJson.version).toBe("0.15.0"); + expect(packageJson.version).toBe("0.15.1"); expect(packageJson.license).toBe("MIT"); expect(packageJson.publishConfig?.access).toBe("public"); expect(packageJson.dependencies).not.toHaveProperty("@e2b/desktop"); @@ -128,6 +128,7 @@ describe("release readiness", () => { expect(publish).toContain("git merge-base --is-ancestor \"$GITHUB_SHA\" origin/main"); expect(publish).toContain("[ \"v${PACKAGE_VERSION}\" != \"$GITHUB_REF_NAME\" ]"); expect(publish).toContain("pnpm release:check"); + expect(publish).toContain("HUMANISH_PUBLIC_DENYLIST_PATTERN: ${{ secrets.HUMANISH_PUBLIC_DENYLIST_PATTERN }}"); expect(publish).toContain("npm publish --access public"); expect(ci).toContain("pnpm/action-setup@v6"); expect(ci).toContain("pnpm release:check"); diff --git a/tests/run-path-containment.test.ts b/tests/run-path-containment.test.ts new file mode 100644 index 0000000..5a6786c --- /dev/null +++ b/tests/run-path-containment.test.ts @@ -0,0 +1,445 @@ +import { + access, + link, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +import { runInit } from "../src/init.js"; +import { renderObserver, serveObserver } from "../src/observer.js"; +import { preflightOssMetaRepoAccess } from "../src/oss-meta-lab.js"; +import { runOssLab } from "../src/oss-lab.js"; +import { createProgram } from "../src/program.js"; +import { doctor, listRuns, runDryRun, verifyRun } from "../src/run.js"; +import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "../src/run-paths.js"; +import { writePreparedRunLatestPointer } from "../src/selected-output-paths.js"; + +const execFileAsync = promisify(execFile); + +async function withTempProject(callback: (cwd: string, root: string) => Promise): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "humanish-path-containment-")); + const cwd = path.join(root, "project"); + await mkdir(cwd); + try { + return await callback(cwd, root); + } finally { + await rm(root, { force: true, recursive: true }); + } +} + +async function runCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + let exitCode = 0; + const stdout: string[] = []; + const stderr: string[] = []; + const program = createProgram({ + writeOut: (text) => stdout.push(text), + writeErr: (text) => stderr.push(text), + setExitCode: (code) => { exitCode = code; } + }); + await program.parseAsync(["node", "humanish", ...args], { from: "node" }); + return { exitCode, stdout: stdout.join(""), stderr: stderr.join("") }; +} + +describe("run path containment", () => { + it.each(["symlink", "hardlink", "fifo"] as const)( + "fails Doctor safely for a %s .gitignore without following or blocking", + async (kind) => { + await withTempProject(async (cwd, root) => { + await writeFile(path.join(cwd, "package.json"), '{"name":"doctor-containment"}\n', "utf8"); + await mkdir(path.join(cwd, "humanish")); + const outside = path.join(root, `outside-gitignore-${kind}`); + await writeFile(outside, ".humanish/\nOUTSIDE-SENTINEL\n", "utf8"); + const gitignore = path.join(cwd, ".gitignore"); + + if (kind === "symlink") { + await symlink(outside, gitignore); + } else if (kind === "hardlink") { + try { + await link(outside, gitignore); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + } else { + await execFileAsync("mkfifo", [gitignore]); + } + + const result = await Promise.race([ + doctor(cwd), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`Doctor hung on ${kind} .gitignore`)), 1_000); + }) + ]); + + expect(result.ok).toBe(false); + expect(result.checks.find((check) => check.name === "target cwd")?.ok).toBe(true); + expect(result.checks.find((check) => check.name === "package.json")?.ok).toBe(true); + expect(result.checks.find((check) => check.name === "humanish source")?.ok).toBe(true); + expect(result.checks.find((check) => check.name === "runtime ignore")?.ok).toBe(false); + expect(await readFile(outside, "utf8")).toBe(".humanish/\nOUTSIDE-SENTINEL\n"); + }); + } + ); + + it("preserves safe legacy ids, explicit latest, and same-id overwrite", async () => { + await withTempProject(async (cwd) => { + for (const runId of ["UPPER_ID.v1", "café.v2_under", "..safe", "repeat--dash", "latest"]) { + const first = await runDryRun({ cwd, dryRun: true, runId }); + expect(first.ok).toBe(true); + expect(first.runId).toBe(runId); + const second = await runDryRun({ cwd, dryRun: true, runId }); + expect(second.ok).toBe(true); + expect((await verifyRun(cwd, "latest")).ok).toBe(true); + expect((await verifyRun(cwd, runId === "latest" ? "latest" : runId)).ok).toBe(true); + } + }); + }); + + it.each(["../escape", "nested/escape", "nested\\escape", "/absolute", "bad\0id", "latest.json"])( + "rejects path-shaped run id %j before writing", + async (runId) => { + await withTempProject(async (cwd, root) => { + const sentinel = path.join(root, "sentinel.txt"); + await writeFile(sentinel, "unchanged\n", "utf8"); + await expect(runDryRun({ cwd, dryRun: true, runId })).rejects.toThrow(/run id|path segment|reserved/i); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + } + ); + + it("derives latest navigation from runId and rejects a mismatched pointer path", async () => { + await withTempProject(async (cwd) => { + await runDryRun({ cwd, dryRun: true, runId: "safe-run" }); + const pointerPath = path.join(cwd, ".humanish", "runs", "latest.json"); + const writePointer = (declaredPath: string): Promise => writeFile( + pointerPath, + `${JSON.stringify({ + schema: "humanish.latest-run.v1", + runId: "safe-run", + path: declaredPath, + updatedAt: new Date().toISOString() + })}\n`, + "utf8" + ); + + await writePointer(path.join(".humanish", "runs", "safe-run")); + expect((await verifyRun(cwd, "latest")).ok).toBe(true); + for (const invalidPath of [ + "/absolute/outside", + "C:\\outside\\run", + "\\\\server\\share\\run", + path.join(".humanish", "runs", "other"), + ".humanish/runs/other/../safe-run", + path.join("..", "..", "outside") + ]) { + await writePointer(invalidPath); + expect((await verifyRun(cwd, "latest")).ok, invalidPath).toBe(false); + } + expect((await verifyRun(cwd, "safe-run")).ok).toBe(true); + }); + }); + + it("emits one structured JSON error for an invalid CLI run id", async () => { + await withTempProject(async (cwd) => { + const result = await runCli(["run", "--dry-run", "--run-id", "../escape", "--cwd", cwd, "--json"]); + expect(result.exitCode).toBe(2); + const documents = result.stdout.trim().split(/\n(?=\{)/); + expect(documents).toHaveLength(1); + const envelope = JSON.parse(result.stdout) as { ok: boolean; error?: { code: string; message: string } }; + expect(envelope.ok).toBe(false); + expect(envelope.error?.code).toBe("HUMANISH_UNEXPECTED"); + expect(envelope.error?.message).not.toContain(cwd); + expect(result.stdout).not.toContain("at "); + }); + }); + + it("rejects symlinked storage roots, run directories, pointer files, and descendants", async () => { + await withTempProject(async (cwd, root) => { + const outside = path.join(root, "outside"); + await mkdir(outside); + const sentinel = path.join(outside, "sentinel.txt"); + await writeFile(sentinel, "unchanged\n", "utf8"); + await symlink(outside, path.join(cwd, ".humanish")); + await expect(runDryRun({ cwd, dryRun: true, runId: "blocked" })).rejects.toThrow(/symbolic link/i); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + + await withTempProject(async (cwd, root) => { + const outside = path.join(root, "outside"); + await mkdir(outside); + await mkdir(path.join(cwd, ".humanish")); + await symlink(outside, path.join(cwd, ".humanish", "runs")); + await expect(runDryRun({ cwd, dryRun: true, runId: "blocked" })).rejects.toThrow(/symbolic link/i); + }); + + await withTempProject(async (cwd, root) => { + const outside = path.join(root, "outside"); + await mkdir(outside); + await mkdir(path.join(cwd, ".humanish", "runs"), { recursive: true }); + await symlink(outside, path.join(cwd, ".humanish", "runs", "blocked")); + await expect(runDryRun({ cwd, dryRun: true, runId: "blocked" })).rejects.toThrow(/symbolic link/i); + }); + + await withTempProject(async (cwd, root) => { + await runDryRun({ cwd, dryRun: true, runId: "existing" }); + const outside = path.join(root, "outside.txt"); + await writeFile(outside, "unchanged\n", "utf8"); + await symlink(outside, path.join(cwd, ".humanish", "runs", "existing", "linked.txt")); + const before = await readFile(path.join(cwd, ".humanish", "runs", "existing", "run.json"), "utf8"); + await expect(runDryRun({ cwd, dryRun: true, runId: "existing" })).rejects.toThrow(/symbolic link/i); + expect(await readFile(path.join(cwd, ".humanish", "runs", "existing", "run.json"), "utf8")).toBe(before); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + }); + + await withTempProject(async (cwd, root) => { + await runDryRun({ cwd, dryRun: true, runId: "pointer-safe" }); + const pointer = path.join(cwd, ".humanish", "runs", "latest.json"); + const externalPointer = path.join(root, "external-latest.json"); + await rm(pointer); + await writeFile(externalPointer, "{}\n", "utf8"); + await symlink(externalPointer, pointer); + expect((await verifyRun(cwd, "latest")).ok).toBe(false); + await expect(runDryRun({ cwd, dryRun: true, runId: "new-run" })).rejects.toThrow(/regular files|symbolic links/i); + expect(await readFile(externalPointer, "utf8")).toBe("{}\n"); + }); + + await withTempProject(async (cwd, root) => { + await runDryRun({ cwd, dryRun: true, runId: "hardlink-safe" }); + const outside = path.join(root, "outside-hardlink.txt"); + const linked = path.join(cwd, ".humanish", "runs", "hardlink-safe", "linked.txt"); + await writeFile(outside, "unchanged\n", "utf8"); + try { + await link(outside, linked); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + await expect(prepareRunArtifactPaths(cwd, "hardlink-safe")).rejects.toThrow(/hardlink|single-link/i); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + }); + }); + + it("does not serve encoded cross-run ids or symlinked artifacts", async () => { + await withTempProject(async (cwd, root) => { + await runDryRun({ cwd, dryRun: true, runId: "served" }); + const rendered = await renderObserver(cwd, "served"); + expect(rendered.ok).toBe(true); + const secret = path.join(root, "secret.txt"); + await writeFile(secret, "DO-NOT-SERVE\n", "utf8"); + const server = await serveObserver(rendered, { port: 0 }); + try { + await symlink(secret, path.join(cwd, ".humanish", "runs", "served", "leak.txt")); + const base = new URL(server.url); + const origin = `${base.protocol}//${base.host}`; + const slash = await fetch(`${origin}/_humanish/runs/served%2Fother/run.json`); + const backslash = await fetch(`${origin}/_humanish/runs/served%5Cother/run.json`); + const leak = await fetch(`${origin}/leak.txt`); + expect(slash.status).toBe(404); + expect(backslash.status).toBe(404); + expect(leak.status).toBe(404); + expect(await leak.text()).not.toContain("DO-NOT-SERVE"); + } finally { + await server.close(); + } + }); + }); + + it("runs, lists, verifies, and renders through a symlinked cwd", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "humanish-run-symlink-cwd-")); + try { + const realProject = path.join(root, "real-project"); + const linkedProject = path.join(root, "linked-project"); + await mkdir(realProject); + await symlink(realProject, linkedProject); + expect((await runDryRun({ cwd: linkedProject, dryRun: true, runId: "linked-cwd" })).ok).toBe(true); + expect((await listRuns(linkedProject)).runs.map((run) => run.runId)).toContain("linked-cwd"); + expect((await verifyRun(linkedProject, "latest")).ok).toBe(true); + expect((await renderObserver(linkedProject, "latest")).ok).toBe(true); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("binds a prepared run to its original cwd target and directory identity", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "humanish-run-token-")); + try { + const first = path.join(root, "first-project"); + const second = path.join(root, "second-project"); + const alias = path.join(root, "project-alias"); + await mkdir(first); + await mkdir(second); + await symlink(first, alias, "dir"); + const prepared = await prepareRunArtifactPaths(alias, "bound-run"); + + await mkdir(path.join(second, ".humanish", "runs", "bound-run"), { recursive: true }); + const secondSentinel = path.join(second, ".humanish", "runs", "bound-run", "sentinel.txt"); + await writeFile(secondSentinel, "unchanged\n", "utf8"); + await rm(alias); + await symlink(second, alias, "dir"); + await expect(validatePreparedRunArtifactPaths(prepared)).rejects.toThrow(/changed physical destination/i); + expect(await readFile(secondSentinel, "utf8")).toBe("unchanged\n"); + + const directProject = path.join(root, "direct-project"); + await mkdir(directProject); + const recreated = await prepareRunArtifactPaths(directProject, "recreated-run"); + await rm(recreated.physicalRunRoot, { recursive: true }); + await mkdir(recreated.physicalRunRoot); + await writeFile(path.join(recreated.physicalRunRoot, "sentinel.txt"), "unchanged\n", "utf8"); + await expect(validatePreparedRunArtifactPaths(recreated)).rejects.toThrow(/identity changed/i); + expect(await readFile(path.join(recreated.physicalRunRoot, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("rejects a hardlinked latest pointer before an atomic prepared write", async () => { + await withTempProject(async (cwd, root) => { + const prepared = await prepareRunArtifactPaths(cwd, "latest-hardlink"); + const outside = path.join(root, "outside-latest.json"); + await writeFile(outside, "unchanged\n", "utf8"); + try { + await link(outside, prepared.physicalLatestPointer); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + await expect(writePreparedRunLatestPointer(prepared, "mutated\n", "utf8")) + .rejects.toThrow(/hardlink|single-link/i); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + }); + }); + + it("fails verification when referenced evidence is replaced by a symlink", async () => { + await withTempProject(async (cwd, root) => { + await runDryRun({ cwd, dryRun: true, runId: "linked-evidence" }); + const events = path.join(cwd, ".humanish", "runs", "linked-evidence", "events.ndjson"); + const outside = path.join(root, "outside-events.ndjson"); + await rm(events); + await writeFile(outside, "{\"event\":\"outside\"}\n", "utf8"); + await symlink(outside, events); + const verified = await verifyRun(cwd, "linked-evidence"); + expect(verified.ok).toBe(false); + expect(verified.error?.code).toBe("HUMANISH_INVALID_RUN_BUNDLE"); + expect(verified.checks).toContainEqual(expect.objectContaining({ + name: "run storage containment", + ok: false + })); + }); + }); + + it("rejects a hardlinked implicit scenario before creating run output or reading its bytes", async () => { + await withTempProject(async (cwd, root) => { + await mkdir(path.join(cwd, "humanish", "personas"), { recursive: true }); + await mkdir(path.join(cwd, "humanish", "scenarios"), { recursive: true }); + await writeFile(path.join(cwd, "package.json"), "{\"name\":\"safe-project\"}\n", "utf8"); + await writeFile( + path.join(cwd, "humanish", "personas", "synthetic-new-user.yaml"), + "id: safe-user\nname: Safe User\n", + "utf8" + ); + await writeFile( + path.join(cwd, "humanish", "scenarios", "first-run-smoke.yaml"), + "id: safe-smoke\ntitle: Safe Smoke\ngoal: Safe goal\n", + "utf8" + ); + const outside = path.join(root, "outside-secret.yaml"); + await writeFile(outside, "id: SHOULD-NOT-BE-READ\ntitle: secret\n", "utf8"); + try { + await link(outside, path.join(cwd, "humanish", "scenarios", "extra.yaml")); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + + await expect(runDryRun({ cwd, dryRun: true, runId: "unsafe-config" })) + .rejects.toThrow(/single-link/i); + await expect(access(path.join(cwd, ".humanish"))).rejects.toThrow(); + expect(await readFile(outside, "utf8")).toBe("id: SHOULD-NOT-BE-READ\ntitle: secret\n"); + }); + }); + + it("fails closed on symlinked OSS auxiliary storage before any network call", async () => { + await withTempProject(async (cwd, root) => { + const outside = path.join(root, "outside"); + await mkdir(outside); + await writeFile(path.join(outside, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(outside, path.join(cwd, ".humanish")); + await expect(runOssLab({ cwd, repos: ["owner/repo"], limit: 1, runId: "oss-safe" })).rejects.toThrow(/symbolic link/i); + await expect(preflightOssMetaRepoAccess({ assignments: [], cwd, env: {} })).rejects.toThrow(/symbolic link/i); + expect(await readFile(path.join(outside, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + }); + }); + + it("wires every direct run producer through the shared path guard", async () => { + const producers = [ + "run.ts", + "cua-actor-lab.ts", + "shared-world-lab.ts", + "concurrent-shared-world-lab.ts", + "scripted-browser-lab.ts", + "e2b-terminal-lab.ts" + ]; + for (const producer of producers) { + const source = await readFile(path.resolve("src", producer), "utf8"); + expect(source, producer).toContain("prepareRunArtifactPaths"); + } + const metaSource = await readFile(path.resolve("src", "oss-meta-lab.ts"), "utf8"); + expect(metaSource).toContain("bindExistingRunArtifactPaths"); + }); +}); + +describe("init path containment", () => { + it.each([ + { target: "humanish", kind: "directory" }, + { target: ".humanish", kind: "directory" }, + { target: "humanish/personas/synthetic-new-user.yaml", kind: "file" }, + { target: ".gitignore", kind: "file" }, + { target: "package.json", kind: "file" } + ])("rejects a symlinked init target: $target", async ({ target, kind }) => { + await withTempProject(async (cwd, root) => { + const outsideDir = path.join(root, "outside"); + await mkdir(outsideDir); + const sentinel = path.join(outsideDir, "sentinel.txt"); + await writeFile(sentinel, "unchanged\n", "utf8"); + const targetPath = path.join(cwd, target); + await mkdir(path.dirname(targetPath), { recursive: true }); + const source = kind === "directory" ? outsideDir : sentinel; + await symlink(source, targetPath); + + const result = await runInit({ cwd, yes: true }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_UNSAFE_PROJECT_PATH"); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + }); + + it("supports a symlinked cwd while preserving the requested result path", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "humanish-init-symlink-cwd-")); + try { + const realProject = path.join(root, "real-project"); + const linkedProject = path.join(root, "linked-project"); + await mkdir(realProject); + await writeFile(path.join(realProject, "package.json"), "{\"name\":\"fixture\"}\n", "utf8"); + await symlink(realProject, linkedProject); + const result = await runInit({ cwd: linkedProject, yes: true }); + expect(result.ok).toBe(true); + expect(result.cwd).toBe(path.resolve(linkedProject)); + expect(await readFile(path.join(realProject, "humanish", "README.md"), "utf8")).toContain("# Humanish"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/tests/run.test.ts b/tests/run.test.ts index 768bdd0..c4cb595 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -1,16 +1,18 @@ -import { chmod, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { access, chmod, cp, link, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; import { createServer, type Server } from "node:http"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { ACTOR_TRACE_SCHEMA, type ActorTrace } from "../src/actor-contract.js"; import type { CuaLoopResult } from "../src/computer-use.js"; +import { captureGitState } from "../src/core/git-state.js"; import { buildCuaBundle } from "../src/cua-actor-lab.js"; import { renderObserver } from "../src/observer.js"; import { createProgram } from "../src/program.js"; import { startCodexAppServerUi } from "../src/codex-app-server-ui.js"; -import type { E2BDesktopModule } from "../src/e2b-desktop-launch.js"; import { CLEANUP_SCHEMA, PUBLIC_TARGET_CWD, @@ -25,6 +27,15 @@ import { type RunSubjectStateStepRecord } from "../src/run.js"; +const execFileAsync = promisify(execFile); + +function isNodeErrorCode(error: unknown, ...codes: string[]): boolean { + return error instanceof Error + && "code" in error + && typeof error.code === "string" + && codes.includes(error.code); +} + async function withFixtureCopy(callback: (cwd: string) => Promise): Promise { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-run-fixture-")); const tempApp = path.join(tempRoot, "minimal-app"); @@ -236,7 +247,50 @@ describe("dry-run bundles", () => { }); }); - it("cleans run-owned provider resources by exact recorded id", async () => { + it("does not hang a generic dry-run on special .git metadata", async () => { + await withFixtureCopy(async (cwd) => { + await execFileAsync("mkfifo", [path.join(cwd, ".git")]); + + const run = await Promise.race([ + runDryRun({ + cwd, + dryRun: true, + runId: "dryrun-special-git" + }), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("generic dry-run hung on special .git metadata")), 1_000); + }) + ]); + + expect(run.ok).toBe(true); + const bundle = JSON.parse( + await readFile(path.join(cwd, ".humanish/runs/dryrun-special-git/run.json"), "utf8") + ) as { source: { git: { note: string; status: string } } }; + expect(bundle.source.git.status).toBe("unavailable"); + expect(bundle.source.git.note).toBe("Git metadata failed containment validation."); + }); + }); + + it("verifies a built run whose bounded Git capture timed out", async () => { + await withFixtureCopy(async (cwd) => { + const run = await runDryRun({ cwd, dryRun: true, runId: "dryrun-git-timeout" }); + expect(run.ok).toBe(true); + const bundlePath = path.join(cwd, ".humanish/runs/dryrun-git-timeout/run.json"); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as { + source: { git: unknown }; + }; + bundle.source.git = await captureGitState(cwd, { + commandTimeoutMs: 10, + runner: async () => await new Promise(() => {}) + }); + await writeFile(bundlePath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + + const verify = await verifyRun(cwd, "dryrun-git-timeout"); + expect(verify.ok).toBe(true); + }); + }); + + it("refuses to trust a stored provider id without a verified resource lease", async () => { await withFixtureCopy(async (cwd) => { await runDryRun({ cwd, @@ -258,33 +312,122 @@ describe("dry-run bundles", () => { streamId: "stream-001", laneId: "lane-01", createdAt: "2026-01-01T00:00:00.000Z" + }, + { + schema: "humanish.provider-resource.v1", + provider: "e2b-desktop", + kind: "sandbox", + id: "sbx-forged-unknown", + owner: "humanish", + status: "unknown", + createdAt: "2026-01-01T00:00:00.000Z" } ]; await writeFile(bundlePath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); - const killed: string[] = []; + let providerLoads = 0; const cleanup = await cleanupRun(cwd, "cleanup-owned", { now: () => new Date("2026-01-01T00:01:00.000Z"), - loadDesktopModule: async () => ({ - Sandbox: { - kill: async (sandboxId: string) => { - killed.push(sandboxId); - } - } - } as unknown as E2BDesktopModule) + loadDesktopModule: async () => { + providerLoads += 1; + throw new Error("provider module must not be loaded from stored resource metadata"); + } }); expect(cleanup.schema).toBe(CLEANUP_SCHEMA); - expect(cleanup.ok).toBe(true); - expect(killed).toEqual(["sbx-owned-1"]); - expect(cleanup.summary).toMatchObject({ resources: 1, killed: 1, alreadyClean: 0, failed: 0, skipped: 0 }); + expect(cleanup.ok).toBe(false); + expect(providerLoads).toBe(0); + expect(cleanup.resources).toEqual([ + expect.objectContaining({ + id: "sbx-owned-1", + status: "failed", + message: "automatic provider cleanup requires a verified resource lease" + }), + expect.objectContaining({ + id: "sbx-forged-unknown", + status: "failed", + message: "automatic provider cleanup requires a verified resource lease" + }) + ]); + expect(cleanup.summary).toMatchObject({ resources: 2, killed: 0, alreadyClean: 0, failed: 2, skipped: 0 }); const cleanupText = await readFile(path.join(cwd, ".humanish/runs/cleanup-owned/cleanup.json"), "utf8"); expect(cleanupText).toContain("humanish.cleanup-result.v1"); const verify = await verifyRun(cwd, "cleanup-owned"); - expect(verify.ok).toBe(true); - expect(verify.checks.find((check) => check.name === "cleanup receipt")?.ok).toBe(true); + expect(verify.ok).toBe(false); + expect(verify.checks.find((check) => check.name === "cleanup receipt")?.ok).toBe(false); + }); + }); + + it("keeps cleanup bound to the original physical run across a cwd alias retarget", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "humanish-cleanup-alias-")); + const physicalA = path.join(tempRoot, "physical-a"); + const physicalB = path.join(tempRoot, "physical-b"); + const cwdAlias = path.join(tempRoot, "selected-cwd"); + try { + await cp(path.resolve("fixtures/minimal-app"), physicalA, { recursive: true }); + await cp(path.resolve("fixtures/minimal-app"), physicalB, { recursive: true }); + await symlink(physicalA, cwdAlias, "dir"); + await runDryRun({ cwd: cwdAlias, dryRun: true, runId: "cleanup-retarget" }); + await cp(path.join(physicalA, ".humanish"), path.join(physicalB, ".humanish"), { recursive: true }); + const bCleanup = path.join(physicalB, ".humanish/runs/cleanup-retarget/cleanup.json"); + await writeFile(bCleanup, "physical-b-sentinel\n", "utf8"); + + await expect(cleanupRun(cwdAlias, "cleanup-retarget", { + cleanupAdapterResources: async ({ runDir }) => { + expect(runDir).toBe(path.join(await realpath(physicalA), ".humanish/runs/cleanup-retarget")); + await rm(cwdAlias); + await symlink(physicalB, cwdAlias, "dir"); + return []; + } + })).rejects.toThrow(/changed physical destination|identity/i); + + await expect(readFile(bCleanup, "utf8")).resolves.toBe("physical-b-sentinel\n"); + await expect(stat(path.join(physicalA, ".humanish/runs/cleanup-retarget/cleanup.json"))).rejects.toMatchObject({ + code: "ENOENT" + }); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } + }); + + it("rejects a symlinked cleanup receipt without mutating its target", async () => { + await withFixtureCopy(async (cwd) => { + await runDryRun({ cwd, dryRun: true, runId: "cleanup-symlink" }); + const sentinel = path.join(path.dirname(cwd), "cleanup-symlink-sentinel.txt"); + const cleanupPath = path.join(cwd, ".humanish/runs/cleanup-symlink/cleanup.json"); + await writeFile(sentinel, "outside-sentinel\n", "utf8"); + await symlink(sentinel, cleanupPath); + + await expect(cleanupRun(cwd, "cleanup-symlink")).resolves.toMatchObject({ + ok: false, + error: { code: "HUMANISH_INVALID_RUN_BUNDLE" } + }); + await expect(readFile(sentinel, "utf8")).resolves.toBe("outside-sentinel\n"); + }); + }); + + it("rejects a hardlinked cleanup receipt without mutating its target", async () => { + await withFixtureCopy(async (cwd) => { + await runDryRun({ cwd, dryRun: true, runId: "cleanup-hardlink" }); + const sentinel = path.join(path.dirname(cwd), "cleanup-hardlink-sentinel.txt"); + const cleanupPath = path.join(cwd, ".humanish/runs/cleanup-hardlink/cleanup.json"); + await writeFile(sentinel, "outside-sentinel\n", "utf8"); + try { + await link(sentinel, cleanupPath); + } catch (error) { + if (isNodeErrorCode(error, "EPERM", "ENOTSUP", "EOPNOTSUPP")) { + return; + } + throw error; + } + + await expect(cleanupRun(cwd, "cleanup-hardlink")).resolves.toMatchObject({ + ok: false, + error: { code: "HUMANISH_INVALID_RUN_BUNDLE" } + }); + await expect(readFile(sentinel, "utf8")).resolves.toBe("outside-sentinel\n"); }); }); @@ -1663,6 +1806,43 @@ describe("dry-run bundles", () => { }); }); + it("rejects a latest-pointer hardlink planted by a workspace-write actor", async () => { + await withFixtureCopy(async (cwd) => { + const sentinel = path.join(path.dirname(cwd), "actor-latest-sentinel.json"); + const probe = path.join(cwd, "hardlink-support-probe"); + await writeFile(sentinel, "outside-sentinel\n", "utf8"); + try { + await link(sentinel, probe); + await rm(probe); + } catch (error) { + if (isNodeErrorCode(error, "EPERM", "ENOTSUP", "EOPNOTSUPP")) { + return; + } + throw error; + } + const fakeActor = path.join(cwd, "fake-codex-exec-latest-hardlink.mjs"); + await writeFile(fakeActor, [ + "import fs from 'node:fs';", + "const latest = '.humanish/runs/latest.json';", + "fs.rmSync(latest);", + `fs.linkSync(${JSON.stringify(sentinel)}, latest);`, + "process.stdout.write('actor completed after planting hostile latest pointer\\n');" + ].join("\n"), "utf8"); + + await expect(runDryRun({ + cwd, + actor: "codex-exec", + actorCommand: [process.execPath, fakeActor], + runId: "codex-exec-hostile-latest", + simCount: 1, + timeoutMs: 5_000 + })).rejects.toThrow(/hardlink|single-link/i); + + expect(await readFile(sentinel, "utf8")).toBe("outside-sentinel\n"); + await expect(access(path.join(cwd, ".humanish/runs/codex-exec-hostile-latest/transcripts"))).rejects.toThrow(); + }); + }); + it("ignores a bare local Codex exec verdict marker that lacks the per-run nonce", async () => { await withFixtureCopy(async (cwd) => { const fakeActor = path.join(cwd, "fake-codex-exec-forged-verdict-actor.mjs"); @@ -2347,6 +2527,297 @@ describe("dry-run bundles", () => { }); }); + it("does not derive Codex trust from a repo-controlled git commondir", async () => { + await withFixtureCopy(async (cwd) => { + const root = path.dirname(cwd); + const trustedProject = path.join(root, "already-trusted"); + const fakeGitDir = path.join(cwd, ".fakegit"); + const codexHome = path.join(root, "codex-home"); + const fakeBin = path.join(root, "fake-bin"); + const fakeCodex = path.join(fakeBin, "codex"); + const spawnedSentinel = path.join(root, "forged-trust-actor-started"); + const previousCodexHome = process.env.CODEX_HOME; + const previousActorCommand = process.env.HUMANISH_CODEX_ACTOR_COMMAND; + const previousPath = process.env.PATH; + await mkdir(path.join(trustedProject, ".git"), { recursive: true }); + await mkdir(fakeGitDir, { recursive: true }); + await writeFile(path.join(cwd, ".git"), "gitdir: .fakegit\n", "utf8"); + await writeFile( + path.join(fakeGitDir, "commondir"), + `${path.relative(fakeGitDir, path.join(trustedProject, ".git"))}\n`, + "utf8" + ); + await writeFile(path.join(fakeGitDir, "gitdir"), `${path.join(cwd, ".git")}\n`, "utf8"); + await mkdir(codexHome, { recursive: true }); + await writeFile( + path.join(codexHome, "config.toml"), + [ + `[projects."${trustedProject.replace(/["\\]/g, "\\$&")}"]`, + 'trust_level = "trusted"', + `[projects."${cwd.replace(/["\\]/g, "\\$&")}"]`, + 'trust_level = "trusted"', + "" + ].join("\n"), + "utf8" + ); + await mkdir(fakeBin, { recursive: true }); + await writeFile( + fakeCodex, + `#!/usr/bin/env sh\ntouch ${JSON.stringify(spawnedSentinel)}\nprintf 'forged trust actor started\\n'\n`, + "utf8" + ); + await chmod(fakeCodex, 0o755); + delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + process.env.CODEX_HOME = codexHome; + process.env.PATH = previousPath ? `${fakeBin}${path.delimiter}${previousPath}` : fakeBin; + + try { + const result = await runDryRun({ + cwd, + actor: "codex-tui", + runId: "codex-forged-commondir", + simCount: 1, + timeoutMs: 5_000 + }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_LOCAL_CODEX_TUI_FAILED"); + await expect(access(spawnedSentinel)).rejects.toThrow(); + const bundle = JSON.parse( + await readFile(path.join(cwd, ".humanish/runs/codex-forged-commondir/run.json"), "utf8") + ) as { events: Array<{ type: string }>; streams: Array<{ status: string }> }; + expect(bundle.streams[0]?.status).toBe("blocked"); + expect(bundle.events.map((event) => event.type)).not.toContain("actor.spawned"); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousActorCommand === undefined) delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + else process.env.HUMANISH_CODEX_ACTOR_COMMAND = previousActorCommand; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); + }); + + it.each(["symlink", "hardlink", "fifo"] as const)( + "blocks unsafe %s .git metadata even when the exact project is trusted", + async (kind) => { + await withFixtureCopy(async (cwd) => { + const root = path.dirname(cwd); + const codexHome = path.join(root, `codex-home-${kind}`); + const fakeBin = path.join(root, `fake-bin-${kind}`); + const fakeCodex = path.join(fakeBin, "codex"); + const spawnedSentinel = path.join(root, `unsafe-${kind}-actor-started`); + const previousCodexHome = process.env.CODEX_HOME; + const previousActorCommand = process.env.HUMANISH_CODEX_ACTOR_COMMAND; + const previousPath = process.env.PATH; + if (kind === "symlink") { + const outsideGit = path.join(root, "outside-git"); + await mkdir(outsideGit); + await symlink(outsideGit, path.join(cwd, ".git"), "dir"); + } else if (kind === "hardlink") { + const outsideGitFile = path.join(root, "outside-git-file"); + await writeFile(outsideGitFile, "gitdir: .fakegit\n", "utf8"); + try { + await link(outsideGitFile, path.join(cwd, ".git")); + } catch (error) { + if (isNodeErrorCode(error, "EPERM", "ENOTSUP", "EOPNOTSUPP")) return; + throw error; + } + } else { + await execFileAsync("mkfifo", [path.join(cwd, ".git")]); + } + await mkdir(codexHome); + await writeFile( + path.join(codexHome, "config.toml"), + `[projects."${cwd.replace(/["\\]/g, "\\$&")}"]\ntrust_level = "trusted"\n`, + "utf8" + ); + await mkdir(fakeBin); + await writeFile( + fakeCodex, + `#!/usr/bin/env sh\ntouch ${JSON.stringify(spawnedSentinel)}\nprintf 'unsafe metadata actor started\\n'\n`, + "utf8" + ); + await chmod(fakeCodex, 0o755); + delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + process.env.CODEX_HOME = codexHome; + process.env.PATH = previousPath ? `${fakeBin}${path.delimiter}${previousPath}` : fakeBin; + + try { + const result = await runDryRun({ + cwd, + actor: "codex-tui", + runId: `codex-unsafe-git-${kind}`, + simCount: 1, + timeoutMs: 5_000 + }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_LOCAL_CODEX_TUI_FAILED"); + await expect(access(spawnedSentinel)).rejects.toThrow(); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousActorCommand === undefined) delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + else process.env.HUMANISH_CODEX_ACTOR_COMMAND = previousActorCommand; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); + } + ); + + it.each(["commondir-symlink", "gitdir-hardlink", "gitdir-fifo", "gitdir-mismatch"] as const)( + "blocks forged linked-worktree admin metadata with unsafe %s even when both roots are trusted", + async (kind) => { + await withFixtureCopy(async (cwd) => { + const root = path.dirname(cwd); + const canonical = path.join(root, `canonical-${kind}`); + const commonGit = path.join(canonical, ".git"); + const adminGit = path.join(commonGit, "worktrees", "forged"); + const codexHome = path.join(root, `codex-home-admin-${kind}`); + const fakeBin = path.join(root, `fake-bin-admin-${kind}`); + const fakeCodex = path.join(fakeBin, "codex"); + const spawnedSentinel = path.join(root, `unsafe-admin-${kind}-actor-started`); + const previousCodexHome = process.env.CODEX_HOME; + const previousActorCommand = process.env.HUMANISH_CODEX_ACTOR_COMMAND; + const previousPath = process.env.PATH; + + await mkdir(adminGit, { recursive: true }); + await writeFile(path.join(cwd, ".git"), `gitdir: ${adminGit}\n`, "utf8"); + + if (kind === "commondir-symlink") { + const outsideCommonDir = path.join(root, "outside-commondir"); + await writeFile(outsideCommonDir, "../..\n", "utf8"); + await symlink(outsideCommonDir, path.join(adminGit, "commondir")); + await writeFile(path.join(adminGit, "gitdir"), `${path.join(cwd, ".git")}\n`, "utf8"); + } else { + await writeFile(path.join(adminGit, "commondir"), "../..\n", "utf8"); + if (kind === "gitdir-hardlink") { + const outsideBackPointer = path.join(root, "outside-gitdir-backpointer"); + await writeFile(outsideBackPointer, `${path.join(cwd, ".git")}\n`, "utf8"); + try { + await link(outsideBackPointer, path.join(adminGit, "gitdir")); + } catch (error) { + if (isNodeErrorCode(error, "EPERM", "ENOTSUP", "EOPNOTSUPP")) return; + throw error; + } + } else if (kind === "gitdir-fifo") { + await execFileAsync("mkfifo", [path.join(adminGit, "gitdir")]); + } else { + await writeFile(path.join(adminGit, "gitdir"), `${path.join(root, "different", ".git")}\n`, "utf8"); + } + } + + await mkdir(codexHome); + await writeFile( + path.join(codexHome, "config.toml"), + [ + `[projects."${canonical.replace(/["\\]/g, "\\$&")}"]`, + 'trust_level = "trusted"', + `[projects."${cwd.replace(/["\\]/g, "\\$&")}"]`, + 'trust_level = "trusted"', + "" + ].join("\n"), + "utf8" + ); + await mkdir(fakeBin); + await writeFile( + fakeCodex, + `#!/usr/bin/env sh\ntouch ${JSON.stringify(spawnedSentinel)}\nprintf 'unsafe admin metadata actor started\\n'\n`, + "utf8" + ); + await chmod(fakeCodex, 0o755); + delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + process.env.CODEX_HOME = codexHome; + process.env.PATH = previousPath ? `${fakeBin}${path.delimiter}${previousPath}` : fakeBin; + + try { + const result = await Promise.race([ + runDryRun({ + cwd, + actor: "codex-tui", + runId: `codex-unsafe-admin-${kind}`, + simCount: 1, + timeoutMs: 5_000 + }), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`trust preflight hung on ${kind}`)), 1_000); + }) + ]); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_LOCAL_CODEX_TUI_FAILED"); + await expect(access(spawnedSentinel)).rejects.toThrow(); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousActorCommand === undefined) delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + else process.env.HUMANISH_CODEX_ACTOR_COMMAND = previousActorCommand; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); + } + ); + + it("inherits Codex trust only through verified Git linked-worktree metadata", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "humanish-codex-linked-worktree-")); + const canonical = path.join(root, "canonical"); + const linked = path.join(root, "linked"); + const codexHome = path.join(root, "codex-home"); + const fakeBin = path.join(root, "fake-bin"); + const fakeCodex = path.join(fakeBin, "codex"); + const spawnedSentinel = path.join(root, "linked-worktree-actor-started"); + const previousCodexHome = process.env.CODEX_HOME; + const previousActorCommand = process.env.HUMANISH_CODEX_ACTOR_COMMAND; + const previousPath = process.env.PATH; + try { + await mkdir(canonical); + await execFileAsync("git", ["init", "--initial-branch=main"], { cwd: canonical }); + await execFileAsync("git", ["config", "user.email", "humanish@example.test"], { cwd: canonical }); + await execFileAsync("git", ["config", "user.name", "Humanish Test"], { cwd: canonical }); + await writeFile(path.join(canonical, "package.json"), '{"name":"linked-worktree-fixture"}\n', "utf8"); + await writeFile(path.join(canonical, ".gitignore"), ".humanish/\n", "utf8"); + await execFileAsync("git", ["add", "package.json", ".gitignore"], { cwd: canonical }); + await execFileAsync("git", ["commit", "-m", "fixture"], { cwd: canonical }); + await execFileAsync("git", ["worktree", "add", "-b", "linked-proof", linked], { cwd: canonical }); + + await mkdir(codexHome); + await writeFile( + path.join(codexHome, "config.toml"), + `[projects."${canonical.replace(/["\\]/g, "\\$&")}"]\ntrust_level = "trusted"\n`, + "utf8" + ); + await mkdir(fakeBin); + await writeFile( + fakeCodex, + `#!/usr/bin/env sh\ntouch ${JSON.stringify(spawnedSentinel)}\nprintf 'verified linked worktree actor started\\n'\nprintf 'HUMANISH_ACTOR_VERDICT=passed HUMANISH_ACTOR_NONCE=%s\\n' "$HUMANISH_ACTOR_VERDICT_NONCE"\n`, + "utf8" + ); + await chmod(fakeCodex, 0o755); + delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + process.env.CODEX_HOME = codexHome; + process.env.PATH = previousPath ? `${fakeBin}${path.delimiter}${previousPath}` : fakeBin; + + const result = await runDryRun({ + cwd: linked, + actor: "codex-tui", + runId: "codex-linked-worktree", + simCount: 1, + timeoutMs: 5_000 + }); + expect(result.ok).toBe(true); + await expect(access(spawnedSentinel)).resolves.toBeUndefined(); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousActorCommand === undefined) delete process.env.HUMANISH_CODEX_ACTOR_COMMAND; + else process.env.HUMANISH_CODEX_ACTOR_COMMAND = previousActorCommand; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await rm(root, { force: true, recursive: true }); + } + }); + it("allows the default Codex TUI actor when the exact project root is trusted", async () => { await withFixtureCopy(async (cwd) => { const codexHome = path.join(cwd, ".codex-home"); diff --git a/tests/scripted-browser-actor.test.ts b/tests/scripted-browser-actor.test.ts index f76766f..f07de71 100644 --- a/tests/scripted-browser-actor.test.ts +++ b/tests/scripted-browser-actor.test.ts @@ -1,5 +1,5 @@ import { createServer, type Server } from "node:http"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, link, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -39,6 +39,7 @@ interface FakeAppOptions { gotoError?: string; /** Make goto hang forever (wall-clock timeout). */ gotoHangs?: boolean; + screenshotHook?: () => Promise; } function makeFakeBrowser(options: FakeAppOptions = {}): { browser: ScriptedBrowserLike; state: { url: string; body: string } } { @@ -81,8 +82,9 @@ function makeFakeBrowser(options: FakeAppOptions = {}): { browser: ScriptedBrows throw new Error(`Timeout waiting for text ${String(needle)}`); }, screenshot: async ({ path: screenshotPath }) => { - await writeFile(screenshotPath, PNG_1X1); - return undefined; + await options.screenshotHook?.(); + if (screenshotPath) await writeFile(screenshotPath, PNG_1X1); + return PNG_1X1; }, url: () => state.url, evaluate: async () => state.body as unknown as T @@ -300,6 +302,127 @@ describe("runScriptedBrowserSession (completion semantics through the REAL step expect(result.trace.counts.screenshots).toBe(0); }); + it("rejects path-shaped surface and step ids before browser launch", async () => { + let launches = 0; + const launchBrowser = async (): Promise => { + launches += 1; + return makeFakeBrowser().browser; + }; + await expect(runScriptedBrowserSession({ + appUrl: "http://127.0.0.1:9/", + journey: demoJourney(), + surface: { ...surface, id: "../escape" } as unknown as typeof surface, + persona, + timeoutMs: 5_000, + artifactRoot, + launchBrowser + })).rejects.toThrow(/path segment/i); + const maliciousJourney = demoJourney(); + maliciousJourney.steps[0] = { ...maliciousJourney.steps[0]!, id: "nested\\escape" }; + await expect(runScriptedBrowserSession({ + appUrl: "http://127.0.0.1:9/", + journey: maliciousJourney, + surface, + persona, + timeoutMs: 5_000, + artifactRoot, + launchBrowser + })).rejects.toThrow(/path segment/i); + expect(launches).toBe(0); + }); + + it("rejects generated root aliases before browser launch", async () => { + const selected = path.join(artifactRoot, "selected"); + const outside = path.join(artifactRoot, "outside"); + await mkdir(selected); + await mkdir(outside); + await writeFile(path.join(outside, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(outside, path.join(selected, "screenshots"), "dir"); + let launches = 0; + await expect(runScriptedBrowserSession({ + appUrl: "http://127.0.0.1:9/", + journey: demoJourney(), + surface, + persona, + timeoutMs: 5_000, + artifactRoot: selected, + launchBrowser: async () => { + launches += 1; + return makeFakeBrowser().browser; + } + })).rejects.toThrow(/symbolic links/i); + expect(launches).toBe(0); + expect(await readFile(path.join(outside, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + }); + + it("retains selected-root identity across browser launch", async () => { + const first = path.join(artifactRoot, "first"); + const second = path.join(artifactRoot, "second"); + const alias = path.join(artifactRoot, "selected-alias"); + await mkdir(first); + await mkdir(second); + await writeFile(path.join(second, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(first, alias, "dir"); + const fake = makeFakeBrowser(); + let closes = 0; + const browser: ScriptedBrowserLike = { + ...fake.browser, + close: async () => { closes += 1; } + }; + + await expect(runScriptedBrowserSession({ + appUrl: "http://127.0.0.1:9/", + journey: demoJourney(), + surface, + persona, + timeoutMs: 5_000, + artifactRoot: alias, + launchBrowser: async () => { + await rm(alias); + await symlink(second, alias, "dir"); + return browser; + } + })).rejects.toThrow(/changed physical destination/i); + expect(closes).toBe(1); + expect(await readFile(path.join(second, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + await expect(access(path.join(second, "traces", "desktop.json"))).rejects.toThrow(); + }); + + it("does not let an awaitable screenshot hook redirect bytes through a hardlink", async () => { + await withHttpServer(async (appUrl) => { + const outside = path.join(artifactRoot, "outside-screenshot.png"); + const target = path.join(artifactRoot, "screenshots", "desktop-step-01-load.png"); + await writeFile(outside, "unchanged\n", "utf8"); + let planted = false; + const { browser } = makeFakeBrowser({ + bodyAfterClick: "Welcome aboard", + screenshotHook: async () => { + if (planted) return; + planted = true; + try { + await link(outside, target); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + } + }); + const result = await runScriptedBrowserSession({ + appUrl, + journey: demoJourney(), + surface, + persona, + timeoutMs: 10_000, + artifactRoot, + launchBrowser: async () => browser + }); + expect(result.status).toBe("failed"); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + expect(await readFile(target, "utf8")).toBe("unchanged\n"); + }); + }); + it("redacts provisioned subject URLs from persisted evidence while driving the raw app URL", async () => { await withHttpServer(async (appUrl) => { const { browser, state } = makeFakeBrowser({ bodyAfterClick: "Welcome aboard" }); diff --git a/tests/scripted-browser-lab.test.ts b/tests/scripted-browser-lab.test.ts index 8b2f5be..4e337e9 100644 --- a/tests/scripted-browser-lab.test.ts +++ b/tests/scripted-browser-lab.test.ts @@ -1,6 +1,6 @@ import { CommanderError } from "commander"; import { createServer, type Server } from "node:http"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { AddressInfo } from "node:net"; @@ -19,7 +19,7 @@ import { runScriptedBrowserLab, type ScriptedBrowserLabHooks } from "../src/scripted-browser-lab.js"; -import type { ScriptedBrowserLike, ScriptedLocatorLike, ScriptedPageLike } from "../src/scripted-browser-actor.js"; +import type { ScriptedBrowserLike, ScriptedBrowserSessionResult, ScriptedLocatorLike, ScriptedPageLike } from "../src/scripted-browser-actor.js"; const ROOT = process.cwd(); const PNG_1X1 = Buffer.from( @@ -65,8 +65,8 @@ function makeFakeBrowser(options: { throw new Error(`Timeout waiting for text ${String(needle)}`); }, screenshot: async ({ path: screenshotPath }) => { - await writeFile(screenshotPath, PNG_1X1); - return undefined; + if (screenshotPath) await writeFile(screenshotPath, PNG_1X1); + return PNG_1X1; }, url: () => state.url, evaluate: async () => state.body as unknown as T @@ -629,6 +629,59 @@ describe("runScriptedBrowserLab", () => { expect(bundle.review.verdict).toBe("fail"); }); + it("rejects callback-returned traversal artifacts before parent bundle finalization", async () => { + await writeCommittedScenario(cwd); + const outside = path.join(path.dirname(cwd), "scripted-outside-sentinel.txt"); + await writeFile(outside, "UNCHANGED", "utf8"); + const runId = "unsafe-hook-result"; + const hooks: ScriptedBrowserLabHooks = { + browserCommand: "/synthetic/browser", + runSession: async (options) => ({ + status: "passed", + completionReason: "goal_satisfied", + reason: "synthetic malicious callback result", + capture: { + capturedAt: "2026-07-13T00:00:00.000Z", + durationMs: 1, + ok: true, + reason: "synthetic malicious callback result", + steps: [], + surface: options.surface, + tracePath: "../../scripted-outside-sentinel.txt" + }, + trace: { + schema: ACTOR_TRACE_SCHEMA, + provider: "browser-persona", + protocol: "scripted-steps", + lane: "scripted-browser", + persona: options.persona, + redaction: { status: "passed", screenshots: "none", notes: "synthetic" }, + startedAt: "2026-07-13T00:00:00.000Z", + completedAt: "2026-07-13T00:00:00.000Z", + status: "passed", + completionReason: "goal_satisfied", + summary: "synthetic", + capabilities: SCRIPTED_BROWSER_CAPABILITIES, + actions: [], + tokenUsage: { input: 0, output: 0, total: 0, costUsd: 0 } + } + } as unknown as ScriptedBrowserSessionResult) + }; + + await expect(runScriptedBrowserLab({ + cwd, + config: scriptedConfig({ count: 1, mode: "live" }), + dryRun: false, + hooks, + runId + })).rejects.toThrow(/unsafe artifact path/i); + expect(await readFile(outside, "utf8")).toBe("UNCHANGED"); + await expect(stat(path.join(cwd, ".humanish", "runs", runId, "run.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(path.join(cwd, ".humanish", "runs", "latest.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + describe("scenario.ref consumption (fail-closed)", () => { it.each([ ["missing scenario file", "does-not-exist", undefined], @@ -679,6 +732,35 @@ describe("runScriptedBrowserLab", () => { expect(result.scenario?.source).toBe("custom/journey.yaml"); expect(result.scenario?.sourceDigest).toBe(digestText(text)); }); + + it("rejects a symlinked scenario before any browser hook runs or outside bytes enter output", async () => { + const outsideScenario = path.join(path.dirname(cwd), "outside-scenario.yaml"); + const secretMarker = "OUTSIDE-SCENARIO-SECRET"; + const scenarioText = await readFile(path.join(ROOT, "humanish", "scenarios", "scripted-first-run.yaml"), "utf8"); + await writeFile(outsideScenario, `${scenarioText}\n# ${secretMarker}\n`, "utf8"); + await mkdir(path.join(cwd, "humanish", "scenarios"), { recursive: true }); + await symlink(outsideScenario, path.join(cwd, "humanish", "scenarios", "linked.yaml")); + let hookCalled = false; + + const result = await runScriptedBrowserLab({ + cwd, + config: scriptedConfig({ ref: "linked", mode: "live" }), + dryRun: false, + hooks: { + browserCommand: "/synthetic/browser", + runSession: async () => { + hookCalled = true; + throw new Error("must not run"); + } + } + }); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_SCRIPTED_LAB_SCENARIO_INVALID"); + expect(result.error?.message).not.toContain(secretMarker); + expect(hookCalled).toBe(false); + await expect(readdir(path.join(cwd, ".humanish", "runs"))).rejects.toThrow(); + }); }); it("rejects a non-scripted actor at the engine even if a config bypasses the parser", async () => { diff --git a/tests/selected-output-paths.test.ts b/tests/selected-output-paths.test.ts new file mode 100644 index 0000000..554559f --- /dev/null +++ b/tests/selected-output-paths.test.ts @@ -0,0 +1,209 @@ +import { link, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + assertPreparedSelectedOutputDirectory, + prepareContainedOutputDirectory, + prepareContainedOutputFile, + prepareManagedHumanishOutputDirectory, + prepareSelectedOutputDirectory, + prepareSelectedOutputFile, + readContainedRegularFile, + writeContainedOutputFile, + writePreparedSelectedOutputFile +} from "../src/selected-output-paths.js"; + +describe("selected output path containment", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "humanish-selected-output-")); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("preserves requested paths while mapping an in-base selection through a symlinked cwd", async () => { + const physicalProject = path.join(root, "physical-project"); + const cwdAlias = path.join(root, "project-alias"); + await mkdir(physicalProject); + await symlink(physicalProject, cwdAlias, "dir"); + + const preparedRoot = await prepareSelectedOutputDirectory(cwdAlias, ".humanish/codex-app-server-ui"); + const preparedState = await prepareSelectedOutputFile(cwdAlias, ".humanish/codex-app-server-ui/state.json"); + await writePreparedSelectedOutputFile(preparedState, "state\n", "utf8"); + + expect(preparedRoot.requestedPath).toBe(path.join(cwdAlias, ".humanish/codex-app-server-ui")); + expect(preparedRoot.physicalPath).toBe(await realpath(path.join(physicalProject, ".humanish/codex-app-server-ui"))); + expect(preparedState.requestedPath).toBe(path.join(cwdAlias, ".humanish/codex-app-server-ui/state.json")); + expect(await readFile(path.join(physicalProject, ".humanish/codex-app-server-ui/state.json"), "utf8")).toBe("state\n"); + }); + + it("binds explicit absolute and lexically outside relative directory aliases to their physical target", async () => { + const base = path.join(root, "base"); + const target = path.join(root, "selected-target"); + const absoluteAlias = path.join(root, "absolute-alias"); + const outsideAlias = path.join(root, "outside-alias"); + await mkdir(base); + await mkdir(target); + await symlink(target, absoluteAlias, "dir"); + await symlink(target, outsideAlias, "dir"); + + const absolute = await prepareSelectedOutputDirectory(base, absoluteAlias); + const outside = await prepareSelectedOutputDirectory(base, "../outside-alias"); + await writeContainedOutputFile(absolute, "codex-app-server/summary.json", "{}\n", "utf8"); + + expect(absolute.requestedPath).toBe(absoluteAlias); + expect(outside.requestedPath).toBe(outsideAlias); + expect(absolute.physicalPath).toBe(await realpath(target)); + expect(outside.physicalPath).toBe(await realpath(target)); + expect(await readFile(path.join(target, "codex-app-server/summary.json"), "utf8")).toBe("{}\n"); + }); + + it("allows a canonical OS alias ancestor such as /tmp while guarding the selected child", async () => { + const selected = path.join("/tmp", `humanish-selected-${path.basename(root)}`); + try { + const prepared = await prepareSelectedOutputDirectory(root, selected); + await writeContainedOutputFile(prepared, "proof.txt", "ok\n", "utf8"); + expect(await readFile(path.join(prepared.physicalPath, "proof.txt"), "utf8")).toBe("ok\n"); + } finally { + await rm(selected, { force: true, recursive: true }); + } + }); + + it("treats equivalent relative and absolute caller selections as the same authority", async () => { + const project = path.join(root, "project"); + const target = path.join(root, "target"); + await mkdir(project); + await mkdir(target); + await symlink(target, path.join(project, "relative-alias"), "dir"); + + const relative = await prepareSelectedOutputDirectory(project, "relative-alias"); + const absolute = await prepareSelectedOutputDirectory(project, path.join(project, "relative-alias")); + expect(relative.physicalPath).toBe(await realpath(target)); + expect(absolute.physicalPath).toBe(relative.physicalPath); + }); + + it("rejects a managed default alias even though the same explicit selection is authorized", async () => { + const project = path.join(root, "project"); + const outside = path.join(root, "outside"); + const sentinel = path.join(outside, "sentinel.txt"); + await mkdir(project); + await mkdir(outside); + await writeFile(sentinel, "unchanged\n", "utf8"); + await symlink(outside, path.join(project, ".humanish"), "dir"); + + const explicit = await prepareSelectedOutputDirectory(project, ".humanish/codex-app-server-ui"); + expect(explicit.physicalPath).toBe(await realpath(path.join(outside, "codex-app-server-ui"))); + await expect(prepareManagedHumanishOutputDirectory(project, "codex-app-server-ui")) + .rejects.toThrow(/symbolic links/i); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + + it("rejects generated child and file symlinks plus an exact selected file leaf", async () => { + const selectedRoot = path.join(root, "selected"); + const outside = path.join(root, "outside"); + const sentinel = path.join(outside, "sentinel.txt"); + await mkdir(selectedRoot); + await mkdir(outside); + await writeFile(sentinel, "unchanged\n", "utf8"); + const prepared = await prepareSelectedOutputDirectory(root, selectedRoot); + + await symlink(outside, path.join(selectedRoot, "codex-app-server"), "dir"); + await expect(prepareContainedOutputDirectory(prepared, "codex-app-server")) + .rejects.toThrow(/symbolic links/i); + await rm(path.join(selectedRoot, "codex-app-server")); + + await mkdir(path.join(selectedRoot, "codex-app-server")); + await symlink(sentinel, path.join(selectedRoot, "codex-app-server", "summary.json")); + await expect(prepareContainedOutputFile(prepared, "codex-app-server/summary.json")) + .rejects.toThrow(/regular files/i); + + const selectedState = path.join(root, "selected-state.json"); + await symlink(sentinel, selectedState); + await expect(prepareSelectedOutputFile(root, selectedState)).rejects.toThrow(/regular files/i); + expect(await readFile(sentinel, "utf8")).toBe("unchanged\n"); + }); + + it("reads only lexically and physically contained regular files", async () => { + const selectedRoot = path.join(root, "run"); + const sibling = path.join(root, "run-sibling"); + const outside = path.join(root, "outside"); + await mkdir(selectedRoot); + await mkdir(sibling); + await mkdir(outside); + await writeFile(path.join(selectedRoot, "ordinary.txt"), "ordinary\n", "utf8"); + await writeFile(path.join(sibling, "secret.txt"), "sibling\n", "utf8"); + await writeFile(path.join(outside, "secret.txt"), "outside\n", "utf8"); + await symlink(path.join(outside, "secret.txt"), path.join(selectedRoot, "leaf-link.txt")); + await symlink(outside, path.join(selectedRoot, "dir-link"), "dir"); + + const prepared = await prepareSelectedOutputDirectory(root, selectedRoot); + expect((await readContainedRegularFile(prepared, "ordinary.txt"))?.toString("utf8")).toBe("ordinary\n"); + expect(await readContainedRegularFile(prepared, "../run-sibling/secret.txt")).toBeNull(); + expect(await readContainedRegularFile(prepared, "leaf-link.txt")).toBeNull(); + expect(await readContainedRegularFile(prepared, "dir-link/secret.txt")).toBeNull(); + }); + + it("rejects a selected-root alias retarget and same-path directory recreation", async () => { + const first = path.join(root, "first"); + const second = path.join(root, "second"); + const alias = path.join(root, "selected"); + await mkdir(first); + await mkdir(second); + await writeFile(path.join(second, "sentinel.txt"), "unchanged\n", "utf8"); + await symlink(first, alias, "dir"); + const preparedAlias = await prepareSelectedOutputDirectory(root, alias); + + await rm(alias); + await symlink(second, alias, "dir"); + await expect(assertPreparedSelectedOutputDirectory(preparedAlias)).rejects.toThrow(/changed physical destination/i); + await expect(writeContainedOutputFile(preparedAlias, "sentinel.txt", "mutated\n", "utf8")) + .rejects.toThrow(/changed physical destination/i); + expect(await readFile(path.join(second, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + + const recreated = path.join(root, "recreated"); + await mkdir(recreated); + const preparedRecreated = await prepareSelectedOutputDirectory(root, recreated); + await rm(recreated, { recursive: true }); + await mkdir(recreated); + await writeFile(path.join(recreated, "sentinel.txt"), "unchanged\n", "utf8"); + await expect(assertPreparedSelectedOutputDirectory(preparedRecreated)).rejects.toThrow(/identity changed/i); + await expect(writeContainedOutputFile(preparedRecreated, "sentinel.txt", "mutated\n", "utf8")) + .rejects.toThrow(/identity changed/i); + expect(await readFile(path.join(recreated, "sentinel.txt"), "utf8")).toBe("unchanged\n"); + }); + + it("rejects hardlinked inputs and atomically replaces an ordinary output file", async () => { + const selectedRoot = path.join(root, "selected-hardlink"); + const outside = path.join(root, "outside-hardlink.txt"); + await mkdir(selectedRoot); + await writeFile(outside, "unchanged\n", "utf8"); + const hardlink = path.join(selectedRoot, "hardlink.txt"); + try { + await link(outside, hardlink); + } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (["EPERM", "ENOTSUP", "EOPNOTSUPP"].includes(code)) return; + throw error; + } + const prepared = await prepareSelectedOutputDirectory(root, selectedRoot); + expect(await readContainedRegularFile(prepared, "hardlink.txt")).toBeNull(); + await expect(writeContainedOutputFile(prepared, "hardlink.txt", "mutated\n", "utf8")) + .rejects.toThrow(/hardlinks|single-link/i); + await expect(prepareSelectedOutputFile(root, hardlink)).rejects.toThrow(/hardlinks|single-link/i); + expect(await readFile(outside, "utf8")).toBe("unchanged\n"); + + const ordinary = path.join(selectedRoot, "ordinary.txt"); + await writeFile(ordinary, "before\n", "utf8"); + const before = await stat(ordinary); + await writeContainedOutputFile(prepared, "ordinary.txt", "after\n", "utf8"); + const after = await stat(ordinary); + expect(await readFile(ordinary, "utf8")).toBe("after\n"); + expect(after.ino).not.toBe(before.ino); + }); +}); diff --git a/tests/shared-world-lab.test.ts b/tests/shared-world-lab.test.ts index b719089..9194ab4 100644 --- a/tests/shared-world-lab.test.ts +++ b/tests/shared-world-lab.test.ts @@ -789,6 +789,29 @@ describe("runSharedWorldLab (local-tree route: subject.source: local-tree)", () expect(result.error?.message).toContain("subject.serve"); }); + it("engine re-enforcement rejects path-shaped role ids before loading a desktop", async () => { + const valid = sharedWorldConfig(); + const actor = valid.actors[0]!; + const lanes = actor.lanes!.map((lane, index) => index === 0 ? { ...lane, id: "../escape" } : lane); + const broken: LabConfig = { ...valid, actors: [{ ...actor, lanes }] }; + let desktopLoads = 0; + const result = await runSharedWorldLab({ + cwd, + config: broken, + dryRun: false, + hooks: { + loadDesktopModule: async () => { + desktopLoads += 1; + throw new Error("must not load"); + } + } + }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("HUMANISH_SHARED_WORLD_LAB_INVALID"); + expect(result.runId).toBe("not-created"); + expect(desktopLoads).toBe(0); + }); + it("engine re-enforcement: a local-tree config with the wrong execution.target fails closed", async () => { const valid = localTreeSharedWorldConfig(); const executionWithoutTarget: Record = { ...valid.execution }; diff --git a/tests/source-archive.test.ts b/tests/source-archive.test.ts index be75525..29ef12e 100644 --- a/tests/source-archive.test.ts +++ b/tests/source-archive.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { link, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; @@ -280,6 +280,30 @@ describe("symlinks", () => { const afterRetarget = createLocalTreeArchive(root).archiveSha256; expect(afterRetarget).not.toBe(beforeContentChange); }); + + it.each(["git", "fallback"] as const)( + "rejects a hardlinked outside file in %s enumeration before creating an archive", + async (mode) => { + const root = await makeTempRoot(`hardlink-root-${mode}`); + const outsideDir = await makeTempRoot(`hardlink-outside-${mode}`); + const outputDir = await makeTempRoot(`hardlink-output-${mode}`); + const outsideSecret = path.join(outsideDir, "outside-secret.txt"); + const outputPath = path.join(outputDir, "source.tar.gz"); + const secretBytes = "OUTSIDE_HARDLINK_SECRET_7f4b2e"; + await writeFile(outsideSecret, `${secretBytes}\n`, "utf8"); + await writeFile(path.join(root, "regular.txt"), "regular\n", "utf8"); + await link(outsideSecret, path.join(root, "linked-source.txt")); + if (mode === "git") { + runGit(root, ["init", "-q", "."]); + } + + expect(() => createLocalTreeArchive(root, { outputPath })).toThrow(/hardlinked source files/i); + await expect(stat(outputPath)).rejects.toMatchObject({ code: "ENOENT" }); + const archiveBytes = await readFile(outputPath).catch(() => Buffer.alloc(0)); + expect(archiveBytes.includes(Buffer.from(secretBytes, "utf8"))).toBe(false); + expect(await readFile(outsideSecret, "utf8")).toBe(`${secretBytes}\n`); + }, + ); }); describe("digest stability", () => { diff --git a/tests/terminal-product-adapter-seam.test.ts b/tests/terminal-product-adapter-seam.test.ts index dbd5d3f..811a8de 100644 --- a/tests/terminal-product-adapter-seam.test.ts +++ b/tests/terminal-product-adapter-seam.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -246,6 +246,68 @@ describe("terminal-product extension seam (SLICE 4 conformance — thin adapter, expect(verified.ok).toBe(true); // the bundle still verifies — the seam stayed fail-closed }); + it("drops an adapter candidate with an escaping evidence path and leaves outside files unchanged", async () => { + const outsideSentinel = path.join(cwd, "outside-sentinel.txt"); + const original = "outside must stay unchanged\n"; + await writeFile(outsideSentinel, original, "utf8"); + const hooks = passingHooks({ + deriveFeedback: (ctx) => { + const [candidate] = exampleAdapterFeedback(ctx); + if (!candidate) return []; + return [{ + ...candidate, + evidence: [{ path: "../../outside-sentinel.txt", kind: "log", note: "must be rejected" }] + }]; + } + }); + + const result = await runTerminalProductLab({ cwd, config: liveConfig(), dryRun: false, open: false, hooks }); + const bundle = JSON.parse(await readFile(path.join(cwd, ".humanish", "runs", result.runId, "run.json"), "utf8")) as RunBundle; + + expect(bundle.feedbackCandidates).toHaveLength(0); + expect(result.warnings.some((warning) => warning.includes("feedback-candidate.v1"))).toBe(true); + expect(await readFile(outsideSentinel, "utf8")).toBe(original); + + const verified = await verifyRun(cwd, result.runId); + expect(verified.ok).toBe(true); + expect(verified.checks.find((check) => check.name === "local evidence artifacts exist")?.ok).toBe(true); + }); + + it("fails before finalization when an adapter hook retargets the prepared run root", async () => { + const runId = "terminal-hook-root-retarget"; + const runRoot = path.join(cwd, ".humanish", "runs", runId); + const capturedRunRoot = path.join(cwd, ".humanish", "runs", `${runId}-captured`); + const outsideRoot = path.join(cwd, "outside-retarget"); + const outsideSentinel = path.join(outsideRoot, "sentinel.txt"); + const original = "outside target must stay unchanged\n"; + await mkdir(outsideRoot); + await writeFile(outsideSentinel, original, "utf8"); + let hookRan = false; + const hooks = passingHooks({ + score: async (ctx) => { + hookRan = true; + await rename(runRoot, capturedRunRoot); + await symlink(outsideRoot, runRoot, "dir"); + return exampleAdapterScore(ctx); + } + }); + + await expect(runTerminalProductLab({ + cwd, + config: liveConfig(), + dryRun: false, + hooks, + open: false, + runId + })).rejects.toThrow(/changed physical destination|identity changed/i); + + expect(hookRan).toBe(true); + expect(await readFile(outsideSentinel, "utf8")).toBe(original); + await expect(stat(path.join(outsideRoot, "run.json"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(path.join(cwd, ".humanish", "runs", "latest.json"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(path.join(capturedRunRoot, "run.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + // The contract types the adapter needs ARE exported (ActorTrace / TerminalLedgers used via the // context above); this no-op assertion makes the "adapter typed only against the public barrel" // claim explicit and load-bearing in CI — if any export disappeared this would fail to type-check.