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 ADDED
@@ -0,0 +1,67 @@
1
+ """CTRLRun: make consequential AI-agent actions safe to execute.
2
+
3
+ Public API re-exports land with build-list item 1 onward; SPEC-v0.1 §8 freezes the names.
4
+ """
5
+
6
+ from .action import Action, Principal, action_hash, canonicalize
7
+ from .approval import (
8
+ Approval,
9
+ ApprovalProvider,
10
+ ApprovalRequest,
11
+ LocalApprovalProvider,
12
+ ScriptedApprovalProvider,
13
+ )
14
+ from .control import Control, context, protect, with_approval
15
+ from .effect import EffectRecord, EffectState
16
+ from .errors import (
17
+ ActionDenied,
18
+ AmbiguousEffect,
19
+ ApprovalMismatch,
20
+ ApprovalRequired,
21
+ ApprovalTimeout,
22
+ CTRLRunError,
23
+ DuplicateEffect,
24
+ EffectKeyError,
25
+ InvalidArgument,
26
+ NotExecuted,
27
+ PolicyError,
28
+ )
29
+ from .policy import Decision, Policy
30
+ from .receipt import Event, Receipt
31
+ from .state import InMemoryStateStore, SQLiteStateStore, StateStore
32
+
33
+ __all__ = [
34
+ "Action",
35
+ "ActionDenied",
36
+ "AmbiguousEffect",
37
+ "Approval",
38
+ "ApprovalMismatch",
39
+ "ApprovalProvider",
40
+ "ApprovalRequest",
41
+ "ApprovalRequired",
42
+ "ApprovalTimeout",
43
+ "CTRLRunError",
44
+ "Control",
45
+ "Decision",
46
+ "DuplicateEffect",
47
+ "EffectKeyError",
48
+ "EffectRecord",
49
+ "EffectState",
50
+ "Event",
51
+ "InMemoryStateStore",
52
+ "InvalidArgument",
53
+ "LocalApprovalProvider",
54
+ "NotExecuted",
55
+ "Policy",
56
+ "PolicyError",
57
+ "Principal",
58
+ "Receipt",
59
+ "SQLiteStateStore",
60
+ "ScriptedApprovalProvider",
61
+ "StateStore",
62
+ "action_hash",
63
+ "canonicalize",
64
+ "context",
65
+ "protect",
66
+ "with_approval",
67
+ ]
ctrlrun/action.py ADDED
@@ -0,0 +1,161 @@
1
+ """Action model, canonicalization, action_hash. Build-list item 1; SPEC-v0.1 §2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from types import MappingProxyType
11
+ from typing import Any, Final, TypeAlias
12
+
13
+ from .errors import InvalidArgument
14
+
15
+ ACTION_SCHEMA: Final = "ctrlrun.action/v1"
16
+
17
+ #: The argument value types allowed by SPEC-v0.1 §2.3. Note the absence of float.
18
+ PlainValue: TypeAlias = "str | int | bool | list[PlainValue] | dict[str, PlainValue] | None"
19
+
20
+ #: How those values are stored on an Action: deep-frozen (SPEC-v0.1 §2.2).
21
+ FrozenValue: TypeAlias = (
22
+ "str | int | bool | tuple[FrozenValue, ...] | Mapping[str, FrozenValue] | None"
23
+ )
24
+
25
+ _ID_HEX_BYTES: Final = 16 # "act_" + 32 hex chars
26
+
27
+
28
+ def _new_action_id() -> str:
29
+ return f"act_{secrets.token_hex(_ID_HEX_BYTES)}"
30
+
31
+
32
+ def _frozen_value(value: object, path: str) -> FrozenValue:
33
+ """Validate an argument value and return a deep-frozen copy of it."""
34
+ if isinstance(value, float):
35
+ raise InvalidArgument(
36
+ f"float is not an allowed argument type at {path}: "
37
+ "use integer minor units (amount=200000) or a decimal string ('2000.00')"
38
+ )
39
+ if value is None or isinstance(value, str | int): # bool is a subclass of int
40
+ return value
41
+ if isinstance(value, Mapping):
42
+ return _frozen_mapping(value, path)
43
+ if isinstance(value, list | tuple):
44
+ # SPEC: §2.3 — a tuple canonicalizes to the same JSON array as the equivalent list,
45
+ # so it is accepted on input and normalized here.
46
+ return tuple(_frozen_value(item, f"{path}[{index}]") for index, item in enumerate(value))
47
+ raise InvalidArgument(f"{type(value).__name__} is not an allowed argument type at {path}")
48
+
49
+
50
+ def _frozen_mapping(value: Mapping[Any, Any], path: str) -> Mapping[str, FrozenValue]:
51
+ result: dict[str, FrozenValue] = {}
52
+ for key, item in value.items():
53
+ if not isinstance(key, str):
54
+ raise InvalidArgument(f"argument keys must be str, got {type(key).__name__} at {path}")
55
+ result[key] = _frozen_value(item, f"{path}.{key}")
56
+ return MappingProxyType(result)
57
+
58
+
59
+ def _plain_value(value: FrozenValue) -> PlainValue:
60
+ """Return the JSON-serializable counterpart of a stored (frozen) argument value."""
61
+ if isinstance(value, Mapping):
62
+ return _plain_mapping(value)
63
+ if isinstance(value, tuple):
64
+ return [_plain_value(item) for item in value]
65
+ return value
66
+
67
+
68
+ def _plain_mapping(value: Mapping[str, FrozenValue]) -> dict[str, PlainValue]:
69
+ return {key: _plain_value(item) for key, item in value.items()}
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Principal:
74
+ """Who is acting: an agent, optionally on behalf of a human."""
75
+
76
+ agent: str
77
+ user: str | None = None
78
+
79
+ def __post_init__(self) -> None:
80
+ # SPEC: §2.1 does not say what an empty identity means; treat it as invalid.
81
+ if not self.agent:
82
+ raise InvalidArgument("principal.agent must be a non-empty string")
83
+ if self.user is not None and not self.user:
84
+ raise InvalidArgument("principal.user must be a non-empty string or None")
85
+
86
+
87
+ @dataclass(frozen=True, eq=False)
88
+ class Action:
89
+ """A proposed agent action: what, with which arguments, by whom, on what.
90
+
91
+ Equality and hashing follow the proposal, not the content: two Actions are equal iff
92
+ their `action_id` matches (SPEC-v0.1 §2.1). Identical content shares an `action_hash`.
93
+ """
94
+
95
+ name: str
96
+ arguments: Mapping[str, Any]
97
+ principal: Principal
98
+ resource: str | None = None
99
+ environment: str = "production"
100
+ action_id: str = field(default_factory=_new_action_id)
101
+
102
+ def __post_init__(self) -> None:
103
+ if not self.name:
104
+ raise InvalidArgument("action.name must be a non-empty string")
105
+ if not self.environment:
106
+ raise InvalidArgument("action.environment must be a non-empty string")
107
+ if self.resource is not None and not self.resource:
108
+ raise InvalidArgument("action.resource must be a non-empty string or None")
109
+ if not isinstance(self.arguments, Mapping):
110
+ raise InvalidArgument("action.arguments must be a mapping")
111
+ object.__setattr__(self, "arguments", _frozen_mapping(self.arguments, "arguments"))
112
+
113
+ def __eq__(self, other: object) -> bool:
114
+ if not isinstance(other, Action):
115
+ return NotImplemented
116
+ return self.action_id == other.action_id
117
+
118
+ def __hash__(self) -> int:
119
+ return hash(self.action_id)
120
+
121
+ @property
122
+ def action_hash(self) -> str:
123
+ """`"sha256:" + hex(SHA-256(canonical form))` (SPEC-v0.1 §2.3)."""
124
+ return f"sha256:{hashlib.sha256(canonicalize(self)).hexdigest()}"
125
+
126
+ @property
127
+ def canonical_arguments(self) -> dict[str, Any]:
128
+ """Arguments parsed back from the canonical form; what the executor is given.
129
+
130
+ Plain, mutable containers, built fresh on each access, so an executor cannot
131
+ reach back into the Action (SPEC-v0.1 §2.2).
132
+ """
133
+ arguments: dict[str, Any] = json.loads(canonicalize(self))["arguments"]
134
+ return arguments
135
+
136
+
137
+ def canonicalize(action: Action) -> bytes:
138
+ """Return the canonical form of an Action: UTF-8 JSON, sorted keys, no whitespace.
139
+
140
+ `action_id` and timestamps are excluded; the schema tag is included (SPEC-v0.1 §2.2).
141
+ """
142
+ payload = {
143
+ "arguments": _plain_mapping(action.arguments),
144
+ "environment": action.environment,
145
+ "name": action.name,
146
+ "principal": {"agent": action.principal.agent, "user": action.principal.user},
147
+ "resource": action.resource,
148
+ "schema": ACTION_SCHEMA,
149
+ }
150
+ return json.dumps(
151
+ payload,
152
+ sort_keys=True,
153
+ separators=(",", ":"),
154
+ ensure_ascii=False,
155
+ allow_nan=False,
156
+ ).encode("utf-8")
157
+
158
+
159
+ def action_hash(action: Action) -> str:
160
+ """Return the action hash used to bind approvals to an exact action (SPEC-v0.1 §2.3)."""
161
+ return action.action_hash
ctrlrun/approval.py ADDED
@@ -0,0 +1,447 @@
1
+ """Approval requests, grants and providers. Build-list item 4; SPEC-v0.1 §4.
2
+
3
+ An approval authorizes one exact action: it carries the `action_hash` of what a human saw,
4
+ it can be used once, and it expires. Everything here exists to make those three properties
5
+ hard to lose. The store performs the state transitions (they must be atomic); this module
6
+ owns the models, the reason vocabulary, and the two providers that ask a human.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ import time
13
+ from collections.abc import Callable, Iterable
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime, timedelta
16
+ from enum import StrEnum
17
+ from typing import Final, Protocol, runtime_checkable
18
+
19
+ from .action import Action
20
+ from .errors import (
21
+ ActionDenied,
22
+ ApprovalMismatch,
23
+ ApprovalTimeout,
24
+ CTRLRunError,
25
+ InvalidArgument,
26
+ )
27
+
28
+ #: SPEC-v0.1 §4.1 — an approval request lives for fifteen minutes unless told otherwise.
29
+ DEFAULT_APPROVAL_TTL: Final = timedelta(minutes=15)
30
+
31
+ DEFAULT_POLL_INTERVAL: Final = timedelta(seconds=0.5)
32
+
33
+ #: `ApprovalMismatch.reason` values that are not simply the record's status.
34
+ UNKNOWN_APPROVAL: Final = "unknown"
35
+ HASH_MISMATCH: Final = "mismatch"
36
+
37
+ #: `ActionDenied.reason` when the human said no.
38
+ APPROVAL_DENIED: Final = "approval_denied"
39
+
40
+ #: "apr_" + 32 hex chars. 128 bits, not because anything in v0.1 can be attacked by guessing
41
+ #: an approval id — consuming one needs write access to the store, which is game over anyway —
42
+ #: but because a remote approval provider (v0.2, webhooks) turns this id into a bearer token,
43
+ #: and an id format is not a thing you get to widen later without breaking every stored record.
44
+ _ID_HEX_BYTES: Final = 16
45
+
46
+
47
+ def _utc_now() -> datetime:
48
+ return datetime.now(UTC)
49
+
50
+
51
+ def new_request_id() -> str:
52
+ return f"apr_{secrets.token_hex(_ID_HEX_BYTES)}"
53
+
54
+
55
+ def _require_aware(moment: datetime, field: str) -> None:
56
+ # SPEC: §4 — expiry is a comparison, and comparing a naive datetime to an aware one
57
+ # raises at the worst possible moment. Reject naive input instead.
58
+ if moment.tzinfo is None or moment.tzinfo.utcoffset(moment) is None:
59
+ raise InvalidArgument(f"{field} must be timezone-aware, got {moment!r}")
60
+
61
+
62
+ class ApprovalStatus(StrEnum):
63
+ """The status carried by a stored approval record (SPEC-v0.1 §4.1).
64
+
65
+ `StrEnum`, so a status renders as its value in events and CLI output (§6.1), and so a
66
+ refusal reason can simply be the status the record was in.
67
+ """
68
+
69
+ PENDING = "pending"
70
+ GRANTED = "granted"
71
+ DENIED = "denied"
72
+ EXPIRED = "expired"
73
+ CONSUMED = "consumed"
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ApprovalRequest:
78
+ """A pending question for a human: may this exact action run? (SPEC-v0.1 §4.1)"""
79
+
80
+ request_id: str
81
+ action_hash: str
82
+ action: Action
83
+ created_at: datetime
84
+ expires_at: datetime
85
+
86
+ def __post_init__(self) -> None:
87
+ if not self.request_id:
88
+ raise InvalidArgument("approval request_id must be a non-empty string")
89
+ if not self.action_hash:
90
+ raise InvalidArgument("approval action_hash must be a non-empty string")
91
+ _require_aware(self.created_at, "approval created_at")
92
+ _require_aware(self.expires_at, "approval expires_at")
93
+ if self.expires_at <= self.created_at:
94
+ raise InvalidArgument(
95
+ f"approval {self.request_id} expires at or before it was created; "
96
+ "a request nobody can answer is not a request"
97
+ )
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Approval:
102
+ """A human's grant, bound to one `action_hash` (SPEC-v0.1 §4.1).
103
+
104
+ `approval_id == request_id` in v0.1: a request produces at most one approval.
105
+ """
106
+
107
+ approval_id: str
108
+ action_hash: str
109
+ approver: str
110
+ granted_at: datetime
111
+ expires_at: datetime
112
+
113
+ def __post_init__(self) -> None:
114
+ if not self.approval_id:
115
+ raise InvalidArgument("approval_id must be a non-empty string")
116
+ if not self.action_hash:
117
+ raise InvalidArgument("approval action_hash must be a non-empty string")
118
+ if not self.approver:
119
+ raise InvalidArgument("approver must be a non-empty string")
120
+ _require_aware(self.granted_at, "approval granted_at")
121
+ _require_aware(self.expires_at, "approval expires_at")
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ApprovalRecord:
126
+ """What the StateStore holds for a request: the request plus its status (SPEC §4.1)."""
127
+
128
+ request: ApprovalRequest
129
+ status: ApprovalStatus
130
+ approver: str | None = None
131
+ granted_at: datetime | None = None
132
+ consumed_at: datetime | None = None
133
+
134
+ @property
135
+ def approval_id(self) -> str:
136
+ return self.request.request_id
137
+
138
+ @property
139
+ def action_hash(self) -> str:
140
+ return self.request.action_hash
141
+
142
+ @property
143
+ def expires_at(self) -> datetime:
144
+ return self.request.expires_at
145
+
146
+ def as_approval(self) -> Approval:
147
+ """The `Approval` this record stands for. Only a granted record has one."""
148
+ if self.approver is None or self.granted_at is None:
149
+ raise ApprovalMismatch(
150
+ f"approval {self.approval_id} was never granted",
151
+ reason=str(self.status),
152
+ approval_id=self.approval_id,
153
+ )
154
+ return Approval(
155
+ approval_id=self.approval_id,
156
+ action_hash=self.action_hash,
157
+ approver=self.approver,
158
+ granted_at=self.granted_at,
159
+ expires_at=self.expires_at,
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class ApprovalVerdict:
165
+ """What a store must do about one approval it was handed (SPEC-v0.1 §4.2).
166
+
167
+ Exactly one of `record` and `refusal` is set. `expire` means the record must first be
168
+ marked `expired`, and that write kept even though the approval is refused: a lapsed
169
+ approval is evidence, not something to roll back.
170
+ """
171
+
172
+ record: ApprovalRecord | None = None
173
+ refusal: CTRLRunError | None = None
174
+ expire: bool = False
175
+
176
+
177
+ def check_consumable(
178
+ record: ApprovalRecord | None, approval_id: str, action_hash: str, now: datetime
179
+ ) -> ApprovalVerdict:
180
+ """Decide whether a presented approval authorizes this action (SPEC-v0.1 §4.2).
181
+
182
+ Pure: it reads a record and returns a verdict, so every store applies the same rules and
183
+ performs the same writes. The hash is checked *before* the status, so a mutated action
184
+ leaves the approval untouched and still grantable for the action the human actually saw
185
+ (A1, acceptance test T2).
186
+ """
187
+ if record is None:
188
+ return ApprovalVerdict(
189
+ refusal=ApprovalMismatch(
190
+ f"no approval {approval_id}", reason=UNKNOWN_APPROVAL, approval_id=approval_id
191
+ )
192
+ )
193
+ if record.action_hash != action_hash:
194
+ return ApprovalVerdict(
195
+ refusal=ApprovalMismatch(
196
+ f"approval {approval_id} authorizes {record.action_hash}, not {action_hash}",
197
+ reason=HASH_MISMATCH,
198
+ approval_id=approval_id,
199
+ )
200
+ )
201
+ if record.status is ApprovalStatus.DENIED:
202
+ return ApprovalVerdict(
203
+ refusal=ActionDenied(
204
+ f"approval {approval_id} was denied by {record.approver}",
205
+ reason=APPROVAL_DENIED,
206
+ )
207
+ )
208
+ if record.status is not ApprovalStatus.GRANTED:
209
+ return ApprovalVerdict(
210
+ refusal=ApprovalMismatch(
211
+ f"approval {approval_id} is {record.status}, not granted",
212
+ reason=str(record.status),
213
+ approval_id=approval_id,
214
+ )
215
+ )
216
+ if now > record.expires_at:
217
+ # A3: expiry is checked here, at consumption, not only at grant time.
218
+ return ApprovalVerdict(refusal=_expired(record, approval_id), expire=True)
219
+ return ApprovalVerdict(record=record)
220
+
221
+
222
+ def check_answerable(
223
+ record: ApprovalRecord | None, approval_id: str, now: datetime
224
+ ) -> ApprovalVerdict:
225
+ """Decide whether a request may still be granted or denied (SPEC-v0.1 §4.1)."""
226
+ if record is None:
227
+ return ApprovalVerdict(
228
+ refusal=ApprovalMismatch(
229
+ f"no approval request {approval_id}",
230
+ reason=UNKNOWN_APPROVAL,
231
+ approval_id=approval_id,
232
+ )
233
+ )
234
+ if record.status is not ApprovalStatus.PENDING:
235
+ return ApprovalVerdict(
236
+ refusal=ApprovalMismatch(
237
+ f"approval request {approval_id} is already {record.status}",
238
+ reason=str(record.status),
239
+ approval_id=approval_id,
240
+ )
241
+ )
242
+ if now > record.expires_at:
243
+ return ApprovalVerdict(refusal=_expired(record, approval_id), expire=True)
244
+ return ApprovalVerdict(record=record)
245
+
246
+
247
+ def _expired(record: ApprovalRecord, approval_id: str) -> ApprovalMismatch:
248
+ return ApprovalMismatch(
249
+ f"approval {approval_id} expired at {record.expires_at.isoformat()}",
250
+ reason=str(ApprovalStatus.EXPIRED),
251
+ approval_id=approval_id,
252
+ )
253
+
254
+
255
+ class ApprovalStore(Protocol):
256
+ """The approval half of the `StateStore` protocol (SPEC-v0.1 §5.3).
257
+
258
+ Split out so approval providers depend on the slice they use, and so `state.py` — which
259
+ implements it — can be the only module that owns transitions.
260
+ """
261
+
262
+ def put_approval_request(self, request: ApprovalRequest) -> None:
263
+ """Record a new request as `pending`. A reused `request_id` is an error."""
264
+ ...
265
+
266
+ def get_approval(self, approval_id: str) -> ApprovalRecord | None:
267
+ """The stored record, or `None` if there is no such approval."""
268
+ ...
269
+
270
+ def grant_approval(self, approval_id: str, approver: str) -> Approval:
271
+ """Move `pending → granted`. Anything else raises `ApprovalMismatch`."""
272
+ ...
273
+
274
+ def deny_approval(self, approval_id: str, approver: str) -> None:
275
+ """Move `pending → denied`. Anything else raises `ApprovalMismatch`."""
276
+ ...
277
+
278
+ def consume_approval(self, approval_id: str, action_hash: str) -> Approval:
279
+ """Atomically move `granted → consumed`, for this `action_hash` only (§4.2)."""
280
+ ...
281
+
282
+
283
+ @runtime_checkable
284
+ class ApprovalProvider(Protocol):
285
+ """How a human is asked, and how the answer comes back (SPEC-v0.1 §4.3).
286
+
287
+ `runtime_checkable` so a test can assert the shipped providers still answer to this
288
+ shape; it checks method names only, which is why the static check matters more.
289
+ """
290
+
291
+ def request(self, action: Action, ttl: timedelta) -> ApprovalRequest:
292
+ """Record a request for `action` and return it."""
293
+ ...
294
+
295
+ def wait(self, request_id: str, timeout: timedelta | None) -> Approval | None:
296
+ """Block until answered: the `Approval` if granted, `None` if denied.
297
+
298
+ Raises `ApprovalTimeout` if nobody answers within `timeout` or before the request
299
+ itself expires.
300
+ """
301
+ ...
302
+
303
+
304
+ def _build_request(action: Action, ttl: timedelta, now: datetime) -> ApprovalRequest:
305
+ if ttl <= timedelta(0):
306
+ raise InvalidArgument(f"approval ttl must be positive, got {ttl!r}")
307
+ return ApprovalRequest(
308
+ request_id=new_request_id(),
309
+ action_hash=action.action_hash,
310
+ action=action,
311
+ created_at=now,
312
+ expires_at=now + ttl,
313
+ )
314
+
315
+
316
+ class LocalApprovalProvider:
317
+ """Requests go to the StateStore; `wait()` polls it (SPEC-v0.1 §4.3).
318
+
319
+ The human answers out of band — `ctrlrun approve <id>` or `ctrlrun deny <id>` in another
320
+ shell. Waiting is bounded by the request's own expiry, so a request nobody answers ends
321
+ in `ApprovalTimeout` rather than a blocked agent.
322
+ """
323
+
324
+ def __init__(
325
+ self,
326
+ store: ApprovalStore,
327
+ *,
328
+ clock: Callable[[], datetime] = _utc_now,
329
+ poll_interval: timedelta = DEFAULT_POLL_INTERVAL,
330
+ ) -> None:
331
+ self._store = store
332
+ self._clock = clock
333
+ self._poll_interval = poll_interval
334
+
335
+ def request(self, action: Action, ttl: timedelta = DEFAULT_APPROVAL_TTL) -> ApprovalRequest:
336
+ request = _build_request(action, ttl, self._clock())
337
+ self._store.put_approval_request(request)
338
+ return request
339
+
340
+ def wait(self, request_id: str, timeout: timedelta | None = None) -> Approval | None:
341
+ deadline = self._clock() + timeout if timeout is not None else None
342
+ while True:
343
+ record = self._store.get_approval(request_id)
344
+ if record is None:
345
+ raise ApprovalMismatch(
346
+ f"no approval request {request_id}",
347
+ reason=UNKNOWN_APPROVAL,
348
+ approval_id=request_id,
349
+ )
350
+ if record.status is ApprovalStatus.GRANTED:
351
+ return record.as_approval()
352
+ if record.status is not ApprovalStatus.PENDING:
353
+ # Denied, consumed or already expired: this request will never be granted.
354
+ return None
355
+ now = self._clock()
356
+ if now > record.expires_at or (deadline is not None and now >= deadline):
357
+ raise ApprovalTimeout(
358
+ f"approval request {request_id} was not answered in time",
359
+ request_id=request_id,
360
+ )
361
+ time.sleep(self._poll_interval.total_seconds())
362
+
363
+
364
+ class ScriptedOutcome(StrEnum):
365
+ """What a scripted approver does on one poll."""
366
+
367
+ PENDING = "pending"
368
+ GRANT = "grant"
369
+ DENY = "deny"
370
+
371
+
372
+ class ScriptedApprovalProvider:
373
+ """A human replaced by a fixed script: for tests and `ctrlrun demo` (SPEC-v0.1 §4.3).
374
+
375
+ Each `wait()` poll takes the next step. `PENDING` means "no answer yet", so a script can
376
+ make `wait=True` genuinely block. The script is one sequence shared by every request, in
377
+ poll order. An exhausted script raises `ApprovalTimeout`: a scripted approver never
378
+ grants by accident, and a test can never hang waiting for a step that will not come.
379
+
380
+ The script does not outrank the clock. A step that lands after the request has expired
381
+ raises `ApprovalTimeout` and is not applied, so the double cannot grant something the
382
+ real provider would have refused (SPEC-v0.1 §4.3).
383
+ """
384
+
385
+ def __init__(
386
+ self,
387
+ store: ApprovalStore,
388
+ script: Iterable[str | ScriptedOutcome],
389
+ *,
390
+ approver: str = "cli:scripted",
391
+ clock: Callable[[], datetime] = _utc_now,
392
+ ) -> None:
393
+ if not approver:
394
+ raise InvalidArgument("approver must be a non-empty string")
395
+ self._store = store
396
+ self._script = tuple(_parse_outcome(step) for step in script)
397
+ self._approver = approver
398
+ self._clock = clock
399
+ self._step = 0
400
+ self.polls = 0
401
+
402
+ def request(self, action: Action, ttl: timedelta = DEFAULT_APPROVAL_TTL) -> ApprovalRequest:
403
+ request = _build_request(action, ttl, self._clock())
404
+ self._store.put_approval_request(request)
405
+ return request
406
+
407
+ def wait(self, request_id: str, timeout: timedelta | None = None) -> Approval | None:
408
+ deadline = self._clock() + timeout if timeout is not None else None
409
+ while True:
410
+ if self._step >= len(self._script):
411
+ raise ApprovalTimeout(
412
+ f"the approval script has no answer for {request_id}",
413
+ request_id=request_id,
414
+ )
415
+ record = self._store.get_approval(request_id)
416
+ if record is None:
417
+ raise ApprovalMismatch(
418
+ f"no approval request {request_id}",
419
+ reason=UNKNOWN_APPROVAL,
420
+ approval_id=request_id,
421
+ )
422
+ now = self._clock()
423
+ if now > record.expires_at or (deadline is not None and now >= deadline):
424
+ # SPEC §4.3 — the request's own expiry bounds the wait for every provider;
425
+ # a scripted answer arriving after it is an answer to a dead request.
426
+ raise ApprovalTimeout(
427
+ f"approval request {request_id} was not answered in time",
428
+ request_id=request_id,
429
+ )
430
+ outcome = self._script[self._step]
431
+ self._step += 1
432
+ self.polls += 1
433
+ if outcome is ScriptedOutcome.GRANT:
434
+ return self._store.grant_approval(request_id, self._approver)
435
+ if outcome is ScriptedOutcome.DENY:
436
+ self._store.deny_approval(request_id, self._approver)
437
+ return None
438
+
439
+
440
+ def _parse_outcome(step: str | ScriptedOutcome) -> ScriptedOutcome:
441
+ try:
442
+ return ScriptedOutcome(step)
443
+ except ValueError as exc:
444
+ allowed = ", ".join(member.value for member in ScriptedOutcome)
445
+ raise InvalidArgument(
446
+ f"unknown scripted approval outcome {step!r}, expected one of {allowed}"
447
+ ) from exc
@@ -0,0 +1 @@
1
+ """Command-line interface. Build-list item 8; SPEC-v0.1 §8."""