diff --git a/README.md b/README.md index a3e5826..900023e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ Nothing in that flow merges, pushes, publishes, tags, or creates a release. ## Codex Plugin -The thin plugin contributes exactly three conversational skills. CEWP Core and the CLI remain authoritative. +The thin plugin contributes exactly three conversational skills, a local stdio MCP bridge, and an optional +review-required subagent evidence hook. Every mutating MCP operation delegates to the same CEWP Core used +by the CLI; plugin surfaces do not become execution owners or bypass gates. From a source checkout: @@ -77,7 +79,14 @@ codex plugin add cewp@cewp-local codex plugin list ``` -Then ask Codex to plan a supervised run, run the current checkpoint, or resume an existing run. The plugin does not gain direct access to the host's private thread, native goal lifecycle, billing data, or persistent UI. +Then ask Codex to plan a supervised run, run the current checkpoint, or resume an existing run. The plugin +can expose `cewp_create`, `cewp_inspect`, `cewp_approve`, `cewp_continue`, `cewp_retry`, `cewp_revise`, +`cewp_verify`, and `cewp_finalize` when the package-provided `cewp-mcp` command is on `PATH`. It does not +gain direct access to the host's private thread, native goal lifecycle, billing data, or persistent UI. + +For a workflow with a validated host binding, `cewp integration controls --json` shows +preventive, post-execution, imported-observation, and unavailable control classes without promoting +audit-only evidence into enforcement. ## What CEWP Records @@ -112,6 +121,8 @@ CEWP still ships ten reusable engineering skills and the earlier Coordinator Mod - [Install Guide](docs/install.md) - [Supervised Workflow](docs/supervised-workflow.md) +- [External Integration Boundary](docs/external-integration-boundary.md) +- [Evidence Receipts](docs/evidence-receipts.md) - [Workflow Runtime](docs/workflow-runtime.md) - [Known Limitations](docs/known-limitations.md) - [Pilot Kit](docs/pilot-kit.md) diff --git a/bin/cewp-mcp.js b/bin/cewp-mcp.js new file mode 100644 index 0000000..a3b0ce8 --- /dev/null +++ b/bin/cewp-mcp.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +"use strict"; + +const { runStdio } = require("../src/mcp/server"); + +runStdio(); diff --git a/bin/cewp.js b/bin/cewp.js index a6b91b2..63325e2 100644 --- a/bin/cewp.js +++ b/bin/cewp.js @@ -19,6 +19,7 @@ const { } = require("../src/run/basic"); const { runFinalize } = require("../src/run/finalize"); const { runCollect } = require("../src/run/collect"); +const { runVerify } = require("../src/run/verify"); const { runDispatchPlan } = require("../src/run/dispatch/plan"); const { runDispatchCheck } = require("../src/run/dispatch/check"); const { runDispatchPrompts } = require("../src/run/dispatch/prompts"); @@ -34,6 +35,7 @@ const { init } = require("../src/skills/install"); const { list, doctor } = require("../src/skills/status"); const { runSupervise } = require("../src/supervise/cli"); const { runWorkflow } = require("../src/workflow/cli"); +const { runIntegration } = require("../src/integration/cli"); const { printHuman: printSupervisedDemo, runSupervisedDemo } = require("../src/demo/supervised"); function runDemo(options) { @@ -76,6 +78,11 @@ async function runCommand(options) { return; } + if (options.subcommand === "verify") { + runVerify(options); + return; + } + if (options.subcommand === "prompts") { runPrompts(options); return; @@ -209,12 +216,17 @@ async function main() { return; } + if (args.command === "integration") { + runIntegration(args); + return; + } + if (args.command === "demo") { runDemo(args); return; } - if (!["init", "list", "doctor", "policy", "run", "supervise", "workflow", "demo"].includes(args.command)) { + if (!["init", "list", "doctor", "policy", "run", "supervise", "workflow", "integration", "demo"].includes(args.command)) { throw new Error(`Unsupported command: ${args.command}`); } } catch (error) { diff --git a/docs/adr/0002-execution-ownership.md b/docs/adr/0002-execution-ownership.md index 3218f0d..84e2bdb 100644 --- a/docs/adr/0002-execution-ownership.md +++ b/docs/adr/0002-execution-ownership.md @@ -42,6 +42,11 @@ Receipts label each control as one of: Native and audit-only runs must not inherit managed claims. A hook or conversation warning may project a decision but never becomes the ownership or enforcement source. +The runtime materializes these classifications as `integration-control-receipt/v1` and exposes the receipt +through `cewp integration controls --json`. Audit-only bindings with preventive entries or +controls assigned to multiple classes are invalid. Imported entries explicitly render as observed, not +enforced, and receipt inspection verifies the artifact still matches its validated host binding. + ## Consequences The runtime needs a deterministic ownership registry and conflict fixtures before the supervised golden path ships. Recovery can safely resume only after worktree, plan, policy, owner, backend, and process state are compatible. App Server may later replace `codex-exec` for a managed checkpoint only through a new capability and migration decision; it cannot run beside it for the same checkpoint. diff --git a/docs/adr/0005-codex-integration-backend.md b/docs/adr/0005-codex-integration-backend.md index b654a4c..dbf01a9 100644 --- a/docs/adr/0005-codex-integration-backend.md +++ b/docs/adr/0005-codex-integration-backend.md @@ -39,6 +39,7 @@ The Phase 11 review used the current Codex manual and a controlled local probe a - Existing `codex-exec` users keep a stable fallback and one backend per managed checkpoint. - Native goals remain useful without CEWP pretending to control or inspect a private host session. - External agent interfaces can use MCP and `operator-json/v1` without CEWP building a competing terminal or desktop UI. +- The shipped MCP transport is local stdio only, fixes repository scope to process `cwd`, and delegates all eight operations directly to CEWP Core. It is a control surface, not an execution owner or reviewer. - Capability or schema drift produces an explicit compatibility warning and returns to generated-goal or explicit intake. - App Server can be reconsidered later without changing CEWP's provider-neutral workflow and evidence schemas. diff --git a/docs/codex-capability-matrix.md b/docs/codex-capability-matrix.md index c6ec9f3..769833a 100644 --- a/docs/codex-capability-matrix.md +++ b/docs/codex-capability-matrix.md @@ -47,10 +47,12 @@ Schema presence does not prove that a plugin can attach to the desktop app's exi | Desktop notifications | host-specific | The host owns documented notification behavior and settings. CEWP has no arbitrary notification category. | | Hook `statusMessage` | supported | Official hook configuration exposes it as transient handler status. | | Hook `systemMessage` | supported | Official hook output exposes it as a UI or event-stream warning. | +| `SubagentStart`/`SubagentStop` evidence | supported, opt-in | The plugin records only documented parent session/turn, agent id/type, permission/model context, and bounded stop summary after separate CEWP approval and host `/hooks` trust. The documented input exposes no subagent thread id, so CEWP preserves it as `unknown`. | +| Hook trust and version drift | supported | `npm run test:integration-hook-evidence` binds the exact bundle, Codex version, CEWP runtime, hook contract, and workflow revision. Drift or malformed input emits a warning, appends no trusted evidence, and leaves Core gates unchanged. | | `PreToolUse` deny output | supported | The deterministic fixture emits the documented `permissionDecision: deny` shape and is covered by `npm run test:hook-output`. | | `PreToolUse` as complete enforcement | unavailable | Official docs exclude or limit richer shell and non-MCP paths. A real CLI 0.137.0 Windows probe executed the requested PowerShell command despite the Bash deny hook. Core policy remains authoritative. | | Hook-based instant turn cancellation | unknown | Stop semantics do not establish instantaneous cancellation of an in-flight model or external process. | -| Local MCP to CEWP Core | unknown | MCP is supported by the host. Phase 11 implements a small Core-backed tool surface while conversation and CLI fallbacks remain required. | +| Local MCP to CEWP Core | supported | `cewp-mcp` implements the documented local stdio JSON-RPC lifecycle and exactly eight Core-backed tools. `npm run test:integration-mcp` proves schema validation, current-directory repository scope, Core state transitions, confirmation gates, business errors, protocol errors, and explicit protocol-version drift fallback without credentials. | ## App Server Boundary @@ -102,7 +104,7 @@ Reasons: - App Server adds useful goal metadata and lifecycle methods, but remains a separately owned experimental process with version drift and unresolved authenticated usage/cancellation behavior. - The spike did not demonstrate enough recovery or accounting advantage to justify shipping two incomplete managed backends. -The native fallback is a bounded generated goal brief plus supported host goal tools or explicit result intake. `audit-only` remains available for evidence supplied by another owner. The local MCP bridge is the next supported integration surface. Hooks and Apps SDK UI remain optional projections; their absence never weakens CEWP Core. See [ADR 0005](adr/0005-codex-integration-backend.md). +The native fallback is a bounded generated goal brief plus supported host goal tools or explicit result intake. `audit-only` remains available for evidence supplied by another owner. The local MCP bridge is the supported headless integration surface. Hooks and Apps SDK UI remain optional projections; their absence never weakens CEWP Core. See [ADR 0005](adr/0005-codex-integration-backend.md) and the [external integration boundary](external-integration-boundary.md). ## Reproduction @@ -115,6 +117,8 @@ npm run probe:codex-app-server npm run test:plugin-lifecycle npm run test:hook-output npm run test:integration-capabilities +npm run test:integration-hook-evidence +npm run test:integration-mcp ``` The nested model probe is intentionally excluded from automated tests because it consumes account usage. Raw account values, credentials, thread ids, and machine-specific paths are not part of this document. diff --git a/docs/evidence-receipts.md b/docs/evidence-receipts.md new file mode 100644 index 0000000..2e36e9e --- /dev/null +++ b/docs/evidence-receipts.md @@ -0,0 +1,106 @@ +# Evidence Receipts + +`cewp workflow receipt ` writes `evidence-receipt.json` and `evidence-receipt.md` under the local +workflow run directory. `--json` wraps the same receipt and paths in `operator-json/v1` for external tools. +Receipt generation does not execute an agent, verification command, or control process. + +`evidence-receipt/v1` is a normalized read model over the approved workflow definition, canonical run +state, checkpoints, validated task results, interventions, events, independent reviews, integration control +receipt, and referenced evidence files. It includes: + +- goal and source-plan identity; +- workflow digest and revision history; +- operating modes, execution owner, and backend; +- tasks, attempts, changed files, scope verdicts, commands, and verification evidence; +- failure and recovery fields, interventions, review decision, and timestamps; +- observed usage where supported and explicit unknown values otherwise; +- the full approved and consumed budget envelope; +- git base/head identities for new runs; +- warnings and a sorted integrity inventory. + +The JSON model is deterministic for unchanged inputs when `generatedAt` is fixed. The CLI timestamp is the +documented variable. Historical runs that predate a field retain an explicit unknown. Runs that are not +finalized, have malformed event/evidence data, or are missing referenced evidence produce a partial receipt +with warnings rather than a complete claim. + +The Markdown view is generated from that same model and includes tasks, checkpoints, approved commands and +verification, revisions, interventions/recovery, budget and protected reserves, usage provenance/estimate, +control classifications, final review, timestamps, and warnings. It is intended to explain a run without +requiring raw logs; the JSON remains the machine-readable contract. + +## Integrity Boundary + +Integrity entries contain byte length and `sha256` for canonical run evidence, the approved definition, +and referenced evidence files. Source identities include workflow/source hashes when available plus git +base and receipt-time head commits. This is `tamper-evident-local-metadata`, not tamper-proof storage: an +attacker who can rewrite both evidence and metadata can replace both. Durable signing or remote attestation +is not implied. + +## Privacy Boundary + +Raw prompts, adapter output, transcripts, and raw log contents are excluded by default. The receipt may +contain repository-relative paths, changed filenames, approved commands, bounded failure/review summaries, +artifact names, goal/source metadata, host goal identity, revisions, events, timestamps, and reviewer text. +Inspect these fields before sharing. + +`cewp workflow export ` writes separate `.redacted.json`, `.redacted.md`, and `.redacted.html` +artifacts. It removes recognized credential assignments, authorization values, provider-token shapes, +private-key blocks, URL credentials, sensitive/absolute/traversal paths, and active-content markup. It never +overwrites or implicitly creates the canonical receipt. The export records `redaction-policy/v1`, its +replacement count/classes, and that canonical local evidence is still required for integrity verification. +Pattern redaction reduces accidental disclosure but is not a proof that arbitrary prose contains no secret; +inspect exported metadata before sending it outside the repository boundary. + +## Event Ledger And Run Health + +New workflow lifecycle records use `event/v1`, with a closed type-to-category vocabulary covering run, +revision, task, checkpoint, dispatch, intervention, verification, usage, estimates, budgets, warnings, +pauses, scope, review, cancellation, and finalization. Existing `workflow-event/v1` records are accepted as +read-only legacy input and normalized in receipts; CEWP does not rewrite historical ledgers implicitly. +Core workflow transitions emit distinct budget approval, checkpoint, dispatch, scope, verification, usage, +allocation consumption, threshold/warning, pause, cancellation, review, and finalization records where the +corresponding action occurs. + +`cewp run verify ` checks canonical state/definition consistency, event syntax and schemas, +required result/checkpoint artifacts, bound-worktree liveness, and every receipt integrity hash. It executes +no agent and no approved verification command. A failed check returns a nonzero exit code while `--json` +still emits the complete `run-verification/v1` diagnostic model. + +## Offline Operator Report + +`cewp workflow report ` writes `operator-report.json` and a standalone `operator-report.html` in +the workflow run directory. Both are derived from the same normalized receipt model. The HTML uses no +JavaScript, remote fonts, external stylesheets, network requests, server, or control process, so it can be +opened directly from disk on Windows or Linux. + +The report separates observed, estimated, budgeted, and unknown values; shows task progress, revisions, +checkpoint verification, interventions and recovery state, protected reserves, preventive versus observed +controls, and final review. Repository metadata is HTML-escaped, and raw prompts/logs remain excluded. + +Task receipts retain the failed checkpoint/classification, blocker, failure history, state history, and the +operator intervention/reason that reopened work. Budget-paused receipts remain partial and separately prove +absolute-ceiling and protected-allocation compliance; a pause is never rendered as completion. + +## Run Comparison + +`cewp workflow compare ` derives `run-comparison/v1` from two receipts. It +compares outcome, bounded duration, execution owner/backend, observed usage categories, estimates and API +cost when valid, attempts, interventions, failures, scope, commands, and verification evidence. Model time, +CEWP overhead, estimate accuracy, billing cost, or usage that was not observed remains `unknown` and is +excluded from numeric deltas. + +A native-owned workflow is labeled as a native-goal baseline only when a validated host binding includes a +native goal reference. Native ownership alone is insufficient, and unavailable native usage is never zero. + +## Usage And Estimate Truth + +Each task-result, review-result, and supported host usage record becomes a separate +`usage-observation/v1`. The receipt retains its normalized category, raw category name, observed/imported/ +unknown availability, source schema, authentication boundary, timestamp, scope, and effective model only +when known. Bounded raw host payloads are not copied into the receipt. Imported observations stay imported, +do not enter observed totals, and never imply billing impact. + +`usage-estimate/v1` records estimator version/method, grouping dimensions, local sample basis, calibration +snapshot, and drift state. With fewer than five comparable runs—or without known model/effort—it remains an +unknown range with unavailable confidence. CEWP does not promote a numeric estimate from fixtures or +non-comparable history. diff --git a/docs/external-integration-boundary.md b/docs/external-integration-boundary.md new file mode 100644 index 0000000..e1645eb --- /dev/null +++ b/docs/external-integration-boundary.md @@ -0,0 +1,51 @@ +# External Integration Boundary + +CEWP is a local workflow, governance, verification, and evidence runtime. A third-party interface may +present CEWP state in its own agent UI, but it must not become the execution owner merely by calling a +CEWP control surface. It remains responsible for its UI, user consent, authentication, transport lifecycle, +and any agent process it starts. + +## Supported Headless Surfaces + +`operator-json/v1` is the stable envelope shape for CLI inspection and control results. External tools may +invoke documented `cewp ... --json` commands, retain the `command`, `generatedAt`, `data`, and `warnings` +fields, and render them without converting warnings or observations into PASS. In particular, +`cewp integration controls --json` keeps preventive, post-execution, imported, and +unavailable controls distinct. + +`cewp-mcp` is the structured local stdio bridge. Configure its current working directory as exactly one +intended repository. It exposes create, inspect, approve, continue, retry, revise, verify, and finalize; the +tools import the same CEWP Core services as the CLI. An MCP client may add its own confirmation UI, but +cannot bypass Core approval, ownership, policy, effort, scope, budget, verification, receipt, or reviewer +gates. The server opens no network listener and provides no host account, billing, or private-session data. +Unsupported MCP protocol versions return an explicit `mcp-protocol-version-drift` compatibility warning +and name `cewp-cli-operator-json` as the safe fallback. + +Hooks and conversation messages are optional projections. Hook evidence is separately trusted and +version-bound; a missing, disabled, stale, or malformed hook never changes Core enforcement. + +## Execution Ownership + +The external UI is not a fourth owner. Each run remains `managed`, `native`, or `audit-only`. A managed +checkpoint retains one backend and one CEWP-owned worktree. Native work remains host-owned. Audit-only +evidence may be imported or checked after execution but never presented as preventive enforcement. +Provider-specific host, goal, thread, turn, and subagent identifiers stay in integration sidecars rather +than provider-neutral workflow schemas. + +## Rich Codex Clients And App Server + +A client that needs rich Codex thread, turn, or goal lifecycle should integrate with the documented Codex +App Server and own that separate process, authentication boundary, thread identifiers, selected working +directory, interruption behavior, and cleanup. That client is separate from the CEWP plugin. Its process +does not attach to the ChatGPT desktop app's existing internal session, inherit private desktop credentials, +or turn App Server schema presence into plugin capability. + +CEWP has not graduated App Server as a managed backend. A request for that ungraduated backend falls back +to the selected `codex-exec` path, which already owns isolated dispatch, artifacts, verification, recovery, +and reviewer gates. External clients can still use CEWP MCP or operator JSON around their own UI without +changing this backend decision. + +CEWP will build no custom terminal-session protocol, terminal server, desktop shell, private Codex +protocol adapter, UI scraper, or undocumented desktop-session attachment. A richer client should compose +the supported Codex App Server and CEWP's headless surfaces rather than making CEWP a competing terminal +product. diff --git a/docs/install.md b/docs/install.md index ab7dd01..89c0a61 100644 --- a/docs/install.md +++ b/docs/install.md @@ -228,6 +228,16 @@ The harness uses temporary repos, exercises Coordinator Mode runtime helpers, an The supervised demo uses a deterministic fake Codex process in a temporary repository. It does not use credentials or start a real provider. +The npm package also installs the plugin-declared local MCP command: + +```bash +cewp-mcp +``` + +It is a stdio protocol process, not an interactive shell command or network server. Codex starts it from +the repository selected for the task. Third-party MCP clients may configure the same command with the +intended repository as `cwd`; the command must be available on `PATH`. + If Codex does not show installed skills, restart or reload Codex and confirm that each skill has: ```txt diff --git a/docs/known-limitations.md b/docs/known-limitations.md index d17d910..abd8be4 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -10,7 +10,8 @@ CEWP is beta software. These limits are product boundaries, not hidden roadmap p - ChatGPT subscription credit impact and host-internal retries or compaction remain `unknown` without a supported machine-readable contract. - Numeric usage estimates stay unavailable until enough comparable local runs exist. When available, they are ranges with confidence and sample basis, never point promises. - File-level test-authoring enforcement recognizes common test directories and filename conventions. It cannot prove whether production code contains test-like logic. -- The plugin contributes skills only. It does not provide MCP tools, hooks, an Apps SDK card, or an App Server client. +- The plugin contributes skills, a local stdio MCP bridge, and an optional review-required `SubagentStart`/`SubagentStop` evidence hook. The hook cannot expose a subagent thread id, does not read transcripts, and is never a Core enforcement boundary. MCP exposes only CEWP Core operations and does not attach to native host sessions. An Apps SDK card and App Server client are not shipped. +- Audit-only integration can validate imported evidence and record post-execution checks, but it cannot claim that CEWP prevented actions performed by the external owner. Its integration control receipt therefore permits no preventive entries. - Experimental OpenCode execution remains optional and outside the supervised golden path. Binary/version availability does not prove authentication or model readiness. - Manual is a non-executing handoff adapter. Claude, Gemini, Hermes, and other providers are not implemented. - Supervised worktree cleanup automation is not shipped; rollback is available for owned unverified work, and terminal evidence is retained for deliberate inspection/removal. diff --git a/docs/release-notes.md b/docs/release-notes.md index 20e756a..34503fc 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,7 +2,46 @@ ## Unreleased -No changes yet. +### Phase 12 development + +- Added deterministic `evidence-receipt/v1` JSON/Markdown generation for workflow runs with complete-versus-partial truth, task/checkpoint/review evidence, usage unknowns, budget compliance, git identities, and local SHA-256 integrity metadata. +- Added `cewp workflow receipt ` without executing agents or verification commands. Raw prompts, transcripts, adapter output, and raw log contents remain excluded by default. +- Added the closed `event/v1` lifecycle vocabulary with read-only normalization of historical `workflow-event/v1` records. +- Added read-only `cewp run verify ` checks for state, schemas, events, required artifacts, worktree liveness, and receipt integrity. +- Added portable `operator-report/v1` JSON and standalone offline HTML generation from the normalized receipt model. +- Added `run-comparison/v1` and `cewp workflow compare` with explicit unknowns and evidence-backed native-goal baseline labeling. +- Added adversarially tested `redaction-policy/v1` exports that preserve canonical local evidence and avoid absolute-path output. +- Added `usage-observation/v1` provenance/raw-category records and reproducible unknown `usage-estimate/v1` calibration metadata. +- Expanded lifecycle evidence and recovery receipts for failed checkpoints, budget pauses, host limits, cancellations, and audit-only controls. +- Expanded the Markdown receipt so complete and partial runs can be understood without opening raw logs. + +## 0.11.0-beta.0 + +### Summary + +Codex-first native-goal supervision and integration bridge preparation. Phase 11 keeps managed, native, +and audit-only ownership separate, retains `codex-exec`, adds supported headless integration surfaces, +and preserves provider-neutral workflow state. This version is prepared locally and is not published, +tagged, or released; clean Linux validation remains required before the technical release gate can close. + +### Added + +- Enforced cross-mode worktree conflicts so native and managed ownership cannot target the same CEWP task worktree or active checkpoint. +- Added explicit implementation, repair, and reviewer task classes with operator-approved model/effort revisions and no automatic model routing. +- Added opt-in, exact-definition and version-bound `SubagentStart`/`SubagentStop` evidence. Hook absence, drift, malformed input, or host distrust leaves Core gates unchanged. +- Added a local stdio bridge with eight Core-backed MCP tools for create, inspect, approve, continue, retry, revise, verify, and finalize. MCP and CLI call the same services and preserve the same Core gates. +- Added MCP protocol-drift negotiation with a stable compatibility warning and CLI/operator-JSON fallback. +- Added structured host observations that keep observed, imported, stale, malformed, unavailable, and unknown truth states distinct without inventing billing impact. +- Added `integration-control-receipt/v1` and `cewp integration controls` so audit-only evidence cannot be presented as preventive enforcement. +- Added a packaged external-integration boundary for third-party MCP/operator JSON clients and rich Codex clients that own a separate App Server lifecycle. + +### Changed + +- App Server remains ungraduated because no material supported lifecycle, usage, or recovery advantage was proven. An explicit request retains the `codex-exec` fallback. +- Provider-specific host, goal, thread, turn, subagent, and worktree references stay outside provider-neutral workflow schemas. +- Host goal completion, hook completion, and imported evidence never count as CEWP verification or independent reviewer PASS. +- Independent external pilot evidence remains Phase 13 validation debt; fixtures, maintainer dogfood, and multiple machines used by one maintainer do not satisfy it. +- No provider, desktop UI, terminal server, merge, push, publish, tag, or release automation was added. ## 0.10.0-beta.0 diff --git a/docs/skill-plugin-compatibility.md b/docs/skill-plugin-compatibility.md index 1a06e95..a6f9ac6 100644 --- a/docs/skill-plugin-compatibility.md +++ b/docs/skill-plugin-compatibility.md @@ -1,6 +1,6 @@ # Skill And Plugin Compatibility -Observed: 2026-07-16 +Observed: 2026-07-22 CEWP's ten bundled skills use the current Codex skill shape: a required `SKILL.md` with `name` and `description`, plus optional `scripts/`, `references/`, `assets/`, and `agents/`. `agents/openai.yaml` is accepted as optional UI, invocation-policy, and dependency metadata. CEWP no longer treats these official components as forbidden. @@ -23,7 +23,7 @@ The Phase 9 plugin follows the official boundary: - `.codex-plugin/plugin.json` is the required manifest and the only file under `.codex-plugin/`. - `skills/`, `hooks/`, `.mcp.json`, `.app.json`, and `assets/` live at the plugin root and use `./`-prefixed contained paths. -- Installing or enabling a plugin does not trust its bundled hooks. CEWP hooks remain optional until the user reviews and trusts the current definition. +- Installing or enabling a plugin does not trust its bundled hooks. CEWP declares one contained subagent-evidence bundle, requires a run-bound operator approval, and still directs the user to `/hooks` to review and trust the exact current host definition. Bundle, Codex, CEWP runtime, hook-contract, or workflow-revision drift disables trusted evidence and falls back to Core plus conversation output. - A repo marketplace lives at `.agents/plugins/marketplace.json`; npm remains the source of the CEWP Core CLI/runtime. - MCP and Apps SDK components are optional projections. The plugin skeleton and golden path cannot depend on them until their versioned capability tests pass. diff --git a/docs/supervised-workflow.md b/docs/supervised-workflow.md index 10e57ad..c718924 100644 --- a/docs/supervised-workflow.md +++ b/docs/supervised-workflow.md @@ -56,6 +56,26 @@ Changing the goal, scope, checks, or stopping conditions creates a new plan revi ## 3. Execute And Verify +An optional Codex-specific sidecar can assign an explicit task class without changing the +provider-neutral workflow or run schemas: + +```bash +cewp supervise effort \ + --operation implementation \ + --task-class demanding-implementation \ + --model \ + --effort high \ + --yes +``` + +Supported operations are `implementation`, `repair`, and `reviewer`. Supported task classes are +`fast-exploration`, `demanding-implementation`, and `high-effort-independent-review`. A task class +never selects a model or reasoning effort automatically. Every initial selection or change requires +`--yes`, creates a revision with an operator-approval digest, and fails closed when the sidecar's +approved selection digest no longer matches. Model and effort remain `unknown` when omitted. Explicit selections become known +effective evidence only after supported structured turn-completion usage is received; an early CLI, +model, or host rejection leaves them unknown. + Managed execution is an advanced local action: ```bash @@ -136,6 +156,29 @@ Generated files include: Editing `progress.md` does not mutate `run.json`. Managed token categories are observed only from valid structured turn events. Host-internal usage remains unknown when the selected boundary does not expose it. +## Local MCP Bridge + +The npm package installs `cewp-mcp`, a local stdio MCP server. The Codex plugin declares it through +`plugins/cewp/.mcp.json`; it opens no network listener and uses the server process working directory as +the fixed repository root. The eight tools are `cewp_create`, `cewp_inspect`, `cewp_approve`, +`cewp_continue`, `cewp_retry`, `cewp_revise`, `cewp_verify`, and `cewp_finalize`. + +The MCP dispatcher imports the same CEWP Core functions used by `cewp supervise`; it does not invoke a +second workflow implementation. CLI `--yes` gates map to a required `confirm: true` argument for approve, +retry, and finalize. All state, execution ownership, policy, effort, scope, budget, verification, receipt, +and independent-review checks remain in Core. MCP host approval is an additional host safety boundary and +never substitutes for those checks. Business-rule failures return structured tool errors; malformed calls +and unknown tools return JSON-RPC protocol errors. + +During initialization, a supported MCP protocol version is echoed with `compatibility.compatible: true`. +An unsupported version negotiates the current supported version but also returns the stable +`mcp-protocol-version-drift` warning and `cewp-cli-operator-json` fallback; clients should disconnect or +use that fallback rather than assuming newer semantics. + +The plugin MCP entry expects the package-provided `cewp-mcp` binary to be on `PATH`. If a third-party MCP +client cannot discover the plugin manifest, configure a local stdio server with command `cewp-mcp` and set +its working directory to the intended repository. Do not point one server process at multiple repositories. + ## Cleanup `cewp demo supervised` removes its temporary repository and worktree automatically. diff --git a/docs/workflow-runtime.md b/docs/workflow-runtime.md index 1c07524..acaa7f9 100644 --- a/docs/workflow-runtime.md +++ b/docs/workflow-runtime.md @@ -36,6 +36,22 @@ A succeeded `task-result/v1` must include every approved baseline, targeted, and A failed `task-result/v1` is still evidence. CEWP validates its scope, approved verification commands, failure classification and signature, bounded output, and usage truth. It persists the result, accounts observed CEWP-controlled operations, and moves the checkpoint, task, and run to `blocked`. An identical canonical signature is derived as `repeated-failure`; a provider cannot self-declare it. Unknown host-internal work remains unknown and is not fabricated as observed usage. +## Integration Control Claims + +A validated host binding writes `integration/control-receipt.json` beside the provider-specific binding, +outside provider-neutral workflow state. Inspect it with: + +```bash +cewp integration controls --json +``` + +`integration-control-receipt/v1` classifies each named check as `preventive`, `postExecution`, `imported`, +or `unavailable`. One check cannot occupy multiple classes. Audit-only ownership cannot claim any preventive +control: imported evidence is rendered as `observed-not-enforced`, while a CEWP check performed after import +remains post-execution rather than preventive. The receipt is derived from and checked against the validated +host binding, so editing the artifact cannot promote an observation into enforcement. Guardrail authority +remains CEWP Core outside provider-controlled execution. + Recovery is explicit: retry, revise, reassign, a permitted pre-existing-failure waiver, rollback, cancel, or abandon. New regressions, repeated failures, scope gates, destructive-operation policy, and required reviewer PASS are non-waivable. A failed or unverified checkpoint never counts as completed. ## Budgets diff --git a/package.json b/package.json index 811b8db..d777533 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@setrathex/codex-engineering-workflow-pack", - "version": "0.10.0-beta.0", + "version": "0.11.0-beta.0", "description": "Long-running Codex goals without blind runs: local-first supervision, verification, recovery, review, and evidence.", "license": "MIT", "repository": { @@ -13,6 +13,7 @@ }, "bin": { "cewp": "bin/cewp.js", + "cewp-mcp": "bin/cewp-mcp.js", "codex-engineering-workflow-pack": "bin/cewp.js" }, "files": [ @@ -29,6 +30,8 @@ "docs/known-limitations.md", "docs/pilot-kit.md", "docs/codex-capability-matrix.md", + "docs/external-integration-boundary.md", + "docs/evidence-receipts.md", "docs/adr/0001-codex-first-supervised-goals.md", "docs/adr/0002-execution-ownership.md", "docs/adr/0003-cost-assurance-and-safe-pauses.md", @@ -49,8 +52,9 @@ "node": ">=22" }, "scripts": { - "test": "npm run test:contracts && npm run smoke", - "test:contracts": "npm run test:init-install && npm run test:adapter-profile && npm run test:doctor-json && npm run test:operator-json && npm run test:hook-output && npm run test:skill-format && npm run test:ownership-gates && npm run test:fixtures && npm run test:plugin-package && npm run test:supervised-intake && npm run test:supervised-proposal && npm run test:supervised-controls && npm run test:supervised-execution && npm run test:supervised-review && npm run test:supervised-failure && npm run test:supervised-linear-resume && npm run test:supervised-demo && npm run test:workflow-definition && npm run test:workflow-compiler && npm run test:workflow-proposal && npm run test:workflow-scheduler && npm run test:workflow-worker-matrix && npm run test:workflow-result && npm run test:workflow-failure-result && npm run test:workflow-state-machine && npm run test:workflow-interventions && npm run test:workflow-failure-matrix && npm run test:workflow-budget && npm run test:workflow-progress && npm run test:workflow-review && npm run test:workflow-checkpoint-review && npm run test:workflow-lifecycle && npm run test:workflow-revision && npm run test:workflow-migration && npm run test:workflow-templates && npm run test:workflow-docs && npm run test:workflow-release && npm run test:integration-capabilities && npm run test:integration-binding && npm run test:integration-observation && npm run test:native-goal-events", + "test": "npm run test:contracts && npm run test:phase12 && npm run smoke", + "test:phase12": "npm run test:evidence-receipt && npm run test:event-run-verify && npm run test:operator-report && npm run test:run-comparison && npm run test:evidence-redaction", + "test:contracts": "npm run test:init-install && npm run test:adapter-profile && npm run test:doctor-json && npm run test:operator-json && npm run test:hook-output && npm run test:skill-format && npm run test:ownership-gates && npm run test:fixtures && npm run test:plugin-package && npm run test:supervised-intake && npm run test:supervised-proposal && npm run test:supervised-controls && npm run test:supervised-execution && npm run test:supervised-review && npm run test:supervised-failure && npm run test:supervised-linear-resume && npm run test:supervised-demo && npm run test:workflow-definition && npm run test:workflow-compiler && npm run test:workflow-proposal && npm run test:workflow-scheduler && npm run test:workflow-worker-matrix && npm run test:workflow-result && npm run test:workflow-failure-result && npm run test:workflow-state-machine && npm run test:workflow-interventions && npm run test:workflow-failure-matrix && npm run test:workflow-budget && npm run test:workflow-progress && npm run test:workflow-review && npm run test:workflow-checkpoint-review && npm run test:workflow-lifecycle && npm run test:workflow-revision && npm run test:workflow-migration && npm run test:workflow-templates && npm run test:workflow-docs && npm run test:workflow-release && npm run test:integration-capabilities && npm run test:integration-binding && npm run test:integration-effort-policy && npm run test:integration-hook-evidence && npm run test:integration-mcp && npm run test:integration-observation && npm run test:native-goal-events", "test:init-install": "node tests/contracts/init-install.js", "test:adapter-profile": "node tests/contracts/adapter-profile.js", "test:doctor-json": "node tests/contracts/doctor-json.js", @@ -90,6 +94,14 @@ "test:workflow-release": "node tests/contracts/workflow-release.js", "test:integration-capabilities": "node tests/contracts/integration-capabilities.js", "test:integration-binding": "node tests/contracts/integration-binding.js", + "test:integration-effort-policy": "node tests/contracts/integration-effort-policy.js", + "test:integration-hook-evidence": "node tests/contracts/integration-hook-evidence.js", + "test:integration-mcp": "node tests/contracts/integration-mcp.js", + "test:evidence-receipt": "node tests/contracts/evidence-receipt.js", + "test:event-run-verify": "node tests/contracts/event-run-verify.js", + "test:operator-report": "node tests/contracts/operator-report.js", + "test:run-comparison": "node tests/contracts/run-comparison.js", + "test:evidence-redaction": "node tests/contracts/evidence-redaction.js", "test:integration-observation": "node tests/contracts/integration-observation.js", "test:native-goal-events": "node tests/contracts/native-goal-events.js", "demo:supervised": "node src/demo/supervised.js", @@ -98,9 +110,11 @@ "probe:codex-app-server": "node tests/capabilities/codex-app-server.js", "smoke": "node tests/harness/run-smoke.js", "check:cli": "node ./bin/cewp.js --help", - "check:syntax": "node --check ./bin/cewp.js && node --check ./src/demo/fake-codex.js && node --check ./src/demo/supervised.js && node --check ./src/integration/capabilities.js && node --check ./src/integration/binding.js && node --check ./src/integration/observation.js && node --check ./src/integration/native-goal.js && node --check ./src/skills/install.js && node --check ./src/skills/format.js && node --check ./src/skills/doctor-report.js && node --check ./src/run/ownership.js && node --check ./src/run/control-gates.js && node --check ./src/supervise/budget.js && node --check ./src/supervise/cli.js && node --check ./src/supervise/commands.js && node --check ./src/supervise/controls.js && node --check ./src/supervise/execution.js && node --check ./src/supervise/profiles.js && node --check ./src/supervise/receipt.js && node --check ./src/supervise/review.js && node --check ./src/supervise/state.js && node --check ./src/supervise/test-authoring.js && node --check ./src/supervise/verification.js && node --check ./src/workflow/cli.js && node --check ./src/workflow/definition.js && node --check ./src/workflow/graph.js && node --check ./src/workflow/result.js && node --check ./src/workflow/scheduler.js && node --check ./src/workflow/source.js && node --check ./src/workflow/state.js && node --check ./src/workflow/transitions.js && node --check ./tests/contracts/init-install.js && node --check ./tests/contracts/adapter-profile.js && node --check ./tests/contracts/doctor-json.js && node --check ./tests/contracts/operator-json.js && node --check ./tests/contracts/hook-output.js && node --check ./tests/contracts/skill-format.js && node --check ./tests/contracts/ownership-gates.js && node --check ./tests/contracts/deterministic-fixtures.js && node --check ./tests/contracts/plugin-package.js && node --check ./tests/contracts/supervised-intake.js && node --check ./tests/contracts/supervised-proposal.js && node --check ./tests/contracts/supervised-controls.js && node --check ./tests/contracts/supervised-execution.js && node --check ./tests/contracts/supervised-review.js && node --check ./tests/contracts/supervised-failure.js && node --check ./tests/contracts/supervised-linear-resume.js && node --check ./tests/contracts/supervised-demo.js && node --check ./tests/contracts/integration-capabilities.js && node --check ./tests/contracts/integration-binding.js && node --check ./tests/contracts/integration-observation.js && node --check ./tests/contracts/native-goal-events.js && node --check ./tests/contracts/workflow-definition.js && node --check ./tests/contracts/workflow-proposal.js && node --check ./tests/contracts/workflow-scheduler.js && node --check ./tests/contracts/workflow-result.js && node --check ./tests/contracts/workflow-state-machine.js && node --check ./tests/contracts/workflow-interventions.js && node --check ./tests/capabilities/clean-install.js && node --check ./tests/capabilities/plugin-lifecycle.js && node --check ./tests/capabilities/codex-app-server.js && node --check ./tests/capabilities/fixtures/deny-shell-hook.js", + "check:syntax": "node --check ./bin/cewp.js && node --check ./src/demo/fake-codex.js && node --check ./src/demo/supervised.js && node --check ./src/integration/capabilities.js && node --check ./src/integration/binding.js && node --check ./src/integration/cli.js && node --check ./src/integration/effort-policy.js && node --check ./src/integration/hook-evidence.js && node --check ./src/integration/observation.js && node --check ./src/integration/native-goal.js && node --check ./src/skills/install.js && node --check ./src/skills/format.js && node --check ./src/skills/doctor-report.js && node --check ./src/run/ownership.js && node --check ./src/run/control-gates.js && node --check ./src/supervise/budget.js && node --check ./src/supervise/cli.js && node --check ./src/supervise/commands.js && node --check ./src/supervise/controls.js && node --check ./src/supervise/execution.js && node --check ./src/supervise/profiles.js && node --check ./src/supervise/receipt.js && node --check ./src/supervise/review.js && node --check ./src/supervise/state.js && node --check ./src/supervise/test-authoring.js && node --check ./src/supervise/verification.js && node --check ./src/workflow/cli.js && node --check ./src/workflow/definition.js && node --check ./src/workflow/graph.js && node --check ./src/workflow/result.js && node --check ./src/workflow/scheduler.js && node --check ./src/workflow/source.js && node --check ./src/workflow/state.js && node --check ./src/workflow/transitions.js && node --check ./plugins/cewp/hooks/capture-subagent.js && node --check ./tests/contracts/init-install.js && node --check ./tests/contracts/adapter-profile.js && node --check ./tests/contracts/doctor-json.js && node --check ./tests/contracts/operator-json.js && node --check ./tests/contracts/hook-output.js && node --check ./tests/contracts/skill-format.js && node --check ./tests/contracts/ownership-gates.js && node --check ./tests/contracts/deterministic-fixtures.js && node --check ./tests/contracts/plugin-package.js && node --check ./tests/contracts/supervised-intake.js && node --check ./tests/contracts/supervised-proposal.js && node --check ./tests/contracts/supervised-controls.js && node --check ./tests/contracts/supervised-execution.js && node --check ./tests/contracts/supervised-review.js && node --check ./tests/contracts/supervised-failure.js && node --check ./tests/contracts/supervised-linear-resume.js && node --check ./tests/contracts/supervised-demo.js && node --check ./tests/contracts/integration-capabilities.js && node --check ./tests/contracts/integration-binding.js && node --check ./tests/contracts/integration-effort-policy.js && node --check ./tests/contracts/integration-hook-evidence.js && node --check ./tests/contracts/integration-observation.js && node --check ./tests/contracts/native-goal-events.js && node --check ./tests/contracts/workflow-definition.js && node --check ./tests/contracts/workflow-proposal.js && node --check ./tests/contracts/workflow-scheduler.js && node --check ./tests/contracts/workflow-result.js && node --check ./tests/contracts/workflow-state-machine.js && node --check ./tests/contracts/workflow-interventions.js && node --check ./tests/capabilities/clean-install.js && node --check ./tests/capabilities/plugin-lifecycle.js && node --check ./tests/capabilities/codex-app-server.js && node --check ./tests/capabilities/fixtures/deny-shell-hook.js", "check:workflow-syntax": "node --check ./src/workflow/budget.js && node --check ./src/workflow/compiler.js && node --check ./src/workflow/migration.js && node --check ./src/workflow/progress.js && node --check ./src/workflow/proposal.js && node --check ./src/workflow/review.js && node --check ./src/workflow/revision.js && node --check ./src/workflow/templates.js && node --check ./tests/contracts/workflow-budget.js && node --check ./tests/contracts/workflow-checkpoint-review.js && node --check ./tests/contracts/workflow-compiler.js && node --check ./tests/contracts/workflow-docs.js && node --check ./tests/contracts/workflow-failure-matrix.js && node --check ./tests/contracts/workflow-failure-result.js && node --check ./tests/contracts/workflow-migration.js && node --check ./tests/contracts/workflow-progress.js && node --check ./tests/contracts/workflow-release.js && node --check ./tests/contracts/workflow-review.js && node --check ./tests/contracts/workflow-lifecycle.js && node --check ./tests/contracts/workflow-revision.js && node --check ./tests/contracts/workflow-templates.js && node --check ./tests/contracts/workflow-worker-matrix.js", - "check": "npm run check:syntax && npm run check:workflow-syntax && npm test", + "check:mcp": "node --check ./bin/cewp-mcp.js && node --check ./src/mcp/server.js && node --check ./src/mcp/tools.js && node --check ./tests/contracts/integration-mcp.js", + "check:evidence": "node --check ./src/evidence/receipt.js && node --check ./src/evidence/events.js && node --check ./src/evidence/report.js && node --check ./src/evidence/compare.js && node --check ./src/evidence/redaction.js && node --check ./src/evidence/usage.js && node --check ./src/evidence/verify.js && node --check ./src/run/verify.js && node --check ./tests/contracts/evidence-receipt.js && node --check ./tests/contracts/event-run-verify.js && node --check ./tests/contracts/operator-report.js && node --check ./tests/contracts/run-comparison.js && node --check ./tests/contracts/evidence-redaction.js", + "check": "npm run check:syntax && npm run check:workflow-syntax && npm run check:mcp && npm run check:evidence && npm test", "pack:dry-run": "npm pack --dry-run" }, "keywords": [ diff --git a/plugins/cewp/.codex-plugin/plugin.json b/plugins/cewp/.codex-plugin/plugin.json index b04e299..2c21b5c 100644 --- a/plugins/cewp/.codex-plugin/plugin.json +++ b/plugins/cewp/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cewp", - "version": "0.10.0-beta.0", + "version": "0.11.0-beta.0", "description": "Plan, run, recover, and review bounded engineering checkpoints through the local CEWP runtime.", "author": { "name": "SetraTheXX", @@ -16,6 +16,8 @@ "evidence" ], "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "CEWP", "shortDescription": "Long-running goals without blind runs", diff --git a/plugins/cewp/.mcp.json b/plugins/cewp/.mcp.json new file mode 100644 index 0000000..7aa9e5b --- /dev/null +++ b/plugins/cewp/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "cewp": { + "command": "cewp-mcp", + "args": [], + "env": {} + } + } +} diff --git a/plugins/cewp/README.md b/plugins/cewp/README.md index 5fba658..d6fcf4a 100644 --- a/plugins/cewp/README.md +++ b/plugins/cewp/README.md @@ -8,7 +8,27 @@ It ships exactly three entry skills: - `run-supervised-checkpoint`: execute one controlled model operation and follow Core gates. - `resume-supervised-run`: inspect or recover canonical state without silently restarting work. -The plugin does not attach to the ChatGPT desktop app's private thread, automate native goals, inject persistent UI, expose hidden host usage, execute the optional OpenCode adapter, or add another provider. Phase 9 uses one selected pair: `managed` owner with the `codex-exec` backend. +The plugin does not attach to the ChatGPT desktop app's private thread, automate native goals, inject persistent UI, expose hidden host usage, execute the optional OpenCode adapter, or add another provider. The managed path uses one selected pair: `managed` owner with the `codex-exec` backend. + +## Local MCP Tools + +The plugin declares one local stdio server backed by the npm package's `cewp-mcp` command. It exposes +create, inspect, approve, continue, retry, revise, verify, and finalize as structured tools. The server fixes +repository scope to its working directory and imports the same CEWP Core services as the CLI. MCP host +consent never replaces Core approval, ownership, policy, effort, scope, budget, verification, receipt, or +independent-review gates. + +## Optional Subagent Evidence + +The plugin declares one `SubagentStart`/`SubagentStop` hook bundle. Installation or enablement does not trust it. For a selected workflow run, first inspect the bundle and activate the CEWP-side binding: + +```bash +cewp integration hooks approve --yes --json +``` + +Then open `/hooks` in Codex, review the exact current definition, and decide whether to trust it. `cewp integration hooks status --json` reports bundle, Codex, CEWP runtime, hook-contract, and workflow-revision drift. A changed or malformed source produces a warning and no trusted evidence. + +The hook records bounded parent session/turn references, the documented subagent id/type, and the stop summary in a provider-specific sidecar. The documented hook input does not expose a subagent thread id, so that field remains `unknown`. The handler never reads transcript files, never blocks or continues a subagent, and never opens a policy, verification, review, or finalization gate. Install from the CEWP source marketplace: @@ -17,4 +37,4 @@ codex plugin marketplace add /path/to/Codex-Engineering-Workflow-Pack codex plugin add cewp@cewp-local ``` -The npm package supplies the `cewp` runtime used by these skills. Run `cewp doctor --json` before the first supervised checkpoint. +The npm package supplies the `cewp` runtime used by the skills and optional evidence handler. Run `cewp doctor --json` before the first supervised checkpoint. diff --git a/plugins/cewp/hooks/capture-subagent.js b/plugins/cewp/hooks/capture-subagent.js new file mode 100644 index 0000000..7d0b9ed --- /dev/null +++ b/plugins/cewp/hooks/capture-subagent.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +"use strict"; + +const childProcess = require("node:child_process"); + +process.stdin.setEncoding("utf8"); +let input = ""; +let oversized = false; +process.stdin.on("data", (chunk) => { + input += chunk; + if (Buffer.byteLength(input, "utf8") > 64 * 1024) oversized = true; +}); +process.stdin.on("end", () => { + try { + if (oversized) throw new Error("hook input exceeds 65536 bytes"); + JSON.parse(input || "{}"); + const command = process.env.CEWP_HOOK_CLI_COMMAND || (process.platform === "win32" ? "cewp.cmd" : "cewp"); + const prefixArgs = process.env.CEWP_HOOK_CLI_PREFIX_ARGS + ? JSON.parse(process.env.CEWP_HOOK_CLI_PREFIX_ARGS) + : []; + if (!Array.isArray(prefixArgs) || prefixArgs.some((value) => typeof value !== "string")) { + throw new Error("invalid CEWP hook CLI prefix configuration"); + } + const result = childProcess.spawnSync(command, [ + ...prefixArgs, + "integration", "hooks", "ingest", + ], { + cwd: process.cwd(), + input, + encoding: "utf8", + windowsHide: true, + timeout: 15000, + shell: process.platform === "win32" && !process.env.CEWP_HOOK_CLI_COMMAND, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const reason = String(result.stderr || "CEWP hook ingestion failed") + .replace(/\s+/g, " ") + .trim() + .slice(0, 1000); + throw new Error(reason); + } + process.stdout.write("{}\n"); + } catch (error) { + process.stdout.write(`${JSON.stringify({ + systemMessage: `CEWP hook evidence unavailable: ${error.message} Core gates remain unchanged.`, + })}\n`); + } +}); diff --git a/plugins/cewp/hooks/hooks.json b/plugins/cewp/hooks/hooks.json new file mode 100644 index 0000000..ef858e0 --- /dev/null +++ b/plugins/cewp/hooks/hooks.json @@ -0,0 +1,31 @@ +{ + "description": "Optional CEWP subagent lifecycle evidence. CEWP Core gates remain authoritative.", + "hooks": { + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/hooks/capture-subagent.js\"", + "command_windows": "node \"%PLUGIN_ROOT%\\hooks\\capture-subagent.js\"", + "statusMessage": "Recording optional CEWP subagent evidence" + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/hooks/capture-subagent.js\"", + "command_windows": "node \"%PLUGIN_ROOT%\\hooks\\capture-subagent.js\"", + "statusMessage": "Recording optional CEWP subagent summary" + } + ] + } + ] + } +} diff --git a/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md b/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md index 717e208..4350cc0 100644 --- a/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md +++ b/plugins/cewp/skills/run-supervised-checkpoint/SKILL.md @@ -11,9 +11,9 @@ Use canonical JSON from `cewp supervise`; generated Markdown is a view, not muta 2. Perform no more than one CEWP-controlled model operation per invocation. Local verification commands do not count as model operations, but their approved limits still apply. 3. Follow only the transition matching canonical state: - `proposed`: show the plan and run `cewp supervise approve --yes --json` only after explicit approval. Stop before dispatch. - - `approved/ready`: run `cewp supervise execute --yes --json` only after explicit execution approval. If dispatch reaches `verifying`, run `cewp supervise verify --json`; then stop at the verified, repair, paused, or blocked result. - - `needs-repair/repair-ready`: explain the failure signature and remaining repair allocation. Run `cewp supervise retry --yes --json` only when the user explicitly chooses retry, then run local verification and stop. - - `checkpoint-complete/verified`: offer final review or a safe pause for one manually bounded next checkpoint. For final review, record `cewp supervise continue --json`, run `cewp supervise review --yes --json`, and stop at the reviewer decision. For more work, hand off to the resume workflow without dispatching another model operation. + - `approved/ready`: if the operator explicitly requested a Codex task class, model, or effort, record it first with `cewp supervise effort --operation implementation --task-class [--model ] [--effort ] --yes --json`. Never infer model or effort from the class. Then run `cewp supervise execute --yes --json` only after explicit execution approval. If dispatch reaches `verifying`, run `cewp supervise verify --json`; then stop at the verified, repair, paused, or blocked result. + - `needs-repair/repair-ready`: explain the failure signature and remaining repair allocation. Record any explicitly requested repair-specific Codex selection through `cewp supervise effort ... --operation repair ... --yes --json`; do not reuse or change implementation settings implicitly. Run `cewp supervise retry --yes --json` only when the user explicitly chooses retry, then run local verification and stop. + - `checkpoint-complete/verified`: offer final review or a safe pause for one manually bounded next checkpoint. For final review, record any explicitly requested independent-review Codex selection through `cewp supervise effort ... --operation reviewer ... --yes --json`, record `cewp supervise continue --json`, run `cewp supervise review --yes --json`, and stop at the reviewer decision. For more work, hand off to the resume workflow without dispatching another model operation. - `review-passed`: run `cewp supervise receipt --json` to preview the receipt. Do not finalize in the same step unless the user already gave explicit finalize intent after seeing equivalent receipt facts. - `ready-to-finalize`: run `cewp supervise finalize --yes --json` only after explicit finalization approval. - any `paused-*` or `blocked`: do not dispatch. Present Core recovery actions and hand off to the resume workflow. diff --git a/src/cli/parse.js b/src/cli/parse.js index 15b3c04..64a73fc 100644 --- a/src/cli/parse.js +++ b/src/cli/parse.js @@ -74,6 +74,11 @@ function parseArgs(argv) { workerId: undefined, templateName: undefined, compilerDigest: undefined, + operation: undefined, + taskClass: undefined, + model: undefined, + effort: undefined, + comparisonRunId: undefined, }; if (argv[0] === "--help" || argv[0] === "-h") { @@ -93,7 +98,7 @@ function parseArgs(argv) { return args; } - const optionStart = ["run", "supervise", "workflow", "demo"].includes(args.command) ? 2 : 1; + const optionStart = ["run", "supervise", "workflow", "integration", "demo"].includes(args.command) ? 2 : 1; for (let index = optionStart; index < argv.length; index += 1) { const arg = argv[index]; @@ -103,7 +108,7 @@ function parseArgs(argv) { continue; } - if (args.command === "run" && ["status", "next", "resume"].includes(args.subcommand) && index === 2 && !arg.startsWith("--")) { + if (args.command === "run" && ["status", "next", "resume", "verify"].includes(args.subcommand) && index === 2 && !arg.startsWith("--")) { args.runId = arg; continue; } @@ -128,6 +133,21 @@ function parseArgs(argv) { continue; } + if (args.command === "integration" && args.subcommand === "hooks" && index === 2) { + args.action = arg; + continue; + } + + if (args.command === "integration" && args.subcommand === "hooks" && index === 3 && !arg.startsWith("--")) { + args.workflowRunId = arg; + continue; + } + + if (args.command === "integration" && args.subcommand === "controls" && index === 2 && !arg.startsWith("--")) { + args.workflowRunId = arg; + continue; + } + if (args.command === "workflow" && args.subcommand === "validate" && index === 2 && !arg.startsWith("--")) { args.definitionFile = arg; continue; @@ -138,7 +158,17 @@ function parseArgs(argv) { continue; } - if (args.command === "workflow" && ["status", "start", "result", "review", "finalize", "intervene", "revise", "apply-revision", "migrate"].includes(args.subcommand) && index === 2 && !arg.startsWith("--")) { + if (args.command === "workflow" && args.subcommand === "compare" && index === 2 && !arg.startsWith("--")) { + args.workflowRunId = arg; + continue; + } + + if (args.command === "workflow" && args.subcommand === "compare" && index === 3 && !arg.startsWith("--")) { + args.comparisonRunId = arg; + continue; + } + + if (args.command === "workflow" && ["status", "start", "result", "review", "finalize", "receipt", "report", "export", "intervene", "revise", "apply-revision", "migrate"].includes(args.subcommand) && index === 2 && !arg.startsWith("--")) { args.workflowRunId = arg; continue; } @@ -203,7 +233,7 @@ function parseArgs(argv) { if ( args.command === "supervise" && [ - "approve", "status", "execute", "verify", "retry", "review", "receipt", "finalize", + "approve", "status", "execute", "verify", "retry", "review", "receipt", "finalize", "effort", "revise", "pause", "resume", "add-budget", "rollback", "cancel", "abandon", "block", "continue", "reassign", ].includes(args.subcommand) && index === 2 @@ -213,6 +243,38 @@ function parseArgs(argv) { continue; } + if (args.command === "supervise" && arg === "--operation") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--operation requires an effort-policy operation."); + args.operation = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--task-class") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--task-class requires a Codex task class."); + args.taskClass = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--model") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--model requires an explicit Codex model."); + args.model = value; + index += 1; + continue; + } + + if (args.command === "supervise" && arg === "--effort") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--effort requires a supported Codex reasoning effort."); + args.effort = value; + index += 1; + continue; + } + if ( ( args.command === "supervise" @@ -410,7 +472,7 @@ function parseArgs(argv) { continue; } - if (["doctor", "run", "supervise", "workflow", "demo"].includes(args.command) && arg === "--json") { + if (["doctor", "run", "supervise", "workflow", "integration", "demo"].includes(args.command) && arg === "--json") { args.json = true; continue; } @@ -425,6 +487,12 @@ function parseArgs(argv) { continue; } + + if (args.command === "integration" && arg === "--yes") { + args.yes = true; + continue; + } + if (args.command === "supervise" && arg === "--allow-test-authoring") { args.allowTestAuthoring = true; continue; diff --git a/src/cli/usage.js b/src/cli/usage.js index b804c0c..8b3eaf6 100644 --- a/src/cli/usage.js +++ b/src/cli/usage.js @@ -17,6 +17,13 @@ Usage: cewp workflow result --task --result --yes [--json] cewp workflow review --result --yes [--json] cewp workflow finalize --yes [--json] + cewp workflow receipt [--json] + cewp workflow report [--json] + cewp workflow compare [--json] + cewp workflow export [--json] + cewp integration hooks approve --yes [--json] + cewp integration hooks status [--json] + cewp integration controls [--json] cewp workflow revise --proposal [--from ] [--json] cewp workflow apply-revision --proposal --digest --yes [--json] cewp workflow migrate [--digest --yes] [--json] @@ -31,6 +38,7 @@ Usage: cewp supervise plan --proposal [--from ] [--source-kind ] [--json] cewp supervise approve [run-id] [--allow-test-authoring] --yes [--json] cewp supervise status [run-id] [--json] + cewp supervise effort [run-id] --operation --task-class [--model ] [--effort ] --yes [--json] cewp supervise execute [run-id] --yes [--timeout ] [--json] cewp supervise verify [run-id] [--timeout ] [--json] cewp supervise retry [run-id] --yes [--timeout ] [--json] @@ -52,6 +60,7 @@ Usage: cewp run status [run-id] [--json] cewp run next [run-id] [--json] cewp run resume [run-id] [--json] + cewp run verify [--json] cewp run prompts cewp run prompt cewp run worktrees plan diff --git a/src/evidence/compare.js b/src/evidence/compare.js new file mode 100644 index 0000000..fac2963 --- /dev/null +++ b/src/evidence/compare.js @@ -0,0 +1,128 @@ +"use strict"; + +const RUN_COMPARISON_SCHEMA_VERSION = "run-comparison/v1"; + +function observed(value, source = "cewp-run-state") { + return { label: "observed", value, source }; +} + +function unknown(reason) { + return { label: "unknown", value: null, reason }; +} + +function duration(receipt) { + const start = Date.parse(receipt.timestamps.createdAt); + const end = Date.parse(receipt.timestamps.finalizedAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return unknown("run is not finalized with a bounded duration"); + return observed(end - start, "cewp-run-timestamps"); +} + +function commandCount(receipt) { + return receipt.commands.reduce((total, task) => ( + total + + (task.verification.baseline ? 1 : 0) + + task.verification.targeted.length + + task.verification.full.length + ), 0); +} + +function failureSummary(receipt) { + const failures = receipt.tasks.flatMap((task) => task.failure ? [{ taskId: task.id, ...task.failure }] : []); + return { count: failures.length, entries: failures }; +} + +function scopeSummary(receipt) { + return { + passed: receipt.tasks.filter((task) => task.scopeVerdict.status === "passed").length, + unknown: receipt.tasks.filter((task) => task.scopeVerdict.status === "unknown").length, + changedFiles: [...new Set(receipt.tasks.flatMap((task) => task.changedFiles))].sort(), + }; +} + +function verificationSummary(receipt) { + const evidence = receipt.tasks.flatMap((task) => [ + ...(task.verification.baseline.evidence || []), + ...task.verification.targeted, + ...task.verification.full, + ]); + return { + commandCount: commandCount(receipt), + passedEvidence: evidence.filter((entry) => entry.status === "passed").length, + failedEvidence: evidence.filter((entry) => entry.status === "failed").length, + evidence, + }; +} + +function summarizeRun(receipt) { + const nativeGoal = receipt.integration && receipt.integration.nativeGoal; + return { + runId: receipt.runId, + execution: receipt.execution, + nativeGoalBaseline: Boolean(nativeGoal && nativeGoal.status === "known"), + nativeGoalEvidence: nativeGoal || unknown("no supported native goal binding"), + }; +} + +function delta(left, right) { + if (left.label !== "observed" || right.label !== "observed") return unknown("both comparison values must be observed"); + return observed(right.value - left.value, "derived-from-observed-values"); +} + +function compareEvidenceReceipts(left, right) { + if (!left || left.schemaVersion !== "evidence-receipt/v1" || !right || right.schemaVersion !== "evidence-receipt/v1") { + throw new Error("Run comparison requires two evidence-receipt/v1 values."); + } + const leftDuration = duration(left); + const rightDuration = duration(right); + const leftAttempts = observed(left.tasks.reduce((total, task) => total + task.attempts, 0)); + const rightAttempts = observed(right.tasks.reduce((total, task) => total + task.attempts, 0)); + const leftInterventions = observed(left.interventions.length); + const rightInterventions = observed(right.interventions.length); + const leftFailures = failureSummary(left); + const rightFailures = failureSummary(right); + const unavailable = []; + if (leftDuration.label === "unknown" || rightDuration.label === "unknown") unavailable.push("duration"); + unavailable.push("model-time", "estimate-accuracy", "cewp-overhead"); + for (const category of ["managedOperations", "capturedOutputBytes", "managedTokens", "hostInternal"]) { + if (left.usage[category].label === "unknown" || right.usage[category].label === "unknown") unavailable.push(`usage.${category}`); + } + if (left.cost.apiEquivalent.label === "unknown" || right.cost.apiEquivalent.label === "unknown") unavailable.push("api-equivalent-cost"); + return { + schemaVersion: RUN_COMPARISON_SCHEMA_VERSION, + generatedAt: left.generatedAt === right.generatedAt ? left.generatedAt : null, + runs: { left: summarizeRun(left), right: summarizeRun(right) }, + dimensions: { + outcome: { + left: { completeness: left.completeness.status, runStatus: left.completeness.runStatus, reviewerDecision: left.reviewer.decision }, + right: { completeness: right.completeness.status, runStatus: right.completeness.runStatus, reviewerDecision: right.reviewer.decision }, + }, + duration: { left: leftDuration, right: rightDuration, delta: delta(leftDuration, rightDuration) }, + modelTime: { left: unknown("effective model time is unavailable"), right: unknown("effective model time is unavailable") }, + usage: { + managedOperations: { left: left.usage.managedOperations, right: right.usage.managedOperations }, + capturedOutputBytes: { left: left.usage.capturedOutputBytes, right: right.usage.capturedOutputBytes }, + managedTokens: { left: left.usage.managedTokens, right: right.usage.managedTokens }, + hostInternal: { left: left.usage.hostInternal, right: right.usage.hostInternal }, + }, + estimateAccuracy: { left: unknown("no calibrated observed outcome mapping"), right: unknown("no calibrated observed outcome mapping") }, + apiEquivalentCost: { left: left.cost.apiEquivalent, right: right.cost.apiEquivalent }, + cewpOverhead: { left: unknown("CEWP overhead was not separately observed"), right: unknown("CEWP overhead was not separately observed") }, + attempts: { left: leftAttempts, right: rightAttempts, delta: delta(leftAttempts, rightAttempts) }, + interventions: { left: leftInterventions, right: rightInterventions, delta: delta(leftInterventions, rightInterventions) }, + failures: { left: leftFailures, right: rightFailures, delta: observed(rightFailures.count - leftFailures.count, "derived-from-run-state") }, + scope: { left: scopeSummary(left), right: scopeSummary(right) }, + commands: { left: left.commands, right: right.commands }, + verification: { left: verificationSummary(left), right: verificationSummary(right) }, + }, + equivalence: { + status: unavailable.length === 0 ? "complete" : "partial", + unavailable: [...new Set(unavailable)].sort(), + warning: unavailable.length > 0 ? "Unavailable values remain unknown and are excluded from numeric deltas." : null, + }, + }; +} + +module.exports = { + RUN_COMPARISON_SCHEMA_VERSION, + compareEvidenceReceipts, +}; diff --git a/src/evidence/events.js b/src/evidence/events.js new file mode 100644 index 0000000..cdc5307 --- /dev/null +++ b/src/evidence/events.js @@ -0,0 +1,133 @@ +"use strict"; + +const fs = require("node:fs"); + +const EVENT_SCHEMA_VERSION = "event/v1"; +const LEGACY_EVENT_SCHEMA_VERSIONS = new Set(["workflow-event/v1"]); +const EVENT_CATEGORIES = Object.freeze({ + "workflow-approved": "run", + "workflow-migrated": "plan-revision", + "workflow-revised": "plan-revision", + "task-started": "task", + "task-failed": "task", + "task-completed": "task", + "checkpoint-started": "checkpoint", + "checkpoint-completed": "checkpoint", + "dispatch-started": "dispatch", + "dispatch-completed": "dispatch", + "workflow-intervention": "operator-intervention", + "verification-completed": "verification", + "usage-observed": "usage-observation", + "estimate-revised": "estimate-revision", + "budget-approved": "budget-approval", + "allocation-consumed": "allocation-consumption", + "budget-threshold": "threshold", + "warning-presented": "warning-presentation", + "pause-budget-safe": "safe-pause", + "paused-budget-safe": "safe-pause", + "pause-budget-unverified": "unverified-pause", + "paused-budget-unverified": "unverified-pause", + "pause-host-limit": "host-limit", + "paused-host-limit": "host-limit", + "scope-evaluated": "scope", + "review-passed": "review", + "review-blocked": "review", + "checkpoint-review-passed": "review", + "checkpoint-review-blocked": "review", + "workflow-cancelled": "cancellation", + "workflow-finalized": "finalize", + "workflow-lifecycle": "run", + "hook-evidence-approved": "scope", +}); + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeLifecycleEvent(candidate, options = {}) { + if (!isObject(candidate)) throw new Error("Lifecycle event must be an object."); + const legacy = LEGACY_EVENT_SCHEMA_VERSIONS.has(candidate.schemaVersion); + if (candidate.schemaVersion !== EVENT_SCHEMA_VERSION && !(options.allowLegacy && legacy)) { + throw new Error(`Incompatible lifecycle event schema: ${candidate.schemaVersion || "missing"}.`); + } + if (typeof candidate.type !== "string" || !EVENT_CATEGORIES[candidate.type]) { + throw new Error(`Unknown lifecycle event type: ${candidate.type || "missing"}.`); + } + if (typeof candidate.runId !== "string" || candidate.runId.length === 0) { + throw new Error("Lifecycle event runId is required."); + } + if (options.runId && candidate.runId !== options.runId) { + throw new Error(`Lifecycle event runId mismatch: ${candidate.runId}.`); + } + if (typeof candidate.timestamp !== "string" || !Number.isFinite(Date.parse(candidate.timestamp))) { + throw new Error("Lifecycle event timestamp must be an ISO timestamp."); + } + const category = EVENT_CATEGORIES[candidate.type]; + if (!legacy && candidate.category !== category) { + throw new Error(`Lifecycle event ${candidate.type} requires category ${category}.`); + } + return { + ...candidate, + schemaVersion: EVENT_SCHEMA_VERSION, + timestamp: new Date(candidate.timestamp).toISOString(), + category, + }; +} + +function createLifecycleEvent(candidate) { + return normalizeLifecycleEvent({ + ...candidate, + schemaVersion: EVENT_SCHEMA_VERSION, + category: EVENT_CATEGORIES[candidate.type], + }); +} + +function parseLifecycleEvents(content, options = {}) { + const events = []; + const issues = []; + String(content).split(/\r?\n/).forEach((line, index) => { + if (!line) return; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + issues.push({ code: "malformed-event", line: index + 1, message: "Lifecycle event is not valid JSON." }); + return; + } + try { + events.push(normalizeLifecycleEvent(parsed, { ...options, allowLegacy: true })); + } catch (error) { + issues.push({ + code: /schema/.test(error.message) ? "incompatible-event-schema" : "invalid-event", + line: index + 1, + message: error.message, + }); + } + }); + return { events, issues }; +} + +function readLifecycleEvents(filePath, options = {}) { + const parsed = parseLifecycleEvents(fs.readFileSync(filePath, "utf8"), options); + if (parsed.issues.length > 0) { + const first = parsed.issues[0]; + throw new Error(`${first.code} at line ${first.line}: ${first.message}`); + } + return parsed.events; +} + +function appendLifecycleEvent(runRoot, candidate) { + const event = createLifecycleEvent(candidate); + fs.appendFileSync(require("node:path").join(runRoot, "events.jsonl"), `${JSON.stringify(event)}\n`); + return event; +} + +module.exports = { + EVENT_CATEGORIES, + EVENT_SCHEMA_VERSION, + appendLifecycleEvent, + createLifecycleEvent, + normalizeLifecycleEvent, + parseLifecycleEvents, + readLifecycleEvents, +}; diff --git a/src/evidence/receipt.js b/src/evidence/receipt.js new file mode 100644 index 0000000..512fc8a --- /dev/null +++ b/src/evidence/receipt.js @@ -0,0 +1,487 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { getGitHeadCommit } = require("../lib/git"); +const { normalizeSlashPath } = require("../lib/paths"); +const { loadHostBinding, loadIntegrationControlReceipt } = require("../integration/binding"); +const { parseLifecycleEvents } = require("./events"); +const { buildUsageObservations, unknownUsageEstimate } = require("./usage"); +const { writeJsonAtomic } = require("../workflow/state"); + +const EVIDENCE_RECEIPT_SCHEMA_VERSION = "evidence-receipt/v1"; +const RECEIPT_BASENAMES = new Set(["evidence-receipt.json", "evidence-receipt.md"]); + +function lexicalCompare(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isInside(parentPath, childPath) { + const relative = path.relative(path.resolve(parentPath), path.resolve(childPath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function readJson(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error(`Invalid ${label}: ${error.message}`); + } +} + +function listFiles(rootPath) { + if (!fs.existsSync(rootPath)) return []; + const files = []; + const visit = (current) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) visit(entryPath); + else if (entry.isFile()) files.push(entryPath); + } + }; + visit(rootPath); + return files.sort((left, right) => lexicalCompare(normalizeSlashPath(left), normalizeSlashPath(right))); +} + +function readJsonDirectory(found, rootPath, label, warnings) { + return listFiles(rootPath) + .filter((filePath) => filePath.endsWith(".json")) + .map((filePath) => { + try { + return { path: filePath, value: readJson(filePath, label) }; + } catch (error) { + warnings.push({ + code: "malformed-evidence-file", + path: normalizeSlashPath(path.relative(found.repoRoot, filePath)), + message: error.message, + }); + return null; + } + }) + .filter(Boolean); +} + +function readEvents(found, warnings) { + const eventPath = path.join(found.runRoot, "events.jsonl"); + if (!fs.existsSync(eventPath)) { + warnings.push({ code: "missing-events", message: "Workflow event ledger is missing." }); + return []; + } + const parsed = parseLifecycleEvents(fs.readFileSync(eventPath, "utf8"), { runId: found.run.runId }); + warnings.push(...parsed.issues); + return parsed.events; +} + +function truthAggregate(values, reason) { + const present = values.filter(Boolean); + if (present.length > 0 && present.every((entry) => entry.label === "observed")) { + return { + label: "observed", + value: present.reduce((total, entry) => total + entry.value, 0), + sources: [...new Set(present.map((entry) => entry.source).filter(Boolean))].sort(), + }; + } + return { label: "unknown", value: null, reason }; +} + +function safeEvidencePath(found, relativePath) { + if (typeof relativePath !== "string" || relativePath.length === 0) return null; + const resolved = path.resolve(found.repoRoot, relativePath); + return isInside(found.repoRoot, resolved) ? resolved : null; +} + +function referencedPaths(results, reviews) { + const paths = []; + for (const result of results) { + for (const entry of result.value.verification.baseline.evidence || []) paths.push(entry.evidencePath); + for (const entry of result.value.verification.targeted || []) paths.push(entry.evidencePath); + for (const entry of result.value.verification.full || []) paths.push(entry.evidencePath); + for (const artifact of result.value.artifacts || []) paths.push(artifact.path); + for (const evidencePath of (result.value.failure && result.value.failure.evidencePaths) || []) paths.push(evidencePath); + } + for (const review of reviews) { + for (const evidence of review.value.evidence || []) paths.push(evidence.path); + for (const finding of review.value.findings || []) { + for (const evidencePath of finding.evidencePaths || []) paths.push(evidencePath); + } + } + return [...new Set(paths)].sort(); +} + +function hashFile(found, filePath) { + const content = fs.readFileSync(filePath); + return { + path: normalizeSlashPath(path.relative(found.repoRoot, filePath)), + sha256: `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`, + bytes: content.length, + }; +} + +function integrityInventory(found, results, reviews, warnings) { + const canonical = listFiles(found.runRoot).filter((filePath) => { + if (RECEIPT_BASENAMES.has(path.basename(filePath))) return false; + const relative = normalizeSlashPath(path.relative(found.runRoot, filePath)); + return relative === "run.json" + || relative === "events.jsonl" + || relative.startsWith("checkpoints/") + || relative.startsWith("results/") + || relative.startsWith("reviews/") + || relative.startsWith("integration/"); + }); + const definitionPath = path.resolve(found.repoRoot, found.run.workflow.definitionPath); + if (isInside(found.repoRoot, definitionPath) && fs.existsSync(definitionPath)) canonical.push(definitionPath); + for (const relativePath of referencedPaths(results, reviews)) { + const filePath = safeEvidencePath(found, relativePath); + if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + warnings.push({ code: "missing-referenced-evidence", path: relativePath, message: "Referenced evidence file is unavailable." }); + continue; + } + canonical.push(filePath); + } + return [...new Set(canonical.map((entry) => path.resolve(entry)))] + .map((filePath) => hashFile(found, filePath)) + .sort((left, right) => lexicalCompare(left.path, right.path)); +} + +function taskReceipt(found, runtimeTask, resultsByTask) { + const definitionTask = found.definition.tasks.find((entry) => entry.id === runtimeTask.id); + const result = resultsByTask.get(runtimeTask.id) || null; + return { + id: runtimeTask.id, + title: definitionTask.title, + status: runtimeTask.status, + attempts: runtimeTask.attempts, + allowedFiles: definitionTask.allowedFiles, + forbiddenFiles: definitionTask.forbiddenFiles, + stoppingConditions: definitionTask.stoppingConditions, + changedFiles: result ? result.changedFiles : [], + scopeVerdict: result ? { status: "passed", basis: "validated-task-result" } : { status: "unknown", basis: "result-unavailable" }, + verification: result ? result.verification : { + baseline: { status: "unknown", evidence: [] }, + targeted: [], + full: [], + }, + failure: result ? result.failure : null, + artifacts: result ? result.artifacts : [], + resultId: runtimeTask.resultId, + recovery: { + blocker: runtimeTask.blocker, + failureHistory: runtimeTask.failureHistory || [], + stateHistory: runtimeTask.stateHistory || [], + interventions: (found.run.interventions || []).filter((entry) => entry.taskId === runtimeTask.id), + }, + }; +} + +function checkpointReceipt(entry) { + return { + checkpointId: entry.value.checkpointId, + taskId: entry.value.taskId, + attempt: entry.value.attempt, + status: entry.value.status, + startedAt: entry.value.startedAt, + completedAt: entry.value.completedAt, + failureClassification: entry.value.failureClassification, + interventionState: entry.value.interventionState, + reviewer: entry.value.reviewer, + result: entry.value.result, + verification: entry.value.verification, + }; +} + +function budgetReceipt(budget) { + const allocationChecks = Object.keys(budget.allocations).sort().map((name) => ({ + name, + approved: budget.allocations[name], + consumed: budget.consumed.allocations[name], + respected: budget.consumed.allocations[name] <= budget.allocations[name], + protected: budget.protectedAllocations.includes(name), + })); + const ceilings = { + modelOperations: budget.consumed.modelOperations <= budget.modelOperations, + targetedVerificationRuns: budget.consumed.targetedVerificationRuns <= budget.maxTargetedVerificationRuns, + fullVerificationRuns: budget.consumed.fullVerificationRuns <= budget.maxFullVerificationRuns, + capturedOutputBytes: budget.consumed.capturedOutputBytes <= budget.maxCapturedOutputBytes, + }; + const respected = Object.values(ceilings).every(Boolean) && allocationChecks.every((entry) => entry.respected); + return { + ...budget, + compliance: { + status: respected ? "passed" : "failed", + absoluteCeilingRespected: ceilings.modelOperations, + protectedAllocationsRespected: allocationChecks.filter((entry) => entry.protected).every((entry) => entry.respected), + ceilings, + allocations: allocationChecks, + }, + }; +} + +function buildEvidenceReceipt(found, options = {}) { + const generatedAt = options.generatedAt || new Date().toISOString(); + const warnings = []; + const results = readJsonDirectory(found, path.join(found.runRoot, "results"), "workflow result", warnings); + const reviews = readJsonDirectory(found, path.join(found.runRoot, "reviews"), "workflow review", warnings); + const checkpoints = readJsonDirectory(found, path.join(found.runRoot, "checkpoints"), "workflow checkpoint", warnings); + const events = readEvents(found, warnings); + const resultsByTask = new Map(results.map((entry) => [entry.value.taskId, entry.value])); + const reviewValues = reviews.map((entry) => entry.value); + const usageValues = [...results.map((entry) => entry.value), ...reviewValues].map((entry) => entry.usage); + const usageObservations = buildUsageObservations( + found, + results.map((entry) => entry.value), + reviewValues, + warnings, + ); + let complete = found.run.status === "finalized" + && found.run.tasks.every((task) => task.status === "completed" && task.verification && task.verification.status === "passed") + && (!found.run.reviewerPolicy.requiredForFinalize || found.run.reviewer.status === "passed"); + if (!complete) warnings.push({ code: "run-not-finalized", message: `Run status ${found.run.status} produces a partial receipt.` }); + const integrityFiles = integrityInventory(found, results, reviews, warnings); + if (warnings.some((warning) => ["missing-referenced-evidence", "malformed-evidence-file", "malformed-event", "incompatible-event-schema", "invalid-event", "missing-events", "malformed-usage-observation-ledger"].includes(warning.code))) { + complete = false; + } + const headCommit = getGitHeadCommit(found.repoRoot); + const baseCommit = found.run.git && found.run.git.baseCommit; + const hostBinding = loadHostBinding(found); + + return { + schemaVersion: EVIDENCE_RECEIPT_SCHEMA_VERSION, + generatedAt, + runId: found.run.runId, + goal: found.run.goal, + sourcePlan: found.run.approval.source, + workflow: found.run.workflow, + planRevisions: found.run.revisionHistory || [], + operatingModes: found.run.execution.allowedModes, + execution: { + owner: found.run.execution.owner, + backend: found.run.execution.backend, + }, + assurance: found.run.assurance, + completeness: { status: complete ? "complete" : "partial", runStatus: found.run.status }, + tasks: found.run.tasks.map((task) => taskReceipt(found, task, resultsByTask)), + checkpoints: checkpoints.map(checkpointReceipt), + interventions: found.run.interventions || [], + events, + providers: [{ + provider: found.run.execution.backend === "codex-exec" + ? { status: "known", value: "codex" } + : { status: "unknown", value: null, reason: "selected execution boundary does not identify a provider" }, + effectiveModel: { status: "unknown", value: null, reason: "workflow result does not expose an effective model" }, + }], + commands: found.definition.tasks.map((task) => ({ taskId: task.id, verification: task.verification })), + reviewer: found.run.reviewer, + reviews: reviewValues, + reviewHistory: found.run.reviewHistory || [], + budget: budgetReceipt(found.run.budget), + usage: { + managedOperations: truthAggregate(usageValues.map((usage) => usage && usage.managedOperations), "no uniformly observed managed operation evidence"), + capturedOutputBytes: truthAggregate(usageValues.map((usage) => usage && usage.capturedOutputBytes), "no uniformly observed captured output evidence"), + managedTokens: truthAggregate(usageValues.map((usage) => usage && usage.managedTokens), "managed token totals are unavailable"), + hostInternal: truthAggregate(usageValues.map((usage) => usage && usage.hostInternal), "host-internal usage is unavailable"), + observations: usageObservations, + }, + estimate: unknownUsageEstimate(), + cost: { apiEquivalent: { label: "unknown", value: null, currency: null, pricingDate: null, model: null, reason: "no valid dated API pricing mapping" } }, + warningSurface: { status: "unknown", deliveries: [] }, + git: { + baseCommit: baseCommit + ? { status: "known", value: baseCommit } + : { status: "unknown", value: null, reason: "historical run predates source git identity" }, + headCommit: { status: "known", value: headCommit }, + }, + policy: { + approval: found.run.approval, + assurance: found.run.assurance, + controls: loadIntegrationControlReceipt(found), + }, + integration: { + nativeGoal: hostBinding && hostBinding.execution.owner === "native" && hostBinding.references.goalId + ? { + status: "known", + goalId: hostBinding.references.goalId, + surface: hostBinding.host.surface, + authenticationBoundary: hostBinding.provenance.authenticationBoundary, + } + : { status: "unknown", goalId: null, reason: "no supported native goal binding" }, + }, + integrity: { + algorithm: "sha256", + claim: "tamper-evident-local-metadata", + tamperProof: false, + files: integrityFiles, + sourceIdentities: { + workflowDigest: found.run.workflow.digest, + sourcePlanSha256: found.run.approval.source.sha256, + gitBaseCommit: baseCommit || null, + gitHeadCommit: headCommit, + }, + }, + timestamps: { + createdAt: found.run.createdAt, + updatedAt: found.run.updatedAt, + finalizedAt: found.run.finalization ? found.run.finalization.finalizedAt : null, + generatedAt, + }, + warnings, + }; +} + +function renderEvidenceReceiptMarkdown(receipt) { + const taskLines = receipt.tasks.flatMap((task) => { + const recovery = task.recovery || { failureHistory: [], interventions: [] }; + return [ + `- ${task.id}: ${task.status}; attempts ${task.attempts}; scope ${task.scopeVerdict.status}`, + ` - Allowed: ${task.allowedFiles.join(", ") || "none"}`, + ` - Changed: ${task.changedFiles.join(", ") || "none"}`, + ` - Artifacts: ${task.artifacts.map((entry) => `${entry.kind}:${entry.path}`).join(", ") || "none"}`, + ` - Failure: ${task.failure ? `${task.failure.classification}: ${task.failure.summary}` : "none"}`, + ` - Recovery: ${recovery.interventions.map((entry) => `${entry.event}: ${entry.reason}`).join("; ") || "none"}`, + ]; + }).join("\n"); + const checkpointLines = receipt.checkpoints.map((entry) => { + const baseline = entry.verification && entry.verification.baseline + ? entry.verification.baseline.status + : "unknown"; + return `- ${entry.checkpointId}: ${entry.status}; task ${entry.taskId}; attempt ${entry.attempt}; baseline ${baseline}; failure ${entry.failureClassification || "none"}`; + }).join("\n"); + const commandLines = receipt.commands.flatMap((entry) => [ + `- ${entry.taskId} baseline: ${entry.verification.baseline || "none"}`, + ...entry.verification.targeted.map((command) => ` - targeted: ${command}`), + ...entry.verification.full.map((command) => ` - full: ${command}`), + ]).join("\n"); + const revisionLines = receipt.planRevisions.map((entry) => ( + `- revision ${entry.revision}: ${entry.reason || entry.digest}; superseded ${entry.supersededAt || "unknown"}` + )).join("\n"); + const interventionLines = receipt.interventions.map((entry) => ( + `- ${entry.event}: ${entry.reason}; actor ${entry.actor}; ${entry.recordedAt}` + )).join("\n"); + const significantEventLines = receipt.events + .filter((entry) => ["threshold", "warning-presentation", "safe-pause", "unverified-pause", "host-limit", "cancellation"].includes(entry.category)) + .map((entry) => `- ${entry.timestamp}: ${entry.category}/${entry.type}; ${entry.reason || entry.warning || entry.threshold || "recorded"}`) + .join("\n"); + const allocationLines = receipt.budget.compliance.allocations.map((entry) => ( + `- ${entry.name}: ${entry.consumed}/${entry.approved}; protected ${entry.protected ? "yes" : "no"}; ${entry.respected ? "passed" : "failed"}` + )).join("\n"); + const observationLines = receipt.usage.observations.map((entry) => ( + `- ${entry.category}/${entry.rawCategory}: ${entry.availability}; source ${entry.source.id}; auth ${entry.source.authenticationBoundary}; model ${entry.effectiveModel.status}` + )).join("\n"); + const controlReceipt = receipt.policy.controls; + const controlLines = controlReceipt + ? controlReceipt.controls.map((entry) => `- ${entry.name}: ${entry.classification}; ${entry.effect}`).join("\n") + : "- none"; + const warningLines = receipt.warnings.length > 0 + ? receipt.warnings.map((warning) => `- ${warning.code}: ${warning.message}`).join("\n") + : "- none"; + return `# CEWP Evidence Receipt + +- Run: ${receipt.runId} +- Goal: ${receipt.goal} +- Status: ${receipt.completeness.runStatus} (${receipt.completeness.status}) +- Execution: ${receipt.execution.owner} / ${receipt.execution.backend || "none"} +- Operating modes: ${receipt.operatingModes.join(", ")} +- Source: ${receipt.sourcePlan.kind}; ${receipt.sourcePlan.path || "direct goal"} +- Workflow: ${receipt.workflow.id} revision ${receipt.workflow.revision}; ${receipt.workflow.digest} +- Git base: ${receipt.git.baseCommit.status === "known" ? receipt.git.baseCommit.value : "unknown"} +- Git head: ${receipt.git.headCommit.value} +- Integrity: ${receipt.integrity.claim}; ${receipt.integrity.files.length} hashed files; not tamper-proof +${receipt.redaction && receipt.redaction.applied ? `- Redaction: applied (${receipt.redaction.replacements} replacements)\n` : ""} + +## Tasks + +${taskLines || "- none"} + +## Checkpoints + +${checkpointLines || "- none"} + +## Commands and verification + +${commandLines || "- none"} + +## Plan revisions + +${revisionLines || "- none"} + +## Interventions and lifecycle decisions + +${interventionLines || "- none"} +${significantEventLines ? `\n${significantEventLines}` : ""} + +## Budget + +- Compliance: ${receipt.budget.compliance.status} +- Absolute ceiling: ${receipt.budget.compliance.absoluteCeilingRespected ? "passed" : "failed"} +- Protected allocations: ${receipt.budget.compliance.protectedAllocationsRespected ? "passed" : "failed"} +- Pause reason: ${receipt.budget.pauseReason || "none"} + +${allocationLines || "- none"} + +## Usage and estimate + +- Managed operations: ${receipt.usage.managedOperations.label}${receipt.usage.managedOperations.value === null ? "" : ` (${receipt.usage.managedOperations.value})`} +- Captured output: ${receipt.usage.capturedOutputBytes.label}${receipt.usage.capturedOutputBytes.value === null ? "" : ` (${receipt.usage.capturedOutputBytes.value})`} +- Managed tokens: ${receipt.usage.managedTokens.label} +- Host-internal usage: ${receipt.usage.hostInternal.label} +- Estimate: ${receipt.estimate.label}; confidence ${receipt.estimate.confidence}; samples ${receipt.estimate.sampleBasis.count}; estimator ${receipt.estimate.estimator.version}; drift ${receipt.estimate.drift.state} +- API-equivalent cost: ${receipt.cost.apiEquivalent.label}${receipt.cost.apiEquivalent.model ? `; model ${receipt.cost.apiEquivalent.model}; pricing ${receipt.cost.apiEquivalent.pricingDate}` : ""} + +${observationLines || "- no usage observations"} + +## Controls + +- Execution owner: ${receipt.execution.owner} +- Preventive enforced: ${controlReceipt ? controlReceipt.summary.preventiveEnforced : 0} +- Imported observed: ${controlReceipt ? controlReceipt.summary.importedObserved : 0} + +${controlLines} + +## Final review + +- Status: ${receipt.reviewer.status} +- Decision: ${receipt.reviewer.decision || "none"} +- Review ID: ${receipt.reviewer.reviewId || "none"} +- Reviews recorded: ${receipt.reviews.length} + +## Timestamps + +- Created: ${receipt.timestamps.createdAt} +- Updated: ${receipt.timestamps.updatedAt} +- Finalized: ${receipt.timestamps.finalizedAt || "none"} +- Receipt generated: ${receipt.timestamps.generatedAt} + +## Warnings + +${warningLines} + +This receipt excludes raw prompts and raw log contents by default. +`; +} + +function writeTextAtomic(filePath, content) { + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, content, { flag: "wx" }); + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function writeEvidenceReceipt(found, options = {}) { + const receipt = buildEvidenceReceipt(found, options); + const jsonPath = path.join(found.runRoot, "evidence-receipt.json"); + const markdownPath = path.join(found.runRoot, "evidence-receipt.md"); + writeJsonAtomic(jsonPath, receipt); + writeTextAtomic(markdownPath, renderEvidenceReceiptMarkdown(receipt)); + return { receipt, paths: { json: jsonPath, markdown: markdownPath } }; +} + +module.exports = { + EVIDENCE_RECEIPT_SCHEMA_VERSION, + buildEvidenceReceipt, + renderEvidenceReceiptMarkdown, + writeEvidenceReceipt, +}; diff --git a/src/evidence/redaction.js b/src/evidence/redaction.js new file mode 100644 index 0000000..ccbe8a9 --- /dev/null +++ b/src/evidence/redaction.js @@ -0,0 +1,138 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { normalizeSlashPath } = require("../lib/paths"); +const { writeJsonAtomic } = require("../workflow/state"); +const { buildEvidenceReceipt, renderEvidenceReceiptMarkdown } = require("./receipt"); +const { buildOperatorReport, renderOperatorReportHtml } = require("./report"); + +const REDACTION_SCHEMA_VERSION = "redaction-policy/v1"; +const SENSITIVE_KEY = /^(authorization|password|passwd|secret|client[_-]?secret|api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?token|auth[_-]?token|cookie|private[_-]?key|credential)$/i; +const PATH_KEY = /(^|[A-Z_-])(paths?|files?|director(?:y|ies))$/i; +const SENSITIVE_PATH = /(^|[\\/])(?:\.env(?:\.[^\\/]*)?|id_rsa|id_ed25519|credentials?(?:\.[^\\/]*)?|[^\\/]+\.(?:pem|p12|pfx|key))$/i; +const ABSOLUTE_WINDOWS_PATH = /^[a-z]:[\\/]/i; +const ABSOLUTE_UNC_PATH = /^\\\\/; + +function redactString(input, key, state) { + if (SENSITIVE_KEY.test(key || "")) { + state.replacements += 1; + state.classes.add("sensitive-key"); + return "[REDACTED]"; + } + if (PATH_KEY.test(key || "") && ( + path.isAbsolute(input) + || ABSOLUTE_WINDOWS_PATH.test(input) + || ABSOLUTE_UNC_PATH.test(input) + || /^~[\\/]/.test(input) + || /^(?:file:|\$(?:\{)?HOME|%(?:USERPROFILE|HOME)%)/i.test(input) + || input.split(/[\\/]+/).includes("..") + || SENSITIVE_PATH.test(input) + )) { + state.replacements += 1; + state.classes.add("sensitive-path"); + return "[REDACTED_PATH]"; + } + let value = input; + const patterns = [ + { className: "active-content", pattern: /<\/?(?:script|style|iframe|object|embed|link|meta)\b[^>]*>/gi, replacement: "[REDACTED_MARKUP]" }, + { className: "private-key", pattern: /-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" }, + { className: "authorization", pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi, replacement: "Bearer [REDACTED]" }, + { className: "url-credentials", pattern: /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, replacement: "$1[REDACTED]@" }, + { className: "provider-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g, replacement: "[REDACTED_TOKEN]" }, + { className: "assigned-secret", pattern: /((?:--)?(?:password|passwd|token|secret|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, replacement: "$1[REDACTED]" }, + { className: "embedded-path", pattern: /\b[A-Za-z]:[\\/](?:[^\s,;<>"']+[\\/]?)+|\/(?:home|Users|root|tmp|var\/tmp)\/[^\s,;<>"']+/g, replacement: "[REDACTED_PATH]" }, + ]; + for (const entry of patterns) { + let count = 0; + value = value.replace(entry.pattern, (...args) => { + count += 1; + return typeof entry.replacement === "function" ? entry.replacement(...args) : entry.replacement.replace("$1", args[1] || ""); + }); + if (count > 0) { + state.replacements += count; + state.classes.add(entry.className); + } + } + return value; +} + +function redactNode(value, key, state) { + if (typeof value === "string") return redactString(value, key, state); + if (Array.isArray(value)) return value.map((entry) => redactNode(entry, key, state)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactNode(entryValue, entryKey, state), + ])); +} + +function redactEvidenceValue(value) { + const state = { replacements: 0, classes: new Set() }; + return { + value: redactNode(value, "", state), + replacements: state.replacements, + classes: [...state.classes].sort(), + }; +} + +function redactEvidenceReceipt(receipt) { + const redacted = redactEvidenceValue(receipt); + return { + ...redacted.value, + redaction: { + schemaVersion: REDACTION_SCHEMA_VERSION, + applied: true, + replacements: redacted.replacements, + classes: redacted.classes, + canonicalReceiptModified: false, + rawPromptsIncluded: false, + rawLogsIncluded: false, + }, + integrity: { + ...redacted.value.integrity, + exportRedaction: { + applied: true, + canonicalLocalReceiptRequiredForVerification: true, + }, + }, + }; +} + +function writeTextAtomic(filePath, content) { + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, content, { flag: "wx" }); + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function exportRedactedEvidence(found, options = {}) { + const receipt = redactEvidenceReceipt(buildEvidenceReceipt(found, options)); + const report = { ...buildOperatorReport(receipt), redaction: receipt.redaction }; + const absolutePaths = { + receiptJson: path.join(found.runRoot, "evidence-receipt.redacted.json"), + receiptMarkdown: path.join(found.runRoot, "evidence-receipt.redacted.md"), + reportJson: path.join(found.runRoot, "operator-report.redacted.json"), + html: path.join(found.runRoot, "operator-report.redacted.html"), + }; + writeJsonAtomic(absolutePaths.receiptJson, receipt); + writeTextAtomic(absolutePaths.receiptMarkdown, renderEvidenceReceiptMarkdown(receipt)); + writeJsonAtomic(absolutePaths.reportJson, report); + writeTextAtomic(absolutePaths.html, renderOperatorReportHtml(report)); + const paths = Object.fromEntries(Object.entries(absolutePaths).map(([name, filePath]) => [ + name, + normalizeSlashPath(path.relative(found.repoRoot, filePath)), + ])); + return { receipt, report, paths }; +} + +module.exports = { + REDACTION_SCHEMA_VERSION, + exportRedactedEvidence, + redactEvidenceReceipt, + redactEvidenceValue, +}; diff --git a/src/evidence/report.js b/src/evidence/report.js new file mode 100644 index 0000000..b378bea --- /dev/null +++ b/src/evidence/report.js @@ -0,0 +1,168 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { writeJsonAtomic } = require("../workflow/state"); +const { buildEvidenceReceipt } = require("./receipt"); + +const OPERATOR_REPORT_SCHEMA_VERSION = "operator-report/v1"; +const ACTIVE_TASK_STATUSES = new Set(["running", "verifying", "review-pending"]); + +function remainingAllocation(entry) { + return Math.max(0, entry.approved - entry.consumed); +} + +function groupControls(receipt) { + const controls = receipt.policy.controls && Array.isArray(receipt.policy.controls.controls) + ? receipt.policy.controls.controls + : []; + return { + enforced: controls.filter((entry) => entry.classification === "preventive"), + checked: controls.filter((entry) => entry.classification === "post-execution"), + observed: controls.filter((entry) => entry.classification === "imported"), + unavailable: controls.filter((entry) => entry.classification === "unavailable"), + claims: receipt.policy.controls ? receipt.policy.controls.claims : { + observedEvidenceIsPreventiveEnforcement: false, + providerExecutionSuppliesEnforcement: false, + preventiveControlAuthority: "none", + guardrailAuthority: "cewp-core-outside-provider-execution", + }, + }; +} + +function buildOperatorReport(receipt) { + if (!receipt || receipt.schemaVersion !== "evidence-receipt/v1") { + throw new Error("Operator report requires evidence-receipt/v1."); + } + const allocationEntries = receipt.budget.compliance.allocations; + const completedTasks = receipt.tasks.filter((task) => task.status === "completed").length; + const activeTasks = receipt.tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status)).length; + return { + schemaVersion: OPERATOR_REPORT_SCHEMA_VERSION, + generatedAt: receipt.generatedAt, + runId: receipt.runId, + goal: receipt.goal, + completeness: receipt.completeness, + execution: receipt.execution, + progress: { + runStatus: receipt.completeness.runStatus, + totalTasks: receipt.tasks.length, + completedTasks, + activeTasks, + remainingTasks: receipt.tasks.length - completedTasks, + }, + planRevisions: receipt.planRevisions, + tasks: receipt.tasks, + checkpoints: receipt.checkpoints, + interventions: receipt.interventions, + values: { + observed: receipt.usage, + estimated: receipt.estimate, + budgeted: receipt.budget, + apiEquivalentCost: receipt.cost.apiEquivalent, + }, + reserves: { + absoluteCeilingRespected: receipt.budget.compliance.absoluteCeilingRespected, + protectedAllocationsRespected: receipt.budget.compliance.protectedAllocationsRespected, + allocations: allocationEntries.map((entry) => ({ ...entry, remaining: remainingAllocation(entry) })), + }, + pauseAndRecovery: { + pauseReason: receipt.budget.pauseReason, + resumeStatus: receipt.budget.resumeStatus, + thresholdEvents: receipt.budget.thresholdEvents, + interventions: receipt.interventions, + failures: receipt.tasks.flatMap((task) => task.failure ? [{ taskId: task.id, ...task.failure }] : []), + }, + controls: groupControls(receipt), + finalReview: receipt.reviewer, + reviews: receipt.reviews, + warnings: receipt.warnings, + integrity: { + claim: receipt.integrity.claim, + tamperProof: receipt.integrity.tamperProof, + fileCount: receipt.integrity.files.length, + }, + }; +} + +function escapeHtml(value) { + return String(value === null || value === undefined ? "unknown" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function truthText(value) { + if (!value || value.label === "unknown") return "unknown"; + return `${value.label}: ${value.value}`; +} + +function list(items, render, empty = "None") { + return items.length > 0 + ? `
    ${items.map((item) => `
  • ${render(item)}
  • `).join("")}
