diff --git a/CHANGELOG.md b/CHANGELOG.md index a9390c8f..7fcede8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,84 @@ any change to one appears here. ## [Unreleased] -Work towards 0.2.0. Version is `0.2.0.dev0`; nothing here has shipped. +Nothing yet. + +## [0.2.0] — unreleased + +Everything below ships. `pip install ctrlrun` still installs nothing but `pyyaml` and +`click`; the gateway, the ACS hook and the OpenTelemetry sink live in extras. ### Added -- `docs/SPEC-v0.2.md` — the v0.2 contract, written as a delta over v0.1. Eight deliverables: - a reconciliation hook, `examples/` scripts and sector policy templates, per-action - `effect:` / `resource:` in policy, an `EventSink` protocol, `ctrlrun inspect`, an MCP - gateway, a webhook approval provider, and an OpenTelemetry sink. Acceptance tests T13–T31. -- Empty `gateway` and `otel` extras in `pyproject.toml`. `pip install ctrlrun` will keep - installing nothing but `pyyaml` and `click`; anything needing an HTTP server or a - third-party SDK goes in an extra, imported lazily. +- **MCP gateway** — `ctrlrun gateway --upstream --alias `, in `ctrlrun[gateway]`. + An existing MCP tool server gets CTRLRun semantics with no agent changes: `tools/call` + becomes an Action, everything else is relayed unchanged. The request forwarded upstream is + built from the action's *canonical* arguments, so what was hashed, reserved and recorded is + byte-for-byte what the tool receives. Serves `2026-07-28` and `2025-03-26`–`2025-11-25` in + passthrough. A fresh connection per intercepted call, because "the connection was never + established" is the only observation that proves non-execution. +- **Reconciliation hook** — `@protect(..., reconcile=...)`. The second authority permitted to + move a record out of `AMBIGUOUS`, and only where its answer points. An exception, a nonsense + return value and a hook that is never called all mean `"unknown"`, which changes nothing. +- **`Suspended` and `Control.resume`** — an executor may say "the remote asked for something + before it will finish". The record stays `EXECUTING`, the lease is extended, the + continuation is held, no receipt is written, and the signal reaches the caller. Built for + MCP elicitation; used by the ACS adapter for the same reason. +- **`EventSink`** — a protocol receiving every `Event` and `Receipt`, called after the + authoritative store write. `JSONLEventSink` is the v0.1 file writer under that interface. + A sink that raises is logged and skipped; it can never change a decision or an outcome. +- **`OTelEventSink`** — in `ctrlrun[otel]`. One OpenTelemetry span per action, one span event + per step, `ctrlrun.*` attributes. Argument values are withheld unless asked for. +- **`WebhookApprovalProvider`** — core, over stdlib `urllib.request`. One signed POST on + `APPROVAL_REQUESTED`; the gateway serves the signed inbound grant/deny at + `POST /ctrlrun/approvals/`. An undelivered notification is not an approval. +- **`ctrlrun inspect `** — one action's whole history: proposal, decision, + approvals, effect, receipt and the event timeline. `--json` emits `ctrlrun.inspection/v1`. +- **Policy `schema: ctrlrun.policy/v2`** — per-action `effect:`, `resource:` and `mcp:` + templates, needed because a gateway call has no decorator to carry them. Where a decorator + and the policy disagree, the decorator wins and the mismatch is warned about once. +- **ACS control hook** — `ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]`. Answers the + OWASP Agent Control Standard's `steps/toolCallRequest` and `steps/toolCallResult`. See + `docs/ACS.md` for the mapping and for the four places ACS is silent. **No compliance + claim**: at the commit read there is no ACS reference implementation and no conformance + suite, so there is nothing to be conformant with. +- **`examples/`** — four standalone failure scenarios, an ACS integration example, and nine + sector policy templates under `examples/policies/`. + +### Changed + +- `StateStore.append_event` returns the event as stored, where it returned `None`. Sinks must + be called with the `event_id` the store assigned, and the store is the only thing that knows + it. Callers that ignore the return value are unaffected. Recorded in `SPEC-v0.1.md` §8. +- `SQLiteStateStore` no longer writes JSONL. `Control` does, through `JSONLEventSink`, and + `Control.from_file()` installs one by default — so the two files land exactly where v0.1 put + them and existing evidence directories are unchanged. +- `docs/SPEC-v0.2.md` §9 amended: it forbade an ACS adapter on the reading that ACS had no + stable interface. The v0.1.0 schemas say otherwise, so the adapter ships. The no-claim rule + is untouched. + +### Removed + +- `SQLiteStateStore.journal`, and the `EventLog` class behind it. `JSONLEventSink` is that + class under the sink interface, and it is `Control`'s now. + +### Fixed + +- `.gitignore` ignored `ctrlrun.yaml` unanchored, so it matched at any depth and silently kept + every example's policy file out of the repository. Anchored to `/ctrlrun.yaml`. + +### Compatibility + +- **A v0.1 `ctrlrun.yaml` loads unchanged.** `ctrlrun.policy/v2` is opt-in and additive; a + document declaring `v1` that uses a v2 key is a load-time `PolicyError` naming the key and + the schema it needs, because a v0.1 reader would ignore the template and execute with no + duplicate protection at all. +- **The receipt schema is unchanged** — `ctrlrun.receipt/v1` still describes every receipt + v0.2 writes. `EFFECT_RESOLVED` gains `data.resolved_by`, and four event types join the set: + `RECONCILIATION_STARTED`, `RECONCILIATION_RESOLVED`, `EXECUTION_SUSPENDED`, + `EXECUTION_RESUMED`. +- A database written by v0.1 is read by v0.2 without migration: the one new table + (`continuations`) is created on open. ### Notes diff --git a/README.md b/README.md index c4a0d3f9..b91ab2c6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig Now the same agent can refund €100 on its own, must get a human to approve €2,000, and cannot refund €20,000 at all. Neither can it refund a negative amount, which is a charge wearing a refund's name — an upper bound alone is not a range. Unknown actions are denied. CTRLRun fails closed. +## Protect an existing MCP server + +No agent changes. Point the client at the gateway instead of at the tool server: + +```bash +pip install "ctrlrun[gateway]" +ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent +``` + +The gateway prints, on the line that starts it, every action in your policy that has no +`effect:` template — because a write with no effect key is exactly the configuration this +exists to prevent, and it should not be discovered in a receipt three weeks later: + +```text +1 action(s) have no effect: template and get no reservation: + mcp.acme.list_payments +That is right for a read, and wrong for anything that changes the world. +``` + +Tools become actions named `mcp..`, decided by the same `ctrlrun.yaml`. Declare +their effect and resource templates there, since a tool call has no decorator to carry them: + +```yaml +schema: ctrlrun.policy/v2 + +actions: + mcp.acme.create_refund: + effect: "refund:{payment_id}" + resource: "payment:{payment_id}" + decision: approve +``` + +Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the +retry exactly as it does in-process — that is the whole point of putting it here. + +## Two more things + +**Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a +function that asks the remote what happened to an effect, and it is the only thing besides a +human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer +points. + +**Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an +`OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values +stay out of it unless you ask for them. + ## What `ctrlrun demo` shows ```console @@ -125,6 +171,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does | Doc | Purpose | |---|---| | [`docs/SPEC-v0.1.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.1.md) | The v0.1 contract: models, invariants, acceptance tests | +| [`docs/SPEC-v0.2.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/SPEC-v0.2.md) | The v0.2 delta: gateway, sinks, reconciliation, webhooks | +| [`docs/ACS.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ACS.md) | The OWASP Agent Control Standard: what maps, and where it is silent | | [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions | | [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 | | [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't | diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index 0d9ba026..815fb472 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -8,7 +8,7 @@ removed from the README in the same commit — the README is not allowed to desc that no longer ships. If you find a row here that does not hold against the version you installed, that is a bug: please open an issue. -Regenerated for: **v0.1.0**. Line numbers refer to that tag. +Regenerated for: **v0.2.0**. Line numbers refer to that tag. ## The opening paragraph @@ -32,6 +32,35 @@ Regenerated for: **v0.1.0**. Line numbers refer to that tag. | "records what actually happened" | `ReceiptResult` — `receipt.py:44`; `Event` and JSONL `append_event` — `receipt.py:222` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | | "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:233` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:164`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load (`policy.py:50`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | + +## What v0.2 adds to the README + +Every sentence the README gained in this release, mapped the same way. + +| Claim | Code | Proof | +|---|---|---| +| "Point the client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:135`; `serve` — `gateway/server.py:892` | `test_T19_the_upstream_receives_the_canonical_arguments` | +| "No agent changes" | `tools/call` is intercepted and every other method relayed unchanged — `gateway/mcp.py:84` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | +| "The gateway prints … every action in your policy that has no `effect:` template" | `_announce_actions_without_an_effect` — `cli/main.py` | Shown in the README block; produced by `Policy.effect_template` — `policy.py:281` | +| "Tools become actions named `mcp..`" | `Gateway._intercept` — `gateway/server.py` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` | +| "Declare their effect and resource templates there" | `Policy.effect_template` / `resource_template` — `policy.py:281`; `McpOptions` — `policy.py:187` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | +| "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` | +| "A lost response over the wire blocks the retry exactly as it does in-process" | `classify` — `gateway/outcome.py:144`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:713`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | +| "and only in the direction its answer points" | `"unknown"` is absent from `RECONCILED_STATES` — `effect.py` | `test_T15_a_hook_that_cannot_answer_leaves_the_record_ambiguous` | +| "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | +| "Argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | + +### Two claims the README deliberately does not make + +- **Nothing about ACS conformance.** `ctrlrun.acs.AcsControlHook` (`acs.py:74`) exists and is + tested (T51–T55), but at the ACS commit read there is no reference implementation and no + conformance suite. "ACS-compatible" appears nowhere in the README, in docstrings, or in CLI + output. `docs/ACS.md` says what was read and where the standard is silent. +- **Nothing about exactly-once execution.** Unchanged from v0.1, and still the honest line: + CTRLRun guarantees it will not *knowingly* execute the same logical effect twice, and that + it will never treat an unknown outcome as a failure. + ## Two claims stated as limits The README also makes two negative claims. They matter as much as the positive ones. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dddaa461..e5dfd6d1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,18 +14,31 @@ Exit: all acceptance tests in `SPEC-v0.1.md §7` pass; demo < 60 s; README liter Standards: none. `THREAT_MODEL.md` is the only compliance-adjacent claim. -## v0.2 — Zero-friction deployment +## v0.2 — Zero-friction deployment (shipped) - MCP adapter and `ctrlrun gateway --upstream ` so an existing MCP tool server gets CTRLRun semantics with no agent changes. - OpenTelemetry export of events (align with ACS observability; don't invent a tracing format). - Webhook approval provider (Slack/Teams/anything that can POST back). - `ctrlrun inspect `. -- Reconciliation hook: executor may implement `check(effect_key) -> committed | not_executed | unknown` to resolve AMBIGUOUS automatically. +- Reconciliation hook: `@protect(..., reconcile=...)` resolves AMBIGUOUS automatically, and only where its answer points. - `examples/` directory with standalone scripts per scenario (`double-refund/`, `approval-mutation/`, `agent-race/`, `approval-replay/`). In v0.1 `ctrlrun demo` is the example; separate scripts earn their keep once there is more than one way to wire CTRLRun in. - Sector policy templates: `examples/policies/.yaml` for devops, payments, e-commerce, insurance, healthcare, legal, security, government, hr. Header comment: *"Starting point on the v0.1 kernel. Adapt before use."* Uses only v0.1 primitives. Adoption story: *existing MCP server + one CTRLRun gateway = action safety.* +**Reconciled against what shipped.** Three things arrived earlier than this file expected, and +one arrived that it did not list: + +- The **OWASP ACS adapter** was a v0.3 standards line. Reading the v0.1.0 schemas showed a + stable enough interface to build against, so it shipped here — with no compliance claim, and + `docs/ACS.md` recording where the standard is silent. +- **`Suspended` / `Control.resume`** were not on any milestone. MCP elicitation (§6.9) needs a + reservation held across a round trip the kernel does not control, and so does an advisory + hook model like ACS. It is public API now, frozen in `SPEC-v0.2.md` §11. +- **Policy `schema: ctrlrun.policy/v2`** grew out of the gateway rather than being planned: + a tool call has no decorator to carry an effect template. +- **`EventSink`** replaced the store's file writing, which the v0.1 kernel had owned. + Standards: OpenTelemetry export (code), MCP gateway. The OWASP ACS adapter shipped in v0.2 (see `docs/ACS.md`); "ACS-compatible" is still unearned and waits on an ACS conformance suite to measure against. ## v0.3 — Authority @@ -37,7 +50,7 @@ Standards: OpenTelemetry export (code), MCP gateway. The OWASP ACS adapter shipp This is the first point at which `VISION.md` may be opened for design input. -Standards: align principal/delegation semantics with NIST agent identity work and OAuth-based agent identity. Wording is "consumes identities from", never "implements". +Standards: align principal/delegation semantics with NIST agent identity work and OAuth-based agent identity. Wording is "consumes identities from", never "implements". `--principal-from-client-info` is removed here: it exists in v0.2 only because a v0.1 policy cannot address the principal at all, and the authority model makes a self-reported name an authorization input. ## v0.4 — Verification diff --git a/pyproject.toml b/pyproject.toml index cbd4cb6e..02fe523c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.2.0.dev0" +version = "0.2.0" description = "Make consequential AI-agent actions safe to execute." readme = "README.md" requires-python = ">=3.11"