From f55abde13eb67a79cf98beacb7bcdce8a757e1c3 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:45:18 +0800 Subject: [PATCH 1/5] feat(effects): add resource-controlled trusted writes Compile closed EffectIR with transitive label checks, route declared writes through the existing resource authority, recover failed and crashed transactions, and expose ledger-backed why-effect evidence. --- CHANGELOG.md | 28 + .../workspace/host-support-baseline.json | 78 +- docs/internal/0.3.0-agent-goal.md | 119 +++ docs/internal/0.3.0-ga-scoreboard.md | 76 ++ docs/internal/0.3.0-release-plan.md | 35 + docs/internal/0.3.0-trusted-effects-mvp.md | 132 +++ examples/trusted-effects-write.json | 34 + .../claude-taskflow/test/mcp-server.test.ts | 4 +- packages/codex-taskflow/test/e2e-codex.mts | 100 +- .../test/e2e-mcp-comprehensive.mts | 58 +- .../codex-taskflow/test/mcp-server.test.ts | 4 +- .../grok-taskflow/test/mcp-server.test.ts | 2 + .../opencode-taskflow/test/mcp-server.test.ts | 4 +- packages/taskflow-core/src/effects/index.ts | 9 + .../src/effects/runtime-apply.ts | 263 ++++++ packages/taskflow-core/src/effects/schema.ts | 82 ++ packages/taskflow-core/src/effects/types.ts | 130 +++ .../taskflow-core/src/effects/validate.ts | 445 +++++++++ packages/taskflow-core/src/effects/why.ts | 445 +++++++++ .../taskflow-core/src/exec/kernel-policy.ts | 4 + packages/taskflow-core/src/exec/step.ts | 6 + .../src/flowir/canonical-hash.ts | 7 +- packages/taskflow-core/src/flowir/compile.ts | 51 +- packages/taskflow-core/src/flowir/index.ts | 2 +- packages/taskflow-core/src/flowir/meta.ts | 6 + packages/taskflow-core/src/flowir/schema.ts | 19 + .../taskflow-core/src/flowir/translate.ts | 30 +- packages/taskflow-core/src/index.ts | 2 + .../taskflow-core/src/resources/execution.ts | 110 ++- .../src/resources/file-transaction.ts | 649 +++++++++++++ .../taskflow-core/src/resources/journal.ts | 206 ++++- packages/taskflow-core/src/resources/types.ts | 4 + packages/taskflow-core/src/runtime.ts | 197 +++- .../src/runtime/phases/script.ts | 6 + packages/taskflow-core/src/schema.ts | 28 + .../src/verifiers/effects-lint.ts | 95 ++ packages/taskflow-core/src/verifiers/index.ts | 4 +- packages/taskflow-core/src/verify.ts | 4 + .../test/effects-agent-te.test.ts | 309 +++++++ .../test/effects-deliverables.test.ts | 207 +++++ .../test/effects-e2e-fixture.test.ts | 164 ++++ .../test/effects-gateway-bypass.test.ts | 342 +++++++ .../test/effects-trusted.test.ts | 871 ++++++++++++++++++ .../test/flowir-canonical-hash.test.ts | 90 ++ .../test/resource-file-transaction.test.ts | 394 ++++++++ .../test/resource-journal.test.ts | 39 + .../taskflow-core/test/verify-effects.test.ts | 160 ++++ packages/taskflow-mcp-core/src/mcp/server.ts | 41 +- 48 files changed, 6014 insertions(+), 81 deletions(-) create mode 100644 docs/internal/0.3.0-agent-goal.md create mode 100644 docs/internal/0.3.0-ga-scoreboard.md create mode 100644 docs/internal/0.3.0-release-plan.md create mode 100644 docs/internal/0.3.0-trusted-effects-mvp.md create mode 100644 examples/trusted-effects-write.json create mode 100644 packages/taskflow-core/src/effects/index.ts create mode 100644 packages/taskflow-core/src/effects/runtime-apply.ts create mode 100644 packages/taskflow-core/src/effects/schema.ts create mode 100644 packages/taskflow-core/src/effects/types.ts create mode 100644 packages/taskflow-core/src/effects/validate.ts create mode 100644 packages/taskflow-core/src/effects/why.ts create mode 100644 packages/taskflow-core/src/resources/file-transaction.ts create mode 100644 packages/taskflow-core/src/verifiers/effects-lint.ts create mode 100644 packages/taskflow-core/test/effects-agent-te.test.ts create mode 100644 packages/taskflow-core/test/effects-deliverables.test.ts create mode 100644 packages/taskflow-core/test/effects-e2e-fixture.test.ts create mode 100644 packages/taskflow-core/test/effects-gateway-bypass.test.ts create mode 100644 packages/taskflow-core/test/effects-trusted.test.ts create mode 100644 packages/taskflow-core/test/resource-file-transaction.test.ts create mode 100644 packages/taskflow-core/test/verify-effects.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb692cfe..de9b91f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +## [0.3.0] — Unreleased (Trusted Effects candidate) + +> **Version plan:** keep published packages at **0.2.7** until a human cuts `v0.3.0`. +> Branch: `feat/0.3.0-trusted-effects`. **Not GA until tag + publish.** + +### Added + +- **Trusted Effects MVP** (`packages/taskflow-core/src/effects/`): + - EffectIR (`EFFECT_KINDS`), PathRef reuse, SecretRef/ServiceRef (type-only fail-closed) + - closed TypeBox EffectIR + confidentiality/integrity source-to-sink validation + - resource-controlled FS transaction: durable snapshot → persistent lease → journal intent/permit → stage → Commit or Restore+Reject + - declaration-only bridge in `effects/runtime-apply.ts`; no second changeset/gateway authority + - ledger-backed `whyAuthorized` / `whyContext` / `whyEffect` +- Optional phase/flow `effects[]`; FlowIR translate/compile/hash include effects +- Built-in `detectEffectsIssues` (category `effects`) + `effectsLintVerifier` +- Every imperative phase fast path finalizes declared `fs.write` through the resource transaction; event-kernel-enabled runs use the same safe imperative path +- Honest host baseline: `conformance/workspace/host-support-baseline.json` +- Docs: `docs/internal/0.3.0-trusted-effects-mvp.md`, `0.3.0-agent-goal.md`, `0.3.0-ga-scoreboard.md` +- Example: `examples/trusted-effects-write.json` +- Tests: `test/effects*.test.ts`, `test/verify-effects.test.ts` + +### Notes + +- SecretRef/ServiceRef have **no** vault/network backends in this cut. +- Resolve-only is not an OS sandbox. Direct writes to declared targets are detected and restored; writes outside declared targets remain host-policy dependent. +- Historical Control Plane (`feat/0.3.0`) is **not** this release definition. +- **Not released; not GA.** + ## [0.2.7] — 2026-08-06 ### Added diff --git a/conformance/workspace/host-support-baseline.json b/conformance/workspace/host-support-baseline.json index 2df1eba6..904d2643 100644 --- a/conformance/workspace/host-support-baseline.json +++ b/conformance/workspace/host-support-baseline.json @@ -1,5 +1,79 @@ { "schemaVersion": 1, - "baselineId": "taskflow-workspace-proposed-v1", - "cells": [] + "baselineId": "taskflow-workspace-trusted-effects-v0.3.0-mvp", + "notes": "Honest cells for 0.3 Trusted Effects MVP. resolve-only is not a FileBroker sandbox. Empty claims removed.", + "cells": [ + { + "host": "pi", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "codex", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "claude", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "opencode", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "grok", + "resourceDomain": "filesystem", + "capability": "pathref-resolve", + "status": "supported", + "guarantee": "resolve-only", + "evidence": "packages/taskflow-core/src/resources/execution.ts" + }, + { + "host": "*", + "resourceDomain": "filesystem", + "capability": "file-broker", + "status": "unsupported", + "guarantee": "none", + "evidence": "No host has passed FileBroker adversarial baseline; do not claim sandbox." + }, + { + "host": "node-local", + "resourceDomain": "filesystem", + "capability": "trusted-effects-resource-transaction", + "status": "supported", + "guarantee": "pathref-lease-journal-permit-commit-or-restore", + "evidence": "packages/taskflow-core/src/resources/file-transaction.ts + test/resource-file-transaction.test.ts" + }, + { + "host": "*", + "resourceDomain": "secret", + "capability": "secretref-backend", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "SecretRef validated; no vault adapter in 0.3 MVP" + }, + { + "host": "*", + "resourceDomain": "service", + "capability": "serviceref-adapter", + "status": "unsupported", + "guarantee": "type-only-fail-closed", + "evidence": "ServiceRef validated; no live adapter in 0.3 MVP" + } + ] } diff --git a/docs/internal/0.3.0-agent-goal.md b/docs/internal/0.3.0-agent-goal.md new file mode 100644 index 00000000..7a1ea520 --- /dev/null +++ b/docs/internal/0.3.0-agent-goal.md @@ -0,0 +1,119 @@ +# Agent Goal — Taskflow 0.3 Trusted Effects → Closed-Loop GA + +> **Owner:** coding agent (this session and successors) +> **Branch:** `feat/0.3.0-trusted-effects` +> **Base:** `main` @ v0.2.7 +> **Normative product freeze:** [`0.3.0-trusted-effects-mvp.md`](./0.3.0-trusted-effects-mvp.md) +> **Scoreboard:** [`0.3.0-ga-scoreboard.md`](./0.3.0-ga-scoreboard.md) +> **Convergence workflow:** `tf-03-ga-converge` (`~/.grok/workflows/tf-03-ga-converge.rhai`) + +--- + +## 1. Mission (one sentence) + +> **Drive Trusted Effects MVP from code-on-branch to honest closed-loop GA: every declared FS write effect is validated and finalized through the existing resources authority (PathRef, lease, journal, permit); eight deliverables are contract-tested; real host evidence is green; release candidate is cut — without claiming Control Plane / Adaptive / Retain as 0.3.** + +## 2. Success = L6 GA only when all below hold + +| ID | Requirement | Evidence | +|----|-------------|----------| +| G1 | Eight MVP deliverables each `pass` on scoreboard | scoreboard table | +| G2 | `effects-trusted` + related unit suite green | test command exit 0 | +| G3 | `pnpm run typecheck` (or package-filtered equivalent) green on branch | log | +| G4 | Real host/fixture e2e applies a declared write through the resources ledger | terminal host evidence | +| G5 | Runtime path: declared `fs.write` cannot bypass PathRef/lease/journal/permit authority; bypass restores exact pre-state | code + adversarial test | +| G6 | Host baseline honest (no FileBroker claim without proof) | conformance JSON | +| G7 | CHANGELOG Unreleased → versioned; root package version plan for 0.3.0 | CHANGELOG + package.json | +| G8 | Branch pushed; CI green on clean candidate; tag only after G1–G7 | remote | + +**Forbidden claims until G1–G8:** + +- “0.3 GA” / “shipped” / “ready for everyone” +- Equating historical `feat/0.3.0` Control Plane with this GA +- Auto-tune / Retain / Adaptive as 0.3 scope + +## 3. Operating loop (how I work) + +```text +forever until L6: + 1. AUDIT scoreboard + git + tests → list gaps (ordered) + 2. FAN-OUT parallel implementers on non-overlapping files + 3. VERIFY effects tests + typecheck subset + e2e fixture + 4. RECORD update scoreboard (PASS/PARTIAL/FAIL + evidence) + 5. DECIDE if L6 ready → stop and ask human for release authority + else → next wave (workflow re-run) +``` + +**Parallel policy** + +- Prefer **file ownership** per agent (effects / verify / runtime / docs / e2e). +- Max parallel implementers per wave: **5** (plus 1 auditor + 1 verifier). +- Do **not** merge Control Plane monolith into this branch as a blocker. +- Prefer small commits when human asks; default keep working tree until wave stable. + +**Workflows** + +| Name | Role | +|------|------| +| `tf-03-trusted-effects` | Initial wire / inventory (may still be running) | +| `tf-03-ga-converge` | **Primary loop:** audit → parallel fill gaps → verify → scoreboard | + +Re-run: `/workflow` launch `tf-03-ga-converge` with high `agent_budget` (up to 1024). +Same-process resume if paused for human release authority only. + +## 4. Wave plan (ordered, not optional) + +| Wave | Goal | Exit criteria | +|------|------|----------------| +| **W0** Freeze | Definition + branch | doc + branch exist ✅ | +| **W1** Kernel | types, closed validate, resources transaction, ledger why | unit vertical slice | +| **W2** Contract wire | schema, FlowIR, hash, effects verifier | verify + hash tests | +| **W3** Runtime force | every phase kind routes declared writes through resources | bypass/fast-path tests fail closed | +| **W4** Fixture e2e | no-LLM end-to-end under `test/` or `examples/` | L4 for MVP story | +| **W5** Package honesty | versions, CHANGELOG, skills note, baseline | release-ready docs | +| **W6** Candidate | full typecheck/test, push, CI | L5 evidence | +| **W7** GA | human tag + publish decision | L6 | + +Current wave target after W1–W4 closure: **W5 package honesty + W6 clean candidate / remote CI**. + +## 5. Acceptance Gate (live) + +Update [`0.3.0-ga-scoreboard.md`](./0.3.0-ga-scoreboard.md) every wave. Template: + +| Level | Meaning for 0.3 TE | +|-------|---------------------| +| L1 local | effects code + tests on branch | +| L2 contract | 8 deliverables + schema/verify/hash | +| L3 N/A | no browser product for this MVP | +| L4 real-environment | actual host invocation with durable ledger readback | +| L5 released | tag + npm (or explicit monorepo release) | +| L6 ga | L5 + scoreboard all pass + no dual-path lies | + +## 6. Standing orders + +1. **Truth over speed** — never promote PARTIAL to PASS without evidence path. +2. **MVP over Control Plane** — ignore daemon/P13–P16 for this GA definition. +3. **Fail closed** — unknown effect kinds, secret material, path overlap = error. +4. **One claim band** — user-facing language ≤ Highest proven level. +5. **Human for L5/L6** — agent prepares candidate; human authorizes tag/publish. + +## 7. Commands (agent cheat sheet) + +```bash +# Focused suite +PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test 'packages/taskflow-core/test/effects*.test.ts' + +# Broader core (when wave allows) +pnpm --filter taskflow-core test # if script exists +pnpm run typecheck + +# Scoreboard +# edit docs/internal/0.3.0-ga-scoreboard.md after each wave +``` + +## 8. Stop condition + +- **Hard stop success:** L6 checklist complete and human accepted release. +- **Hard stop blocked:** product redefines 0.3 away from Trusted Effects — rewrite freeze first. +- **Soft continue:** any PARTIAL/FAIL on G1–G6 → another `tf-03-ga-converge` wave. diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md new file mode 100644 index 00000000..cd9f6912 --- /dev/null +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -0,0 +1,76 @@ +# 0.3 Trusted Effects — GA Scoreboard + +> Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. + +**Last updated:** 2026-08-07 (live Codex Trusted Effects closure) +**Branch:** `feat/0.3.0-trusted-effects` +**Current evidence:** focused effects/resources/FlowIR suite 79/79; full monorepo typecheck PASS; full unit suite 2199/2199 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. + +--- + +## Acceptance Gate + +| Level | Status | Evidence | Notes | +|-------|--------|----------|-------| +| L1 local | **PASS** | focused 79/79 + monorepo typecheck PASS | dirty shared worktree remains | +| L2 contract | **PASS** | full unit suite 2199/2199 + full build PASS | local contract only | +| L3 browser/electron | **N/A** | — | | +| L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | +| L5 released | **FAIL** | no tag/publish | human gate | +| L6 ga | **FAIL** | L5 missing | **NOT GA** | + +**Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. +**User-facing claim allowed:** *The branch has a local/contract-green resource-controlled Trusted Effects candidate with live Codex and built MCP proof for admitted declared FS-write targets; clean-candidate, remote CI, release, and GA gates remain open.* +**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. + +--- + +## Eight deliverables + +| # | Deliverable | Status | Evidence | +|---|-------------|--------|----------| +| 1 | EffectIR | **pass** | closed TypeBox schema; translate/compile diagnostics; invalid IR receives no canonical hash | +| 2 | PathRef / SecretRef / ServiceRef | **pass** | typed refs; unsupported secret/service backends fail before body execution | +| 3 | labels | **pass** | phase-local and transitive DAG confidentiality/integrity checks in schema, verifier, compiler, and runtime | +| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + process-crash recovery | +| 5 | single mutation authority | **pass** | legacy changeset/gateway authority removed; effects bridge delegates to resources | +| 6 | mutating-path overlap | **pass** | phase-local static overlap + persistent cross-session/run lease admission | +| 7 | host matrix | **pass (honest)** | five hosts explicitly resolve-only; FileBroker/secret/service cells explicitly unsupported | +| 8 | why-* | **pass** | live and built-host readback derives principal/capability/intent/generation from resource ledger | + +--- + +## Wave log + +| Wave | Result | +|------|--------| +| Resource convergence | single resources authority; focused + full contract PASS | +| Information flow | transitive DAG labels wired through validate/verify/compile/runtime; false-edge regression covered | +| Real host | live Codex three-agent run persisted final output through resource transaction; ledger authorization PASS | + +--- + +## Commands last green + +```text +PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test \ + 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/verify-effects.test.ts +# → 79/79 pass (effects/resources/FlowIR focused set) + +pnpm run typecheck +# → PASS + +pnpm test +# → 2199/2199 PASS + +pnpm run build +# → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages + +pnpm run test:e2e-codex-mcp-full +# → build PASS + 16/16 built Codex MCP checks, including fs.write + ledger why-effect + +pnpm run test:e2e-codex +# → live Codex A→B→C PASS; read-only final agent + fs.write + ledger why-effect +``` diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md new file mode 100644 index 00000000..75674066 --- /dev/null +++ b/docs/internal/0.3.0-release-plan.md @@ -0,0 +1,35 @@ +# 0.3.0 Trusted Effects — release plan (human gate) + +## Current state + +| Item | Value | +|------|--------| +| Branch | `feat/0.3.0-trusted-effects` | +| Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | +| Package versions on npm | still **0.2.7** | +| Tag | **none** | + +## Human checklist for L5 → L6 + +1. Review scoreboard: `docs/internal/0.3.0-ga-scoreboard.md` (all 8 deliverables **pass**, L4 **pass**). +2. Run locally: + ```bash + PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ + --experimental-strip-types --test \ + 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/verify-effects.test.ts + pnpm run typecheck + pnpm test + pnpm run build + pnpm run test:e2e-codex-mcp-full + pnpm run test:e2e-codex + ``` +3. First isolate a clean candidate from the current shared dirty worktree; then push the exact candidate SHA and confirm CI green on that SHA. +4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). +5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. +6. Tag `v0.3.0` and publish **only with explicit human authority**. +7. Update scoreboard L5/L6 to PASS with tag URL + publish receipt. + +## Agent stop line + +Agents **must not** tag or npm publish. Stop at candidate + truthful scoreboard (L5/L6 FAIL until step 6–7). diff --git a/docs/internal/0.3.0-trusted-effects-mvp.md b/docs/internal/0.3.0-trusted-effects-mvp.md new file mode 100644 index 00000000..4ae0f489 --- /dev/null +++ b/docs/internal/0.3.0-trusted-effects-mvp.md @@ -0,0 +1,132 @@ +# 0.3.0 — Trusted Effects MVP(冻结定义) + +> **Status:** ACTIVE implementation track +> **Branch:** `feat/0.3.0-trusted-effects` +> **Base:** `main` @ `v0.2.7` (`44b45025`) +> **Date frozen:** 2026-08-07 + +## 1. What 0.3 *is* (and is not) + +| 0.3 IS | 0.3 is NOT | +|--------|------------| +| **Trusted Effects** minimal vertical slice | Historical Control Plane (`feat/0.3.0`) GA | +| Agent execution substrate: effects, resource transactions, ledger-backed why-* | Adaptive Planning / Goal / Retain GA | +| Built on published 0.2.7 orchestration | Replacing 0.2 phase DAG | + +**Product sentence** + +> Taskflow 0.3 lets a run **declare**, **stage**, **verify**, and **commit** filesystem (and later secret/service) effects under typed refs and labels — so, for admitted declared FS-write targets, the model proposes content and the resources runtime is the only commit authority. + +Historical Control Plane (`feat/0.3.0`: ControlHost/daemon/Receipt) remains an **experimental parallel track**. It is **not** the GA definition of 0.3.0 under this freeze. + +Adaptive / CharterArc Retain stay **shadow** until this MVP is contract-green. + +## 2. Eight core deliverables (MVP scope) + +| # | Deliverable | MVP bar (must be true) | +|---|-------------|------------------------| +| 1 | **EffectIR** | FlowIR nodes may carry a closed `effects[]` set; compile/validate rejects unknown kinds; hash includes effects | +| 2 | **PathRef / SecretRef / ServiceRef** | PathRef reuses `resources/*`; SecretRef + ServiceRef types exist; unresolved/unsupported refs fail closed (no bare string authority) | +| 3 | **confidentiality + integrity labels** | Labels on effects + optional on refs; schema/verify/compile/runtime reject illegal phase-local and transitive DAG flows (e.g. secret→public write) in a fixed rule set | +| 4 | **Resource-controlled file transaction** | For **declared FS write effects**: durable snapshot → lease → intent/permit → stage → `Commit \| Restore+Reject` | +| 5 | **Single mutation authority** | Existing `resources/*` PathRef resolver, persistent leases, durable journal, and mutation permits are the only authority; `effects/*` is declaration/bridge code only | +| 6 | **mutating-path overlap** | Static + admit-time check: two concurrent mutating PathRefs that overlap → deny or serialize (documented) | +| 7 | **host guarantee matrix** | `conformance/workspace/host-support-baseline.json` has real cells; resolve-only hosts explicit; FileBroker not claimed without proof | +| 8 | **why-authorized / why-context / why-effect** | Pure explainers from ledger/decision records; MCP or core API returns structured reasons | + +### Explicit non-goals for 0.3.0 MVP + +- Full OS sandbox / all-host FileBroker green +- Prevention or rollback of writes to **undeclared** paths under resolve-only execution +- Secret vault backends / live ServiceRef adapters +- ControlHost daemon multi-mount GA +- Auto-tune / Retain / Adaptive promotion +- Event-kernel default-ON (may remain OFF if imperative path enforces effects) + +## 3. Vertical slice demo (acceptance story) + +A flow with one `script` or `agent` phase that declares (see also `examples/trusted-effects-write.json`): + +```jsonc +"effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" // PathIntent: create-file | create-directory | existing-file | existing-directory | executable + } + }, + "integrity": "project", + "confidentiality": "internal" + } +] +``` + +**Must:** + +1. `verify` / compile lists the effect and path overlap (if any). +2. Runtime admits every declared target through PathRef resolution, an atomic multi-scope lease, durable intent, and active mutation permit before the phase body. +3. The resource transaction stages payloads and promotes each file with atomic rename; a later failure restores every admitted file to its durable pre-state. +4. Reject proves `beforeContentId === afterContentId` and records `aborted-restored`; unknown restoration blocks later writes. +5. `whyEffect(runId, effectId)` derives principal, capability binding, lifecycle, and generation from the durable resource ledger; declaration alone is not authorization. +6. Unit tests cover happy path, reject, overlap deny, multi-write, and secret label illegal flow — **no live LLM required**. +7. **Agents/scripts must not write declared final paths** — bypass is detected against the durable pre-state and restored; only the resource transaction may finalize declared content. + +## 4. Layered DoD (acceptance-gate) + +| Layer | Required for “0.3 MVP closed” | +|-------|-------------------------------| +| **local** | typecheck + focused unit suite green on branch | +| **contract** | 8 deliverables each have tests + public types exported from `taskflow-core` | +| **real-fixture-e2e** | Node fixture run of vertical slice (script phase, no LLM) | +| **released** | version bump plan + CHANGELOG; not required for “MVP complete” label | +| **GA** | released + host matrix honesty + no dual-path lies; **separate gate after MVP** | + +**GA is not claimed until** local + contract + real-fixture-e2e are green **and** a release candidate is cut. This document’s “MVP complete” ≠ npm publish. + +## 5. Package layout + +```text +packages/taskflow-core/src/ + effects/ + types.ts # EffectIR, labels, refs (Secret/Service) + schema.ts # closed TypeBox EffectIR contract + validate.ts # static effect + label + overlap checks + runtime-apply.ts # declaration-to-resources bridge; no filesystem authority + why.ts # why-authorized / why-context / why-effect + index.ts + flowir/schema.ts # optional effects on nodes (additive) + resources/ + file-transaction.ts # resource-owned snapshot/stage/commit/restore + execution.ts # host authority + PathRef binding + leases.ts # persistent overlap admission + journal.ts # durable intents, permits, terminal evidence +conformance/workspace/host-support-baseline.json # honest cells +``` + +## 6. Relationship to historical Control Plane + +| Track | Branch | Role | +|-------|--------|------| +| Trusted Effects 0.3 | `feat/0.3.0-trusted-effects` | **GA-bound product definition** | +| Control Plane prototype | `feat/0.3.0` | Experimental; may later consume Trusted Effects as exec substrate | +| Adaptive kernel | `codex/hierarchical-adaptive-kernel` | Post-0.3 shadow | + +Do **not** merge Control Plane into this branch as a prerequisite for MVP. + +## 7. Implementation waves + +1. **Types + validate + hash** (EffectIR, labels, SecretRef/ServiceRef, overlap) +2. **Resource transaction** (FS write path only; no second authority) +3. **ledger-backed why-*** + wire into run/tool surfaces +4. **Host baseline honesty** + vertical-slice e2e fixture +5. **Docs / CHANGELOG / skills note** + +## 8. Freeze authority + +This file is the **normative product freeze** for 0.3.0 MVP. Conflicting “Control Plane GA” narratives are superseded for release planning. diff --git a/examples/trusted-effects-write.json b/examples/trusted-effects-write.json new file mode 100644 index 00000000..220846a6 --- /dev/null +++ b/examples/trusted-effects-write.json @@ -0,0 +1,34 @@ +{ + "name": "trusted-effects-write", + "description": "0.3 Trusted Effects MVP demo: script phase declares fs.write; content is stdout; the resources transaction is the only finalizer (no LLM).", + "phases": [ + { + "id": "write-report", + "type": "script", + "run": [ + "node", + "-e", + "process.stdout.write('# Trusted Effects report\\n\\nCommitted via resource intent.\\n')" + ], + "idempotent": false, + "effects": [ + { + "id": "report", + "kind": "fs.write", + "purpose": "write final report", + "confidentiality": "internal", + "integrity": "project", + "target": { + "kind": "path", + "path": { + "workspace": "project", + "subpath": { "literalPath": "out/report.md" }, + "intent": "create-file" + } + } + } + ], + "final": true + } + ] +} diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 6a76f5db..54c02293 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -53,7 +53,7 @@ test("claude mcp: tools/list exposes the same taskflow tools as codex", async () const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("claude mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/codex-taskflow/test/e2e-codex.mts b/packages/codex-taskflow/test/e2e-codex.mts index e5113966..9abc7e3a 100644 --- a/packages/codex-taskflow/test/e2e-codex.mts +++ b/packages/codex-taskflow/test/e2e-codex.mts @@ -12,7 +12,14 @@ */ import assert from "node:assert/strict"; -import { executeTaskflow, type RuntimeDeps } from "taskflow-core"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + executeTaskflow, + whyEffectFromDurableJournal, + type RuntimeDeps, +} from "taskflow-core"; import { codexSubagentRunner } from "taskflow-hosts/codex"; import type { AgentConfig } from "taskflow-core"; import type { Taskflow } from "taskflow-core"; @@ -28,7 +35,7 @@ const AGENTS: AgentConfig[] = [ }, ]; -function mkState(def: Taskflow): RunState { +function mkState(def: Taskflow, cwd: string): RunState { return { runId: `e2e-codex-${Date.now()}`, flowName: def.name, @@ -38,7 +45,7 @@ function mkState(def: Taskflow): RunState { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), - cwd: process.cwd(), + cwd, }; } @@ -57,13 +64,39 @@ const def: Taskflow = { agent: "responder", task: 'Phase pick said: "{steps.pick.output}". Reply with that same word in UPPERCASE, nothing else.', dependsOn: ["pick"], + }, + { + id: "persist", + type: "agent", + agent: "responder", + task: 'Reply with exactly "{steps.use.output}" and nothing else.', + dependsOn: ["use"], + tools: ["read"], + effects: [{ + id: "result", + kind: "fs.write", + purpose: "persist the live Codex result through resource authority", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/result.txt" }, + intent: "create-file", + }, + }, + }], final: true, }, ], }; +const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-codex-e2e-")); +const controlDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-codex-control-")); const deps: RuntimeDeps = { - cwd: process.cwd(), + cwd: root, + workspaceControlDirectory: controlDirectory, agents: AGENTS, runTask: codexSubagentRunner.runTask, onProgress: (s) => { @@ -74,25 +107,46 @@ const deps: RuntimeDeps = { }, }; -console.log("▶ running 2-phase taskflow on codex (real subagents)…\n"); -const t0 = Date.now(); -const res = await executeTaskflow(mkState(def), deps); -const dt = ((Date.now() - t0) / 1000).toFixed(1); +try { + console.log("▶ running 3-phase taskflow on codex (real subagents + Trusted Effects)…\n"); + const t0 = Date.now(); + const state = mkState(def, root); + const res = await executeTaskflow(state, deps); + const dt = ((Date.now() - t0) / 1000).toFixed(1); + + process.stderr.write("\n"); + console.log(`\n✓ run finished in ${dt}s — ok=${res.ok}`); + console.log(" phase pick.output:", JSON.stringify(res.state.phases["pick"]?.output?.trim())); + console.log(" final output :", JSON.stringify(res.finalOutput?.trim())); + console.log(" total usage :", JSON.stringify(res.totalUsage)); -process.stderr.write("\n"); -console.log(`\n✓ run finished in ${dt}s — ok=${res.ok}`); -console.log(" phase pick.output:", JSON.stringify(res.state.phases["pick"]?.output?.trim())); -console.log(" final output :", JSON.stringify(res.finalOutput?.trim())); -console.log(" total usage :", JSON.stringify(res.totalUsage)); + assert.equal(res.ok, true, "run should succeed"); + assert.ok((res.state.phases["pick"]?.output ?? "").trim().length > 0, "phase pick produced output"); + assert.ok((res.finalOutput ?? "").trim().length > 0, "final output non-empty"); + // Phase use uppercases pick; persist echoes that content from a read-only Codex + // sandbox and the resource transaction alone promotes it to the final path. + const pickWord = (res.state.phases["pick"]?.output ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); + const finalWord = (res.finalOutput ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); + assert.ok(finalWord.length > 0, "final word non-empty"); + assert.equal(finalWord, pickWord, `data should flow A→B→C: pick=${pickWord} final=${finalWord}`); + assert.equal(fs.readFileSync(path.join(root, "out/result.txt"), "utf8").trim(), finalWord); -assert.equal(res.ok, true, "run should succeed"); -assert.ok((res.state.phases["pick"]?.output ?? "").trim().length > 0, "phase pick produced output"); -assert.ok((res.finalOutput ?? "").trim().length > 0, "final output non-empty"); -// The final phase should have echoed pick's word in uppercase — prove data flowed -// A→B by checking the final output is uppercase and shares letters with pick. -const pickWord = (res.state.phases["pick"]?.output ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); -const finalWord = (res.finalOutput ?? "").trim().replace(/[^a-zA-Z]/g, "").toUpperCase(); -assert.ok(finalWord.length > 0, "final word non-empty"); -assert.equal(finalWord, pickWord, `data should flow A→B: pick=${pickWord} final=${finalWord}`); + const why = await whyEffectFromDurableJournal({ + flow: def, + runId: state.runId, + phaseId: "persist", + effectId: "result", + workspaceRoot: root, + controlDirectory, + }); + assert.equal(why.ok, true); + if (!why.ok) throw new Error(why.error); + assert.equal(why.why.status, "committed"); + assert.equal(why.why.authorized.allowed, true); + assert.equal(why.why.authorized.principalId, "local-host-invocation"); -console.log("\n✅ E2E PASS — the taskflow engine ran end-to-end on codex, data flowed A→B."); + console.log("\n✅ E2E PASS — live Codex data flowed A→B→C; fs.write committed with ledger-backed authority."); +} finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(controlDirectory, { recursive: true, force: true }); +} diff --git a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts index cc5a8634..cb565d21 100644 --- a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts +++ b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts @@ -26,10 +26,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { defaultWorkspaceControlDirectory } from "../../taskflow-core/src/resources/execution.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(here, ".."); const bin = path.join(repo, "dist", "mcp", "bin.js"); +const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-codex-mcp-effects-e2e-")); +const workspaceControl = defaultWorkspaceControlDirectory(serverCwd); assert.ok(fs.existsSync(bin), `built bin not found at ${bin} — run: npm run build -w codex-taskflow`); @@ -40,7 +43,7 @@ const ok = (label: string) => { }; // --- subprocess MCP client ------------------------------------------------ -const proc = spawn("node", [bin], { cwd: repo, stdio: ["pipe", "pipe", "pipe"] }); +const proc = spawn("node", [bin], { cwd: serverCwd, stdio: ["pipe", "pipe", "pipe"] }); const responses: any[] = []; let buf = ""; proc.stdout.on("data", (d) => { @@ -88,7 +91,7 @@ send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const list = await waitFor(2, "tools/list"); const toolNames = list.result.tools.map((t: any) => t.name).sort(); -assert.deepEqual(toolNames, ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); +assert.deepEqual(toolNames, ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"]); for (const t of list.result.tools) { assert.equal(t.inputSchema.type, "object", `${t.name} has object schema`); assert.equal(typeof t.description, "string"); @@ -242,7 +245,54 @@ assert.equal(runBad.result.isError, true, "empty-phases flow is invalid"); assert.match(runBad.result.content[0].text, /invalid|phase/i); ok("taskflow_run invalid flow → isError with reason"); -// === 9. protocol robustness: batch pipelining + errors in one burst ======== +// === 9. Trusted Effects through the built Codex MCP host ================== +const effectRun = await callTool(12, "taskflow_run", { + define: { + name: "trusted-effects-host-e2e", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('HOST_EFFECT')"], + idempotent: false, + effects: [{ + id: "report", + kind: "fs.write", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.txt" }, + intent: "create-file", + }, + }, + }], + final: true, + }], + }, +}); +assert.equal(effectRun.result.isError, false, effectRun.result.content[0].text); +assert.equal(fs.readFileSync(path.join(serverCwd, "out/report.txt"), "utf8"), "HOST_EFFECT"); +const effectText: string = effectRun.result.content[0].text; +const runId = /· run ([^\s]+)/.exec(effectText)?.[1]; +assert.ok(runId, `taskflow_run did not report a run id: ${effectText}`); +const whyEffect = await callTool(13, "taskflow_why_effect", { + runId, + phaseId: "write", + effectId: "report", + json: true, +}); +assert.equal(whyEffect.result.isError, false, whyEffect.result.content[0].text); +const why = JSON.parse(whyEffect.result.content[0].text); +assert.equal(why.authorized.allowed, true); +assert.equal(why.authorized.principalId, "local-host-invocation"); +assert.equal(why.status, "committed"); +assert.equal(why.journalStatus, "committed-content"); +assert.match(why.intentId, /^[0-9a-f-]{36}$/i); +ok("built Codex MCP → fs.write committed through resources + ledger-backed why-effect"); + +// === 10. protocol robustness: batch pipelining + errors in one burst ======= send({ jsonrpc: "2.0", id: 20, method: "ping" }); send({ jsonrpc: "2.0", id: 21, method: "does/not/exist" }); send({ jsonrpc: "2.0", method: "notifications/somethingUnknown" }); // ignored @@ -276,5 +326,7 @@ await sleep(50); if (stderr.trim()) { console.log("\n⚠ server stderr (non-fatal):\n" + stderr.trim()); } +fs.rmSync(serverCwd, { recursive: true, force: true }); +fs.rmSync(workspaceControl, { recursive: true, force: true }); console.log(`\n✅ COMPREHENSIVE E2E PASS — ${pass} checks against the built dist bin.`); process.exit(0); diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 3ef8f631..68fed30b 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -59,7 +59,7 @@ test("mcp: tools/list exposes the taskflow tools with schemas", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -245,7 +245,7 @@ test("mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index f93e5498..fa9842bd 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -72,6 +72,7 @@ test("grok mcp: tools/list exposes the same taskflow tools as other hosts", asyn "taskflow_trace", "taskflow_verify", "taskflow_version", + "taskflow_why_effect", "taskflow_why_stale", ], ); @@ -122,6 +123,7 @@ test("grok mcp: makeToolHandlers exposes the tools", () => { "taskflow_trace", "taskflow_verify", "taskflow_version", + "taskflow_why_effect", "taskflow_why_stale", ], ); diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 3f5df705..6b178f45 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -53,7 +53,7 @@ test("opencode mcp: tools/list exposes the taskflow tools", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("opencode mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_analytics", "taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_plan", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_effect", "taskflow_why_stale"], ); }); diff --git a/packages/taskflow-core/src/effects/index.ts b/packages/taskflow-core/src/effects/index.ts new file mode 100644 index 00000000..747cff16 --- /dev/null +++ b/packages/taskflow-core/src/effects/index.ts @@ -0,0 +1,9 @@ +/** + * Trusted Effects (0.3 MVP) public surface. + * @see docs/internal/0.3.0-trusted-effects-mvp.md + */ + +export * from "./types.ts"; +export * from "./validate.ts"; +export * from "./runtime-apply.ts"; +export * from "./why.ts"; diff --git a/packages/taskflow-core/src/effects/runtime-apply.ts b/packages/taskflow-core/src/effects/runtime-apply.ts new file mode 100644 index 00000000..2e3b4dfd --- /dev/null +++ b/packages/taskflow-core/src/effects/runtime-apply.ts @@ -0,0 +1,263 @@ +/** + * Runtime bridge from declared `fs.write` effects to the existing resource + * control plane. Admission happens before the phase body and finalization uses + * the same PathRef resolutions, persistent leases, durable journal intent, and + * mutation permit. No effects-layer commit authority exists here. + */ + +import type { ResolveOnlyPhaseBinding } from "../resources/execution.ts"; +import type { + FileTransactionResult, + PreparedResourceFileTransaction, +} from "../resources/file-transaction.ts"; +import type { PathRef } from "../resources/schema.ts"; +import type { EffectDecl } from "./types.ts"; +import { pathRefRelativeKey, pathsOverlap, validateEffectIR } from "./validate.ts"; + +export interface DeclaredFsWrite { + effectId: string; + relativePath: string; + content: string | Buffer; +} + +export type CollectDeclaredFsWritesResult = + | { ok: true; writes: DeclaredFsWrite[] } + | { ok: false; reason: string; code: string }; + +export interface PreparedDeclaredFsWrites { + transaction: PreparedResourceFileTransaction; + effects: unknown; +} + +export type PrepareDeclaredFsWritesResult = + | { ok: true; prepared?: PreparedDeclaredFsWrites } + | { ok: false; reason: string; code: string }; + +export type FinalizePreparedDeclaredFsWritesResult = + | { ok: true; intentId?: string; committedPaths: string[]; commitGeneration?: number } + | { + ok: false; + intentId?: string; + reason: string; + code: string; + restored: boolean; + }; + +export function precheckDeclaredFsWriteOverlap( + writes: readonly DeclaredFsWrite[], +): { ok: true } | { ok: false; reason: string; pathA: string; pathB: string; effectIdA: string; effectIdB: string } { + for (let i = 0; i < writes.length; i++) { + const a = writes[i]!; + for (let j = i + 1; j < writes.length; j++) { + const b = writes[j]!; + if (pathsOverlap(a.relativePath, b.relativePath)) { + return { + ok: false, + reason: + `mutating declared writes '${a.effectId}' and '${b.effectId}' overlap on path ` + + `('${a.relativePath}' vs '${b.relativePath}')`, + pathA: a.relativePath, + pathB: b.relativePath, + effectIdA: a.effectId, + effectIdB: b.effectId, + }; + } + } + } + return { ok: true }; +} + +export function hasDeclaredFsWriteEffects(effects: unknown): boolean { + return Array.isArray(effects) && effects.some((effect) => + effect !== null && typeof effect === "object" && (effect as { kind?: unknown }).kind === "fs.write"); +} + +export function validateDeclaredEffectsBeforeAdmission(effects: unknown): CollectDeclaredFsWritesResult { + if (!Array.isArray(effects) || effects.length === 0) return { ok: true, writes: [] }; + const validation = validateEffectIR({ effects: effects as EffectDecl[] }); + if (!validation.ok) { + return { + ok: false, + code: "effectir-invalid", + reason: validation.issues + .filter((issue) => issue.severity === "error") + .map((issue) => issue.message) + .join("; ") || "EffectIR validation failed", + }; + } + for (const effect of effects as EffectDecl[]) { + if (effect.kind !== "fs.write") { + return { + ok: false, + code: "unsupported-effect-kind", + reason: `effect '${effect.id}': ${effect.kind} has no bound resource backend in the 0.3 fs.write slice`, + }; + } + } + return { ok: true, writes: [] }; +} + +export async function preparePhaseDeclaredFsWrites( + binding: ResolveOnlyPhaseBinding, + opts: { effects: unknown; signal?: AbortSignal }, +): Promise { + const valid = validateDeclaredEffectsBeforeAdmission(opts.effects); + if (!valid.ok) return valid; + if (!hasDeclaredFsWriteEffects(opts.effects)) return { ok: true }; + const effects = opts.effects as EffectDecl[]; + const targets: Array<{ effectId: string; path: PathRef }> = []; + for (const effect of effects) { + if (effect.kind !== "fs.write") continue; + if (effect.target.kind !== "path") { + return { + ok: false, + code: "invalid-path-ref", + reason: `effect '${effect.id}': fs.write requires a path target`, + }; + } + targets.push({ effectId: effect.id, path: effect.target.path }); + } + try { + const transaction = await binding.beginFileWriteTransaction(targets, { + unitId: binding.phaseId, + signal: opts.signal, + }); + return { ok: true, prepared: { transaction, effects: opts.effects } }; + } catch (error) { + return { + ok: false, + code: "resource-admission-failed", + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +function fromFileTransactionResult(result: FileTransactionResult): FinalizePreparedDeclaredFsWritesResult { + return result.ok + ? { + ok: true, + intentId: result.intentId, + committedPaths: result.committedPaths, + commitGeneration: result.commitGeneration, + } + : { + ok: false, + intentId: result.intentId, + code: result.code, + reason: result.reason, + restored: result.restored, + }; +} + +export async function finalizePreparedDeclaredFsWrites( + prepared: PreparedDeclaredFsWrites | undefined, + phaseOutput: string, +): Promise { + if (!prepared) return { ok: true, committedPaths: [] }; + const collected = declaredFsWritesFromPhaseOutput(prepared.effects, phaseOutput); + if (!collected.ok) { + const rejected = await prepared.transaction.reject(collected.reason); + return { + ok: false, + intentId: rejected.intentId, + code: collected.code, + reason: collected.reason, + restored: !rejected.ok && rejected.restored, + }; + } + return fromFileTransactionResult(await prepared.transaction.commit(collected.writes)); +} + +export async function rejectPreparedDeclaredFsWrites( + prepared: PreparedDeclaredFsWrites | undefined, + reason: string, +): Promise { + if (!prepared) return { ok: true, committedPaths: [] }; + return fromFileTransactionResult(await prepared.transaction.reject(reason)); +} + +/** Resolve phase output into the exact payload set admitted before execution. */ +export function declaredFsWritesFromPhaseOutput( + effects: unknown, + phaseOutput: string, +): CollectDeclaredFsWritesResult { + if (effects === undefined || effects === null) return { ok: true, writes: [] }; + if (!Array.isArray(effects)) { + return { ok: false, code: "effects-not-array", reason: "phase.effects must be an array" }; + } + if (effects.length === 0) return { ok: true, writes: [] }; + + const declarations: Array<{ effectId: string; relativePath: string }> = []; + for (const raw of effects) { + if (!raw || typeof raw !== "object") { + return { ok: false, code: "invalid-effect", reason: "each effect must be an object" }; + } + const effect = raw as Record; + const effectId = typeof effect.id === "string" && effect.id.length > 0 ? effect.id : ""; + if (!effectId) return { ok: false, code: "invalid-effect", reason: "effect requires non-empty id" }; + if (effect.kind === "fs.delete") { + return { + ok: false, + code: "unsupported-effect-kind", + reason: `effect '${effectId}': fs.delete is not supported by the file transaction`, + }; + } + if (effect.kind !== "fs.write") continue; + const target = effect.target; + if (!target || typeof target !== "object" || (target as { kind?: unknown }).kind !== "path") { + return { ok: false, code: "invalid-path-ref", reason: `effect '${effectId}': fs.write requires a path target` }; + } + const pathRef = (target as { path?: unknown }).path; + if (!pathRef || typeof pathRef !== "object") { + return { ok: false, code: "invalid-path-ref", reason: `effect '${effectId}': fs.write path target requires PathRef` }; + } + // Authority/path resolution already happened before the body through the + // resource binding. Payload mapping needs only the stable effect id; retain + // a diagnostic label for dynamic PathRefs instead of re-resolving them here. + const relativePath = pathRefRelativeKey(pathRef as PathRef) ?? ``; + if (relativePath === "") { + return { ok: false, code: "path-too-broad", reason: `effect '${effectId}': cannot write whole workspace root` }; + } + declarations.push({ effectId, relativePath }); + } + if (declarations.length === 0) return { ok: true, writes: [] }; + if (declarations.length === 1) { + const declaration = declarations[0]!; + return { + ok: true, + writes: [{ effectId: declaration.effectId, relativePath: declaration.relativePath, content: phaseOutput }], + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(phaseOutput); + } catch { + return { + ok: false, + code: "content-resolution-failed", + reason: "multiple fs.write effects require a JSON object mapping effect id to string content", + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false, + code: "content-resolution-failed", + reason: "multiple fs.write effects require a JSON object mapping effect id to string content", + }; + } + const map = parsed as Record; + const writes: DeclaredFsWrite[] = []; + for (const declaration of declarations) { + const content = map[declaration.effectId]; + if (typeof content !== "string") { + return { + ok: false, + code: "content-resolution-failed", + reason: `missing string content for effect '${declaration.effectId}' in phase JSON output`, + }; + } + writes.push({ ...declaration, content }); + } + return { ok: true, writes }; +} diff --git a/packages/taskflow-core/src/effects/schema.ts b/packages/taskflow-core/src/effects/schema.ts new file mode 100644 index 00000000..1423f1e0 --- /dev/null +++ b/packages/taskflow-core/src/effects/schema.ts @@ -0,0 +1,82 @@ +/** Closed TypeBox contract for EffectIR. Runtime authority still lives in resources/*. */ + +import { Type } from "typebox"; +import { PathRefSchema } from "../resources/schema.ts"; +import { StringEnum } from "../typebox-helpers.ts"; +import { + CONFIDENTIALITY_LABELS, + EFFECT_KINDS, + INTEGRITY_LABELS, + type EffectDecl, +} from "./types.ts"; + +const ConfidentialitySchema = StringEnum(CONFIDENTIALITY_LABELS); +const IntegritySchema = StringEnum(INTEGRITY_LABELS); + +const common = { + id: Type.String({ minLength: 1 }), + confidentiality: Type.Optional(ConfidentialitySchema), + integrity: Type.Optional(IntegritySchema), + purpose: Type.Optional(Type.String()), +}; + +const PathTargetSchema = Type.Object( + { kind: Type.Literal("path"), path: PathRefSchema }, + { additionalProperties: false }, +); + +const SecretTargetSchema = Type.Object( + { + kind: Type.Literal("secret"), + secret: Type.Object( + { + secretId: Type.String({ minLength: 1 }), + issuer: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +const ServiceTargetSchema = Type.Object( + { + kind: Type.Literal("service"), + service: Type.Object( + { + serviceId: Type.String({ minLength: 1 }), + operation: Type.Optional(Type.String({ minLength: 1 })), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +const fsKinds = EFFECT_KINDS.filter((kind) => kind.startsWith("fs.")); + +const ClosedEffectDeclSchema = Type.Union([ + ...fsKinds.map((kind) => Type.Object( + { ...common, kind: Type.Literal(kind), target: PathTargetSchema }, + { additionalProperties: false }, + )), + Type.Object( + { ...common, kind: Type.Literal("secret.read"), target: SecretTargetSchema }, + { additionalProperties: false }, + ), + Type.Object( + { ...common, kind: Type.Literal("service.call"), target: ServiceTargetSchema }, + { additionalProperties: false }, + ), +]); + +// PathRefSchema deliberately uses optional Never fields to encode XOR, which +// TypeBox validates correctly at runtime but currently infers as `never` when +// nested through a union. Preserve the closed runtime schema while exposing +// the independently reviewed EffectDecl TypeScript contract. +export const EffectDeclSchema = Type.Unsafe(ClosedEffectDeclSchema); + +export const EffectIRSchema = Type.Object( + { effects: Type.Array(EffectDeclSchema) }, + { additionalProperties: false }, +); diff --git a/packages/taskflow-core/src/effects/types.ts b/packages/taskflow-core/src/effects/types.ts new file mode 100644 index 00000000..9e705b80 --- /dev/null +++ b/packages/taskflow-core/src/effects/types.ts @@ -0,0 +1,130 @@ +/** + * Trusted Effects (0.3 MVP) — pure type contract. + * + * EffectIR is the closed vocabulary of side effects a FlowIR node may declare. + * PathRef is reused from resources/*; SecretRef/ServiceRef are typed handles + * that fail closed until a real backend is bound (MVP: type + validation only). + * + * @see docs/internal/0.3.0-trusted-effects-mvp.md + */ + +import type { PathRef } from "../resources/schema.ts"; + +// --------------------------------------------------------------------------- +// Information-flow labels (MVP fixed lattice) +// --------------------------------------------------------------------------- + +/** Confidentiality lattice (low → high). Higher may not flow to lower sinks. */ +export const CONFIDENTIALITY_LABELS = ["public", "internal", "secret"] as const; +export type ConfidentialityLabel = (typeof CONFIDENTIALITY_LABELS)[number]; + +/** Integrity lattice (low → high). Lower integrity must not overwrite higher. */ +export const INTEGRITY_LABELS = ["untrusted", "project", "verified"] as const; +export type IntegrityLabel = (typeof INTEGRITY_LABELS)[number]; + +export const CONFIDENTIALITY_RANK: Record = { + public: 0, + internal: 1, + secret: 2, +}; + +export const INTEGRITY_RANK: Record = { + untrusted: 0, + project: 1, + verified: 2, +}; + +// --------------------------------------------------------------------------- +// Refs +// --------------------------------------------------------------------------- + +/** + * Opaque secret handle — never carries secret material. + * MVP: validation only; no vault backend. + */ +export interface SecretRef { + secretId: string; + issuer?: string; +} + +/** + * External service endpoint handle — no ambient network authority from strings. + * MVP: validation only; no live adapter. + */ +export interface ServiceRef { + serviceId: string; + /** Optional logical operation name (e.g. "createIssue"). */ + operation?: string; +} + +export type EffectTarget = + | { kind: "path"; path: PathRef } + | { kind: "secret"; secret: SecretRef } + | { kind: "service"; service: ServiceRef }; + +// --------------------------------------------------------------------------- +// Effect kinds (closed set for MVP) +// --------------------------------------------------------------------------- + +export const EFFECT_KINDS = [ + "fs.read", + "fs.write", + "fs.delete", + "secret.read", + "service.call", +] as const; +export type EffectKind = (typeof EFFECT_KINDS)[number]; + +/** + * One declared side effect on a FlowIR / phase node. + * `id` is stable within the flow for why-effect attribution. + */ +export interface EffectDecl { + id: string; + kind: EffectKind; + target: EffectTarget; + confidentiality?: ConfidentialityLabel; + integrity?: IntegrityLabel; + /** Free-text purpose for why-* explainers (not authority). */ + purpose?: string; +} + +/** EffectIR: bag of effects attached to a node or whole flow. */ +export interface EffectIR { + effects: EffectDecl[]; +} + +// --------------------------------------------------------------------------- +// why-* records +// --------------------------------------------------------------------------- + +export interface WhyAuthorized { + effectId: string; + allowed: boolean; + principalId?: string; + capabilityBindingIds: string[]; + reasons: string[]; +} + +export interface WhyContext { + effectId: string; + runId: string; + phaseId?: string; + confidentiality: ConfidentialityLabel; + integrity: IntegrityLabel; + workspaceRoot?: string; + reasons: string[]; +} + +export interface WhyEffect { + effectId: string; + kind: EffectKind; + targetSummary: string; + purpose?: string; + intentId?: string; + journalStatus?: string; + status: "declared" | "staged" | "committed" | "rejected" | "unknown" | "skipped"; + reasons: string[]; + authorized: WhyAuthorized; + context: WhyContext; +} diff --git a/packages/taskflow-core/src/effects/validate.ts b/packages/taskflow-core/src/effects/validate.ts new file mode 100644 index 00000000..400eda05 --- /dev/null +++ b/packages/taskflow-core/src/effects/validate.ts @@ -0,0 +1,445 @@ +/** + * Static EffectIR validation: kinds, refs, labels, mutating-path overlap. + * Pure — no I/O. + */ + +import { normalizePortableRelativePath, type PathRef } from "../resources/schema.ts"; +import { Value } from "typebox/value"; +import { EffectDeclSchema } from "./schema.ts"; +import { + CONFIDENTIALITY_LABELS, + CONFIDENTIALITY_RANK, + EFFECT_KINDS, + INTEGRITY_LABELS, + INTEGRITY_RANK, + type ConfidentialityLabel, + type EffectDecl, + type EffectIR, + type EffectKind, + type IntegrityLabel, + type SecretRef, + type ServiceRef, +} from "./types.ts"; + +export interface EffectValidationIssue { + severity: "error" | "warning"; + code: string; + message: string; + effectId?: string; + pathA?: string; + pathB?: string; +} + +export interface EffectValidationResult { + ok: boolean; + issues: EffectValidationIssue[]; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isConf(v: unknown): v is ConfidentialityLabel { + return typeof v === "string" && (CONFIDENTIALITY_LABELS as readonly string[]).includes(v); +} + +function isInteg(v: unknown): v is IntegrityLabel { + return typeof v === "string" && (INTEGRITY_LABELS as readonly string[]).includes(v); +} + +function isEffectKind(v: unknown): v is EffectKind { + return typeof v === "string" && (EFFECT_KINDS as readonly string[]).includes(v); +} + +type ValidEffectRecord = Record & { id: string; kind: EffectKind }; + +const SOURCE_KINDS: ReadonlySet = new Set(["fs.read", "secret.read"]); +const SINK_KINDS: ReadonlySet = new Set(["fs.write", "service.call"]); + +function confidentialityOf(item: ValidEffectRecord): ConfidentialityLabel { + return isConf(item.confidentiality) ? item.confidentiality : item.kind === "secret.read" ? "secret" : "internal"; +} + +function integrityOf(item: ValidEffectRecord): IntegrityLabel { + return isInteg(item.integrity) ? item.integrity : "project"; +} + +function labelFlowIssues( + source: ValidEffectRecord, + sink: ValidEffectRecord, + sourceLabel = source.id, + sinkLabel = sink.id, +): EffectValidationIssue[] { + const issues: EffectValidationIssue[] = []; + const sourceConf = confidentialityOf(source); + const sinkConf = confidentialityOf(sink); + const sourceIntegrity = integrityOf(source); + const sinkIntegrity = integrityOf(sink); + if (CONFIDENTIALITY_RANK[sourceConf] > CONFIDENTIALITY_RANK[sinkConf]) { + issues.push({ + severity: "error", + code: "confidentiality-flow-violation", + message: `source effect '${sourceLabel}' (${sourceConf}) cannot flow to sink '${sinkLabel}' (${sinkConf})`, + effectId: sinkLabel, + }); + } + if (INTEGRITY_RANK[sourceIntegrity] < INTEGRITY_RANK[sinkIntegrity]) { + issues.push({ + severity: "error", + code: "integrity-flow-violation", + message: `source effect '${sourceLabel}' (${sourceIntegrity}) cannot satisfy sink '${sinkLabel}' integrity (${sinkIntegrity})`, + effectId: sinkLabel, + }); + } + return issues; +} + +/** Extract portable relative path string from a PathRef for overlap checks. */ +export function pathRefRelativeKey(pathRef: PathRef): string | undefined { + const sub = pathRef.subpath; + if (!sub) return ""; + if ("literalPath" in sub && typeof sub.literalPath === "string") { + const n = normalizePortableRelativePath(sub.literalPath); + return n.ok ? n.value : undefined; + } + // Dynamic arg/segments paths cannot be fully resolved statically. + return undefined; +} + +/** + * True if path A is the same as, a parent of, or a child of path B + * (portable `/` paths, no `..`). + */ +export function pathsOverlap(a: string, b: string): boolean { + if (a === b) return true; + if (a === "" || b === "") return true; // whole workspace + const ap = a.endsWith("/") ? a.slice(0, -1) : a; + const bp = b.endsWith("/") ? b.slice(0, -1) : b; + return ap.startsWith(bp + "/") || bp.startsWith(ap + "/"); +} + +function validateSecretRef(s: unknown, effectId: string, issues: EffectValidationIssue[]): void { + if (!isObject(s) || typeof s.secretId !== "string" || !s.secretId.trim()) { + issues.push({ + severity: "error", + code: "invalid-secret-ref", + message: `effect '${effectId}': secret target requires non-empty secretId`, + effectId, + }); + return; + } + if ("value" in s || "material" in s || "token" in s) { + issues.push({ + severity: "error", + code: "secret-material-forbidden", + message: `effect '${effectId}': SecretRef must not carry secret material fields`, + effectId, + }); + } +} + +function validateServiceRef(s: unknown, effectId: string, issues: EffectValidationIssue[]): void { + if (!isObject(s) || typeof s.serviceId !== "string" || !s.serviceId.trim()) { + issues.push({ + severity: "error", + code: "invalid-service-ref", + message: `effect '${effectId}': service target requires non-empty serviceId`, + effectId, + }); + } +} + +function validatePathTarget(pathRef: unknown, effectId: string, issues: EffectValidationIssue[]): PathRef | undefined { + if (!isObject(pathRef)) { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': path target must be a PathRef object`, + effectId, + }); + return undefined; + } + const hasWorkspace = typeof pathRef.workspace === "string" && pathRef.workspace.length > 0; + const hasHandle = isObject(pathRef.handle); + if (hasWorkspace === hasHandle) { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': PathRef needs exactly one of workspace or handle`, + effectId, + }); + return undefined; + } + if (!pathRef.intent || typeof pathRef.intent !== "string") { + issues.push({ + severity: "error", + code: "invalid-path-ref", + message: `effect '${effectId}': PathRef.intent is required`, + effectId, + }); + return undefined; + } + return pathRef as unknown as PathRef; +} + +const MUTATING_KINDS: ReadonlySet = new Set(["fs.write", "fs.delete"]); + +/** + * Validate an EffectIR bag: shape, labels, target/kind consistency, overlap. + */ +export function validateEffectIR(ir: EffectIR | { effects?: unknown }): EffectValidationResult { + const issues: EffectValidationIssue[] = []; + const raw = ir?.effects; + if (raw === undefined) return { ok: true, issues: [] }; + if (!Array.isArray(raw)) { + return { + ok: false, + issues: [{ severity: "error", code: "effects-not-array", message: "effects must be an array" }], + }; + } + + const seenIds = new Set(); + const mutating: Array<{ effectId: string; pathKey: string }> = []; + + for (const item of raw) { + if (!isObject(item)) { + issues.push({ severity: "error", code: "effect-not-object", message: "each effect must be an object" }); + continue; + } + if (!Value.Check(EffectDeclSchema, item)) { + issues.push({ + severity: "error", + code: "invalid-effect-shape", + message: `effect '${typeof item.id === "string" ? item.id : ""}': declaration is outside the closed EffectIR schema`, + effectId: typeof item.id === "string" ? item.id : undefined, + }); + } + const id = item.id; + if (typeof id !== "string" || !id.trim()) { + issues.push({ severity: "error", code: "effect-id-required", message: "effect.id is required" }); + continue; + } + if (seenIds.has(id)) { + issues.push({ + severity: "error", + code: "duplicate-effect-id", + message: `duplicate effect id '${id}'`, + effectId: id, + }); + } + seenIds.add(id); + + if (!isEffectKind(item.kind)) { + issues.push({ + severity: "error", + code: "unknown-effect-kind", + message: `effect '${id}': unknown kind ${String(item.kind)}`, + effectId: id, + }); + continue; + } + const kind = item.kind; + + if (item.confidentiality !== undefined && !isConf(item.confidentiality)) { + issues.push({ + severity: "error", + code: "invalid-confidentiality", + message: `effect '${id}': invalid confidentiality label`, + effectId: id, + }); + } + if (item.integrity !== undefined && !isInteg(item.integrity)) { + issues.push({ + severity: "error", + code: "invalid-integrity", + message: `effect '${id}': invalid integrity label`, + effectId: id, + }); + } + + const target = item.target; + if (!isObject(target) || typeof target.kind !== "string") { + issues.push({ + severity: "error", + code: "invalid-target", + message: `effect '${id}': target.kind is required`, + effectId: id, + }); + continue; + } + + if (kind.startsWith("fs.")) { + if (target.kind !== "path") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': fs.* requires target.kind === "path"`, + effectId: id, + }); + } else { + const pref = validatePathTarget(target.path, id, issues); + if (pref && MUTATING_KINDS.has(kind)) { + const key = pathRefRelativeKey(pref); + if (key === undefined) { + issues.push({ + severity: "warning", + code: "dynamic-path-overlap-unknown", + message: `effect '${id}': mutating path is dynamic; overlap cannot be proven statically`, + effectId: id, + }); + } else { + mutating.push({ effectId: id, pathKey: key }); + } + } + } + } else if (kind === "secret.read") { + if (target.kind !== "secret") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': secret.read requires target.kind === "secret"`, + effectId: id, + }); + } else { + validateSecretRef(target.secret, id, issues); + } + } else if (kind === "service.call") { + if (target.kind !== "service") { + issues.push({ + severity: "error", + code: "target-kind-mismatch", + message: `effect '${id}': service.call requires target.kind === "service"`, + effectId: id, + }); + } else { + validateServiceRef(target.service, id, issues); + } + } + } + + // Mutating path overlap (static) + for (let i = 0; i < mutating.length; i++) { + for (let j = i + 1; j < mutating.length; j++) { + const a = mutating[i]!; + const b = mutating[j]!; + if (pathsOverlap(a.pathKey, b.pathKey)) { + issues.push({ + severity: "error", + code: "mutating-path-overlap", + message: + `mutating effects '${a.effectId}' and '${b.effectId}' overlap on path ` + + `('${a.pathKey || ""}' vs '${b.pathKey || ""}')`, + effectId: a.effectId, + pathA: a.pathKey, + pathB: b.pathKey, + }); + } + } + } + + // Conservative information-flow check. Read declarations are sources; + // writes/calls are sinks. Without an explicit data-flow subgraph, every + // declared source may influence every declared sink in the same phase. + // Confidentiality may only flow upward (sink clearance >= source label), + // while integrity may only flow downward (source trust >= sink requirement). + const valid = raw.filter((item): item is ValidEffectRecord => + isObject(item) && typeof item.id === "string" && isEffectKind(item.kind)); + const sources = valid.filter((item) => SOURCE_KINDS.has(item.kind)); + const sinks = valid.filter((item) => SINK_KINDS.has(item.kind)); + for (const source of sources) { + for (const sink of sinks) { + issues.push(...labelFlowIssues(source, sink)); + } + } + + const ok = !issues.some((i) => i.severity === "error"); + return { ok, issues }; +} + +export interface EffectFlowPhaseLike { + id?: unknown; + effects?: unknown; + dependsOn?: unknown; + from?: unknown; +} + +/** + * Conservative DAG-wide label flow. Every source reachable through a phase's + * dependencies may influence that phase's sinks, including through unlabeled + * intermediate phases. This is a pure static check; conditional branches are + * intentionally not used to declassify data. + */ +export function validateEffectFlow( + phases: readonly EffectFlowPhaseLike[], + dependencyIds: (phase: EffectFlowPhaseLike) => readonly string[] = (phase) => [ + ...(Array.isArray(phase.dependsOn) ? phase.dependsOn.filter((id): id is string => typeof id === "string") : []), + ...(Array.isArray(phase.from) ? phase.from.filter((id): id is string => typeof id === "string") : []), + ], +): EffectValidationResult { + const issues: EffectValidationIssue[] = []; + const byId = new Map(); + const ownSources = new Map>(); + for (const phase of phases) { + if (typeof phase.id !== "string" || !phase.id) continue; + byId.set(phase.id, phase); + const sources = new Map(); + for (const effect of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(effect) || typeof effect.id !== "string" || !isEffectKind(effect.kind)) continue; + const valid = effect as ValidEffectRecord; + if (SOURCE_KINDS.has(valid.kind)) sources.set(`${phase.id}/${valid.id}`, valid); + } + ownSources.set(phase.id, sources); + } + const reachable = new Map([...ownSources].map(([id, sources]) => [id, new Map(sources)])); + for (let pass = 0; pass < byId.size; pass++) { + let changed = false; + for (const [phaseId, phase] of byId) { + const target = reachable.get(phaseId)!; + for (const depId of dependencyIds(phase)) { + for (const [sourceId, source] of reachable.get(depId) ?? []) { + if (!target.has(sourceId)) { + target.set(sourceId, source); + changed = true; + } + } + } + } + if (!changed) break; + } + for (const [phaseId, phase] of byId) { + const upstreamSources = [...(reachable.get(phaseId) ?? [])] + .filter(([sourceId]) => !sourceId.startsWith(`${phaseId}/`)); + if (upstreamSources.length === 0) continue; + for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; + const sink = raw as ValidEffectRecord; + if (!SINK_KINDS.has(sink.kind)) continue; + for (const [sourceId, source] of upstreamSources) { + issues.push(...labelFlowIssues(source, sink, sourceId, `${phaseId}/${sink.id}`)); + } + } + } + return { ok: !issues.some((issue) => issue.severity === "error"), issues }; +} + +/** Type guard for a well-formed SecretRef (no material). */ +export function isSecretRef(v: unknown): v is SecretRef { + return isObject(v) && typeof v.secretId === "string" && v.secretId.length > 0 && !("value" in v) && !("material" in v); +} + +/** Type guard for ServiceRef. */ +export function isServiceRef(v: unknown): v is ServiceRef { + return isObject(v) && typeof v.serviceId === "string" && v.serviceId.length > 0; +} + +/** Collect mutating path keys from effects (static literals only). */ +export function collectMutatingPathKeys(effects: EffectDecl[]): Array<{ effectId: string; pathKey: string }> { + const out: Array<{ effectId: string; pathKey: string }> = []; + for (const e of effects) { + if (!MUTATING_KINDS.has(e.kind)) continue; + if (e.target.kind !== "path") continue; + const key = pathRefRelativeKey(e.target.path); + if (key !== undefined) out.push({ effectId: e.id, pathKey: key }); + } + return out; +} diff --git a/packages/taskflow-core/src/effects/why.ts b/packages/taskflow-core/src/effects/why.ts new file mode 100644 index 00000000..23ba3e8f --- /dev/null +++ b/packages/taskflow-core/src/effects/why.ts @@ -0,0 +1,445 @@ +/** + * why-authorized / why-context / why-effect — pure explainers over effect + auth records. + */ + +import type { + ConfidentialityLabel, + EffectDecl, + EffectKind, + IntegrityLabel, + WhyAuthorized, + WhyContext, + WhyEffect, +} from "./types.ts"; +import { EFFECT_KINDS } from "./types.ts"; +import { pathRefRelativeKey, validateEffectIR } from "./validate.ts"; +import { defaultWorkspaceControlDirectory } from "../resources/execution.ts"; +import { WriteIntentJournal, type WriteIntentRecord } from "../resources/journal.ts"; + +export interface WhyInput { + effect: EffectDecl; + runId: string; + phaseId?: string; + principalId?: string; + /** Capability binding ids that covered this effect (if any). */ + capabilityBindingIds?: string[]; + /** Whether static/admit validation allowed the effect. */ + allowed: boolean; + allowReasons: string[]; + denyReasons?: string[]; + intentId?: string; + journalStatus?: string; + status: WhyEffect["status"]; + workspaceRoot?: string; + defaultConfidentiality?: ConfidentialityLabel; + defaultIntegrity?: IntegrityLabel; +} + +function targetSummary(effect: EffectDecl): string { + const t = effect.target; + if (t.kind === "path") { + const key = pathRefRelativeKey(t.path); + const ws = "workspace" in t.path ? t.path.workspace : "handle"; + return `path:${ws}:${key ?? ""}`; + } + if (t.kind === "secret") return `secret:${t.secret.secretId}`; + return `service:${t.service.serviceId}${t.service.operation ? `:${t.service.operation}` : ""}`; +} + +export function whyAuthorized(input: WhyInput): WhyAuthorized { + const reasons = input.allowed + ? [...input.allowReasons] + : [...(input.denyReasons ?? ["effect not authorized"])]; + if (input.allowed && reasons.length === 0) { + reasons.push("effect declared on plan and passed validateEffectIR"); + } + return { + effectId: input.effect.id, + allowed: input.allowed, + principalId: input.principalId, + capabilityBindingIds: input.capabilityBindingIds ?? [], + reasons, + }; +} + +export function whyContext(input: WhyInput): WhyContext { + const confidentiality = input.effect.confidentiality ?? input.defaultConfidentiality ?? "internal"; + const integrity = input.effect.integrity ?? input.defaultIntegrity ?? "project"; + const reasons: string[] = [ + `run=${input.runId}`, + `confidentiality=${confidentiality}`, + `integrity=${integrity}`, + ]; + if (input.phaseId) reasons.push(`phase=${input.phaseId}`); + if (input.workspaceRoot) reasons.push(`workspaceRoot=${input.workspaceRoot}`); + return { + effectId: input.effect.id, + runId: input.runId, + phaseId: input.phaseId, + confidentiality, + integrity, + workspaceRoot: input.workspaceRoot, + reasons, + }; +} + +export function whyEffect(input: WhyInput): WhyEffect { + const authorized = whyAuthorized(input); + const context = whyContext(input); + const reasons: string[] = [ + `kind=${input.effect.kind}`, + `status=${input.status}`, + ...authorized.reasons, + ]; + if (input.effect.purpose) reasons.push(`purpose=${input.effect.purpose}`); + if (input.intentId) reasons.push(`intent=${input.intentId}`); + if (input.journalStatus) reasons.push(`journalStatus=${input.journalStatus}`); + return { + effectId: input.effect.id, + kind: input.effect.kind, + targetSummary: targetSummary(input.effect), + purpose: input.effect.purpose, + intentId: input.intentId, + journalStatus: input.journalStatus, + status: input.status, + reasons, + authorized, + context, + }; +} + +// --------------------------------------------------------------------------- +// Flow-scoped lookup: whyEffect(runId, effectId) surface for MCP / hosts +// --------------------------------------------------------------------------- + +/** Minimal flow shape for effect lookup (Taskflow / FlowIR phases). */ +export interface WhyEffectFlowLike { + effects?: unknown; + phases?: ReadonlyArray<{ id?: string; effects?: unknown } | null | undefined>; +} + +export interface WhyEffectFromFlowInput { + /** Flow definition that declares effects (typically RunState.def). */ + flow: WhyEffectFlowLike; + runId: string; + /** Effect id as declared, or `phaseId/effectId` composite. */ + effectId: string; + /** Disambiguate when the same effect id appears on multiple phases. */ + phaseId?: string; + workspaceRoot?: string; + /** Runtime lifecycle status; MCP defaults to `declared` (read-only explain). */ + status?: WhyEffect["status"]; + intentId?: string; + principalId?: string; + capabilityBindingIds?: string[]; +} + +export type WhyEffectFromFlowResult = + | { ok: true; why: WhyEffect; phaseId?: string } + | { ok: false; error: string }; + +interface LocatedEffect { + effect: EffectDecl; + phaseId?: string; + /** Id used for bag validation (phase-prefixed when from a phase). */ + bagId: string; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function asEffectDecl(raw: unknown): EffectDecl | undefined { + if (!isObject(raw)) return undefined; + const id = raw.id; + const kind = raw.kind; + const target = raw.target; + if (typeof id !== "string" || !id.trim()) return undefined; + if (typeof kind !== "string" || !(EFFECT_KINDS as readonly string[]).includes(kind)) return undefined; + if (!isObject(target) || typeof target.kind !== "string") return undefined; + return raw as unknown as EffectDecl; +} + +/** Collect declared effects from flow-level + each phase (original ids preserved). */ +export function collectDeclaredEffects(flow: WhyEffectFlowLike): LocatedEffect[] { + const out: LocatedEffect[] = []; + const flowLevel = flow.effects; + if (Array.isArray(flowLevel)) { + for (const raw of flowLevel) { + const e = asEffectDecl(raw); + if (e) out.push({ effect: e, bagId: e.id }); + else if (isObject(raw) && typeof raw.id === "string") { + // Keep a stub so validation can still surface shape errors via bag. + out.push({ + effect: raw as unknown as EffectDecl, + bagId: raw.id, + }); + } + } + } + const phases = Array.isArray(flow.phases) ? flow.phases : []; + for (const p of phases) { + if (!p || typeof p !== "object") continue; + const phaseId = typeof p.id === "string" ? p.id : undefined; + const pe = p.effects; + if (!Array.isArray(pe)) continue; + for (const raw of pe) { + const e = asEffectDecl(raw); + if (e) { + out.push({ + effect: e, + phaseId, + bagId: phaseId ? `${phaseId}/${e.id}` : e.id, + }); + } else if (isObject(raw) && typeof raw.id === "string") { + out.push({ + effect: raw as unknown as EffectDecl, + phaseId, + bagId: phaseId ? `${phaseId}/${raw.id}` : raw.id, + }); + } + } + } + return out; +} + +function matchesEffectId(loc: LocatedEffect, effectId: string, phaseId?: string): boolean { + if (phaseId !== undefined) { + if (loc.phaseId !== phaseId) return false; + return loc.effect.id === effectId || loc.bagId === effectId; + } + // Bare id, composite bag id, or phaseId/effectId string. + if (loc.effect.id === effectId) return true; + if (loc.bagId === effectId) return true; + if (loc.phaseId && effectId === `${loc.phaseId}/${loc.effect.id}`) return true; + return false; +} + +/** + * Pure: resolve a declared effect from a flow and explain authorization + context. + * + * Fail-closed: if static validation reports any error for this effect (or the + * bag cannot be validated), `authorized.allowed` is false with deny reasons. + * Missing effect → `{ ok: false }` (MCP returns isError). + * + * Zero tokens / no I/O. Runtime commit status is optional (`status` defaults + * to `"declared"`). Use `whyEffectFromLedger` for authorization claims. + */ +export function whyEffectFromFlow(input: WhyEffectFromFlowInput): WhyEffectFromFlowResult { + const effectId = input.effectId.trim(); + if (!effectId) return { ok: false, error: "effectId is required" }; + if (!input.runId.trim()) return { ok: false, error: "runId is required" }; + + const located = collectDeclaredEffects(input.flow); + if (located.length === 0) { + return { ok: false, error: `No declared effects on run "${input.runId}" (flow has empty effects[]).` }; + } + + const matches = located.filter((l) => matchesEffectId(l, effectId, input.phaseId)); + if (matches.length === 0) { + const known = located + .map((l) => (l.phaseId ? `${l.phaseId}/${l.effect.id}` : l.effect.id)) + .slice(0, 12); + const more = located.length > 12 ? ` (+${located.length - 12} more)` : ""; + const scope = input.phaseId ? ` in phase "${input.phaseId}"` : ""; + return { + ok: false, + error: + `Effect "${effectId}" not found${scope} on run "${input.runId}". ` + + `Known: ${known.join(", ") || "—"}${more}`, + }; + } + if (matches.length > 1) { + const candidates = matches.map((m) => + m.phaseId ? `${m.phaseId}/${m.effect.id}` : m.effect.id, + ); + return { + ok: false, + error: + `Effect id "${effectId}" is ambiguous (matches ${matches.length} declarations: ` + + `${candidates.join(", ")}). Pass phaseId to disambiguate.`, + }; + } + + const hit = matches[0]!; + const bagEffects = located.map((l) => ({ + ...l.effect, + id: l.bagId, + })); + const validation = validateEffectIR({ effects: bagEffects }); + const effectIssues = validation.issues.filter( + (i) => + i.effectId === hit.bagId || + i.effectId === hit.effect.id || + (hit.phaseId && i.effectId === `${hit.phaseId}/${hit.effect.id}`), + ); + // Fail closed: any error on this effect, or unknown kind / malformed target, denies. + const denyFromIssues = effectIssues + .filter((i) => i.severity === "error") + .map((i) => i.message); + const kindOk = (EFFECT_KINDS as readonly string[]).includes(hit.effect.kind as EffectKind); + if (!kindOk) { + denyFromIssues.push(`unknown effect kind: ${String(hit.effect.kind)}`); + } + // Also deny if effect cannot form a coherent EffectDecl (asEffectDecl failed shape). + const shapeOk = asEffectDecl(hit.effect) !== undefined; + if (!shapeOk) { + denyFromIssues.push("effect declaration is not a well-formed EffectDecl"); + } + + const allowed = denyFromIssues.length === 0; + const status = input.status ?? "declared"; + const why = whyEffect({ + effect: hit.effect, + runId: input.runId, + phaseId: hit.phaseId, + principalId: input.principalId, + capabilityBindingIds: input.capabilityBindingIds, + allowed, + allowReasons: allowed + ? ["declared on flow plan", "validateEffectIR ok for this effect"] + : [], + denyReasons: allowed ? undefined : denyFromIssues, + intentId: input.intentId, + status, + workspaceRoot: input.workspaceRoot, + }); + return { ok: true, why, phaseId: hit.phaseId }; +} + +export interface WhyEffectFromLedgerInput extends Omit { + intents: readonly WriteIntentRecord[]; + ledgerError?: string; +} + +function lifecycleStatus(status: WriteIntentRecord["status"]): WhyEffect["status"] { + if (status === "pending") return "staged"; + if (status === "committed-content" || status === "committed-generation") return "committed"; + if (status === "dirty-unknown") return "unknown"; + return "rejected"; +} + +/** + * Explain authority from the durable resource ledger, not from declaration + * alone. A statically valid effect without a matching write intent remains + * unauthorized because no mutation permit was durably admitted for it. + */ +export function whyEffectFromLedger(input: WhyEffectFromLedgerInput): WhyEffectFromFlowResult { + const declared = whyEffectFromFlow({ + flow: input.flow, + runId: input.runId, + effectId: input.effectId, + phaseId: input.phaseId, + workspaceRoot: input.workspaceRoot, + status: "declared", + }); + if (!declared.ok) return declared; + const phaseId = declared.phaseId; + const matches = input.intents + .filter((intent) => + intent.owner.runId === input.runId && + (phaseId === undefined || intent.owner.phaseId === phaseId) && + intent.scopes.some((scope) => scope.effectId === declared.why.effectId)) + .sort((left, right) => right.intentSequence - left.intentSequence); + const intent = matches[0]; + const staticAllowed = declared.why.authorized.allowed; + if (!intent) { + const reason = input.ledgerError + ? `durable resource ledger unavailable: ${input.ledgerError}` + : "no durable resource intent admitted this effect for the requested run/phase"; + return { + ok: true, + phaseId, + why: { + ...declared.why, + status: "declared", + reasons: [`kind=${declared.why.kind}`, "status=declared", reason], + authorized: { + ...declared.why.authorized, + allowed: false, + principalId: undefined, + capabilityBindingIds: [], + reasons: staticAllowed + ? [reason] + : [...declared.why.authorized.reasons, reason], + }, + }, + }; + } + const effectScopes = intent.scopes.filter((scope) => scope.effectId === declared.why.effectId); + const capabilityBindingIds = [...new Set(effectScopes + .map((scope) => scope.capabilityBindingId) + .filter((value): value is string => typeof value === "string" && value.length > 0))]; + const allowed = staticAllowed && capabilityBindingIds.length > 0 && intent.authorizationPrincipalId !== undefined; + const ledgerReasons = [ + `durable resource intent ${intent.intentId}`, + `journal status ${intent.status}`, + `scope evidence ${effectScopes.length}`, + ...(intent.commitGeneration === undefined ? [] : [`commit generation ${intent.commitGeneration}`]), + ]; + if (capabilityBindingIds.length === 0) ledgerReasons.push("missing capability binding evidence"); + if (!intent.authorizationPrincipalId) ledgerReasons.push("missing authenticated principal evidence"); + return { + ok: true, + phaseId, + why: { + ...declared.why, + intentId: intent.intentId, + journalStatus: intent.status, + status: lifecycleStatus(intent.status), + reasons: [ + `kind=${declared.why.kind}`, + `status=${lifecycleStatus(intent.status)}`, + ...ledgerReasons, + ], + authorized: { + effectId: declared.why.effectId, + allowed, + principalId: intent.authorizationPrincipalId, + capabilityBindingIds, + reasons: staticAllowed ? ledgerReasons : [...declared.why.authorized.reasons, ...ledgerReasons], + }, + }, + }; +} + +export async function whyEffectFromDurableJournal( + input: Omit & { controlDirectory?: string }, +): Promise { + try { + const directory = input.controlDirectory ?? defaultWorkspaceControlDirectory(input.workspaceRoot ?? process.cwd()); + const intents = await new WriteIntentJournal({ directory, journalEpoch: 1 }).listIntents(); + return whyEffectFromLedger({ ...input, intents }); + } catch (error) { + return whyEffectFromLedger({ + ...input, + intents: [], + ledgerError: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Plain-text render for MCP / CLI (no markdown fences). */ +export function formatWhyEffect(why: WhyEffect): string { + const lines: string[] = [ + `why-effect ${why.effectId}`, + ` kind: ${why.kind}`, + ` target: ${why.targetSummary}`, + ` status: ${why.status}`, + ` authorized: ${why.authorized.allowed ? "yes" : "NO (fail-closed)"}`, + ]; + if (why.purpose) lines.push(` purpose: ${why.purpose}`); + if (why.intentId) lines.push(` intent: ${why.intentId}`); + if (why.journalStatus) lines.push(` journalStatus: ${why.journalStatus}`); + if (why.context.phaseId) lines.push(` phase: ${why.context.phaseId}`); + lines.push(` confidentiality: ${why.context.confidentiality}`); + lines.push(` integrity: ${why.context.integrity}`); + if (why.context.workspaceRoot) lines.push(` workspaceRoot: ${why.context.workspaceRoot}`); + lines.push(" reasons:"); + for (const r of why.reasons) lines.push(` • ${r}`); + if (why.authorized.reasons.length > 0) { + lines.push(" authorization:"); + for (const r of why.authorized.reasons) lines.push(` • ${r}`); + } + return lines.join("\n"); +} diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index 187847e9..abbaff51 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -56,6 +56,10 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { } for (const p of def.phases ?? []) { const id = p.id; + if (Array.isArray((p as { effects?: unknown }).effects) && + ((p as { effects?: unknown[] }).effects?.length ?? 0) > 0) { + return `phase '${id}': declared effects require the resource-controlled imperative transaction seam`; + } if (p.type === "gate" && (p as { score?: unknown }).score !== undefined) { return `phase '${id}': score gates require the imperative runtime`; } diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index f8caa1da..e3c52371 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -581,6 +581,12 @@ export async function stepPhase(phase: Phase, ctx: StepContext): Promise 0) { + const error = "TFWS_RESOURCE_AUTHORITY_UNAVAILABLE: event-kernel step cannot execute declared effects without a resource transaction binding"; + events.push(baseEvent(ctx, phase.id, "phase-end", { status: "failed", error })); + return { events, status: "failed", error, usage: emptyUsage() }; + } if (type === "flow") { events.push( baseEvent(ctx, phase.id, "decision", { diff --git a/packages/taskflow-core/src/flowir/canonical-hash.ts b/packages/taskflow-core/src/flowir/canonical-hash.ts index 2376bf53..eb867089 100644 --- a/packages/taskflow-core/src/flowir/canonical-hash.ts +++ b/packages/taskflow-core/src/flowir/canonical-hash.ts @@ -117,8 +117,8 @@ function canonicalSerialize(value: unknown): string { * equivalent condition spellings collapse, and `undefined` optionals are * omitted so their presence/absence does not affect the hash. * - * `inject`/`emits`/`deps` arrays are preserved verbatim (order is semantic — - * declared-read order matters for fingerprinting). + * `inject`/`emits`/`deps`/`effects` arrays are preserved verbatim (order is + * semantic — declared-read / effect-declaration order matters for fingerprinting). */ function canonicalNodeObject(node: FlowIRNode): Record { const obj: Record = { @@ -136,6 +136,9 @@ function canonicalNodeObject(node: FlowIRNode): Record { if (node.join !== undefined) obj.join = node.join; if (node.timeout !== undefined) obj.timeout = node.timeout; if (node.payload !== undefined) obj.payload = node.payload; + // Trusted Effects (0.3): content-address declared side effects when present. + // Array order is semantic (declaration order), matching inject/emits. + if (node.effects !== undefined && node.effects.length > 0) obj.effects = node.effects; return obj; } diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index 4fafe2a9..577170ba 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -13,7 +13,7 @@ * @see ./translate.ts (stub; still used for sidecar field list parity) */ -import { collectRefs, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; +import { collectRefs, dependenciesOf, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; import { cwdArgName } from "../cwd-bridge.ts"; import { normalizeCond } from "./cond.ts"; import type { @@ -30,6 +30,8 @@ import type { FlowIRNode, TaskflowIRMeta, } from "./meta.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; // Keep in sync with translate.ts SIDECAR_PHASE_FIELDS (round-trip lossless). const SIDECAR_PHASE_FIELDS = [ @@ -80,9 +82,12 @@ const SIDECAR_PHASE_FIELDS = [ "idleTimeout", "reduceStrategy", "batchSize", + "effects", ] as const; -const NODE_FIELD_KEYS = new Set(["task", "dependsOn", "join", "when", "timeout"]); +// First-class node fields: present on the IR node, stripped from payload so +// they are not double-hashed (payload is itself content-addressed). +const NODE_FIELD_KEYS = new Set(["task", "dependsOn", "join", "when", "timeout", "effects"]); const VALID_KINDS = new Set(PHASE_TYPES); @@ -215,11 +220,30 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR node.condRef = n.canonical || undefined; } if (typeof phase.task === "string") node.task = phase.task; - if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; - if (phase.join === "all" || phase.join === "any") node.join = phase.join; - if (typeof phase.timeout === "number") node.timeout = phase.timeout; - const payload = payloadForPhase(phase); - if (payload) node.payload = payload; + if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; + if (phase.join === "all" || phase.join === "any") node.join = phase.join; + if (typeof phase.timeout === "number") node.timeout = phase.timeout; + const effectsRaw = phase.effects; + if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + const effectValidation = validateEffectIR({ effects: effectsRaw }); + for (const issue of effectValidation.issues) { + if (issue.severity === "error") { + errors.push({ + phaseId: phase.id, + code: `effect-${issue.code}`, + message: issue.message, + }); + } else { + warnings.push({ phaseId: phase.id, message: issue.message }); + } + } + if (effectValidation.ok) { + // Only closed, validated EffectIR enters the content-addressed representation. + node.effects = effectsRaw as EffectDecl[]; + } + } + const payload = payloadForPhase(phase); + if (payload) node.payload = payload; for (const from of inject) { edges.push({ from, to: phase.id }); @@ -229,6 +253,16 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } + const effectFlow = validateEffectFlow(def.phases ?? [], (phase) => dependenciesOf(phase as Phase)); + for (const issue of effectFlow.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + if (issue.severity === "error") { + errors.push({ phaseId, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId, message: issue.message }); + } + } + const canonical: CanonicalFlowIR = { name: def.name || "unnamed", version: typeof def.version === "number" ? def.version : 1, @@ -262,6 +296,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR inject: n.inject, emits: n.emits, when: n.when, + ...(n.effects && n.effects.length > 0 ? { effects: n.effects } : {}), }), ), args: def.args, @@ -276,7 +311,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR }; // Genuine compiler owns the IR hash when we produced nodes. - const usedFallbackHash = nodes.length === 0 || errors.some((e) => e.code === "empty-phases"); + const usedFallbackHash = nodes.length === 0 || errors.length > 0; return { canonical, ir, meta, warnings, errors, usedFallbackHash }; } diff --git a/packages/taskflow-core/src/flowir/index.ts b/packages/taskflow-core/src/flowir/index.ts index c8a250a6..04a466d2 100644 --- a/packages/taskflow-core/src/flowir/index.ts +++ b/packages/taskflow-core/src/flowir/index.ts @@ -34,7 +34,7 @@ export async function compileTaskflowToIR(def: Taskflow): Promise { const c = compileTaskflowToFlowIR(def); let hash: string | undefined; try { - if (c.canonical.nodes.length > 0) { + if (c.errors.length === 0 && c.canonical.nodes.length > 0) { hash = hashFlowIR(c.canonical); } } catch { diff --git a/packages/taskflow-core/src/flowir/meta.ts b/packages/taskflow-core/src/flowir/meta.ts index 9b4427af..3c79de21 100644 --- a/packages/taskflow-core/src/flowir/meta.ts +++ b/packages/taskflow-core/src/flowir/meta.ts @@ -17,6 +17,7 @@ */ import type { Budget, Taskflow } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; // --------------------------------------------------------------------------- // Declared dependency plane (compile-time, M2) @@ -63,6 +64,11 @@ export interface FlowIRNode { emits: string[]; /** Raw `when` guard passthrough (stub: not rewritten to IR conditions). */ when?: string; + /** + * Trusted Effects (0.3 MVP): declared side effects for this node. + * Content-addressed by `hashFlowIR` when present on the canonical IR. + */ + effects?: EffectDecl[]; } /** The compiled IR: a flat list of nodes plus flow-level metadata. */ diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts index 475eb04f..fba18b73 100644 --- a/packages/taskflow-core/src/flowir/schema.ts +++ b/packages/taskflow-core/src/flowir/schema.ts @@ -33,8 +33,11 @@ */ import { Type } from "typebox"; +import { Value } from "typebox/value"; import { StringEnum } from "../typebox-helpers.ts"; import { PHASE_TYPES, type PhaseType } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { EffectDeclSchema } from "../effects/schema.ts"; // --------------------------------------------------------------------------- // FlowIRNodeKind — closed literal union = PHASE_TYPES (currently 12 kinds) @@ -122,6 +125,12 @@ export interface FlowIRNode { timeout?: number; /** Runtime-affecting DSL payload not otherwise modeled by the core node fields. */ payload?: Record; + /** + * Trusted Effects (0.3 MVP): declared side effects for this node. + * Shape is {@link EffectDecl}[]; deep validation is `validateEffectIR` / + * the effects verifier (TypeBox here only checks presence of an array). + */ + effects?: EffectDecl[]; } // --------------------------------------------------------------------------- @@ -247,6 +256,12 @@ export const FlowIRNodeSchema = Type.Object( description: "Runtime-affecting DSL payload not otherwise modeled by the core node fields", }), ), + effects: Type.Optional( + Type.Array(EffectDeclSchema, { + description: + "Trusted Effects (0.3): closed declared side effects (EffectDecl[]).", + }), + ), }, { additionalProperties: false }, ); @@ -317,6 +332,10 @@ export function isFlowIRNode(value: unknown): value is FlowIRNode { if (n.payload !== undefined && (typeof n.payload !== "object" || n.payload === null || Array.isArray(n.payload))) { return false; } + if (n.effects !== undefined && ( + !Array.isArray(n.effects) || + n.effects.some((effect) => !Value.Check(EffectDeclSchema, effect)) + )) return false; return true; } diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index 223366dd..23fba363 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -14,7 +14,9 @@ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1) */ -import { collectRefs, type Phase, type Taskflow } from "../schema.ts"; +import { collectRefs, dependenciesOf, type Phase, type Taskflow } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { CompileError, CompileWarning, @@ -84,6 +86,7 @@ const SIDECAR_PHASE_FIELDS = [ "idleTimeout", "reduceStrategy", "batchSize", + "effects", ] as const; /** Build the per-phase sidecar record (verbatim copy of non-IR fields). */ @@ -158,15 +161,40 @@ export function translateTaskflow(def: Taskflow): { sidecarPhases[phase.id] = sidecarForPhase(phase); + const effectsRaw = phase.effects; + let effects: EffectDecl[] | undefined; + if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + const effectValidation = validateEffectIR({ effects: effectsRaw }); + for (const issue of effectValidation.issues) { + if (issue.severity === "error") { + errors.push({ phaseId: phase.id, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId: phase.id, message: issue.message }); + } + } + if (effectValidation.ok) effects = effectsRaw as EffectDecl[]; + } + return { id: phase.id, kind: phase.type ?? "agent", inject: Array.from(reads), emits: [phase.id], when: phase.when, + ...(effects ? { effects } : {}), } satisfies FlowIRNode; }); + const effectFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + for (const issue of effectFlow.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + if (issue.severity === "error") { + errors.push({ phaseId, code: `effect-${issue.code}`, message: issue.message }); + } else { + warnings.push({ phaseId, message: issue.message }); + } + } + const ir: FlowIR = { name: def.name, nodes, diff --git a/packages/taskflow-core/src/index.ts b/packages/taskflow-core/src/index.ts index 89c3701d..c49939f3 100644 --- a/packages/taskflow-core/src/index.ts +++ b/packages/taskflow-core/src/index.ts @@ -62,3 +62,5 @@ export * from "./preflight.ts"; export * from "./savings.ts"; export * from "./hooks.ts"; export * from "./analytics.ts"; +// 0.3 Trusted Effects MVP (additive pure surface + resource-controlled FS transaction) +export * from "./effects/index.ts"; diff --git a/packages/taskflow-core/src/resources/execution.ts b/packages/taskflow-core/src/resources/execution.ts index e1e4b0cd..596f5c86 100644 --- a/packages/taskflow-core/src/resources/execution.ts +++ b/packages/taskflow-core/src/resources/execution.ts @@ -27,8 +27,13 @@ import { import { PersistentLeaseCoordinator, type LeaseHandle } from "./leases.ts"; import { createHostRootGrant, createRootRegistry, type RootGrant, type RootRegistry } from "./registry.ts"; import { resolvePathRef, type ResolvedPathRef } from "./resolve.ts"; +import { + prepareResourceFileTransaction, + recoverResourceFileIntent, + type PreparedResourceFileTransaction, +} from "./file-transaction.ts"; import { computeHostBaselineBodyDigest, createSandboxPolicyPlan, SandboxPolicyFactory } from "./sandbox.ts"; -import type { ScopedCapability } from "./schema.ts"; +import type { PathRef, ScopedCapability } from "./schema.ts"; import { sameExecutionOwner, type ExecutionOwner } from "./types.ts"; export interface CoordinatedScriptResult { @@ -85,6 +90,10 @@ export interface ResolveOnlyPhaseBinding { readonly resourceDomainId: string; readonly runId: string; readonly phaseId: string; + beginFileWriteTransaction( + targets: readonly { effectId: string; path: PathRef }[], + options?: { unitId?: string; signal?: AbortSignal }, + ): Promise; runAgent(call: ResolveOnlyAgentCall): Promise; runScript(call: ResolveOnlyScriptCall): Promise; } @@ -285,6 +294,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { readonly #rootIdentity: DirectoryIdentity; readonly #pendingLeaseReleases = new Set(); readonly #allowReconcile: boolean; + readonly #controlDirectory: string; // A single resolve-only session can fan out many tasks over the same granted // root. They are all potential writers and cannot safely overlap without a // native broker/snapshot backend. Serialize them before lease acquisition so @@ -317,6 +327,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { const controlDirectory = options.controlDirectory ?? defaultWorkspaceControlDirectory(this.invocationRoot); ensurePrivateDirectory(controlDirectory); const canonicalControlDirectory = fs.realpathSync(controlDirectory); + this.#controlDirectory = canonicalControlDirectory; if (isWithin(this.invocationRoot, canonicalControlDirectory) || isWithin(canonicalControlDirectory, this.invocationRoot)) { throw new Error("TFWS_INVALID_POLICY: control-plane storage must not overlap the flow workspace grant"); } @@ -352,6 +363,13 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { const active = await this.#leases.list(); return active.some((lease) => sameExecutionOwner(lease.owner, owner)); }, + recoverKnownClean: (intent) => recoverResourceFileIntent({ + controlDirectory: this.#controlDirectory, + intent, + leases: this.#leases, + leaseTimeoutMs: this.#leaseTimeoutMs, + signal: this.#signal, + }), }); } @@ -468,6 +486,83 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { } } + async beginFileWriteTransaction( + input: BindResolveOnlyPhaseInput, + boundPath: string, + targets: readonly { effectId: string; path: PathRef }[], + unitId: string, + signal: AbortSignal | undefined, + ): Promise { + await this.#drainPendingLeaseReleases(); + this.#assertRootIdentity(); + if (signal?.aborted || this.#signal?.aborted) { + throw new Error("ABORT_ERR: file transaction was cancelled before admission"); + } + const dirty = (await this.#journal.listIntents()).find((intent) => + intent.resourceDomainId === this.#grant.resourceDomainId && intent.status === "dirty-unknown"); + if (dirty) throw new Error("TFWS_RESOURCE_DIRTY: workspace requires reconciliation before another write"); + + const owner: ExecutionOwner = { + runId: input.runId, + phaseId: input.phaseId, + attemptId: crypto.randomUUID(), + unitId, + ancestry: [], + }; + const generation = await this.#journal.getDomainGeneration(this.#grant.resourceDomainId); + const rootPrefix = path.relative(this.invocationRoot, boundPath).split(path.sep).filter(Boolean).join("/"); + const baseCapability: ScopedCapability = { + bindingId: this.#grant.bindingId, + resourceDomainId: this.#grant.resourceDomainId, + providerInstanceId: "root", + logicalWorkspaceId: "invocation", + logicalPrefix: rootPrefix, + physicalScopeRoot: boundPath, + access: "read-write", + version: { identityMode: "path-bound", generation, state: "clean" }, + lifetime: { scope: "phase", runId: input.runId, phaseId: input.phaseId, attemptId: owner.attemptId }, + }; + const resolvedTargets = targets.map((target) => { + if (!("workspace" in target.path) || target.path.workspace === undefined) { + throw new Error(`TFWS_HANDLE_INVALID: effect '${target.effectId}' requires a bound workspace PathRef`); + } + if (target.path.intent !== "create-file" && target.path.intent !== "existing-file") { + throw new Error(`TFWS_INVALID_PATH: effect '${target.effectId}' fs.write requires create-file or existing-file intent`); + } + const logicalWorkspaceId = target.path.workspace; + const capability: ScopedCapability = { ...baseCapability, logicalWorkspaceId }; + const resolved = resolvePathRef( + { ...target.path, access: "read-write", maxLifetime: { scope: "phase" } }, + { + workspaces: new Map([[logicalWorkspaceId, capability]]), + runId: input.runId, + phaseId: input.phaseId, + attemptId: owner.attemptId, + }, + { definitions: input.argDefinitions, values: input.argValues }, + ); + if (!resolved.ok) throw new Error(`${resolved.error.code}: ${resolved.error.redactedMessage}`); + return { effectId: target.effectId, ref: resolved.value }; + }); + const combinedSignal = this.#signal && signal + ? AbortSignal.any([this.#signal, signal]) + : this.#signal ?? signal; + return prepareResourceFileTransaction({ + controlDirectory: this.#controlDirectory, + resourceDomainId: this.#grant.resourceDomainId, + owner, + targets: resolvedTargets, + leases: this.#leases, + journal: this.#journal, + leaseTimeoutMs: this.#leaseTimeoutMs, + permitTtlMs: this.#permitTtlMs, + signal: combinedSignal, + authorizationPrincipalId: this.authority.principalId, + authorizationScopeRoot: boundPath, + onDeferredLeaseRelease: (lease) => this.#pendingLeaseReleases.add(lease), + }); + } + async #executeMutationNow( input: BindResolveOnlyPhaseInput, boundPath: string, @@ -712,6 +807,19 @@ class ResolveOnlyPhaseBindingImpl implements ResolveOnlyPhaseBinding { } } + beginFileWriteTransaction( + targets: readonly { effectId: string; path: PathRef }[], + options: { unitId?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#session.beginFileWriteTransaction( + this.#input, + this.absolutePath, + targets, + options.unitId ?? this.phaseId, + options.signal, + ); + } + runScript(call: ResolveOnlyScriptCall): Promise { return this.#session.executeMutation( this.#input, diff --git a/packages/taskflow-core/src/resources/file-transaction.ts b/packages/taskflow-core/src/resources/file-transaction.ts new file mode 100644 index 00000000..bdeac541 --- /dev/null +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -0,0 +1,649 @@ +/** + * Resource-controlled file mutation transaction. + * + * This module is deliberately internal to `resources/execution.ts`: callers + * receive transactions only after host authority and PathRef resolution. It + * owns the data-plane sequence beneath that boundary: + * + * durable snapshots -> atomic multi-scope lease -> durable intent/permit + * -> stage -> promote -> commit-content | restore + abort-restored + * + * The local backend provides taskflow-exclusive isolation. It does not claim + * hostile external-process fencing; concurrent Taskflow writers are excluded + * by the persistent lease and unexpected external changes fail closed. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ResolvedPathRef } from "./resolve.ts"; +import type { WriteIntentJournal, PreparedMutation, WriteIntentRecord } from "./journal.ts"; +import type { LeaseHandle, PersistentLeaseCoordinator } from "./leases.ts"; +import { ensureDirectory, fsyncDirectory, writeJsonAtomicDurable } from "./persistence.ts"; +import type { ExecutionOwner, ScopedContentEvidence } from "./types.ts"; + +export interface ResolvedFileWriteTarget { + effectId: string; + ref: ResolvedPathRef; +} + +export interface FileWritePayload { + effectId: string; + content: string | Buffer; +} + +export type FileTransactionResult = + | { + ok: true; + intentId: string; + committedPaths: string[]; + commitGeneration: number; + } + | { + ok: false; + intentId: string; + code: "declared-path-bypass" | "commit-rejected"; + reason: string; + restored: true; + }; + +interface SnapshotManifest { + version: 1; + artifactId: string; + effectId: string; + physicalPath: string; + logicalSubpath: string; + exists: boolean; + contentId: string; + mode?: number; + nearestExistingAncestor: string; + blobPath?: string; +} + +interface Snapshot extends SnapshotManifest { + capabilityBindingId: string; + scopeDigest: string; +} + +export interface ResourceFileTransactionOptions { + controlDirectory: string; + resourceDomainId: string; + owner: ExecutionOwner; + targets: readonly ResolvedFileWriteTarget[]; + leases: PersistentLeaseCoordinator; + journal: WriteIntentJournal; + leaseTimeoutMs: number; + permitTtlMs: number; + signal?: AbortSignal; + authorizationPrincipalId?: string; + authorizationScopeRoot: string; + /** Preserve a failed authenticated release for the session's next-admission drain. */ + onDeferredLeaseRelease?: (lease: LeaseHandle) => void; +} + +function hash(parts: readonly (string | Buffer)[]): string { + const digest = crypto.createHash("sha256"); + for (const part of parts) digest.update(part); + return `sha256:${digest.digest("hex")}`; +} + +function contentId(content: Buffer | undefined): string { + return content === undefined ? hash(["missing\0"]) : hash(["regular-file\0", content]); +} + +function isWithin(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); +} + +function nearestExistingAncestor(candidate: string): string { + let current = path.dirname(candidate); + while (true) { + try { + const real = fs.realpathSync(current); + if (!fs.statSync(real).isDirectory()) throw new Error(`ancestor is not a directory: ${current}`); + return real; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = path.dirname(current); + if (parent === current) throw new Error(`no existing ancestor for ${candidate}`); + current = parent; + } + } +} + +function inspectRegularFile(filePath: string): { exists: false } | { exists: true; content: Buffer; mode: number } { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile()) throw new Error(`TFWS_INVALID_PATH: target is not a regular file: ${filePath}`); + const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + try { + return { exists: true, content: fs.readFileSync(fd), mode: stat.mode & 0o777 }; + } finally { + fs.closeSync(fd); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false }; + throw error; + } +} + +function writeBufferDurable(filePath: string, content: Buffer, mode = 0o600): void { + ensureDirectory(path.dirname(filePath)); + const fd = fs.openSync(filePath, "wx", mode); + try { + let offset = 0; + while (offset < content.length) offset += fs.writeSync(fd, content, offset, content.length - offset); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fsyncDirectory(path.dirname(filePath)); +} + +function replaceFileAtomic(filePath: string, content: Buffer, mode = 0o600): void { + const parent = path.dirname(filePath); + ensureDirectory(parent); + const temp = path.join(parent, `.taskflow-write-${process.pid}-${crypto.randomUUID()}`); + try { + writeBufferDurable(temp, content, mode); + fs.renameSync(temp, filePath); + if (process.platform !== "win32") fs.chmodSync(filePath, mode); + fsyncDirectory(parent); + } catch (error) { + try { fs.unlinkSync(temp); } catch { /* best effort; transaction restore remains authoritative */ } + throw error; + } +} + +function pruneCreatedParents(snapshot: Snapshot): void { + let current = path.dirname(snapshot.physicalPath); + while (current !== snapshot.nearestExistingAncestor && isWithin(snapshot.nearestExistingAncestor, current)) { + try { + fs.rmdirSync(current); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + current = path.dirname(current); + continue; + } + break; + } + current = path.dirname(current); + } +} + +function currentContentId(snapshot: Snapshot): string { + const current = inspectRegularFile(snapshot.physicalPath); + return contentId(current.exists ? current.content : undefined); +} + +function restoreSnapshot(snapshot: Snapshot): void { + if (!snapshot.exists) { + try { + const stat = fs.lstatSync(snapshot.physicalPath); + if (stat.isDirectory()) throw new Error(`cannot restore over directory target: ${snapshot.physicalPath}`); + fs.unlinkSync(snapshot.physicalPath); + fsyncDirectory(path.dirname(snapshot.physicalPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + pruneCreatedParents(snapshot); + return; + } + if (!snapshot.blobPath || snapshot.mode === undefined) throw new Error(`snapshot artifact is incomplete: ${snapshot.artifactId}`); + const bytes = fs.readFileSync(snapshot.blobPath); + if (contentId(bytes) !== snapshot.contentId) throw new Error(`snapshot artifact hash mismatch: ${snapshot.artifactId}`); + replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode); +} + +function restoredEvidence(snapshots: readonly Snapshot[]): ScopedContentEvidence[] { + return snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + afterContentId: currentContentId(snapshot), + })); +} + +function assertExactPreState(snapshots: readonly Snapshot[]): void { + for (const snapshot of snapshots) { + if (currentContentId(snapshot) !== snapshot.contentId) { + throw new Error(`declared final path '${snapshot.logicalSubpath}' changed outside the resource transaction`); + } + } +} + +function assertExactPostState(snapshots: readonly Snapshot[], expected: ReadonlyMap): void { + for (const snapshot of snapshots) { + if (currentContentId(snapshot) !== expected.get(snapshot.effectId)) { + throw new Error(`post-state changed before durable commit for '${snapshot.logicalSubpath}'`); + } + } +} + +async function releaseBestEffort(lease: LeaseHandle): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + await lease.release(); + return true; + } catch { + // A terminal journal record must remain the operation result. Keep the + // authenticated handle for a later admission drain instead of making a + // committed mutation appear retryable. + } + } + return false; +} + +function readManifestFile(filePath: string): unknown { + const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) { + throw new Error(`invalid snapshot manifest: ${filePath}`); + } + return JSON.parse(fs.readFileSync(fd, "utf8")) as unknown; + } finally { + fs.closeSync(fd); + } +} + +function loadRecoverySnapshots( + controlDirectory: string, + intent: WriteIntentRecord, +): Snapshot[] { + const authorityRoot = intent.authorizationScopeRoot; + if (!authorityRoot || !path.isAbsolute(authorityRoot)) { + throw new Error(`intent ${intent.intentId} lacks an authorized recovery root`); + } + const canonicalAuthorityRoot = fs.realpathSync(authorityRoot); + const artifacts = intent.restorableSnapshotArtifactIds; + if (!artifacts || artifacts.length !== intent.scopes.length) { + throw new Error(`intent ${intent.intentId} lacks an exact snapshot artifact set`); + } + const byEffect = new Map(intent.scopes.map((scope) => [scope.effectId, scope])); + if (byEffect.size !== intent.scopes.length || byEffect.has(undefined)) { + throw new Error(`intent ${intent.intentId} lacks unique effect attribution`); + } + const snapshots: Snapshot[] = []; + let expectedTransactionId: string | undefined; + for (const artifactId of artifacts) { + const match = /^workspace-snapshot:([0-9a-f-]{36}):([0-9a-f]{64})$/i.exec(artifactId); + if (!match) throw new Error(`invalid snapshot artifact id: ${artifactId}`); + const [, transactionId, artifactStem] = match; + expectedTransactionId ??= transactionId; + if (transactionId !== expectedTransactionId) throw new Error("snapshot artifacts span multiple transactions"); + const transactionDirectory = path.join(controlDirectory, "file-transactions", transactionId!); + const canonicalTransactionDirectory = fs.realpathSync(transactionDirectory); + if (!isWithin(fs.realpathSync(controlDirectory), canonicalTransactionDirectory)) { + throw new Error("snapshot transaction directory escapes trusted control storage"); + } + const raw = readManifestFile(path.join(canonicalTransactionDirectory, `${artifactStem}.json`)); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`invalid snapshot manifest ${artifactId}`); + const manifest = raw as Record; + if ( + manifest.version !== 1 || manifest.artifactId !== artifactId || + typeof manifest.effectId !== "string" || typeof manifest.physicalPath !== "string" || + typeof manifest.logicalSubpath !== "string" || typeof manifest.exists !== "boolean" || + typeof manifest.contentId !== "string" || typeof manifest.nearestExistingAncestor !== "string" || + (manifest.mode !== undefined && (!Number.isInteger(manifest.mode) || Number(manifest.mode) < 0 || Number(manifest.mode) > 0o777)) + ) throw new Error(`invalid snapshot manifest ${artifactId}`); + const scope = byEffect.get(manifest.effectId); + if (!scope || path.normalize(manifest.physicalPath) !== scope.canonicalPrefix) { + throw new Error(`snapshot ${artifactId} does not match its durable intent scope`); + } + const physicalPath = path.normalize(manifest.physicalPath); + const ancestor = path.normalize(manifest.nearestExistingAncestor); + if (!isWithin(canonicalAuthorityRoot, physicalPath) || + !isWithin(canonicalAuthorityRoot, ancestor) || + !isWithin(ancestor, physicalPath)) { + throw new Error(`snapshot ${artifactId} escapes its authorized recovery root`); + } + const exists = manifest.exists; + const blobPath = exists ? path.join(canonicalTransactionDirectory, `${artifactStem}.blob`) : undefined; + const beforeBytes = exists ? fs.readFileSync(blobPath!) : undefined; + if (contentId(beforeBytes) !== manifest.contentId || + hash(["workspace-snapshot\0", physicalPath, "\0", String(manifest.contentId)]) !== `sha256:${artifactStem}`) { + throw new Error(`snapshot ${artifactId} failed content-address verification`); + } + snapshots.push({ + version: 1, + artifactId, + effectId: manifest.effectId, + physicalPath, + logicalSubpath: manifest.logicalSubpath, + exists, + contentId: manifest.contentId, + ...(manifest.mode === undefined ? {} : { mode: Number(manifest.mode) }), + nearestExistingAncestor: ancestor, + ...(blobPath === undefined ? {} : { blobPath }), + capabilityBindingId: scope.capabilityBindingId ?? "", + scopeDigest: scope.scopeDigest, + }); + } + return snapshots; +} + +/** Restore a stale content intent while journal recovery holds its mutex. */ +export async function recoverResourceFileIntent(options: { + controlDirectory: string; + intent: WriteIntentRecord; + leases: PersistentLeaseCoordinator; + leaseTimeoutMs: number; + signal?: AbortSignal; +}): Promise<{ + scopes: ScopedContentEvidence[]; + reason: string; + restorableSnapshotArtifactIds: string[]; +} | undefined> { + const { intent } = options; + if (intent.commitMode !== "content-snapshot" || intent.externalMutation !== "taskflow-managed") return undefined; + const snapshots = loadRecoverySnapshots(options.controlDirectory, intent); + const owner: ExecutionOwner = { + runId: `recovery-${crypto.randomUUID()}`, + phaseId: "resource-recovery", + attemptId: crypto.randomUUID(), + unitId: intent.intentId, + ancestry: [], + }; + const lease = await options.leases.acquire(intent.scopes.map((scope) => ({ + key: { resourceDomainId: intent.resourceDomainId, canonicalPrefix: scope.canonicalPrefix }, + access: "read-write" as const, + owner, + })), { timeoutMs: options.leaseTimeoutMs, signal: options.signal }); + try { + for (const snapshot of snapshots) { + const currentAncestor = nearestExistingAncestor(snapshot.physicalPath); + if (!isWithin(intent.authorizationScopeRoot!, currentAncestor)) { + throw new Error(`recovery parent for '${snapshot.logicalSubpath}' escaped its authorized root`); + } + } + for (const snapshot of [...snapshots].reverse()) restoreSnapshot(snapshot); + const scopes = restoredEvidence(snapshots); + if (scopes.some((scope) => scope.afterContentId !== scope.beforeContentId)) { + throw new Error("crash recovery did not reproduce the durable pre-state"); + } + return { + scopes, + reason: "startup recovery restored durable file-transaction pre-state", + restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId), + }; + } finally { + if (!(await releaseBestEffort(lease))) { + console.warn(`[taskflow] resource recovery lease cleanup deferred for lease ${lease.leaseId}`); + } + } +} + +export class PreparedResourceFileTransaction { + readonly intentId: string; + readonly #snapshots: readonly Snapshot[]; + readonly #mutation: PreparedMutation; + readonly #lease: LeaseHandle; + readonly #journal: WriteIntentJournal; + readonly #stagingDirectory: string; + readonly #onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + #settled = false; + + constructor(input: { + snapshots: readonly Snapshot[]; + mutation: PreparedMutation; + lease: LeaseHandle; + journal: WriteIntentJournal; + stagingDirectory: string; + onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + }) { + this.intentId = input.mutation.intent.intentId; + this.#snapshots = input.snapshots; + this.#mutation = input.mutation; + this.#lease = input.lease; + this.#journal = input.journal; + this.#stagingDirectory = input.stagingDirectory; + this.#onDeferredLeaseRelease = input.onDeferredLeaseRelease; + } + + async commit(payloads: readonly FileWritePayload[]): Promise { + this.#assertOpen(); + const byId = new Map(payloads.map((payload) => [payload.effectId, payload])); + if (byId.size !== this.#snapshots.length || this.#snapshots.some((snapshot) => !byId.has(snapshot.effectId))) { + return this.#rejectAndFinish("commit-rejected", "payload ids do not exactly match the admitted effects"); + } + try { + assertExactPreState(this.#snapshots); + } catch (error) { + return this.#rejectAndFinish( + "declared-path-bypass", + error instanceof Error ? error.message : String(error), + ); + } + + const expected = new Map(); + try { + for (const snapshot of this.#snapshots) { + const payload = byId.get(snapshot.effectId)!; + const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content; + const stagedPath = path.join(this.#stagingDirectory, "staged", `${snapshot.effectId}-${crypto.randomUUID()}.blob`); + writeBufferDurable(stagedPath, bytes); + expected.set(snapshot.effectId, contentId(bytes)); + } + for (const snapshot of this.#snapshots) { + await this.#journal.assertActive(this.#mutation.permit, this.#mutation.intent.owner); + const payload = byId.get(snapshot.effectId)!; + const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content; + replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode ?? 0o600); + } + const evidence = this.#snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + afterContentId: expected.get(snapshot.effectId)!, + })); + const committed = await this.#journal.commitContent( + this.intentId, + evidence, + this.#snapshots.map((snapshot) => snapshot.artifactId), + { preCommitGuard: () => assertExactPostState(this.#snapshots, expected) }, + ); + this.#settled = true; + return { + ok: true, + intentId: this.intentId, + committedPaths: this.#snapshots.map((snapshot) => snapshot.logicalSubpath), + commitGeneration: committed.commitGeneration!, + }; + } catch (error) { + return await this.#rejectAndRestore("commit-rejected", error instanceof Error ? error.message : String(error)); + } finally { + await this.#finish(); + } + } + + async reject(reason: string): Promise { + this.#assertOpen(); + try { + return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected"); + } finally { + await this.#finish(); + } + } + + async #rejectAndRestore( + code: "declared-path-bypass" | "commit-rejected", + reason: string, + ): Promise { + try { + for (const snapshot of [...this.#snapshots].reverse()) restoreSnapshot(snapshot); + const evidence = restoredEvidence(this.#snapshots); + if (evidence.some((scope) => scope.afterContentId !== scope.beforeContentId)) { + throw new Error("restored filesystem state does not match the durable pre-state snapshot"); + } + await this.#journal.abortRestored( + this.intentId, + evidence, + reason, + this.#snapshots.map((snapshot) => snapshot.artifactId), + ); + this.#settled = true; + return { ok: false, intentId: this.intentId, code, reason, restored: true }; + } catch (restoreError) { + const current = await this.#journal.getIntent(this.intentId); + if (current?.status === "pending") { + await this.#journal.markUnknown( + this.intentId, + `restore failed after ${reason}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ); + } + this.#settled = true; + throw restoreError; + } + } + + async #rejectAndFinish( + code: "declared-path-bypass" | "commit-rejected", + reason: string, + ): Promise { + try { + return await this.#rejectAndRestore(code, reason); + } finally { + await this.#finish(); + } + } + + #assertOpen(): void { + if (this.#settled) throw new Error(`resource file transaction ${this.intentId} is already settled`); + } + + async #finish(): Promise { + try { + fs.rmSync(path.join(this.#stagingDirectory, "staged"), { recursive: true, force: true }); + } finally { + if (!(await releaseBestEffort(this.#lease))) { + this.#onDeferredLeaseRelease?.(this.#lease); + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${this.#lease.leaseId}`); + } + } + } +} + +export async function prepareResourceFileTransaction( + options: ResourceFileTransactionOptions, +): Promise { + if (options.targets.length === 0) throw new Error("a resource file transaction requires at least one target"); + const seenEffects = new Set(); + const seenPaths = new Set(); + for (const target of options.targets) { + if (!target.effectId || seenEffects.has(target.effectId)) throw new Error(`duplicate or empty effect id: ${target.effectId}`); + if (target.ref.capability.resourceDomainId !== options.resourceDomainId) { + throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' resolved to a foreign resource domain`); + } + if (target.ref.capability.access !== "read-write") { + throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' lacks read-write capability`); + } + if (seenPaths.has(target.ref.physicalPath)) throw new Error(`duplicate file target: ${target.ref.logicalSubpath}`); + seenEffects.add(target.effectId); + seenPaths.add(target.ref.physicalPath); + } + + let lease: LeaseHandle | undefined; + let mutation: PreparedMutation | undefined; + const transactionId = crypto.randomUUID(); + const transactionDirectory = path.join(options.controlDirectory, "file-transactions", transactionId); + try { + lease = await options.leases.acquire(options.targets.map((target) => ({ + key: { resourceDomainId: options.resourceDomainId, canonicalPrefix: target.ref.physicalPath }, + access: "read-write" as const, + owner: options.owner, + })), { + timeoutMs: options.leaseTimeoutMs, + signal: options.signal, + }); + ensureDirectory(transactionDirectory); + const snapshots: Snapshot[] = []; + for (const target of options.targets) { + const inspected = inspectRegularFile(target.ref.physicalPath); + const before = inspected.exists ? inspected.content : undefined; + const beforeId = contentId(before); + const artifactHash = hash(["workspace-snapshot\0", target.ref.physicalPath, "\0", beforeId]); + const artifactStem = artifactHash.slice("sha256:".length); + const artifactId = `workspace-snapshot:${transactionId}:${artifactStem}`; + const blobPath = inspected.exists ? path.join(transactionDirectory, `${artifactStem}.blob`) : undefined; + if (inspected.exists && blobPath) writeBufferDurable(blobPath, inspected.content); + const snapshot: Snapshot = { + version: 1, + artifactId, + effectId: target.effectId, + physicalPath: target.ref.physicalPath, + logicalSubpath: target.ref.logicalSubpath, + exists: inspected.exists, + contentId: beforeId, + ...(inspected.exists ? { mode: inspected.mode } : {}), + nearestExistingAncestor: nearestExistingAncestor(target.ref.physicalPath), + ...(blobPath ? { blobPath } : {}), + capabilityBindingId: target.ref.capability.bindingId, + scopeDigest: hash([ + options.resourceDomainId, + "\0", + target.ref.capability.bindingId, + "\0", + target.ref.logicalSubpath, + ]), + }; + writeJsonAtomicDurable(path.join(transactionDirectory, `${artifactStem}.json`), { + version: snapshot.version, + artifactId: snapshot.artifactId, + effectId: snapshot.effectId, + physicalPath: snapshot.physicalPath, + logicalSubpath: snapshot.logicalSubpath, + exists: snapshot.exists, + contentId: snapshot.contentId, + ...(snapshot.mode === undefined ? {} : { mode: snapshot.mode }), + nearestExistingAncestor: snapshot.nearestExistingAncestor, + ...(snapshot.blobPath === undefined ? {} : { blobPath: snapshot.blobPath }), + } satisfies SnapshotManifest); + snapshots.push(snapshot); + } + mutation = await options.journal.prepare({ + resourceDomainId: options.resourceDomainId, + providerInstanceId: "root", + scopes: snapshots.map((snapshot) => ({ + canonicalPrefix: snapshot.physicalPath, + scopeDigest: snapshot.scopeDigest, + effectId: snapshot.effectId, + capabilityBindingId: snapshot.capabilityBindingId, + beforeContentId: snapshot.contentId, + })), + owner: options.owner, + commitMode: "content-snapshot", + externalMutation: "taskflow-managed", + restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId), + authorizationPrincipalId: options.authorizationPrincipalId, + authorizationScopeRoot: options.authorizationScopeRoot, + permitTtlMs: options.permitTtlMs, + }); + await options.journal.activate([mutation.permit], options.owner); + return new PreparedResourceFileTransaction({ + snapshots, + mutation, + lease, + journal: options.journal, + stagingDirectory: transactionDirectory, + onDeferredLeaseRelease: options.onDeferredLeaseRelease, + }); + } catch (error) { + if (mutation) { + const current = await options.journal.getIntent(mutation.intent.intentId); + if (current?.status === "pending") await options.journal.markUnknown(current.intentId, "file transaction preparation failed"); + } + if (lease && !(await releaseBestEffort(lease))) { + options.onDeferredLeaseRelease?.(lease); + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + } + throw error; + } +} diff --git a/packages/taskflow-core/src/resources/journal.ts b/packages/taskflow-core/src/resources/journal.ts index d3ca2786..f3373062 100644 --- a/packages/taskflow-core/src/resources/journal.ts +++ b/packages/taskflow-core/src/resources/journal.ts @@ -29,7 +29,13 @@ import { export type { ExternalMutationModel, ScopedContentEvidence, VersionCommitMode } from "./types.ts"; export type { MutationPermit } from "./permits.ts"; -export type WriteIntentStatus = "pending" | "committed-content" | "committed-generation" | "dirty-unknown" | "reconciled"; +export type WriteIntentStatus = + | "pending" + | "committed-content" + | "committed-generation" + | "aborted-restored" + | "dirty-unknown" + | "reconciled"; export interface WriteIntentRecord { journalVersion: 1; @@ -46,6 +52,9 @@ export interface WriteIntentRecord { externalMutation: ExternalMutationModel; status: WriteIntentStatus; restorableSnapshotArtifactIds?: string[]; + terminalReason?: string; + authorizationPrincipalId?: string; + authorizationScopeRoot?: string; } export interface RecoverPendingOptions { @@ -56,6 +65,15 @@ export interface RecoverPendingOptions { * recovery append. */ isOwnerActive?: (owner: ExecutionOwner) => boolean | Promise; + /** Trusted resource backend hook. Return exact before=after evidence only + * after restoring every scope from durable pre-state artifacts. */ + recoverKnownClean?: ( + intent: WriteIntentRecord, + ) => Promise<{ + scopes: readonly ScopedContentEvidence[]; + reason: string; + restorableSnapshotArtifactIds?: readonly string[]; + } | undefined>; } export interface PrepareWriteIntent { @@ -68,6 +86,11 @@ export interface PrepareWriteIntent { beforeGeneration?: number; commitMode: VersionCommitMode; externalMutation: ExternalMutationModel; + /** Durable pre-state artifacts captured before permit activation. */ + restorableSnapshotArtifactIds?: readonly string[]; + /** Host-authenticated principal; flow JSON cannot supply this value. */ + authorizationPrincipalId?: string; + authorizationScopeRoot?: string; permitTtlMs?: number; } @@ -112,6 +135,17 @@ interface UnknownWalRecord { reason: string; } +interface AbortRestoredWalRecord { + journalVersion: 1; + type: "write-abort-restored"; + ts: string; + intentId: string; + resourceDomainId: string; + reason: string; + scopes: ScopedContentEvidence[]; + restorableSnapshotArtifactIds?: string[]; +} + interface ReconcileWalRecord { journalVersion: 1; type: "write-reconcile"; @@ -122,7 +156,13 @@ interface ReconcileWalRecord { reason: string; } -export type JournalWalRecord = IntentWalRecord | CommitContentWalRecord | CommitGenerationWalRecord | UnknownWalRecord | ReconcileWalRecord; +export type JournalWalRecord = + | IntentWalRecord + | CommitContentWalRecord + | CommitGenerationWalRecord + | AbortRestoredWalRecord + | UnknownWalRecord + | ReconcileWalRecord; interface JournalProjection { intents: Map; @@ -142,6 +182,7 @@ export interface PreparedMutation { export interface RecoveryResult { recoveredIntentIds: string[]; + restoredIntentIds: string[]; dirtyDomains: string[]; } @@ -165,6 +206,8 @@ function cloneEvidence(scope: ScopedContentEvidence): ScopedContentEvidence { return { canonicalPrefix: normalizeCanonicalPrefix(scope.canonicalPrefix), scopeDigest: scope.scopeDigest, + ...(scope.effectId === undefined ? {} : { effectId: scope.effectId }), + ...(scope.capabilityBindingId === undefined ? {} : { capabilityBindingId: scope.capabilityBindingId }), ...(scope.beforeContentId === undefined ? {} : { beforeContentId: scope.beforeContentId }), ...(scope.afterContentId === undefined ? {} : { afterContentId: scope.afterContentId }), }; @@ -241,6 +284,27 @@ function fold(records: readonly JournalWalRecord[]): JournalProjection { if (record.resourceDomainId !== intent.resourceDomainId) throw new JournalStateError(`Intent ${record.intentId} domain mismatch`); if (record.type === "write-unknown") { intent.status = "dirty-unknown"; + intent.terminalReason = record.reason; + continue; + } + if (record.type === "write-abort-restored") { + if (!sameCanonicalScopes(intentScopeKeys(intent), record.scopes.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + })))) { + throw new JournalStateError(`Restored scopes do not match intent ${record.intentId}`); + } + for (const scope of record.scopes) { + if (!scope.beforeContentId || scope.afterContentId !== scope.beforeContentId) { + throw new JournalStateError(`Restored scope ${scope.canonicalPrefix} does not prove before=after`); + } + } + intent.status = "aborted-restored"; + intent.scopes = structuredClone(record.scopes); + intent.restorableSnapshotArtifactIds = record.restorableSnapshotArtifactIds + ? [...record.restorableSnapshotArtifactIds] + : undefined; + intent.terminalReason = record.reason; continue; } const previousGeneration = domainGenerations.get(record.resourceDomainId) ?? 0; @@ -304,6 +368,12 @@ export class WriteIntentJournal { throw new Error("beforeGeneration must be a non-negative safe integer"); } if (!input.resourceDomainId) throw new Error("resourceDomainId must be non-empty"); + if (input.authorizationPrincipalId !== undefined && !input.authorizationPrincipalId.trim()) { + throw new Error("authorizationPrincipalId must be non-empty when supplied"); + } + if (input.authorizationScopeRoot !== undefined && !path.isAbsolute(input.authorizationScopeRoot)) { + throw new Error("authorizationScopeRoot must be absolute when supplied"); + } if (!(["content-snapshot", "generation-only", "unavailable"] as const).includes(input.commitMode)) throw new Error(`Unsupported commitMode ${String(input.commitMode)}`); if (!(["taskflow-managed", "externally-mutable"] as const).includes(input.externalMutation)) throw new Error(`Unsupported externalMutation ${String(input.externalMutation)}`); const scopes = normalizeEvidence(input.scopes); @@ -337,6 +407,15 @@ export class WriteIntentJournal { commitMode: input.commitMode, externalMutation: input.externalMutation, status: "pending", + ...(input.authorizationPrincipalId === undefined + ? {} + : { authorizationPrincipalId: input.authorizationPrincipalId }), + ...(input.authorizationScopeRoot === undefined + ? {} + : { authorizationScopeRoot: path.normalize(input.authorizationScopeRoot) }), + ...(input.restorableSnapshotArtifactIds === undefined + ? {} + : { restorableSnapshotArtifactIds: [...input.restorableSnapshotArtifactIds] }), }; this.#append([{ journalVersion: 1, type: "write-intent", ts: nowIso, intent }]); try { @@ -476,10 +555,63 @@ export class WriteIntentJournal { await this.#settleTerminalPermit(intentId); const dirty = cloneIntent(intent); dirty.status = "dirty-unknown"; + dirty.terminalReason = reason || "mutation outcome unknown"; return cloneIntent(dirty); }); } + /** + * Settle an active mutation without advancing the resource generation after + * the caller has restored every admitted scope to its exact pre-state. + * This is a known-clean terminal state, not reconciliation: callers must + * provide content evidence proving `beforeContentId === afterContentId` for + * every originally admitted scope. + */ + async abortRestored( + intentId: string, + scopes: readonly ScopedContentEvidence[], + reason: string, + restorableSnapshotArtifactIds?: readonly string[], + ): Promise { + const normalized = normalizeEvidence(scopes); + if (normalized.some((scope) => !scope.beforeContentId || scope.afterContentId !== scope.beforeContentId)) { + throw new JournalStateError("abort-restored requires beforeContentId === afterContentId for every scope"); + } + return this.#mutex.runExclusive(async () => { + const intent = this.#projection().intents.get(intentId); + if (!intent || intent.status !== "pending") throw new JournalStateError(`Intent ${intentId} is not pending`); + if (!sameCanonicalScopes(intentScopeKeys(intent), normalized.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + })))) { + throw new JournalStateError(`Restored content scopes do not match intent ${intentId}`); + } + await this.permits.assertIntentActive(intentId, intent.owner); + const terminalReason = reason || "mutation rejected and pre-state restored"; + this.#append([{ + journalVersion: 1, + type: "write-abort-restored", + ts: new Date(this.now()).toISOString(), + intentId, + resourceDomainId: intent.resourceDomainId, + reason: terminalReason, + scopes: normalized, + ...(restorableSnapshotArtifactIds === undefined + ? {} + : { restorableSnapshotArtifactIds: [...restorableSnapshotArtifactIds] }), + }]); + await this.#settleTerminalPermit(intentId); + const aborted = cloneIntent(intent); + aborted.status = "aborted-restored"; + aborted.scopes = structuredClone(normalized); + aborted.restorableSnapshotArtifactIds = restorableSnapshotArtifactIds + ? [...restorableSnapshotArtifactIds] + : undefined; + aborted.terminalReason = terminalReason; + return cloneIntent(aborted); + }); + } + /** Explicitly acknowledge the current external filesystem state for one * resource domain. Callers must hold an exclusive whole-domain lease; this * method serializes the durable decision and advances the generation so no @@ -541,8 +673,9 @@ export class WriteIntentJournal { } } - /** Startup recovery: any fsynced intent without a terminal WAL record and - * without a live mutation owner becomes dirty-unknown before reuse. */ + /** Startup recovery: a stale taskflow-managed content intent may first be + * restored by its trusted resource backend. Every other fsynced intent + * without a terminal WAL record becomes dirty-unknown before reuse. */ async recoverPending( reason = "startup recovery found an uncommitted write intent", options: RecoverPendingOptions = {}, @@ -550,21 +683,67 @@ export class WriteIntentJournal { return this.#mutex.runExclusive(async () => { const projection = this.#projection(); const pending: WriteIntentRecord[] = []; + const restored: Array<{ + intent: WriteIntentRecord; + scopes: ScopedContentEvidence[]; + reason: string; + artifacts?: string[]; + }> = []; + const dirty: WriteIntentRecord[] = []; for (const intent of projection.intents.values()) { if (intent.status !== "pending") continue; if (options.isOwnerActive && await options.isOwnerActive(cloneExecutionOwner(intent.owner))) continue; pending.push(intent); + let recovered: Awaited>>; + try { + recovered = await options.recoverKnownClean?.(cloneIntent(intent)); + } catch { + recovered = undefined; + } + if (recovered) { + const scopes = normalizeEvidence(recovered.scopes); + const exact = sameCanonicalScopes(intentScopeKeys(intent), scopes.map((scope) => ({ + resourceDomainId: intent.resourceDomainId, + canonicalPrefix: scope.canonicalPrefix, + }))); + const clean = scopes.every((scope) => + typeof scope.beforeContentId === "string" && scope.afterContentId === scope.beforeContentId); + if (exact && clean) { + restored.push({ + intent, + scopes, + reason: recovered.reason || reason, + ...(recovered.restorableSnapshotArtifactIds === undefined + ? {} + : { artifacts: [...recovered.restorableSnapshotArtifactIds] }), + }); + continue; + } + } + dirty.push(intent); } if (pending.length > 0) { const ts = new Date(this.now()).toISOString(); - this.#append(pending.map((intent): UnknownWalRecord => ({ - journalVersion: 1, - type: "write-unknown", - ts, - intentId: intent.intentId, - resourceDomainId: intent.resourceDomainId, - reason, - }))); + this.#append([ + ...restored.map(({ intent, scopes, reason: restoredReason, artifacts }): AbortRestoredWalRecord => ({ + journalVersion: 1, + type: "write-abort-restored", + ts, + intentId: intent.intentId, + resourceDomainId: intent.resourceDomainId, + reason: restoredReason, + scopes, + ...(artifacts === undefined ? {} : { restorableSnapshotArtifactIds: artifacts }), + })), + ...dirty.map((intent): UnknownWalRecord => ({ + journalVersion: 1, + type: "write-unknown", + ts, + intentId: intent.intentId, + resourceDomainId: intent.resourceDomainId, + reason, + })), + ]); } // Also closes the crash window after a durable commit/unknown append but // before the permit registry transition was persisted. Retained pending @@ -576,7 +755,8 @@ export class WriteIntentJournal { for (const intent of settle) await this.#settleTerminalPermit(intent.intentId); return { recoveredIntentIds: pending.map((intent) => intent.intentId), - dirtyDomains: [...new Set(pending.map((intent) => intent.resourceDomainId))].sort(), + restoredIntentIds: restored.map(({ intent }) => intent.intentId), + dirtyDomains: [...new Set(dirty.map((intent) => intent.resourceDomainId))].sort(), }; }); } diff --git a/packages/taskflow-core/src/resources/types.ts b/packages/taskflow-core/src/resources/types.ts index cc31c963..9554f8f1 100644 --- a/packages/taskflow-core/src/resources/types.ts +++ b/packages/taskflow-core/src/resources/types.ts @@ -24,6 +24,10 @@ export interface LeaseRequest { export interface ScopedContentEvidence { canonicalPrefix: string; scopeDigest: string; + /** Stable declaration id when this scope originates from Trusted Effects. */ + effectId?: string; + /** Capability binding that resolved and authorized this exact scope. */ + capabilityBindingId?: string; beforeContentId?: string; afterContentId?: string; } diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 7e2318dd..8cac87bf 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -819,6 +819,29 @@ function flowTreeUsesCwdBridge( return false; } +function flowTreeUsesDeclaredEffects( + def: Taskflow, + loadFlow: RuntimeDeps["loadFlow"], + seenUses = new Set(), +): boolean { + if (def.phases.some((phase) => Array.isArray((phase as { effects?: unknown }).effects) && + ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) return true; + if (!loadFlow) return false; + for (const phase of def.phases) { + if ((phase.type ?? "agent") !== "flow" || !phase.use) continue; + if (seenUses.has(phase.use)) continue; + seenUses.add(phase.use); + try { + const child = loadFlow(phase.use); + if (child && flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; + } catch { + // A mutable/unavailable saved flow is authority-bearing until proven otherwise. + return true; + } + } + return false; +} + /** * Freeze the saved-flow namespace for one top-level execution. Capability * discovery and phase execution must observe the same definition, including @@ -1138,9 +1161,132 @@ async function executePhaseImpl( } return ps; }; + const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { + const effects = (phase as { effects?: unknown }).effects; + if (!Array.isArray(effects) || effects.length === 0) { + return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + } + const te = await import("./effects/index.ts"); + const effectValidation = te.validateDeclaredEffectsBeforeAdmission(effects); + if (!effectValidation.ok) { + return { + id: phase.id, + status: "failed", + error: `trusted-effects admission failed (${effectValidation.code}): ${effectValidation.reason}`, + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + const hasWrites = te.hasDeclaredFsWriteEffects(effects); + let binding = innerDeps._workspaceBinding; + if (hasWrites && !binding) { + const effectiveCwd = resolveEffCwd(innerDeps, phase); + try { + binding = await innerDeps.workspaceSession?.bindPhase({ + invocationRoot: effectiveCwd, + runId: state.runId, + phaseId: phase.id, + argDefinitions: state.def.args ?? {}, + argValues: state.args, + }); + } catch (error) { + return { + id: phase.id, + status: "failed", + error: error instanceof Error ? error.message : String(error), + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + } + if (hasWrites && !binding) { + return { + id: phase.id, + status: "failed", + error: "TFWS_RESOURCE_AUTHORITY_UNAVAILABLE: declared fs.write requires a workspace authority binding", + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + const admitted = binding + ? await te.preparePhaseDeclaredFsWrites(binding, { effects, signal: innerDeps.signal }) + : { ok: true as const, prepared: undefined }; + if (!admitted.ok) { + return { + id: phase.id, + status: "failed", + error: `trusted-effects admission failed (${admitted.code}): ${admitted.reason}`, + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + let ps: PhaseState; + try { + // The file transaction owns admission for declared targets. Do not open a + // second broad generation-only intent around each nested runner call. + ps = await executePhaseInner( + phase, + state, + hasWrites + ? { ...innerDeps, _workspaceBinding: undefined, _disableCache: true } + : innerDeps, + prior, + emitProgress, + _retryDepth, + innerOpts, + ); + } catch (error) { + if (admitted.prepared) { + await te.rejectPreparedDeclaredFsWrites( + admitted.prepared, + `phase body threw: ${error instanceof Error ? error.message : String(error)}`, + ); + } + throw error; + } + if (!admitted.prepared) return ps; + if (ps.status !== "done") { + try { + await te.rejectPreparedDeclaredFsWrites( + admitted.prepared, + ps.error ?? `phase ended with status ${ps.status}`, + ); + } catch (error) { + return { + ...ps, + status: "failed", + error: `${ps.error ?? "phase failed"}; trusted-effects restore failed: ${error instanceof Error ? error.message : String(error)}`, + endedAt: Date.now(), + }; + } + return ps; + } + const finalized = await te.finalizePreparedDeclaredFsWrites(admitted.prepared, ps.output ?? ""); + if (!finalized.ok) { + return { + ...ps, + status: "failed", + error: `trusted-effects finalize failed (${finalized.code}): ${finalized.reason}`, + sideEffect: true, + endedAt: Date.now(), + }; + } + return { + ...ps, + sideEffect: finalized.committedPaths.length > 0 || ps.sideEffect, + warnings: finalized.committedPaths.length === 0 + ? ps.warnings + : [ + ...(ps.warnings ?? []), + `trusted-effects: committed ${finalized.committedPaths.join(", ")} via resource intent ${finalized.intentId}`, + ], + }; + }; const cwdArg = cwdArgName(phase.cwd); let innerOpts: PhaseExecOpts = { ...opts, upstreamDeps: deps }; - if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined) && phase.when !== undefined) { + if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined || + (Array.isArray((phase as { effects?: unknown }).effects) && ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) && + phase.when !== undefined) { const whenReadRefs: string[] = []; const whenCtx = buildInterpolationContext( state, @@ -1242,7 +1388,7 @@ async function executePhaseImpl( _disableCache: true, _workspaceBinding: workspaceBinding, }; - const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + const ps = await executeInnerWithDeclaredEffects(innerDeps); ps.warnings = [ ...(ps.warnings ?? []), `cwd bridge: resolve-only {args.${cwdArg}} -> ${bound.value.logicalPath}; principal/root authorization, cross-process lease, and write journal are active, but filesystem access outside this directory is not sandbox-enforced`, @@ -1287,25 +1433,17 @@ async function executePhaseImpl( usage: emptyUsage(), }); } - return stamp(await executePhaseInner( - phase, - state, - { + return stamp(await executeInnerWithDeclaredEffects({ ...deps, _cwdOverride: selected.canonicalPath, _cwdBoundary: selected.canonicalPath, _cacheCwdIdentity: selected.canonicalPath, _workspaceBinding: narrowedBinding, - }, - prior, - emitProgress, - _retryDepth, - innerOpts, - )); + })); } // Non-keyword cwd (or none): no workspace lifecycle — run directly. if (!isWorkspaceKeyword(phase.cwd)) { - return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts)); + return stamp(await executeInnerWithDeclaredEffects(deps)); } let ws: Workspace | undefined; try { @@ -1320,7 +1458,7 @@ async function executePhaseImpl( } const innerDeps: RuntimeDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps; try { - const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + const ps = await executeInnerWithDeclaredEffects(innerDeps); if (ws && (ws.kind !== "inherited" || ws.note)) { const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`; const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`; @@ -1449,6 +1587,13 @@ async function executePhaseInner( if (phase.idempotent === false) { cacheScope = "off"; } + // Declared fs.write → resource transaction finalize; never cache-skip promotion. + if ( + Array.isArray((phase as { effects?: unknown }).effects) && + ((phase as { effects?: { kind?: string }[] }).effects ?? []).some((e) => e?.kind === "fs.write") + ) { + cacheScope = "off"; + } if (deps._disableCache) { cacheScope = "off"; } @@ -4212,11 +4357,25 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // mutations or let downstream phases observe stale files. Disable cache and // within-run resume reuse across the complete reachable flow tree. const bridgeTree = flowTreeUsesCwdBridge(def, deps.loadFlow); + const effectsTree = flowTreeUsesDeclaredEffects(def, deps.loadFlow); + const resourceTree = bridgeTree || effectsTree; + if (effectsTree) { + const { validateEffectFlow } = await import("./effects/validate.ts"); + const labelFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + if (!labelFlow.ok) { + return failBeforeExecution( + `Taskflow '${def.name}' EffectIR label flow is invalid: ${labelFlow.issues + .filter((issue) => issue.severity === "error") + .map((issue) => issue.message) + .join("; ")}`, + ); + } + } // Persisted binding is a permanent taint bit: saved-flow definitions can // change between resumes, but prior outputs may already depend on filesystem // mutations. Never regain cache/rebind privileges merely because the current // snapshot no longer declares the bridge. - const bridgeTainted = bridgeTree || state.cwdRootBinding !== undefined; + const bridgeTainted = resourceTree || state.cwdRootBinding !== undefined; if (bridgeTainted) { const invocationRoot = directoryIdentity(deps.cwd); const statePathRoot = directoryIdentity(state.cwd); @@ -4228,7 +4387,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // phase previously executed without a persisted root binding. Conversely, // a host's launch snapshot proves root continuity, not prior bridge // authorization: adding a bridge after ordinary phases ran still fails. - const isLegacyResume = bridgeTree && recordedRoot === undefined && hasExecutablePriorState; + const isLegacyResume = resourceTree && recordedRoot === undefined && hasExecutablePriorState; if ( isLegacyResume || !sameDirectoryIdentity(statePathRoot, invocationRoot) || @@ -4236,7 +4395,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi (recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot)) ) { return failBeforeExecution( - `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, + `Taskflow '${def.name}' resource-authority invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`, ); } state.cwdRootBinding ??= invocationRoot; @@ -4252,7 +4411,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // control/durability scaffold. This does not upgrade its assurance: the // session is deliberately labelled resolve-only and no OS sandbox claim is // made. A native session must come from an exact approved host baseline cell. - if (bridgeTree && deps.cwdBridgeMode === "resolve-only" && !deps.workspaceSession) { + if (resourceTree && (effectsTree || deps.cwdBridgeMode === "resolve-only") && !deps.workspaceSession) { try { deps = { ...deps, @@ -4319,7 +4478,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi // not yet persist compatible input hashes, so it must never blindly trust a // prior `done` row. const hasPriorState = Object.keys(state.phases).length > 0; - if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) { + if (eventKernelEnabled(deps) && !effectsTree && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) { if (!deps.runTask) { throw new Error("event kernel requires RuntimeDeps.runTask"); } diff --git a/packages/taskflow-core/src/runtime/phases/script.ts b/packages/taskflow-core/src/runtime/phases/script.ts index 3ed1fdd3..ca2f1831 100644 --- a/packages/taskflow-core/src/runtime/phases/script.ts +++ b/packages/taskflow-core/src/runtime/phases/script.ts @@ -1,6 +1,12 @@ /** * Script phase — zero-token shell command execution. * Isolated from runtime.ts so S5 strangler can flip kinds without growing the monolith. + * + * Trusted Effects (0.3): when a phase declares `fs.write` effects, the runtime + * (imperative path + event-kernel step) pre-snapshots declared finals, runs the + * script (content via stdout only — **must not** write those finals), then + * promotes through the phase-level resource transaction owned by runtime.ts. + * Mid-phase writes to declared finals fail closed (`declared-path-bypass`). */ import type { Phase } from "../../schema.ts"; diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index b7ec1840..10c8de92 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -13,6 +13,8 @@ import { Type, type Static } from "typebox"; import { Errors as SchemaErrors } from "typebox/value"; import { cwdArgName, hasCwdPlaceholder, normalizeRelativePath } from "./cwd-bridge.ts"; import { WORKSPACE_KEYWORDS } from "./workspace.ts"; +import { EffectDeclSchema } from "./effects/schema.ts"; +import { validateEffectFlow } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Phase types @@ -374,6 +376,17 @@ const PhaseSchema = Type.Object( default: true, }), ), + /** + * Trusted Effects (0.3 MVP): declared side effects for this phase. + * Validated by the effects verifier (`validateEffectIR`). See + * docs/internal/0.3.0-trusted-effects-mvp.md and effects/types.ts. + */ + effects: Type.Optional( + Type.Array(EffectDeclSchema, { + description: + "[0.3 Trusted Effects] Declared side effects (fs.read/write/delete, secret.read, service.call) with PathRef/SecretRef/ServiceRef targets and optional confidentiality/integrity labels.", + }), + ), concurrency: Type.Optional(Type.Number({ description: "Override max concurrency for map/parallel" })), context: Type.Optional( Type.Array(Type.String(), { @@ -1521,6 +1534,21 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va } } + // Cycle detection (Kahn) + try { + const labelFlow = validateEffectFlow( + flow.phases as Phase[], + (phase) => dependenciesOf(phase as Phase), + ); + for (const issue of labelFlow.issues) { + const message = `[effects] ${issue.message}`; + if (issue.severity === "error") errors.push(message); + else warnings.push(message); + } + } catch (error) { + errors.push(`[effects] label-flow validation failed closed: ${error instanceof Error ? error.message : String(error)}`); + } + // Cycle detection (Kahn) if (errors.length === 0) { const cycle = detectCycle(flow.phases as Phase[]); diff --git a/packages/taskflow-core/src/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts new file mode 100644 index 00000000..08266984 --- /dev/null +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -0,0 +1,95 @@ +/** + * Built-in effects verifier — static EffectIR checks (0.3 Trusted Effects MVP). + * + * Collects phase-level `effects[]` (and optional flow-level effects if present), + * runs validateEffectIR, maps issues to VerificationIssue with category `"effects"`. + * + * Wired into `verifyTaskflow` as a built-in detector (always on when effects are + * present). Also exported as {@link effectsLintVerifier} for plugin-style + * registration (e.g. `pluginVerifierErrors` / explicit host lists). + */ + +import type { Phase } from "../schema.ts"; +import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { dependenciesOf } from "../schema.ts"; +import type { EffectDecl } from "../effects/types.ts"; +import type { + TaskflowVerifier, + VerifiableFlow, + VerificationIssue, + VerifierIssue, +} from "../verify.ts"; + +function phaseEffects(p: Phase): EffectDecl[] { + const raw = (p as Phase & { effects?: unknown }).effects; + if (!Array.isArray(raw)) return []; + return raw as EffectDecl[]; +} + +function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { + const raw = (flow as VerifiableFlow & { effects?: unknown }).effects; + if (!Array.isArray(raw)) return []; + return raw as EffectDecl[]; +} + +/** + * Collect all declared effects from a flow (flow-level + each phase), prefix + * phase effect ids with `phaseId/` for unique bag identity, and run + * `validateEffectIR`. Returns VerificationIssues with `category: "effects"`. + * + * Pure — no I/O. Empty effects → empty array (does not fail verify). + */ +export function detectEffectsIssues(flow: VerifiableFlow): VerificationIssue[] { + const phases = Array.isArray(flow.phases) ? flow.phases : []; + const scopedResults: Array<{ phaseId?: string; result: ReturnType }> = []; + const topLevel = flowLevelEffects(flow); + if (topLevel.length > 0) scopedResults.push({ result: validateEffectIR({ effects: topLevel }) }); + for (const rawPhase of phases) { + if (!rawPhase || typeof rawPhase !== "object") continue; + const phase = rawPhase as Phase; + const effects = phaseEffects(phase); + if (effects.length > 0) { + scopedResults.push({ phaseId: phase.id, result: validateEffectIR({ effects }) }); + } + } + if (scopedResults.length === 0) return []; + + const flowResult = validateEffectFlow(phases as Phase[], (phase) => dependenciesOf(phase as Phase)); + const issues: VerificationIssue[] = []; + for (const scoped of scopedResults) { + for (const issue of scoped.result.issues) { + issues.push({ + message: `[effects] ${issue.message}`, + severity: issue.severity, + category: "effects", + phaseId: scoped.phaseId, + source: "effects-lint", + }); + } + } + for (const issue of flowResult.issues) { + const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; + issues.push({ + message: `[effects] ${issue.message}`, + severity: issue.severity, + category: "effects", + phaseId, + source: "effects-lint", + }); + } + return issues; +} + +/** Plugin-style wrapper around {@link detectEffectsIssues} for hosts that + * register verifiers explicitly. Prefer the built-in path via `verifyTaskflow` + * (category `"effects"`); this path stamps category `"plugin"`. */ +export const effectsLintVerifier: TaskflowVerifier = { + name: "effects-lint", + verify(flow: VerifiableFlow): VerifierIssue[] { + return detectEffectsIssues(flow).map((i) => ({ + message: i.message, + severity: i.severity, + phaseId: i.phaseId, + })); + }, +}; diff --git a/packages/taskflow-core/src/verifiers/index.ts b/packages/taskflow-core/src/verifiers/index.ts index 96539f10..543c89dc 100644 --- a/packages/taskflow-core/src/verifiers/index.ts +++ b/packages/taskflow-core/src/verifiers/index.ts @@ -6,10 +6,12 @@ */ export { scriptLintVerifier } from "./script-lint.ts"; +export { effectsLintVerifier } from "./effects-lint.ts"; export { discoverVerifiers, listVerifierPaths, type DiscoveredVerifiers } from "./discover.ts"; import type { TaskflowVerifier } from "../verify.ts"; import { scriptLintVerifier } from "./script-lint.ts"; +import { effectsLintVerifier } from "./effects-lint.ts"; /** All built-in verifiers, in recommended registration order. */ -export const builtinVerifiers: readonly TaskflowVerifier[] = [scriptLintVerifier]; +export const builtinVerifiers: readonly TaskflowVerifier[] = [scriptLintVerifier, effectsLintVerifier]; diff --git a/packages/taskflow-core/src/verify.ts b/packages/taskflow-core/src/verify.ts index eac85fe0..18ec90db 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -12,6 +12,7 @@ import type { Phase } from "./schema.ts"; import { asArray, dependenciesOf, LOOP_DEFAULT_MAX_ITERATIONS } from "./schema.ts"; import { type OutputContract } from "./contract.ts"; +import { detectEffectsIssues } from "./verifiers/effects-lint.ts"; // --------------------------------------------------------------------------- // Types @@ -26,6 +27,7 @@ export type IssueCategory = | "ref-integrity" | "guard-contradiction" | "contract" + | "effects" | "plugin"; export interface VerificationIssue { @@ -638,6 +640,8 @@ export function verifyTaskflow(flow: VerifiableFlow, options?: VerifyOptions): V issues.push(...detectConcurrencyWarnings(safeFlow, succ)); issues.push(...detectGuardContradictions(phases)); issues.push(...detectContractRefMismatches(phases)); + // Trusted Effects (0.3): static EffectIR checks when phase/flow carries effects[] + issues.push(...detectEffectsIssues(safeFlow)); // Caller-supplied verifiers run last, against an isolated deep-frozen snapshot // of the sanitized flow (so a verifier cannot mutate the real execution plan diff --git a/packages/taskflow-core/test/effects-agent-te.test.ts b/packages/taskflow-core/test/effects-agent-te.test.ts new file mode 100644 index 00000000..9314e18d --- /dev/null +++ b/packages/taskflow-core/test/effects-agent-te.test.ts @@ -0,0 +1,309 @@ +/** + * G5 agent-path Trusted Effects: mock runner must not promote finals by + * writing declared paths; content is resource-transaction promoted from output. + * Illegal EffectIR (label-flow) fails closed before authority admission. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunResult } from "../src/host/runner-types.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow, cwd: string): RunState { + return { + runId: "test-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function okResult(agentName: string, task: string, output: string): RunResult { + return { + agent: agentName, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), output: 5, turns: 1 }, + stopReason: "end", + completionSource: "process-exit", + }; +} + +const writeEffect = { + id: "report", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file" as const, + }, + }, + confidentiality: "internal" as const, + integrity: "project" as const, +}; + +test("agent path: mock runner writing declared final fails closed (bypass)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-bypass-")); + try { + const def: Taskflow = { + name: "te-agent-bypass", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "write the report", + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_cwd, _agents, agentName, task) => { + fs.mkdirSync(path.join(root, "out"), { recursive: true }); + fs.writeFileSync(path.join(root, "out/report.md"), "BYPASS\n"); + return okResult(agentName, task, "should-not-promote\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false, "bypass must fail the run/phase"); + const ps = res.state.phases["write"]; + assert.equal(ps?.status, "failed"); + assert.match(ps?.error ?? "", /declared-path-bypass|trusted-effects/i); + assert.equal( + fs.existsSync(path.join(root, "out/report.md")), + false, + "failed phase must restore the pre-phase state instead of retaining the bypass write", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("gate eval fast path: declared write is finalized through the same authority path", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-gate-eval-")); + try { + const def: Taskflow = { + name: "te-gate-eval-write", + phases: [ + { + id: "gate", + type: "gate", + eval: ["true"], + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async () => { + throw new Error("eval auto-pass must not call an LLM"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, true, res.state.finalOutput ?? JSON.stringify(res.state.phases)); + assert.equal( + fs.readFileSync(path.join(root, "out/report.md"), "utf8"), + "PASS (eval checks passed — no LLM call)", + ); + assert.ok((res.state.phases["gate"]?.warnings ?? []).some((w) => /trusted-effects|resource intent/i.test(w))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("agent path: mock runner content is promoted only via the resource transaction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-ok-")); + try { + const def: Taskflow = { + name: "te-agent-ok", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "produce report body", + effects: [writeEffect], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => okResult(agentName, task, "FROM_AGENT_VIA_RESOURCE_INTENT\n"), + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, true, res.state.finalOutput ?? JSON.stringify(res.state.phases)); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "FROM_AGENT_VIA_RESOURCE_INTENT\n"); + const ps = res.state.phases["write"]; + assert.equal(ps?.status, "done"); + assert.ok((ps?.warnings ?? []).some((w) => /trusted-effects|resource intent/i.test(w))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("agent path: illegal label-flow fails phase via executeTaskflow", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-agent-label-")); + try { + const def: Taskflow = { + name: "te-agent-label", + phases: [ + { + id: "write", + type: "agent", + agent: "executor", + task: "x", + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "bad-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { + workspace: "p", + subpath: { literalPath: "leak.txt" }, + intent: "create-file", + }, + }, + }, + ], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => okResult(agentName, task, "nope\n"), + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.match(res.state.phases["write"]?.error ?? "", /effectir-invalid|trusted-effects|label/i); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: cross-phase label violation fails before any phase body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-cross-label-")); + let calls = 0; + try { + const def: Taskflow = { + name: "te-cross-label", + phases: [ + { + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + type: "agent", + agent: "executor", + task: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }], + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.equal(calls, 0, "whole-flow validation must run before the first phase body"); + assert.match(res.finalOutput, /EffectIR label flow is invalid.*read-secret\/secret-input.*publish\/public-output/); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: unbound effect kind fails before the phase body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-unbound-effect-")); + let calls = 0; + try { + const def: Taskflow = { + name: "te-unbound-effect", + phases: [{ + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + final: true, + }], + }; + const deps: RuntimeDeps = { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }; + const res = await executeTaskflow(mkState(def, root), deps); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.state.phases["read-secret"]?.error ?? "", /unsupported-effect-kind.*secret\.read/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-deliverables.test.ts b/packages/taskflow-core/test/effects-deliverables.test.ts new file mode 100644 index 00000000..ce583f9b --- /dev/null +++ b/packages/taskflow-core/test/effects-deliverables.test.ts @@ -0,0 +1,207 @@ +/** + * Named observations for each of the 8 Trusted Effects MVP deliverables. + * Each test title maps to a scoreboard row. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { + CONFIDENTIALITY_LABELS, + EFFECT_KINDS, + INTEGRITY_LABELS, + finalizePreparedDeclaredFsWrites, + isSecretRef, + isServiceRef, + preparePhaseDeclaredFsWrites, + validateEffectIR, + whyAuthorized, + whyContext, + whyEffect, +} from "../src/effects/index.ts"; +import { createResolveOnlyWorkspaceSession } from "../src/resources/execution.ts"; +import * as os from "node:os"; + +// test/ → package → packages → repo root +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +// 1 EffectIR +test("deliverable:EffectIR closed kinds + validate rejects unknown", () => { + assert.ok(EFFECT_KINDS.includes("fs.write")); + const bad = validateEffectIR({ + effects: [{ id: "x", kind: "net.open", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }); + assert.equal(bad.ok, false); + const good = validateEffectIR({ + effects: [ + { + id: "w", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "a.md" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(good.ok, true); +}); + +// 2 PathRef / SecretRef / ServiceRef +test("deliverable:Refs PathRef + SecretRef/ServiceRef fail-closed", () => { + assert.equal(isSecretRef({ secretId: "k" }), true); + assert.equal(isSecretRef({ secretId: "k", value: "nope" }), false); + assert.equal(isServiceRef({ serviceId: "s" }), true); + const mat = validateEffectIR({ + effects: [ + { + id: "s", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k", material: "x" } }, + }, + ], + }); + assert.equal(mat.ok, false); + assert.ok(mat.issues.some((i) => i.code === "secret-material-forbidden")); +}); + +// 3 labels +test("deliverable:Labels confidentiality+integrity lattice enforcement", () => { + assert.ok(CONFIDENTIALITY_LABELS.includes("secret")); + assert.ok(INTEGRITY_LABELS.includes("project")); + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k" } }, + }, + { + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "confidentiality-flow-violation")); +}); + +// 4 resource transaction + 5 durable authority (combined observation) +test("deliverable:ResourceTransaction PathRef-lease-intent-permit-commit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-del-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-del-control-")); + try { + const session = await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + const binding = await session.bindPhase({ + invocationRoot: root, + runId: "d", + phaseId: "write", + argDefinitions: {}, + argValues: {}, + }); + const admitted = await preparePhaseDeclaredFsWrites(binding, { + effects: [ + { + id: "w", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "f.txt" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const finalized = await finalizePreparedDeclaredFsWrites(admitted.prepared, "via-resource-control\n"); + assert.equal(finalized.ok, true); + assert.equal(fs.readFileSync(path.join(root, "f.txt"), "utf8"), "via-resource-control\n"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +// 6 overlap +test("deliverable:Overlap mutating paths denied", () => { + const r = validateEffectIR({ + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "out/x" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.delete", + target: { + kind: "path", + path: { workspace: "p", subpath: { literalPath: "out" }, intent: "existing-directory" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "mutating-path-overlap")); +}); + +// 7 host matrix honesty +test("deliverable:HostMatrix FileBroker unsupported", () => { + const p = path.join(repoRoot, "conformance/workspace/host-support-baseline.json"); + const j = JSON.parse(fs.readFileSync(p, "utf8")) as { + cells: Array<{ capability: string; status: string; guarantee: string }>; + }; + const fb = j.cells.find((c) => c.capability === "file-broker"); + assert.ok(fb, "file-broker cell required"); + assert.equal(fb.status, "unsupported"); + assert.equal(fb.guarantee, "none"); + const te = j.cells.find((c) => c.capability === "trusted-effects-resource-transaction"); + assert.ok(te); + assert.equal(te.status, "supported"); +}); + +// 8 why-* +test("deliverable:Why authorized/context/effect", () => { + const effect = { + id: "w1", + kind: "fs.write" as const, + purpose: "demo", + confidentiality: "internal" as const, + integrity: "project" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file" as const, + }, + }, + }; + const input = { + effect, + runId: "run", + phaseId: "p", + allowed: true, + allowReasons: ["declared"], + status: "committed" as const, + intentId: "intent", + workspaceRoot: "/tmp/x", + }; + const a = whyAuthorized(input); + const c = whyContext(input); + const e = whyEffect(input); + assert.equal(a.allowed, true); + assert.equal(c.confidentiality, "internal"); + assert.equal(e.effectId, "w1"); + assert.ok(e.targetSummary.includes("out/r.md")); +}); diff --git a/packages/taskflow-core/test/effects-e2e-fixture.test.ts b/packages/taskflow-core/test/effects-e2e-fixture.test.ts new file mode 100644 index 00000000..2d5466fb --- /dev/null +++ b/packages/taskflow-core/test/effects-e2e-fixture.test.ts @@ -0,0 +1,164 @@ +/** No-LLM runtime fixture for the resource-controlled Trusted Effects path. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { type Taskflow, validateTaskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; + +function mkState(def: Taskflow, cwd: string, runId: string): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function deps(cwd: string, control: string): RuntimeDeps { + return { + cwd, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script-only fixture must not call an LLM"); }, + persist: () => {}, + onProgress: () => {}, + }; +} + +function fileEffect(id: string, relativePath: string, intent: "create-file" | "existing-file" = "create-file") { + return { + id, + kind: "fs.write" as const, + purpose: `write ${relativePath}`, + confidentiality: "internal" as const, + integrity: "project" as const, + target: { + kind: "path" as const, + path: { workspace: "project", subpath: { literalPath: relativePath }, intent }, + }, + }; +} + +test("fixture: script output commits through resource authority without an LLM", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-fixture-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "trusted-effects-write", + phases: [{ + id: "write-report", + type: "script", + run: ["node", "-e", "process.stdout.write('REPORT')"], + effects: [fileEffect("report", "out/report.md")], + final: true, + }], + }; + assert.equal(validateTaskflow(def).ok, true); + const result = await executeTaskflow(mkState(def, root, "fixture-commit"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "REPORT"); + assert.ok((result.state.phases["write-report"]?.warnings ?? []).some((warning) => /resource intent/.test(warning))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: checked-in Trusted Effects example executes through the same path", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-example-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const example = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../examples/trusted-effects-write.json"); + const def = JSON.parse(fs.readFileSync(example, "utf8")) as Taskflow; + const result = await executeTaskflow(mkState(def, root, "fixture-example"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.ok(fs.existsSync(path.join(root, "out/report.md"))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: direct overwrite of an existing final is rejected and original bytes are restored", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-existing-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + fs.mkdirSync(path.join(root, "out")); + fs.writeFileSync(path.join(root, "out/report.md"), "ORIGINAL"); + const def: Taskflow = { + name: "trusted-effects-restore-existing", + phases: [{ + id: "write-report", + type: "script", + run: ["node", "-e", "require('fs').writeFileSync('out/report.md','BYPASS');process.stdout.write('DECLARED')"], + effects: [fileEffect("report", "out/report.md", "existing-file")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-restore"), deps(root, control)); + assert.equal(result.ok, false); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "ORIGINAL"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: multi-write JSON output commits as one resource transaction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-multi-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "trusted-effects-multi", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write(JSON.stringify({a:'A',b:'B'}))"], + effects: [fileEffect("a", "a.txt"), fileEffect("b", "b.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-multi"), deps(root, control)); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal(fs.readFileSync(path.join(root, "b.txt"), "utf8"), "B"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("fixture: overlapping declared targets fail before script invocation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-overlap-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const marker = path.join(root, "invoked"); + const def: Taskflow = { + name: "trusted-effects-overlap", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", `require('fs').writeFileSync(${JSON.stringify(marker)},'yes')`], + effects: [fileEffect("a", "same.txt"), fileEffect("b", "same.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(def, root, "fixture-overlap"), deps(root, control)); + assert.equal(result.ok, false); + assert.equal(fs.existsSync(marker), false); + assert.match(result.state.phases.write?.error ?? "", /overlap|effectir-invalid/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-gateway-bypass.test.ts b/packages/taskflow-core/test/effects-gateway-bypass.test.ts new file mode 100644 index 00000000..fc0e4976 --- /dev/null +++ b/packages/taskflow-core/test/effects-gateway-bypass.test.ts @@ -0,0 +1,342 @@ +/** Adversarial runtime coverage for resource-controlled Trusted Effects. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import { createResolveOnlyWorkspaceSession } from "../src/resources/execution.ts"; +import { WriteIntentJournal } from "../src/resources/journal.ts"; +import { executeTaskflow } from "../src/runtime.ts"; +import { emptyUsage } from "../src/usage.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; + +function mkState(def: Taskflow, cwd: string, runId = "te-runtime"): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function writeEffect(relativePath: string, id = "report") { + return { + id, + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: relativePath }, + intent: "create-file" as const, + }, + }, + }; +} + +test("runtime script: declared write commits through a resource intent", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-script-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-script-write", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('HELLO_FROM_RESOURCE')"], + effects: [writeEffect("out/report.md")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "out/report.md"), "utf8"), "HELLO_FROM_RESOURCE"); + assert.ok((result.state.phases.write?.warnings ?? []).some((warning) => /resource intent/.test(warning))); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime script: direct final write fails and restores the exact pre-state", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-script-bypass-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-script-bypass", + phases: [{ + id: "write", + type: "script", + run: [ + "node", + "-e", + "const fs=require('fs');fs.mkdirSync('out',{recursive:true});fs.writeFileSync('out/report.md','BYPASS');process.stdout.write('DECLARED')", + ], + effects: [writeEffect("out/report.md")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "script-bypass"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /declared-path-bypass|changed outside/i); + assert.equal(fs.existsSync(path.join(root, "out")), false, "failed phase must leave no declared-path residue"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime admission: workspace parent symlink escape is rejected before body execution", async (t) => { + if (process.platform === "win32") return t.skip("symlink privileges are platform-specific"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-symlink-root-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-symlink-outside-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + let invoked = 0; + try { + fs.symlinkSync(outside, path.join(root, "linked"), "dir"); + const flow: Taskflow = { + name: "te-symlink-escape", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "produce content", + effects: [writeEffect("linked/escape.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "symlink-escape"), { + cwd: root, + workspaceControlDirectory: control, + agents: [{ name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }], + runTask: async () => { + invoked++; + throw new Error("must not run"); + }, + }); + assert.equal(result.ok, false); + assert.equal(invoked, 0); + assert.match(result.state.phases.write?.error ?? "", /TFWS_PATH_ESCAPE/); + assert.equal(fs.existsSync(path.join(outside, "escape.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime multi-write: incomplete content map rejects without final files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-multi-map-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-multi-map", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write(JSON.stringify({a:'A'}))"], + effects: [writeEffect("a.txt", "a"), writeEffect("b.txt", "b")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "multi-map"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /content-resolution-failed|missing string content/); + assert.equal(fs.existsSync(path.join(root, "a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "b.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime event-kernel flag: declared effects use the same resource transaction semantics", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-kernel-fallback-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-kernel-fallback", + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('SAME_AUTHORITY')"], + effects: [writeEffect("kernel.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "kernel-fallback"), { + cwd: root, + workspaceControlDirectory: control, + eventKernel: true, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "kernel.txt"), "utf8"), "SAME_AUTHORITY"); + assert.equal((await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents())[0]?.status, "committed-content"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime PathRef: typed dynamic output path resolves once at resource admission", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-path-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const flow: Taskflow = { + name: "te-dynamic-path", + args: { outputPath: { type: "relative-path", required: true } }, + phases: [{ + id: "write", + type: "script", + run: ["node", "-e", "process.stdout.write('DYNAMIC')"], + effects: [{ + id: "report", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { argPath: "outputPath" }, + intent: "create-file", + }, + }, + }], + final: true, + }], + }; + const state = mkState(flow, root, "dynamic-path"); + state.args = { outputPath: "generated/report.txt" }; + const result = await executeTaskflow(state, { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, true, result.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "generated/report.txt"), "utf8"), "DYNAMIC"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime authority: an isolated cwd outside the invocation grant fails before its body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-isolated-cwd-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + const marker = path.join(root, "body-ran"); + try { + const flow: Taskflow = { + name: "te-isolated-cwd", + phases: [{ + id: "write", + type: "script", + cwd: "temp", + run: ["node", "-e", `require('fs').writeFileSync(${JSON.stringify(marker)},'yes')`], + effects: [writeEffect("report.txt")], + final: true, + }], + }; + const result = await executeTaskflow(mkState(flow, root, "isolated-cwd"), { + cwd: root, + workspaceControlDirectory: control, + agents: [], + runTask: async () => { throw new Error("script flow must not call an LLM"); }, + }); + assert.equal(result.ok, false); + assert.match(result.state.phases.write?.error ?? "", /TFWS_PATH_ESCAPE/); + assert.equal(fs.existsSync(marker), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("runtime admission: overlapping cross-run effects reject before the second body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-cross-run-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + let releaseFirst!: () => void; + let firstEntered!: () => void; + const firstBodyEntered = new Promise((resolve) => { firstEntered = resolve; }); + const firstBodyRelease = new Promise((resolve) => { releaseFirst = resolve; }); + let secondInvoked = 0; + try { + const flow: Taskflow = { + name: "te-cross-run-overlap", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "produce content", + effects: [writeEffect("shared.txt")], + final: true, + }], + }; + const agents = [{ name: "executor", description: "test", systemPrompt: "", source: "user" as const, filePath: "" }]; + const firstSession = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs: 500, + }); + const secondSession = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs: 30, + }); + const first = executeTaskflow(mkState(flow, root, "run-first"), { + cwd: root, + workspaceSession: firstSession, + agents, + runTask: async (cwd, _agents, agent, task) => { + firstEntered(); + await firstBodyRelease; + return { agent, task, exitCode: 0, output: "FIRST", stderr: "", usage: emptyUsage(), stopReason: "end" }; + }, + }); + await firstBodyEntered; + const second = await executeTaskflow(mkState(flow, root, "run-second"), { + cwd: root, + workspaceSession: secondSession, + agents, + runTask: async () => { + secondInvoked++; + throw new Error("overlapping body must not run"); + }, + }); + assert.equal(second.ok, false); + assert.equal(secondInvoked, 0); + assert.match(second.state.phases.write?.error ?? "", /Lease timeout/); + releaseFirst(); + const firstResult = await first; + assert.equal(firstResult.ok, true, firstResult.finalOutput); + assert.equal(fs.readFileSync(path.join(root, "shared.txt"), "utf8"), "FIRST"); + assert.equal((await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents()).length, 1); + } finally { + releaseFirst?.(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-trusted.test.ts b/packages/taskflow-core/test/effects-trusted.test.ts new file mode 100644 index 00000000..9817a42a --- /dev/null +++ b/packages/taskflow-core/test/effects-trusted.test.ts @@ -0,0 +1,871 @@ +/** + * Trusted Effects MVP — unit + vertical-slice fixture (no LLM). + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + pathsOverlap, + precheckDeclaredFsWriteOverlap, + validateEffectIR, + whyEffect, + whyEffectFromFlow, + formatWhyEffect, + type EffectDecl, + type EffectIR, +} from "../src/effects/index.ts"; + +// --------------------------------------------------------------------------- +// Schema accept / reject — PhaseSchema + FlowIRNode (effects optional) +// --------------------------------------------------------------------------- + +test("PhaseSchema/validateTaskflow: accepts phase with effects[] (and without — backward compat)", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const withEffects = { + name: "fx-schema-ok", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + final: true, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const without = { + name: "fx-schema-legacy", + phases: [{ id: "w", type: "script" as const, run: "true", final: true }], + }; + const ok1 = validateTaskflow(withEffects); + const ok2 = validateTaskflow(without); + assert.equal(ok1.ok, true, JSON.stringify(ok1.errors)); + assert.equal(ok2.ok, true, JSON.stringify(ok2.errors)); +}); + +test("PhaseSchema/validateTaskflow: rejects non-array effects", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const bad = { + name: "fx-schema-bad", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: { id: "w1", kind: "fs.write" }, + }, + ], + }; + const r = validateTaskflow(bad); + assert.equal(r.ok, false); + assert.ok( + r.errors.some((e) => /effects/i.test(e) || /array/i.test(e)), + JSON.stringify(r.errors), + ); +}); + +test("PhaseSchema/validateTaskflow: rejects open or unknown EffectIR declarations", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const phase = { + id: "w", + type: "script" as const, + run: "true", + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + ambientAuthority: true, + }], + }; + const result = validateTaskflow({ name: "fx-closed", phases: [phase] }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /effects|kind|union/i.test(error)), JSON.stringify(result.errors)); +}); + +test("FlowIRNodeSchema/isFlowIRNode: accepts optional effects[] and rejects non-array", async () => { + const { Value } = await import("typebox/value"); + const { FlowIRNodeSchema, isFlowIRNode } = await import("../src/flowir/schema.ts"); + const base = { + id: "w", + kind: "script" as const, + inject: [] as string[], + emits: ["w"], + }; + const withFx = { + ...base, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/r.md" }, + intent: "create-file", + }, + }, + }, + ], + }; + assert.equal(isFlowIRNode(base), true); + assert.equal(isFlowIRNode(withFx), true); + assert.equal(Value.Check(FlowIRNodeSchema, base), true); + assert.equal(Value.Check(FlowIRNodeSchema, withFx), true); + + assert.equal(isFlowIRNode({ ...base, effects: "nope" }), false); + assert.equal(Value.Check(FlowIRNodeSchema, { ...base, effects: "nope" }), false); + const unknown = { + ...base, + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + }; + assert.equal(isFlowIRNode(unknown), false); + assert.equal(Value.Check(FlowIRNodeSchema, unknown), false); + const open = { ...withFx, effects: [{ ...withFx.effects[0], ambientAuthority: true }] }; + assert.equal(isFlowIRNode(open), false); + assert.equal(Value.Check(FlowIRNodeSchema, open), false); +}); + +// --------------------------------------------------------------------------- +// validate + overlap +// --------------------------------------------------------------------------- + +test("validateEffectIR: accepts well-formed fs.write", () => { + const ir: EffectIR = { + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + confidentiality: "internal", + integrity: "project", + }, + ], + }; + const r = validateEffectIR(ir); + assert.equal(r.ok, true, JSON.stringify(r.issues)); +}); + +test("validateEffectIR: rejects unknown kind", () => { + const r = validateEffectIR({ + effects: [{ id: "x", kind: "net.open", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "unknown-effect-kind")); +}); + +test("validateEffectIR: rejects undeclared fields outside the closed schema", () => { + const r = validateEffectIR({ + effects: [{ + id: "x", + kind: "fs.write", + target: { kind: "path", path: { workspace: "p", intent: "create-file" } }, + ambientAuthority: true, + }], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((issue) => issue.code === "invalid-effect-shape")); +}); + +test("validateEffectIR: rejects secret material on SecretRef", () => { + const r = validateEffectIR({ + effects: [ + { + id: "s1", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "k", value: "leaked" } }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "secret-material-forbidden")); +}); + +test("validateEffectIR: mutating path overlap is an error", () => { + const r = validateEffectIR({ + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.delete", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out" }, intent: "existing-directory" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "mutating-path-overlap")); +}); + +test("pathsOverlap: parent/child", () => { + assert.equal(pathsOverlap("out", "out/a.md"), true); + assert.equal(pathsOverlap("out/a.md", "out/b.md"), false); +}); + +test("validateEffectIR: secret source cannot flow to a public sink", () => { + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "confidentiality-flow-violation")); +}); + +test("validateEffectIR: untrusted source cannot flow to a verified sink", () => { + const r = validateEffectIR({ + effects: [ + { + id: "untrusted-input", + kind: "fs.read", + integrity: "untrusted", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.txt" }, intent: "existing-file" } }, + }, + { + id: "verified-output", + kind: "fs.write", + integrity: "verified", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "out.txt" }, intent: "create-file" } }, + }, + ], + }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.code === "integrity-flow-violation")); +}); + +test("validateEffectIR: confidentiality and integrity-preserving source-to-sink flow is allowed", () => { + const r = validateEffectIR({ + effects: [ + { + id: "secret-input", + kind: "secret.read", + integrity: "verified", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }, + { + id: "protected-output", + kind: "fs.write", + confidentiality: "secret", + integrity: "project", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "protected.txt" }, intent: "create-file" } }, + }, + ], + }); + assert.equal(r.ok, true, JSON.stringify(r.issues)); +}); + +test("validateTaskflow: secret data cannot cross a dependency edge into a public sink", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-cross-phase-secret", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + task: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /read-secret\/secret-input.*publish\/public-output/.test(error)), JSON.stringify(result.errors)); +}); + +test("validateTaskflow: labels propagate through effect-free intermediate phases", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-transitive-integrity", + phases: [ + { + id: "read-untrusted", + task: "read", + effects: [{ + id: "input", + kind: "fs.read", + integrity: "untrusted", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "input.txt" }, intent: "existing-file" }, + }, + }], + }, + { id: "transform", task: "transform", dependsOn: ["read-untrusted"] }, + { + id: "publish-verified", + task: "publish", + dependsOn: ["transform"], + effects: [{ + id: "verified-output", + kind: "fs.write", + integrity: "verified", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "verified.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /read-untrusted\/input.*publish-verified\/verified-output/.test(error)), JSON.stringify(result.errors)); +}); + +test("validateTaskflow: cross-phase confidentiality and integrity preserving flow is allowed", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-cross-phase-allowed", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + integrity: "verified", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "store-protected", + task: "store", + dependsOn: ["read-secret"], + effects: [{ + id: "protected-output", + kind: "fs.write", + confidentiality: "secret", + integrity: "project", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "protected.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test("precheckDeclaredFsWriteOverlap: parent/child paths are rejected before admission", () => { + const result = precheckDeclaredFsWriteOverlap([ + { effectId: "parent", relativePath: "out", content: "x" }, + { effectId: "child", relativePath: "out/a.md", content: "y" }, + ]); + assert.equal(result.ok, false); +}); + +// --------------------------------------------------------------------------- +// why-* +// --------------------------------------------------------------------------- + +test("verifyTaskflow: overlapping mutating effects are category=effects errors", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-overlap", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, false); + const fx = r.issues.filter((i) => i.category === "effects"); + assert.ok(fx.length > 0, "expected effects issues"); + assert.ok(fx.some((i) => /overlap/i.test(i.message))); + assert.equal(fx[0]!.source, "effects-lint"); + assert.equal(fx[0]!.phaseId, "w"); +}); + +test("verifyTaskflow: unknown effect kind is a verify error", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-unknown", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "x", + kind: "net.open", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, false); + assert.ok( + r.issues.some( + (i) => i.category === "effects" && i.severity === "error" && /unknown kind/i.test(i.message), + ), + ); +}); + +test("verifyTaskflow: well-formed effects do not fail verify", async () => { + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-ok", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + final: true, + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + confidentiality: "internal", + integrity: "project", + }, + ], + }, + ], + }; + const r = verifyTaskflow(flow as never); + assert.equal(r.ok, true, JSON.stringify(r.issues)); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("effectsLintVerifier plugin path: still flags overlap when registered", async () => { + const { effectsLintVerifier } = await import("../src/verifiers/effects-lint.ts"); + const { verifyTaskflow } = await import("../src/verify.ts"); + const flow = { + name: "fx-plugin", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "a", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + { + id: "b", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/a.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + // Built-in already flags; plugin adds a second source=effects-lint with category=plugin + const r = verifyTaskflow(flow as never, { verifiers: [effectsLintVerifier] }); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); + assert.ok(r.issues.some((i) => i.category === "plugin" && i.source === "effects-lint" && /overlap/i.test(i.message))); +}); + +test("translateTaskflow: carries phase effects onto FlowIR nodes", async () => { + const { translateTaskflow } = await import("../src/flowir/translate.ts"); + const def = { + name: "fx-hash", + phases: [ + { + id: "w", + type: "script" as const, + run: "true", + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/r.md" }, intent: "create-file" }, + }, + }, + ], + }, + ], + }; + const { ir } = translateTaskflow(def as never); + assert.ok(ir.nodes[0]?.effects?.length === 1); +}); + +test("translateTaskflow: invalid effects are diagnosed and excluded from projected IR", async () => { + const { translateTaskflow } = await import("../src/flowir/translate.ts"); + const result = translateTaskflow({ + name: "fx-translate-closed", + phases: [{ + id: "w", + type: "script", + run: "true", + effects: [{ id: "x", kind: "root.shell", target: { kind: "path", path: { workspace: "p", intent: "create-file" } } }], + }], + } as never); + assert.ok(result.errors.some((error) => error.code.startsWith("effect-"))); + assert.equal(result.ir.nodes[0]?.effects, undefined); +}); + +test("compileTaskflowToFlowIR + hashFlowIR: effects are content-addressed", async () => { + const { compileTaskflowToFlowIR, hashFlowIR } = await import("../src/flowir/index.ts"); + const basePhase = { + id: "w", + type: "script" as const, + run: "true", + }; + const effect = (path: string) => ({ + id: "w1", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { workspace: "project", subpath: { literalPath: path }, intent: "create-file" as const }, + }, + }); + const def1 = { + name: "fx-hash", + phases: [{ ...basePhase, effects: [effect("out/r.md")] }], + }; + const def2 = { + name: "fx-hash", + phases: [{ ...basePhase, effects: [effect("out/other.md")] }], + }; + const defNone = { + name: "fx-hash", + phases: [{ ...basePhase }], + }; + const c1 = compileTaskflowToFlowIR(def1 as never); + const c2 = compileTaskflowToFlowIR(def2 as never); + const c0 = compileTaskflowToFlowIR(defNone as never); + assert.ok(c1.canonical.nodes[0]?.effects?.length === 1, "compile must carry effects"); + assert.ok(c2.canonical.nodes[0]?.effects?.length === 1); + assert.equal(c0.canonical.nodes[0]?.effects, undefined); + + const h1 = hashFlowIR(c1.canonical); + const h2 = hashFlowIR(c2.canonical); + const h0 = hashFlowIR(c0.canonical); + assert.notEqual(h1, h2, "effects must affect content hash"); + assert.notEqual(h1, h0, "presence of effects must affect content hash"); + // Stability + assert.equal(h1, hashFlowIR(c1.canonical)); + assert.match(h1, /^ir:[0-9a-f]{64}$/); +}); + +test("compileTaskflowToIR: invalid EffectIR is diagnosed and never content-addressed", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-compile-closed", + phases: [{ + id: "w", + type: "script" as const, + run: "true", + effects: [{ + id: "x", + kind: "root.shell", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + }], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /effect-/.test(error.code))); + assert.equal(compiled.canonical.nodes[0]?.effects, undefined); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); + assert.equal(publicResult.usedFallbackHash, true); +}); + +test("compileTaskflowToIR: cross-phase label violation is diagnosed and never content-addressed", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR, translateTaskflow } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-compile-label-flow", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + type: "script" as const, + run: "true", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" }, + }, + }], + }, + ], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /confidentiality-flow-violation/.test(error.code))); + assert.ok(compiled.errors.some((error) => /read-secret\/secret-input.*publish\/public-output/.test(error.message))); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); + const translated = translateTaskflow(def as never); + assert.ok(translated.errors.some((error) => /confidentiality-flow-violation/.test(error.code))); +}); + +test("whyEffect: structured explanation", () => { + const effect: EffectDecl = { + id: "w1", + kind: "fs.write", + purpose: "write report", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/report.md" }, intent: "create-file" }, + }, + }; + const w = whyEffect({ + effect, + runId: "run_1", + phaseId: "write", + allowed: true, + allowReasons: ["declared on BoundPlan", "validateEffectIR ok"], + intentId: "intent_1", + journalStatus: "committed-content", + status: "committed", + workspaceRoot: "/tmp/proj", + }); + assert.equal(w.effectId, "w1"); + assert.equal(w.authorized.allowed, true); + assert.equal(w.context.confidentiality, "internal"); + assert.ok(w.targetSummary.includes("out/report.md")); + assert.ok(w.reasons.some((r) => r.includes("intent=intent_1"))); +}); + + +test("whyEffectFromFlow: finds declared effect and authorizes when valid", () => { + const flow = { + phases: [ + { + id: "write", + effects: [ + { + id: "w1", + kind: "fs.write", + purpose: "emit report", + confidentiality: "internal", + integrity: "project", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "out/report.md" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ + flow, + runId: "run_fx_1", + effectId: "w1", + workspaceRoot: "/tmp/proj", + }); + assert.equal(r.ok, true); + if (!r.ok) return; + assert.equal(r.why.effectId, "w1"); + assert.equal(r.why.authorized.allowed, true); + assert.equal(r.why.status, "declared"); + assert.equal(r.phaseId, "write"); + assert.ok(r.why.targetSummary.includes("out/report.md")); + const text = formatWhyEffect(r.why); + assert.ok(text.includes("authorized: yes")); + assert.ok(text.includes("w1")); +}); + +test("whyEffectFromFlow: missing effect fails closed with known list", () => { + const flow = { + phases: [ + { + id: "write", + effects: [ + { + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "a.txt" }, + intent: "create-file", + }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ flow, runId: "run_x", effectId: "nope" }); + assert.equal(r.ok, false); + if (r.ok) return; + assert.ok(r.error.includes("not found")); + assert.ok(r.error.includes("write/w1") || r.error.includes("w1")); +}); + +test("whyEffectFromFlow: invalid secret material deny (authorized=false)", () => { + const flow = { + phases: [ + { + id: "s", + effects: [ + { + id: "bad-secret", + kind: "secret.read", + target: { + kind: "secret", + secret: { secretId: "x", value: "leaked" }, + }, + }, + ], + }, + ], + }; + const r = whyEffectFromFlow({ flow, runId: "run_s", effectId: "bad-secret" }); + assert.equal(r.ok, true); + if (!r.ok) return; + assert.equal(r.why.authorized.allowed, false); + assert.ok(r.why.authorized.reasons.some((x) => /secret/i.test(x))); +}); + +test("whyEffectFromFlow: ambiguous id requires phaseId", () => { + const effect = { + id: "shared", + kind: "fs.read", + target: { + kind: "path", + path: { + workspace: "project", + subpath: { literalPath: "a.txt" }, + intent: "existing-file", + }, + }, + }; + const flow = { + phases: [ + { id: "a", effects: [effect] }, + { id: "b", effects: [{ ...effect }] }, + ], + }; + const amb = whyEffectFromFlow({ flow, runId: "r", effectId: "shared" }); + assert.equal(amb.ok, false); + if (amb.ok) return; + assert.ok(/ambiguous/i.test(amb.error)); + const ok = whyEffectFromFlow({ flow, runId: "r", effectId: "shared", phaseId: "b" }); + assert.equal(ok.ok, true); + if (!ok.ok) return; + assert.equal(ok.phaseId, "b"); + assert.equal(ok.why.authorized.allowed, true); +}); diff --git a/packages/taskflow-core/test/flowir-canonical-hash.test.ts b/packages/taskflow-core/test/flowir-canonical-hash.test.ts index b3bc7c36..04314785 100644 --- a/packages/taskflow-core/test/flowir-canonical-hash.test.ts +++ b/packages/taskflow-core/test/flowir-canonical-hash.test.ts @@ -5,6 +5,7 @@ import { hashFlowIR, hashNode, } from "../src/flowir/canonical-hash.ts"; +import type { EffectDecl } from "../src/effects/types.ts"; import type { FlowIR, FlowIRNode } from "../src/flowir/schema.ts"; function node(id: string, overrides: Partial = {}): FlowIRNode { @@ -303,3 +304,92 @@ test("SENSITIVITY: two distinct IRs do not collide", () => { ]); assert.notEqual(hashFlowIR(a), hashFlowIR(b)); }); + +// --------------------------------------------------------------------------- +// 4. TRUSTED EFFECTS (0.3) — content-addressed when present +// --------------------------------------------------------------------------- + +const sampleEffect = (path: string): EffectDecl => ({ + id: "w1", + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: path }, intent: "create-file" }, + }, + integrity: "project", + confidentiality: "internal", +}); + +test("SENSITIVITY: node effects change hashNode", () => { + const a = node("x", { task: "t" }); + const b = node("x", { task: "t", effects: [sampleEffect("out/a.md")] }); + assert.notEqual(hashNode(a), hashNode(b), "presence of effects must change the node hash"); +}); + +test("SENSITIVITY: different effect targets produce different hashes", () => { + const a = node("x", { effects: [sampleEffect("out/a.md")] }); + const b = node("x", { effects: [sampleEffect("out/b.md")] }); + assert.notEqual(hashNode(a), hashNode(b)); +}); + +test("SENSITIVITY: different effect kinds produce different hashes", () => { + const a = node("x", { + effects: [{ id: "e1", kind: "fs.read" as const, target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.md" }, intent: "existing-file" } } }], + }); + const b = node("x", { + effects: [{ id: "e1", kind: "fs.write" as const, target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "in.md" }, intent: "create-file" } } }], + }); + assert.notEqual(hashNode(a), hashNode(b)); +}); + +test("SENSITIVITY: flows differing only in effects produce different hashFlowIR", () => { + const a = ir([node("w", { kind: "script", effects: [sampleEffect("out/a.md")] })]); + const b = ir([node("w", { kind: "script", effects: [sampleEffect("out/b.md")] })]); + assert.notEqual(hashFlowIR(a), hashFlowIR(b)); +}); + +test("DETERMINISM: effects-bearing IR hashes stably across calls and clones", () => { + const a = ir([ + node("w", { + kind: "script", + task: "write", + effects: [sampleEffect("out/report.md")], + }), + ]); + const h1 = hashFlowIR(a); + const h2 = hashFlowIR(a); + const h3 = hashFlowIR(JSON.parse(JSON.stringify(a))); + assert.equal(h1, h2); + assert.equal(h2, h3); + assert.match(h1, /^ir:[0-9a-f]{64}$/); +}); + +test("INDEPENDENCE: effects object key order does not change the hash", () => { + const e1: EffectDecl = { + id: "w1", + kind: "fs.write", + integrity: "project", + confidentiality: "internal", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "out/r.md" }, intent: "create-file" }, + }, + }; + const e2: EffectDecl = { + target: { + path: { intent: "create-file", subpath: { literalPath: "out/r.md" }, workspace: "project" }, + kind: "path", + }, + confidentiality: "internal", + integrity: "project", + kind: "fs.write", + id: "w1", + }; + assert.equal(hashNode(node("x", { effects: [e1] })), hashNode(node("x", { effects: [e2] }))); +}); + +test("INDEPENDENCE: empty effects array is equivalent to absent effects", () => { + const a = node("x", { task: "t", effects: [] }); + const b = node("x", { task: "t" }); + assert.equal(hashNode(a), hashNode(b)); +}); diff --git a/packages/taskflow-core/test/resource-file-transaction.test.ts b/packages/taskflow-core/test/resource-file-transaction.test.ts new file mode 100644 index 00000000..6e2e9bdb --- /dev/null +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -0,0 +1,394 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test } from "node:test"; +import { + finalizePreparedDeclaredFsWrites, + preparePhaseDeclaredFsWrites, +} from "../src/effects/runtime-apply.ts"; +import { whyEffectFromLedger } from "../src/effects/why.ts"; +import { + createResolveOnlyWorkspaceSession, + type ResolveOnlyPhaseBinding, +} from "../src/resources/execution.ts"; +import { + prepareResourceFileTransaction, + type ResolvedFileWriteTarget, +} from "../src/resources/file-transaction.ts"; +import { WriteIntentJournal } from "../src/resources/journal.ts"; +import { + PersistentLeaseCoordinator, + type LeaseAcquireOptions, + type LeaseHandle, + type LeaseRequest, +} from "../src/resources/leases.ts"; +import type { MutationPermit } from "../src/resources/permits.ts"; +import { resolvePathRef } from "../src/resources/resolve.ts"; +import type { PathRef, ScopedCapability } from "../src/resources/schema.ts"; +import type { ExecutionOwner } from "../src/resources/types.ts"; + +function fixture(): { root: string; control: string } { + return { + root: fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-root-")), + control: fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-control-")), + }; +} + +async function rootBinding(root: string, control: string, leaseTimeoutMs = 500): Promise { + const session = await createResolveOnlyWorkspaceSession({ + invocationRoot: root, + controlDirectory: control, + leaseTimeoutMs, + }); + return session.bindPhase({ + invocationRoot: root, + runId: "run", + phaseId: "phase", + argDefinitions: {}, + argValues: {}, + }); +} + +function writePath(relativePath: string): PathRef { + return { + workspace: "project", + subpath: { literalPath: relativePath }, + access: "read-write", + intent: "create-file", + maxLifetime: { scope: "phase" }, + }; +} + +test("resource file transaction: commits content with durable authority evidence", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const tx = await bound.beginFileWriteTransaction([ + { effectId: "a", path: writePath("out/a.txt") }, + { effectId: "b", path: writePath("out/b.txt") }, + ]); + const result = await tx.commit([ + { effectId: "a", content: "A" }, + { effectId: "b", content: "B" }, + ]); + assert.equal(result.ok, true); + assert.equal(fs.readFileSync(path.join(root, "out/a.txt"), "utf8"), "A"); + assert.equal(fs.readFileSync(path.join(root, "out/b.txt"), "utf8"), "B"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const [intent] = await journal.listIntents(); + assert.equal(intent?.status, "committed-content"); + assert.equal(intent?.commitGeneration, 1); + assert.equal(intent?.restorableSnapshotArtifactIds?.length, 2); + assert.equal(intent?.authorizationPrincipalId, "local-host-invocation"); + assert.deepEqual(intent?.scopes.map((scope) => scope.effectId).sort(), ["a", "b"]); + assert.ok(intent?.scopes.every((scope) => scope.capabilityBindingId?.startsWith("binding-"))); + const why = whyEffectFromLedger({ + flow: { + phases: [{ + id: "phase", + effects: [{ + id: "a", + kind: "fs.write", + target: { kind: "path", path: writePath("out/a.txt") }, + }], + }], + }, + runId: "run", + phaseId: "phase", + effectId: "a", + intents: [intent!], + }); + assert.equal(why.ok, true); + if (why.ok) { + assert.equal(why.why.authorized.allowed, true); + assert.equal(why.why.status, "committed"); + assert.equal(why.why.intentId, intent?.intentId); + assert.equal(why.why.authorized.principalId, "local-host-invocation"); + } + const declarationOnly = whyEffectFromLedger({ + flow: { phases: [{ id: "phase", effects: [{ + id: "a", + kind: "fs.write", + target: { kind: "path", path: writePath("out/a.txt") }, + }] }] }, + runId: "run", + phaseId: "phase", + effectId: "a", + intents: [], + }); + assert.equal(declarationOnly.ok, true); + if (declarationOnly.ok) assert.equal(declarationOnly.why.authorized.allowed, false); + assert.equal(fs.readdirSync(path.join(control, "file-transactions")).length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: direct final-path bypass is restored with a known-clean terminal record", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const tx = await bound.beginFileWriteTransaction([ + { effectId: "report", path: writePath("out/report.md") }, + ]); + fs.mkdirSync(path.join(root, "out")); + fs.writeFileSync(path.join(root, "out/report.md"), "BYPASS"); + const result = await tx.commit([{ effectId: "report", content: "DECLARED" }]); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "declared-path-bypass"); + assert.equal(result.restored, true); + assert.equal(fs.existsSync(path.join(root, "out/report.md")), false); + assert.equal(fs.existsSync(path.join(root, "out")), false, "new empty parents are part of rollback"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const [intent] = await journal.listIntents(); + assert.equal(intent?.status, "aborted-restored"); + assert.match(intent?.terminalReason ?? "", /changed outside the resource transaction/); + assert.equal(await journal.getDomainGeneration(intent!.resourceDomainId), 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("effects bridge: declaration is admitted before body and committed by resource authority", async () => { + const { root, control } = fixture(); + try { + const bound = await rootBinding(root, control); + const effects = [{ + id: "report", + kind: "fs.write" as const, + target: { kind: "path" as const, path: writePath("report.md") }, + confidentiality: "internal" as const, + integrity: "project" as const, + }]; + const admitted = await preparePhaseDeclaredFsWrites(bound, { effects }); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const finalized = await finalizePreparedDeclaredFsWrites(admitted.prepared, "REPORT"); + assert.equal(finalized.ok, true); + assert.equal(fs.readFileSync(path.join(root, "report.md"), "utf8"), "REPORT"); + const [intent] = await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents(); + assert.equal(intent?.status, "committed-content"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: PathRef resolver rejects a parent symlink escape before intent creation", async (t) => { + if (process.platform === "win32") return t.skip("symlink privileges are platform-specific"); + const { root, control } = fixture(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tfws-file-tx-outside-")); + try { + fs.symlinkSync(outside, path.join(root, "linked"), "dir"); + const bound = await rootBinding(root, control); + await assert.rejects( + bound.beginFileWriteTransaction([{ effectId: "escape", path: writePath("linked/escape.txt") }]), + /TFWS_PATH_ESCAPE/, + ); + assert.equal(fs.existsSync(path.join(outside, "escape.txt")), false); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + assert.deepEqual(await journal.listIntents(), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test("resource file transaction: overlapping cross-session writer is rejected before a second intent", async () => { + const { root, control } = fixture(); + try { + const first = await rootBinding(root, control, 500); + const second = await rootBinding(root, control, 30); + const open = await first.beginFileWriteTransaction([{ effectId: "a", path: writePath("same.txt") }]); + await assert.rejects( + second.beginFileWriteTransaction([{ effectId: "b", path: writePath("same.txt") }]), + /Lease timeout/, + ); + await open.reject("test cleanup"); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const intents = await journal.listIntents(); + assert.equal(intents.length, 1); + assert.equal(intents[0]?.status, "aborted-restored"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +class FailSecondPermitAssertionJournal extends WriteIntentJournal { + #calls = 0; + + override async assertActive(permit: MutationPermit, owner: ExecutionOwner): Promise { + await super.assertActive(permit, owner); + this.#calls++; + if (this.#calls === 2) throw new Error("injected second promotion failure"); + } +} + +class ReleaseFailsAfterDurableUnlockCoordinator extends PersistentLeaseCoordinator { + override async acquire( + requests: readonly LeaseRequest[], + options: LeaseAcquireOptions = {}, + ): Promise { + const lease = await super.acquire(requests, options); + return { + ...lease, + release: async () => { + await lease.release(); + throw new Error("injected post-release cleanup failure"); + }, + }; + } +} + +function resolvedTargets(root: string, owner: ExecutionOwner): ResolvedFileWriteTarget[] { + const capability: ScopedCapability = { + bindingId: "binding", + resourceDomainId: "domain", + providerInstanceId: "root", + logicalWorkspaceId: "project", + logicalPrefix: "", + physicalScopeRoot: root, + access: "read-write", + version: { identityMode: "path-bound", generation: 0, state: "clean" }, + lifetime: { scope: "phase", runId: owner.runId, phaseId: owner.phaseId, attemptId: owner.attemptId }, + }; + return ["a.txt", "b.txt"].map((relativePath, index) => { + const ref = resolvePathRef( + writePath(relativePath), + { + workspaces: new Map([["project", capability]]), + runId: owner.runId, + phaseId: owner.phaseId, + attemptId: owner.attemptId, + }, + { definitions: {}, values: {} }, + ); + if (!ref.ok) throw new Error(ref.error.redactedMessage); + return { effectId: index === 0 ? "a" : "b", ref: ref.value }; + }); +} + +test("resource file transaction: later promotion failure rolls back every earlier file", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + try { + const journal = new FailSecondPermitAssertionJournal({ directory: control, journalEpoch: 1 }); + const tx = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner), + leases: new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }), + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }); + const result = await tx.commit([ + { effectId: "a", content: "A" }, + { effectId: "b", content: "B" }, + ]); + assert.equal(result.ok, false); + assert.equal(fs.existsSync(path.join(root, "a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "b.txt")), false); + assert.equal((await journal.listIntents())[0]?.status, "aborted-restored"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: post-terminal lease cleanup failure never makes commit retryable", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" ")); + try { + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const tx = await prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases: new ReleaseFailsAfterDurableUnlockCoordinator({ directory: control, registryId: "registry" }), + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }); + const result = await tx.commit([{ effectId: "a", content: "A" }]); + assert.equal(result.ok, true); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal((await journal.listIntents())[0]?.status, "committed-content"); + assert.ok(warnings.some((warning) => /lease cleanup deferred/.test(warning))); + } finally { + console.warn = originalWarn; + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup recovery restores a process-crashed partial multi-file mutation", async () => { + const { root, control } = fixture(); + try { + const executionModule = pathToFileURL(path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "../src/resources/execution.ts", + )).href; + const child = spawnSync(process.execPath, [ + "--experimental-strip-types", + "--input-type=module", + "-e", + ` + import * as fs from "node:fs"; + import * as path from "node:path"; + import { createResolveOnlyWorkspaceSession } from ${JSON.stringify(executionModule)}; + const root = ${JSON.stringify(root)}; + const session = await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: ${JSON.stringify(control)} }); + const bound = await session.bindPhase({ invocationRoot: root, runId: "crashed-run", phaseId: "write", argDefinitions: {}, argValues: {} }); + await bound.beginFileWriteTransaction([ + { effectId: "a", path: { workspace: "project", subpath: { literalPath: "out/a.txt" }, intent: "create-file" } }, + { effectId: "b", path: { workspace: "project", subpath: { literalPath: "out/b.txt" }, intent: "create-file" } }, + ]); + fs.mkdirSync(path.join(root, "out"), { recursive: true }); + fs.writeFileSync(path.join(root, "out/a.txt"), "PARTIAL_CRASH"); + process.exit(0); + `, + ], { encoding: "utf8", timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + assert.equal(fs.readFileSync(path.join(root, "out/a.txt"), "utf8"), "PARTIAL_CRASH"); + + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.equal(fs.existsSync(path.join(root, "out/a.txt")), false); + assert.equal(fs.existsSync(path.join(root, "out/b.txt")), false); + assert.equal(fs.existsSync(path.join(root, "out")), false); + const [intent] = await new WriteIntentJournal({ directory: control, journalEpoch: 1 }).listIntents(); + assert.equal(intent?.status, "aborted-restored"); + assert.match(intent?.terminalReason ?? "", /startup recovery restored/); + assert.equal(intent?.commitGeneration, undefined); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/resource-journal.test.ts b/packages/taskflow-core/test/resource-journal.test.ts index a083e818..27cbc53a 100644 --- a/packages/taskflow-core/test/resource-journal.test.ts +++ b/packages/taskflow-core/test/resource-journal.test.ts @@ -256,3 +256,42 @@ test("journal: content commits require exact scopes and trustworthy post-state e fs.rmSync(dir, { recursive: true, force: true }); } }); + +test("journal: restored abort is terminal, generation-neutral, and ledger-backed", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-journal-abort-restored-")); + const root = path.join(dir, "repo"); + fs.mkdirSync(root); + try { + const journal = new WriteIntentJournal({ directory: path.join(dir, "control"), journalEpoch: 1 }); + const prepared = await journal.prepare(input(root, { commitMode: "content-snapshot" })); + await journal.activate([prepared.permit], OWNER); + await assert.rejects( + journal.abortRestored(prepared.intent.intentId, [evidence(root, "repo", true)], "rollback"), + /beforeContentId === afterContentId/, + ); + const restored = [{ + ...evidence(root, "repo"), + afterContentId: "before-repo", + }]; + const aborted = await journal.abortRestored( + prepared.intent.intentId, + restored, + "commit rejected; exact pre-state restored", + ["snapshot-before-repo"], + ); + assert.equal(aborted.status, "aborted-restored"); + assert.equal(aborted.commitGeneration, undefined); + assert.equal(aborted.terminalReason, "commit rejected; exact pre-state restored"); + assert.deepEqual(aborted.restorableSnapshotArtifactIds, ["snapshot-before-repo"]); + assert.equal(await journal.getDomainGeneration("repo"), 0); + assert.equal(await journal.permits.stateOf(prepared.permit.permitId), "settled"); + + const next = await journal.prepare(input(root, { + owner: { ...OWNER, attemptId: "after-restored-abort" }, + commitMode: "content-snapshot", + })); + await journal.markUnknown(next.intent.intentId, "test cleanup"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/verify-effects.test.ts b/packages/taskflow-core/test/verify-effects.test.ts new file mode 100644 index 00000000..42eb9c52 --- /dev/null +++ b/packages/taskflow-core/test/verify-effects.test.ts @@ -0,0 +1,160 @@ +/** + * Built-in effects verification — validateEffectIR wired into verifyTaskflow. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { verifyTaskflow, type VerifiableFlow } from "../src/verify.ts"; +import type { Phase } from "../src/schema.ts"; +import { detectEffectsIssues } from "../src/verifiers/effects-lint.ts"; + +function scriptPhase(id: string, effects: unknown[], overrides?: Partial): Phase { + return { + id, + type: "script", + run: "true", + effects: effects as Phase["effects"], + ...overrides, + }; +} + +function vf(phases: Phase[], extra?: Partial): VerifiableFlow { + return { name: "test", phases, ...extra }; +} + +const writeEffect = (id: string, literalPath: string) => ({ + id, + kind: "fs.write", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath }, intent: "create-file" }, + }, +}); + +test("verify: effects category — mutating path overlap is an error", () => { + const flow = vf([ + scriptPhase("w", [writeEffect("a", "out/a.md"), writeEffect("b", "out/a.md")], { final: true }), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + const issues = r.issues.filter((i) => i.category === "effects"); + assert.ok(issues.some((i) => i.severity === "error" && /overlap/i.test(i.message))); + assert.equal(issues[0]?.phaseId, "w"); + assert.equal(issues[0]?.source, "effects-lint"); +}); + +test("verify: effects category — unknown kind is an error", () => { + const flow = vf([ + scriptPhase( + "w", + [ + { + id: "x", + kind: "net.open", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "x" }, intent: "create-file" }, + }, + }, + ], + { final: true }, + ), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + assert.ok( + r.issues.some( + (i) => i.category === "effects" && i.severity === "error" && /unknown kind/i.test(i.message), + ), + ); +}); + +test("verify: effects category — valid effects leave verify ok", () => { + const flow = vf([ + scriptPhase("w", [writeEffect("w1", "out/report.md")], { + final: true, + }), + ]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, true, JSON.stringify(r.issues)); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("verify: no effects[] — effects detector is a no-op", () => { + const flow = vf([{ id: "a", type: "script", run: "true", final: true }]); + const r = verifyTaskflow(flow); + assert.equal(r.ok, true); + assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); +}); + +test("verify: flow-level effects[] are validated", () => { + const flow = vf([{ id: "a", type: "script", run: "true", final: true }], { + // flow-level effects (optional extension surface) + ...({ + effects: [ + writeEffect("a", "out/x.md"), + writeEffect("b", "out/x.md"), + ], + } as Partial), + }); + const r = verifyTaskflow(flow); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); +}); + +test("detectEffectsIssues: pure helper returns category effects", () => { + const flow = vf([ + scriptPhase("p", [ + { + id: "bad", + kind: "not-a-kind", + target: { kind: "path", path: { workspace: "p", intent: "create-file" } }, + }, + ]), + ]); + const issues = detectEffectsIssues(flow); + assert.ok(issues.length > 0); + assert.ok(issues.every((i) => i.category === "effects")); + assert.ok(issues.some((i) => /unknown kind/i.test(i.message))); +}); + +test("verify: independent source and sink phases do not create a false information-flow edge", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + scriptPhase("public-output", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.issues.some((issue) => /flow to sink/.test(issue.message)), false, JSON.stringify(result.issues)); +}); + +test("verify: dependency-connected source and sink enforce information-flow labels", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + scriptPhase("public-output", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["read-secret"], final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /read-secret\/secret-input.*public-output\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index 0af9190b..1f45c473 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -24,7 +24,7 @@ * - taskflow_peek : inspect a stored run's intermediate phase output * - taskflow_trace : read a run's append-only event trace * - taskflow_replay : re-evaluate a recorded trace under alternate knobs (zero tokens) - * - taskflow_why_stale / taskflow_recompute / taskflow_reconcile_workspace + * - taskflow_why_stale / taskflow_why_effect / taskflow_recompute / taskflow_reconcile_workspace * - taskflow_save / taskflow_search */ @@ -99,6 +99,8 @@ import { readMapOf, declaredReadMapOfDef, formatWhyStale, + whyEffectFromDurableJournal, + formatWhyEffect, recomputeTaskflow, type RecomputeReport, preflightTaskflow, @@ -647,6 +649,23 @@ const TOOLS: McpTool[] = [ required: ["runId"], }, }, + { + name: "taskflow_why_effect", + title: "Explain why a declared effect is authorized", + description: + "Given a runId + effectId (+ optional phaseId): explain authorization and lifecycle from the durable resource intent ledger. Zero tokens and read-only. A declaration without matching principal/capability/intent evidence is unauthorized.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + runId: { type: "string", description: "The run whose flow definition declares the effect." }, + effectId: { type: "string", description: "Effect id as declared on the phase (or phaseId/effectId composite)." }, + phaseId: { type: "string", description: "Optional phase scope when the same effect id appears on multiple phases." }, + json: { type: "boolean", description: "Return the full WhyEffect record as JSON." }, + }, + required: ["runId", "effectId"], + }, + }, { name: "taskflow_recompute", title: "Re-run a stored run's stale frontier (dry-run only)", @@ -1143,6 +1162,26 @@ export function makeToolHandlers( return textContent(formatWhyStale(run.runId, run.flowName, reads, seeds, declared)); }, + taskflow_why_effect: async (args) => { + const runId = String(args.runId ?? ""); + const effectId = String(args.effectId ?? ""); + if (!runId) return textContent("taskflow_why_effect requires `runId`.", true); + if (!effectId) return textContent("taskflow_why_effect requires `effectId`.", true); + const runR = loadRunDiagnosed(cwd, runId); + if (!runR.ok) return textContent(describeLoadFailure(runR, `Run "${runId}"`), true); + const run = runR.value; + const result = await whyEffectFromDurableJournal({ + flow: run.def, + runId: run.runId, + effectId, + phaseId: typeof args.phaseId === "string" ? args.phaseId : undefined, + workspaceRoot: run.cwd, + }); + if (!result.ok) return textContent(result.error, true); + if (args.json === true) return textContent(JSON.stringify(result.why, null, 2)); + return textContent(formatWhyEffect(result.why)); + }, + taskflow_recompute: async (args, context) => { // MCP exposes recompute as DRY-RUN ONLY (never spends tokens). To actually // re-execute, hosts use the Pi adapter's /tf recompute --apply. From 1478510ff063cecbd88000a004b062ca2ac3bb9e Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:49:40 +0800 Subject: [PATCH 2/5] docs: record clean 0.3 candidate evidence --- docs/internal/0.3.0-ga-scoreboard.md | 17 +++++++++-------- docs/internal/0.3.0-release-plan.md | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index cd9f6912..f876d3e2 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,9 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-07 (live Codex Trusted Effects closure) -**Branch:** `feat/0.3.0-trusted-effects` -**Current evidence:** focused effects/resources/FlowIR suite 79/79; full monorepo typecheck PASS; full unit suite 2199/2199 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. +**Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) +**Branch:** `codex/0.3.0-trusted-effects-candidate` +**Code candidate:** `f55abde1` (evidence-only follow-up excluded) +**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All evidence was rerun in the clean candidate worktree. --- @@ -12,15 +13,15 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | focused 79/79 + monorepo typecheck PASS | dirty shared worktree remains | -| L2 contract | **PASS** | full unit suite 2199/2199 + full build PASS | local contract only | +| L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | +| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS | clean-candidate contract only | | L3 browser/electron | **N/A** | — | | | L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | **Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The branch has a local/contract-green resource-controlled Trusted Effects candidate with live Codex and built MCP proof for admitted declared FS-write targets; clean-candidate, remote CI, release, and GA gates remain open.* +**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex and built MCP proof for admitted declared FS-write targets; remote CI, release, and GA gates remain open.* **G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. --- @@ -57,13 +58,13 @@ PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ --experimental-strip-types --test \ 'packages/taskflow-core/test/effects*.test.ts' \ packages/taskflow-core/test/verify-effects.test.ts -# → 79/79 pass (effects/resources/FlowIR focused set) +# → 82/82 pass (effects/resources/FlowIR focused set) pnpm run typecheck # → PASS pnpm test -# → 2199/2199 PASS +# → 2181/2181 PASS pnpm run build # → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index 75674066..d5f8ae2f 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -4,7 +4,8 @@ | Item | Value | |------|--------| -| Branch | `feat/0.3.0-trusted-effects` | +| Branch | `codex/0.3.0-trusted-effects-candidate` | +| Code candidate | `f55abde1` (evidence-only follow-up excluded) | | Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | | Package versions on npm | still **0.2.7** | | Tag | **none** | From 3744c3910fa28d0ef312e372364f5ad68bc1b786 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 7 Aug 2026 17:53:19 +0800 Subject: [PATCH 3/5] docs: record remote candidate CI evidence --- docs/internal/0.3.0-ga-scoreboard.md | 7 ++++--- docs/internal/0.3.0-release-plan.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index f876d3e2..9281578e 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -5,7 +5,7 @@ **Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) **Branch:** `codex/0.3.0-trusted-effects-candidate` **Code candidate:** `f55abde1` (evidence-only follow-up excluded) -**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All evidence was rerun in the clean candidate worktree. +**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All local evidence was rerun in the clean candidate worktree. Draft PR #117 exact-SHA CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`. --- @@ -14,14 +14,14 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| | L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | -| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS | clean-candidate contract only | +| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS; PR #117 exact-SHA CI green | Node 22/24, Linux/macOS/Windows supervisor, packed consumer, website, MCP E2E, CodeQL | | L3 browser/electron | **N/A** | — | | | L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | **Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex and built MCP proof for admitted declared FS-write targets; remote CI, release, and GA gates remain open.* +**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex, built MCP, and remote exact-SHA CI proof for admitted declared FS-write targets; release and GA gates remain open.* **G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. --- @@ -48,6 +48,7 @@ | Resource convergence | single resources authority; focused + full contract PASS | | Information flow | transitive DAG labels wired through validate/verify/compile/runtime; false-edge regression covered | | Real host | live Codex three-agent run persisted final output through resource transaction; ledger authorization PASS | +| Remote candidate | Draft PR #117; run 31167592775 passed all CI jobs on `1478510f` | --- diff --git a/docs/internal/0.3.0-release-plan.md b/docs/internal/0.3.0-release-plan.md index d5f8ae2f..6710bec9 100644 --- a/docs/internal/0.3.0-release-plan.md +++ b/docs/internal/0.3.0-release-plan.md @@ -7,6 +7,7 @@ | Branch | `codex/0.3.0-trusted-effects-candidate` | | Code candidate | `f55abde1` (evidence-only follow-up excluded) | | Highest proven | **L4** (local + contract + built MCP + live Codex Trusted Effects fixture) | +| Remote candidate | Draft PR #117; exact-SHA CI run 31167592775 green on `1478510f` | | Package versions on npm | still **0.2.7** | | Tag | **none** | @@ -25,7 +26,7 @@ pnpm run test:e2e-codex-mcp-full pnpm run test:e2e-codex ``` -3. First isolate a clean candidate from the current shared dirty worktree; then push the exact candidate SHA and confirm CI green on that SHA. +3. ~~Isolate a clean candidate, push the exact candidate SHA, and confirm CI green.~~ Done on Draft PR #117; the evidence-only follow-up must also finish exact-SHA CI before release authorization. 4. Bump workspace package versions to `0.3.0` (root + publishable packages) via existing release scripts (`RELEASE.md`). 5. Move CHANGELOG `## [0.3.0] — Unreleased` → dated release section. 6. Tag `v0.3.0` and publish **only with explicit human authority**. From 601269543859eaab39672c17a1fc1b33555678e2 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 8 Aug 2026 01:29:18 +0800 Subject: [PATCH 4/5] fix(effects): close composition and transaction gaps --- .../taskflow-core/src/effects/validate.ts | 183 ++++++++++- packages/taskflow-core/src/effects/why.ts | 33 +- packages/taskflow-core/src/flowir/compile.ts | 10 +- .../taskflow-core/src/flowir/translate.ts | 10 +- .../taskflow-core/src/resources/execution.ts | 2 + .../src/resources/file-transaction.ts | 103 ++++++- packages/taskflow-core/src/runtime.ts | 77 +++-- packages/taskflow-core/src/schema.ts | 7 +- .../src/verifiers/effects-lint.ts | 30 +- packages/taskflow-core/src/verify.ts | 2 +- .../test/effects-agent-te.test.ts | 89 ++++++ .../test/effects-composition-cache.test.ts | 284 ++++++++++++++++++ .../test/effects-trusted.test.ts | 135 +++++++++ .../test/resource-file-transaction.test.ts | 127 +++++++- .../taskflow-core/test/verify-effects.test.ts | 103 ++++++- 15 files changed, 1072 insertions(+), 123 deletions(-) create mode 100644 packages/taskflow-core/test/effects-composition-cache.test.ts diff --git a/packages/taskflow-core/src/effects/validate.ts b/packages/taskflow-core/src/effects/validate.ts index 400eda05..29f83408 100644 --- a/packages/taskflow-core/src/effects/validate.ts +++ b/packages/taskflow-core/src/effects/validate.ts @@ -71,6 +71,11 @@ function labelFlowIssues( sinkLabel = sink.id, ): EffectValidationIssue[] { const issues: EffectValidationIssue[] = []; + // Two unresolved composition boundaries carry taint but do not create a + // concrete source→sink edge by themselves. Keep the taint flowing until it + // meets a declared effect; otherwise ordinary dynamic composition would + // reject solely because two unknown summaries are sequenced. + if (source.__unknownBoundary === true && sink.__unknownBoundary === true) return issues; const sourceConf = confidentialityOf(source); const sinkConf = confidentialityOf(sink); const sourceIntegrity = integrityOf(source); @@ -361,6 +366,83 @@ export interface EffectFlowPhaseLike { effects?: unknown; dependsOn?: unknown; from?: unknown; + type?: unknown; + def?: unknown; + use?: unknown; + final?: unknown; +} + +export interface ComposedEffectFlowLike { + name?: unknown; + phases?: readonly EffectFlowPhaseLike[]; +} + +interface LabeledEffect { + label: string; + effect: ValidEffectRecord; +} + +interface PhaseEffectSummary { + sources: LabeledEffect[]; + sinks: LabeledEffect[]; +} + +export interface ComposedEffectFlowOptions { + resolveFlow?: (name: string) => ComposedEffectFlowLike | undefined; +} + +function directPhaseSummary(phase: EffectFlowPhaseLike, phaseLabel: string): PhaseEffectSummary { + const sources: LabeledEffect[] = []; + const sinks: LabeledEffect[] = []; + for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { + if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; + const effect = raw as ValidEffectRecord; + const labeled = { label: `${phaseLabel}/${effect.id}`, effect }; + if (SOURCE_KINDS.has(effect.kind)) sources.push(labeled); + if (SINK_KINDS.has(effect.kind)) sinks.push(labeled); + } + return { sources, sinks }; +} + +function unknownBoundarySummary(phaseLabel: string): PhaseEffectSummary { + return { + sources: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "secret.read", + confidentiality: "secret", + integrity: "untrusted", + __unknownBoundary: true, + target: { kind: "secret", secret: { secretId: "" } }, + }, + }], + sinks: [{ + label: `${phaseLabel}/`, + effect: { + id: "", + kind: "service.call", + confidentiality: "public", + integrity: "verified", + __unknownBoundary: true, + target: { kind: "service", service: { serviceId: "" } }, + }, + }], + }; +} + +function parseInlineFlow(raw: unknown): ComposedEffectFlowLike | undefined { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return undefined; + } + } + if (Array.isArray(value)) return { phases: value as EffectFlowPhaseLike[] }; + if (!isObject(value) || !Array.isArray(value.phases)) return undefined; + return { name: value.name, phases: value.phases as EffectFlowPhaseLike[] }; } /** @@ -375,20 +457,18 @@ export function validateEffectFlow( ...(Array.isArray(phase.dependsOn) ? phase.dependsOn.filter((id): id is string => typeof id === "string") : []), ...(Array.isArray(phase.from) ? phase.from.filter((id): id is string => typeof id === "string") : []), ], + phaseSummaries?: ReadonlyMap, ): EffectValidationResult { const issues: EffectValidationIssue[] = []; const byId = new Map(); const ownSources = new Map>(); + const sinks = new Map(); for (const phase of phases) { if (typeof phase.id !== "string" || !phase.id) continue; byId.set(phase.id, phase); - const sources = new Map(); - for (const effect of Array.isArray(phase.effects) ? phase.effects : []) { - if (!isObject(effect) || typeof effect.id !== "string" || !isEffectKind(effect.kind)) continue; - const valid = effect as ValidEffectRecord; - if (SOURCE_KINDS.has(valid.kind)) sources.set(`${phase.id}/${valid.id}`, valid); - } - ownSources.set(phase.id, sources); + const summary = phaseSummaries?.get(phase.id) ?? directPhaseSummary(phase, phase.id); + ownSources.set(phase.id, new Map(summary.sources.map(({ label, effect }) => [label, effect]))); + sinks.set(phase.id, summary.sinks); } const reachable = new Map([...ownSources].map(([id, sources]) => [id, new Map(sources)])); for (let pass = 0; pass < byId.size; pass++) { @@ -406,22 +486,97 @@ export function validateEffectFlow( } if (!changed) break; } - for (const [phaseId, phase] of byId) { + for (const phaseId of byId.keys()) { + const ownSourceIds = new Set(ownSources.get(phaseId)?.keys() ?? []); const upstreamSources = [...(reachable.get(phaseId) ?? [])] - .filter(([sourceId]) => !sourceId.startsWith(`${phaseId}/`)); + .filter(([sourceId]) => !ownSourceIds.has(sourceId)); if (upstreamSources.length === 0) continue; - for (const raw of Array.isArray(phase.effects) ? phase.effects : []) { - if (!isObject(raw) || typeof raw.id !== "string" || !isEffectKind(raw.kind)) continue; - const sink = raw as ValidEffectRecord; - if (!SINK_KINDS.has(sink.kind)) continue; + for (const { label: sinkLabel, effect: sink } of sinks.get(phaseId) ?? []) { for (const [sourceId, source] of upstreamSources) { - issues.push(...labelFlowIssues(source, sink, sourceId, `${phaseId}/${sink.id}`)); + issues.push(...labelFlowIssues(source, sink, sourceId, sinkLabel)); } } } return { ok: !issues.some((issue) => issue.severity === "error"), issues }; } +interface ComposedSummaryResult extends EffectValidationResult { + summary: PhaseEffectSummary; +} + +function summarizeComposedEffectFlow( + flow: ComposedEffectFlowLike, + options: ComposedEffectFlowOptions, + prefix: string, + seenUses: ReadonlySet, +): ComposedSummaryResult { + const phases = Array.isArray(flow.phases) ? flow.phases : []; + const issues: EffectValidationIssue[] = []; + const summaries = new Map(); + for (const phase of phases) { + if (typeof phase.id !== "string" || !phase.id) continue; + const phaseLabel = `${prefix}${phase.id}`; + if (prefix && phase.effects !== undefined) { + issues.push(...validateEffectIR({ effects: phase.effects }).issues.map((issue) => ({ + ...issue, + effectId: issue.effectId ? `${phaseLabel}/${issue.effectId}` : phaseLabel, + message: issue.effectId + ? issue.message.replace(`'${issue.effectId}'`, `'${phaseLabel}/${issue.effectId}'`) + : `${phaseLabel}: ${issue.message}`, + }))); + } + const summary = directPhaseSummary(phase, phaseLabel); + const type = phase.type ?? "agent"; + if (type === "flow" || type === "expand") { + let child: ComposedEffectFlowLike | undefined; + let childSeen = seenUses; + if (phase.def !== undefined) { + child = parseInlineFlow(phase.def); + } else if (type === "flow" && typeof phase.use === "string" && phase.use) { + if (!seenUses.has(phase.use)) { + try { + child = options.resolveFlow?.(phase.use); + } catch { + child = undefined; + } + childSeen = new Set([...seenUses, phase.use]); + } + } + const childResult = child + ? summarizeComposedEffectFlow(child, options, `${phaseLabel}/`, childSeen) + : { ok: true, issues: [], summary: unknownBoundarySummary(phaseLabel) }; + issues.push(...childResult.issues); + summary.sources.push(...childResult.summary.sources); + summary.sinks.push(...childResult.summary.sinks); + } + summaries.set(phase.id, summary); + } + const flowResult = validateEffectFlow(phases, undefined, summaries); + issues.push(...flowResult.issues); + return { + ok: !issues.some((issue) => issue.severity === "error"), + issues, + summary: { + sources: [...summaries.values()].flatMap((summary) => summary.sources), + sinks: [...summaries.values()].flatMap((summary) => summary.sinks), + }, + }; +} + +/** + * Validate label flow across nested flow/expand boundaries. Child sources are + * summarized onto the parent node and child sinks receive the parent's + * dependency-reachable sources. Unresolved saved/dynamic children expose a + * maximally confidential source and public sink until runtime resolution. + */ +export function validateComposedEffectFlow( + flow: ComposedEffectFlowLike, + options: ComposedEffectFlowOptions = {}, +): EffectValidationResult { + const result = summarizeComposedEffectFlow(flow, options, "", new Set()); + return { ok: result.ok, issues: result.issues }; +} + /** Type guard for a well-formed SecretRef (no material). */ export function isSecretRef(v: unknown): v is SecretRef { return isObject(v) && typeof v.secretId === "string" && v.secretId.length > 0 && !("value" in v) && !("material" in v); diff --git a/packages/taskflow-core/src/effects/why.ts b/packages/taskflow-core/src/effects/why.ts index 23ba3e8f..7b7c16e5 100644 --- a/packages/taskflow-core/src/effects/why.ts +++ b/packages/taskflow-core/src/effects/why.ts @@ -12,7 +12,7 @@ import type { WhyEffect, } from "./types.ts"; import { EFFECT_KINDS } from "./types.ts"; -import { pathRefRelativeKey, validateEffectIR } from "./validate.ts"; +import { pathRefRelativeKey, validateEffectFlow, validateEffectIR } from "./validate.ts"; import { defaultWorkspaceControlDirectory } from "../resources/execution.ts"; import { WriteIntentJournal, type WriteIntentRecord } from "../resources/journal.ts"; @@ -114,8 +114,7 @@ export function whyEffect(input: WhyInput): WhyEffect { /** Minimal flow shape for effect lookup (Taskflow / FlowIR phases). */ export interface WhyEffectFlowLike { - effects?: unknown; - phases?: ReadonlyArray<{ id?: string; effects?: unknown } | null | undefined>; + phases?: ReadonlyArray<{ id?: string; effects?: unknown; dependsOn?: unknown; from?: unknown } | null | undefined>; } export interface WhyEffectFromFlowInput { @@ -160,23 +159,9 @@ function asEffectDecl(raw: unknown): EffectDecl | undefined { return raw as unknown as EffectDecl; } -/** Collect declared effects from flow-level + each phase (original ids preserved). */ +/** Collect declared effects from each phase (original ids preserved). */ export function collectDeclaredEffects(flow: WhyEffectFlowLike): LocatedEffect[] { const out: LocatedEffect[] = []; - const flowLevel = flow.effects; - if (Array.isArray(flowLevel)) { - for (const raw of flowLevel) { - const e = asEffectDecl(raw); - if (e) out.push({ effect: e, bagId: e.id }); - else if (isObject(raw) && typeof raw.id === "string") { - // Keep a stub so validation can still surface shape errors via bag. - out.push({ - effect: raw as unknown as EffectDecl, - bagId: raw.id, - }); - } - } - } const phases = Array.isArray(flow.phases) ? flow.phases : []; for (const p of phases) { if (!p || typeof p !== "object") continue; @@ -262,12 +247,12 @@ export function whyEffectFromFlow(input: WhyEffectFromFlowInput): WhyEffectFromF } const hit = matches[0]!; - const bagEffects = located.map((l) => ({ - ...l.effect, - id: l.bagId, - })); - const validation = validateEffectIR({ effects: bagEffects }); - const effectIssues = validation.issues.filter( + const hitPhase = input.flow.phases?.find((phase) => phase?.id === hit.phaseId); + const localValidation = validateEffectIR({ effects: hitPhase?.effects }); + const flowValidation = validateEffectFlow( + (input.flow.phases ?? []).filter((phase): phase is NonNullable => phase !== null && phase !== undefined), + ); + const effectIssues = [...localValidation.issues, ...flowValidation.issues].filter( (i) => i.effectId === hit.bagId || i.effectId === hit.effect.id || diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index 577170ba..1d9964e7 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -13,7 +13,7 @@ * @see ./translate.ts (stub; still used for sidecar field list parity) */ -import { collectRefs, dependenciesOf, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; +import { collectRefs, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; import { cwdArgName } from "../cwd-bridge.ts"; import { normalizeCond } from "./cond.ts"; import type { @@ -31,7 +31,7 @@ import type { TaskflowIRMeta, } from "./meta.ts"; import type { EffectDecl } from "../effects/types.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; // Keep in sync with translate.ts SIDECAR_PHASE_FIELDS (round-trip lossless). const SIDECAR_PHASE_FIELDS = [ @@ -224,7 +224,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR if (phase.join === "all" || phase.join === "any") node.join = phase.join; if (typeof phase.timeout === "number") node.timeout = phase.timeout; const effectsRaw = phase.effects; - if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + if (effectsRaw !== undefined) { const effectValidation = validateEffectIR({ effects: effectsRaw }); for (const issue of effectValidation.issues) { if (issue.severity === "error") { @@ -237,7 +237,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR warnings.push({ phaseId: phase.id, message: issue.message }); } } - if (effectValidation.ok) { + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) { // Only closed, validated EffectIR enters the content-addressed representation. node.effects = effectsRaw as EffectDecl[]; } @@ -253,7 +253,7 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR nodes.push(node); } - const effectFlow = validateEffectFlow(def.phases ?? [], (phase) => dependenciesOf(phase as Phase)); + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases ?? [] }); for (const issue of effectFlow.issues) { const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; if (issue.severity === "error") { diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index 23fba363..3f2a5956 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -14,9 +14,9 @@ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1) */ -import { collectRefs, dependenciesOf, type Phase, type Taskflow } from "../schema.ts"; +import { collectRefs, type Phase, type Taskflow } from "../schema.ts"; import type { EffectDecl } from "../effects/types.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { CompileError, CompileWarning, @@ -163,7 +163,7 @@ export function translateTaskflow(def: Taskflow): { const effectsRaw = phase.effects; let effects: EffectDecl[] | undefined; - if (Array.isArray(effectsRaw) && effectsRaw.length > 0) { + if (effectsRaw !== undefined) { const effectValidation = validateEffectIR({ effects: effectsRaw }); for (const issue of effectValidation.issues) { if (issue.severity === "error") { @@ -172,7 +172,7 @@ export function translateTaskflow(def: Taskflow): { warnings.push({ phaseId: phase.id, message: issue.message }); } } - if (effectValidation.ok) effects = effectsRaw as EffectDecl[]; + if (effectValidation.ok && Array.isArray(effectsRaw) && effectsRaw.length > 0) effects = effectsRaw as EffectDecl[]; } return { @@ -185,7 +185,7 @@ export function translateTaskflow(def: Taskflow): { } satisfies FlowIRNode; }); - const effectFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + const effectFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }); for (const issue of effectFlow.issues) { const phaseId = issue.effectId?.includes("/") ? issue.effectId.split("/")[0] : undefined; if (issue.severity === "error") { diff --git a/packages/taskflow-core/src/resources/execution.ts b/packages/taskflow-core/src/resources/execution.ts index 596f5c86..dbd5f2f3 100644 --- a/packages/taskflow-core/src/resources/execution.ts +++ b/packages/taskflow-core/src/resources/execution.ts @@ -28,6 +28,7 @@ import { PersistentLeaseCoordinator, type LeaseHandle } from "./leases.ts"; import { createHostRootGrant, createRootRegistry, type RootGrant, type RootRegistry } from "./registry.ts"; import { resolvePathRef, type ResolvedPathRef } from "./resolve.ts"; import { + garbageCollectResourceFileTransactions, prepareResourceFileTransaction, recoverResourceFileIntent, type PreparedResourceFileTransaction, @@ -371,6 +372,7 @@ class ResolveOnlyWorkspaceSessionImpl implements ResolveOnlyWorkspaceSession { signal: this.#signal, }), }); + garbageCollectResourceFileTransactions(this.#controlDirectory, await this.#journal.listIntents()); } async bindPhase(input: BindResolveOnlyPhaseInput): Promise { diff --git a/packages/taskflow-core/src/resources/file-transaction.ts b/packages/taskflow-core/src/resources/file-transaction.ts index bdeac541..c4d7eaa7 100644 --- a/packages/taskflow-core/src/resources/file-transaction.ts +++ b/packages/taskflow-core/src/resources/file-transaction.ts @@ -79,6 +79,8 @@ export interface ResourceFileTransactionOptions { authorizationScopeRoot: string; /** Preserve a failed authenticated release for the session's next-admission drain. */ onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + /** Internal fault-injection seam for post-terminal staging cleanup tests. */ + cleanupStaging?: (stagingDirectory: string) => void; } function hash(parts: readonly (string | Buffer)[]): string { @@ -238,6 +240,63 @@ async function releaseBestEffort(lease: LeaseHandle): Promise { return false; } +function removeTransactionDirectoryBestEffort(transactionDirectory: string, label: string): void { + try { + fs.rmSync(transactionDirectory, { recursive: true, force: true }); + } catch (error) { + console.warn( + `[taskflow] resource transaction ${label} deferred for ${path.basename(transactionDirectory)}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } +} + +function transactionIdFromArtifact(artifactId: string): string | undefined { + return /^workspace-snapshot:([0-9a-f-]{36}):[0-9a-f]{64}$/i.exec(artifactId)?.[1]; +} + +const ORPHAN_TRANSACTION_GRACE_MS = 5 * 60_000; + +/** Remove terminal and pre-intent orphan before-images while retaining any + * pending/dirty transaction needed for recovery or explicit reconciliation. */ +export function garbageCollectResourceFileTransactions( + controlDirectory: string, + intents: readonly WriteIntentRecord[], +): void { + const retained = new Set(); + const terminal = new Set(); + for (const intent of intents) { + for (const artifactId of intent.restorableSnapshotArtifactIds ?? []) { + const transactionId = transactionIdFromArtifact(artifactId); + if (!transactionId) continue; + if (intent.status === "pending" || intent.status === "dirty-unknown") retained.add(transactionId); + else terminal.add(transactionId); + } + } + const root = path.join(controlDirectory, "file-transactions"); + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + console.warn(`[taskflow] resource transaction orphan GC deferred: ${error instanceof Error ? error.message : String(error)}`); + return; + } + for (const entry of entries) { + if (retained.has(entry)) continue; + if (!terminal.has(entry)) { + try { + const stat = fs.lstatSync(path.join(root, entry)); + if (stat.mtimeMs > Date.now() - ORPHAN_TRANSACTION_GRACE_MS) continue; + } catch { + // A concurrently removed entry is already collected. Any other inspection + // failure stays fail-safe: the best-effort remove below may still decline. + } + } + removeTransactionDirectoryBestEffort(path.join(root, entry), "orphan GC"); + } +} + function readManifestFile(filePath: string): unknown { const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); @@ -387,7 +446,9 @@ export class PreparedResourceFileTransaction { readonly #journal: WriteIntentJournal; readonly #stagingDirectory: string; readonly #onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + readonly #cleanupStaging: (stagingDirectory: string) => void; #settled = false; + #knownCleanTerminal = false; constructor(input: { snapshots: readonly Snapshot[]; @@ -396,6 +457,7 @@ export class PreparedResourceFileTransaction { journal: WriteIntentJournal; stagingDirectory: string; onDeferredLeaseRelease?: (lease: LeaseHandle) => void; + cleanupStaging?: (stagingDirectory: string) => void; }) { this.intentId = input.mutation.intent.intentId; this.#snapshots = input.snapshots; @@ -404,6 +466,9 @@ export class PreparedResourceFileTransaction { this.#journal = input.journal; this.#stagingDirectory = input.stagingDirectory; this.#onDeferredLeaseRelease = input.onDeferredLeaseRelease; + this.#cleanupStaging = input.cleanupStaging ?? ((stagingDirectory) => { + fs.rmSync(path.join(stagingDirectory, "staged"), { recursive: true, force: true }); + }); } async commit(payloads: readonly FileWritePayload[]): Promise { @@ -451,6 +516,7 @@ export class PreparedResourceFileTransaction { { preCommitGuard: () => assertExactPostState(this.#snapshots, expected) }, ); this.#settled = true; + this.#knownCleanTerminal = true; return { ok: true, intentId: this.intentId, @@ -490,6 +556,7 @@ export class PreparedResourceFileTransaction { this.#snapshots.map((snapshot) => snapshot.artifactId), ); this.#settled = true; + this.#knownCleanTerminal = true; return { ok: false, intentId: this.intentId, code, reason, restored: true }; } catch (restoreError) { const current = await this.#journal.getIntent(this.intentId); @@ -521,7 +588,17 @@ export class PreparedResourceFileTransaction { async #finish(): Promise { try { - fs.rmSync(path.join(this.#stagingDirectory, "staged"), { recursive: true, force: true }); + try { + this.#cleanupStaging(this.#stagingDirectory); + } catch (error) { + console.warn( + `[taskflow] resource transaction staging cleanup deferred for ${this.intentId}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + if (this.#knownCleanTerminal) { + removeTransactionDirectoryBestEffort(this.#stagingDirectory, "snapshot GC"); + } } finally { if (!(await releaseBestEffort(this.#lease))) { this.#onDeferredLeaseRelease?.(this.#lease); @@ -634,16 +711,24 @@ export async function prepareResourceFileTransaction( journal: options.journal, stagingDirectory: transactionDirectory, onDeferredLeaseRelease: options.onDeferredLeaseRelease, + cleanupStaging: options.cleanupStaging, }); } catch (error) { - if (mutation) { - const current = await options.journal.getIntent(mutation.intent.intentId); - if (current?.status === "pending") await options.journal.markUnknown(current.intentId, "file transaction preparation failed"); - } - if (lease && !(await releaseBestEffort(lease))) { - options.onDeferredLeaseRelease?.(lease); - console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + let journalError: unknown; + try { + if (mutation) { + const current = await options.journal.getIntent(mutation.intent.intentId); + if (current?.status === "pending") await options.journal.markUnknown(current.intentId, "file transaction preparation failed"); + } + } catch (cleanupError) { + journalError = cleanupError; + } finally { + if (lease && !(await releaseBestEffort(lease))) { + options.onDeferredLeaseRelease?.(lease); + console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`); + } + if (!mutation) removeTransactionDirectoryBestEffort(transactionDirectory, "pre-intent GC"); } - throw error; + throw journalError ?? error; } } diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 8cac87bf..1683eb49 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -824,19 +824,41 @@ function flowTreeUsesDeclaredEffects( loadFlow: RuntimeDeps["loadFlow"], seenUses = new Set(), ): boolean { - if (def.phases.some((phase) => Array.isArray((phase as { effects?: unknown }).effects) && - ((phase as { effects?: unknown[] }).effects?.length ?? 0) > 0)) return true; - if (!loadFlow) return false; + if (def.contextSharing === true || def.phases.some((phase) => phase.shareContext === true)) return true; + if (def.phases.some((phase) => (phase as { effects?: unknown }).effects !== undefined)) return true; for (const phase of def.phases) { - if ((phase.type ?? "agent") !== "flow" || !phase.use) continue; - if (seenUses.has(phase.use)) continue; - seenUses.add(phase.use); - try { - const child = loadFlow(phase.use); - if (child && flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; - } catch { - // A mutable/unavailable saved flow is authority-bearing until proven otherwise. - return true; + const type = phase.type ?? "agent"; + if ((type === "flow" || type === "expand") && phase.def !== undefined) { + // Inline definitions are resolved before their parent phase cache lookup. + // Statically inspect authored object/JSON forms. An interpolated/malformed + // form is capability-bearing until resolution proves otherwise: otherwise a + // prior parent cache row could skip a newly generated resource transaction. + const parsed = typeof phase.def === "string" ? safeParse(phase.def) : phase.def; + if ( + parsed === undefined && + typeof phase.def === "string" && + !/[{](?:steps[.]|args[.]|previous[.]|item(?:[.}]|\b)|loop[.]|reflexion[}])/.test(phase.def) + ) { + // A malformed authored literal cannot resolve into a child at runtime; + // preserve the established fail-open/cleanup path. Interpolated strings + // remain unknown and therefore authority-bearing until resolved. + continue; + } + const child = normalizeInlineDef(parsed, phase.id); + if (!child) return true; + if (flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; + } + if (type === "flow" && phase.use) { + if (seenUses.has(phase.use)) continue; + seenUses.add(phase.use); + if (!loadFlow) return true; + try { + const child = loadFlow(phase.use); + if (child && flowTreeUsesDeclaredEffects(child, loadFlow, seenUses)) return true; + } catch { + // A mutable/unavailable saved flow is authority-bearing until proven otherwise. + return true; + } } } return false; @@ -1161,11 +1183,20 @@ async function executePhaseImpl( } return ps; }; - const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { - const effects = (phase as { effects?: unknown }).effects; - if (!Array.isArray(effects) || effects.length === 0) { - return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); - } + const executeInnerWithDeclaredEffects = async (innerDeps: RuntimeDeps): Promise => { + const effects = (phase as { effects?: unknown }).effects; + if (effects === undefined || (Array.isArray(effects) && effects.length === 0)) { + return executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts); + } + if (!Array.isArray(effects)) { + return { + id: phase.id, + status: "failed", + error: "trusted-effects admission failed (effects-not-array): effects must be an array", + endedAt: Date.now(), + usage: emptyUsage(), + }; + } const te = await import("./effects/index.ts"); const effectValidation = te.validateDeclaredEffectsBeforeAdmission(effects); if (!effectValidation.ok) { @@ -3039,7 +3070,9 @@ async function executePhaseInner( // change between the root pre-scan and this phase (or return aliases), and // a bridge-bearing child must never be skipped by a cached parent result. const nestedBridgeTree = flowTreeUsesCwdBridge(subDef, deps.loadFlow); - if (nestedBridgeTree) deps._disableCache = true; + const nestedEffectsTree = flowTreeUsesDeclaredEffects(subDef, deps.loadFlow); + const nestedResourceTree = nestedBridgeTree || nestedEffectsTree; + if (nestedResourceTree) deps._disableCache = true; // Plugin-error verifier preflight (no-spend gate) BEFORE cache/resume reuse, // for SAVED-USE children only. Inline-def children are already gated by the // verifyTaskflow in the hasDef branch above (plugin errors fail-close, @@ -3059,7 +3092,7 @@ async function executePhaseInner( return failPhase(phase.id, `flow phase '${phase.id}': sub-flow '${subDef.name}' failed verifier preflight: ${flowPluginErrors.join("; ")}`); } } - const flowCc: PhaseCacheCtx = nestedBridgeTree ? { ...cc, scope: "off" } : cc; + const flowCc: PhaseCacheCtx = nestedResourceTree ? { ...cc, scope: "off" } : cc; // Every sub-flow cache identity includes the resolved definition. A saved // flow's name alone is insufficient: its contents can change without the // parent definition moving. @@ -4360,8 +4393,10 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi const effectsTree = flowTreeUsesDeclaredEffects(def, deps.loadFlow); const resourceTree = bridgeTree || effectsTree; if (effectsTree) { - const { validateEffectFlow } = await import("./effects/validate.ts"); - const labelFlow = validateEffectFlow(def.phases, (phase) => dependenciesOf(phase as Phase)); + const { validateComposedEffectFlow } = await import("./effects/validate.ts"); + const labelFlow = validateComposedEffectFlow({ name: def.name, phases: def.phases }, { + resolveFlow: deps.loadFlow, + }); if (!labelFlow.ok) { return failBeforeExecution( `Taskflow '${def.name}' EffectIR label flow is invalid: ${labelFlow.issues diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index 10c8de92..dd9dfd7c 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -14,7 +14,7 @@ import { Errors as SchemaErrors } from "typebox/value"; import { cwdArgName, hasCwdPlaceholder, normalizeRelativePath } from "./cwd-bridge.ts"; import { WORKSPACE_KEYWORDS } from "./workspace.ts"; import { EffectDeclSchema } from "./effects/schema.ts"; -import { validateEffectFlow } from "./effects/validate.ts"; +import { validateComposedEffectFlow } from "./effects/validate.ts"; // --------------------------------------------------------------------------- // Phase types @@ -1536,10 +1536,7 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va // Cycle detection (Kahn) try { - const labelFlow = validateEffectFlow( - flow.phases as Phase[], - (phase) => dependenciesOf(phase as Phase), - ); + const labelFlow = validateComposedEffectFlow({ name: flow.name, phases: flow.phases as Phase[] }); for (const issue of labelFlow.issues) { const message = `[effects] ${issue.message}`; if (issue.severity === "error") errors.push(message); diff --git a/packages/taskflow-core/src/verifiers/effects-lint.ts b/packages/taskflow-core/src/verifiers/effects-lint.ts index 08266984..86306af0 100644 --- a/packages/taskflow-core/src/verifiers/effects-lint.ts +++ b/packages/taskflow-core/src/verifiers/effects-lint.ts @@ -1,7 +1,7 @@ /** * Built-in effects verifier — static EffectIR checks (0.3 Trusted Effects MVP). * - * Collects phase-level `effects[]` (and optional flow-level effects if present), + * Collects phase-level `effects[]`, * runs validateEffectIR, maps issues to VerificationIssue with category `"effects"`. * * Wired into `verifyTaskflow` as a built-in detector (always on when effects are @@ -10,9 +10,7 @@ */ import type { Phase } from "../schema.ts"; -import { validateEffectFlow, validateEffectIR } from "../effects/validate.ts"; -import { dependenciesOf } from "../schema.ts"; -import type { EffectDecl } from "../effects/types.ts"; +import { validateComposedEffectFlow, validateEffectIR } from "../effects/validate.ts"; import type { TaskflowVerifier, VerifiableFlow, @@ -20,20 +18,8 @@ import type { VerifierIssue, } from "../verify.ts"; -function phaseEffects(p: Phase): EffectDecl[] { - const raw = (p as Phase & { effects?: unknown }).effects; - if (!Array.isArray(raw)) return []; - return raw as EffectDecl[]; -} - -function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { - const raw = (flow as VerifiableFlow & { effects?: unknown }).effects; - if (!Array.isArray(raw)) return []; - return raw as EffectDecl[]; -} - /** - * Collect all declared effects from a flow (flow-level + each phase), prefix + * Collect all declared effects from each phase, prefix * phase effect ids with `phaseId/` for unique bag identity, and run * `validateEffectIR`. Returns VerificationIssues with `category: "effects"`. * @@ -42,19 +28,17 @@ function flowLevelEffects(flow: VerifiableFlow): EffectDecl[] { export function detectEffectsIssues(flow: VerifiableFlow): VerificationIssue[] { const phases = Array.isArray(flow.phases) ? flow.phases : []; const scopedResults: Array<{ phaseId?: string; result: ReturnType }> = []; - const topLevel = flowLevelEffects(flow); - if (topLevel.length > 0) scopedResults.push({ result: validateEffectIR({ effects: topLevel }) }); for (const rawPhase of phases) { if (!rawPhase || typeof rawPhase !== "object") continue; const phase = rawPhase as Phase; - const effects = phaseEffects(phase); - if (effects.length > 0) { + const effects = (phase as Phase & { effects?: unknown }).effects; + if (effects !== undefined) { scopedResults.push({ phaseId: phase.id, result: validateEffectIR({ effects }) }); } } - if (scopedResults.length === 0) return []; - const flowResult = validateEffectFlow(phases as Phase[], (phase) => dependenciesOf(phase as Phase)); + const flowResult = validateComposedEffectFlow({ phases: phases as Phase[] }); + if (scopedResults.length === 0 && flowResult.issues.length === 0) return []; const issues: VerificationIssue[] = []; for (const scoped of scopedResults) { for (const issue of scoped.result.issues) { diff --git a/packages/taskflow-core/src/verify.ts b/packages/taskflow-core/src/verify.ts index 18ec90db..439e75b4 100644 --- a/packages/taskflow-core/src/verify.ts +++ b/packages/taskflow-core/src/verify.ts @@ -640,7 +640,7 @@ export function verifyTaskflow(flow: VerifiableFlow, options?: VerifyOptions): V issues.push(...detectConcurrencyWarnings(safeFlow, succ)); issues.push(...detectGuardContradictions(phases)); issues.push(...detectContractRefMismatches(phases)); - // Trusted Effects (0.3): static EffectIR checks when phase/flow carries effects[] + // Trusted Effects (0.3): static EffectIR checks when a phase carries effects[] issues.push(...detectEffectsIssues(safeFlow)); // Caller-supplied verifiers run last, against an isolated deep-frozen snapshot diff --git a/packages/taskflow-core/test/effects-agent-te.test.ts b/packages/taskflow-core/test/effects-agent-te.test.ts index 9314e18d..bb2c22f4 100644 --- a/packages/taskflow-core/test/effects-agent-te.test.ts +++ b/packages/taskflow-core/test/effects-agent-te.test.ts @@ -272,6 +272,95 @@ test("runtime: cross-phase label violation fails before any phase body", async ( } }); +test("runtime: parent secret cannot flow into a saved child public sink", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-saved-label-")); + let calls = 0; + try { + const child: Taskflow = { + name: "saved-public-child", + phases: [{ + id: "publish", + type: "agent", + agent: "executor", + task: "publish", + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { + kind: "path", + path: { workspace: "project", subpath: { literalPath: "leak.txt" }, intent: "create-file" }, + }, + }], + final: true, + }], + }; + const def: Taskflow = { + name: "saved-parent-label", + phases: [ + { + id: "read-secret", + type: "agent", + agent: "executor", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { id: "child", type: "flow", use: child.name, dependsOn: ["read-secret"], final: true }, + ], + }; + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + loadFlow: (name) => name === child.name ? child : undefined, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "nope\n"); + }, + }); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.finalOutput, /read-secret\/secret-input.*child\/publish\/public-output/); + assert.equal(fs.existsSync(path.join(root, "leak.txt")), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runtime: malformed non-array effects fail before phase execution", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-effects-shape-")); + let calls = 0; + try { + const def = { + name: "te-effects-shape", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "must-not-run", + effects: { id: "erased-write" }, + final: true, + }], + } as unknown as Taskflow; + const res = await executeTaskflow(mkState(def, root), { + cwd: root, + agents: AGENTS, + runTask: async (_c, _a, agentName, task) => { + calls++; + return okResult(agentName, task, "ran\n"); + }, + }); + assert.equal(res.ok, false); + assert.equal(calls, 0); + assert.match(res.state.phases.write?.error ?? "", /effects must be an array|effects-not-array/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("runtime: unbound effect kind fails before the phase body", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-unbound-effect-")); let calls = 0; diff --git a/packages/taskflow-core/test/effects-composition-cache.test.ts b/packages/taskflow-core/test/effects-composition-cache.test.ts new file mode 100644 index 00000000..fcc8fc5e --- /dev/null +++ b/packages/taskflow-core/test/effects-composition-cache.test.ts @@ -0,0 +1,284 @@ +/** Adversarial cache/resume coverage for resource-bearing composed flows. */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { CacheStore } from "../src/cache.ts"; +import { queueSpawn } from "../src/context-store.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "executor", description: "test", systemPrompt: "", source: "user", filePath: "" }, + { name: "planner", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function state(def: Taskflow, cwd: string, runId: string): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function writeEffect(relativePath: string) { + return { + id: "report", + kind: "fs.write" as const, + target: { + kind: "path" as const, + path: { + workspace: "project", + subpath: { literalPath: relativePath }, + intent: "create-file" as const, + }, + }, + }; +} + +function runner(counter: { calls: number }, plannerOutput?: Taskflow): RuntimeDeps["runTask"] { + return async (_cwd, _agents, agentName, task): Promise => { + counter.calls++; + const output = agentName === "planner" && plannerOutput + ? JSON.stringify(plannerOutput) + : `CONTENT:${task}`; + return { + agent: agentName, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), output: 1, turns: 1 }, + stopReason: "end", + }; + }; +} + +test("flow.def: a resource-bearing inline child cannot be skipped by a cross-run parent cache hit", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-flow-cache-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "writer-child", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "inline", + effects: [writeEffect("out/report.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "inline-parent", + phases: [{ + id: "child", + type: "flow", + def: child, + cache: { scope: "cross-run" }, + final: true, + }], + }; + const counter = { calls: 0 }; + const cacheStore = new CacheStore(control); + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + cacheStore, + agents: AGENTS, + runTask: runner(counter), + }; + + const first = await executeTaskflow(state(def, root, "inline-first"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 1); + fs.rmSync(path.join(root, "out/report.txt")); + + const second = await executeTaskflow(state(def, root, "inline-second"), deps); + assert.equal(second.ok, true, second.finalOutput); + assert.equal(counter.calls, 2, "the resource-bearing child must execute again"); + assert.equal(second.state.phases.child?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "out/report.txt"), "utf8"), "CONTENT:inline"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("expand.def: a resource-bearing child cannot be skipped by within-run resume reuse", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-expand-resume-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const def: Taskflow = { + name: "expand-parent", + phases: [{ + id: "grow", + type: "expand", + expandMode: "nested", + def: { + name: "expand-child", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "expand", + effects: [writeEffect("expanded.txt")], + final: true, + }], + }, + final: true, + }], + }; + const counter = { calls: 0 }; + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter), + }; + + const first = await executeTaskflow(state(def, root, "expand-resume"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 1); + fs.rmSync(path.join(root, "expanded.txt")); + + const resumed = await executeTaskflow(first.state, deps); + assert.equal(resumed.ok, true, resumed.finalOutput); + assert.equal(counter.calls, 2, "resume must re-enter the resource-bearing expand child"); + assert.equal(resumed.state.phases.grow?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "expanded.txt"), "utf8"), "CONTENT:expand"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("dynamic flow.def: an unknown child capability permanently binds the parent invocation root", async () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-root-a-")); + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-dynamic-root-b-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "planned-writer", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "dynamic", + effects: [writeEffect("dynamic.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "dynamic-parent", + phases: [ + { id: "plan", type: "agent", agent: "planner", task: "plan", output: "json" }, + { id: "run", type: "flow", def: "{steps.plan.json}", dependsOn: ["plan"], final: true }, + ], + }; + const counter = { calls: 0 }; + const first = await executeTaskflow(state(def, rootA, "dynamic-root"), { + cwd: rootA, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter, child), + }); + assert.equal(first.ok, true, first.finalOutput); + assert.ok(first.state.cwdRootBinding, "the parent must persist the capability root"); + assert.equal(fs.readFileSync(path.join(rootA, "dynamic.txt"), "utf8"), "CONTENT:dynamic"); + + const rebound = await executeTaskflow(first.state, { + cwd: rootB, + workspaceControlDirectory: control, + agents: AGENTS, + runTask: runner(counter, child), + }); + assert.equal(rebound.ok, false); + assert.match(rebound.finalOutput, /invocation root does not match|start a new run/i); + assert.equal(fs.existsSync(path.join(rootB, "dynamic.txt")), false); + } finally { + fs.rmSync(rootA, { recursive: true, force: true }); + fs.rmSync(rootB, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("ctx_spawn: shareContext cannot cache away a resource-bearing dynamic child", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-spawn-cache-")); + const control = fs.mkdtempSync(path.join(os.tmpdir(), "tf-te-control-")); + try { + const child: Taskflow = { + name: "spawned-writer", + phases: [{ + id: "write", + type: "agent", + agent: "executor", + task: "spawned-write", + effects: [writeEffect("spawned.txt")], + final: true, + }], + }; + const def: Taskflow = { + name: "spawn-parent", + phases: [{ + id: "parent", + type: "agent", + agent: "executor", + task: "queue-child", + shareContext: true, + cache: { scope: "cross-run" }, + final: true, + }], + }; + const counter = { calls: 0 }; + const runTask: RuntimeDeps["runTask"] = async (_cwd, _agents, agentName, task, options: RunOptions) => { + counter.calls++; + if (task.includes("queue-child")) { + queueSpawn(options.ctxDir!, options.nodeId!, [{ subflow: child }]); + } + return { + agent: agentName, + task, + exitCode: 0, + output: task.includes("spawned-write") ? "SPAWNED" : "PARENT", + stderr: "", + usage: { ...emptyUsage(), output: 1, turns: 1 }, + stopReason: "end", + }; + }; + const deps: RuntimeDeps = { + cwd: root, + workspaceControlDirectory: control, + cacheStore: new CacheStore(control), + agents: AGENTS, + runTask, + }; + + const first = await executeTaskflow(state(def, root, "spawn-first"), deps); + assert.equal(first.ok, true, first.finalOutput); + assert.equal(counter.calls, 2); + assert.equal(fs.readFileSync(path.join(root, "spawned.txt"), "utf8"), "SPAWNED"); + fs.rmSync(path.join(root, "spawned.txt")); + + const second = await executeTaskflow(state(def, root, "spawn-second"), deps); + assert.equal(second.ok, true, second.finalOutput); + assert.equal(counter.calls, 4, "the parent and resource-bearing spawned child must execute again"); + assert.equal(second.state.phases.parent?.cacheHit, undefined); + assert.equal(fs.readFileSync(path.join(root, "spawned.txt"), "utf8"), "SPAWNED"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-core/test/effects-trusted.test.ts b/packages/taskflow-core/test/effects-trusted.test.ts index 9817a42a..6b6bfc39 100644 --- a/packages/taskflow-core/test/effects-trusted.test.ts +++ b/packages/taskflow-core/test/effects-trusted.test.ts @@ -56,6 +56,21 @@ test("PhaseSchema/validateTaskflow: accepts phase with effects[] (and without assert.equal(ok2.ok, true, JSON.stringify(ok2.errors)); }); +test("TaskflowSchema: effects[] is phase-scoped and rejects a flow-level ghost declaration", async () => { + const { validateTaskflow } = await import("../src/schema.ts"); + const result = validateTaskflow({ + name: "fx-flow-level-rejected", + effects: [{ + id: "ghost", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", intent: "create-file" } }, + }], + phases: [{ id: "w", type: "script", run: "true", final: true }], + }); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => /unknown field 'effects'/.test(error)), JSON.stringify(result.errors)); +}); + test("PhaseSchema/validateTaskflow: rejects non-array effects", async () => { const { validateTaskflow } = await import("../src/schema.ts"); const bad = { @@ -671,6 +686,68 @@ test("compileTaskflowToIR: invalid EffectIR is diagnosed and never content-addre assert.equal(publicResult.usedFallbackHash, true); }); +test("compileTaskflowToFlowIR: non-array effects fail closed instead of hashing as no effects", async () => { + const { compileTaskflowToFlowIR, compileTaskflowToIR, translateTaskflow } = await import("../src/flowir/index.ts"); + const def = { + name: "fx-effects-not-array", + phases: [{ + id: "w", + type: "script", + run: "true", + effects: { id: "erased-write" }, + }], + }; + const compiled = compileTaskflowToFlowIR(def as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => error.code === "effect-effects-not-array"), JSON.stringify(compiled.errors)); + assert.equal(compiled.canonical.nodes[0]?.effects, undefined); + const translated = translateTaskflow(def as never); + assert.ok(translated.errors.some((error) => error.code === "effect-effects-not-array"), JSON.stringify(translated.errors)); + const publicResult = await compileTaskflowToIR(def as never); + assert.equal(publicResult.hash, undefined); +}); + +test("compileTaskflowToFlowIR: nested label-flow violations fail content addressing", async () => { + const { compileTaskflowToFlowIR } = await import("../src/flowir/index.ts"); + const compiled = compileTaskflowToFlowIR({ + name: "fx-compile-composed-label", + phases: [ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "child", + type: "flow", + dependsOn: ["read-secret"], + def: { + name: "public-child", + phases: [{ + id: "publish", + type: "script", + run: "true", + effects: [{ + id: "public-output", + kind: "fs.write", + confidentiality: "public", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + }], + final: true, + }], + }, + final: true, + }, + ], + } as never); + assert.equal(compiled.usedFallbackHash, true); + assert.ok(compiled.errors.some((error) => /read-secret\/secret-input.*child\/publish\/public-output/.test(error.message)), JSON.stringify(compiled.errors)); +}); + test("compileTaskflowToIR: cross-phase label violation is diagnosed and never content-addressed", async () => { const { compileTaskflowToFlowIR, compileTaskflowToIR, translateTaskflow } = await import("../src/flowir/index.ts"); const def = { @@ -869,3 +946,61 @@ test("whyEffectFromFlow: ambiguous id requires phaseId", () => { assert.equal(ok.phaseId, "b"); assert.equal(ok.why.authorized.allowed, true); }); + +test("whyEffectFromFlow: independent phases do not invent an information-flow dependency", () => { + const flow = { + phases: [ + { + id: "read-secret", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + effects: [{ + id: "public-output", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + confidentiality: "public", + }], + }, + ], + }; + const result = whyEffectFromFlow({ flow, runId: "run-independent", phaseId: "publish", effectId: "public-output" }); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.why.authorized.allowed, true, result.why.authorized.reasons.join("; ")); +}); + +test("whyEffectFromFlow: dependency-connected phases agree with DAG label validation", () => { + const flow = { + phases: [ + { + id: "read-secret", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "publish", + dependsOn: ["read-secret"], + effects: [{ + id: "public-output", + kind: "fs.write", + target: { kind: "path", path: { workspace: "project", subpath: { literalPath: "public.txt" }, intent: "create-file" } }, + confidentiality: "public", + }], + }, + ], + }; + const result = whyEffectFromFlow({ flow, runId: "run-dependent", phaseId: "publish", effectId: "public-output" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.why.authorized.allowed, false); + assert.ok(result.why.authorized.reasons.some((reason) => /read-secret\/secret-input.*publish\/public-output/.test(reason))); + } +}); diff --git a/packages/taskflow-core/test/resource-file-transaction.test.ts b/packages/taskflow-core/test/resource-file-transaction.test.ts index 6e2e9bdb..5d7374df 100644 --- a/packages/taskflow-core/test/resource-file-transaction.test.ts +++ b/packages/taskflow-core/test/resource-file-transaction.test.ts @@ -37,6 +37,11 @@ function fixture(): { root: string; control: string } { }; } +function transactionArtifacts(control: string): string[] { + const directory = path.join(control, "file-transactions"); + return fs.existsSync(directory) ? fs.readdirSync(directory) : []; +} + async function rootBinding(root: string, control: string, leaseTimeoutMs = 500): Promise { const session = await createResolveOnlyWorkspaceSession({ invocationRoot: root, @@ -121,7 +126,7 @@ test("resource file transaction: commits content with durable authority evidence }); assert.equal(declarationOnly.ok, true); if (declarationOnly.ok) assert.equal(declarationOnly.why.authorized.allowed, false); - assert.equal(fs.readdirSync(path.join(control, "file-transactions")).length, 1); + assert.deepEqual(transactionArtifacts(control), [], "terminal commit garbage-collects before-images"); } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(control, { recursive: true, force: true }); @@ -149,6 +154,7 @@ test("resource file transaction: direct final-path bypass is restored with a kno assert.equal(intent?.status, "aborted-restored"); assert.match(intent?.terminalReason ?? "", /changed outside the resource transaction/); assert.equal(await journal.getDomainGeneration(intent!.resourceDomainId), 0); + assert.deepEqual(transactionArtifacts(control), [], "terminal abort garbage-collects before-images"); } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(control, { recursive: true, force: true }); @@ -248,6 +254,16 @@ class ReleaseFailsAfterDurableUnlockCoordinator extends PersistentLeaseCoordinat } } +class ActivationAndInspectionFailJournal extends WriteIntentJournal { + override async activate(): Promise { + throw new Error("injected activation failure"); + } + + override async getIntent(): Promise { + throw new Error("injected journal inspection failure"); + } +} + function resolvedTargets(root: string, owner: ExecutionOwner): ResolvedFileWriteTarget[] { const capability: ScopedCapability = { bindingId: "binding", @@ -349,6 +365,84 @@ test("resource file transaction: post-terminal lease cleanup failure never makes } }); +test("resource file transaction: post-commit staged cleanup failure remains durable success", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" ")); + let cleanupCalls = 0; + try { + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + const journal = new WriteIntentJournal({ directory: control, journalEpoch: 1 }); + const options = { + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases, + journal, + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + cleanupStaging: () => { + cleanupCalls++; + throw new Error("injected staged cleanup failure"); + }, + } as Parameters[0] & { cleanupStaging: () => void }; + const tx = await prepareResourceFileTransaction(options); + const result = await tx.commit([{ effectId: "a", content: "A" }]); + assert.equal(result.ok, true); + assert.equal(cleanupCalls, 1); + assert.equal(fs.readFileSync(path.join(root, "a.txt"), "utf8"), "A"); + assert.equal((await journal.listIntents())[0]?.status, "committed-content"); + assert.equal((await leases.list()).length, 0, "cleanup failure must not leak the lease"); + assert.ok(warnings.some((warning) => /staging cleanup deferred/.test(warning))); + } finally { + console.warn = originalWarn; + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: activation plus journal-inspection double fault still releases lease", async () => { + const { root, control } = fixture(); + const owner: ExecutionOwner = { + runId: "run", + phaseId: "phase", + attemptId: "attempt", + unitId: "unit", + ancestry: [], + }; + try { + const leases = new PersistentLeaseCoordinator({ directory: control, registryId: "registry" }); + await assert.rejects( + prepareResourceFileTransaction({ + controlDirectory: control, + resourceDomainId: "domain", + owner, + targets: resolvedTargets(root, owner).slice(0, 1), + leases, + journal: new ActivationAndInspectionFailJournal({ directory: control, journalEpoch: 1 }), + leaseTimeoutMs: 500, + permitTtlMs: 5_000, + authorizationScopeRoot: root, + }), + /injected journal inspection failure/, + ); + assert.equal((await leases.list()).length, 0, "all preparation failures must release the lease"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + test("resource file transaction: startup recovery restores a process-crashed partial multi-file mutation", async () => { const { root, control } = fixture(); try { @@ -387,6 +481,37 @@ test("resource file transaction: startup recovery restores a process-crashed par assert.equal(intent?.status, "aborted-restored"); assert.match(intent?.terminalReason ?? "", /startup recovery restored/); assert.equal(intent?.commitGeneration, undefined); + assert.deepEqual(transactionArtifacts(control), [], "startup recovery garbage-collects restored before-images"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup garbage-collects orphan snapshot directories", async () => { + const { root, control } = fixture(); + try { + const orphan = path.join(control, "file-transactions", "orphan-before-intent"); + fs.mkdirSync(orphan, { recursive: true }); + fs.writeFileSync(path.join(orphan, "before.blob"), "SECRET-BEFORE-IMAGE"); + const stale = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(orphan, stale, stale); + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.deepEqual(transactionArtifacts(control), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(control, { recursive: true, force: true }); + } +}); + +test("resource file transaction: startup GC preserves a fresh pre-intent transaction window", async () => { + const { root, control } = fixture(); + try { + const fresh = path.join(control, "file-transactions", "fresh-before-intent"); + fs.mkdirSync(fresh, { recursive: true }); + fs.writeFileSync(path.join(fresh, "before.blob"), "IN-FLIGHT-BEFORE-IMAGE"); + await createResolveOnlyWorkspaceSession({ invocationRoot: root, controlDirectory: control }); + assert.deepEqual(transactionArtifacts(control), ["fresh-before-intent"]); } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(control, { recursive: true, force: true }); diff --git a/packages/taskflow-core/test/verify-effects.test.ts b/packages/taskflow-core/test/verify-effects.test.ts index 42eb9c52..22e0fb4f 100644 --- a/packages/taskflow-core/test/verify-effects.test.ts +++ b/packages/taskflow-core/test/verify-effects.test.ts @@ -87,21 +87,6 @@ test("verify: no effects[] — effects detector is a no-op", () => { assert.equal(r.issues.filter((i) => i.category === "effects").length, 0); }); -test("verify: flow-level effects[] are validated", () => { - const flow = vf([{ id: "a", type: "script", run: "true", final: true }], { - // flow-level effects (optional extension surface) - ...({ - effects: [ - writeEffect("a", "out/x.md"), - writeEffect("b", "out/x.md"), - ], - } as Partial), - }); - const r = verifyTaskflow(flow); - assert.equal(r.ok, false); - assert.ok(r.issues.some((i) => i.category === "effects" && /overlap/i.test(i.message))); -}); - test("detectEffectsIssues: pure helper returns category effects", () => { const flow = vf([ scriptPhase("p", [ @@ -158,3 +143,91 @@ test("verify: dependency-connected source and sink enforce information-flow labe assert.equal(result.ok, false); assert.ok(result.issues.some((issue) => /read-secret\/secret-input.*public-output\/write/.test(issue.message)), JSON.stringify(result.issues)); }); + +test("verify: parent secret cannot flow into a flow.def child public sink", () => { + const flow = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { + id: "child", + type: "flow", + dependsOn: ["read-secret"], + def: { + name: "public-child", + phases: [scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { final: true })], + }, + final: true, + }, + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /read-secret\/secret-input.*child\/publish\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); + +test("verify: expand child secret source cannot flow into a parent public sink", () => { + const flow = vf([ + { + id: "child", + type: "expand", + def: { + name: "secret-child", + phases: [{ + id: "read", + task: "read", + effects: [{ + id: "secret-output", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + final: true, + }], + }, + }, + scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["child"], final: true }), + ]); + const result = verifyTaskflow(flow); + assert.equal(result.ok, false); + assert.ok(result.issues.some((issue) => /child\/read\/secret-output.*publish\/write/.test(issue.message)), JSON.stringify(result.issues)); +}); + +test("verify: unresolved dynamic definitions stay tainted in both directions", () => { + const intoDynamic = vf([ + { + id: "read-secret", + task: "read", + effects: [{ + id: "secret-input", + kind: "secret.read", + target: { kind: "secret", secret: { secretId: "api-key" } }, + }], + }, + { id: "dynamic", type: "flow", def: "{steps.plan.json}", dependsOn: ["read-secret"], final: true }, + ]); + const intoResult = verifyTaskflow(intoDynamic); + assert.equal(intoResult.ok, false); + assert.ok(intoResult.issues.some((issue) => /read-secret\/secret-input.*dynamic\//.test(issue.message)), JSON.stringify(intoResult.issues)); + + const outOfDynamic = vf([ + { id: "dynamic", type: "expand", def: "{steps.plan.json}" }, + scriptPhase("publish", [{ + ...writeEffect("write", "public.txt"), + confidentiality: "public", + }], { dependsOn: ["dynamic"], final: true }), + ]); + const outResult = verifyTaskflow(outOfDynamic); + assert.equal(outResult.ok, false); + assert.ok(outResult.issues.some((issue) => /dynamic\/.*publish\/write/.test(issue.message)), JSON.stringify(outResult.issues)); +}); From 8778b2f943d3509ed38e4338df5e9ec07f81ae28 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 8 Aug 2026 01:31:29 +0800 Subject: [PATCH 5/5] docs: refresh trusted effects candidate evidence --- CHANGELOG.md | 8 ++++- docs/internal/0.3.0-ga-scoreboard.md | 44 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9b91f2..83ec0200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - resource-controlled FS transaction: durable snapshot → persistent lease → journal intent/permit → stage → Commit or Restore+Reject - declaration-only bridge in `effects/runtime-apply.ts`; no second changeset/gateway authority - ledger-backed `whyAuthorized` / `whyContext` / `whyEffect` -- Optional phase/flow `effects[]`; FlowIR translate/compile/hash include effects +- Optional phase `effects[]`; FlowIR translate/compile/hash include effects - Built-in `detectEffectsIssues` (category `effects`) + `effectsLintVerifier` - Every imperative phase fast path finalizes declared `fs.write` through the resource transaction; event-kernel-enabled runs use the same safe imperative path - Honest host baseline: `conformance/workspace/host-support-baseline.json` @@ -23,6 +23,12 @@ All notable changes to taskflow are documented here. This project follows [Keep - Example: `examples/trusted-effects-write.json` - Tests: `test/effects*.test.ts`, `test/verify-effects.test.ts` +### Fixed + +- Resource-bearing inline/saved/expanded/`ctx_spawn` children can no longer be skipped by parent cache or resume reuse. +- Information-flow labels compose across nested flow boundaries; unresolved dynamic definitions remain tainted, malformed non-array `effects` fail admission/compile, and `why-effect` follows DAG dependencies. +- Durable commit/abort results survive staging/lease cleanup faults, activation double faults release leases, and clean-terminal/aged-orphan before-images are garbage-collected. + ### Notes - SecretRef/ServiceRef have **no** vault/network backends in this cut. diff --git a/docs/internal/0.3.0-ga-scoreboard.md b/docs/internal/0.3.0-ga-scoreboard.md index 9281578e..e6241ab0 100644 --- a/docs/internal/0.3.0-ga-scoreboard.md +++ b/docs/internal/0.3.0-ga-scoreboard.md @@ -2,10 +2,10 @@ > Living ledger. **Do not claim GA** unless L6 is PASS with human tag evidence. -**Last updated:** 2026-08-07 (clean candidate + live Codex Trusted Effects closure) +**Last updated:** 2026-08-08 (post-review correctness hardening; local contract evidence only) **Branch:** `codex/0.3.0-trusted-effects-candidate` -**Code candidate:** `f55abde1` (evidence-only follow-up excluded) -**Current evidence:** focused effects/resources/FlowIR suite 82/82; full monorepo typecheck PASS; full unit suite 2181/2181 PASS; full build PASS; built Codex MCP host E2E 16/16 PASS; live Codex CLI three-phase A→B→C run PASS with read-only final agent, resource-controlled `fs.write`, and ledger readback. All local evidence was rerun in the clean candidate worktree. Draft PR #117 exact-SHA CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`. +**Code candidate:** `60126954` (scoreboard follow-up excluded) +**Current evidence:** focused effects/resources/FlowIR suite 99/99; full monorepo typecheck PASS; full unit suite 2198/2198 PASS; full build PASS. These checks cover the post-review cache/composition, information-flow, malformed-admission, cleanup/lease, and before-image GC fixes. Exact-SHA remote CI and live-host E2E have **not** yet been rerun for `60126954`. Historical evidence remains: Draft PR #117 CI run 31167592775 passed all 10 matrix jobs plus GitHub CodeQL on `1478510f`; the prior candidate also passed built Codex MCP 16/16 and a live Codex CLI A→B→C run. --- @@ -13,16 +13,16 @@ | Level | Status | Evidence | Notes | |-------|--------|----------|-------| -| L1 local | **PASS** | focused 82/82 + monorepo typecheck PASS | clean candidate worktree | -| L2 contract | **PASS** | full unit suite 2181/2181 + full build PASS; PR #117 exact-SHA CI green | Node 22/24, Linux/macOS/Windows supervisor, packed consumer, website, MCP E2E, CodeQL | +| L1 local | **PASS** | `60126954`: focused 99/99 + monorepo typecheck PASS | post-review fixes committed | +| L2 contract | **PASS** | `60126954`: full unit suite 2198/2198 + full build PASS | exact-SHA remote CI not yet evidenced; prior CI was on `1478510f` | | L3 browser/electron | **N/A** | — | | -| L4 real-environment | **PASS (scoped)** | live Codex CLI A→B→C + declared `fs.write` + ledger readback; built MCP E2E 16/16 | guarantee covers admitted declared targets; no FileBroker/sandbox claim | +| L4 real-environment | **NOT_RUN** | no live-host rerun on `60126954` | prior candidate had scoped live Codex + built MCP evidence; it is not exact-current-SHA proof | | L5 released | **FAIL** | no tag/publish | human gate | | L6 ga | **FAIL** | L5 missing | **NOT GA** | -**Highest proven:** **L4 for the live Codex host plus built Codex MCP fixture**. -**User-facing claim allowed:** *The clean candidate has local/contract-green resource-controlled Trusted Effects with live Codex, built MCP, and remote exact-SHA CI proof for admitted declared FS-write targets; release and GA gates remain open.* -**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, gate fast path, cross-run overlap, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. +**Highest proven for the current code candidate:** **L2 contract**. +**User-facing claim allowed:** *The post-review Trusted Effects candidate is local/contract green for admitted declared FS-write targets; current-SHA remote CI, live-host, release, and GA gates remain open.* +**G5 status:** **contract pass for admitted declared targets** — PathRef escape, direct-write restore, crash recovery, multi-file rollback, terminal cleanup failure, activation/journal double-fault lease release, before-image GC, gate fast path, cross-run/dynamic-spawn cache bypass, and event-kernel fallback are adversarially covered. Resolve-only execution does not prevent or restore writes to undeclared paths; that requires a future FileBroker/native sandbox and is not a 0.3 MVP claim. --- @@ -32,12 +32,12 @@ |---|-------------|--------|----------| | 1 | EffectIR | **pass** | closed TypeBox schema; translate/compile diagnostics; invalid IR receives no canonical hash | | 2 | PathRef / SecretRef / ServiceRef | **pass** | typed refs; unsupported secret/service backends fail before body execution | -| 3 | labels | **pass** | phase-local and transitive DAG confidentiality/integrity checks in schema, verifier, compiler, and runtime | -| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + process-crash recovery | +| 3 | labels | **pass** | compositional source/sink summaries cover `flow.def`, saved `use`, `expand`, and unresolved dynamic definitions in schema, verifier, compiler, and runtime | +| 4 | resource transaction | **pass** | durable snapshot/intent/permit + Commit-or-Restore + non-throwing post-terminal cleanup + process-crash recovery/GC | | 5 | single mutation authority | **pass** | legacy changeset/gateway authority removed; effects bridge delegates to resources | | 6 | mutating-path overlap | **pass** | phase-local static overlap + persistent cross-session/run lease admission | | 7 | host matrix | **pass (honest)** | five hosts explicitly resolve-only; FileBroker/secret/service cells explicitly unsupported | -| 8 | why-* | **pass** | live and built-host readback derives principal/capability/intent/generation from resource ledger | +| 8 | why-* | **pass** | DAG-consistent explanation avoids invented independent-phase dependencies; ledger readback derives principal/capability/intent/generation | --- @@ -49,6 +49,11 @@ | Information flow | transitive DAG labels wired through validate/verify/compile/runtime; false-edge regression covered | | Real host | live Codex three-agent run persisted final output through resource transaction; ledger authorization PASS | | Remote candidate | Draft PR #117; run 31167592775 passed all CI jobs on `1478510f` | +| Post-review hardening | composition/cache and dynamic-spawn taint; nested information flow; malformed `effects`; terminal cleanup/lease safety; terminal/orphan before-image GC; local contract PASS on `60126954` | + +## Explicitly deferred non-blocker + +The append-only resource journal still lacks a sharded/compacted projection designed for thousands of intents, and long-lived control-file scale has not been benchmarked. That is a performance/capacity limitation, not a Commit-or-Reject exception in the current bounded MVP. Full before-image bodies are now removed after durable clean terminal states, and aged pre-intent orphans are collected at startup; no high-scale journal claim is made for 0.3. --- @@ -58,21 +63,22 @@ PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development \ --experimental-strip-types --test \ 'packages/taskflow-core/test/effects*.test.ts' \ + packages/taskflow-core/test/resource-file-transaction.test.ts \ + packages/taskflow-core/test/resource-journal.test.ts \ packages/taskflow-core/test/verify-effects.test.ts -# → 82/82 pass (effects/resources/FlowIR focused set) +# → 99/99 pass (effects/resources/FlowIR focused set) pnpm run typecheck # → PASS pnpm test -# → 2181/2181 PASS +# → 2198/2198 PASS pnpm run build # → PASS across core, CharterArc, MCP, hosts, DSL, and five delivery packages -pnpm run test:e2e-codex-mcp-full -# → build PASS + 16/16 built Codex MCP checks, including fs.write + ledger why-effect - -pnpm run test:e2e-codex -# → live Codex A→B→C PASS; read-only final agent + fs.write + ledger why-effect +# NOT RERUN on 60126954: +# pnpm run test:e2e-codex-mcp-full +# pnpm run test:e2e-codex +# Prior-candidate historical evidence: built MCP 16/16 and live Codex A→B→C PASS. ```