` + : `

${escapeHtml(empty)}

`; +} + +function renderOperatorReportHtml(report) { + if (!report || report.schemaVersion !== OPERATOR_REPORT_SCHEMA_VERSION) { + throw new Error(`Operator HTML requires ${OPERATOR_REPORT_SCHEMA_VERSION}.`); + } + const allocationRows = report.reserves.allocations.map((entry) => `${escapeHtml(entry.name)}${entry.approved}${entry.consumed}${entry.remaining}${entry.protected ? "yes" : "no"}${entry.respected ? "passed" : "failed"}`).join(""); + const taskRows = report.tasks.map((task) => `${escapeHtml(task.id)}${escapeHtml(task.status)}${task.attempts}${escapeHtml(task.scopeVerdict.status)}${escapeHtml(task.changedFiles.join(", ") || "none")}`).join(""); + const controlItems = (label, entries) => `

${label}

${list(entries, (entry) => `${escapeHtml(entry.name)} (${escapeHtml(entry.effect)})`)}`; + return ` + + + + + +CEWP Operator Report - ${escapeHtml(report.runId)} + + + +

CEWP Operator Report

+

Run: ${escapeHtml(report.runId)}
Goal: ${escapeHtml(report.goal)}
Status: ${escapeHtml(report.progress.runStatus)} (${escapeHtml(report.completeness.status)})
Execution: ${escapeHtml(report.execution.owner)} / ${escapeHtml(report.execution.backend)}

