ctrlrun 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrlrun-0.2.0/CHANGELOG.md +187 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/MANIFEST.in +6 -0
- {ctrlrun-0.1.0/src/ctrlrun.egg-info → ctrlrun-0.2.0}/PKG-INFO +55 -1
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/README.md +48 -0
- ctrlrun-0.2.0/docs/ACS.md +205 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/ARCHITECTURE.md +18 -5
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/CLAIMS.md +30 -1
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/ROADMAP.md +17 -4
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/SPEC-v0.1.md +7 -1
- ctrlrun-0.2.0/docs/SPEC-v0.2.md +1432 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/docs/THREAT_MODEL.md +37 -0
- ctrlrun-0.2.0/examples/acs/ctrlrun.yaml +19 -0
- ctrlrun-0.2.0/examples/acs/main.py +146 -0
- ctrlrun-0.2.0/examples/agent-race/ctrlrun.yaml +11 -0
- ctrlrun-0.2.0/examples/agent-race/main.py +104 -0
- ctrlrun-0.2.0/examples/approval-mutation/ctrlrun.yaml +13 -0
- ctrlrun-0.2.0/examples/approval-mutation/main.py +98 -0
- ctrlrun-0.2.0/examples/approval-replay/ctrlrun.yaml +13 -0
- ctrlrun-0.2.0/examples/approval-replay/main.py +100 -0
- ctrlrun-0.2.0/examples/double-refund/ctrlrun.yaml +11 -0
- ctrlrun-0.2.0/examples/double-refund/main.py +93 -0
- ctrlrun-0.2.0/examples/policies/devops.yaml +47 -0
- ctrlrun-0.2.0/examples/policies/e-commerce.yaml +54 -0
- ctrlrun-0.2.0/examples/policies/government.yaml +48 -0
- ctrlrun-0.2.0/examples/policies/healthcare.yaml +43 -0
- ctrlrun-0.2.0/examples/policies/hr.yaml +47 -0
- ctrlrun-0.2.0/examples/policies/insurance.yaml +47 -0
- ctrlrun-0.2.0/examples/policies/legal.yaml +45 -0
- ctrlrun-0.2.0/examples/policies/payments.yaml +46 -0
- ctrlrun-0.2.0/examples/policies/security.yaml +53 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/pyproject.toml +30 -1
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/__init__.py +11 -2
- ctrlrun-0.2.0/src/ctrlrun/acs.py +417 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/cli/demo.py +9 -2
- ctrlrun-0.2.0/src/ctrlrun/cli/main.py +574 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/control.py +505 -67
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/effect.py +105 -1
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/errors.py +46 -0
- ctrlrun-0.2.0/src/ctrlrun/gateway/__init__.py +94 -0
- ctrlrun-0.2.0/src/ctrlrun/gateway/legacy.py +50 -0
- ctrlrun-0.2.0/src/ctrlrun/gateway/mcp.py +315 -0
- ctrlrun-0.2.0/src/ctrlrun/gateway/outcome.py +195 -0
- ctrlrun-0.2.0/src/ctrlrun/gateway/server.py +914 -0
- ctrlrun-0.2.0/src/ctrlrun/otel.py +222 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/policy.py +143 -9
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/receipt.py +35 -6
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/state.py +368 -32
- ctrlrun-0.2.0/src/ctrlrun/webhook.py +372 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0/src/ctrlrun.egg-info}/PKG-INFO +55 -1
- ctrlrun-0.2.0/src/ctrlrun.egg-info/SOURCES.txt +83 -0
- ctrlrun-0.2.0/src/ctrlrun.egg-info/requires.txt +15 -0
- ctrlrun-0.2.0/tests/test_acs.py +491 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_concurrency.py +12 -4
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_demo.py +78 -25
- ctrlrun-0.2.0/tests/test_elicitation.py +388 -0
- ctrlrun-0.2.0/tests/test_examples.py +293 -0
- ctrlrun-0.2.0/tests/test_gateway.py +501 -0
- ctrlrun-0.2.0/tests/test_gateway_server.py +656 -0
- ctrlrun-0.2.0/tests/test_inspect.py +404 -0
- ctrlrun-0.2.0/tests/test_mcp.py +423 -0
- ctrlrun-0.2.0/tests/test_otel.py +477 -0
- ctrlrun-0.2.0/tests/test_outcome.py +266 -0
- ctrlrun-0.2.0/tests/test_packaging.py +190 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_policy.py +177 -1
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_protect.py +196 -0
- ctrlrun-0.2.0/tests/test_reconcile.py +643 -0
- ctrlrun-0.2.0/tests/test_resume.py +343 -0
- ctrlrun-0.2.0/tests/test_sinks.py +382 -0
- ctrlrun-0.2.0/tests/test_webhook.py +538 -0
- ctrlrun-0.1.0/CHANGELOG.md +0 -73
- ctrlrun-0.1.0/src/ctrlrun/cli/main.py +0 -260
- ctrlrun-0.1.0/src/ctrlrun.egg-info/SOURCES.txt +0 -40
- ctrlrun-0.1.0/src/ctrlrun.egg-info/requires.txt +0 -7
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/LICENSE +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/SECURITY.md +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/VISION.md +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/ctrlrun.example.yaml +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/setup.cfg +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/action.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/approval.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/cli/__init__.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun/py.typed +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/dependency_links.txt +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/entry_points.txt +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/src/ctrlrun.egg-info/top_level.txt +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/conftest.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_action.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_approval.py +0 -0
- {ctrlrun-0.1.0 → ctrlrun-0.2.0}/tests/test_effect.py +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
Public API names are frozen in `docs/SPEC-v0.1.md` §8. Before 1.0 they may still change, and
|
|
8
|
+
any change to one appears here.
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
Nothing yet.
|
|
13
|
+
|
|
14
|
+
## [0.2.0] — unreleased
|
|
15
|
+
|
|
16
|
+
Everything below ships. `pip install ctrlrun` still installs nothing but `pyyaml` and
|
|
17
|
+
`click`; the gateway, the ACS hook and the OpenTelemetry sink live in extras.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **MCP gateway** — `ctrlrun gateway --upstream <url> --alias <name>`, in `ctrlrun[gateway]`.
|
|
22
|
+
An existing MCP tool server gets CTRLRun semantics with no agent changes: `tools/call`
|
|
23
|
+
becomes an Action, everything else is relayed unchanged. The request forwarded upstream is
|
|
24
|
+
built from the action's *canonical* arguments, so what was hashed, reserved and recorded is
|
|
25
|
+
byte-for-byte what the tool receives. Serves `2026-07-28` and `2025-03-26`–`2025-11-25` in
|
|
26
|
+
passthrough. A fresh connection per intercepted call, because "the connection was never
|
|
27
|
+
established" is the only observation that proves non-execution.
|
|
28
|
+
- **Reconciliation hook** — `@protect(..., reconcile=...)`. The second authority permitted to
|
|
29
|
+
move a record out of `AMBIGUOUS`, and only where its answer points. An exception, a nonsense
|
|
30
|
+
return value and a hook that is never called all mean `"unknown"`, which changes nothing.
|
|
31
|
+
- **`Suspended` and `Control.resume`** — an executor may say "the remote asked for something
|
|
32
|
+
before it will finish". The record stays `EXECUTING`, the lease is extended, the
|
|
33
|
+
continuation is held, no receipt is written, and the signal reaches the caller. Built for
|
|
34
|
+
MCP elicitation; used by the ACS adapter for the same reason.
|
|
35
|
+
- **`EventSink`** — a protocol receiving every `Event` and `Receipt`, called after the
|
|
36
|
+
authoritative store write. `JSONLEventSink` is the v0.1 file writer under that interface.
|
|
37
|
+
A sink that raises is logged and skipped; it can never change a decision or an outcome.
|
|
38
|
+
- **`OTelEventSink`** — in `ctrlrun[otel]`. One OpenTelemetry span per action, one span event
|
|
39
|
+
per step, `ctrlrun.*` attributes. Argument values are withheld unless asked for.
|
|
40
|
+
- **`WebhookApprovalProvider`** — core, over stdlib `urllib.request`. One signed POST on
|
|
41
|
+
`APPROVAL_REQUESTED`; the gateway serves the signed inbound grant/deny at
|
|
42
|
+
`POST /ctrlrun/approvals/<request_id>`. An undelivered notification is not an approval.
|
|
43
|
+
- **`ctrlrun inspect <action_id>`** — one action's whole history: proposal, decision,
|
|
44
|
+
approvals, effect, receipt and the event timeline. `--json` emits `ctrlrun.inspection/v1`.
|
|
45
|
+
- **Policy `schema: ctrlrun.policy/v2`** — per-action `effect:`, `resource:` and `mcp:`
|
|
46
|
+
templates, needed because a gateway call has no decorator to carry them. Where a decorator
|
|
47
|
+
and the policy disagree, the decorator wins and the mismatch is warned about once.
|
|
48
|
+
- **ACS control hook** — `ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]`. Answers the
|
|
49
|
+
OWASP Agent Control Standard's `steps/toolCallRequest` and `steps/toolCallResult`. See
|
|
50
|
+
`docs/ACS.md` for the mapping and for the four places ACS is silent. **No compliance
|
|
51
|
+
claim**: at the commit read there is no ACS reference implementation and no conformance
|
|
52
|
+
suite, so there is nothing to be conformant with.
|
|
53
|
+
- **`examples/`** — four standalone failure scenarios, an ACS integration example, and nine
|
|
54
|
+
sector policy templates under `examples/policies/`.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- `StateStore.append_event` returns the event as stored, where it returned `None`. Sinks must
|
|
59
|
+
be called with the `event_id` the store assigned, and the store is the only thing that knows
|
|
60
|
+
it. Callers that ignore the return value are unaffected. Recorded in `SPEC-v0.1.md` §8.
|
|
61
|
+
- `SQLiteStateStore` no longer writes JSONL. `Control` does, through `JSONLEventSink`, and
|
|
62
|
+
`Control.from_file()` installs one by default — so the two files land exactly where v0.1 put
|
|
63
|
+
them and existing evidence directories are unchanged.
|
|
64
|
+
- `docs/SPEC-v0.2.md` §9 amended: it forbade an ACS adapter on the reading that ACS had no
|
|
65
|
+
stable interface. The v0.1.0 schemas say otherwise, so the adapter ships. The no-claim rule
|
|
66
|
+
is untouched.
|
|
67
|
+
|
|
68
|
+
### Deprecated
|
|
69
|
+
|
|
70
|
+
- **`ctrlrun gateway --principal-from-client-info` — removed in 0.3. Use
|
|
71
|
+
`--principal-header`.** It takes the agent's name from `_meta["io.modelcontextprotocol/
|
|
72
|
+
clientInfo"]`, which the MCP revision says is self-reported and *"SHOULD NOT"* be relied on
|
|
73
|
+
for security decisions. It is offerable in 0.2 only because of a fact that stops being true:
|
|
74
|
+
a v0.1 policy cannot address the principal at all (`SPEC-v0.1.md` §3.2 refuses `agent_eq`
|
|
75
|
+
and every other reserved name at load), so an unauthenticated principal misattributes
|
|
76
|
+
evidence and cannot widen an outcome. v0.3's authority model makes the principal an
|
|
77
|
+
authorization input, at which point a self-reported name cannot be one. The flag warns at
|
|
78
|
+
startup and its `--help` says so.
|
|
79
|
+
|
|
80
|
+
### Removed
|
|
81
|
+
|
|
82
|
+
- `SQLiteStateStore.journal`, and the `EventLog` class behind it. `JSONLEventSink` is that
|
|
83
|
+
class under the sink interface, and it is `Control`'s now.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- `.gitignore` ignored `ctrlrun.yaml` unanchored, so it matched at any depth and silently kept
|
|
88
|
+
every example's policy file out of the repository. Anchored to `/ctrlrun.yaml`.
|
|
89
|
+
|
|
90
|
+
### Compatibility
|
|
91
|
+
|
|
92
|
+
- **A v0.1 `ctrlrun.yaml` loads unchanged.** `ctrlrun.policy/v2` is opt-in and additive; a
|
|
93
|
+
document declaring `v1` that uses a v2 key is a load-time `PolicyError` naming the key and
|
|
94
|
+
the schema it needs, because a v0.1 reader would ignore the template and execute with no
|
|
95
|
+
duplicate protection at all.
|
|
96
|
+
- **The receipt schema is unchanged** — `ctrlrun.receipt/v1` still describes every receipt
|
|
97
|
+
v0.2 writes. `EFFECT_RESOLVED` gains `data.resolved_by`, and four event types join the set:
|
|
98
|
+
`RECONCILIATION_STARTED`, `RECONCILIATION_RESOLVED`, `EXECUTION_SUSPENDED`,
|
|
99
|
+
`EXECUTION_RESUMED`.
|
|
100
|
+
- A database written by v0.1 is read by v0.2 without migration: the one new table
|
|
101
|
+
(`continuations`) is created on open.
|
|
102
|
+
|
|
103
|
+
### Notes
|
|
104
|
+
|
|
105
|
+
- The spec is written against **MCP revision 2026-07-28**, which removed the `initialize`
|
|
106
|
+
handshake, protocol-level sessions and `Mcp-Session-Id`, and made `Mcp-Method` / `Mcp-Name`
|
|
107
|
+
required request headers that servers must validate against the body. The gateway will also
|
|
108
|
+
serve `2025-03-26` through `2025-11-25` in passthrough mode, relaying session ids, `GET` SSE
|
|
109
|
+
streams and `DELETE` without interpreting them; header–body validation applies only where the
|
|
110
|
+
headers exist. Decisions come from the parsed body on every revision, so header trust is
|
|
111
|
+
never the guarantee. The deprecated `2024-11-05` HTTP+SSE transport is not served.
|
|
112
|
+
- An MCP tool call held open across a multi round-trip elicitation keeps its effect reservation
|
|
113
|
+
in `EXECUTING` with an extended lease, so concurrent duplicates stay blocked for the whole
|
|
114
|
+
exchange and only the final result is mapped to an outcome. This needs the upstream to supply
|
|
115
|
+
a `requestState`, the protocol's only correlator and an optional one; without it the first
|
|
116
|
+
leg is `AMBIGUOUS`, because the alternative would let any client walk past duplicate
|
|
117
|
+
protection by inventing an `inputResponses` field.
|
|
118
|
+
- A policy file using the new `effect:` / `resource:` / `mcp:` keys must declare
|
|
119
|
+
`schema: ctrlrun.policy/v2`. `ctrlrun.policy/v1` files keep loading unchanged; a `v2` file
|
|
120
|
+
will not load on 0.1.0, which is the point — 0.1.0 would ignore the effect template and
|
|
121
|
+
execute with no duplicate protection.
|
|
122
|
+
- MCP tool arguments that CTRLRun cannot canonicalize — any JSON number with a fraction — will
|
|
123
|
+
be refused by the gateway, never rounded or coerced. Tools that move money through the
|
|
124
|
+
gateway need integer minor units or decimal strings in their schema.
|
|
125
|
+
|
|
126
|
+
## [0.1.0] — 2026-09-03
|
|
127
|
+
|
|
128
|
+
First packaged release. The v0.1 kernel is complete: every acceptance test in
|
|
129
|
+
`docs/SPEC-v0.1.md` §7 passes, including the multi-process concurrency test.
|
|
130
|
+
|
|
131
|
+
### Added
|
|
132
|
+
|
|
133
|
+
- **Action** — canonical form, `action_hash`, deep-frozen arguments. `float` is rejected in
|
|
134
|
+
arguments: `0.1` and `0.10` are the same money and different hashes.
|
|
135
|
+
- **Policy** — YAML loader with `ALLOW` / `APPROVE` / `DENY` and fail-closed defaults. An
|
|
136
|
+
unknown action is denied; there is no default-allow. See the config-breaking rule below
|
|
137
|
+
for how condition keys are validated.
|
|
138
|
+
- **`@ctrlrun.protect()`** — binds a function call to an Action, evaluates it, and executes
|
|
139
|
+
from the action's canonical arguments rather than the caller's objects.
|
|
140
|
+
- **Approval binding** — approvals carry the `action_hash` of what a human saw, and are
|
|
141
|
+
single-use and expiring. A mutated action cannot present an approval granted for another.
|
|
142
|
+
- **Effect key and reservation** — template-resolved effect identity, reserved atomically
|
|
143
|
+
across processes via `BEGIN IMMEDIATE` and a unique constraint on `effect_key`.
|
|
144
|
+
- **Effect state machine** — `NEW → RESERVED → EXECUTING → COMMITTED | FAILED | AMBIGUOUS`.
|
|
145
|
+
Only an executor raising `NotExecuted` produces `FAILED`; every other exception, timeouts
|
|
146
|
+
included, produces `AMBIGUOUS`, and only a human resolves it.
|
|
147
|
+
- **Receipts and events** — portable JSONL evidence for every action.
|
|
148
|
+
- **CLI** — `init`, `demo`, `approve`, `deny`, `receipts`, `effects`, `resolve`.
|
|
149
|
+
- **`ctrlrun demo`** — four failure scenarios, in process, no network.
|
|
150
|
+
- `SECURITY.md` and `docs/CLAIMS.md`, which maps every README claim to its code and test.
|
|
151
|
+
|
|
152
|
+
### Config-breaking rules
|
|
153
|
+
|
|
154
|
+
Rules that reject a policy file which an earlier build of this kernel would have loaded.
|
|
155
|
+
A `ctrlrun.yaml` written before this release may need an edit; the process refuses to start
|
|
156
|
+
until it gets one, which is the point.
|
|
157
|
+
|
|
158
|
+
- **A condition key naming an `Action` field is now a load-time `PolicyError`.** The
|
|
159
|
+
reserved names are `action_id`, `agent`, `environment`, `principal`, `resource` and
|
|
160
|
+
`user`. `when: { environment_eq: production }` reads exactly like it scopes a rule to
|
|
161
|
+
production, and matched nothing at all — conditions address an action's *arguments*, and
|
|
162
|
+
those are not arguments. Combined with a catch-all `decision: allow` beneath it, a rule
|
|
163
|
+
that looked restrictive silently permitted everything. If a protected function genuinely
|
|
164
|
+
takes an argument by one of those names, rename the argument (SPEC-v0.1 §3.2). Only the
|
|
165
|
+
whole name is reserved: `resource_id_eq` is unaffected.
|
|
166
|
+
- **A condition on an argument the action does not carry now logs a warning.** The decision
|
|
167
|
+
is unchanged — still false, still never an error, per SPEC-v0.1 §3.2 — but a typo such as
|
|
168
|
+
`amont_lte` no longer disappears in silence. Nothing to edit; expect new log output.
|
|
169
|
+
|
|
170
|
+
### Notes
|
|
171
|
+
|
|
172
|
+
- Requires Python ≥ 3.11. Runtime dependencies are `pyyaml` and `click`.
|
|
173
|
+
- Single-host only: reservation is atomic across processes on one machine via SQLite.
|
|
174
|
+
Multi-host needs the Postgres store planned for v0.6.
|
|
175
|
+
- Receipts are not signed. A database administrator can alter history (v0.6).
|
|
176
|
+
- Approver identity is free text and is not authenticated (v0.3).
|
|
177
|
+
- Generated ids (`act_`, `apr_`, `ctr_`) are 128 bits. An approval id is not a bearer token
|
|
178
|
+
in v0.1 — consuming one needs write access to the store — but it becomes one with the
|
|
179
|
+
webhook provider in v0.2, and an id format cannot be widened after records exist.
|
|
180
|
+
- Effect key templates do not escape placeholder values, so a crafted argument can make two
|
|
181
|
+
distinct effects share one key. The result is a refusal rather than a double execution;
|
|
182
|
+
`docs/THREAT_MODEL.md` states the limit and the workaround.
|
|
183
|
+
- Policy conditions address an action's arguments only. Scoping a rule by environment,
|
|
184
|
+
resource or principal arrives with the authority model in v0.3.
|
|
185
|
+
|
|
186
|
+
[Unreleased]: https://github.com/CTRLRun/ctrlrun/compare/v0.1.0...HEAD
|
|
187
|
+
[0.1.0]: https://github.com/CTRLRun/ctrlrun/releases/tag/v0.1.0
|
|
@@ -14,6 +14,12 @@ include VISION.md
|
|
|
14
14
|
recursive-include docs *.md
|
|
15
15
|
recursive-include tests *.py
|
|
16
16
|
|
|
17
|
+
# `examples/` travels because T31 runs every script and loads every sector template out of
|
|
18
|
+
# the source tree (SPEC-v0.2 §1.1). Without these the sdist would ship a test suite that
|
|
19
|
+
# fails on a fresh checkout, which is the failure `prune internal` exists to avoid.
|
|
20
|
+
recursive-include examples *.py
|
|
21
|
+
recursive-include examples *.yaml
|
|
22
|
+
|
|
17
23
|
# Local-only: working notes, and anything an operator's run left behind. Nothing here is
|
|
18
24
|
# picked up by default — these are belt and braces, so a stray file cannot ride along.
|
|
19
25
|
prune internal
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrlrun
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Make consequential AI-agent actions safe to execute.
|
|
5
5
|
Author-email: Arpan Ghoshal <contact@arpanghoshal.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -23,6 +23,12 @@ Provides-Extra: dev
|
|
|
23
23
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
24
|
Requires-Dist: mypy>=2.3; extra == "dev"
|
|
25
25
|
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
26
|
+
Provides-Extra: gateway
|
|
27
|
+
Requires-Dist: httpx>=0.27; extra == "gateway"
|
|
28
|
+
Provides-Extra: otel
|
|
29
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
31
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
|
|
26
32
|
Dynamic: license-file
|
|
27
33
|
|
|
28
34
|
# CTRLRun
|
|
@@ -96,6 +102,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig
|
|
|
96
102
|
|
|
97
103
|
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.
|
|
98
104
|
|
|
105
|
+
## Protect an existing MCP server
|
|
106
|
+
|
|
107
|
+
No agent changes. Point the client at the gateway instead of at the tool server:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pip install "ctrlrun[gateway]"
|
|
111
|
+
ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The gateway prints, on the line that starts it, every action in your policy that has no
|
|
115
|
+
`effect:` template — because a write with no effect key is exactly the configuration this
|
|
116
|
+
exists to prevent, and it should not be discovered in a receipt three weeks later:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
1 action(s) have no effect: template and get no reservation:
|
|
120
|
+
mcp.acme.list_payments
|
|
121
|
+
That is right for a read, and wrong for anything that changes the world.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Tools become actions named `mcp.<alias>.<tool>`, decided by the same `ctrlrun.yaml`. Declare
|
|
125
|
+
their effect and resource templates there, since a tool call has no decorator to carry them:
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
schema: ctrlrun.policy/v2
|
|
129
|
+
|
|
130
|
+
actions:
|
|
131
|
+
mcp.acme.create_refund:
|
|
132
|
+
effect: "refund:{payment_id}"
|
|
133
|
+
resource: "payment:{payment_id}"
|
|
134
|
+
decision: approve
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the
|
|
138
|
+
retry exactly as it does in-process — that is the whole point of putting it here.
|
|
139
|
+
|
|
140
|
+
## Two more things
|
|
141
|
+
|
|
142
|
+
**Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a
|
|
143
|
+
function that asks the remote what happened to an effect, and it is the only thing besides a
|
|
144
|
+
human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer
|
|
145
|
+
points.
|
|
146
|
+
|
|
147
|
+
**Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an
|
|
148
|
+
`OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values
|
|
149
|
+
stay out of it unless you ask for them.
|
|
150
|
+
|
|
99
151
|
## What `ctrlrun demo` shows
|
|
100
152
|
|
|
101
153
|
```console
|
|
@@ -152,6 +204,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does
|
|
|
152
204
|
| Doc | Purpose |
|
|
153
205
|
|---|---|
|
|
154
206
|
| [`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 |
|
|
207
|
+
| [`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 |
|
|
208
|
+
| [`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 |
|
|
155
209
|
| [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions |
|
|
156
210
|
| [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 |
|
|
157
211
|
| [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't |
|
|
@@ -69,6 +69,52 @@ Amounts are integer minor units — cents, not euros. Floats are rejected outrig
|
|
|
69
69
|
|
|
70
70
|
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.
|
|
71
71
|
|
|
72
|
+
## Protect an existing MCP server
|
|
73
|
+
|
|
74
|
+
No agent changes. Point the client at the gateway instead of at the tool server:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install "ctrlrun[gateway]"
|
|
78
|
+
ctrlrun gateway --upstream http://localhost:8000/mcp --alias acme --principal refund-agent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The gateway prints, on the line that starts it, every action in your policy that has no
|
|
82
|
+
`effect:` template — because a write with no effect key is exactly the configuration this
|
|
83
|
+
exists to prevent, and it should not be discovered in a receipt three weeks later:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
1 action(s) have no effect: template and get no reservation:
|
|
87
|
+
mcp.acme.list_payments
|
|
88
|
+
That is right for a read, and wrong for anything that changes the world.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Tools become actions named `mcp.<alias>.<tool>`, decided by the same `ctrlrun.yaml`. Declare
|
|
92
|
+
their effect and resource templates there, since a tool call has no decorator to carry them:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
schema: ctrlrun.policy/v2
|
|
96
|
+
|
|
97
|
+
actions:
|
|
98
|
+
mcp.acme.create_refund:
|
|
99
|
+
effect: "refund:{payment_id}"
|
|
100
|
+
resource: "payment:{payment_id}"
|
|
101
|
+
decision: approve
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Everything but `tools/call` is relayed untouched. A lost response over the wire blocks the
|
|
105
|
+
retry exactly as it does in-process — that is the whole point of putting it here.
|
|
106
|
+
|
|
107
|
+
## Two more things
|
|
108
|
+
|
|
109
|
+
**Resolving an unknown outcome without a human.** `@protect(..., reconcile=...)` takes a
|
|
110
|
+
function that asks the remote what happened to an effect, and it is the only thing besides a
|
|
111
|
+
human permitted to move a record out of `AMBIGUOUS` — and only in the direction its answer
|
|
112
|
+
points.
|
|
113
|
+
|
|
114
|
+
**Exporting to your tracing backend.** `pip install "ctrlrun[otel]"` adds an
|
|
115
|
+
`OTelEventSink`: one OpenTelemetry span per action, one span event per step. Argument values
|
|
116
|
+
stay out of it unless you ask for them.
|
|
117
|
+
|
|
72
118
|
## What `ctrlrun demo` shows
|
|
73
119
|
|
|
74
120
|
```console
|
|
@@ -125,6 +171,8 @@ CTRLRun cannot guarantee exactly-once execution against external systems it does
|
|
|
125
171
|
| Doc | Purpose |
|
|
126
172
|
|---|---|
|
|
127
173
|
| [`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 |
|
|
174
|
+
| [`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 |
|
|
175
|
+
| [`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 |
|
|
128
176
|
| [`docs/ARCHITECTURE.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ARCHITECTURE.md) | Kernel design and key decisions |
|
|
129
177
|
| [`docs/ROADMAP.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/ROADMAP.md) | v0.1 → v1.0 |
|
|
130
178
|
| [`docs/THREAT_MODEL.md`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/THREAT_MODEL.md) | What CTRLRun defends against and what it doesn't |
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# CTRLRun and the Agent Control Standard
|
|
2
|
+
|
|
3
|
+
What was read, what maps, what does not, and how the adapter is built.
|
|
4
|
+
|
|
5
|
+
## What was read
|
|
6
|
+
|
|
7
|
+
The [Agent Control Standard](https://agentcontrolstandard.org/), a project of the OWASP GenAI
|
|
8
|
+
Security Project, in
|
|
9
|
+
[`GenAI-Security-Project/agent-control-standard`](https://github.com/GenAI-Security-Project/agent-control-standard)
|
|
10
|
+
at commit **`c7ad162f69386daac94b89073e3b751e8cdf28b2`** (2026-08-11, *"Raise the Python floor
|
|
11
|
+
to unblock Dependabot security updates"*). The repository was at release **v0.1.1**, with the
|
|
12
|
+
specification version decoupled from the release version at commit `1af1f92` (2026-08-11) and
|
|
13
|
+
tracked separately at **v0.1.0**. Code is Apache-2.0; documentation is CC BY-SA 4.0.
|
|
14
|
+
|
|
15
|
+
Specifically:
|
|
16
|
+
|
|
17
|
+
| File | What it gave |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `specification/ACS/acs_schema.json` | the v0.1.0 aggregator; 22 hooks; `oneOf` a RequestEnvelope or a ResponseEnvelope |
|
|
20
|
+
| `specification/v0.1.0/request-envelope.json` | JSON-RPC 2.0; method namespaces; `AcsParams` and its `metadata` identity fields |
|
|
21
|
+
| `specification/v0.1.0/response-envelope.json` | `AcsResult`, and the five decisions |
|
|
22
|
+
| `specification/v0.1.0/hooks/tool-call-request.json` | `steps/toolCallRequest` — `tool`, `arguments`, `operation`, `capability`, `intent` |
|
|
23
|
+
| `specification/v0.1.0/hooks/tool-call-result.json` | `steps/toolCallResult` — `exit_status`, `outputs`, `request_id_ref`, `duration_ms` |
|
|
24
|
+
| `specification/v0.1.0/ask-details.json` | what an `ask` decision must carry |
|
|
25
|
+
| `CONTRIBUTING.md` | DCO required (`git commit -s`); spec changes open a Discussion first; no CLA |
|
|
26
|
+
|
|
27
|
+
Two things the repository does **not** have at that commit, both of which shaped this work:
|
|
28
|
+
|
|
29
|
+
- **No reference implementation.** The README's roadmap places a Guardian Agent sample and
|
|
30
|
+
FastMCP instrumentation at v1. There is a `pyproject.toml`, but it serves the docs and
|
|
31
|
+
version-sync tooling. So there is nothing to conform *to* except the schemas, and this
|
|
32
|
+
adapter is written against them directly.
|
|
33
|
+
- **No `examples/` directory**, and so no house format for a community example. `examples/acs/`
|
|
34
|
+
therefore follows CTRLRun's own convention.
|
|
35
|
+
|
|
36
|
+
## What ACS defines
|
|
37
|
+
|
|
38
|
+
Three layers: **Instrument** (runtime hooks and the Guardian Agent pattern), **Trace**
|
|
39
|
+
(OpenTelemetry and OCSF with agent-specific conventions), and **Inspect** (CycloneDX, SPDX and
|
|
40
|
+
SWID for a dynamic Agent BOM). Only Instrument is relevant here.
|
|
41
|
+
|
|
42
|
+
The wire format is JSON-RPC 2.0. A host fires a hook as a request whose `method` is
|
|
43
|
+
`steps/<hookName>`; a Guardian answers with an `AcsResult` carrying one of five decisions:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
allow · deny · modify · ask · defer
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`deny` requires `reasoning`. `modify` requires `reasoning` and `modifications`. `ask` requires
|
|
50
|
+
`reasoning` and `ask_details`. `defer` requires `reasoning` and `defer_details`.
|
|
51
|
+
|
|
52
|
+
Of the 22 hooks, CTRLRun answers **two**, and it is worth being explicit that it declines the
|
|
53
|
+
other twenty: `SessionStart`, `SessionEnd`, `AgentTrigger`, `TurnStart`, `TurnEnd`,
|
|
54
|
+
`UserMessage`, `AgentResponse`, `KnowledgeRetrieval`, `MemoryContextRetrieval`, `MemoryStore`,
|
|
55
|
+
`PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `SkillRegister`, `SkillLoad`,
|
|
56
|
+
`SkillUnload`, `SystemPing`, `AgbomSnapshot`, `AgbomChanged`. Those are checkpoints about what
|
|
57
|
+
the model is thinking, remembering or composed of. CTRLRun's product rule is that it decides
|
|
58
|
+
actions that can affect the real world, and nothing else — so an unanswered method returns a
|
|
59
|
+
JSON-RPC error in ACS's reserved range rather than an opinion.
|
|
60
|
+
|
|
61
|
+
## The mapping, hook by hook
|
|
62
|
+
|
|
63
|
+
### `steps/toolCallRequest` → build the Action, decide it, take the reservation
|
|
64
|
+
|
|
65
|
+
| ACS field | CTRLRun |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `params.metadata.agent_id` | `Principal.agent` |
|
|
68
|
+
| `params.metadata.user_context.user_id` | `Principal.user` |
|
|
69
|
+
| `params.metadata.environment` | `Action.environment` (ACS's enum passes through; v0.1 §2.1 takes any non-empty string) |
|
|
70
|
+
| `payload.tool.name`, `payload.tool.provider`, `payload.operation` | `Action.name`, as `<prefix>.<provider>.<tool>[.<operation>]` |
|
|
71
|
+
| `payload.arguments` | `Action.arguments`, unwrapped from `{name: {value, provenance}}` to `{name: value}` |
|
|
72
|
+
| — *(ACS has no resource field)* | `Action.resource`, from the policy's `resource:` template (SPEC-v0.2 §3) |
|
|
73
|
+
| `params.request_id` | the continuation that joins this hook to its result |
|
|
74
|
+
|
|
75
|
+
`operation` is part of the name because two verbs on one tool are two actions, and a policy has
|
|
76
|
+
to be able to say different things about `create` and `void`.
|
|
77
|
+
|
|
78
|
+
The decision maps out:
|
|
79
|
+
|
|
80
|
+
| CTRLRun | ACS |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `ALLOW` | `allow` |
|
|
83
|
+
| `DENY` | `deny` + `reasoning` + `reason_codes` |
|
|
84
|
+
| `APPROVE` (`ApprovalRequired`) | `ask` + `reasoning` + `ask_details` |
|
|
85
|
+
| `DuplicateEffect` | `deny`, `reason_codes: ["ctrlrun.duplicate_effect", <state>]` |
|
|
86
|
+
| `AmbiguousEffect` | `deny`, `reason_codes: ["ctrlrun.ambiguous_effect"]` |
|
|
87
|
+
| `ApprovalMismatch` | `deny`, `reason_codes: ["ctrlrun.blocked", <reason>]` |
|
|
88
|
+
|
|
89
|
+
`ask_details` carries `approver` (`{type: "human", id}`), a `question` naming the request id a
|
|
90
|
+
human answers with `ctrlrun approve`, and `timeout_seconds`. All three are required by
|
|
91
|
+
`ask-details.json`.
|
|
92
|
+
|
|
93
|
+
`ask_details.intent_extension` is **not** used. It grants capabilities for `this_request` or
|
|
94
|
+
`session`, which is an authority model — CTRLRun has none until v0.3, and a grant it cannot
|
|
95
|
+
represent is one it must not claim to honour.
|
|
96
|
+
|
|
97
|
+
### `steps/toolCallResult` → close the reservation
|
|
98
|
+
|
|
99
|
+
ACS describes this hook as *"fires after tool execution, before results reach the agent,
|
|
100
|
+
serving as an output redaction checkpoint"*. CTRLRun redacts nothing, so it always answers
|
|
101
|
+
`allow`; the work is the outcome it records.
|
|
102
|
+
|
|
103
|
+
`exit_status` is `success | failure | timeout | blocked`. **ACS does not say what any of them
|
|
104
|
+
means for the side effect.** This is the fail-closed reading, and it is the same one
|
|
105
|
+
SPEC-v0.2 §6.8 applies to MCP:
|
|
106
|
+
|
|
107
|
+
| `exit_status` | Effect state | Why |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `success` | `COMMITTED` | the only status that asserts the effect happened |
|
|
110
|
+
| `blocked` | `FAILED` | a control refused it before dispatch — this is what `NotExecuted` means |
|
|
111
|
+
| `failure` | `AMBIGUOUS` | a tool that failed *after* acting and one that failed *before* send the same string |
|
|
112
|
+
| `timeout` | `AMBIGUOUS` | the same, and the case this library exists for |
|
|
113
|
+
| `failure` **with** `mcp.not_executed_on_error: true` | `FAILED` | an operator's per-tool assertion (SPEC-v0.2 §3.1) |
|
|
114
|
+
|
|
115
|
+
A result whose `request_id_ref` matches no held reservation — a restarted Guardian, a result
|
|
116
|
+
fired twice, one arriving out of order — is logged and nothing is written about an effect.
|
|
117
|
+
Guessing which call it meant is how a duplicate gets committed.
|
|
118
|
+
|
|
119
|
+
## Where ACS is silent
|
|
120
|
+
|
|
121
|
+
These are not criticisms of ACS; they are the seam. ACS governs *whether an action may
|
|
122
|
+
proceed*. It has almost nothing to say about *what happened afterwards*, because that is not
|
|
123
|
+
what the Instrument layer was built for.
|
|
124
|
+
|
|
125
|
+
**1. No effect identity.** `params.request_id` is a per-hook-invocation UUID and
|
|
126
|
+
`request_id_ref` links a result to its request. Neither identifies the *effect*: two calls that
|
|
127
|
+
would refund the same payment get two unrelated UUIDs. There is no idempotency key, no
|
|
128
|
+
`capability`-plus-argument identity, nothing an implementer could use to recognise that a retry
|
|
129
|
+
is a repeat. CTRLRun supplies one from the policy's `effect:` template.
|
|
130
|
+
|
|
131
|
+
**2. `exit_status` is a status of the call, not an outcome of the effect.** Four values, and
|
|
132
|
+
the vocabulary itself carries the confusion: `timeout` sits alongside `failure` as though both
|
|
133
|
+
were kinds of not-working. The distinction that matters — *did the side effect land?* — has no
|
|
134
|
+
representation. A conformant Guardian reading `timeout` has no way to say "unknown, and a
|
|
135
|
+
retry is unsafe until a human resolves it".
|
|
136
|
+
|
|
137
|
+
**3. Nothing binds an approval to the exact action.** `ask_details` carries a question and an
|
|
138
|
+
approver. What comes back is a decision on *the request*, and `intent_extension` can widen a
|
|
139
|
+
capability for the session. Neither pins the approval to a canonical form of the arguments, so
|
|
140
|
+
nothing in ACS prevents an agent from getting `refund(2000)` approved and then calling
|
|
141
|
+
`refund(5000)`. CTRLRun binds to `action_hash`, which covers the principal, the arguments, the
|
|
142
|
+
resource and the environment.
|
|
143
|
+
|
|
144
|
+
**4. No terminal unknown state.** ACS's decisions are about the future of a call. There is no
|
|
145
|
+
way for a Guardian to record that an effect's outcome is unresolved and that *no* further call
|
|
146
|
+
on that effect may proceed until a human says which way it went. `AMBIGUOUS` has no ACS
|
|
147
|
+
counterpart, and it is the state most of CTRLRun's design exists to protect.
|
|
148
|
+
|
|
149
|
+
**5. The Guardian does not execute.** ACS is advisory by construction — the platform runs the
|
|
150
|
+
tool. That is a reasonable separation, but it means a Guardian cannot make reserve-and-execute
|
|
151
|
+
atomic. The best available is what this adapter does: reserve at the request hook, close at the
|
|
152
|
+
result hook, and accept that a platform which never fires the result hook leaves a reservation
|
|
153
|
+
to lease-expire into `AMBIGUOUS` by the ordinary path of v0.1 §5.3 E3.
|
|
154
|
+
|
|
155
|
+
## The adapter's design
|
|
156
|
+
|
|
157
|
+
`ctrlrun.acs.AcsControlHook`, in `ctrlrun[gateway]` — it needs no new dependency, and it is
|
|
158
|
+
kept out of core for the same reason the gateway is: `import ctrlrun` must not grow.
|
|
159
|
+
|
|
160
|
+
The shape is forced by the seam above. ACS is advisory and CTRLRun is executing, so one action
|
|
161
|
+
is split across two hooks and the reservation is held between them. That is exactly the shape
|
|
162
|
+
`Suspended` and `Control.resume` were built for in SPEC-v0.2 §6.9 — a reservation held across a
|
|
163
|
+
round trip the kernel does not control — so the adapter reuses them rather than reaching for
|
|
164
|
+
the store itself:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
steps/toolCallRequest
|
|
168
|
+
→ Action ← envelope metadata + payload + policy templates
|
|
169
|
+
→ Control.execute(action, executor=raise Suspended(request_id), effect_key)
|
|
170
|
+
· policy decided, approval consumed, effect reserved, EXECUTION_STARTED
|
|
171
|
+
· executor suspends: no outcome, no receipt, lease extended, continuation held
|
|
172
|
+
→ allow | deny | ask
|
|
173
|
+
|
|
174
|
+
steps/toolCallResult
|
|
175
|
+
→ Control.resume(request_id_ref, executor=report(exit_status))
|
|
176
|
+
· the same outcome mapping execute uses (v0.1 §5.5)
|
|
177
|
+
· commit / fail / ambiguous, one receipt, same action_id and attempt
|
|
178
|
+
→ allow
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`Control` remains the only module that composes the others (ARCHITECTURE §6). The adapter
|
|
182
|
+
translates two vocabularies and decides nothing.
|
|
183
|
+
|
|
184
|
+
**What it does not do.** It does not use `modify` — CTRLRun refuses or permits an action as
|
|
185
|
+
proposed, and rewriting an agent's arguments is a different product. It does not use `defer`.
|
|
186
|
+
It does not answer the other twenty hooks. It makes no claim of conformance: the schemas are
|
|
187
|
+
`v0.1.0`, the repository is a public preview, there is no reference implementation to test
|
|
188
|
+
against, and there is no conformance suite. **The words "ACS-compatible" do not appear in this
|
|
189
|
+
repository's README, docstrings or CLI output**, and should not until something exists to be
|
|
190
|
+
compatible *with*.
|
|
191
|
+
|
|
192
|
+
## What the OTel attributes would have to become
|
|
193
|
+
|
|
194
|
+
SPEC-v0.2 §8's sink emits `ctrlrun.*` attributes. ACS's Trace layer extends OpenTelemetry with
|
|
195
|
+
its own agent conventions and maps security events to OCSF; `acs_schema.json` carries
|
|
196
|
+
`TraceOtelMapping` and `TraceOcsfMapping` definitions for that purpose.
|
|
197
|
+
|
|
198
|
+
Aligning would mean renaming `ctrlrun.action.name`, `ctrlrun.principal.agent`,
|
|
199
|
+
`ctrlrun.decision` and the rest onto ACS's conventions. The cost is not the rename: it is that
|
|
200
|
+
receipts already written carry the old names, and a receipt is evidence that has to outlive the
|
|
201
|
+
tool that wrote it. Any alignment is therefore additive — emit both, deprecate neither — or it
|
|
202
|
+
is a schema version bump on the receipt, which SPEC-v0.2 §11 explicitly does not do.
|
|
203
|
+
|
|
204
|
+
That work is not in this release, and the mapping table is deliberately not written yet: it
|
|
205
|
+
would be a compliance claim with nothing behind it.
|
|
@@ -86,7 +86,7 @@ A retry is a new proposal (`action_id`) for the same logical effect (`effect_key
|
|
|
86
86
|
### 4.4 AMBIGUOUS is a first-class terminal state
|
|
87
87
|
A timeout after a request was sent is not a failure. The remote may have committed. Frameworks that map timeout → failed → retry are how double refunds happen. CTRLRun refuses to guess: `AMBIGUOUS` blocks retries until a human resolves it.
|
|
88
88
|
|
|
89
|
-
*Trade-off:* this creates operational work (someone must run `ctrlrun resolve`). That is the correct place for the work to land. v0.2
|
|
89
|
+
*Trade-off:* this creates operational work (someone must run `ctrlrun resolve`). That is the correct place for the work to land. v0.2 adds a `reconcile` hook (`SPEC-v0.2.md` §2) for executors that can ask the remote what happened: it is the second — and only other — authority permitted to move a record out of `AMBIGUOUS`, and only where its answer points. An answer it cannot give is `"unknown"`, which changes nothing.
|
|
90
90
|
|
|
91
91
|
### 4.5 The executor opts into FAILED
|
|
92
92
|
Only `NotExecuted` maps to `FAILED`. Every other exception is `AMBIGUOUS`. The library cannot know whether an arbitrary exception fired before or after the side effect; the executor author can. Making the safe outcome the default means a lazy integration is a safe integration.
|
|
@@ -135,16 +135,29 @@ Pragmas: `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`.
|
|
|
135
135
|
| Module | Owns | Must not know about |
|
|
136
136
|
|---|---|---|
|
|
137
137
|
| `action.py` | model, canonicalization, hash | policy, storage |
|
|
138
|
-
| `policy.py` | YAML → rules → Decision | approvals,
|
|
138
|
+
| `policy.py` | YAML → rules → Decision; `effect:`/`resource:` templates | approvals, effect *state* |
|
|
139
139
|
| `approval.py` | request/grant/consume, providers | executors |
|
|
140
140
|
| `effect.py` | key templating, state enum, transition rules | SQLite |
|
|
141
|
-
| `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator |
|
|
142
|
-
| `control.py` | `Control` orchestration, decorator, context | CLI |
|
|
143
|
-
| `receipt.py` | Receipt/Event models, JSONL
|
|
141
|
+
| `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator, sinks |
|
|
142
|
+
| `control.py` | `Control` orchestration, decorator, context, suspend/resume | CLI |
|
|
143
|
+
| `receipt.py` | Receipt/Event models, `EventSink`, JSONL sink | everything else |
|
|
144
144
|
| `cli/` | click commands, demo | internals beyond `Control` |
|
|
145
145
|
|
|
146
146
|
Dependencies point downward only. `Control` is the only module that composes the others.
|
|
147
147
|
|
|
148
|
+
The gateway (v0.2) does not change this. It builds an Action and calls `Control` — including
|
|
149
|
+
`Control.resume` for an elicitation's second leg — rather than reserving and committing for
|
|
150
|
+
itself. A gateway that owned the reservation would be a second module composing the others,
|
|
151
|
+
and a second implementation of SPEC-v0.1 §5.5's asymmetry, which is the one rule in this
|
|
152
|
+
codebase that must not drift.
|
|
153
|
+
|
|
154
|
+
One exception, added in v0.2 and worth stating rather than discovering: `policy.py` imports the
|
|
155
|
+
template grammar (`template_placeholders`) from `effect.py`, because SPEC-v0.2 §3.1 requires an
|
|
156
|
+
`effect:` / `resource:` template to be validated when the policy loads and the grammar is
|
|
157
|
+
security-critical enough that a second copy of it is worse than the import. Policy still knows
|
|
158
|
+
nothing of effect state — no records, no transitions, no reservations — and `effect.py` does not
|
|
159
|
+
import `policy.py`, so there is no cycle.
|
|
160
|
+
|
|
148
161
|
## 7. What changes after v0.1 (and what doesn't)
|
|
149
162
|
|
|
150
163
|
Stable from v0.1 onward: the four public concepts, the action canonical form (versioned), the effect state machine, fail-closed defaults, the executor outcome mapping.
|
|
@@ -8,7 +8,7 @@ removed from the README in the same commit — the README is not allowed to desc
|
|
|
8
8
|
that no longer ships. If you find a row here that does not hold against the version you
|
|
9
9
|
installed, that is a bug: please open an issue.
|
|
10
10
|
|
|
11
|
-
Regenerated for: **v0.
|
|
11
|
+
Regenerated for: **v0.2.0**. Line numbers refer to that tag.
|
|
12
12
|
|
|
13
13
|
## The opening paragraph
|
|
14
14
|
|
|
@@ -32,6 +32,35 @@ Regenerated for: **v0.1.0**. Line numbers refer to that tag.
|
|
|
32
32
|
| "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` |
|
|
33
33
|
| "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` |
|
|
34
34
|
|
|
35
|
+
|
|
36
|
+
## What v0.2 adds to the README
|
|
37
|
+
|
|
38
|
+
Every sentence the README gained in this release, mapped the same way.
|
|
39
|
+
|
|
40
|
+
| Claim | Code | Proof |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| "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` |
|
|
43
|
+
| "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` |
|
|
44
|
+
| "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` |
|
|
45
|
+
| "Tools become actions named `mcp.<alias>.<tool>`" | `Gateway._intercept` — `gateway/server.py` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` |
|
|
46
|
+
| "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` |
|
|
47
|
+
| "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` |
|
|
48
|
+
| "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` |
|
|
49
|
+
| "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` |
|
|
50
|
+
| "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` |
|
|
51
|
+
| "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` |
|
|
52
|
+
| "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` |
|
|
53
|
+
|
|
54
|
+
### Two claims the README deliberately does not make
|
|
55
|
+
|
|
56
|
+
- **Nothing about ACS conformance.** `ctrlrun.acs.AcsControlHook` (`acs.py:74`) exists and is
|
|
57
|
+
tested (T51–T55), but at the ACS commit read there is no reference implementation and no
|
|
58
|
+
conformance suite. "ACS-compatible" appears nowhere in the README, in docstrings, or in CLI
|
|
59
|
+
output. `docs/ACS.md` says what was read and where the standard is silent.
|
|
60
|
+
- **Nothing about exactly-once execution.** Unchanged from v0.1, and still the honest line:
|
|
61
|
+
CTRLRun guarantees it will not *knowingly* execute the same logical effect twice, and that
|
|
62
|
+
it will never treat an unknown outcome as a failure.
|
|
63
|
+
|
|
35
64
|
## Two claims stated as limits
|
|
36
65
|
|
|
37
66
|
The README also makes two negative claims. They matter as much as the positive ones.
|