From 86fd26a349c6e0bc7993553fcc51d6a38d02e7da Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 4 Sep 2026 03:02:56 +0530 Subject: [PATCH] item 9: release 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 0.2.0. Not tagged, not published. README gains the second quick start — an existing MCP server behind one gateway — with the gateway's real startup output, the one that names every action with no effect: template. A write with no effect key is exactly the configuration this exists to prevent, and it belongs on the line that starts the process rather than in a receipt three weeks later. Plus one sentence each on reconcile= and the OTel extra, and links to SPEC-v0.2 and ACS.md. docs/CLAIMS.md regenerated for v0.2.0: every sentence the README gained, mapped to code and to the test that proves it, and a short section naming the two claims the README deliberately does not make — ACS conformance, and exactly-once execution. CHANGELOG 0.2.0 with a Compatibility section: a v0.1 ctrlrun.yaml loads unchanged, the receipt schema is unchanged, and a v0.1 database is read without migration because the one new table is created on open. ROADMAP reconciled against what actually shipped rather than what it planned. Three things arrived early and one was never listed: the ACS adapter was a v0.3 standards line; Suspended/Control.resume were on no milestone at all and are public API now; policy schema v2 grew out of the gateway; EventSink took file writing off the store. v0.3's line records that --principal-from-client-info is removed there. --- CHANGELOG.md | 83 ++++++++++++++++++++++++++++++++++++++++++++----- README.md | 48 ++++++++++++++++++++++++++++ docs/CLAIMS.md | 31 +++++++++++++++++- docs/ROADMAP.md | 19 +++++++++-- pyproject.toml | 2 +- 5 files changed, 170 insertions(+), 13 deletions(-) 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"