+

Progress

Tasks: ${report.progress.completedTasks}/${report.progress.totalTasks} complete
Active: ${report.progress.activeTasks}
Remaining: ${report.progress.remainingTasks}
${taskRows}
TaskStatusAttemptsScopeChanged files
+

Plan revisions

${list(report.planRevisions, (entry) => `Revision ${escapeHtml(entry.revision)}: ${escapeHtml(entry.reason || entry.digest)}`)}
+

Checkpoint evidence

${list(report.checkpoints, (entry) => `${escapeHtml(entry.checkpointId)}: ${escapeHtml(entry.status)}; task ${escapeHtml(entry.taskId)}; baseline ${escapeHtml(entry.verification && entry.verification.baseline && entry.verification.baseline.status)}`)}
+

Interventions and recovery

${list(report.interventions, (entry) => `${escapeHtml(entry.event)}: ${escapeHtml(entry.reason)}`)}

Pause: ${escapeHtml(report.pauseAndRecovery.pauseReason)}; resume state: ${escapeHtml(report.pauseAndRecovery.resumeStatus)}

+

Observed, estimated, budgeted, and unknown

Observed managed operations: ${escapeHtml(truthText(report.values.observed.managedOperations))}
Observed host usage: ${escapeHtml(truthText(report.values.observed.hostInternal))}
Estimate: ${escapeHtml(report.values.estimated.label)} (${escapeHtml(report.values.estimated.confidence)})
API-equivalent cost: ${escapeHtml(report.values.apiEquivalentCost.label)}
+

