ctrlrun 0.1.0__py3-none-any.whl
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/__init__.py +67 -0
- ctrlrun/action.py +161 -0
- ctrlrun/approval.py +447 -0
- ctrlrun/cli/__init__.py +1 -0
- ctrlrun/cli/demo.py +300 -0
- ctrlrun/cli/main.py +260 -0
- ctrlrun/control.py +822 -0
- ctrlrun/effect.py +296 -0
- ctrlrun/errors.py +114 -0
- ctrlrun/policy.py +389 -0
- ctrlrun/py.typed +0 -0
- ctrlrun/receipt.py +229 -0
- ctrlrun/state.py +1131 -0
- ctrlrun-0.1.0.dist-info/METADATA +164 -0
- ctrlrun-0.1.0.dist-info/RECORD +19 -0
- ctrlrun-0.1.0.dist-info/WHEEL +5 -0
- ctrlrun-0.1.0.dist-info/entry_points.txt +2 -0
- ctrlrun-0.1.0.dist-info/licenses/LICENSE +202 -0
- ctrlrun-0.1.0.dist-info/top_level.txt +1 -0
ctrlrun/cli/demo.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The four-scenario demo with an in-process fake Stripe. Build-list item 8; SPEC §7 T11.
|
|
2
|
+
|
|
3
|
+
Four ways an agent action goes wrong at the boundary between intention and effect, and what
|
|
4
|
+
CTRLRun does about each. Everything runs in this process: no network, no clock skew, no
|
|
5
|
+
sleeping. The fake remote is the only thing pretending — and it pretends in the one way that
|
|
6
|
+
matters, by committing before its response goes missing.
|
|
7
|
+
|
|
8
|
+
The demo keeps its evidence in `.ctrlrun/demo/`, never in the store a real agent is using:
|
|
9
|
+
it reserves effect keys and would otherwise collide with — or block — live work.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shlex
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Final, TypeVar
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from ..approval import ScriptedApprovalProvider
|
|
22
|
+
from ..control import Control, context, protect, with_approval
|
|
23
|
+
from ..errors import (
|
|
24
|
+
AmbiguousEffect,
|
|
25
|
+
ApprovalMismatch,
|
|
26
|
+
ApprovalRequired,
|
|
27
|
+
DuplicateEffect,
|
|
28
|
+
)
|
|
29
|
+
from ..policy import Policy
|
|
30
|
+
from ..receipt import EVENTS_FILENAME, RECEIPTS_FILENAME
|
|
31
|
+
from ..state import SQLiteStateStore
|
|
32
|
+
|
|
33
|
+
#: Where the demo keeps its own store and evidence, under `.ctrlrun/`.
|
|
34
|
+
DEMO_DIRNAME: Final = "demo"
|
|
35
|
+
|
|
36
|
+
#: The headings `ctrlrun demo` prints, in order (README, "What `ctrlrun demo` shows").
|
|
37
|
+
SCENARIO_HEADINGS: Final = (
|
|
38
|
+
"Duplicate effect after a lost response",
|
|
39
|
+
"Approval mutation",
|
|
40
|
+
"Concurrent agents, same effect",
|
|
41
|
+
"Approval replay",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
#: What each scenario refunds, in integer minor units (SPEC-v0.1 §2.3). The README's
|
|
45
|
+
#: "What `ctrlrun demo` shows" quotes the first three, and
|
|
46
|
+
#: `test_the_readme_demo_section_shows_the_amounts_the_demo_uses` keeps the two in step:
|
|
47
|
+
#: the demo is the truth, and the README follows it.
|
|
48
|
+
LOST_RESPONSE_AMOUNT: Final = 50000 # €500 — autonomous
|
|
49
|
+
PROPOSED_AMOUNT: Final = 200000 # €2,000 — the refund a human approves
|
|
50
|
+
MUTATED_AMOUNT: Final = 500000 # €5,000 — what the agent tried to run instead
|
|
51
|
+
CONCURRENT_AMOUNT: Final = 50000 # €500 — autonomous, raced for by two agents
|
|
52
|
+
REPLAY_AMOUNT: Final = 200000 # €2,000 — approved once, presented twice
|
|
53
|
+
|
|
54
|
+
#: The amounts the README prints, in the order it prints them.
|
|
55
|
+
README_AMOUNTS: Final = (LOST_RESPONSE_AMOUNT, PROPOSED_AMOUNT, MUTATED_AMOUNT)
|
|
56
|
+
|
|
57
|
+
#: Amounts are integer minor units (SPEC-v0.1 §2.3): €1,000 autonomous, €10,000 with a human.
|
|
58
|
+
DEMO_POLICY: Final = """
|
|
59
|
+
schema: ctrlrun.policy/v1
|
|
60
|
+
actions:
|
|
61
|
+
customer.read:
|
|
62
|
+
decision: allow
|
|
63
|
+
stripe.refund:
|
|
64
|
+
rules:
|
|
65
|
+
- when: { amount_lte: 100000 }
|
|
66
|
+
decision: allow
|
|
67
|
+
- when: { amount_lte: 1000000 }
|
|
68
|
+
decision: approve
|
|
69
|
+
- decision: deny
|
|
70
|
+
iam.grant_admin:
|
|
71
|
+
decision: deny
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_BLOCKED: Final = " ✗ BLOCKED — "
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FakeStripe:
|
|
78
|
+
"""An in-process payment API. It commits first, then decides how the reply goes wrong.
|
|
79
|
+
|
|
80
|
+
That order is the whole point: a remote that fails *before* doing anything is easy, and
|
|
81
|
+
the executor can say so by raising `NotExecuted`. The dangerous remote is this one.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.calls: list[str] = []
|
|
86
|
+
self.lose_response: set[str] = set()
|
|
87
|
+
self.on_call: dict[str, Callable[[], None]] = {}
|
|
88
|
+
|
|
89
|
+
def refund(self, payment_id: str, amount: int) -> dict[str, Any]:
|
|
90
|
+
self.calls.append(payment_id) # the money has moved by the time anything else runs
|
|
91
|
+
hook = self.on_call.pop(payment_id, None)
|
|
92
|
+
if hook is not None:
|
|
93
|
+
hook()
|
|
94
|
+
if payment_id in self.lose_response:
|
|
95
|
+
raise TimeoutError("no response from api.stripe.com after 30s")
|
|
96
|
+
return {"id": f"re_{payment_id}", "amount": amount, "status": "succeeded"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_Refusal = TypeVar("_Refusal", bound=BaseException)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _euros(minor_units: int) -> str:
|
|
103
|
+
return f"€{minor_units // 100:,}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _heading(number: int, text: str) -> None:
|
|
107
|
+
click.echo("")
|
|
108
|
+
click.echo(f"{number}. {text}")
|
|
109
|
+
click.echo("")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _refused(call: Callable[[], Any], expected: type[_Refusal]) -> _Refusal:
|
|
113
|
+
"""Run something the kernel must refuse, and return the refusal.
|
|
114
|
+
|
|
115
|
+
A scenario that is not blocked has demonstrated nothing, and would print nothing to say
|
|
116
|
+
so. Raising here makes `ctrlrun demo` fail loudly instead of quietly passing.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
call()
|
|
120
|
+
except expected as blocked:
|
|
121
|
+
return blocked
|
|
122
|
+
raise AssertionError(f"the demo expected {expected.__name__} and the action went through")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_demo(root: Path) -> None:
|
|
126
|
+
"""Run the four scenarios under `root`, printing what each one blocks (SPEC §7 T11)."""
|
|
127
|
+
evidence = _fresh_evidence_dir(root)
|
|
128
|
+
store = SQLiteStateStore(evidence / "state.db")
|
|
129
|
+
remote = FakeStripe()
|
|
130
|
+
# Two grants: scenario 2's human, and scenario 4's. A scripted approver never grants
|
|
131
|
+
# more than it was told to, so a scenario that asked twice would fail loudly.
|
|
132
|
+
approvals = ScriptedApprovalProvider(store, ["grant", "grant"], approver="human:demo")
|
|
133
|
+
control = Control(Policy.from_yaml(DEMO_POLICY, source="<demo>"), store, approvals)
|
|
134
|
+
|
|
135
|
+
@protect("stripe.refund", effect="refund:{payment_id}", control=control)
|
|
136
|
+
def refund(payment_id: str, amount: int, currency: str = "EUR") -> dict[str, Any]:
|
|
137
|
+
return remote.refund(payment_id, amount)
|
|
138
|
+
|
|
139
|
+
click.echo("CTRLRun demo — four ways an agent action goes wrong, and what stops it.")
|
|
140
|
+
click.echo(
|
|
141
|
+
"Policy: refunds up to €1,000 are autonomous, up to €10,000 need a human, "
|
|
142
|
+
"above that are denied."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
_lost_response(refund, remote)
|
|
147
|
+
_approval_mutation(refund, approvals)
|
|
148
|
+
_concurrent_agents(refund, remote)
|
|
149
|
+
_approval_replay(refund, approvals)
|
|
150
|
+
finally:
|
|
151
|
+
written = len(store.receipts())
|
|
152
|
+
store.close()
|
|
153
|
+
|
|
154
|
+
click.echo("")
|
|
155
|
+
click.echo(f"Receipts ({written}): {written_path(root, evidence / RECEIPTS_FILENAME)}")
|
|
156
|
+
click.echo(f"Events: {written_path(root, evidence / EVENTS_FILENAME)}")
|
|
157
|
+
click.echo("")
|
|
158
|
+
click.echo(f"Read them: {read_them_command(evidence)}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- 1. the signature scenario (SPEC §7 T1) -------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _lost_response(refund: Callable[..., Any], remote: FakeStripe) -> None:
|
|
165
|
+
amount = LOST_RESPONSE_AMOUNT
|
|
166
|
+
remote.lose_response.add("txn_1")
|
|
167
|
+
_heading(1, SCENARIO_HEADINGS[0])
|
|
168
|
+
click.echo(
|
|
169
|
+
f" refund {_euros(amount)} → remote commits → response lost → effect: AMBIGUOUS"
|
|
170
|
+
)
|
|
171
|
+
with context(agent="refund-agent"):
|
|
172
|
+
_refused(lambda: refund(payment_id="txn_1", amount=amount), TimeoutError)
|
|
173
|
+
click.echo(" agent retries the same refund")
|
|
174
|
+
_refused(lambda: refund(payment_id="txn_1", amount=amount), AmbiguousEffect)
|
|
175
|
+
click.echo(f"{_BLOCKED}effect may already have committed; blind retry refused")
|
|
176
|
+
click.echo(f" remote refund calls: {remote.calls.count('txn_1')}")
|
|
177
|
+
click.echo(" only a human moves it on: ctrlrun resolve refund:txn_1 --committed|--failed")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- 2. the approval is for one exact action (SPEC §7 T2) ------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _approval_mutation(refund: Callable[..., Any], approvals: ScriptedApprovalProvider) -> None:
|
|
184
|
+
proposed, mutated = PROPOSED_AMOUNT, MUTATED_AMOUNT
|
|
185
|
+
_heading(2, SCENARIO_HEADINGS[1])
|
|
186
|
+
with context(agent="refund-agent"):
|
|
187
|
+
request_id = _propose(refund, payment_id="txn_2", amount=proposed)
|
|
188
|
+
approvals.wait(request_id, None) # the scripted human says yes
|
|
189
|
+
click.echo(
|
|
190
|
+
f" agent proposes refund {_euros(proposed)} → human approves {request_id} "
|
|
191
|
+
"(bound to the action hash)"
|
|
192
|
+
)
|
|
193
|
+
click.echo(f" agent executes refund {_euros(mutated)} →")
|
|
194
|
+
|
|
195
|
+
def execute_the_mutation() -> None:
|
|
196
|
+
with with_approval(request_id):
|
|
197
|
+
refund(payment_id="txn_2", amount=mutated)
|
|
198
|
+
|
|
199
|
+
blocked = _refused(execute_the_mutation, ApprovalMismatch)
|
|
200
|
+
click.echo(f"{_BLOCKED}approved action ≠ requested action ({blocked.reason})")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- 3. two agents, one effect (SPEC §7 T3, in one process) ----------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _concurrent_agents(refund: Callable[..., Any], remote: FakeStripe) -> None:
|
|
207
|
+
amount, key = CONCURRENT_AMOUNT, "refund:txn_123"
|
|
208
|
+
_heading(3, SCENARIO_HEADINGS[2])
|
|
209
|
+
refused: list[DuplicateEffect] = []
|
|
210
|
+
|
|
211
|
+
def agent_b() -> None:
|
|
212
|
+
"""Agent B arrives while A holds the key, which is what makes this a race."""
|
|
213
|
+
with context(agent="refund-agent-b"):
|
|
214
|
+
refused.append(
|
|
215
|
+
_refused(lambda: refund(payment_id="txn_123", amount=amount), DuplicateEffect)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
remote.on_call["txn_123"] = agent_b
|
|
219
|
+
with context(agent="refund-agent-a"):
|
|
220
|
+
refund(payment_id="txn_123", amount=amount)
|
|
221
|
+
click.echo(f" Agent A reserve {key} → ACQUIRED → executes")
|
|
222
|
+
click.echo(f" Agent B reserve {key} →")
|
|
223
|
+
click.echo(f"{_BLOCKED}already reserved ({refused[0].state})")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- 4. an approval is worth exactly one execution (SPEC §7 T4) ------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _approval_replay(refund: Callable[..., Any], approvals: ScriptedApprovalProvider) -> None:
|
|
230
|
+
amount = REPLAY_AMOUNT
|
|
231
|
+
_heading(4, SCENARIO_HEADINGS[3])
|
|
232
|
+
with context(agent="refund-agent"):
|
|
233
|
+
request_id = _propose(refund, payment_id="txn_4", amount=amount)
|
|
234
|
+
approvals.wait(request_id, None)
|
|
235
|
+
with with_approval(request_id):
|
|
236
|
+
refund(payment_id="txn_4", amount=amount)
|
|
237
|
+
used = f"approval {request_id} used once"
|
|
238
|
+
click.echo(f" {used} → consumed")
|
|
239
|
+
# The two arrows line up, and the id's width is not a constant to hardcode: it grew
|
|
240
|
+
# once already (48 → 128 bits) and quietly knocked this column out of true.
|
|
241
|
+
click.echo(f" {'same approval presented again':<{len(used)}} →")
|
|
242
|
+
|
|
243
|
+
def present_it_again() -> None:
|
|
244
|
+
with with_approval(request_id):
|
|
245
|
+
refund(payment_id="txn_4", amount=amount)
|
|
246
|
+
|
|
247
|
+
blocked = _refused(present_it_again, ApprovalMismatch)
|
|
248
|
+
click.echo(f"{_BLOCKED}single-use approval already {blocked.reason}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _propose(refund: Callable[..., Any], payment_id: str, amount: int) -> str:
|
|
252
|
+
"""Make the proposal that asks a human, and return the request it raised (SPEC §4.3)."""
|
|
253
|
+
pending = _refused(lambda: refund(payment_id=payment_id, amount=amount), ApprovalRequired)
|
|
254
|
+
return pending.request_id
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def written_path(root: Path, path: Path) -> str:
|
|
258
|
+
"""`path` as the reader can retype it: relative to the directory the demo ran in.
|
|
259
|
+
|
|
260
|
+
The demo's transcript is meant to be pasted — into an issue, a post, the README — and an
|
|
261
|
+
absolute path carries the operator's username and directory layout along with it. A path
|
|
262
|
+
outside `root` has no relative form and is printed whole; the demo never writes one.
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
return str(path.relative_to(root))
|
|
266
|
+
except ValueError:
|
|
267
|
+
return str(path)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_them_command(evidence: Path) -> str:
|
|
271
|
+
"""The command that reads the demo's receipts, ready to paste (SPEC-v0.1 §8).
|
|
272
|
+
|
|
273
|
+
The demo keeps its own store, so `ctrlrun receipts` on its own would read the operator's.
|
|
274
|
+
`$CTRLRUN_STATE` is the documented way to point a command at another store, and this is
|
|
275
|
+
the one place a new user needs it.
|
|
276
|
+
|
|
277
|
+
The path is relative to where the demo ran, which is also where the reader is standing
|
|
278
|
+
when they paste this, so `evidence.parents[1]` is that directory: `<root>/.ctrlrun/demo`.
|
|
279
|
+
"""
|
|
280
|
+
state = written_path(evidence.parents[1], evidence / "state.db")
|
|
281
|
+
return f"CTRLRUN_STATE={shlex.quote(state)} ctrlrun receipts"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _fresh_evidence_dir(root: Path) -> Path:
|
|
285
|
+
"""`.ctrlrun/demo/`, emptied of a previous run's evidence so the demo repeats.
|
|
286
|
+
|
|
287
|
+
Only the files this demo writes are removed, by name: a demo that deleted a directory
|
|
288
|
+
would eventually delete somebody's real evidence.
|
|
289
|
+
"""
|
|
290
|
+
evidence = root / ".ctrlrun" / DEMO_DIRNAME
|
|
291
|
+
evidence.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
for name in (
|
|
293
|
+
"state.db",
|
|
294
|
+
"state.db-wal",
|
|
295
|
+
"state.db-shm",
|
|
296
|
+
RECEIPTS_FILENAME,
|
|
297
|
+
EVENTS_FILENAME,
|
|
298
|
+
):
|
|
299
|
+
(evidence / name).unlink(missing_ok=True)
|
|
300
|
+
return evidence
|
ctrlrun/cli/main.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""click command group for the ctrlrun CLI. Build-list item 8; SPEC-v0.1 §8.
|
|
2
|
+
|
|
3
|
+
The CLI is the human end of the kernel: it answers approval requests, shows the evidence,
|
|
4
|
+
and resolves the one state no machine may resolve for itself (§5.2). It is also the only
|
|
5
|
+
place in CTRLRun that prints.
|
|
6
|
+
|
|
7
|
+
Every command works on the store an agent is already using — `.ctrlrun/state.db` beside the
|
|
8
|
+
policy, or wherever `$CTRLRUN_STATE` says (§8) — so approving here answers the request an
|
|
9
|
+
agent is waiting on in another shell.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from ..control import DEFAULT_STATE_DIR, state_path
|
|
21
|
+
from ..effect import EffectRecord, EffectState
|
|
22
|
+
from ..errors import CTRLRunError
|
|
23
|
+
from ..policy import DEFAULT_POLICY_FILENAME
|
|
24
|
+
from ..receipt import Event, EventType, Receipt, iso_timestamp
|
|
25
|
+
from ..state import RESOLUTIONS, SQLiteStateStore
|
|
26
|
+
from .demo import run_demo
|
|
27
|
+
|
|
28
|
+
#: Who the CLI records as the answer's author. Free text in v0.1 (SPEC-v0.1 §4.1).
|
|
29
|
+
CLI_APPROVER: Final = "cli:local"
|
|
30
|
+
|
|
31
|
+
#: `ctrlrun init` writes this. It is `ctrlrun.example.yaml` in the repository, and
|
|
32
|
+
#: `test_the_shipped_example_policy_is_the_one_in_the_repository` keeps the two identical.
|
|
33
|
+
EXAMPLE_POLICY: Final = """# ctrlrun.yaml — action-level autonomy policy (v0.1)
|
|
34
|
+
# Unknown actions are DENIED. There is no default-allow. List what is safe.
|
|
35
|
+
schema: ctrlrun.policy/v1
|
|
36
|
+
|
|
37
|
+
actions:
|
|
38
|
+
# Reads: autonomous. Declare no effect key on these in code.
|
|
39
|
+
customer.read:
|
|
40
|
+
decision: allow
|
|
41
|
+
invoice.read:
|
|
42
|
+
decision: allow
|
|
43
|
+
|
|
44
|
+
# External communication: autonomous in v0.1 (allow_with_log arrives later).
|
|
45
|
+
email.send:
|
|
46
|
+
decision: allow
|
|
47
|
+
|
|
48
|
+
# Money: autonomy depends on the amount. First matching rule wins.
|
|
49
|
+
# Amounts are integer minor units (cents). Floats are rejected.
|
|
50
|
+
# Bound both ends: `amount_lte` alone lets a negative amount through, and a refund of
|
|
51
|
+
# a negative amount is a charge. An upper bound is not a range.
|
|
52
|
+
stripe.refund:
|
|
53
|
+
rules:
|
|
54
|
+
- when: { amount_gte: 0, amount_lte: 50000 } # €0.00 to €500.00
|
|
55
|
+
decision: allow
|
|
56
|
+
- when: { amount_gte: 0, amount_lte: 500000 } # €0.00 to €5,000.00
|
|
57
|
+
decision: approve
|
|
58
|
+
- decision: deny
|
|
59
|
+
|
|
60
|
+
# Privilege changes: never autonomous.
|
|
61
|
+
iam.grant_admin:
|
|
62
|
+
decision: deny
|
|
63
|
+
|
|
64
|
+
# Destructive infrastructure: human every time.
|
|
65
|
+
k8s.delete_namespace:
|
|
66
|
+
decision: approve
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _store() -> SQLiteStateStore:
|
|
71
|
+
"""Open the store this working tree's agents use (SPEC-v0.1 §8)."""
|
|
72
|
+
return SQLiteStateStore(state_path())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fail(exc: CTRLRunError) -> click.ClickException:
|
|
76
|
+
"""Turn a kernel refusal into a non-zero exit with the reason, not a traceback."""
|
|
77
|
+
return click.ClickException(str(exc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _event(
|
|
81
|
+
type_: EventType,
|
|
82
|
+
action_id: str,
|
|
83
|
+
*,
|
|
84
|
+
effect_key: str | None = None,
|
|
85
|
+
approval_id: str | None = None,
|
|
86
|
+
**data: object,
|
|
87
|
+
) -> Event:
|
|
88
|
+
"""One event for something a human did at the terminal (SPEC-v0.1 §6.2)."""
|
|
89
|
+
return Event(
|
|
90
|
+
type=type_,
|
|
91
|
+
action_id=action_id,
|
|
92
|
+
ts=datetime.now(UTC),
|
|
93
|
+
data=data,
|
|
94
|
+
effect_key=effect_key,
|
|
95
|
+
approval_id=approval_id,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.group()
|
|
100
|
+
@click.version_option(package_name="ctrlrun")
|
|
101
|
+
def main() -> None:
|
|
102
|
+
"""CTRLRun — transaction safety for AI-agent actions."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@main.command()
|
|
106
|
+
def init() -> None:
|
|
107
|
+
"""Write a starter ctrlrun.yaml and create .ctrlrun/."""
|
|
108
|
+
policy = Path.cwd() / DEFAULT_POLICY_FILENAME
|
|
109
|
+
if policy.exists():
|
|
110
|
+
# A policy is the thing that decides what an agent may do; overwriting one is not a
|
|
111
|
+
# convenience. Refuse, and let the human choose.
|
|
112
|
+
raise click.ClickException(f"{policy} already exists; delete it first to start over")
|
|
113
|
+
policy.write_text(EXAMPLE_POLICY, encoding="utf-8")
|
|
114
|
+
state = Path.cwd() / DEFAULT_STATE_DIR
|
|
115
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
click.echo(f"wrote {policy}")
|
|
117
|
+
click.echo(f"created {state}/")
|
|
118
|
+
click.echo("edit the policy, then protect an action with @ctrlrun.protect(...)")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.command()
|
|
122
|
+
def demo() -> None:
|
|
123
|
+
"""Run the four failure scenarios, in process, with no network."""
|
|
124
|
+
run_demo(Path.cwd())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@main.command()
|
|
128
|
+
@click.argument("request_id")
|
|
129
|
+
def approve(request_id: str) -> None:
|
|
130
|
+
"""Grant a pending approval request."""
|
|
131
|
+
store = _store()
|
|
132
|
+
try:
|
|
133
|
+
record = store.get_approval(request_id)
|
|
134
|
+
approval = store.grant_approval(request_id, CLI_APPROVER)
|
|
135
|
+
except CTRLRunError as exc:
|
|
136
|
+
raise _fail(exc) from exc
|
|
137
|
+
if record is not None:
|
|
138
|
+
store.append_event(
|
|
139
|
+
_event(
|
|
140
|
+
EventType.APPROVAL_GRANTED,
|
|
141
|
+
record.request.action.action_id,
|
|
142
|
+
approval_id=request_id,
|
|
143
|
+
approver=approval.approver,
|
|
144
|
+
action_hash=approval.action_hash,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
click.echo(f"granted {request_id} for {approval.action_hash}")
|
|
148
|
+
click.echo(f"expires {iso_timestamp(approval.expires_at)}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@main.command()
|
|
152
|
+
@click.argument("request_id")
|
|
153
|
+
def deny(request_id: str) -> None:
|
|
154
|
+
"""Refuse a pending approval request."""
|
|
155
|
+
store = _store()
|
|
156
|
+
try:
|
|
157
|
+
record = store.get_approval(request_id)
|
|
158
|
+
store.deny_approval(request_id, CLI_APPROVER)
|
|
159
|
+
except CTRLRunError as exc:
|
|
160
|
+
raise _fail(exc) from exc
|
|
161
|
+
if record is not None:
|
|
162
|
+
store.append_event(
|
|
163
|
+
_event(
|
|
164
|
+
EventType.APPROVAL_DENIED,
|
|
165
|
+
record.request.action.action_id,
|
|
166
|
+
approval_id=request_id,
|
|
167
|
+
approver=CLI_APPROVER,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
click.echo(f"denied {request_id}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command()
|
|
174
|
+
@click.option("--last", type=click.IntRange(min=1), default=None, help="Show only the last N.")
|
|
175
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the portable receipt JSON.")
|
|
176
|
+
def receipts(last: int | None, as_json: bool) -> None:
|
|
177
|
+
"""Show the receipts this store holds."""
|
|
178
|
+
try:
|
|
179
|
+
found = _store().receipts()
|
|
180
|
+
except CTRLRunError as exc:
|
|
181
|
+
raise _fail(exc) from exc
|
|
182
|
+
if last is not None:
|
|
183
|
+
found = found[-last:]
|
|
184
|
+
if not found:
|
|
185
|
+
click.echo("no receipts yet")
|
|
186
|
+
return
|
|
187
|
+
for receipt in found:
|
|
188
|
+
click.echo(receipt.to_json() if as_json else _receipt_line(receipt))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@main.command()
|
|
192
|
+
@click.option(
|
|
193
|
+
"--state",
|
|
194
|
+
type=click.Choice([str(member) for member in EffectState]),
|
|
195
|
+
default=None,
|
|
196
|
+
help="Show only effects in this state.",
|
|
197
|
+
)
|
|
198
|
+
def effects(state: str | None) -> None:
|
|
199
|
+
"""Show the logical effects this store knows about."""
|
|
200
|
+
try:
|
|
201
|
+
found = _store().list_effects(None if state is None else EffectState(state))
|
|
202
|
+
except CTRLRunError as exc:
|
|
203
|
+
raise _fail(exc) from exc
|
|
204
|
+
if not found:
|
|
205
|
+
click.echo("no effects yet" if state is None else f"no effects are {state}")
|
|
206
|
+
return
|
|
207
|
+
for record in found:
|
|
208
|
+
click.echo(_effect_line(record))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@main.command()
|
|
212
|
+
@click.argument("effect_key")
|
|
213
|
+
@click.option("--committed", is_flag=True, help="The effect did happen at the remote.")
|
|
214
|
+
@click.option("--failed", is_flag=True, help="The effect provably did not happen.")
|
|
215
|
+
def resolve(effect_key: str, committed: bool, failed: bool) -> None:
|
|
216
|
+
"""Say what actually happened to an effect with an unknown outcome."""
|
|
217
|
+
if committed == failed:
|
|
218
|
+
# SPEC §5.2 — a resolution is a human's claim about the real world, and the two
|
|
219
|
+
# claims are opposites. Neither flag says nothing; both say nothing twice.
|
|
220
|
+
raise click.UsageError(
|
|
221
|
+
f"say which it was: exactly one of --committed or --failed "
|
|
222
|
+
f"({'|'.join(sorted(RESOLUTIONS))} are the only answers)"
|
|
223
|
+
)
|
|
224
|
+
outcome = EffectState.COMMITTED if committed else EffectState.FAILED
|
|
225
|
+
store = _store()
|
|
226
|
+
try:
|
|
227
|
+
record = store.resolve_effect(effect_key, outcome, CLI_APPROVER)
|
|
228
|
+
store.append_event(
|
|
229
|
+
_event(
|
|
230
|
+
EventType.EFFECT_RESOLVED,
|
|
231
|
+
record.action_id,
|
|
232
|
+
effect_key=effect_key,
|
|
233
|
+
state=str(record.state),
|
|
234
|
+
resolver=CLI_APPROVER,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
except CTRLRunError as exc:
|
|
238
|
+
raise _fail(exc) from exc
|
|
239
|
+
click.echo(f"{effect_key} resolved {record.state} by {CLI_APPROVER}")
|
|
240
|
+
if record.state is EffectState.FAILED:
|
|
241
|
+
click.echo("a retry of this effect is now permitted")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _receipt_line(receipt: Receipt) -> str:
|
|
245
|
+
return (
|
|
246
|
+
f"{iso_timestamp(receipt.finished_at)} {receipt.receipt_id} {receipt.action} "
|
|
247
|
+
f"{receipt.decision}/{receipt.result} {receipt.effect_key or '-'} "
|
|
248
|
+
f"{receipt.principal.agent}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _effect_line(record: EffectRecord) -> str:
|
|
253
|
+
return (
|
|
254
|
+
f"{record.effect_key} {record.state} attempt {record.attempt} "
|
|
255
|
+
f"{record.action_id} {iso_timestamp(record.updated_at)}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__": # pragma: no cover
|
|
260
|
+
main()
|