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/control.py
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
"""The @protect decorator, Control and the ambient action context. Build-list item 3.
|
|
2
|
+
|
|
3
|
+
`Control` is the only place the other modules are composed (ARCHITECTURE §6): it turns a
|
|
4
|
+
function call into an Action, decides it against the policy, runs the executor, and records
|
|
5
|
+
what happened. SPEC-v0.1 §8 freezes the names here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from contextvars import ContextVar
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Final, ParamSpec, TypeVar
|
|
21
|
+
|
|
22
|
+
from .action import Action, Principal
|
|
23
|
+
from .approval import (
|
|
24
|
+
DEFAULT_APPROVAL_TTL,
|
|
25
|
+
Approval,
|
|
26
|
+
ApprovalProvider,
|
|
27
|
+
ApprovalStatus,
|
|
28
|
+
LocalApprovalProvider,
|
|
29
|
+
)
|
|
30
|
+
from .effect import (
|
|
31
|
+
DEFAULT_LEASE,
|
|
32
|
+
UNRESOLVED_EFFECT,
|
|
33
|
+
Reservation,
|
|
34
|
+
resolve_effect_key,
|
|
35
|
+
resolve_resource,
|
|
36
|
+
template_placeholders,
|
|
37
|
+
)
|
|
38
|
+
from .errors import (
|
|
39
|
+
ActionDenied,
|
|
40
|
+
AmbiguousEffect,
|
|
41
|
+
ApprovalMismatch,
|
|
42
|
+
ApprovalRequired,
|
|
43
|
+
DuplicateEffect,
|
|
44
|
+
EffectKeyError,
|
|
45
|
+
InvalidArgument,
|
|
46
|
+
NotExecuted,
|
|
47
|
+
)
|
|
48
|
+
from .policy import Decision, Evaluation, Policy, discover_policy_path
|
|
49
|
+
from .receipt import Event, EventType, Receipt, ReceiptResult, iso_timestamp, new_receipt_id
|
|
50
|
+
from .state import SQLiteStateStore, StateStore
|
|
51
|
+
|
|
52
|
+
_LOG = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
P = ParamSpec("P")
|
|
55
|
+
R = TypeVar("R")
|
|
56
|
+
|
|
57
|
+
DEFAULT_ENVIRONMENT: Final = "production"
|
|
58
|
+
|
|
59
|
+
#: Denial reason when a protected function is called outside `ctrlrun.context()`.
|
|
60
|
+
NO_PRINCIPAL: Final = "no_principal"
|
|
61
|
+
|
|
62
|
+
#: Where `Control.from_file` keeps its store, and the env var that overrides it (SPEC §8).
|
|
63
|
+
STATE_ENV_VAR: Final = "CTRLRUN_STATE"
|
|
64
|
+
DEFAULT_STATE_DIR: Final = ".ctrlrun"
|
|
65
|
+
DEFAULT_STATE_FILENAME: Final = "state.db"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _utc_now() -> datetime:
|
|
69
|
+
return datetime.now(UTC)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- the ambient context ---------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class _Invocation:
|
|
77
|
+
principal: Principal
|
|
78
|
+
environment: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_CONTEXT: ContextVar[_Invocation] = ContextVar("ctrlrun_context")
|
|
82
|
+
_PRESENTED_APPROVAL: ContextVar[str] = ContextVar("ctrlrun_approval")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def context(agent: str, user: str | None = None, environment: str | None = None) -> Iterator[None]:
|
|
87
|
+
"""Bind the principal (and optionally the environment) for calls made inside the block.
|
|
88
|
+
|
|
89
|
+
A protected function called outside any `context()` has no principal and is denied
|
|
90
|
+
(SPEC-v0.1 §2.1). A nested `context()` that omits `environment` inherits the enclosing
|
|
91
|
+
one, so entering a context to change agent cannot silently move a call to production.
|
|
92
|
+
"""
|
|
93
|
+
if environment is not None and not environment:
|
|
94
|
+
raise InvalidArgument("context(environment=...) must be a non-empty string or None")
|
|
95
|
+
enclosing = _CONTEXT.get(None)
|
|
96
|
+
if environment is not None:
|
|
97
|
+
resolved = environment
|
|
98
|
+
elif enclosing is not None:
|
|
99
|
+
resolved = enclosing.environment
|
|
100
|
+
else:
|
|
101
|
+
resolved = DEFAULT_ENVIRONMENT
|
|
102
|
+
token = _CONTEXT.set(_Invocation(Principal(agent=agent, user=user), resolved))
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
finally:
|
|
106
|
+
_CONTEXT.reset(token)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def with_approval(request_id: str) -> Iterator[None]:
|
|
111
|
+
"""Present a granted approval to the calls made inside the block (SPEC-v0.1 §4.3).
|
|
112
|
+
|
|
113
|
+
The approval still has to match: it authorizes the exact action a human saw, once, and
|
|
114
|
+
only until it expires. Presenting it is an offer, not a decision.
|
|
115
|
+
"""
|
|
116
|
+
if not request_id:
|
|
117
|
+
raise InvalidArgument("with_approval(request_id) must be a non-empty request id")
|
|
118
|
+
token = _PRESENTED_APPROVAL.set(request_id)
|
|
119
|
+
try:
|
|
120
|
+
yield
|
|
121
|
+
finally:
|
|
122
|
+
_PRESENTED_APPROVAL.reset(token)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- Control ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Control:
|
|
129
|
+
"""Policy, state and evidence composed around a single action (SPEC-v0.1 §8).
|
|
130
|
+
|
|
131
|
+
`evaluate()` decides an action and touches nothing. `execute()` decides it, runs the
|
|
132
|
+
executor, and records a receipt and events for whatever happened.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
policy: Policy,
|
|
138
|
+
store: StateStore,
|
|
139
|
+
approvals: ApprovalProvider | None = None,
|
|
140
|
+
*,
|
|
141
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
142
|
+
approval_ttl: timedelta = DEFAULT_APPROVAL_TTL,
|
|
143
|
+
lease: timedelta = DEFAULT_LEASE,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._policy = policy
|
|
146
|
+
self._store = store
|
|
147
|
+
self._approvals: ApprovalProvider = (
|
|
148
|
+
approvals if approvals is not None else LocalApprovalProvider(store, clock=clock)
|
|
149
|
+
)
|
|
150
|
+
self._clock = clock
|
|
151
|
+
self._approval_ttl = approval_ttl
|
|
152
|
+
self._lease = _checked_lease(lease, "Control(lease=...)")
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_file(cls, path: str | os.PathLike[str] | None = None) -> Control:
|
|
156
|
+
"""Build a Control from a policy file (`$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`).
|
|
157
|
+
|
|
158
|
+
A missing or malformed policy raises `PolicyError`: a Control cannot be constructed
|
|
159
|
+
without one (SPEC-v0.1 §3.4). State lands in `.ctrlrun/state.db` beside the policy —
|
|
160
|
+
or wherever `$CTRLRUN_STATE` says — so approvals and effects outlive the process and
|
|
161
|
+
are shared by every worker that loaded the same policy (§5.3 E1, §8).
|
|
162
|
+
"""
|
|
163
|
+
policy = Policy.from_file(path)
|
|
164
|
+
store = SQLiteStateStore(state_path(policy.source))
|
|
165
|
+
return cls(policy, store, LocalApprovalProvider(store))
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def policy(self) -> Policy:
|
|
169
|
+
return self._policy
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def store(self) -> StateStore:
|
|
173
|
+
return self._store
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def approvals(self) -> ApprovalProvider:
|
|
177
|
+
return self._approvals
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def lease(self) -> timedelta:
|
|
181
|
+
"""How long a reservation this Control takes is held for (SPEC-v0.1 §5.3 E3)."""
|
|
182
|
+
return self._lease
|
|
183
|
+
|
|
184
|
+
def evaluate(self, action: Action) -> Evaluation:
|
|
185
|
+
"""Decide an action. No side effects: nothing is recorded (SPEC-v0.1 §8)."""
|
|
186
|
+
return self._policy.evaluate(action)
|
|
187
|
+
|
|
188
|
+
def execute(
|
|
189
|
+
self,
|
|
190
|
+
action: Action,
|
|
191
|
+
executor: Callable[[], Any],
|
|
192
|
+
effect_key: str | None = None,
|
|
193
|
+
*,
|
|
194
|
+
lease: timedelta | None = None,
|
|
195
|
+
) -> Receipt:
|
|
196
|
+
"""Decide, run and record one action. Returns the receipt for its terminal state.
|
|
197
|
+
|
|
198
|
+
The executor's own exceptions propagate: `NotExecuted` after a `failed` receipt,
|
|
199
|
+
anything else after an `ambiguous` one (SPEC-v0.1 §5.5). The exception is an effect
|
|
200
|
+
record that moved on while the executor ran — the lease lapsed and a human owns it
|
|
201
|
+
now — where the store's refusal propagates instead, after an `ambiguous` receipt.
|
|
202
|
+
|
|
203
|
+
`effect_key` is the already-resolved logical effect identity (§5.1); it is recorded
|
|
204
|
+
on the receipt and on every event for this action. `lease` is how long this action's
|
|
205
|
+
reservation is held; `None` means this Control's lease (§5.3 E3).
|
|
206
|
+
"""
|
|
207
|
+
if effect_key is not None and not effect_key:
|
|
208
|
+
raise InvalidArgument("effect_key must be a non-empty string or None")
|
|
209
|
+
held = self._lease if lease is None else _checked_lease(lease, "execute(lease=...)")
|
|
210
|
+
|
|
211
|
+
started_at = self._clock()
|
|
212
|
+
self._append(
|
|
213
|
+
EventType.ACTION_PROPOSED, action, {"action_hash": action.action_hash}, effect_key
|
|
214
|
+
)
|
|
215
|
+
evaluation = self._policy.evaluate(action)
|
|
216
|
+
self._append(
|
|
217
|
+
EventType.POLICY_EVALUATED,
|
|
218
|
+
action,
|
|
219
|
+
{"decision": str(evaluation.decision), "reason": evaluation.reason},
|
|
220
|
+
effect_key,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if evaluation.decision is Decision.DENY:
|
|
224
|
+
self._append(EventType.ACTION_DENIED, action, {"reason": evaluation.reason}, effect_key)
|
|
225
|
+
self._record(
|
|
226
|
+
action, evaluation, ReceiptResult.DENIED, started_at, effect_key=effect_key
|
|
227
|
+
)
|
|
228
|
+
raise ActionDenied(
|
|
229
|
+
f"{action.name} denied: {evaluation.reason}",
|
|
230
|
+
reason=evaluation.reason,
|
|
231
|
+
action_id=action.action_id,
|
|
232
|
+
)
|
|
233
|
+
approval, reservation = self._secure(action, evaluation, started_at, effect_key, held)
|
|
234
|
+
attempt = 1 if reservation is None else reservation.attempt
|
|
235
|
+
|
|
236
|
+
if effect_key is not None:
|
|
237
|
+
try:
|
|
238
|
+
self._store.begin_execution(effect_key, action.action_id)
|
|
239
|
+
except (DuplicateEffect, AmbiguousEffect) as refused:
|
|
240
|
+
# The key was taken from under this attempt between winning it and starting:
|
|
241
|
+
# its lease expired and another attempt declared the effect AMBIGUOUS. The
|
|
242
|
+
# refusal is terminal for this proposal, so it gets a receipt like any other.
|
|
243
|
+
self._refused(
|
|
244
|
+
action, evaluation, started_at, effect_key, refused, approval=approval
|
|
245
|
+
)
|
|
246
|
+
raise
|
|
247
|
+
self._append(EventType.EXECUTION_STARTED, action, {}, effect_key, approval=approval)
|
|
248
|
+
try:
|
|
249
|
+
result = executor()
|
|
250
|
+
except NotExecuted as exc:
|
|
251
|
+
if effect_key is not None:
|
|
252
|
+
# SPEC §5.5 — the executor asserted the remote side did nothing, so this is
|
|
253
|
+
# the one outcome that leaves the key retryable (§5.4).
|
|
254
|
+
try:
|
|
255
|
+
self._store.fail_effect(effect_key, action.action_id, str(exc))
|
|
256
|
+
except (DuplicateEffect, AmbiguousEffect) as refused:
|
|
257
|
+
# SPEC: §5.2 — the record moved on while the executor ran: this attempt's
|
|
258
|
+
# lease lapsed and another declared the effect AMBIGUOUS, which only a
|
|
259
|
+
# human moves it out of. The store's refusal is what propagates, not the
|
|
260
|
+
# NotExecuted: an agent that caught that would retry a key it lost.
|
|
261
|
+
self._unrecorded(
|
|
262
|
+
action, evaluation, started_at, effect_key, attempt, refused, approval
|
|
263
|
+
)
|
|
264
|
+
raise
|
|
265
|
+
self._append(
|
|
266
|
+
EventType.EXECUTION_FAILED,
|
|
267
|
+
action,
|
|
268
|
+
{"error": str(exc)},
|
|
269
|
+
effect_key,
|
|
270
|
+
approval=approval,
|
|
271
|
+
)
|
|
272
|
+
self._record(
|
|
273
|
+
action,
|
|
274
|
+
evaluation,
|
|
275
|
+
ReceiptResult.FAILED,
|
|
276
|
+
started_at,
|
|
277
|
+
error=str(exc),
|
|
278
|
+
approval=approval,
|
|
279
|
+
effect_key=effect_key,
|
|
280
|
+
attempt=attempt,
|
|
281
|
+
)
|
|
282
|
+
raise
|
|
283
|
+
except BaseException as exc:
|
|
284
|
+
# SPEC: §5.5 — anything that is not NotExecuted is an AMBIGUOUS outcome, never
|
|
285
|
+
# FAILED, and "anything" means BaseException: a KeyboardInterrupt mid-request
|
|
286
|
+
# leaves the same unknown outcome a timeout does. Narrowing this to Exception
|
|
287
|
+
# is a regression, not a cleanup.
|
|
288
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
289
|
+
if effect_key is not None:
|
|
290
|
+
try:
|
|
291
|
+
self._store.mark_ambiguous(effect_key, action.action_id, error)
|
|
292
|
+
except (DuplicateEffect, AmbiguousEffect) as refused:
|
|
293
|
+
# Recording an unknown outcome must never mask the exception that caused
|
|
294
|
+
# it. A store that refuses here has the record in a state a human already
|
|
295
|
+
# owns — where this attempt was trying to put it — and the receipt below
|
|
296
|
+
# says `ambiguous` either way.
|
|
297
|
+
_LOG.warning("%s: effect %s: %s", action.name, effect_key, refused)
|
|
298
|
+
self._append(
|
|
299
|
+
EventType.EXECUTION_AMBIGUOUS,
|
|
300
|
+
action,
|
|
301
|
+
{"error": error},
|
|
302
|
+
effect_key,
|
|
303
|
+
approval=approval,
|
|
304
|
+
)
|
|
305
|
+
self._record(
|
|
306
|
+
action,
|
|
307
|
+
evaluation,
|
|
308
|
+
ReceiptResult.AMBIGUOUS,
|
|
309
|
+
started_at,
|
|
310
|
+
error=error,
|
|
311
|
+
approval=approval,
|
|
312
|
+
effect_key=effect_key,
|
|
313
|
+
attempt=attempt,
|
|
314
|
+
)
|
|
315
|
+
raise
|
|
316
|
+
if effect_key is not None:
|
|
317
|
+
try:
|
|
318
|
+
self._store.commit_effect(effect_key, action.action_id, result)
|
|
319
|
+
except (DuplicateEffect, AmbiguousEffect) as refused:
|
|
320
|
+
# SPEC: §5.2 — the executor returned, but the key is no longer this attempt's
|
|
321
|
+
# to commit: the lease lapsed and the record is AMBIGUOUS until a human says
|
|
322
|
+
# otherwise. What happened at the remote is now as unknown as a timeout, so
|
|
323
|
+
# the attempt is recorded `ambiguous` rather than committed.
|
|
324
|
+
self._unrecorded(
|
|
325
|
+
action, evaluation, started_at, effect_key, attempt, refused, approval
|
|
326
|
+
)
|
|
327
|
+
raise
|
|
328
|
+
self._append(EventType.EXECUTION_COMMITTED, action, {}, effect_key, approval=approval)
|
|
329
|
+
return self._record(
|
|
330
|
+
action,
|
|
331
|
+
evaluation,
|
|
332
|
+
ReceiptResult.COMMITTED,
|
|
333
|
+
started_at,
|
|
334
|
+
approval=approval,
|
|
335
|
+
effect_key=effect_key,
|
|
336
|
+
attempt=attempt,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# --- the effect key (SPEC-v0.1 §5.1) ------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _resolve_effect(self, action: Action, template: str | None) -> str | None:
|
|
342
|
+
"""Resolve an effect template against a constructed action, or deny the action.
|
|
343
|
+
|
|
344
|
+
A missing placeholder is never a silent `None` (§5.1): the action is refused and
|
|
345
|
+
recorded as denied, before the policy is consulted. An action whose logical effect
|
|
346
|
+
cannot be identified cannot be protected against duplication, whatever the policy
|
|
347
|
+
would have said about it — and unlike a call outside `context()` (§2.1) there is a
|
|
348
|
+
principal here, so the refusal belongs in the evidence log.
|
|
349
|
+
"""
|
|
350
|
+
if template is None:
|
|
351
|
+
return None
|
|
352
|
+
started_at = self._clock()
|
|
353
|
+
try:
|
|
354
|
+
return resolve_effect_key(template, action)
|
|
355
|
+
except EffectKeyError as exc:
|
|
356
|
+
self._append(EventType.ACTION_PROPOSED, action, {"action_hash": action.action_hash})
|
|
357
|
+
self._append(
|
|
358
|
+
EventType.ACTION_DENIED,
|
|
359
|
+
action,
|
|
360
|
+
{"reason": UNRESOLVED_EFFECT, "effect": template, "error": str(exc)},
|
|
361
|
+
)
|
|
362
|
+
# SPEC: §6.1 — a receipt needs a decision and the policy never rendered one, so
|
|
363
|
+
# the fail-closed value is recorded: denied, for a reason that is not a rule.
|
|
364
|
+
self._record(
|
|
365
|
+
action,
|
|
366
|
+
Evaluation(Decision.DENY, UNRESOLVED_EFFECT),
|
|
367
|
+
ReceiptResult.DENIED,
|
|
368
|
+
started_at,
|
|
369
|
+
error=str(exc),
|
|
370
|
+
)
|
|
371
|
+
raise
|
|
372
|
+
|
|
373
|
+
# --- authority and the effect key (SPEC-v0.1 §4.2, §5.3) --------------------------
|
|
374
|
+
|
|
375
|
+
def _secure(
|
|
376
|
+
self,
|
|
377
|
+
action: Action,
|
|
378
|
+
evaluation: Evaluation,
|
|
379
|
+
started_at: datetime,
|
|
380
|
+
effect_key: str | None,
|
|
381
|
+
lease: timedelta,
|
|
382
|
+
) -> tuple[Approval | None, Reservation | None]:
|
|
383
|
+
"""Take everything this action needs before it may run: the grant, and the key.
|
|
384
|
+
|
|
385
|
+
Both are taken in one store transaction (SPEC-v0.1 §4.2 A4). The approval is checked
|
|
386
|
+
first, so a replayed approval is what gets raised when a duplicate effect would also
|
|
387
|
+
apply (T4), and a refused reservation leaves the approval granted for the action the
|
|
388
|
+
human actually saw (T12).
|
|
389
|
+
"""
|
|
390
|
+
approval_id = (
|
|
391
|
+
self._presented(action, effect_key) if evaluation.decision is Decision.APPROVE else None
|
|
392
|
+
)
|
|
393
|
+
if approval_id is None and effect_key is None:
|
|
394
|
+
return None, None
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
approval, reservation = self._take(action, approval_id, effect_key, lease)
|
|
398
|
+
except ActionDenied as denied:
|
|
399
|
+
# The store raises this when the record says a human refused. §4.2 makes that a
|
|
400
|
+
# denial of the action, not a mismatch: nothing about the action was wrong.
|
|
401
|
+
approver = self._approver_of(approval_id)
|
|
402
|
+
self._append(
|
|
403
|
+
EventType.APPROVAL_DENIED,
|
|
404
|
+
action,
|
|
405
|
+
{"approver": approver},
|
|
406
|
+
effect_key,
|
|
407
|
+
approval_id=approval_id,
|
|
408
|
+
)
|
|
409
|
+
self._append(EventType.ACTION_DENIED, action, {"reason": denied.reason}, effect_key)
|
|
410
|
+
self._record(
|
|
411
|
+
action,
|
|
412
|
+
evaluation,
|
|
413
|
+
ReceiptResult.DENIED,
|
|
414
|
+
started_at,
|
|
415
|
+
error=str(denied),
|
|
416
|
+
approval_id=approval_id,
|
|
417
|
+
approver=approver,
|
|
418
|
+
effect_key=effect_key,
|
|
419
|
+
)
|
|
420
|
+
raise ActionDenied(
|
|
421
|
+
str(denied), reason=denied.reason, action_id=action.action_id
|
|
422
|
+
) from denied
|
|
423
|
+
except ApprovalMismatch as mismatch:
|
|
424
|
+
if mismatch.reason == ApprovalStatus.EXPIRED:
|
|
425
|
+
self._append(
|
|
426
|
+
EventType.APPROVAL_EXPIRED, action, {}, effect_key, approval_id=approval_id
|
|
427
|
+
)
|
|
428
|
+
self._append(
|
|
429
|
+
EventType.APPROVAL_INVALIDATED,
|
|
430
|
+
action,
|
|
431
|
+
{"reason": mismatch.reason, "action_hash": action.action_hash},
|
|
432
|
+
effect_key,
|
|
433
|
+
approval_id=approval_id,
|
|
434
|
+
)
|
|
435
|
+
self._record(
|
|
436
|
+
action,
|
|
437
|
+
evaluation,
|
|
438
|
+
ReceiptResult.BLOCKED,
|
|
439
|
+
started_at,
|
|
440
|
+
error=str(mismatch),
|
|
441
|
+
approval_id=approval_id,
|
|
442
|
+
approver=self._approver_of(approval_id),
|
|
443
|
+
effect_key=effect_key,
|
|
444
|
+
)
|
|
445
|
+
raise
|
|
446
|
+
except (DuplicateEffect, AmbiguousEffect) as refused:
|
|
447
|
+
# SPEC §5.4 — this effect already happened, is happening, or ended unknown. The
|
|
448
|
+
# approval, if one was presented, was not consumed: it is still worth something.
|
|
449
|
+
self._refused(
|
|
450
|
+
action, evaluation, started_at, effect_key, refused, approval_id=approval_id
|
|
451
|
+
)
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
if approval is not None:
|
|
455
|
+
self._append(
|
|
456
|
+
EventType.APPROVAL_CONSUMED,
|
|
457
|
+
action,
|
|
458
|
+
{"approver": approval.approver},
|
|
459
|
+
effect_key,
|
|
460
|
+
approval_id=approval.approval_id,
|
|
461
|
+
)
|
|
462
|
+
if reservation is not None:
|
|
463
|
+
self._append(
|
|
464
|
+
EventType.EFFECT_RESERVED,
|
|
465
|
+
action,
|
|
466
|
+
{
|
|
467
|
+
"attempt": reservation.attempt,
|
|
468
|
+
"lease_expires_at": iso_timestamp(reservation.lease_expires_at),
|
|
469
|
+
},
|
|
470
|
+
effect_key,
|
|
471
|
+
approval=approval,
|
|
472
|
+
)
|
|
473
|
+
return approval, reservation
|
|
474
|
+
|
|
475
|
+
def _refused(
|
|
476
|
+
self,
|
|
477
|
+
action: Action,
|
|
478
|
+
evaluation: Evaluation,
|
|
479
|
+
started_at: datetime,
|
|
480
|
+
effect_key: str | None,
|
|
481
|
+
refused: DuplicateEffect | AmbiguousEffect,
|
|
482
|
+
*,
|
|
483
|
+
approval: Approval | None = None,
|
|
484
|
+
approval_id: str | None = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Record a refusal by the effect key: the event, and a `blocked` receipt (§6.1)."""
|
|
487
|
+
presented = approval.approval_id if approval is not None else approval_id
|
|
488
|
+
self._append(
|
|
489
|
+
EventType.EFFECT_RESERVATION_REFUSED,
|
|
490
|
+
action,
|
|
491
|
+
_refusal_data(refused),
|
|
492
|
+
effect_key,
|
|
493
|
+
approval_id=presented,
|
|
494
|
+
)
|
|
495
|
+
self._record(
|
|
496
|
+
action,
|
|
497
|
+
evaluation,
|
|
498
|
+
ReceiptResult.BLOCKED,
|
|
499
|
+
started_at,
|
|
500
|
+
error=str(refused),
|
|
501
|
+
approval_id=presented,
|
|
502
|
+
approver=self._approver_of(presented),
|
|
503
|
+
effect_key=effect_key,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _unrecorded(
|
|
507
|
+
self,
|
|
508
|
+
action: Action,
|
|
509
|
+
evaluation: Evaluation,
|
|
510
|
+
started_at: datetime,
|
|
511
|
+
effect_key: str,
|
|
512
|
+
attempt: int,
|
|
513
|
+
refused: DuplicateEffect | AmbiguousEffect,
|
|
514
|
+
approval: Approval | None,
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Record an outcome the store refused to write (SPEC-v0.1 §5.2, §5.5).
|
|
517
|
+
|
|
518
|
+
The executor finished, but the effect record moved on while it ran, so what happened
|
|
519
|
+
at the remote is exactly as unknown as a timeout: `ambiguous`, whatever the executor
|
|
520
|
+
returned or raised. A terminal action still gets its receipt (§6.1).
|
|
521
|
+
"""
|
|
522
|
+
error = f"{type(refused).__name__}: {refused}"
|
|
523
|
+
self._append(
|
|
524
|
+
EventType.EXECUTION_AMBIGUOUS, action, {"error": error}, effect_key, approval=approval
|
|
525
|
+
)
|
|
526
|
+
self._record(
|
|
527
|
+
action,
|
|
528
|
+
evaluation,
|
|
529
|
+
ReceiptResult.AMBIGUOUS,
|
|
530
|
+
started_at,
|
|
531
|
+
error=error,
|
|
532
|
+
approval=approval,
|
|
533
|
+
effect_key=effect_key,
|
|
534
|
+
attempt=attempt,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def _presented(self, action: Action, effect_key: str | None) -> str:
|
|
538
|
+
"""The approval this call presents, or record a request and suspend the action.
|
|
539
|
+
|
|
540
|
+
With nothing presented, `ApprovalRequired` is raised so the caller can come back with
|
|
541
|
+
`with_approval(request_id)` (SPEC-v0.1 §4.3). No receipt is written for that: the
|
|
542
|
+
action is suspended awaiting a human, which is not a terminal state (§6.1). The
|
|
543
|
+
`APPROVAL_REQUESTED` event is the evidence.
|
|
544
|
+
"""
|
|
545
|
+
presented = _PRESENTED_APPROVAL.get(None)
|
|
546
|
+
if presented is not None:
|
|
547
|
+
return presented
|
|
548
|
+
request = self._approvals.request(action, self._approval_ttl)
|
|
549
|
+
self._append(
|
|
550
|
+
EventType.APPROVAL_REQUESTED,
|
|
551
|
+
action,
|
|
552
|
+
{"action_hash": action.action_hash, "expires_at": iso_timestamp(request.expires_at)},
|
|
553
|
+
effect_key,
|
|
554
|
+
approval_id=request.request_id,
|
|
555
|
+
)
|
|
556
|
+
raise ApprovalRequired(
|
|
557
|
+
f"{action.name} requires approval: run 'ctrlrun approve {request.request_id}', "
|
|
558
|
+
f"then retry inside ctrlrun.with_approval({request.request_id!r})",
|
|
559
|
+
request_id=request.request_id,
|
|
560
|
+
action_id=action.action_id,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _take(
|
|
564
|
+
self, action: Action, approval_id: str | None, effect_key: str | None, lease: timedelta
|
|
565
|
+
) -> tuple[Approval | None, Reservation | None]:
|
|
566
|
+
"""Consume the approval, reserve the effect, or both at once (SPEC-v0.1 §4.2 A4)."""
|
|
567
|
+
if approval_id is not None and effect_key is not None:
|
|
568
|
+
return self._store.consume_approval_and_reserve(
|
|
569
|
+
approval_id, action.action_hash, effect_key, action.action_id, lease
|
|
570
|
+
)
|
|
571
|
+
if approval_id is not None:
|
|
572
|
+
return self._store.consume_approval(approval_id, action.action_hash), None
|
|
573
|
+
if effect_key is not None:
|
|
574
|
+
return None, self._store.reserve_effect(effect_key, action.action_id, lease)
|
|
575
|
+
return None, None
|
|
576
|
+
|
|
577
|
+
def _approver_of(self, approval_id: str | None) -> str | None:
|
|
578
|
+
"""Who answered, for the evidence trail. `None` if there is no such record."""
|
|
579
|
+
if approval_id is None:
|
|
580
|
+
return None
|
|
581
|
+
record = self._store.get_approval(approval_id)
|
|
582
|
+
return None if record is None else record.approver
|
|
583
|
+
|
|
584
|
+
# --- evidence ---------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
def _append(
|
|
587
|
+
self,
|
|
588
|
+
type_: EventType,
|
|
589
|
+
action: Action,
|
|
590
|
+
data: Mapping[str, Any],
|
|
591
|
+
effect_key: str | None = None,
|
|
592
|
+
*,
|
|
593
|
+
approval: Approval | None = None,
|
|
594
|
+
approval_id: str | None = None,
|
|
595
|
+
) -> None:
|
|
596
|
+
self._store.append_event(
|
|
597
|
+
Event(
|
|
598
|
+
type=type_,
|
|
599
|
+
action_id=action.action_id,
|
|
600
|
+
ts=self._clock(),
|
|
601
|
+
data=data,
|
|
602
|
+
effect_key=effect_key,
|
|
603
|
+
approval_id=approval_id if approval is None else approval.approval_id,
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _record(
|
|
608
|
+
self,
|
|
609
|
+
action: Action,
|
|
610
|
+
evaluation: Evaluation,
|
|
611
|
+
result: ReceiptResult,
|
|
612
|
+
started_at: datetime,
|
|
613
|
+
error: str | None = None,
|
|
614
|
+
*,
|
|
615
|
+
approval: Approval | None = None,
|
|
616
|
+
approval_id: str | None = None,
|
|
617
|
+
approver: str | None = None,
|
|
618
|
+
effect_key: str | None = None,
|
|
619
|
+
attempt: int = 1,
|
|
620
|
+
) -> Receipt:
|
|
621
|
+
receipt = Receipt(
|
|
622
|
+
receipt_id=new_receipt_id(),
|
|
623
|
+
action_id=action.action_id,
|
|
624
|
+
action=action.name,
|
|
625
|
+
action_hash=action.action_hash,
|
|
626
|
+
principal=action.principal,
|
|
627
|
+
resource=action.resource,
|
|
628
|
+
arguments=action.canonical_arguments,
|
|
629
|
+
environment=action.environment,
|
|
630
|
+
decision=evaluation.decision,
|
|
631
|
+
decision_reason=evaluation.reason,
|
|
632
|
+
approval_id=approval.approval_id if approval is not None else approval_id,
|
|
633
|
+
approver=approval.approver if approval is not None else approver,
|
|
634
|
+
effect_key=effect_key,
|
|
635
|
+
attempt=attempt,
|
|
636
|
+
result=result,
|
|
637
|
+
started_at=started_at,
|
|
638
|
+
finished_at=self._clock(),
|
|
639
|
+
error=error,
|
|
640
|
+
)
|
|
641
|
+
self._store.put_receipt(receipt)
|
|
642
|
+
return receipt
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _refusal_data(refused: DuplicateEffect | AmbiguousEffect) -> dict[str, str]:
|
|
646
|
+
"""Why a reservation was refused, for `EFFECT_RESERVATION_REFUSED` (SPEC §5.4, §6.2)."""
|
|
647
|
+
if isinstance(refused, DuplicateEffect):
|
|
648
|
+
return {"reason": "duplicate", "state": refused.state}
|
|
649
|
+
return {"reason": "ambiguous"}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def state_path(source: str | os.PathLike[str] | None = None) -> Path:
|
|
653
|
+
"""Where the state database lives for a given policy file (SPEC-v0.1 §8).
|
|
654
|
+
|
|
655
|
+
`.ctrlrun/state.db` beside the policy file, unless `$CTRLRUN_STATE` names somewhere else.
|
|
656
|
+
Beside the policy, not beside the process: workers started from different directories but
|
|
657
|
+
sharing a policy must share one store, or reservation is atomic within each of them and
|
|
658
|
+
meaningless between them (§5.3 E1). Set but empty is a misconfiguration, not a licence to
|
|
659
|
+
fall back to the default — an agent's effects would land in a store nobody is watching.
|
|
660
|
+
|
|
661
|
+
`source=None` discovers the policy the way `Control.from_file` does, which is how the CLI
|
|
662
|
+
finds the store an agent is using without needing to load the policy itself.
|
|
663
|
+
"""
|
|
664
|
+
configured = os.environ.get(STATE_ENV_VAR)
|
|
665
|
+
if configured is not None:
|
|
666
|
+
if not configured.strip():
|
|
667
|
+
raise InvalidArgument(
|
|
668
|
+
f"{STATE_ENV_VAR} is set but empty; unset it or point it at a state database"
|
|
669
|
+
)
|
|
670
|
+
return Path(configured)
|
|
671
|
+
resolved = discover_policy_path() if source is None else Path(source)
|
|
672
|
+
return resolved.parent / DEFAULT_STATE_DIR / DEFAULT_STATE_FILENAME
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
_DEFAULT_CONTROL: Control | None = None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _default_control() -> Control:
|
|
679
|
+
global _DEFAULT_CONTROL
|
|
680
|
+
if _DEFAULT_CONTROL is None:
|
|
681
|
+
_DEFAULT_CONTROL = Control.from_file()
|
|
682
|
+
return _DEFAULT_CONTROL
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# --- the decorator ----------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def protect(
|
|
689
|
+
name: str,
|
|
690
|
+
*,
|
|
691
|
+
effect: str | None = None,
|
|
692
|
+
resource: str | None = None,
|
|
693
|
+
wait: bool = False,
|
|
694
|
+
lease: timedelta | None = None,
|
|
695
|
+
control: Control | None = None,
|
|
696
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
697
|
+
"""Bind a function to an action name: every call becomes a decided, recorded Action.
|
|
698
|
+
|
|
699
|
+
The wrapped function is the executor (SPEC-v0.1 §5.5), and it is invoked with the
|
|
700
|
+
action's canonical arguments, never with the caller's own objects (§2.2).
|
|
701
|
+
|
|
702
|
+
`effect` and `resource` are templates over the call's arguments (§5.1). Their syntax is
|
|
703
|
+
checked here, at decoration time, so a typo fails at import rather than mid-agent-run.
|
|
704
|
+
|
|
705
|
+
`lease` is how long this action's reservation is held (§5.3 E3), for work that takes
|
|
706
|
+
longer than the Control's default; it overrides that default and nothing else. Expiry
|
|
707
|
+
means what it always meant: past the lease the effect is `AMBIGUOUS`, never released.
|
|
708
|
+
"""
|
|
709
|
+
if not name:
|
|
710
|
+
raise InvalidArgument("protect(name=...) must be a non-empty action name")
|
|
711
|
+
_check_template(name, "effect", effect)
|
|
712
|
+
_check_template(name, "resource", resource)
|
|
713
|
+
held = None if lease is None else _checked_lease(lease, f"protect({name!r}, lease=...)")
|
|
714
|
+
|
|
715
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
716
|
+
signature = inspect.signature(func)
|
|
717
|
+
_reject_variadic(signature, name)
|
|
718
|
+
|
|
719
|
+
@functools.wraps(func)
|
|
720
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
721
|
+
invocation = _CONTEXT.get(None)
|
|
722
|
+
if invocation is None:
|
|
723
|
+
# SPEC-v0.1 §2.1 — a call outside context() is a wiring bug, not an agent
|
|
724
|
+
# action: it is denied and warned about, but never enters the evidence log,
|
|
725
|
+
# which records actions and has no principal to attribute this one to.
|
|
726
|
+
_LOG.warning("%s: denied: no ctrlrun.context() is active", name)
|
|
727
|
+
raise ActionDenied(
|
|
728
|
+
f"{name}: no ctrlrun.context() is active; wrap the call in "
|
|
729
|
+
"'with ctrlrun.context(agent=...)'",
|
|
730
|
+
reason=NO_PRINCIPAL,
|
|
731
|
+
)
|
|
732
|
+
bound = signature.bind(*args, **kwargs)
|
|
733
|
+
bound.apply_defaults()
|
|
734
|
+
action = Action(
|
|
735
|
+
name=name,
|
|
736
|
+
arguments=dict(bound.arguments),
|
|
737
|
+
principal=invocation.principal,
|
|
738
|
+
# SPEC-v0.1 §5.1 — `resource` is part of the canonical form, so it resolves
|
|
739
|
+
# before the Action exists; the effect template resolves against the Action.
|
|
740
|
+
resource=None if resource is None else resolve_resource(resource, bound.arguments),
|
|
741
|
+
environment=invocation.environment,
|
|
742
|
+
)
|
|
743
|
+
resolved = control if control is not None else _default_control()
|
|
744
|
+
effect_key = resolved._resolve_effect(action, effect)
|
|
745
|
+
returned: list[R] = []
|
|
746
|
+
|
|
747
|
+
def executor() -> R:
|
|
748
|
+
value = _invoke(func, signature, action.canonical_arguments)
|
|
749
|
+
returned.append(value)
|
|
750
|
+
return value
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
resolved.execute(action, executor, effect_key, lease=held)
|
|
754
|
+
except ApprovalRequired as pending:
|
|
755
|
+
if not wait:
|
|
756
|
+
raise
|
|
757
|
+
# SPEC-v0.1 §4.3 — with wait=True the decorator blocks on the provider and
|
|
758
|
+
# then re-presents the same proposal. It re-presents a *denied* answer too:
|
|
759
|
+
# the refusal belongs in the receipt Control writes, not in the decorator.
|
|
760
|
+
resolved.approvals.wait(pending.request_id, None)
|
|
761
|
+
with with_approval(pending.request_id):
|
|
762
|
+
resolved.execute(action, executor, effect_key, lease=held)
|
|
763
|
+
return returned[0]
|
|
764
|
+
|
|
765
|
+
return wrapper
|
|
766
|
+
|
|
767
|
+
return decorator
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _checked_lease(lease: object, where: str) -> timedelta:
|
|
771
|
+
"""A lease must be a positive `timedelta` (SPEC-v0.1 §5.3 E3).
|
|
772
|
+
|
|
773
|
+
Checked wherever one is offered — `Control`, `execute`, `protect` — because each is a
|
|
774
|
+
separate way in, and a lease that has already expired reserves nothing: the first
|
|
775
|
+
contender would find the record expired and declare a perfectly healthy effect ambiguous.
|
|
776
|
+
"""
|
|
777
|
+
if not isinstance(lease, timedelta):
|
|
778
|
+
raise InvalidArgument(f"{where}: lease must be a timedelta, not {type(lease).__name__}")
|
|
779
|
+
if lease <= timedelta(0):
|
|
780
|
+
raise InvalidArgument(f"{where}: lease must be positive, got {lease!r}")
|
|
781
|
+
return lease
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _check_template(name: str, kwarg: str, template: str | None) -> None:
|
|
785
|
+
"""Reject a malformed `effect=` / `resource=` template at decoration time (SPEC §5.1)."""
|
|
786
|
+
if template is None:
|
|
787
|
+
return
|
|
788
|
+
try:
|
|
789
|
+
template_placeholders(template)
|
|
790
|
+
except InvalidArgument as exc:
|
|
791
|
+
raise InvalidArgument(f"protect({name!r}, {kwarg}=...): {exc}") from exc
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _reject_variadic(signature: inspect.Signature, name: str) -> None:
|
|
795
|
+
"""Refuse `*args` / `**kwargs` on a protected function.
|
|
796
|
+
|
|
797
|
+
SPEC: §8 — an Action's arguments are a mapping of named values (§2.1); policy conditions
|
|
798
|
+
and templates address them by name. A variadic parameter has no such name, so it could
|
|
799
|
+
never be written into a rule. Positional-only and keyword-only parameters are fine.
|
|
800
|
+
"""
|
|
801
|
+
variadic = inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD
|
|
802
|
+
offending = [
|
|
803
|
+
parameter.name for parameter in signature.parameters.values() if parameter.kind in variadic
|
|
804
|
+
]
|
|
805
|
+
if offending:
|
|
806
|
+
raise InvalidArgument(
|
|
807
|
+
f"protect({name!r}): a protected function cannot take *args or **kwargs "
|
|
808
|
+
f"({', '.join(offending)}); every argument must be nameable in policy"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _invoke(
|
|
813
|
+
func: Callable[..., R], signature: inspect.Signature, arguments: Mapping[str, Any]
|
|
814
|
+
) -> R:
|
|
815
|
+
"""Call `func` with `arguments`, respecting positional-only parameters."""
|
|
816
|
+
positional = [
|
|
817
|
+
parameter.name
|
|
818
|
+
for parameter in signature.parameters.values()
|
|
819
|
+
if parameter.kind is inspect.Parameter.POSITIONAL_ONLY
|
|
820
|
+
]
|
|
821
|
+
keyword = {key: value for key, value in arguments.items() if key not in positional}
|
|
822
|
+
return func(*(arguments[key] for key in positional), **keyword)
|