Protected reserves

Absolute ceiling: ${report.reserves.absoluteCeilingRespected ? "passed" : "failed"}; protected allocations: ${report.reserves.protectedAllocationsRespected ? "passed" : "failed"}

${allocationRows}
AllocationApprovedConsumedRemainingProtectedVerdict
+

Controls

${controlItems("Preventively enforced", report.controls.enforced)}${controlItems("Post-execution checked", report.controls.checked)}${controlItems("Observed, not enforced", report.controls.observed)}${controlItems("Unavailable", report.controls.unavailable)}
+

Final review

Status: ${escapeHtml(report.finalReview.status)}; decision: ${escapeHtml(report.finalReview.decision)}

+

Warnings

${list(report.warnings, (entry) => `${escapeHtml(entry.code)}: ${escapeHtml(entry.message)}`)}
+

Generated ${escapeHtml(report.generatedAt)}. Integrity: ${escapeHtml(report.integrity.claim)}; not tamper-proof. Redaction: ${report.redaction && report.redaction.applied ? `applied (${report.redaction.replacements} replacements)` : "not applied"}. This offline report excludes raw prompts and logs.

+ + +`; +} + +function writeTextAtomic(filePath, content) { + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, content, { flag: "wx" }); + try { + fs.renameSync(temporaryPath, filePath); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function writeOperatorReport(found, options = {}) { + const receipt = buildEvidenceReceipt(found, options); + const report = buildOperatorReport(receipt); + const jsonPath = path.join(found.runRoot, "operator-report.json"); + const htmlPath = path.join(found.runRoot, "operator-report.html"); + writeJsonAtomic(jsonPath, report); + writeTextAtomic(htmlPath, renderOperatorReportHtml(report)); + return { report, paths: { json: jsonPath, html: htmlPath } }; +} + +module.exports = { + OPERATOR_REPORT_SCHEMA_VERSION, + buildOperatorReport, + renderOperatorReportHtml, + writeOperatorReport, +}; diff --git a/src/evidence/usage.js b/src/evidence/usage.js new file mode 100644 index 0000000..050aa52 --- /dev/null +++ b/src/evidence/usage.js @@ -0,0 +1,139 @@ +"use strict"; + +const { readHostObservations } = require("../integration/observation"); + +const USAGE_OBSERVATION_SCHEMA_VERSION = "usage-observation/v1"; +const USAGE_ESTIMATE_SCHEMA_VERSION = "usage-estimate/v1"; +const RESULT_CATEGORIES = Object.freeze([ + "managedOperations", + "capturedOutputBytes", + "managedTokens", + "hostInternal", +]); + +function sourceBoundary(source) { + if (typeof source !== "string") return "unknown"; + if (source.startsWith("codex-exec")) return "managed-child-process"; + if (source.startsWith("cewp-")) return "cewp-core-local"; + return "unknown"; +} + +function resultObservations(kind, values) { + return values.flatMap((value) => RESULT_CATEGORIES.map((category) => { + const truth = value.usage[category]; + const sourceId = truth.source || `${kind}-contract`; + return { + schemaVersion: USAGE_OBSERVATION_SCHEMA_VERSION, + observationId: `${kind}:${kind === "task-result" ? value.resultId : value.reviewId}:${category}`, + observedAt: value.completedAt, + scope: { + runId: value.runId, + taskId: value.taskId || (value.scope && value.scope.taskId) || null, + checkpointId: value.checkpointId || (value.scope && value.scope.checkpointId) || null, + }, + category, + rawCategory: `usage.${category}`, + availability: truth.label === "observed" ? "observed" : "unknown", + evidenceClass: truth.label === "observed" ? "observed" : "unknown", + value: truth.value, + reason: truth.reason || null, + source: { + kind, + id: sourceId, + schemaVersion: value.schemaVersion, + authenticationBoundary: sourceBoundary(truth.source), + }, + effectiveModel: { status: "unknown", value: null, reason: "result contract does not expose effective model" }, + rawValue: truth, + }; + })); +} + +function hostObservations(found, warnings) { + let entries; + try { + entries = readHostObservations(found); + } catch (error) { + warnings.push({ code: "malformed-usage-observation-ledger", message: error.message }); + return []; + } + return entries.map((entry) => ({ + schemaVersion: USAGE_OBSERVATION_SCHEMA_VERSION, + observationId: `host:${entry.observationId}`, + observedAt: entry.observedAt, + scope: { + runId: entry.scope.runId, + taskId: entry.scope.taskId, + checkpointId: entry.scope.checkpointId, + }, + category: entry.category, + rawCategory: entry.rawCategory, + availability: entry.availability, + evidenceClass: entry.evidenceClass, + value: entry.data, + reason: entry.reason, + source: { + kind: "host-observation", + id: entry.source.path, + schemaVersion: entry.source.schemaVersion, + codexVersion: entry.source.codexVersion, + authenticationBoundary: entry.source.authenticationBoundary, + }, + effectiveModel: { status: "unknown", value: null, reason: "host observation does not expose effective model" }, + rawValue: null, + billingImpact: entry.billingImpact, + })); +} + +function buildUsageObservations(found, results, reviews, warnings = []) { + return [ + ...resultObservations("task-result", results), + ...resultObservations("review-result", reviews), + ...hostObservations(found, warnings), + ].sort((left, right) => left.observationId < right.observationId ? -1 : left.observationId > right.observationId ? 1 : 0); +} + +function unknownUsageEstimate(reason = "At least five comparable local runs with known model and effort are required.") { + return { + schemaVersion: USAGE_ESTIMATE_SCHEMA_VERSION, + label: "unknown", + range: null, + confidence: "unavailable", + estimator: { + version: "local-history/v1", + method: "comparable-run-interval", + minimumSampleCount: 5, + groupingDimensions: [ + "taskClass", "effectiveModel", "effectiveEffort", "assurance", "repositorySizeBucket", + "checkpointCount", "workerReviewerShape", "repairs", "verificationSchedule", + ], + }, + sampleBasis: { + count: 0, + comparableRunIds: [], + localOnly: true, + promptsStored: false, + sourceStored: false, + rawLogsStored: false, + }, + calibrationSnapshot: { + id: null, + createdAt: null, + intervalCoverage: null, + absoluteError: null, + }, + drift: { + state: "unknown", + checkedAt: null, + changedDimensions: [], + }, + reason, + }; +} + +module.exports = { + USAGE_ESTIMATE_SCHEMA_VERSION, + USAGE_OBSERVATION_SCHEMA_VERSION, + buildUsageObservations, + unknownUsageEstimate, +}; diff --git a/src/evidence/verify.js b/src/evidence/verify.js new file mode 100644 index 0000000..5c0ff8c --- /dev/null +++ b/src/evidence/verify.js @@ -0,0 +1,191 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { normalizeSlashPath } = require("../lib/paths"); +const { loadWorkflowRun } = require("../workflow/state"); +const { parseLifecycleEvents } = require("./events"); +const { buildEvidenceReceipt } = require("./receipt"); + +const RUN_VERIFICATION_SCHEMA_VERSION = "run-verification/v1"; + +function sha256(filePath) { + return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`; +} + +function isInside(parentPath, childPath) { + const relative = path.relative(path.resolve(parentPath), path.resolve(childPath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function check(id, issues, startIndex, skipped = false) { + const ownIssues = issues.slice(startIndex); + return { + id, + status: skipped ? "not-applicable" : ownIssues.some((entry) => entry.severity === "error") ? "failed" : "passed", + issueCount: ownIssues.length, + }; +} + +function issue(issues, code, message, details = {}) { + issues.push({ severity: "error", code, message, ...details }); +} + +function readJsonRecords(rootPath, issues, code) { + if (!fs.existsSync(rootPath)) return []; + return fs.readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => { + const filePath = path.join(rootPath, entry.name); + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + issue(issues, code, `Artifact is not valid JSON: ${normalizeSlashPath(path.relative(rootPath, filePath))}: ${error.message}`); + return null; + } + }) + .filter(Boolean); +} + +function verifyReceipt(found, issues) { + const receiptPath = path.join(found.runRoot, "evidence-receipt.json"); + if (!fs.existsSync(receiptPath)) { + if (found.run.status === "finalized") { + issue(issues, "missing-evidence-receipt", "Finalized workflow run has no evidence receipt."); + } + return false; + } + let receipt; + try { + receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); + } catch (error) { + issue(issues, "malformed-evidence-receipt", `Evidence receipt is not valid JSON: ${error.message}`); + return true; + } + if (receipt.schemaVersion !== "evidence-receipt/v1" || receipt.runId !== found.run.runId) { + issue(issues, "incompatible-evidence-receipt", "Evidence receipt schema or run identity is incompatible."); + return true; + } + const files = receipt.integrity && receipt.integrity.files; + if (!Array.isArray(files)) { + issue(issues, "malformed-evidence-receipt", "Evidence receipt integrity inventory is missing."); + return true; + } + try { + const rebuilt = buildEvidenceReceipt(found, { generatedAt: receipt.generatedAt }); + if (JSON.stringify(rebuilt.integrity) !== JSON.stringify(receipt.integrity)) { + issue(issues, "receipt-integrity-inventory-mismatch", "Receipt integrity inventory does not match current canonical evidence."); + } + } catch (error) { + issue(issues, "receipt-integrity-rebuild-failed", `Could not rebuild canonical receipt integrity: ${error.message}`); + } + for (const entry of files) { + const filePath = typeof entry.path === "string" ? path.resolve(found.repoRoot, entry.path) : ""; + if (!filePath || !isInside(found.repoRoot, filePath) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + issue(issues, "receipt-integrity-missing", `Receipt evidence is unavailable: ${entry.path || "missing path"}.`, { path: entry.path || null }); + continue; + } + const actual = sha256(filePath); + const bytes = fs.statSync(filePath).size; + if (actual !== entry.sha256 || bytes !== entry.bytes) { + issue(issues, "receipt-integrity-mismatch", `Receipt evidence changed: ${entry.path}.`, { + path: entry.path, + expectedSha256: entry.sha256, + actualSha256: actual, + }); + } + } + return true; +} + +function verifyBoundWorktree(found, issues) { + const bindingPath = path.join(found.runRoot, "integration", "host-binding.json"); + if (!fs.existsSync(bindingPath)) return false; + let binding; + try { + binding = JSON.parse(fs.readFileSync(bindingPath, "utf8")); + } catch (error) { + issue(issues, "malformed-host-binding", `Host binding is not valid JSON: ${error.message}`); + return true; + } + const worktree = binding.references && binding.references.worktree; + if (worktree && typeof worktree.path === "string" && !fs.existsSync(path.resolve(found.repoRoot, worktree.path))) { + issue(issues, "stale-worktree", `Bound worktree is unavailable: ${normalizeSlashPath(worktree.path)}.`, { + worktreeId: worktree.id || null, + }); + } + return true; +} + +function verifyWorkflowRun(repoRoot, runId) { + const issues = []; + const checks = []; + let found; + let start = issues.length; + try { + found = loadWorkflowRun(repoRoot, runId); + } catch (error) { + issue(issues, "state-inconsistent", error.message); + } + checks.push(check("state-consistency", issues, start)); + if (!found) { + return { + schemaVersion: RUN_VERIFICATION_SCHEMA_VERSION, + runId, + status: "failed", + checks, + issues, + execution: { agentsExecuted: false, verificationCommandsExecuted: false }, + }; + } + + start = issues.length; + const eventsPath = path.join(found.runRoot, "events.jsonl"); + if (!fs.existsSync(eventsPath)) { + issue(issues, "missing-events", "Workflow event ledger is missing."); + } else { + const parsed = parseLifecycleEvents(fs.readFileSync(eventsPath, "utf8"), { runId }); + for (const entry of parsed.issues) issue(issues, entry.code, entry.message, { line: entry.line }); + } + checks.push(check("event-ledger", issues, start)); + + start = issues.length; + for (const task of found.run.tasks) { + if (task.resultId) { + const results = readJsonRecords(path.join(found.runRoot, "results", task.id), issues, "malformed-task-result"); + if (!results.some((entry) => entry.resultId === task.resultId)) { + issue(issues, "missing-task-result", `Task ${task.id} references result ${task.resultId}, but that result is missing.`, { taskId: task.id }); + } + } + if (task.activeCheckpointId) { + const checkpoints = readJsonRecords(path.join(found.runRoot, "checkpoints", task.id), issues, "malformed-checkpoint"); + if (!checkpoints.some((entry) => entry.checkpointId === task.activeCheckpointId)) { + issue(issues, "missing-checkpoint", `Task ${task.id} active checkpoint ${task.activeCheckpointId} is missing.`, { taskId: task.id }); + } + } + } + checks.push(check("required-artifacts", issues, start)); + + start = issues.length; + const hadBinding = verifyBoundWorktree(found, issues); + checks.push(check("worktree-liveness", issues, start, !hadBinding)); + + start = issues.length; + const hadReceipt = verifyReceipt(found, issues); + checks.push(check("receipt-integrity", issues, start, !hadReceipt)); + + return { + schemaVersion: RUN_VERIFICATION_SCHEMA_VERSION, + runId, + status: issues.some((entry) => entry.severity === "error") ? "failed" : "passed", + checks, + issues, + execution: { agentsExecuted: false, verificationCommandsExecuted: false }, + }; +} + +module.exports = { + RUN_VERIFICATION_SCHEMA_VERSION, + verifyWorkflowRun, +}; diff --git a/src/integration/binding.js b/src/integration/binding.js index a9027b4..7cbc46f 100644 --- a/src/integration/binding.js +++ b/src/integration/binding.js @@ -7,10 +7,17 @@ const { selectManagedBackend, validateCodexCapabilitySnapshot, } = require("./capabilities"); +const { + OWNERSHIP_SCHEMA_VERSION, + findOwnershipConflict, + loadOwnershipRecords, + validateOwnershipRecord, +} = require("../run/ownership"); const { writeJsonAtomic } = require("../workflow/state"); const HOST_BINDING_SCHEMA_VERSION = "host-binding/v1"; const GENERATED_GOAL_BRIEF_SCHEMA_VERSION = "generated-goal-brief/v1"; +const INTEGRATION_CONTROL_RECEIPT_SCHEMA_VERSION = "integration-control-receipt/v1"; const HOST_SURFACES = Object.freeze([ "chatgpt-desktop", "codex-cli", @@ -203,6 +210,13 @@ function validateHostBinding(value, found) { const controls = Object.fromEntries( CONTROL_CLASSES.map((name) => [name, normalizeStringList(value.controls[name], `controls.${name}`)]), ); + const classifiedControls = CONTROL_CLASSES.flatMap((name) => controls[name]); + if (new Set(classifiedControls).size !== classifiedControls.length) { + throw new Error("Invalid host binding: a control cannot appear in more than one control class."); + } + if (execution.owner === "audit-only" && controls.preventive.length > 0) { + throw new Error("Invalid host binding: audit-only execution cannot claim preventive enforcement."); + } return { schemaVersion: HOST_BINDING_SCHEMA_VERSION, @@ -255,6 +269,91 @@ function bindingPath(found) { return path.join(found.runRoot, "integration", "host-binding.json"); } +function bindingOwnershipPath(found) { + return path.join(found.runRoot, "integration", "ownership.json"); +} + +function controlReceiptPath(found) { + return path.join(found.runRoot, "integration", "control-receipt.json"); +} + +function buildIntegrationControlReceipt(binding) { + const classifications = { + preventive: "preventive", + postExecution: "post-execution", + imported: "imported", + unavailable: "unavailable", + }; + const effects = { + preventive: "prevented-before-execution", + postExecution: "checked-after-execution", + imported: "observed-not-enforced", + unavailable: "unavailable", + }; + const controls = CONTROL_CLASSES.flatMap((classification) => ( + binding.controls[classification].map((name) => ({ + name, + classification: classifications[classification], + effect: effects[classification], + })) + )); + return { + schemaVersion: INTEGRATION_CONTROL_RECEIPT_SCHEMA_VERSION, + generatedAt: binding.provenance.recordedAt, + workflow: binding.workflow, + execution: binding.execution, + provenance: { + kind: binding.provenance.kind, + authenticationBoundary: binding.provenance.authenticationBoundary, + }, + controls, + summary: { + preventiveEnforced: binding.controls.preventive.length, + postExecutionChecked: binding.controls.postExecution.length, + importedObserved: binding.controls.imported.length, + unavailable: binding.controls.unavailable.length, + }, + claims: { + observedEvidenceIsPreventiveEnforcement: false, + providerExecutionSuppliesEnforcement: false, + preventiveControlAuthority: binding.controls.preventive.length > 0 ? "cewp-core" : "none", + guardrailAuthority: "cewp-core-outside-provider-execution", + }, + warnings: binding.execution.owner === "audit-only" + ? ["Audit-only evidence can be imported or checked after execution; it is not preventive enforcement."] + : [], + }; +} + +function claimBindingWorktree(found, binding) { + if (!binding.references.worktree) return null; + if (!binding.workflow.taskId || !binding.workflow.checkpointId) { + throw new Error("Host binding worktree ownership requires a task and active checkpoint identity."); + } + const filePath = bindingOwnershipPath(found); + const requested = validateOwnershipRecord({ + schemaVersion: OWNERSHIP_SCHEMA_VERSION, + runId: binding.workflow.runId, + taskId: binding.workflow.taskId, + checkpointId: binding.workflow.checkpointId, + owner: binding.execution.owner, + backend: binding.execution.backend, + status: "active", + createdAt: binding.provenance.recordedAt, + cleanupAuthority: binding.execution.owner === "managed" ? "cewp-core" : "host-owner", + worktree: binding.references.worktree, + }); + const existing = loadOwnershipRecords(found.repoRoot, { excludePath: filePath }); + const conflict = findOwnershipConflict(existing, requested, { repoRoot: found.repoRoot }); + if (conflict) { + throw new Error( + `Host binding execution ownership conflict with ${conflict.owner} run ${conflict.runId} task ${conflict.taskId}.`, + ); + } + writeJsonAtomic(filePath, requested); + return requested; +} + function createHostBinding(found, candidate, options = {}) { const binding = validateHostBinding(candidate, found); if (!options.capabilities) throw new Error("Host binding requires a versioned capability snapshot."); @@ -264,7 +363,9 @@ function createHostBinding(found, candidate, options = {}) { throw new Error(`Host binding already exists for workflow run ${found.run.runId}.`); } fs.mkdirSync(path.dirname(filePath), { recursive: true }); + claimBindingWorktree(found, binding); writeJsonAtomic(filePath, binding); + writeJsonAtomic(controlReceiptPath(found), buildIntegrationControlReceipt(binding)); return binding; } @@ -274,6 +375,20 @@ function loadHostBinding(found) { return validateHostBinding(JSON.parse(fs.readFileSync(filePath, "utf8")), found); } +function loadIntegrationControlReceipt(found) { + const filePath = controlReceiptPath(found); + if (!fs.existsSync(filePath)) return null; + const receipt = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (receipt.schemaVersion !== INTEGRATION_CONTROL_RECEIPT_SCHEMA_VERSION) { + throw new Error(`Invalid integration control receipt: expected ${INTEGRATION_CONTROL_RECEIPT_SCHEMA_VERSION}.`); + } + const binding = loadHostBinding(found); + if (!binding || JSON.stringify(receipt) !== JSON.stringify(buildIntegrationControlReceipt(binding))) { + throw new Error("Invalid integration control receipt: receipt does not match the validated host binding."); + } + return receipt; +} + function createGeneratedGoalBrief(found, taskId) { if (found.run.execution.owner !== "native") { throw new Error("Generated native goal briefs require native execution ownership."); @@ -314,8 +429,11 @@ module.exports = { GENERATED_GOAL_BRIEF_SCHEMA_VERSION, HOST_BINDING_SCHEMA_VERSION, HOST_SURFACES, + INTEGRATION_CONTROL_RECEIPT_SCHEMA_VERSION, + buildIntegrationControlReceipt, createGeneratedGoalBrief, createHostBinding, + loadIntegrationControlReceipt, loadHostBinding, validateHostBinding, }; diff --git a/src/integration/cli.js b/src/integration/cli.js new file mode 100644 index 0000000..c209b77 --- /dev/null +++ b/src/integration/cli.js @@ -0,0 +1,85 @@ +"use strict"; + +const fs = require("node:fs"); +const { + approveCodexHookTrust, + inspectCodexHookTrust, + recordSubagentHookEvent, +} = require("./hook-evidence"); +const { loadIntegrationControlReceipt } = require("./binding"); +const { loadWorkflowRun } = require("../workflow/state"); + +function outputJson(command, data) { + console.log(JSON.stringify({ + schemaVersion: "operator-json/v1", + command, + generatedAt: new Date().toISOString(), + data, + warnings: [], + }, null, 2)); +} + +function runIntegration(options = {}) { + if (options.subcommand === "controls") { + if (!options.workflowRunId) throw new Error("integration controls requires a workflow run id."); + const found = loadWorkflowRun(process.cwd(), options.workflowRunId); + const result = loadIntegrationControlReceipt(found); + if (!result) throw new Error(`Integration control receipt not found for workflow run ${options.workflowRunId}.`); + if (options.json) outputJson("integration.controls", result); + else { + console.log("CEWP integration control receipt"); + console.log(`Run ID: ${result.workflow.runId}`); + console.log(`Owner: ${result.execution.owner}`); + console.log(`Preventive: ${result.summary.preventiveEnforced}`); + console.log(`Post-execution: ${result.summary.postExecutionChecked}`); + console.log(`Imported observations: ${result.summary.importedObserved}`); + console.log(`Unavailable: ${result.summary.unavailable}`); + } + return; + } + if (options.subcommand === "hooks" && options.action === "ingest") { + const raw = fs.readFileSync(0, "utf8"); + if (Buffer.byteLength(raw, "utf8") > 64 * 1024) { + throw new Error("Codex hook input exceeds 65536 bytes."); + } + const input = JSON.parse(raw || "{}"); + recordSubagentHookEvent({ input }); + console.log("{}"); + return; + } + + if (options.subcommand === "hooks" && options.action === "approve") { + if (!options.workflowRunId) throw new Error("integration hooks approve requires a workflow run id."); + const result = approveCodexHookTrust({ + repoRoot: process.cwd(), + runId: options.workflowRunId, + yes: options.yes, + }); + if (options.json) outputJson("integration.hooks.approve", result); + else { + console.log("CEWP Codex hook evidence approved"); + console.log(`Run ID: ${result.trust.runId}`); + console.log(`Bundle: ${result.trust.bundleDigest}`); + console.log("Next: open /hooks and review the current definition"); + } + return; + } + if (options.subcommand === "hooks" && options.action === "status") { + if (!options.workflowRunId) throw new Error("integration hooks status requires a workflow run id."); + const result = inspectCodexHookTrust({ + repoRoot: process.cwd(), + runId: options.workflowRunId, + }); + if (options.json) outputJson("integration.hooks.status", result); + else { + console.log("CEWP Codex hook evidence status"); + console.log(`Run ID: ${result.runId}`); + console.log(`Active: ${result.active ? "yes" : "no"}`); + result.warnings.forEach((warning) => console.log(`Warning: ${warning.code}: ${warning.message}`)); + } + return; + } + throw new Error(`Unsupported integration command: ${options.subcommand || "missing"}.`); +} + +module.exports = { runIntegration }; diff --git a/src/integration/effort-policy.js b/src/integration/effort-policy.js new file mode 100644 index 0000000..27b98d4 --- /dev/null +++ b/src/integration/effort-policy.js @@ -0,0 +1,265 @@ +"use strict"; + +const fs = require("node:fs"); +const crypto = require("node:crypto"); +const path = require("node:path"); +const { appendEvent, findSupervisedRun, getNextAction } = require("../supervise/state"); +const { writeJsonAtomic } = require("../workflow/state"); + +const CODEX_EFFORT_POLICY_SCHEMA_VERSION = "codex-effort-policy/v1"; +const CODEX_TASK_CLASSES = Object.freeze([ + "fast-exploration", + "demanding-implementation", + "high-effort-independent-review", +]); +const CODEX_EFFORT_OPERATIONS = Object.freeze(["implementation", "repair", "reviewer"]); +const CODEX_REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh"]); + +function requiredChoice(value, allowed, label) { + if (!allowed.includes(value)) { + throw new Error(`${label} must be one of: ${allowed.join(", ")}.`); + } + return value; +} + +function optionalSelection(value, label) { + if (value === undefined || value === null) return { status: "unknown", value: null }; + if (typeof value !== "string" || value.trim().length === 0 || value.length > 128 || /[\u0000-\u001f]/.test(value)) { + throw new Error(`${label} must be bounded non-empty text.`); + } + return { status: "explicit", value: value.trim() }; +} + +function effortPolicyPath(found) { + return path.join(found.runRoot, "integration", "codex-effort-policy.json"); +} + +function selectionDigest(operation, assignment) { + const content = JSON.stringify({ + operation, + workflow: assignment.workflow, + taskClass: assignment.taskClass, + model: assignment.requested.model, + effort: assignment.requested.effort, + }); + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function assignmentSnapshot(assignment) { + if (!assignment) return null; + return { + workflow: assignment.workflow, + taskClass: assignment.taskClass, + requested: assignment.requested, + approval: assignment.approval, + }; +} + +function validateAssignment(operation, assignment, found, options = {}) { + if (!assignment || typeof assignment !== "object") { + throw new Error(`Invalid Codex effort assignment for ${operation}.`); + } + requiredChoice(assignment.taskClass, CODEX_TASK_CLASSES, `${operation}.taskClass`); + if ( + !assignment.workflow + || !Number.isInteger(assignment.workflow.planRevision) + || assignment.workflow.planRevision <= 0 + || typeof assignment.workflow.checkpointId !== "string" + || assignment.workflow.checkpointId.length === 0 + ) { + throw new Error(`Invalid Codex effort workflow binding for ${operation}.`); + } + const model = optionalSelection( + assignment.requested && assignment.requested.model && assignment.requested.model.value, + `${operation}.requested.model`, + ); + const effortValue = assignment.requested && assignment.requested.effort && assignment.requested.effort.value; + if (effortValue !== null && effortValue !== undefined) { + requiredChoice(effortValue, CODEX_REASONING_EFFORTS, `${operation}.requested.effort`); + } + const effort = optionalSelection(effortValue, `${operation}.requested.effort`); + const normalized = { + ...assignment, + requested: { model, effort }, + }; + if ( + !assignment.approval + || assignment.approval.kind !== "operator" + || assignment.approval.selectionDigest !== selectionDigest(operation, normalized) + ) { + throw new Error(`Codex effort assignment for ${operation} is not operator-approved or was modified.`); + } + if ( + options.allowStale !== true + && ( + assignment.workflow.planRevision !== found.run.planRevision + || assignment.workflow.checkpointId !== found.run.tasks[0].id + ) + ) { + throw new Error(`Codex effort assignment for ${operation} was not approved for the current plan revision and checkpoint.`); + } + return normalized; +} + +function loadCodexEffortPolicy(found, options = {}) { + const filePath = effortPolicyPath(found); + if (!fs.existsSync(filePath)) return null; + const policy = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (policy.schemaVersion !== CODEX_EFFORT_POLICY_SCHEMA_VERSION || policy.runId !== found.runId) { + throw new Error(`Invalid Codex effort policy for run ${found.runId}.`); + } + if (policy.provider !== "codex" || policy.automaticModelRouting !== false) { + throw new Error("Invalid Codex effort policy provider or routing boundary."); + } + return { + ...policy, + assignments: Object.fromEntries(Object.entries(policy.assignments || {}).map(([operation, assignment]) => { + requiredChoice(operation, CODEX_EFFORT_OPERATIONS, "effort policy operation"); + return [operation, validateAssignment(operation, assignment, found, options)]; + })), + }; +} + +function approveCodexEffortPolicy(options = {}) { + if (!options.yes) { + throw new Error("Codex effort policy changes require explicit operator approval with --yes."); + } + const found = findSupervisedRun(options); + const operation = requiredChoice(options.operation, CODEX_EFFORT_OPERATIONS, "--operation"); + const taskClass = requiredChoice(options.taskClass, CODEX_TASK_CLASSES, "--task-class"); + if (options.effort !== undefined) { + requiredChoice(options.effort, CODEX_REASONING_EFFORTS, "--effort"); + } + const previous = loadCodexEffortPolicy(found, { allowStale: true }); + const revision = previous ? previous.revision + 1 : 1; + const approvedAt = new Date().toISOString(); + const assignment = { + workflow: { + planRevision: found.run.planRevision, + checkpointId: found.run.tasks[0].id, + }, + taskClass, + requested: { + model: optionalSelection(options.model, "--model"), + effort: optionalSelection(options.effort, "--effort"), + }, + approval: { + kind: "operator", + event: "codex-effort-policy-approved", + revision, + approvedAt, + selectionDigest: null, + }, + }; + assignment.approval.selectionDigest = selectionDigest(operation, assignment); + const previousAssignment = previous ? previous.assignments[operation] || null : null; + const policy = { + schemaVersion: CODEX_EFFORT_POLICY_SCHEMA_VERSION, + runId: found.runId, + provider: "codex", + automaticModelRouting: false, + revision, + updatedAt: approvedAt, + assignments: { + ...(previous ? previous.assignments : {}), + [operation]: assignment, + }, + history: [ + ...(previous ? previous.history : []), + { + revision, + operation, + taskClass, + approvedAt, + previousRevision: previous ? previous.revision : null, + previous: assignmentSnapshot(previousAssignment), + next: assignmentSnapshot(assignment), + }, + ], + }; + const filePath = effortPolicyPath(found); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeJsonAtomic(filePath, policy); + appendEvent(found.runRoot, { + schemaVersion: "supervised-event/v1-beta", + timestamp: approvedAt, + type: "codex-effort-policy-approved", + runId: found.runId, + planRevision: found.run.planRevision, + checkpointId: found.run.tasks[0].id, + actor: "operator", + operation, + revision, + selectionDigest: assignment.approval.selectionDigest, + }); + return { + run: found.run, + effortPolicy: policy, + nextAction: getNextAction(found.run), + }; +} + +function resolveCodexEffortForDispatch(found, operation) { + requiredChoice(operation, CODEX_EFFORT_OPERATIONS, "Codex dispatch operation"); + const policy = loadCodexEffortPolicy(found); + const assignment = policy && policy.assignments[operation]; + if (!assignment) { + return { + model: undefined, + effort: undefined, + evidence: { + taskClass: null, + policyRevision: policy ? policy.revision : null, + selectedModel: { status: "unknown", value: null }, + selectedEffort: { status: "unknown", value: null }, + effectiveModel: { status: "unknown", value: null, source: "not-explicitly-selected" }, + effectiveEffort: { status: "unknown", value: null, source: "not-explicitly-selected" }, + }, + }; + } + const model = assignment.requested.model.value || undefined; + const effort = assignment.requested.effort.value || undefined; + return { + model, + effort, + evidence: { + taskClass: assignment.taskClass, + policyRevision: policy.revision, + selectedModel: assignment.requested.model, + selectedEffort: assignment.requested.effort, + effectiveModel: { status: "unknown", value: null, source: model ? "awaiting-supported-turn-evidence" : "not-explicitly-selected" }, + effectiveEffort: { status: "unknown", value: null, source: effort ? "awaiting-supported-turn-evidence" : "not-explicitly-selected" }, + }, + }; +} + +function confirmCodexEffortEvidence(evidence, usage) { + const confirmed = usage && usage.label === "observed"; + const effective = (selected) => ( + confirmed && selected && selected.status === "explicit" + ? { status: "known", value: selected.value, source: "codex-exec-turn-completed-usage" } + : { + status: "unknown", + value: null, + source: selected && selected.status === "explicit" + ? "supported-turn-evidence-unavailable" + : "not-explicitly-selected", + } + ); + return { + ...evidence, + effectiveModel: effective(evidence.selectedModel), + effectiveEffort: effective(evidence.selectedEffort), + }; +} + +module.exports = { + CODEX_EFFORT_OPERATIONS, + CODEX_EFFORT_POLICY_SCHEMA_VERSION, + CODEX_REASONING_EFFORTS, + CODEX_TASK_CLASSES, + approveCodexEffortPolicy, + confirmCodexEffortEvidence, + loadCodexEffortPolicy, + resolveCodexEffortForDispatch, +}; diff --git a/src/integration/hook-evidence.js b/src/integration/hook-evidence.js new file mode 100644 index 0000000..0ca75c2 --- /dev/null +++ b/src/integration/hook-evidence.js @@ -0,0 +1,402 @@ +"use strict"; + +const childProcess = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { appendLifecycleEvent } = require("../evidence/events"); +const { loadWorkflowRun } = require("../workflow/state"); +const { writeJsonAtomic } = require("../workflow/state"); + +const CODEX_HOOK_TRUST_SCHEMA_VERSION = "codex-hook-trust/v1"; +const CODEX_HOOK_CONTRACT_VERSION = "codex-hooks-doc/2026-07-22"; +const HOOK_BUNDLE_FILES = Object.freeze([ + "hooks/hooks.json", + "hooks/capture-subagent.js", +]); +const SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION = "subagent-hook-evidence/v1"; +const SUBAGENT_HOOK_EVENTS = Object.freeze({ + SubagentStart: "subagent-started", + SubagentStop: "subagent-stopped", +}); + +function sha256(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function pluginRoot() { + if (process.env.PLUGIN_ROOT) return path.resolve(process.env.PLUGIN_ROOT); + return path.resolve(__dirname, "..", "..", "plugins", "cewp"); +} + +function inspectHookBundle(root = pluginRoot()) { + const files = HOOK_BUNDLE_FILES.map((relativePath) => { + const filePath = path.join(root, ...relativePath.split("/")); + if (!fs.existsSync(filePath)) throw new Error(`Codex hook bundle file is missing: ${relativePath}.`); + const content = fs.readFileSync(filePath); + return { path: relativePath, digest: sha256(content), content }; + }); + const bundle = Buffer.concat(files.flatMap((file) => [ + Buffer.from(`${file.path}\0`, "utf8"), + file.content, + Buffer.from("\0", "utf8"), + ])); + return { + digest: sha256(bundle), + files: files.map(({ path: relativePath, digest }) => ({ path: relativePath, digest })), + }; +} + +function detectCodexVersion(options = {}) { + if (process.env.CEWP_HOOK_CODEX_VERSION) return process.env.CEWP_HOOK_CODEX_VERSION.trim(); + const command = options.command || "codex"; + const result = childProcess.spawnSync(command, ["--version"], { + encoding: "utf8", + windowsHide: true, + timeout: 10000, + }); + if (result.status !== 0 || !String(result.stdout || "").trim()) { + throw new Error("Codex hook approval requires a successful local `codex --version` probe."); + } + return String(result.stdout).trim(); +} + +function detectCewpVersion() { + const packagePath = path.resolve(__dirname, "..", "..", "package.json"); + if (!fs.existsSync(packagePath)) { + throw new Error("Codex hook evidence requires the complete CEWP package, including package.json."); + } + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + if (typeof packageJson.version !== "string" || packageJson.version.trim().length === 0) { + throw new Error("Codex hook evidence could not determine the CEWP runtime version."); + } + return packageJson.version.trim(); +} + +function trustPath(found) { + return path.join(found.runRoot, "integration", "codex-hook-trust.json"); +} + +function activationPath(repoRoot) { + return path.join(repoRoot, ".cewp", "integration", "active-hook-trust.json"); +} + +function boundedText(value, label, maximum, optional = false) { + if (optional && (value === null || value === undefined)) return null; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Invalid Codex subagent hook event: ${label} is required.`); + } + const text = value.trim(); + if (text.length > maximum || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text)) { + throw new Error(`Invalid Codex subagent hook event: ${label} is too long or contains control characters.`); + } + return text; +} + +function findActivatedRepoRoot(startPath) { + let current = path.resolve(startPath); + while (true) { + if (fs.existsSync(activationPath(current))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function validateActiveTrust(repoRoot, currentCodexVersion, root = pluginRoot(), currentCewpVersion) { + const activePath = activationPath(repoRoot); + if (!fs.existsSync(activePath)) return null; + const active = JSON.parse(fs.readFileSync(activePath, "utf8")); + if (active.schemaVersion !== "codex-hook-activation/v1") { + throw new Error("Codex hook evidence activation is malformed; approve the hook bundle again."); + } + const found = loadWorkflowRun(repoRoot, active.runId); + const filePath = trustPath(found); + if (!fs.existsSync(filePath)) throw new Error("Codex hook trust receipt is missing; approve the hook bundle again."); + const trust = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (trust.schemaVersion !== CODEX_HOOK_TRUST_SCHEMA_VERSION || trust.provider !== "codex") { + throw new Error("Codex hook trust receipt is malformed; approve the hook bundle again."); + } + const selection = { + runId: trust.runId, + workflowRevision: trust.workflowRevision, + workflowDigest: trust.workflowDigest, + cewpVersion: trust.cewpVersion, + codexVersion: trust.codexVersion, + hookContractVersion: trust.hookContractVersion, + bundleDigest: trust.bundleDigest, + }; + const approvalDigest = sha256(JSON.stringify(selection)); + if ( + !trust.approval + || trust.approval.kind !== "operator" + || trust.approval.digest !== approvalDigest + || active.trustDigest !== approvalDigest + ) { + throw new Error("Codex hook trust approval digest changed; review and approve the hook bundle again."); + } + const currentBundle = inspectHookBundle(root); + if (trust.bundleDigest !== currentBundle.digest) { + throw new Error("Codex hook definition drift detected; review and approve the current bundle again."); + } + if (trust.codexVersion !== currentCodexVersion) { + throw new Error(`Codex hook version drift detected: approved ${trust.codexVersion}, observed ${currentCodexVersion}.`); + } + const observedCewpVersion = currentCewpVersion || detectCewpVersion(); + if (trust.cewpVersion !== observedCewpVersion) { + throw new Error(`CEWP hook runtime drift detected: approved ${trust.cewpVersion}, observed ${observedCewpVersion}.`); + } + if (trust.hookContractVersion !== CODEX_HOOK_CONTRACT_VERSION) { + throw new Error("Codex hook contract drift detected; review and approve the current contract again."); + } + if ( + trust.runId !== found.run.runId + || trust.workflowRevision !== found.run.workflow.revision + || trust.workflowDigest !== found.run.workflow.digest + ) { + throw new Error("Codex hook trust is stale for the current workflow revision."); + } + return { active, found, trust }; +} + +function recordSubagentHookEvent(options = {}) { + const input = options.input; + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Invalid Codex subagent hook event: expected one JSON object."); + } + const eventCwd = boundedText(input.cwd, "cwd", 4096); + const repoRoot = findActivatedRepoRoot(options.repoRoot || eventCwd); + if (!repoRoot) return { recorded: false, reason: "not-activated" }; + const resolvedCwd = path.resolve(eventCwd); + const relativeCwd = path.relative(repoRoot, resolvedCwd); + if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { + throw new Error("Invalid Codex subagent hook event: cwd is outside the activated repository."); + } + const currentCodexVersion = options.codexVersion || detectCodexVersion(options); + const active = validateActiveTrust(repoRoot, currentCodexVersion, options.pluginRoot, options.cewpVersion); + if (!active) return { recorded: false, reason: "not-activated" }; + const hookEventName = boundedText(input.hook_event_name, "hook_event_name", 64); + const type = SUBAGENT_HOOK_EVENTS[hookEventName]; + if (!type) throw new Error(`Invalid Codex subagent hook event: unsupported event ${hookEventName}.`); + const agentId = boundedText(input.agent_id, "agent_id", 512); + const parentSessionId = boundedText(input.session_id, "session_id", 512); + const parentTurnId = boundedText(input.turn_id, "turn_id", 512); + const agentType = boundedText(input.agent_type, "agent_type", 128); + const model = boundedText(input.model, "model", 256); + const permissionMode = boundedText(input.permission_mode, "permission_mode", 64); + const summaryValue = hookEventName === "SubagentStop" + ? boundedText(input.last_assistant_message, "last_assistant_message", 4000, true) + : null; + const observedAt = (options.now || new Date()).toISOString(); + const evidence = { + schemaVersion: SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION, + eventId: sha256(JSON.stringify({ + runId: active.found.run.runId, + hookEventName, + parentSessionId, + parentTurnId, + agentId, + observedAt, + })), + type, + observedAt, + workflow: { + runId: active.found.run.runId, + revision: active.found.run.workflow.revision, + digest: active.found.run.workflow.digest, + }, + source: { + path: "plugin-hook", + evidenceClass: "observed", + codexVersion: currentCodexVersion, + cewpVersion: active.trust.cewpVersion, + hookContractVersion: CODEX_HOOK_CONTRACT_VERSION, + bundleDigest: active.trust.bundleDigest, + trustApprovalDigest: active.trust.approval.digest, + }, + references: { + agentId, + agentType, + parentSessionId, + parentTurnId, + agentThreadId: { status: "unknown", value: null, reason: "not-exposed-by-documented-hook-input" }, + }, + context: { + model, + permissionMode, + workingDirectory: path.relative(repoRoot, resolvedCwd).replace(/\\/g, "/") || ".", + }, + summary: summaryValue + ? { status: "observed", value: summaryValue } + : { status: "unknown", value: null }, + claims: { + coreEnforcement: false, + opensCoreGates: false, + transcriptRead: false, + }, + }; + const ledgerPath = path.join(active.found.runRoot, "integration", "subagent-hook-evidence.jsonl"); + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.appendFileSync(ledgerPath, `${JSON.stringify(evidence)}\n`); + return { recorded: true, evidence }; +} + +function approveCodexHookTrust(options = {}) { + if (!options.yes) { + throw new Error("Codex hook evidence requires explicit operator approval with --yes after reviewing the bundle and `/hooks`."); + } + const found = loadWorkflowRun(options.repoRoot || process.cwd(), options.runId); + const bundle = inspectHookBundle(options.pluginRoot); + const codexVersion = options.codexVersion || detectCodexVersion(options); + const approvedAt = (options.now || new Date()).toISOString(); + const selection = { + runId: found.run.runId, + workflowRevision: found.run.workflow.revision, + workflowDigest: found.run.workflow.digest, + cewpVersion: options.cewpVersion || detectCewpVersion(), + codexVersion, + hookContractVersion: CODEX_HOOK_CONTRACT_VERSION, + bundleDigest: bundle.digest, + }; + const trust = { + schemaVersion: CODEX_HOOK_TRUST_SCHEMA_VERSION, + provider: "codex", + ...selection, + bundleFiles: bundle.files, + approvedAt, + approval: { + kind: "operator", + digest: sha256(JSON.stringify(selection)), + }, + hostTrust: { + required: true, + status: "pending-review", + reviewCommand: "/hooks", + }, + provenance: { + source: "official-documentation", + url: "https://learn.chatgpt.com/docs/hooks", + }, + claims: { + coreEnforcement: false, + opensCoreGates: false, + transcriptRead: false, + }, + }; + const filePath = trustPath(found); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeJsonAtomic(filePath, trust); + const activePath = activationPath(found.repoRoot); + fs.mkdirSync(path.dirname(activePath), { recursive: true }); + writeJsonAtomic(activePath, { + schemaVersion: "codex-hook-activation/v1", + runId: found.run.runId, + trustDigest: trust.approval.digest, + activatedAt: approvedAt, + }); + appendLifecycleEvent(found.runRoot, { + timestamp: approvedAt, + type: "hook-evidence-approved", + runId: found.run.runId, + revision: found.run.workflow.revision, + actor: "operator", + approvalDigest: trust.approval.digest, + }); + return { + trust, + nextAction: { + kind: "host-hook-review", + command: "/hooks", + reason: "Codex separately reviews and trusts the exact current plugin hook definition.", + }, + }; +} + +function classifyCompatibilityWarning(error) { + const message = error && error.message ? error.message : String(error); + const code = /Codex hook version drift/.test(message) + ? "codex-version-drift" + : /definition drift/.test(message) + ? "hook-definition-drift" + : /runtime drift/.test(message) + ? "cewp-version-drift" + : /contract drift/.test(message) + ? "hook-contract-drift" + : /workflow revision|not active/.test(message) + ? "workflow-binding-drift" + : /approval digest|trust receipt/.test(message) + ? "hook-trust-change" + : "hook-evidence-unavailable"; + return { code, message }; +} + +function inspectCodexHookTrust(options = {}) { + const repoRoot = path.resolve(options.repoRoot || process.cwd()); + const found = loadWorkflowRun(repoRoot, options.runId); + const filePath = trustPath(found); + const claims = { + coreEnforcement: false, + opensCoreGates: false, + hostTrustInferred: false, + }; + if (!fs.existsSync(filePath)) { + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: false, + active: false, + trust: null, + warnings: [{ code: "hook-evidence-not-approved", message: "No approved hook evidence bundle exists for this run." }], + fallback: "core-and-conversation-only", + claims, + }; + } + let trust = null; + try { + try { + trust = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error(`Codex hook trust receipt is malformed: ${error.message}`); + } + const currentCodexVersion = options.codexVersion || detectCodexVersion(options); + const active = validateActiveTrust(repoRoot, currentCodexVersion, options.pluginRoot, options.cewpVersion); + if (!active || active.found.run.runId !== found.run.runId) { + throw new Error("Codex hook trust is not active for the requested workflow run."); + } + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: true, + active: true, + trust, + warnings: [], + fallback: null, + claims, + }; + } catch (error) { + return { + schemaVersion: "codex-hook-status/v1", + runId: found.run.runId, + compatible: false, + active: false, + trust, + warnings: [classifyCompatibilityWarning(error)], + fallback: "core-and-conversation-only", + claims, + }; + } +} + +module.exports = { + CODEX_HOOK_CONTRACT_VERSION, + CODEX_HOOK_TRUST_SCHEMA_VERSION, + SUBAGENT_HOOK_EVIDENCE_SCHEMA_VERSION, + approveCodexHookTrust, + detectCewpVersion, + detectCodexVersion, + findActivatedRepoRoot, + inspectCodexHookTrust, + inspectHookBundle, + recordSubagentHookEvent, + validateActiveTrust, +}; diff --git a/src/integration/observation.js b/src/integration/observation.js index 99e3f15..40b7fbf 100644 --- a/src/integration/observation.js +++ b/src/integration/observation.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const { appendLifecycleEvent } = require("../evidence/events"); const { validateCodexCapabilitySnapshot } = require("./capabilities"); const HOST_OBSERVATION_SCHEMA_VERSION = "host-observation/v1"; @@ -353,6 +354,16 @@ function recordHostObservation(found, candidate, options = {}) { const filePath = ledgerPath(found); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.appendFileSync(filePath, `${JSON.stringify(observation)}\n`); + appendLifecycleEvent(found.runRoot, { + timestamp: observation.observedAt, + type: "usage-observed", + runId: found.run.runId, + observationId: observation.observationId, + usageCategory: observation.category, + availability: observation.availability, + evidenceClass: observation.evidenceClass, + actor: observation.source.path, + }); return observation; } diff --git a/src/mcp/server.js b/src/mcp/server.js new file mode 100644 index 0000000..aeb9f2a --- /dev/null +++ b/src/mcp/server.js @@ -0,0 +1,115 @@ +"use strict"; + +const readline = require("node:readline"); +const { TOOLS, callTool } = require("./tools"); + +const PROTOCOL_VERSIONS = new Set(["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]); + +function packageVersion() { + try { + return require("../../package.json").version; + } catch { + return "unknown"; + } +} + +function response(id, result) { + return { jsonrpc: "2.0", id, result }; +} + +function rpcError(id, code, message) { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +function toolResult(value) { + return { + content: [{ type: "text", text: JSON.stringify(value) }], + structuredContent: value, + isError: false, + }; +} + +function toolError(error) { + return { + content: [{ type: "text", text: error && error.message ? error.message : String(error) }], + isError: true, + }; +} + +function createMcpSession(options = {}) { + const repoRoot = options.repoRoot || process.cwd(); + let initialized = false; + + function handle(message) { + if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") { + return rpcError(message && message.id !== undefined ? message.id : null, -32600, "Invalid JSON-RPC request."); + } + if (message.method === "notifications/initialized" || message.method.startsWith("notifications/")) { + return null; + } + if (message.method === "initialize") { + const requested = message.params && message.params.protocolVersion; + const compatible = PROTOCOL_VERSIONS.has(requested); + const protocolVersion = compatible ? requested : "2025-11-25"; + initialized = true; + return response(message.id, { + protocolVersion, + capabilities: { tools: { listChanged: false } }, + serverInfo: { + name: "cewp-local-core", + version: packageVersion(), + description: "Local CEWP Core tools with supervised approval and evidence gates.", + }, + instructions: "Inspect before mutating. Explicit confirmation does not bypass CEWP Core state, ownership, scope, policy, budget, verification, or reviewer gates.", + compatibility: compatible + ? { compatible: true, requestedProtocolVersion: requested, fallback: null, warning: null } + : { + compatible: false, + requestedProtocolVersion: requested || null, + fallback: "cewp-cli-operator-json", + warning: { + code: "mcp-protocol-version-drift", + message: `Requested MCP protocol ${requested || "unknown"} is unsupported; negotiated ${protocolVersion}. Disconnect or use the CEWP CLI operator JSON fallback.`, + }, + }, + }); + } + if (!initialized) return rpcError(message.id, -32002, "MCP session is not initialized."); + if (message.method === "ping") return response(message.id, {}); + if (message.method === "tools/list") return response(message.id, { tools: TOOLS }); + if (message.method === "tools/call") { + const params = message.params; + if (!params || typeof params.name !== "string") { + return rpcError(message.id, -32602, "tools/call requires a tool name."); + } + try { + return response(message.id, toolResult(callTool(params.name, params.arguments || {}, { repoRoot }))); + } catch (error) { + if (error && error.code === -32602) return rpcError(message.id, -32602, error.message); + return response(message.id, toolError(error)); + } + } + return rpcError(message.id, -32601, `Method not found: ${message.method}`); + } + + return { handle }; +} + +function runStdio(options = {}) { + const session = createMcpSession(options); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + lines.on("line", (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + process.stdout.write(`${JSON.stringify(rpcError(null, -32700, "Parse error."))}\n`); + return; + } + const result = session.handle(message); + if (result) process.stdout.write(`${JSON.stringify(result)}\n`); + }); +} + +module.exports = { createMcpSession, runStdio }; diff --git a/src/mcp/tools.js b/src/mcp/tools.js new file mode 100644 index 0000000..08e8032 --- /dev/null +++ b/src/mcp/tools.js @@ -0,0 +1,188 @@ +"use strict"; + +const { + approveSupervisedRun, + createProposedRun, + inspectSupervisedRun, +} = require("../supervise/state"); +const { retrySupervisedCheckpoint } = require("../supervise/execution"); +const { verifySupervisedCheckpoint } = require("../supervise/verification"); +const { finalizeSupervisedRun } = require("../supervise/receipt"); +const { runSupervisedControl } = require("../supervise/controls"); + +const stringArray = { type: "array", items: { type: "string" } }; +const runId = { type: "string", minLength: 1, description: "CEWP supervised run ID." }; +const confirm = { + type: "boolean", + description: "Explicit operator confirmation after reviewing the relevant CEWP evidence.", +}; + +function objectSchema(properties, required = []) { + return { type: "object", properties, required, additionalProperties: false }; +} + +const TOOLS = [ + { + name: "cewp_create", + title: "Create CEWP run", + description: "Create a proposed bounded supervised run in the current repository. Does not approve or execute it.", + inputSchema: objectSchema({ + goal: { type: "string", minLength: 1 }, + scopes: stringArray, + verificationCommands: stringArray, + fullVerificationCommands: stringArray, + stoppingConditions: stringArray, + assurance: { type: "string", enum: ["prototype", "standard", "critical"] }, + testAuthoring: { type: "string", enum: ["auto", "ask", "never"] }, + }, ["goal", "scopes", "verificationCommands", "stoppingConditions"]), + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_inspect", + title: "Inspect CEWP run", + description: "Inspect canonical state and the next allowed action, refreshing the derived progress file.", + inputSchema: objectSchema({ runId }, ["runId"]), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + { + name: "cewp_approve", + title: "Approve CEWP run", + description: "Approve the current proposed plan revision after explicit operator confirmation. Does not execute it.", + inputSchema: objectSchema({ runId, confirm, allowTestAuthoring: { type: "boolean" } }, ["runId", "confirm"]), + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_continue", + title: "Continue CEWP run", + description: "Record operator continuation only after the Core confirms a verified checkpoint.", + inputSchema: objectSchema({ runId }, ["runId"]), + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_retry", + title: "Retry CEWP checkpoint", + description: "Dispatch one bounded managed repair, subject to ownership, policy, effort, scope, and budget gates.", + inputSchema: objectSchema({ runId, confirm, timeoutSeconds: { type: "integer", minimum: 1 } }, ["runId", "confirm"]), + annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_revise", + title: "Revise CEWP plan", + description: "Revise an allowed unstarted or completed checkpoint and invalidate prior approval as required by Core.", + inputSchema: objectSchema({ + runId, + goal: { type: "string", minLength: 1 }, + scopes: stringArray, + verificationCommands: stringArray, + fullVerificationCommands: stringArray, + stoppingConditions: stringArray, + }, ["runId"]), + annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_verify", + title: "Verify CEWP checkpoint", + description: "Run the approved verification schedule in the managed worktree under Core policy and budget gates.", + inputSchema: objectSchema({ runId, timeoutSeconds: { type: "integer", minimum: 1 } }, ["runId"]), + annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + { + name: "cewp_finalize", + title: "Finalize CEWP run", + description: "Finalize only after receipt preview, verified evidence, current worktree gates, and independent reviewer PASS.", + inputSchema: objectSchema({ runId, confirm }, ["runId", "confirm"]), + annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, +]; + +const definitions = new Map(TOOLS.map((tool) => [tool.name, tool])); + +function validateArguments(name, args) { + const definition = definitions.get(name); + if (!definition) { + const error = new Error(`Unknown tool: ${name}`); + error.code = -32602; + throw error; + } + if (!args || typeof args !== "object" || Array.isArray(args)) { + const error = new Error(`Tool ${name} arguments must be an object.`); + error.code = -32602; + throw error; + } + const allowed = new Set(Object.keys(definition.inputSchema.properties)); + const unknown = Object.keys(args).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + const error = new Error(`Tool ${name} received unknown arguments: ${unknown.join(", ")}.`); + error.code = -32602; + throw error; + } + for (const required of definition.inputSchema.required || []) { + if (!(required in args)) { + const error = new Error(`Tool ${name} requires argument ${required}.`); + error.code = -32602; + throw error; + } + } + for (const [key, value] of Object.entries(args)) { + const schema = definition.inputSchema.properties[key]; + const valid = schema.type === "array" + ? Array.isArray(value) && value.every((entry) => typeof entry === "string") + : schema.type === "string" + ? typeof value === "string" && (!schema.minLength || value.length >= schema.minLength) + : schema.type === "boolean" + ? typeof value === "boolean" + : schema.type === "integer" + ? Number.isInteger(value) && (!schema.minimum || value >= schema.minimum) + : true; + if (!valid || (schema.enum && !schema.enum.includes(value))) { + const error = new Error(`Tool ${name} argument ${key} does not match its input schema.`); + error.code = -32602; + throw error; + } + } +} + +function requireConfirmation(name, args) { + if (args.confirm !== true) { + throw new Error(`${name} requires explicit confirmation after reviewing CEWP state and evidence.`); + } +} + +function coreOptions(repoRoot, args) { + return { + ...args, + repoRoot, + scopes: Array.isArray(args.scopes) ? args.scopes : [], + verificationCommands: Array.isArray(args.verificationCommands) ? args.verificationCommands : [], + fullVerificationCommands: Array.isArray(args.fullVerificationCommands) ? args.fullVerificationCommands : [], + stoppingConditions: Array.isArray(args.stoppingConditions) ? args.stoppingConditions : [], + }; +} + +function callTool(name, args, options = {}) { + validateArguments(name, args); + const repoRoot = options.repoRoot || process.cwd(); + const core = coreOptions(repoRoot, args); + if (name === "cewp_create") return createProposedRun(core); + if (name === "cewp_inspect") return inspectSupervisedRun(core); + if (name === "cewp_approve") { + requireConfirmation("approve", args); + return approveSupervisedRun({ ...core, yes: true }); + } + if (name === "cewp_continue") { + return runSupervisedControl({ ...core, subcommand: "continue" }); + } + if (name === "cewp_retry") { + requireConfirmation("retry", args); + return retrySupervisedCheckpoint({ ...core, yes: true }); + } + if (name === "cewp_revise") return runSupervisedControl({ ...core, subcommand: "revise" }); + if (name === "cewp_verify") return verifySupervisedCheckpoint(core); + if (name === "cewp_finalize") { + requireConfirmation("finalize", args); + return finalizeSupervisedRun({ ...core, yes: true }); + } + throw new Error(`Unknown tool: ${name}`); +} + +module.exports = { TOOLS, callTool }; diff --git a/src/run/adapters/codex-exec.js b/src/run/adapters/codex-exec.js index 2e91d81..b5d3ec8 100644 --- a/src/run/adapters/codex-exec.js +++ b/src/run/adapters/codex-exec.js @@ -198,14 +198,20 @@ function buildCodexExecInvocation({ outputLastMessagePath, sandbox = "workspace-write", structuredJson = false, + model, + effort, }) { const formatArgs = structuredJson ? ["--json"] : []; + const modelArgs = model ? ["--model", model] : []; + const effortArgs = effort ? ["--config", `model_reasoning_effort="${effort}"`] : []; return { command: command || "codex", args: [ ...prefixArgs, "exec", ...formatArgs, + ...modelArgs, + ...effortArgs, "--cd", worktreePath, "--sandbox", @@ -218,7 +224,7 @@ function buildCodexExecInvocation({ }; } -function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, timeoutSeconds, sandbox = "workspace-write", structuredJson = false }) { +function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, timeoutSeconds, sandbox = "workspace-write", structuredJson = false, model, effort }) { validateTimeoutSeconds(timeoutSeconds); const prompt = fs.readFileSync(promptPath, "utf8"); const invocation = buildCodexExecInvocation({ @@ -229,6 +235,8 @@ function runCodexExecAdapter({ worktreePath, promptPath, outputLastMessagePath, outputLastMessagePath, sandbox, structuredJson, + model, + effort, }); return childProcess.spawnSync(invocation.command, invocation.args, { diff --git a/src/run/ownership.js b/src/run/ownership.js index ebf7846..9bd88a2 100644 --- a/src/run/ownership.js +++ b/src/run/ownership.js @@ -1,5 +1,6 @@ "use strict"; +const fs = require("node:fs"); const path = require("node:path"); const { normalizeComparePath } = require("../lib/paths"); @@ -76,11 +77,49 @@ function findOwnershipConflict(records, requested, options = {}) { return undefined; } +function loadOwnershipRecords(repoRoot, options = {}) { + const recordsRoot = path.join(repoRoot, ".cewp"); + if (!fs.existsSync(recordsRoot)) return []; + const excludedPath = options.excludePath + ? normalizeComparePath(path.resolve(options.excludePath)) + : null; + const records = []; + const pending = [recordsRoot]; + + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + pending.push(entryPath); + continue; + } + if ( + !entry.isFile() + || entry.name !== "ownership.json" + || normalizeComparePath(path.resolve(entryPath)) === excludedPath + ) { + continue; + } + let record; + try { + record = JSON.parse(fs.readFileSync(entryPath, "utf8")); + } catch (error) { + throw new Error(`Invalid execution ownership registry entry ${entryPath}: ${error.message}`); + } + records.push(validateOwnershipRecord(record)); + } + } + + return records; +} + module.exports = { EXECUTION_OWNERS, MANAGED_BACKENDS, OWNERSHIP_SCHEMA_VERSION, findOwnershipConflict, + loadOwnershipRecords, normalizeWorktreePath, validateOwnershipRecord, }; diff --git a/src/run/verify.js b/src/run/verify.js new file mode 100644 index 0000000..4b4ec55 --- /dev/null +++ b/src/run/verify.js @@ -0,0 +1,21 @@ +"use strict"; + +const { verifyWorkflowRun } = require("../evidence/verify"); + +function runVerify(options = {}) { + if (!options.runId) throw new Error("run verify requires a workflow run id."); + const report = verifyWorkflowRun(process.cwd(), options.runId); + if (options.json) { + console.log(JSON.stringify({ schemaVersion: "operator-json/v1", command: "run.verify", data: report }, null, 2)); + } else { + console.log("CEWP run verification"); + console.log(`Run ID: ${report.runId}`); + console.log(`Status: ${report.status}`); + for (const entry of report.checks) console.log(`${entry.id}: ${entry.status}`); + for (const entry of report.issues) console.log(`[${entry.code}] ${entry.message}`); + } + if (report.status !== "passed") process.exitCode = 1; + return report; +} + +module.exports = { runVerify }; diff --git a/src/supervise/cli.js b/src/supervise/cli.js index cea23e3..f8811f4 100644 --- a/src/supervise/cli.js +++ b/src/supervise/cli.js @@ -10,6 +10,7 @@ const { verifySupervisedCheckpoint } = require("./verification"); const { reviewSupervisedCheckpoint } = require("./review"); const { finalizeSupervisedRun, previewSupervisedReceipt } = require("./receipt"); const { runSupervisedControl } = require("./controls"); +const { approveCodexEffortPolicy } = require("../integration/effort-policy"); const CONTROL_COMMANDS = new Set([ "revise", "pause", "resume", "add-budget", "rollback", "cancel", "abandon", "block", "continue", "reassign", @@ -99,6 +100,19 @@ function runSupervise(options = {}) { return; } + if (options.subcommand === "effort") { + const result = approveCodexEffortPolicy({ + ...options, + repoRoot: process.cwd(), + }); + if (options.json) { + outputJson("supervise.effort", result); + } else { + printStatus("CEWP Codex effort policy approved", result); + } + return; + } + if (options.subcommand === "execute") { const result = executeSupervisedCheckpoint({ ...options, diff --git a/src/supervise/execution.js b/src/supervise/execution.js index 3381da7..0320ad1 100644 --- a/src/supervise/execution.js +++ b/src/supervise/execution.js @@ -23,8 +23,13 @@ const { } = require("../run/adapters/codex-exec"); const { OWNERSHIP_SCHEMA_VERSION, + loadOwnershipRecords, validateOwnershipRecord, } = require("../run/ownership"); +const { + confirmCodexEffortEvidence, + resolveCodexEffortForDispatch, +} = require("../integration/effort-policy"); const { appendEvent, findSupervisedRun, @@ -210,27 +215,7 @@ function createOwnedWorktree(found, startedAt) { if (continued) return continued; const paths = getWorktreePaths(found); - if (fs.existsSync(paths.worktreePath)) { - throw new Error(`Managed checkpoint worktree already exists: ${paths.worktreePath}`); - } - const branchProbe = getGitOutput(["show-ref", "--verify", "--quiet", `refs/heads/${paths.branch}`], found.repoRoot); - if (branchProbe.status === 0) { - throw new Error(`Managed checkpoint branch already exists: ${paths.branch}`); - } - - fs.mkdirSync(path.dirname(paths.worktreePath), { recursive: true }); - const created = getGitOutput([ - "worktree", - "add", - paths.worktreePath, - "-b", - paths.branch, - checkpointBaseCommit(found.run, found.run.tasks[0]), - ], found.repoRoot); - if (created.status !== 0) { - throw new Error(`Failed to create managed checkpoint worktree: ${(created.stderr || created.stdout || "").trim()}`); - } - + const ownershipPath = path.join(found.runRoot, "ownership.json"); const ownership = validateOwnershipRecord({ schemaVersion: OWNERSHIP_SCHEMA_VERSION, runId: found.runId, @@ -254,13 +239,34 @@ function createOwnedWorktree(found, startedAt) { app: false, notification: false, }, - ownershipRecords: [], + ownershipRecords: loadOwnershipRecords(found.repoRoot, { excludePath: ownershipPath }), requestedOwnership: ownership, }, { repoRoot: found.repoRoot }); if (!gate.allowed) { throw new Error(`Controlled operation blocked: ${gate.reason}`); } - writeJsonAtomic(path.join(found.runRoot, "ownership.json"), ownership); + if (fs.existsSync(paths.worktreePath)) { + throw new Error(`Managed checkpoint worktree already exists: ${paths.worktreePath}`); + } + const branchProbe = getGitOutput(["show-ref", "--verify", "--quiet", `refs/heads/${paths.branch}`], found.repoRoot); + if (branchProbe.status === 0) { + throw new Error(`Managed checkpoint branch already exists: ${paths.branch}`); + } + + fs.mkdirSync(path.dirname(paths.worktreePath), { recursive: true }); + const created = getGitOutput([ + "worktree", + "add", + paths.worktreePath, + "-b", + paths.branch, + checkpointBaseCommit(found.run, found.run.tasks[0]), + ], found.repoRoot); + if (created.status !== 0) { + throw new Error(`Failed to create managed checkpoint worktree: ${(created.stderr || created.stdout || "").trim()}`); + } + + writeJsonAtomic(ownershipPath, ownership); return { ...paths, ownership }; } @@ -301,6 +307,7 @@ function executeSupervisedCheckpoint(options = {}) { assertPolicyAllows(found.repoRoot, "runWorkers"); assertPolicyAllows(found.repoRoot, "runCommands"); enforceOperationBudget(found, "implementation"); + const codexSelection = resolveCodexEffortForDispatch(found, "implementation"); const startedAt = new Date().toISOString(); const owned = createOwnedWorktree(found, startedAt); @@ -330,6 +337,7 @@ function executeSupervisedCheckpoint(options = {}) { scope: { status: "pending", warnings: [] }, testAuthoring: { policy: found.run.assurance.testAuthoring, status: "pending", violations: [] }, usage: { label: "unknown", value: null }, + codex: codexSelection.evidence, }; let startedRun = { ...found.run, @@ -396,6 +404,8 @@ function executeSupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "workspace-write", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -439,6 +449,7 @@ function executeSupervisedCheckpoint(options = {}) { }, testAuthoring, usage, + codex: confirmCodexEffortEvidence(attempt.codex, usage), logs: { stdout: path.relative(found.runRoot, stdoutPath).replace(/\\/g, "/"), stderr: path.relative(found.runRoot, stderrPath).replace(/\\/g, "/"), @@ -549,6 +560,7 @@ function retrySupervisedCheckpoint(options = {}) { } assertPolicyAllows(found.repoRoot, "runWorkers"); enforceOperationBudget(found, "repair"); + const codexSelection = resolveCodexEffortForDispatch(found, "repair"); const repairCount = task.attempts.filter((attempt) => attempt.kind === "repair").length; if (repairCount >= found.run.budget.maxRepairsPerCheckpoint.value) { throw new Error("Checkpoint repair limit is exhausted; explicit budget revision is required."); @@ -592,6 +604,7 @@ function retrySupervisedCheckpoint(options = {}) { scope: { status: "pending", warnings: [] }, testAuthoring: { policy: found.run.assurance.testAuthoring, status: "pending", violations: [] }, usage: { label: "unknown", value: null }, + codex: codexSelection.evidence, }; let startedRun = { ...found.run, @@ -638,6 +651,8 @@ function retrySupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "workspace-write", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -679,6 +694,7 @@ function retrySupervisedCheckpoint(options = {}) { }, testAuthoring, usage, + codex: confirmCodexEffortEvidence(attempt.codex, usage), logs: { stdout: path.relative(found.runRoot, stdoutPath).replace(/\\/g, "/"), stderr: path.relative(found.runRoot, stderrPath).replace(/\\/g, "/"), diff --git a/src/supervise/review.js b/src/supervise/review.js index d502c7e..40dbedc 100644 --- a/src/supervise/review.js +++ b/src/supervise/review.js @@ -10,6 +10,10 @@ const { runCodexExecAdapter, } = require("../run/adapters/codex-exec"); const { validateOwnershipRecord } = require("../run/ownership"); +const { + confirmCodexEffortEvidence, + resolveCodexEffortForDispatch, +} = require("../integration/effort-policy"); const { applyThresholdObservation, enforceOperationBudget } = require("./budget"); const { mergeManagedUsage, @@ -80,6 +84,7 @@ function reviewSupervisedCheckpoint(options = {}) { } assertPolicyAllows(found.repoRoot, "runReviewer"); enforceOperationBudget(found, "reviewer"); + const codexSelection = resolveCodexEffortForDispatch(found, "reviewer"); const ownership = validateOwnershipRecord( readJsonFile(path.join(found.runRoot, "ownership.json"), "execution ownership"), ); @@ -114,6 +119,7 @@ function reviewSupervisedCheckpoint(options = {}) { independent: true, status: "executing", startedAt, + codex: codexSelection.evidence, }, }; startedRun.budget.consumed.modelOperations += 1; @@ -147,6 +153,8 @@ function reviewSupervisedCheckpoint(options = {}) { timeoutSeconds: options.timeoutSeconds, sandbox: "read-only", structuredJson: true, + model: codexSelection.model, + effort: codexSelection.effort, }); const remainingOutput = Math.max( 0, @@ -188,6 +196,7 @@ function reviewSupervisedCheckpoint(options = {}) { timedOut, reportPath: path.relative(found.runRoot, reportPath).replace(/\\/g, "/"), usage, + codex: confirmCodexEffortEvidence(startedRun.reviewer.codex, usage), reason: decision ? null : "Reviewer output did not contain a supported Decision line.", diff --git a/src/workflow/cli.js b/src/workflow/cli.js index 7abe7ba..442b9aa 100644 --- a/src/workflow/cli.js +++ b/src/workflow/cli.js @@ -30,6 +30,10 @@ const { } = require("./state"); const { deriveSchedule } = require("./scheduler"); const { listWorkflowTemplates, loadWorkflowTemplate } = require("./templates"); +const { buildEvidenceReceipt, writeEvidenceReceipt } = require("../evidence/receipt"); +const { writeOperatorReport } = require("../evidence/report"); +const { compareEvidenceReceipts } = require("../evidence/compare"); +const { exportRedactedEvidence } = require("../evidence/redaction"); function outputJson(command, data) { console.log(JSON.stringify({ @@ -61,6 +65,61 @@ function resolveProposalSource(options) { } function runWorkflow(options = {}) { + if (options.subcommand === "export") { + if (!options.workflowRunId) throw new Error("workflow export requires a run id."); + const result = exportRedactedEvidence(loadWorkflowRun(process.cwd(), options.workflowRunId)); + if (options.json) outputJson("workflow.export", result); + else { + console.log("CEWP redacted evidence export written"); + console.log(`Run ID: ${result.receipt.runId}`); + for (const [name, filePath] of Object.entries(result.paths)) console.log(`${name}: ${filePath}`); + } + return; + } + if (options.subcommand === "compare") { + if (!options.workflowRunId || !options.comparisonRunId) throw new Error("workflow compare requires two run ids."); + const generatedAt = new Date().toISOString(); + const left = buildEvidenceReceipt(loadWorkflowRun(process.cwd(), options.workflowRunId), { generatedAt }); + const right = buildEvidenceReceipt(loadWorkflowRun(process.cwd(), options.comparisonRunId), { generatedAt }); + const comparison = compareEvidenceReceipts(left, right); + if (options.json) outputJson("workflow.compare", comparison); + else { + console.log("CEWP workflow comparison"); + console.log(`Left: ${comparison.runs.left.runId} (${comparison.runs.left.execution.owner})`); + console.log(`Right: ${comparison.runs.right.runId} (${comparison.runs.right.execution.owner})`); + console.log(`Equivalence: ${comparison.equivalence.status}`); + console.log(`Unavailable: ${comparison.equivalence.unavailable.join(", ") || "none"}`); + } + return; + } + if (options.subcommand === "report") { + if (!options.workflowRunId) throw new Error("workflow report requires a run id."); + const found = loadWorkflowRun(process.cwd(), options.workflowRunId); + const result = writeOperatorReport(found); + if (options.json) outputJson("workflow.report", result); + else { + console.log("CEWP offline operator report written"); + console.log(`Run ID: ${result.report.runId}`); + console.log(`Status: ${result.report.completeness.status}`); + console.log(`JSON: ${result.paths.json}`); + console.log(`HTML: ${result.paths.html}`); + } + return; + } + if (options.subcommand === "receipt") { + if (!options.workflowRunId) throw new Error("workflow receipt requires a run id."); + const found = loadWorkflowRun(process.cwd(), options.workflowRunId); + const result = writeEvidenceReceipt(found); + if (options.json) outputJson("workflow.receipt", result); + else { + console.log("CEWP evidence receipt written"); + console.log(`Run ID: ${result.receipt.runId}`); + console.log(`Completeness: ${result.receipt.completeness.status}`); + console.log(`JSON: ${result.paths.json}`); + console.log(`Markdown: ${result.paths.markdown}`); + } + return; + } if (options.subcommand === "compile") { const request = createWorkflowCompilerRequest({ repoRoot: process.cwd(), diff --git a/src/workflow/migration.js b/src/workflow/migration.js index a0bafd3..ca10852 100644 --- a/src/workflow/migration.js +++ b/src/workflow/migration.js @@ -5,6 +5,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { ensureDir } = require("../lib/fs"); const { normalizeSlashPath } = require("../lib/paths"); +const { appendLifecycleEvent } = require("../evidence/events"); const { findSupervisedRun } = require("../supervise/state"); const { digestWorkflowDefinition, @@ -366,8 +367,7 @@ function applyLegacyMigration(repoRoot, runId, options = {}) { }; writeJsonAtomic(runPath, run); const progress = writeWorkflowProgress(runRoot, run, preview.definition, { now: new Date(timestamp) }); - fs.appendFileSync(path.join(runRoot, "events.jsonl"), `${JSON.stringify({ - schemaVersion: "workflow-event/v1", + appendLifecycleEvent(runRoot, { timestamp, type: "workflow-migrated", runId: run.runId, @@ -378,7 +378,7 @@ function applyLegacyMigration(repoRoot, runId, options = {}) { migrationDigest: preview.migrationDigest, backupPath: normalizeSlashPath(path.relative(preview.found.repoRoot, backupPath)), actor: "operator", - })}\n`); + }); const record = { schemaVersion: "workflow-migration/v1", projectionVersion: MIGRATION_PROJECTION_VERSION, diff --git a/src/workflow/state.js b/src/workflow/state.js index 3128d16..ebca6f9 100644 --- a/src/workflow/state.js +++ b/src/workflow/state.js @@ -3,6 +3,8 @@ const fs = require("node:fs"); const path = require("node:path"); const { ensureDir } = require("../lib/fs"); +const { getGitHeadCommit } = require("../lib/git"); +const { appendLifecycleEvent } = require("../evidence/events"); const { normalizeSlashPath } = require("../lib/paths"); const { digestWorkflowDefinition, validateWorkflowDefinition } = require("./definition"); const { readRepoJson } = require("./source"); @@ -319,7 +321,7 @@ function loadWorkflowRun(repoRoot, runId) { } function appendWorkflowEvent(runRoot, event) { - fs.appendFileSync(path.join(runRoot, "events.jsonl"), `${JSON.stringify(event)}\n`); + appendLifecycleEvent(runRoot, event); } function pauseForWorkflowBudget(found, allocation, decision, timestamp) { @@ -347,6 +349,23 @@ function pauseForWorkflowBudget(found, allocation, decision, timestamp) { }; writeJsonAtomic(found.runPath, run); writeWorkflowProgress(found.runRoot, run, found.definition, { now: new Date(timestamp) }); + if (decision.warning) { + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "budget-threshold", + runId: run.runId, + threshold: decision.warning, + percent: decision.percent, + allocation, + }); + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "warning-presented", + runId: run.runId, + warning: decision.warning, + surface: "cewp-core-state", + }); + } appendWorkflowEvent(found.runRoot, { schemaVersion: "workflow-event/v1", timestamp, @@ -415,10 +434,9 @@ function startWorkflowTask(found, taskId, options = {}) { throw new Error(`Controlled workflow operation paused: ${budgetDecision.pauseStatus} (${budgetDecision.reason}).`); } let runBeforeStart = found.run; - if ( - budgetDecision.warning - && !found.run.budget.thresholdEvents.some((event) => event.threshold === budgetDecision.warning) - ) { + const newBudgetWarning = budgetDecision.warning + && !found.run.budget.thresholdEvents.some((event) => event.threshold === budgetDecision.warning); + if (newBudgetWarning) { runBeforeStart = { ...found.run, budget: { @@ -495,6 +513,41 @@ function startWorkflowTask(found, taskId, options = {}) { }; writeJsonAtomic(found.runPath, run); const progress = writeWorkflowProgress(found.runRoot, run, found.definition, { now }); + if (newBudgetWarning) { + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "budget-threshold", + runId: run.runId, + threshold: budgetDecision.warning, + percent: budgetDecision.percent, + allocation, + }); + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "warning-presented", + runId: run.runId, + warning: budgetDecision.warning, + surface: "cewp-core-state", + }); + } + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "checkpoint-started", + runId: run.runId, + taskId, + checkpointId, + attempt, + }); + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "dispatch-started", + runId: run.runId, + taskId, + checkpointId, + workerId, + executionOwner: run.execution.owner, + backend: run.execution.backend, + }); appendWorkflowEvent(found.runRoot, { schemaVersion: "workflow-event/v1", timestamp, @@ -584,6 +637,50 @@ function recordWorkflowFailureResult(found, runtimeTask, checkpoint, checkpointP writeJsonAtomic(found.runPath, run); const now = new Date(result.completedAt); const progress = writeWorkflowProgress(found.runRoot, run, found.definition, { now }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "scope-evaluated", + runId: run.runId, + taskId: runtimeTask.id, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + status: "passed", + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "verification-completed", + runId: run.runId, + taskId: runtimeTask.id, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + status: "failed", + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "usage-observed", + runId: run.runId, + taskId: runtimeTask.id, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + usageCategories: Object.fromEntries(Object.entries(result.usage).map(([name, value]) => [name, value.label])), + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "allocation-consumed", + runId: run.runId, + taskId: runtimeTask.id, + checkpointId: checkpoint.checkpointId, + allocation: checkpoint.budget.activeAllocation, + consumed: budget.consumed.allocations[checkpoint.budget.activeAllocation], + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "checkpoint-completed", + runId: run.runId, + taskId: runtimeTask.id, + checkpointId: checkpoint.checkpointId, + status: blockedCheckpoint.status, + }); appendWorkflowEvent(found.runRoot, { schemaVersion: "workflow-event/v1", timestamp: result.completedAt, @@ -794,6 +891,50 @@ function recordWorkflowResult(found, taskId, candidate) { }; writeJsonAtomic(found.runPath, run); const progress = writeWorkflowProgress(found.runRoot, run, found.definition, { now: new Date(result.completedAt) }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "scope-evaluated", + runId: run.runId, + taskId, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + status: "passed", + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "verification-completed", + runId: run.runId, + taskId, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + status: "passed", + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "usage-observed", + runId: run.runId, + taskId, + checkpointId: checkpoint.checkpointId, + resultId: result.resultId, + usageCategories: Object.fromEntries(Object.entries(result.usage).map(([name, value]) => [name, value.label])), + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "allocation-consumed", + runId: run.runId, + taskId, + checkpointId: checkpoint.checkpointId, + allocation: checkpoint.budget.activeAllocation, + consumed: budget.consumed.allocations[checkpoint.budget.activeAllocation], + }); + appendWorkflowEvent(found.runRoot, { + timestamp: result.completedAt, + type: "checkpoint-completed", + runId: run.runId, + taskId, + checkpointId: checkpoint.checkpointId, + status: completedCheckpoint.status, + }); appendWorkflowEvent(found.runRoot, { schemaVersion: "workflow-event/v1", timestamp: result.completedAt, @@ -1266,6 +1407,25 @@ function interveneWorkflowRun(found, options, timestamp, reason) { runId: run.runId, ...intervention, }); + if (options.event === "add-budget") { + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "budget-approved", + runId: run.runId, + actor: intervention.actor, + allocation: options.allocation, + operations: options.operations, + revision: budget.revisions.length, + }); + } else if (options.event === "pause-host-limit") { + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "paused-host-limit", + runId: run.runId, + reason, + actor: intervention.actor, + }); + } return { run, checkpoint: null, @@ -1345,6 +1505,15 @@ function interveneWorkflowLifecycle(found, options, timestamp, reason) { runId: run.runId, ...intervention, }); + if (options.event === "cancel") { + appendWorkflowEvent(found.runRoot, { + timestamp, + type: "workflow-cancelled", + runId: run.runId, + reason, + actor: intervention.actor, + }); + } return { run, checkpoints, @@ -1519,6 +1688,9 @@ function createApprovedRun(options) { status: "approved", createdAt: timestamp, updatedAt: timestamp, + git: { + baseCommit: getGitHeadCommit(repoRoot), + }, goal: options.definition.goal, execution: options.definition.execution, assurance: options.definition.assurance, @@ -1556,7 +1728,8 @@ function createApprovedRun(options) { warnings: [], }; writeJsonAtomic(runPath, run); - fs.writeFileSync(path.join(runRoot, "events.jsonl"), `${JSON.stringify({ + fs.writeFileSync(path.join(runRoot, "events.jsonl"), "", { flag: "wx" }); + appendWorkflowEvent(runRoot, { schemaVersion: "workflow-event/v1", timestamp, type: "workflow-approved", @@ -1566,7 +1739,16 @@ function createApprovedRun(options) { digest, approvalDigest, actor: "operator", - })}\n`, { flag: "wx" }); + }); + appendWorkflowEvent(runRoot, { + timestamp, + type: "budget-approved", + runId, + actor: "operator", + budgetSchemaVersion: run.budget.schemaVersion, + modelOperations: run.budget.modelOperations, + protectedAllocations: run.budget.protectedAllocations, + }); writeWorkflowProgress(runRoot, run, options.definition, { now }); return { run, diff --git a/tests/contracts/event-run-verify.js b/tests/contracts/event-run-verify.js new file mode 100644 index 0000000..6cdbeb9 --- /dev/null +++ b/tests/contracts/event-run-verify.js @@ -0,0 +1,115 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode, writeFile } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun, startWorkflowTask } = require("../../src/workflow/state"); +const { writeEvidenceReceipt } = require("../../src/evidence/receipt"); +const { + EVENT_SCHEMA_VERSION, + EVENT_CATEGORIES, + normalizeLifecycleEvent, + readLifecycleEvents, +} = require("../../src/evidence/events"); +const { verifyWorkflowRun } = require("../../src/evidence/verify"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function digest(content) { + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function runContract() { + const repoRoot = makeTempRepo("cewp-event-run-verify-"); + try { + const approved = approveWorkflow(repoRoot, validDefinition()); + const categories = new Set(Object.values(EVENT_CATEGORIES)); + for (const required of [ + "run", "plan-revision", "task", "checkpoint", "dispatch", "operator-intervention", "verification", + "usage-observation", "estimate-revision", "budget-approval", "allocation-consumption", "threshold", + "warning-presentation", "safe-pause", "unverified-pause", "host-limit", "scope", "review", + "cancellation", "finalize", + ]) assert(categories.has(required), `event vocabulary includes ${required}`); + let found = loadWorkflowRun(repoRoot, approved.runId); + const eventsPath = path.join(found.runRoot, "events.jsonl"); + const events = readLifecycleEvents(eventsPath, { runId: approved.runId }); + assert(events.length === 2, "approved workflow writes run and budget approval events"); + assert(events[0].schemaVersion === EVENT_SCHEMA_VERSION, "new lifecycle events use event/v1"); + assert(events[0].category === "run", "workflow approval is categorized as a run event"); + assert(events[1].category === "budget-approval", "approved envelope is a distinct budget event"); + + const legacy = normalizeLifecycleEvent({ + schemaVersion: "workflow-event/v1", + timestamp: "2026-07-22T10:00:00.000Z", + type: "task-started", + runId: approved.runId, + taskId: "implement-example", + }, { allowLegacy: true }); + assert(legacy.schemaVersion === EVENT_SCHEMA_VERSION && legacy.category === "task", "legacy workflow events normalize without rewriting history"); + + const startedAt = new Date(new Date(found.run.createdAt).getTime() + 1000); + startWorkflowTask(found, "implement-example", { now: startedAt }); + found = loadWorkflowRun(repoRoot, approved.runId); + const startedEvents = readLifecycleEvents(eventsPath, { runId: approved.runId }); + for (const category of ["checkpoint", "dispatch", "task"]) { + assert(startedEvents.some((entry) => entry.category === category), `task start emits ${category} lifecycle evidence`); + } + writeEvidenceReceipt(found, { generatedAt: "2026-07-22T10:01:00.000Z" }); + const healthy = verifyWorkflowRun(repoRoot, approved.runId); + assert(healthy.schemaVersion === "run-verification/v1", "run verification is versioned"); + assert(healthy.status === "passed", "consistent run with intact partial receipt passes health verification"); + assert(healthy.execution.agentsExecuted === false && healthy.execution.verificationCommandsExecuted === false, "run verify remains read-only"); + assert(healthy.checks.some((entry) => entry.id === "receipt-integrity" && entry.status === "passed"), "receipt integrity is recomputed"); + + const cli = runNode(cewpCli, ["run", "verify", approved.runId, "--json"], repoRoot); + assert(cli.status === 0, `run verify CLI succeeds for healthy evidence: ${cli.stderr}`); + const cliOutput = JSON.parse(cli.stdout); + assert(cliOutput.schemaVersion === "operator-json/v1" && cliOutput.command === "run.verify", "run verify uses operator JSON"); + + const receiptPath = path.join(found.runRoot, "evidence-receipt.json"); + const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); + receipt.integrity.files[0].sha256 = digest("tampered"); + fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + fs.appendFileSync(eventsPath, "{malformed\n"); + fs.appendFileSync(eventsPath, `${JSON.stringify({ + schemaVersion: "event/v999", + category: "task", + timestamp: new Date(startedAt.getTime() + 1000).toISOString(), + type: "task-started", + runId: approved.runId, + })}\n`); + const checkpointDir = path.join(found.runRoot, "checkpoints", "implement-example"); + fs.rmSync(path.join(checkpointDir, fs.readdirSync(checkpointDir)[0])); + writeFile(path.join(found.runRoot, "integration", "host-binding.json"), JSON.stringify({ + schemaVersion: "host-binding/v1", + references: { worktree: { id: "gone", path: path.join(repoRoot, ".cewp-worktrees", "gone") } }, + })); + const unhealthy = verifyWorkflowRun(repoRoot, approved.runId); + assert(unhealthy.status === "failed", "health verification fails closed on evidence defects"); + assert(unhealthy.issues.some((entry) => entry.code === "malformed-event"), "malformed events are reported"); + assert(unhealthy.issues.some((entry) => entry.code === "incompatible-event-schema"), "incompatible event schemas are reported"); + assert(unhealthy.issues.some((entry) => entry.code === "receipt-integrity-mismatch"), "receipt tampering is reported"); + assert(unhealthy.issues.some((entry) => entry.code === "missing-checkpoint"), "missing active checkpoints are reported"); + assert(unhealthy.issues.some((entry) => entry.code === "stale-worktree"), "missing bound worktrees are reported"); + + const failedCli = runNode(cewpCli, ["run", "verify", approved.runId, "--json"], repoRoot); + assert(failedCli.status !== 0, "run verify exits nonzero when health verification fails"); + const failedOutput = JSON.parse(failedCli.stdout); + assert(failedOutput.data.status === "failed", "failed operator JSON remains inspectable"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] event vocabulary and read-only run verification"); +} catch (error) { + console.error("[FAIL] event vocabulary and run verification contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/evidence-receipt.js b/tests/contracts/evidence-receipt.js new file mode 100644 index 0000000..2b719e7 --- /dev/null +++ b/tests/contracts/evidence-receipt.js @@ -0,0 +1,209 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode, writeFile } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { successfulResult } = require("./workflow-result"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { + finalizeWorkflowRun, + loadWorkflowRun, + recordWorkflowResult, + recordWorkflowReview, + startWorkflowTask, +} = require("../../src/workflow/state"); +const { + buildEvidenceReceipt, + renderEvidenceReceiptMarkdown, + writeEvidenceReceipt, +} = require("../../src/evidence/receipt"); +const { recordHostObservation } = require("../../src/integration/observation"); +const { supportedSnapshot } = require("./integration-capabilities"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function singleTaskDefinition() { + const definition = validDefinition(); + definition.workflowId = "evidence-receipt"; + definition.tasks = [definition.tasks[0]]; + definition.budget.maxTargetedVerificationRuns = 6; + definition.budget.maxFullVerificationRuns = 1; + return definition; +} + +function reviewerCandidate(run) { + return { + schemaVersion: "review-result/v1", + reviewId: `${run.runId}-final-review`, + runId: run.runId, + workflowDigest: run.workflow.digest, + scope: { kind: "workflow", taskId: null, checkpointId: null }, + completedAt: "2026-07-18T12:05:00.000Z", + independent: true, + decision: "PASS", + summary: "Independent evidence receipt review passed.", + findings: [], + evidence: [{ kind: "review-report", path: "evidence/review.md" }], + usage: { + managedOperations: { label: "observed", value: 1, source: "codex-exec-jsonl" }, + capturedOutputBytes: { label: "observed", value: 128, source: "cewp-bounded-output" }, + managedTokens: { label: "unknown", value: null, reason: "fixture omits token totals" }, + hostInternal: { label: "unknown", value: null, reason: "host usage is unavailable" }, + }, + }; +} + +function completeRun(repoRoot) { + const approved = approveWorkflow(repoRoot, singleTaskDefinition()); + let found = loadWorkflowRun(repoRoot, approved.runId); + const started = startWorkflowTask(found, "implement-example", { + now: new Date("2026-07-18T12:00:00.000Z"), + }); + writeFile(path.join(repoRoot, "evidence", "baseline.json"), "{\"status\":\"passed\"}\n"); + writeFile(path.join(repoRoot, "evidence", "targeted.json"), "{\"status\":\"passed\"}\n"); + writeFile(path.join(repoRoot, "evidence", "change.diff"), "diff --git a/src/example.js b/src/example.js\n"); + writeFile(path.join(repoRoot, "evidence", "review.md"), "# Independent review\n\nPASS\n"); + const result = successfulResult(approved, started.checkpoint, [{ + command: "node --test tests/example.test.js", + status: "passed", + evidencePath: "evidence/targeted.json", + }]); + found = loadWorkflowRun(repoRoot, approved.runId); + recordWorkflowResult(found, "implement-example", result); + found = loadWorkflowRun(repoRoot, approved.runId); + recordWorkflowReview(found, reviewerCandidate(approved)); + found = loadWorkflowRun(repoRoot, approved.runId); + finalizeWorkflowRun(found, { now: new Date("2026-07-18T12:06:00.000Z") }); + return loadWorkflowRun(repoRoot, approved.runId); +} + +function runContract() { + const repoRoot = makeTempRepo("cewp-evidence-receipt-"); + try { + const found = completeRun(repoRoot); + recordHostObservation(found, { + schemaVersion: "host-observation/v1", + observationId: "receipt-usage-0001", + observedAt: "2026-07-18T12:06:30.000Z", + source: { + path: "codex-exec", + codexVersion: "codex-cli 0.137.0", + schemaVersion: "codex-exec-jsonl/v1", + authenticationBoundary: "managed-child", + }, + scope: { kind: "workflow-run", runId: found.run.runId, taskId: null, checkpointId: null }, + category: "thread-usage", + rawCategory: "turn.completed.usage", + availability: "observed", + data: { inputTokens: 100, cachedInputTokens: 40, outputTokens: 20, reasoningOutputTokens: 5 }, + raw: { input_tokens: 100, cached_input_tokens: 40, output_tokens: 20, reasoning_output_tokens: 5 }, + }, { capabilities: supportedSnapshot() }); + recordHostObservation(found, { + schemaVersion: "host-observation/v1", + observationId: "receipt-usage-imported-0001", + observedAt: "2026-07-18T12:06:31.000Z", + source: { + path: "audit-import", + codexVersion: null, + schemaVersion: "external-receipt/v1", + authenticationBoundary: "external-owner", + }, + scope: { kind: "workflow-run", runId: found.run.runId, taskId: null, checkpointId: null }, + category: "thread-usage", + rawCategory: "external.usage", + availability: "imported", + data: { inputTokens: 50, cachedInputTokens: 0, outputTokens: 10, reasoningOutputTokens: 0 }, + raw: { imported_total: 60 }, + }, { capabilities: supportedSnapshot() }); + writeFile(path.join(found.runRoot, "adapter-output", "prompt.md"), "TOP_SECRET_PROMPT\n"); + const options = { generatedAt: "2026-07-18T12:07:00.000Z" }; + const first = buildEvidenceReceipt(found, options); + const second = buildEvidenceReceipt(found, options); + assert(first.schemaVersion === "evidence-receipt/v1", "evidence receipt is versioned"); + assert(JSON.stringify(first) === JSON.stringify(second), "fixed-timestamp receipt generation is deterministic"); + assert(first.completeness.status === "complete", "finalized reviewed run produces a complete receipt"); + assert(first.goal === found.run.goal, "receipt retains the approved goal"); + assert(first.sourcePlan.kind === "direct-goal", "receipt retains source-plan identity"); + assert(first.execution.owner === "managed" && first.execution.backend === "codex-exec", "receipt retains owner and backend"); + assert(first.tasks.length === 1 && first.checkpoints.length === 1, "receipt explains task and checkpoint structure"); + assert(first.tasks[0].changedFiles.includes("src/example.js"), "receipt records changed files"); + assert(first.tasks[0].verification.targeted[0].status === "passed", "receipt records verification evidence"); + for (const category of ["scope", "verification", "usage-observation", "allocation-consumption", "checkpoint"]) { + assert(first.events.some((entry) => entry.category === category), `completed task emits ${category} lifecycle evidence`); + } + assert(first.reviewer.decision === "PASS", "receipt retains independent reviewer PASS"); + assert(first.usage.managedOperations.label === "observed", "receipt aggregates observed managed operations"); + assert(first.usage.hostInternal.label === "unknown", "unavailable host usage remains unknown"); + assert(first.usage.observations.every((entry) => entry.schemaVersion === "usage-observation/v1"), "usage observations are independently versioned"); + const hostUsage = first.usage.observations.find((entry) => entry.rawCategory === "turn.completed.usage"); + assert(hostUsage.source.authenticationBoundary === "managed-child", "usage observation preserves authentication boundary"); + assert(hostUsage.value.cachedInputTokens === 40 && hostUsage.value.reasoningOutputTokens === 5, "raw usage categories remain distinct"); + assert(hostUsage.rawValue === null, "receipt excludes raw host payload while preserving normalized categories"); + const importedUsage = first.usage.observations.find((entry) => entry.rawCategory === "external.usage"); + assert(importedUsage.availability === "imported" && importedUsage.evidenceClass === "imported", "imported usage is never relabeled observed"); + assert(importedUsage.billingImpact === "unknown", "host usage never implies a billing impact"); + assert(first.events.some((entry) => entry.type === "usage-observed" && entry.category === "usage-observation"), "usage recording emits the versioned lifecycle category"); + assert(first.estimate.estimator.version === "local-history/v1" && first.estimate.sampleBasis.count === 0, "unknown estimate remains reproducible from its empty sample basis"); + assert(first.estimate.calibrationSnapshot.intervalCoverage === null && first.estimate.drift.state === "unknown", "estimate calibration and drift stay explicit"); + assert(first.budget.compliance.status === "passed", "receipt proves the approved budget ceilings were respected"); + assert(first.budget.compliance.protectedAllocationsRespected === true, "receipt proves protected allocations were not overspent"); + assert(first.cost.apiEquivalent.label === "unknown", "currency cost is not invented"); + assert(first.integrity.claim === "tamper-evident-local-metadata", "integrity claim is explicitly local and tamper-evident"); + assert(first.integrity.files.length > 3, "receipt hashes canonical evidence files"); + assert(first.integrity.files.every((entry) => /^sha256:[a-f0-9]{64}$/.test(entry.sha256)), "evidence file hashes are stable sha256 values"); + assert(first.integrity.files.map((entry) => entry.path).join("\n") === first.integrity.files.map((entry) => entry.path).sort().join("\n"), "integrity inventory is sorted"); + assert(first.git.baseCommit.status === "known" && first.git.headCommit.status === "known", "receipt records source git identities"); + + const markdown = renderEvidenceReceiptMarkdown(first); + assert(markdown.includes("# CEWP Evidence Receipt"), "Markdown receipt is human-readable"); + assert(markdown.includes("Host-internal usage: unknown"), "Markdown keeps explicit unknowns"); + for (const section of ["## Tasks", "## Checkpoints", "## Commands and verification", "## Budget", "## Usage and estimate", "## Controls", "## Final review", "## Timestamps"]) { + assert(markdown.includes(section), `Markdown receipt includes ${section}`); + } + assert(markdown.includes("implement-example-attempt-0001"), "Markdown identifies checkpoint evidence"); + assert(markdown.includes("node --test tests/example.test.js"), "Markdown identifies approved verification commands"); + assert(markdown.includes("Protected allocations: passed"), "Markdown proves protected reserve compliance"); + assert(markdown.includes("Decision: PASS"), "Markdown exposes independent final review"); + assert(!markdown.includes("TOP_SECRET_PROMPT"), "Markdown does not include prompt or raw log content"); + const written = writeEvidenceReceipt(found, options); + assert(fs.existsSync(written.paths.json) && fs.existsSync(written.paths.markdown), "JSON and Markdown receipts are written locally"); + assert(JSON.stringify(written.receipt) === JSON.stringify(first), "written receipt uses the same normalized model"); + const cli = runNode(cewpCli, ["workflow", "receipt", found.run.runId, "--json"], repoRoot); + assert(cli.status === 0, `workflow receipt CLI succeeds: ${cli.stderr}`); + const cliOutput = JSON.parse(cli.stdout); + assert(cliOutput.command === "workflow.receipt", "receipt output uses operator JSON"); + assert(cliOutput.data.receipt.schemaVersion === "evidence-receipt/v1", "CLI writes the normalized receipt contract"); + + fs.rmSync(path.join(repoRoot, "evidence", "change.diff")); + const missingEvidence = buildEvidenceReceipt(found, options); + assert(missingEvidence.completeness.status === "partial", "missing referenced evidence closes receipt completeness"); + assert(missingEvidence.warnings.some((warning) => warning.code === "missing-referenced-evidence"), "missing evidence has an actionable warning"); + fs.appendFileSync(path.join(found.runRoot, "integration", "host-observations.jsonl"), "{malformed\n"); + const malformedUsage = buildEvidenceReceipt(found, options); + assert(malformedUsage.completeness.status === "partial", "malformed usage ledger cannot produce a complete receipt"); + assert(malformedUsage.warnings.some((warning) => warning.code === "malformed-usage-observation-ledger"), "malformed usage ledger has an explicit warning"); + + const partialRepo = makeTempRepo("cewp-evidence-receipt-partial-"); + try { + const partialRun = approveWorkflow(partialRepo, singleTaskDefinition()); + const partial = buildEvidenceReceipt(loadWorkflowRun(partialRepo, partialRun.runId), options); + assert(partial.completeness.status === "partial", "incomplete historical run produces a partial receipt"); + assert(partial.warnings.some((warning) => warning.code === "run-not-finalized"), "partial receipt explains why it is incomplete"); + } finally { + cleanupRepo(partialRepo); + } + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] normalized evidence receipt is deterministic and tamper-evident"); +} catch (error) { + console.error("[FAIL] evidence receipt contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/evidence-redaction.js b/tests/contracts/evidence-redaction.js new file mode 100644 index 0000000..dc82abc --- /dev/null +++ b/tests/contracts/evidence-redaction.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode, writeFile } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun } = require("../../src/workflow/state"); +const { exportRedactedEvidence, redactEvidenceValue } = require("../../src/evidence/redaction"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); +const SECRET = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + +function runContract() { + const synthetic = redactEvidenceValue({ + authorization: "Bearer top-secret-bearer", + password: "hunter2", + clientSecret: "client-secret-value", + command: `tool --token=${SECRET} --count=2`, + url: "https://alice:private@example.invalid/repo", + privateBlock: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + absoluteWindowsPath: "C:\\Users\\Alice\\private\\notes.txt", + traversalPath: "../../etc/passwd", + dotenvPath: "config/.env", + changedFiles: ["src/safe.js", "config/.env"], + note: "See C:\\Users\\Alice\\private\\notes.txt before export", + managedTokens: { label: "observed", value: 42 }, + }); + const serializedSynthetic = JSON.stringify(synthetic.value); + for (const secret of ["top-secret-bearer", "hunter2", "client-secret-value", SECRET, "alice:private", "BEGIN PRIVATE KEY", "Users\\\\Alice", "../../etc/passwd", "config/.env"]) { + assert(!serializedSynthetic.includes(secret), `redaction removes adversarial value ${secret}`); + } + assert(synthetic.value.managedTokens.value === 42, "non-secret usage token counts are preserved"); + assert(synthetic.value.changedFiles[0] === "src/safe.js" && synthetic.value.changedFiles[1] === "[REDACTED_PATH]", "path arrays preserve safe paths and redact sensitive paths"); + assert(synthetic.replacements >= 7, "redaction reports replacement count"); + + const repoRoot = makeTempRepo("cewp-evidence-redaction-"); + try { + const definition = validDefinition(); + definition.workflowId = "redacted-export"; + definition.goal = `Fix using token=${SECRET}`; + definition.tasks[0].verification.targeted[0] = `node test.js --password=hunter2`; + const approved = approveWorkflow(repoRoot, definition); + const found = loadWorkflowRun(repoRoot, approved.runId); + writeFile(path.join(found.runRoot, "adapter-output", "prompt.md"), "RAW_PROMPT_DO_NOT_EXPORT\n"); + const exported = exportRedactedEvidence(found, { generatedAt: "2026-07-22T14:00:00.000Z" }); + assert(exported.receipt.schemaVersion === "evidence-receipt/v1", "redacted export preserves receipt contract"); + assert(exported.receipt.redaction.schemaVersion === "redaction-policy/v1" && exported.receipt.redaction.applied, "redaction is explicit and versioned"); + assert(exported.report.schemaVersion === "operator-report/v1", "redacted export preserves report contract"); + assert(Object.values(exported.paths).every((entry) => !path.isAbsolute(entry) && !entry.includes("..")), "export returns repository-relative paths"); + const contents = Object.values(exported.paths).map((relative) => fs.readFileSync(path.join(repoRoot, relative), "utf8")).join("\n"); + for (const secret of [SECRET, "hunter2", "RAW_PROMPT_DO_NOT_EXPORT", ""]) { + assert(!contents.includes(secret), `export excludes ${secret}`); + } + assert(!/https?:\/\//.test(fs.readFileSync(path.join(repoRoot, exported.paths.html), "utf8")), "redacted HTML remains offline"); + assert(contents.includes("Redaction") && contents.includes("redaction-policy/v1"), "export artifacts disclose that redaction was applied"); + assert(!fs.existsSync(path.join(found.runRoot, "evidence-receipt.json")), "export does not overwrite or create the canonical receipt"); + + const cli = runNode(cewpCli, ["workflow", "export", approved.runId, "--json"], repoRoot); + assert(cli.status === 0, `workflow export CLI succeeds: ${cli.stderr}`); + const output = JSON.parse(cli.stdout); + assert(output.command === "workflow.export" && output.data.receipt.redaction.applied, "CLI emits only the redacted export model"); + assert(!cli.stdout.includes(SECRET) && !cli.stdout.includes(repoRoot), "CLI JSON does not leak secrets or absolute repository paths"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] adversarial evidence redaction and safe export"); +} catch (error) { + console.error("[FAIL] evidence redaction contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/integration-binding.js b/tests/contracts/integration-binding.js index 91da018..0e55c08 100644 --- a/tests/contracts/integration-binding.js +++ b/tests/contracts/integration-binding.js @@ -3,16 +3,20 @@ const fs = require("node:fs"); const path = require("node:path"); const { assert } = require("../harness/lib/assertions"); -const { cleanupRepo, makeTempRepo } = require("../harness/lib/temp-repo"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); const { supportedSnapshot } = require("./integration-capabilities"); const { validDefinition } = require("./workflow-definition"); const { approveWorkflow } = require("./workflow-scheduler"); const { createGeneratedGoalBrief, createHostBinding, + loadIntegrationControlReceipt, loadHostBinding, } = require("../../src/integration/binding"); -const { loadWorkflowRun } = require("../../src/workflow/state"); +const { loadWorkflowRun, startWorkflowTask } = require("../../src/workflow/state"); +const { buildEvidenceReceipt } = require("../../src/evidence/receipt"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); function assertThrows(action, expected, label) { let error; @@ -36,6 +40,17 @@ function nativeDefinition() { return definition; } +function auditDefinition() { + const definition = validDefinition(); + definition.workflowId = "audit-integration"; + definition.execution = { + owner: "audit-only", + backend: null, + allowedModes: ["audit-only"], + }; + return definition; +} + function explicitBinding(runId) { return { schemaVersion: "host-binding/v1", @@ -129,6 +144,132 @@ function main() { cleanupRepo(managedRepo); } + const auditRepo = makeTempRepo("cewp-integration-audit-binding-"); + try { + const audit = approveWorkflow(auditRepo, auditDefinition()); + const auditFound = loadWorkflowRun(auditRepo, audit.runId); + const auditBinding = explicitBinding(audit.runId); + auditBinding.execution = { owner: "audit-only", backend: null }; + auditBinding.host.surface = "external-client"; + auditBinding.mode = "audit-import"; + auditBinding.provenance.kind = "imported-audit"; + auditBinding.references.goalId = null; + auditBinding.references.threadId = "external-thread-1"; + auditBinding.controls = { + preventive: ["scope-policy"], + postExecution: ["receipt-schema"], + imported: ["external-scope-observation"], + unavailable: ["provider-tool-prevention"], + }; + assertThrows( + () => createHostBinding(auditFound, auditBinding, { capabilities: supportedSnapshot() }), + /audit-only.*preventive/i, + "audit-only binding cannot claim preventive enforcement", + ); + + auditBinding.controls.preventive = []; + createHostBinding(auditFound, auditBinding, { capabilities: supportedSnapshot() }); + const controlReceipt = loadIntegrationControlReceipt(auditFound); + assert(controlReceipt.schemaVersion === "integration-control-receipt/v1", "control receipt is versioned"); + assert(controlReceipt.execution.owner === "audit-only", "receipt retains audit-only ownership"); + assert(controlReceipt.summary.preventiveEnforced === 0, "audit-only receipt claims no preventive enforcement"); + assert(controlReceipt.summary.postExecutionChecked === 1, "post-execution checks stay distinct"); + assert( + controlReceipt.controls.find((entry) => entry.name === "receipt-schema").classification === "post-execution", + "public receipt uses the documented post-execution classification", + ); + assert(controlReceipt.summary.importedObserved === 1, "imported observations stay distinct"); + assert( + controlReceipt.controls.find((entry) => entry.name === "external-scope-observation").effect === "observed-not-enforced", + "imported audit evidence is labeled observed rather than enforced", + ); + assert( + controlReceipt.claims.providerExecutionSuppliesEnforcement === false, + "audit receipt never treats provider-controlled execution as the enforcement source", + ); + const auditEvidence = buildEvidenceReceipt(auditFound, { generatedAt: "2026-07-22T15:20:00.000Z" }); + const importedControl = auditEvidence.policy.controls.controls.find((entry) => entry.classification === "imported"); + assert(importedControl.effect === "observed-not-enforced", "audit-only evidence receipt preserves observed-not-enforced effect"); + assert(auditEvidence.policy.controls.summary.preventiveEnforced === 0, "audit-only evidence receipt never reports preventive enforcement"); + const shown = runNode(cewpCli, ["integration", "controls", audit.runId, "--json"], auditRepo); + assert(shown.status === 0, `control receipt is available through operator JSON: ${shown.stderr}`); + const shownReceipt = JSON.parse(shown.stdout); + assert(shownReceipt.command === "integration.controls", "operator JSON identifies control inspection"); + assert(shownReceipt.data.summary.importedObserved === 1, "operator JSON preserves observed audit evidence"); + + const duplicate = { ...auditBinding, controls: { + ...auditBinding.controls, + imported: ["receipt-schema"], + } }; + assertThrows( + () => createHostBinding(auditFound, duplicate, { capabilities: supportedSnapshot(), replace: true }), + /more than one control class/, + "one control cannot receive conflicting enforcement classifications", + ); + + const receiptPath = path.join(auditFound.runRoot, "integration", "control-receipt.json"); + const tampered = JSON.parse(fs.readFileSync(receiptPath, "utf8")); + tampered.controls.find((entry) => entry.classification === "imported").effect = "prevented-before-execution"; + fs.writeFileSync(receiptPath, `${JSON.stringify(tampered, null, 2)}\n`); + assertThrows( + () => loadIntegrationControlReceipt(auditFound), + /does not match the validated host binding/, + "edited receipt cannot promote observed audit evidence to enforcement", + ); + } finally { + cleanupRepo(auditRepo); + } + + const conflictRepo = makeTempRepo("cewp-integration-ownership-conflict-"); + try { + const native = approveWorkflow(conflictRepo, nativeDefinition()); + let nativeFound = loadWorkflowRun(conflictRepo, native.runId); + const started = startWorkflowTask(nativeFound, "implement-example", { + now: new Date("2026-07-18T12:01:00.000Z"), + }); + nativeFound = loadWorkflowRun(conflictRepo, native.runId); + const sharedWorktree = path.join(conflictRepo, "..", ".cewp-worktrees", "shared-task"); + const managedOwnershipPath = path.join( + conflictRepo, + ".cewp", + "supervised-runs", + "managed-conflict", + "ownership.json", + ); + fs.mkdirSync(path.dirname(managedOwnershipPath), { recursive: true }); + fs.writeFileSync(managedOwnershipPath, `${JSON.stringify({ + schemaVersion: "execution-ownership/v1", + runId: "managed-conflict", + taskId: "implement-example", + checkpointId: "implement-example", + owner: "managed", + backend: "codex-exec", + status: "active", + createdAt: "2026-07-18T12:00:00.000Z", + cleanupAuthority: "cewp-core", + worktree: { id: "shared-task", path: sharedWorktree }, + }, null, 2)}\n`); + + const conflictingBinding = explicitBinding(native.runId); + conflictingBinding.workflow = { + runId: native.runId, + taskId: "implement-example", + checkpointId: started.checkpoint.checkpointId, + }; + conflictingBinding.references.worktree = { id: "shared-task", path: sharedWorktree }; + assertThrows( + () => createHostBinding(nativeFound, conflictingBinding, { capabilities: supportedSnapshot() }), + /execution ownership conflict/, + "native host binding cannot claim an active managed task worktree", + ); + assert( + !fs.existsSync(path.join(nativeFound.runRoot, "integration", "host-binding.json")), + "conflicting native binding is not persisted", + ); + } finally { + cleanupRepo(conflictRepo); + } + const coreRun = JSON.parse(fs.readFileSync(found.runPath, "utf8")); assert(coreRun.host === undefined && coreRun.references === undefined, "provider ids stay outside core schema"); diff --git a/tests/contracts/integration-capabilities.js b/tests/contracts/integration-capabilities.js index 6679d4f..4eaa45c 100644 --- a/tests/contracts/integration-capabilities.js +++ b/tests/contracts/integration-capabilities.js @@ -131,6 +131,23 @@ function main() { assert(capabilityMatrix.includes("plugin install, disable, upgrade, and uninstall"), "plugin lifecycle evidence is current"); assert(!capabilityMatrix.includes("Phase 9 must test"), "capability matrix has no stale Phase 9 promise"); + const externalBoundary = fs.readFileSync( + path.join(repoRoot, "docs", "external-integration-boundary.md"), + "utf8", + ); + for (const required of [ + "operator-json/v1", + "cewp-mcp", + "current working directory", + "must not become the execution owner", + "Codex App Server", + "codex-exec", + "does not attach to the ChatGPT desktop app's existing internal session", + "no custom terminal-session protocol", + ]) { + assert(externalBoundary.includes(required), `external integration boundary documents ${required}`); + } + console.log("[PASS] Codex integration capability drift and backend decision stay truthful"); } diff --git a/tests/contracts/integration-effort-policy.js b/tests/contracts/integration-effort-policy.js new file mode 100644 index 0000000..b07f566 --- /dev/null +++ b/tests/contracts/integration-effort-policy.js @@ -0,0 +1,271 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { createFakeCodexAdapter } = require("../harness/lib/fake-adapter"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function parseJson(result, label) { + assert(result.status === 0, `${label} failed: ${result.stderr}`); + return JSON.parse(result.stdout); +} + +function createApprovedRun(repoRoot) { + const planned = parseJson(runNode(cewpCli, [ + "supervise", "plan", + "--goal", "Inspect one bounded behavior", + "--scope", "README.md", + "--verify", "git diff --check", + "--stop", "The bounded behavior is inspected", + "--json", + ], repoRoot), "supervise plan"); + const runId = planned.data.run.runId; + parseJson(runNode(cewpCli, [ + "supervise", "approve", runId, "--yes", "--json", + ], repoRoot), "supervise approve"); + return runId; +} + +function createApprovedRepairRun(repoRoot) { + const verification = "node -e \"const fs=require('fs'); process.exit(fs.readFileSync('README.md','utf8').includes('Fake Codex')?1:0)\""; + const planned = parseJson(runNode(cewpCli, [ + "supervise", "plan", + "--goal", "Repair one bounded regression", + "--scope", "README.md", + "--verify", verification, + "--stop", "The bounded regression is repaired", + "--json", + ], repoRoot), "repair supervise plan"); + const runId = planned.data.run.runId; + parseJson(runNode(cewpCli, ["supervise", "approve", runId, "--yes", "--json"], repoRoot), "repair supervise approve"); + return runId; +} + +function runRepairEffortContract() { + const repoRoot = makeTempRepo("cewp-repair-effort-policy-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRepairRun(repoRoot); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "repair fixture grants dispatch authority"); + parseJson(runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }), "execute repair fixture"); + const failedVerification = runNode(cewpCli, [ + "supervise", "verify", runId, "--timeout", "20", "--json", + ], repoRoot); + assert(failedVerification.status === 1, "repair fixture reaches a failed verification gate"); + const failed = JSON.parse(failedVerification.stdout); + assert(failed.data.run.status === "needs-repair", "failed verification exposes bounded repair"); + + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "repair", + "--task-class", "demanding-implementation", + "--model", "gpt-test-repair", + "--effort", "medium", + "--yes", "--json", + ], repoRoot), "approve repair effort policy"); + const retried = parseJson(runNode(cewpCli, [ + "supervise", "retry", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-repair", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "medium", + }, + }), "retry with approved effort policy"); + const repairAttempt = retried.data.run.tasks[0].attempts.at(-1); + assert(repairAttempt.kind === "repair", "repair attempt remains explicitly classified"); + assert(repairAttempt.codex.taskClass === "demanding-implementation", "repair evidence retains its task class"); + assert(repairAttempt.codex.effectiveModel.value === "gpt-test-repair", "repair evidence records the effective model"); + assert(repairAttempt.codex.effectiveEffort.value === "medium", "repair evidence records the effective effort"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function runEffortTamperContract() { + const repoRoot = makeTempRepo("cewp-effort-policy-tamper-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-approved", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve tamper fixture policy"); + const policyPath = path.join(repoRoot, ".cewp", "supervised-runs", runId, "integration", "codex-effort-policy.json"); + const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); + policy.assignments.implementation.requested.model.value = "gpt-unapproved-edit"; + fs.writeFileSync(policyPath, `${JSON.stringify(policy, null, 2)}\n`); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "tamper fixture grants dispatch authority"); + const refused = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(refused.status === 1, "modified effort sidecar cannot dispatch"); + assert(refused.stderr.includes("not operator-approved or was modified"), "tamper refusal explains the approval failure"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function runStalePlanRevisionContract() { + const repoRoot = makeTempRepo("cewp-effort-policy-stale-revision-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-old-plan", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve old-plan effort policy"); + const revised = parseJson(runNode(cewpCli, [ + "supervise", "revise", runId, + "--goal", "Inspect a revised bounded behavior", + "--json", + ], repoRoot), "revise effort-policy plan"); + assert(revised.data.run.planRevision === 2, "fixture creates a new plan revision"); + parseJson(runNode(cewpCli, ["supervise", "approve", runId, "--yes", "--json"], repoRoot), "approve revised plan"); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "stale revision fixture grants dispatch authority"); + const refused = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(refused.status === 1, "old-plan effort approval cannot dispatch a revised plan"); + assert(refused.stderr.includes("current plan revision"), "stale approval refusal names the plan-revision mismatch"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +function main() { + const repoRoot = makeTempRepo("cewp-effort-policy-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + const unapproved = runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "fast-exploration", + "--json", + ], repoRoot); + assert(unapproved.status === 1, "effort changes require explicit --yes approval"); + assert(unapproved.stderr.includes("explicit operator approval"), "approval refusal is actionable"); + const configured = parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "fast-exploration", + "--yes", "--json", + ], repoRoot), "supervise effort"); + + assert(configured.command === "supervise.effort", "effort command identifies its public operation"); + const policy = configured.data.effortPolicy; + assert(policy.schemaVersion === "codex-effort-policy/v1", "effort policy is versioned"); + assert(policy.provider === "codex", "provider identity stays in the integration sidecar"); + assert(policy.automaticModelRouting === false, "automatic model routing remains disabled"); + assert(policy.assignments.implementation.taskClass === "fast-exploration", "explicit task class is retained"); + assert(policy.assignments.implementation.requested.model.status === "unknown", "task class does not infer a model"); + assert(policy.assignments.implementation.requested.effort.status === "unknown", "task class does not infer effort"); + assert(policy.assignments.implementation.approval.kind === "operator", "operator approval is recorded"); + const canonicalRun = JSON.parse(fs.readFileSync( + path.join(repoRoot, ".cewp", "supervised-runs", runId, "run.json"), + "utf8", + )); + assert(canonicalRun.effortPolicy === undefined, "provider-specific effort policy stays outside canonical run state"); + + const explicit = parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "implementation", + "--task-class", "demanding-implementation", + "--model", "gpt-test-explicit", + "--effort", "high", + "--yes", "--json", + ], repoRoot), "approve explicit model and effort"); + assert(explicit.data.effortPolicy.revision === 2, "approved setting change creates a new revision"); + const change = explicit.data.effortPolicy.history.at(-1); + assert(change.previous.taskClass === "fast-exploration", "change history retains the previous task class"); + assert(change.previous.requested.model.status === "unknown", "change history retains the previous unknown model"); + assert(change.next.taskClass === "demanding-implementation", "change history retains the next task class"); + assert(change.next.requested.model.value === "gpt-test-explicit", "change history retains the approved next model"); + assert(change.next.requested.effort.value === "high", "change history retains the approved next effort"); + const approvalEvents = fs.readFileSync( + path.join(repoRoot, ".cewp", "supervised-runs", runId, "events.jsonl"), + "utf8", + ).trim().split("\n").map((line) => JSON.parse(line)) + .filter((event) => event.type === "codex-effort-policy-approved"); + assert(approvalEvents.length === 2, "each effort policy approval is retained in the run event log"); + assert(approvalEvents.at(-1).revision === 2, "the event log identifies the approved policy revision"); + assert( + approvalEvents.at(-1).selectionDigest === explicit.data.effortPolicy.assignments.implementation.approval.selectionDigest, + "the event log binds the operator approval to the selected policy digest", + ); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "fixture grants dispatch authority"); + + const executed = parseJson(runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-explicit", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "high", + }, + }), "execute with approved effort policy"); + const attempt = executed.data.run.tasks[0].attempts[0]; + assert(attempt.codex.taskClass === "demanding-implementation", "dispatch evidence retains the approved task class"); + assert(attempt.codex.effectiveModel.status === "known", "explicit dispatch model becomes known evidence"); + assert(attempt.codex.effectiveModel.value === "gpt-test-explicit", "effective model matches the approved override"); + assert(attempt.codex.effectiveEffort.status === "known", "explicit dispatch effort becomes known evidence"); + assert(attempt.codex.effectiveEffort.value === "high", "effective effort matches the approved override"); + + const verified = parseJson(runNode(cewpCli, [ + "supervise", "verify", runId, "--timeout", "20", "--json", + ], repoRoot), "verify explicit-effort checkpoint"); + assert(verified.data.run.status === "checkpoint-complete", "review setup retains the verification gate"); + parseJson(runNode(cewpCli, [ + "supervise", "effort", runId, + "--operation", "reviewer", + "--task-class", "high-effort-independent-review", + "--model", "gpt-test-reviewer", + "--effort", "xhigh", + "--yes", "--json", + ], repoRoot), "approve reviewer effort policy"); + const reviewed = parseJson(runNode(cewpCli, [ + "supervise", "review", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { + env: { + ...fake.env, + CEWP_FAKE_CODEX_EXPECT_MODEL: "gpt-test-reviewer", + CEWP_FAKE_CODEX_EXPECT_EFFORT: "xhigh", + }, + }), "review with approved effort policy"); + assert(reviewed.data.run.reviewer.codex.taskClass === "high-effort-independent-review", "review evidence retains its task class"); + assert(reviewed.data.run.reviewer.codex.effectiveModel.value === "gpt-test-reviewer", "review evidence records the effective model"); + assert(reviewed.data.run.reviewer.codex.effectiveEffort.value === "xhigh", "review evidence records the effective effort"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + +try { + main(); + runRepairEffortContract(); + runEffortTamperContract(); + runStalePlanRevisionContract(); + console.log("[PASS] Codex task classes never trigger automatic model routing"); +} catch (error) { + console.error("[FAIL] Codex effort policy contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/integration-hook-evidence.js b/tests/contracts/integration-hook-evidence.js new file mode 100644 index 0000000..3422e86 --- /dev/null +++ b/tests/contracts/integration-hook-evidence.js @@ -0,0 +1,192 @@ +"use strict"; + +const fs = require("node:fs"); +const childProcess = require("node:child_process"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { recordSubagentHookEvent } = require("../../src/integration/hook-evidence"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function assertThrows(action, expected, label) { + let error; + try { + action(); + } catch (caught) { + error = caught; + } + assert(error, `${label}: expected an error`); + assert(expected.test(error.message), `${label}: unexpected error: ${error.message}`); +} + +function main() { + const repoRoot = makeTempRepo("cewp-hook-evidence-"); + try { + const run = approveWorkflow(repoRoot, validDefinition()); + const runPath = path.join(repoRoot, ".cewp", "workflow-runs", run.runId, "run.json"); + const runBefore = fs.readFileSync(runPath, "utf8"); + const refused = runNode(cewpCli, [ + "integration", "hooks", "approve", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(refused.status === 1, "hook trust cannot be activated without explicit --yes approval"); + assert(refused.stderr.includes("explicit operator approval"), "approval refusal explains the trust boundary"); + const approved = runNode(cewpCli, [ + "integration", "hooks", "approve", run.runId, "--yes", "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(approved.status === 0, `hook approval succeeds: ${approved.stderr}`); + const output = JSON.parse(approved.stdout); + assert(output.command === "integration.hooks.approve", "approval identifies the public command"); + assert(output.data.trust.schemaVersion === "codex-hook-trust/v1", "hook trust is versioned"); + assert(output.data.trust.cewpVersion === "0.11.0-beta.0", "approval binds the CEWP runtime version"); + assert(output.data.trust.codexVersion === "codex-cli 0.200.0", "approval binds the observed Codex version"); + assert(/^sha256:[a-f0-9]{64}$/.test(output.data.trust.bundleDigest), "approval binds the exact hook bundle"); + assert(output.data.nextAction.command === "/hooks", "approval still requires the host trust review"); + assert(fs.readFileSync(runPath, "utf8") === runBefore, "hook trust stays outside provider-neutral run state"); + + const inspected = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(inspected.status === 0, `hook status succeeds: ${inspected.stderr}`); + const status = JSON.parse(inspected.stdout); + assert(status.command === "integration.hooks.status", "status identifies the public inspection command"); + assert(status.data.compatible === true && status.data.active === true, "current approved hook evidence is active"); + assert(status.data.claims.coreEnforcement === false, "status never promotes hook evidence to Core enforcement"); + + const driftStatus = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.201.0" }, + }); + assert(driftStatus.status === 0, `hook drift status remains inspectable: ${driftStatus.stderr}`); + const drift = JSON.parse(driftStatus.stdout).data; + assert(drift.compatible === false && drift.active === false, "version drift disables trusted hook evidence"); + assert(drift.warnings[0].code === "codex-version-drift", "version drift has a stable compatibility code"); + assert(drift.fallback === "core-and-conversation-only", "version drift names the safe fallback"); + + const hookPath = path.join(__dirname, "..", "..", "plugins", "cewp", "hooks", "capture-subagent.js"); + const runHook = (input) => childProcess.spawnSync(process.execPath, [hookPath], { + cwd: repoRoot, + input: JSON.stringify(input), + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0", + CEWP_HOOK_CLI_COMMAND: process.execPath, + CEWP_HOOK_CLI_PREFIX_ARGS: JSON.stringify([cewpCli]), + }, + }); + const common = { + session_id: "parent-session-1", + transcript_path: path.join(repoRoot, "does-not-exist.jsonl"), + cwd: repoRoot, + model: "gpt-test-host", + turn_id: "parent-turn-1", + agent_id: "agent-1", + agent_type: "explorer", + permission_mode: "default", + }; + const started = runHook({ ...common, hook_event_name: "SubagentStart" }); + assert(started.status === 0, `SubagentStart hook succeeds: ${started.stderr}`); + assert(JSON.stringify(JSON.parse(started.stdout)) === "{}", "evidence-only start does not steer the subagent"); + const stopped = runHook({ + ...common, + hook_event_name: "SubagentStop", + agent_transcript_path: path.join(repoRoot, "also-does-not-exist.jsonl"), + stop_hook_active: false, + last_assistant_message: "Inspected the bounded files and found no scope issue.", + }); + assert(stopped.status === 0, `SubagentStop hook succeeds: ${stopped.stderr}`); + assert(JSON.stringify(JSON.parse(stopped.stdout)) === "{}", "evidence-only stop does not continue or block the subagent"); + + const ledgerPath = path.join( + repoRoot, ".cewp", "workflow-runs", run.runId, "integration", "subagent-hook-evidence.jsonl", + ); + const evidence = fs.readFileSync(ledgerPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert(evidence.length === 2, "start and stop lifecycle evidence are append-only"); + assert(evidence[0].type === "subagent-started" && evidence[1].type === "subagent-stopped", "supported lifecycle types are normalized"); + assert(evidence[1].references.agentId === "agent-1", "documented subagent id is preserved"); + assert(evidence[1].references.parentSessionId === "parent-session-1", "documented parent session is preserved"); + assert(evidence[1].references.agentThreadId.status === "unknown", "an unavailable subagent thread id is never invented"); + assert(evidence[1].summary.value.includes("no scope issue"), "bounded host summary is retained"); + assert(evidence[1].claims.coreEnforcement === false, "hook evidence never claims Core enforcement"); + + const ledgerBeforeDrift = fs.readFileSync(ledgerPath, "utf8"); + const versionDrift = childProcess.spawnSync(process.execPath, [hookPath], { + cwd: repoRoot, + input: JSON.stringify({ ...common, hook_event_name: "SubagentStart", agent_id: "agent-drift" }), + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + CEWP_HOOK_CODEX_VERSION: "codex-cli 0.201.0", + CEWP_HOOK_CLI_COMMAND: process.execPath, + CEWP_HOOK_CLI_PREFIX_ARGS: JSON.stringify([cewpCli]), + }, + }); + assert(versionDrift.status === 0, "version drift does not break the host lifecycle"); + assert(JSON.parse(versionDrift.stdout).systemMessage.includes("version drift"), "version drift is visible and actionable"); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "version drift cannot append trusted evidence"); + + const malformed = runHook({ ...common, hook_event_name: "SubagentStart", agent_id: null }); + assert(malformed.status === 0, "malformed hook input fails without breaking the host lifecycle"); + assert(JSON.parse(malformed.stdout).systemMessage.includes("agent_id is required"), "malformed input explains the compatibility failure"); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "malformed input cannot append evidence"); + + const changedPluginRoot = path.join(repoRoot, "changed-plugin"); + fs.mkdirSync(path.join(changedPluginRoot, "hooks"), { recursive: true }); + fs.copyFileSync( + path.join(__dirname, "..", "..", "plugins", "cewp", "hooks", "hooks.json"), + path.join(changedPluginRoot, "hooks", "hooks.json"), + ); + fs.copyFileSync(hookPath, path.join(changedPluginRoot, "hooks", "capture-subagent.js")); + fs.appendFileSync(path.join(changedPluginRoot, "hooks", "capture-subagent.js"), "\n// changed after review\n"); + assertThrows( + () => recordSubagentHookEvent({ + repoRoot, + input: { ...common, hook_event_name: "SubagentStart", agent_id: "agent-definition-drift" }, + codexVersion: "codex-cli 0.200.0", + pluginRoot: changedPluginRoot, + }), + /definition drift/, + "changed hook definitions require a fresh review", + ); + assert(fs.readFileSync(ledgerPath, "utf8") === ledgerBeforeDrift, "definition drift cannot append trusted evidence"); + assert(fs.readFileSync(runPath, "utf8") === runBefore, "hook failures and observations never mutate Core workflow state"); + + const trustPath = path.join( + repoRoot, ".cewp", "workflow-runs", run.runId, "integration", "codex-hook-trust.json", + ); + fs.writeFileSync(trustPath, "{ malformed trust receipt\n"); + const malformedTrustStatus = runNode(cewpCli, [ + "integration", "hooks", "status", run.runId, "--json", + ], repoRoot, { + env: { ...process.env, CEWP_HOOK_CODEX_VERSION: "codex-cli 0.200.0" }, + }); + assert(malformedTrustStatus.status === 0, "malformed trust remains inspectable through a fail-safe status"); + const malformedTrust = JSON.parse(malformedTrustStatus.stdout).data; + assert(malformedTrust.active === false && malformedTrust.compatible === false, "malformed trust disables evidence"); + assert(malformedTrust.warnings[0].code === "hook-trust-change", "malformed trust has a stable warning code"); + assert(malformedTrust.fallback === "core-and-conversation-only", "malformed trust preserves the safe fallback"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + main(); + console.log("[PASS] hook evidence is explicitly approved and version-bound"); +} catch (error) { + console.error("[FAIL] hook evidence integration contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/integration-mcp.js b/tests/contracts/integration-mcp.js new file mode 100644 index 0000000..2b962ab --- /dev/null +++ b/tests/contracts/integration-mcp.js @@ -0,0 +1,116 @@ +"use strict"; + +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo } = require("../harness/lib/temp-repo"); + +const mcpBin = path.join(__dirname, "..", "..", "bin", "cewp-mcp.js"); + +function request(id, method, params) { + return JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }); +} + +function runMcp(repoRoot, messages) { + const result = spawnSync(process.execPath, [mcpBin], { + cwd: repoRoot, + encoding: "utf8", + input: `${messages.join("\n")}\n`, + }); + assert(result.status === 0, `MCP server exits cleanly: ${result.stderr}`); + return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +} + +function call(id, name, args = {}) { + return request(id, "tools/call", { name, arguments: args }); +} + +function runContract() { + const repoRoot = makeTempRepo("cewp-integration-mcp-"); + try { + const responses = runMcp(repoRoot, [ + request(1, "initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "cewp-contract", version: "1.0.0" }, + }), + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + request(2, "tools/list", {}), + call(3, "cewp_create", { + goal: "Update the bounded file", + scopes: ["README.md"], + verificationCommands: ["git diff --check"], + stoppingConditions: ["The diff check passes"], + }), + ]); + + assert(responses.length === 3, "notifications do not receive responses"); + assert(responses[0].result.protocolVersion === "2025-11-25", "supported protocol is negotiated"); + assert(responses[0].result.compatibility.compatible === true, "matching MCP protocol is compatible"); + assert(responses[0].result.capabilities.tools, "server advertises tools capability"); + const names = responses[1].result.tools.map((tool) => tool.name); + assert(JSON.stringify(names) === JSON.stringify([ + "cewp_create", "cewp_inspect", "cewp_approve", "cewp_continue", + "cewp_retry", "cewp_revise", "cewp_verify", "cewp_finalize", + ]), "MCP exposes only the eight roadmap operations"); + const inspectTool = responses[1].result.tools.find((tool) => tool.name === "cewp_inspect"); + assert(inspectTool.annotations.readOnlyHint === false, "inspect truthfully declares its generated progress refresh"); + assert(responses[2].result.isError !== true, "create succeeds through MCP"); + const created = responses[2].result.structuredContent; + assert(created.run.status === "proposed", "create calls the supervised Core proposal service"); + assert(created.run.repo.root === repoRoot, "repository root is fixed to the MCP process cwd"); + + const runId = created.run.runId; + const gates = runMcp(repoRoot, [ + request(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "cewp-contract", version: "1.0.0" }, + }), + call(2, "cewp_inspect", { runId }), + call(3, "cewp_approve", { runId, confirm: false }), + call(4, "cewp_approve", { runId, confirm: true }), + call(5, "cewp_continue", { runId }), + call(6, "cewp_retry", { runId, confirm: false }), + call(7, "cewp_revise", { runId, goal: "Revised goal" }), + call(8, "cewp_verify", { runId }), + call(9, "cewp_finalize", { runId, confirm: false }), + call(10, "not_a_cewp_tool", {}), + call(11, "cewp_inspect", { runId: 7 }), + ]); + + assert(gates[1].result.structuredContent.run.status === "proposed", "inspect uses Core state inspection"); + assert(gates[2].result.isError === true && gates[2].result.content[0].text.includes("explicit confirmation"), "approve requires MCP confirmation"); + assert(gates[3].result.structuredContent.run.status === "approved", "confirmed approve reaches Core approval"); + assert(gates[4].result.isError === true && gates[4].result.content[0].text.includes("verified checkpoint"), "continue preserves checkpoint gate"); + assert(gates[5].result.isError === true && gates[5].result.content[0].text.includes("explicit confirmation"), "retry requires MCP confirmation before Core dispatch"); + assert(gates[6].result.structuredContent.run.status === "proposed", "revise calls Core and invalidates approval"); + assert(gates[7].result.isError === true && gates[7].result.content[0].text.includes("cannot verify"), "verify preserves Core state gate"); + assert(gates[8].result.isError === true && gates[8].result.content[0].text.includes("explicit confirmation"), "finalize requires MCP confirmation"); + assert(gates[9].error.code === -32602 && gates[9].error.message.includes("Unknown tool"), "unknown tools are protocol errors"); + assert(gates[10].error.code === -32602 && gates[10].error.message.includes("input schema"), "malformed tool arguments are protocol errors"); + + const drift = runMcp(repoRoot, [ + request(1, "initialize", { + protocolVersion: "2099-01-01", + capabilities: {}, + clientInfo: { name: "future-client", version: "1.0.0" }, + }), + ])[0].result; + assert(drift.protocolVersion === "2025-11-25", "unsupported MCP version negotiates a supported version"); + assert(drift.compatibility.compatible === false, "MCP protocol drift is explicit"); + assert(drift.compatibility.warning.code === "mcp-protocol-version-drift", "MCP drift has a stable warning code"); + assert(drift.compatibility.fallback === "cewp-cli-operator-json", "MCP drift names the CLI fallback"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] local MCP tools share supervised Core gates"); +} catch (error) { + console.error("[FAIL] local MCP integration contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/operator-report.js b/tests/contracts/operator-report.js new file mode 100644 index 0000000..d7b9a97 --- /dev/null +++ b/tests/contracts/operator-report.js @@ -0,0 +1,73 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode, writeFile } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun, startWorkflowTask } = require("../../src/workflow/state"); +const { buildEvidenceReceipt } = require("../../src/evidence/receipt"); +const { + buildOperatorReport, + renderOperatorReportHtml, + writeOperatorReport, +} = require("../../src/evidence/report"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function runContract() { + const repoRoot = makeTempRepo("cewp-operator-report-"); + try { + const definition = validDefinition(); + definition.goal = "Audit safely"; + const approved = approveWorkflow(repoRoot, definition); + let found = loadWorkflowRun(repoRoot, approved.runId); + startWorkflowTask(found, "implement-example", { + now: new Date(new Date(found.run.createdAt).getTime() + 1000), + }); + found = loadWorkflowRun(repoRoot, approved.runId); + writeFile(path.join(found.runRoot, "adapter-output", "prompt.md"), "REPORT_SECRET_PROMPT\n"); + const generatedAt = "2026-07-22T12:00:00.000Z"; + const receipt = buildEvidenceReceipt(found, { generatedAt }); + const first = buildOperatorReport(receipt); + const second = buildOperatorReport(receipt); + assert(first.schemaVersion === "operator-report/v1", "operator report is versioned"); + assert(JSON.stringify(first) === JSON.stringify(second), "operator report model is deterministic for one receipt"); + assert(first.progress.totalTasks === 2 && first.progress.activeTasks === 1, "report derives task progress"); + assert(Array.isArray(first.planRevisions) && Array.isArray(first.checkpoints), "report exposes revision and checkpoint evidence"); + assert(first.checkpoints[0].verification.baseline.status === "pending", "checkpoint verification evidence is preserved in the report model"); + assert(first.values.observed.managedOperations.label === "unknown", "report preserves observed-versus-unknown truth"); + assert(first.values.estimated.schemaVersion === "usage-estimate/v1", "report exposes estimates separately"); + assert(first.values.budgeted.compliance.status === "passed", "report exposes budget compliance"); + assert(first.reserves.allocations.some((entry) => entry.protected), "report identifies protected reserves"); + assert(first.controls.enforced.length === 0 && first.controls.observed.length === 0, "absent controls are not invented"); + assert(first.finalReview.status === "pending", "report presents final-review state without claiming PASS"); + + const html = renderOperatorReportHtml(first); + assert(html.includes("") && html.includes("Content-Security-Policy"), "report is standalone HTML with an offline policy"); + assert(!html.includes(""), "goal content is HTML escaped"); + assert(html.includes("<script>alert('unsafe')</script>"), "escaped goal remains understandable"); + assert(!/https?:\/\//.test(html), "report has no network dependency"); + assert(!html.includes("REPORT_SECRET_PROMPT"), "report excludes raw prompt content"); + assert(html.includes("Protected reserves") && html.includes("Final review"), "offline report includes critical operator sections"); + + const written = writeOperatorReport(found, { generatedAt }); + assert(fs.existsSync(written.paths.json) && fs.existsSync(written.paths.html), "operator JSON and HTML are written locally"); + const cli = runNode(cewpCli, ["workflow", "report", approved.runId, "--json"], repoRoot); + assert(cli.status === 0, `workflow report CLI succeeds: ${cli.stderr}`); + const output = JSON.parse(cli.stdout); + assert(output.command === "workflow.report" && output.data.report.schemaVersion === "operator-report/v1", "CLI returns the shared report model"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] static offline operator report"); +} catch (error) { + console.error("[FAIL] operator report contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/plugin-package.js b/tests/contracts/plugin-package.js index a2a4e1e..6520bdb 100644 --- a/tests/contracts/plugin-package.js +++ b/tests/contracts/plugin-package.js @@ -23,8 +23,24 @@ function runPluginPackageContract() { assert(manifest.version === packageJson.version, "plugin and npm versions stay aligned"); assert(manifest.skills === "./skills/", "plugin skill path is contained and relative"); assert(manifest.apps === undefined, "plugin does not claim an unbuilt app"); - assert(manifest.mcpServers === undefined, "plugin does not claim an unbuilt MCP server"); - assert(manifest.hooks === undefined, "plugin does not enable unreviewed hooks"); + assert(manifest.mcpServers === "./.mcp.json", "plugin declares one contained local MCP bundle"); + const mcpConfig = readJson(path.join(repoRoot, "plugins", "cewp", ".mcp.json")); + assert( + JSON.stringify(Object.keys(mcpConfig.mcpServers)) === JSON.stringify(["cewp"]), + "plugin MCP bundle declares only the CEWP local server", + ); + assert(mcpConfig.mcpServers.cewp.command === "cewp-mcp", "plugin MCP delegates to the installed CEWP Core binary"); + assert(packageJson.bin["cewp-mcp"] === "bin/cewp-mcp.js", "npm package exposes the declared MCP binary"); + assert(manifest.hooks === "./hooks/hooks.json", "plugin declares one contained reviewable hook bundle"); + const hookConfig = readJson(path.join(repoRoot, "plugins", "cewp", "hooks", "hooks.json")); + assert( + JSON.stringify(Object.keys(hookConfig.hooks).sort()) === JSON.stringify(["SubagentStart", "SubagentStop"]), + "plugin hooks are limited to subagent evidence events", + ); + assert( + fs.existsSync(path.join(repoRoot, "plugins", "cewp", "hooks", "capture-subagent.js")), + "declared hook handler exists", + ); assert( fs.existsSync(path.join(repoRoot, "plugins", "cewp", "assets", "cewp.svg")), "plugin asset exists", diff --git a/tests/contracts/run-comparison.js b/tests/contracts/run-comparison.js new file mode 100644 index 0000000..4a2015f --- /dev/null +++ b/tests/contracts/run-comparison.js @@ -0,0 +1,80 @@ +"use strict"; + +const path = require("node:path"); +const { assert } = require("../harness/lib/assertions"); +const { cleanupRepo, makeTempRepo, runNode } = require("../harness/lib/temp-repo"); +const { validDefinition } = require("./workflow-definition"); +const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun } = require("../../src/workflow/state"); +const { buildEvidenceReceipt } = require("../../src/evidence/receipt"); +const { compareEvidenceReceipts } = require("../../src/evidence/compare"); +const { createHostBinding } = require("../../src/integration/binding"); +const { supportedSnapshot } = require("./integration-capabilities"); + +const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); + +function runContract() { + const repoRoot = makeTempRepo("cewp-run-comparison-"); + try { + const managedDefinition = validDefinition(); + managedDefinition.workflowId = "comparison-managed"; + const nativeDefinition = validDefinition(); + nativeDefinition.workflowId = "comparison-native"; + nativeDefinition.execution = { owner: "native", backend: null, allowedModes: ["supervised"] }; + const managed = approveWorkflow(repoRoot, managedDefinition); + const native = approveWorkflow(repoRoot, nativeDefinition); + const nativeFound = loadWorkflowRun(repoRoot, native.runId); + createHostBinding(nativeFound, { + schemaVersion: "host-binding/v1", + workflow: { runId: native.runId, taskId: null, checkpointId: null }, + execution: { owner: "native", backend: null }, + host: { product: "codex", surface: "chatgpt-desktop", version: null }, + mode: "explicit-intake", + provenance: { + kind: "explicit-intake", + capabilitySchemaVersion: null, + authenticationBoundary: "host-owned", + recordedAt: "2026-07-22T12:59:00.000Z", + }, + references: { goalId: "native-goal-baseline-1", threadId: null, turnId: null, subagents: [], worktree: null }, + controls: { preventive: [], postExecution: [], imported: ["goal-reference"], unavailable: ["host-usage"] }, + }, { capabilities: supportedSnapshot() }); + const generatedAt = "2026-07-22T13:00:00.000Z"; + const left = buildEvidenceReceipt(loadWorkflowRun(repoRoot, managed.runId), { generatedAt }); + const right = buildEvidenceReceipt(loadWorkflowRun(repoRoot, native.runId), { generatedAt }); + const comparison = compareEvidenceReceipts(left, right); + assert(comparison.schemaVersion === "run-comparison/v1", "run comparison is versioned"); + assert(comparison.runs.left.execution.owner === "managed" && comparison.runs.right.execution.owner === "native", "execution owner and backend are compared"); + assert(comparison.runs.right.nativeGoalBaseline === true, "native-owner run is labeled as a native-goal baseline"); + const unboundNative = compareEvidenceReceipts(left, { + ...right, + integration: { nativeGoal: { status: "unknown", goalId: null, reason: "no supported native goal binding" } }, + }); + assert(unboundNative.runs.right.nativeGoalBaseline === false, "native ownership alone does not claim a native-goal baseline"); + assert(comparison.dimensions.outcome.left.completeness === "partial", "outcomes remain explicit"); + assert(comparison.dimensions.duration.left.label === "unknown", "unfinished duration remains unknown"); + assert(comparison.dimensions.modelTime.right.label === "unknown", "unavailable native model time remains unknown"); + assert(comparison.dimensions.usage.managedTokens.right.label === "unknown", "unavailable native usage is not converted to zero"); + assert(comparison.dimensions.apiEquivalentCost.right.label === "unknown", "native subscription work does not invent currency cost"); + assert(comparison.dimensions.cewpOverhead.left.label === "unknown", "unobserved CEWP overhead remains unknown"); + assert(comparison.dimensions.attempts.left.label === "observed" && comparison.dimensions.attempts.left.value === 0, "structural zero attempts are observed, not inferred usage"); + assert(comparison.dimensions.commands.left.length > 0 && comparison.dimensions.verification.left.commandCount > 0, "commands and verification evidence are comparable"); + assert(comparison.equivalence.status === "partial" && comparison.equivalence.unavailable.includes("model-time"), "comparison explains unavailable dimensions"); + + const cli = runNode(cewpCli, ["workflow", "compare", managed.runId, native.runId, "--json"], repoRoot); + assert(cli.status === 0, `workflow compare CLI succeeds: ${cli.stderr}`); + const output = JSON.parse(cli.stdout); + assert(output.command === "workflow.compare" && output.data.schemaVersion === "run-comparison/v1", "CLI exposes the shared comparison model"); + } finally { + cleanupRepo(repoRoot); + } +} + +try { + runContract(); + console.log("[PASS] honest managed and native run comparison"); +} catch (error) { + console.error("[FAIL] run comparison contract"); + console.error(error && error.stack ? error.stack : error); + process.exitCode = 1; +} diff --git a/tests/contracts/supervised-execution.js b/tests/contracts/supervised-execution.js index cc8ad5b..bd7d198 100644 --- a/tests/contracts/supervised-execution.js +++ b/tests/contracts/supervised-execution.js @@ -48,6 +48,57 @@ function createApprovedRun(repoRoot) { return runId; } +function runNativeOwnershipConflictContract() { + const repoRoot = makeTempRepo("cewp-supervised-native-conflict-"); + const fake = createFakeCodexAdapter(); + try { + const runId = createApprovedRun(repoRoot); + assert(runNode(cewpCli, ["policy", "set", "full-authority"], repoRoot).status === 0, "fixture grants worker authority"); + const runPath = path.join(repoRoot, ".cewp", "supervised-runs", runId, "run.json"); + const run = JSON.parse(fs.readFileSync(runPath, "utf8")); + const taskId = run.tasks[0].id; + const targetWorktree = path.resolve( + repoRoot, + "..", + ".cewp-worktrees", + path.basename(repoRoot), + runId, + taskId, + ); + const nativeOwnershipPath = path.join( + repoRoot, + ".cewp", + "workflow-runs", + "native-owner", + "integration", + "ownership.json", + ); + fs.mkdirSync(path.dirname(nativeOwnershipPath), { recursive: true }); + fs.writeFileSync(nativeOwnershipPath, `${JSON.stringify({ + schemaVersion: "execution-ownership/v1", + runId: "native-owner", + taskId, + checkpointId: `${taskId}-attempt-0001`, + owner: "native", + backend: null, + status: "active", + createdAt: "2026-07-18T12:00:00.000Z", + cleanupAuthority: "host-owner", + worktree: { id: `${runId}:${taskId}`, path: targetWorktree }, + }, null, 2)}\n`); + + const result = runNode(cewpCli, [ + "supervise", "execute", runId, "--yes", "--timeout", "20", "--json", + ], repoRoot, { env: fake.env }); + assert(result.status === 1, "managed dispatch rejects a native-owned task worktree"); + assert(result.stderr.includes("execution-ownership-conflict"), "ownership refusal is actionable"); + assert(!fs.existsSync(targetWorktree), "conflicting managed worktree is not created"); + } finally { + fs.rmSync(fake.fakeRoot, { recursive: true, force: true }); + cleanupRepo(repoRoot); + } +} + function runSupervisedExecutionContract() { const repoRoot = makeTempRepo("cewp-supervised-exec-"); const fake = createFakeCodexAdapter(); @@ -209,6 +260,7 @@ function runSupervisedExecutionContract() { } try { + runNativeOwnershipConflictContract(); runSupervisedExecutionContract(); console.log("[PASS] supervised dispatch preserves ownership, scope, and usage truth"); } catch (error) { diff --git a/tests/contracts/workflow-budget.js b/tests/contracts/workflow-budget.js index 165b4c5..27e0063 100644 --- a/tests/contracts/workflow-budget.js +++ b/tests/contracts/workflow-budget.js @@ -1,11 +1,14 @@ "use strict"; +const fs = require("node:fs"); const path = require("node:path"); const { assert } = require("../harness/lib/assertions"); const { evaluateWorkflowOperation } = require("../../src/workflow/budget"); const { validDefinition } = require("./workflow-definition"); const { successfulResult } = require("./workflow-result"); const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun } = require("../../src/workflow/state"); +const { buildEvidenceReceipt } = require("../../src/evidence/receipt"); const { cleanupRepo, makeTempRepo, @@ -119,6 +122,15 @@ function runWorkflowBudgetContract() { ], repoRoot).stdout).data.run; assert(paused.status === "paused-budget-safe", "budget refusal persists a safe pause"); assert(paused.budget.pauseReason === "implementation-allocation-exhausted", "pause reason is canonical state"); + const pausedReceipt = buildEvidenceReceipt(loadWorkflowRun(repoRoot, approved.runId), { + generatedAt: "2026-07-22T15:10:00.000Z", + }); + assert(pausedReceipt.completeness.status === "partial" && pausedReceipt.budget.pauseReason === "implementation-allocation-exhausted", "budget-paused run has an explanatory partial receipt"); + assert(pausedReceipt.budget.compliance.absoluteCeilingRespected === true, "paused receipt proves the absolute ceiling was respected"); + assert(pausedReceipt.budget.compliance.protectedAllocationsRespected === true, "paused receipt proves protected allocations were preserved"); + assert(pausedReceipt.events.some((entry) => entry.category === "safe-pause"), "safe pause is normalized in the event ledger"); + assert(pausedReceipt.events.some((entry) => entry.category === "threshold"), "budget refusal records the reached threshold"); + assert(pausedReceipt.events.some((entry) => entry.category === "warning-presentation"), "budget refusal records Core warning presentation"); const addBudget = runNode(cewpCli, [ "workflow", "intervene", approved.runId, @@ -152,6 +164,8 @@ function runWorkflowBudgetContract() { const hostPaused = JSON.parse(hostPause.stdout).data.run; assert(hostPaused.status === "paused-host-limit", "host limit has a distinct run state"); assert(hostPaused.budget.hostLimit.active === true, "host limit observation is retained"); + const hostPauseEvents = fs.readFileSync(path.join(repoRoot, ".cewp", "workflow-runs", approved.runId, "events.jsonl"), "utf8"); + assert(hostPauseEvents.includes("\"category\":\"host-limit\""), "manual host pause emits the host-limit lifecycle category"); const hostResume = runNode(cewpCli, [ "workflow", "intervene", approved.runId, "--event", "resume", diff --git a/tests/contracts/workflow-failure-result.js b/tests/contracts/workflow-failure-result.js index 1888ed0..e70fa78 100644 --- a/tests/contracts/workflow-failure-result.js +++ b/tests/contracts/workflow-failure-result.js @@ -11,6 +11,8 @@ const { } = require("../harness/lib/temp-repo"); const { validDefinition } = require("./workflow-definition"); const { approveWorkflow } = require("./workflow-scheduler"); +const { loadWorkflowRun } = require("../../src/workflow/state"); +const { buildEvidenceReceipt } = require("../../src/evidence/receipt"); const cewpCli = path.join(__dirname, "..", "..", "bin", "cewp.js"); @@ -109,6 +111,16 @@ function runWorkflowFailureResultContract() { const firstRetry = retry(repoRoot, run.runId); assert(firstRetry.status === 0, `first failure permits bounded retry: ${firstRetry.stderr}`); + const recoveryReceipt = buildEvidenceReceipt(loadWorkflowRun(repoRoot, run.runId), { + generatedAt: "2026-07-22T15:00:00.000Z", + }); + const recoveredTask = recoveryReceipt.tasks.find((task) => task.id === "implement-example"); + const failedCheckpoint = recoveryReceipt.checkpoints.find((entry) => entry.checkpointId === firstCheckpoint.checkpointId); + assert(recoveryReceipt.completeness.status === "partial", "recovery in progress produces a partial receipt"); + assert(failedCheckpoint.status === "blocked" && failedCheckpoint.failureClassification === "new-regression", "receipt identifies the failed checkpoint and classification"); + assert(recoveredTask.recovery.failureHistory[0].checkpointId === firstCheckpoint.checkpointId, "receipt retains the failure that triggered recovery"); + assert(recoveredTask.recovery.interventions.some((entry) => entry.event === "retry" && entry.reason === "Apply one bounded repair"), "receipt explains why the run continued"); + assert(recoveredTask.status === "ready" && recoveredTask.attempts === 1, "receipt shows retry changed the task back to ready without claiming success"); const secondCheckpoint = startTask(repoRoot, run.runId); assert(secondCheckpoint.budget.activeAllocation === "repair", "retry consumes only repair allocation"); const repeatedResult = recordFailure(repoRoot, run, secondCheckpoint, "implement-example-failure-2"); diff --git a/tests/contracts/workflow-lifecycle.js b/tests/contracts/workflow-lifecycle.js index 309a2f4..988f86b 100644 --- a/tests/contracts/workflow-lifecycle.js +++ b/tests/contracts/workflow-lifecycle.js @@ -56,6 +56,8 @@ function runWorkflowLifecycleContract() { const cancelled = JSON.parse(cancelledResult.stdout).data.run; assert(cancelled.status === "cancelled", "cancel is a terminal non-success run state"); assert(cancelled.tasks.every((task) => task.status === "cancelled"), "cancel cascades to all unfinished tasks"); + const cancellationEvents = fs.readFileSync(path.join(repoRoot, ".cewp", "workflow-runs", cancelledRun.runId, "events.jsonl"), "utf8"); + assert(cancellationEvents.includes("\"category\":\"cancellation\""), "cancellation has a dedicated lifecycle category"); const abandonedResult = intervene(repoRoot, cancelledRun.runId, "abandon", { reason: "Operator closes the cancelled run", }); diff --git a/tests/contracts/workflow-release.js b/tests/contracts/workflow-release.js index 23e0064..5405ce3 100644 --- a/tests/contracts/workflow-release.js +++ b/tests/contracts/workflow-release.js @@ -13,36 +13,38 @@ const plugin = JSON.parse(fs.readFileSync( const releaseNotes = fs.readFileSync(path.join(repoRoot, "docs", "release-notes.md"), "utf8"); function runWorkflowReleaseContract() { - assert(packageJson.version === "0.10.0-beta.0", "Phase 10 package version is exact"); + assert(packageJson.version === "0.11.0-beta.0", "Phase 11 package version is exact"); assert(plugin.version === packageJson.version, "plugin version follows the package version"); const unreleasedIndex = releaseNotes.indexOf("## Unreleased"); - const releaseIndex = releaseNotes.indexOf("## 0.10.0-beta.0"); - const previousIndex = releaseNotes.indexOf("## 0.8.0-beta.0"); - assert(unreleasedIndex >= 0 && releaseIndex > unreleasedIndex, "fresh Unreleased precedes the Phase 10 release"); - assert(previousIndex > releaseIndex, "Phase 10 release precedes earlier release history"); + const releaseIndex = releaseNotes.indexOf("## 0.11.0-beta.0"); + const previousIndex = releaseNotes.indexOf("## 0.10.0-beta.0"); + assert(unreleasedIndex >= 0 && releaseIndex > unreleasedIndex, "fresh Unreleased precedes the Phase 11 release"); + assert(previousIndex > releaseIndex, "Phase 11 release precedes earlier release history"); const unreleased = releaseNotes.slice(unreleasedIndex, releaseIndex); - assert(unreleased.includes("No changes yet."), "fresh Unreleased is explicitly empty"); + assert(unreleased.includes("Phase 12 development"), "post-Phase 11 work remains in Unreleased"); for (const claim of [ - "supervised golden path", - "workflow-compiler-request/v1", - "workflow-definition/v1", - "run-state/v2", - "one-, two-, and four-worker", - "OpenCode remains experimental", - "independent external pilot evidence remains Phase 13 validation debt", - "No provider, desktop UI, terminal server, native-goal control, merge, push, publish, tag, or release automation", + "native and managed ownership", + "no automatic model routing", + "SubagentStart", + "eight Core-backed MCP tools", + "observed, imported, stale, malformed, unavailable, and unknown", + "audit-only", + "App Server remains ungraduated", + "`codex-exec` fallback", + "external pilot evidence remains Phase 13 validation debt", + "No provider, desktop UI, terminal server, merge, push, publish, tag, or release automation", ]) { - assert(releaseNotes.slice(releaseIndex, previousIndex).includes(claim), `Phase 10 notes include honest claim: ${claim}`); + assert(releaseNotes.slice(releaseIndex, previousIndex).includes(claim), `Phase 11 notes include honest claim: ${claim}`); } - assert(packageJson.files.includes("docs/workflow-runtime.md"), "Phase 10 runtime guide is in the package surface"); + assert(packageJson.files.includes("docs/external-integration-boundary.md"), "Phase 11 boundary guide is in the package surface"); } try { runWorkflowReleaseContract(); - console.log("[PASS] Phase 10 version and release surface are aligned"); + console.log("[PASS] Phase 11 version and release surface are aligned"); } catch (error) { - console.error("[FAIL] Phase 10 release surface contract"); + console.error("[FAIL] Phase 11 release surface contract"); console.error(error && error.stack ? error.stack : error); process.exitCode = 1; } diff --git a/tests/harness/README.md b/tests/harness/README.md index cc20b65..945ed1c 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -25,6 +25,7 @@ npm run test:skill-format npm run test:ownership-gates npm run test:fixtures npm run test:plugin-package +npm run test:integration-hook-evidence ``` `npm test` runs these focused contracts before the broader smoke lifecycle. diff --git a/tests/harness/lib/fake-adapter.js b/tests/harness/lib/fake-adapter.js index 53438f2..bc528ac 100644 --- a/tests/harness/lib/fake-adapter.js +++ b/tests/harness/lib/fake-adapter.js @@ -38,6 +38,17 @@ if (args[0] !== "exec") { process.exit(2); } +const expectedModel = process.env.CEWP_FAKE_CODEX_EXPECT_MODEL; +const expectedEffort = process.env.CEWP_FAKE_CODEX_EXPECT_EFFORT; +if (expectedModel && valueAfter("--model") !== expectedModel) { + console.error("fake codex did not receive the approved --model override"); + process.exit(3); +} +if (expectedEffort && !args.includes('model_reasoning_effort="' + expectedEffort + '"')) { + console.error("fake codex did not receive the approved reasoning-effort override"); + process.exit(3); +} + function valueAfter(flag) { const index = args.indexOf(flag); return index === -1 ? undefined : args[index + 1]; diff --git a/tests/harness/run-smoke.js b/tests/harness/run-smoke.js index c176348..c442adb 100644 --- a/tests/harness/run-smoke.js +++ b/tests/harness/run-smoke.js @@ -2572,7 +2572,7 @@ async function main() { const pack = run("npm", ["pack", "--dry-run"], { cwd: cewpRoot, timeout: 120000 }); const packOutput = `${pack.stdout}\n${pack.stderr}`; assertExit(pack, 0, "npm pack --dry-run"); - assert(packageJson.version === "0.10.0-beta.0", `unexpected package version: ${packageJson.version}`); + assert(packageJson.version === "0.11.0-beta.0", `unexpected package version: ${packageJson.version}`); assert(packOutput.includes("docs/adapter-contract.md"), "adapter contract doc should be packed"); assert(packOutput.includes("docs/supervised-workflow.md"), "supervised workflow doc should be packed"); assert(packOutput.includes("docs/known-limitations.md"), "known limitations should be packed");