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/__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
|
ctrlrun/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface. Build-list item 8; SPEC-v0.1 §8."""
|