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/policy.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Policy loading and rule evaluation to a Decision. Build-list item 2; SPEC-v0.1 §3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import operator
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Any, Final
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .action import Action
|
|
18
|
+
from .errors import PolicyError
|
|
19
|
+
|
|
20
|
+
POLICY_SCHEMA: Final = "ctrlrun.policy/v1"
|
|
21
|
+
CONFIG_ENV_VAR: Final = "CTRLRUN_CONFIG"
|
|
22
|
+
DEFAULT_POLICY_FILENAME: Final = "ctrlrun.yaml"
|
|
23
|
+
|
|
24
|
+
#: Reasons attached to an Evaluation that no rule produced.
|
|
25
|
+
UNKNOWN_ACTION: Final = "unknown_action"
|
|
26
|
+
NO_MATCHING_RULE: Final = "no_matching_rule"
|
|
27
|
+
BARE_DECISION: Final = "decision"
|
|
28
|
+
|
|
29
|
+
_LOG = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = {
|
|
32
|
+
"lt": operator.lt,
|
|
33
|
+
"lte": operator.le,
|
|
34
|
+
"gt": operator.gt,
|
|
35
|
+
"gte": operator.ge,
|
|
36
|
+
}
|
|
37
|
+
_OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE)
|
|
38
|
+
#: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq).
|
|
39
|
+
_OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True))
|
|
40
|
+
|
|
41
|
+
_TOP_LEVEL_KEYS: Final = frozenset({"schema", "actions"})
|
|
42
|
+
_ENTRY_KEYS: Final = frozenset({"decision", "rules"})
|
|
43
|
+
_RULE_KEYS: Final = frozenset({"when", "decision"})
|
|
44
|
+
|
|
45
|
+
#: Names of `Action` fields (SPEC-v0.1 §2.1), which a condition cannot address: conditions
|
|
46
|
+
#: see the action's *arguments* and nothing else (§3.2). Writing one reads like it scopes a
|
|
47
|
+
#: rule — `when: { environment_eq: production }` — and matches nothing, so it is refused at
|
|
48
|
+
#: load. Same fail-closed reading as `{resource}` in §5.1: two candidate meanings for one
|
|
49
|
+
#: name, so make the author rename rather than silently pick one.
|
|
50
|
+
RESERVED_ARGUMENTS: Final = frozenset(
|
|
51
|
+
{"action_id", "agent", "environment", "principal", "resource", "user"}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Decision(StrEnum):
|
|
56
|
+
"""What may happen to an action: exactly three outcomes in v0.1 (SPEC-v0.1 §3.3).
|
|
57
|
+
|
|
58
|
+
`StrEnum`, so a member renders as its value in receipts and CLI output (SPEC-v0.1 §6.1).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
ALLOW = "allow"
|
|
62
|
+
APPROVE = "approve"
|
|
63
|
+
DENY = "deny"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Evaluation:
|
|
68
|
+
"""A decision and the reason it was reached, e.g. `rule[1]` or `unknown_action`."""
|
|
69
|
+
|
|
70
|
+
decision: Decision
|
|
71
|
+
reason: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_int(value: object) -> bool:
|
|
75
|
+
"""True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it."""
|
|
76
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _type_name(value: object) -> str:
|
|
80
|
+
return type(value).__name__
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _equal(value: object, operand: object) -> bool:
|
|
84
|
+
"""Type-strict equality: `True` never equals `1`, and a list never equals a scalar.
|
|
85
|
+
|
|
86
|
+
SPEC: §3.2 — equality is type-strict and applies recursively inside containers.
|
|
87
|
+
Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a
|
|
88
|
+
policy written for `1` would match `True`.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(value, bool) or isinstance(operand, bool):
|
|
91
|
+
return value is operand
|
|
92
|
+
if isinstance(value, Mapping) and isinstance(operand, Mapping):
|
|
93
|
+
return value.keys() == operand.keys() and all(
|
|
94
|
+
_equal(value[key], operand[key]) for key in value
|
|
95
|
+
)
|
|
96
|
+
if isinstance(value, list | tuple) and isinstance(operand, list | tuple):
|
|
97
|
+
return len(value) == len(operand) and all(
|
|
98
|
+
_equal(item, other) for item, other in zip(value, operand, strict=True)
|
|
99
|
+
)
|
|
100
|
+
if _is_container(value) or _is_container(operand):
|
|
101
|
+
return False
|
|
102
|
+
return bool(value == operand)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_container(value: object) -> bool:
|
|
106
|
+
return isinstance(value, Mapping | list | tuple)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class _Condition:
|
|
111
|
+
key: str
|
|
112
|
+
argument: str
|
|
113
|
+
op: str
|
|
114
|
+
operand: Any
|
|
115
|
+
|
|
116
|
+
def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool:
|
|
117
|
+
if self.argument not in arguments:
|
|
118
|
+
# SPEC §3.2 — still false, never an error, but never silent either. Defaults are
|
|
119
|
+
# applied when a call is bound (§8), so an argument is either always present or
|
|
120
|
+
# never: an absent one is a typo, and silence let a mistyped rule disappear into
|
|
121
|
+
# a catch-all below it.
|
|
122
|
+
_LOG.warning(
|
|
123
|
+
"%s: condition %s ignored: the action has no argument %r (it has: %s)",
|
|
124
|
+
action_name,
|
|
125
|
+
self.key,
|
|
126
|
+
self.argument,
|
|
127
|
+
", ".join(sorted(arguments)) or "none",
|
|
128
|
+
)
|
|
129
|
+
return False
|
|
130
|
+
value = arguments[self.argument]
|
|
131
|
+
if self.op == "eq":
|
|
132
|
+
return _equal(value, self.operand)
|
|
133
|
+
if self.op == "neq":
|
|
134
|
+
return not _equal(value, self.operand)
|
|
135
|
+
if self.op == "in":
|
|
136
|
+
return any(_equal(value, item) for item in self.operand)
|
|
137
|
+
if not _is_int(value):
|
|
138
|
+
_LOG.warning(
|
|
139
|
+
"%s: condition %s ignored: argument %r is %s, not int",
|
|
140
|
+
action_name,
|
|
141
|
+
self.key,
|
|
142
|
+
self.argument,
|
|
143
|
+
_type_name(value),
|
|
144
|
+
)
|
|
145
|
+
return False
|
|
146
|
+
return _NUMERIC_COMPARE[self.op](value, self.operand)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class _Rule:
|
|
151
|
+
decision: Decision
|
|
152
|
+
conditions: tuple[_Condition, ...]
|
|
153
|
+
|
|
154
|
+
def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool:
|
|
155
|
+
# A rule with no `when` has no conditions, so all() holds and it always matches.
|
|
156
|
+
return all(condition.matches(action_name, arguments) for condition in self.conditions)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class _ActionPolicy:
|
|
161
|
+
decision: Decision | None
|
|
162
|
+
rules: tuple[_Rule, ...]
|
|
163
|
+
|
|
164
|
+
def evaluate(self, action_name: str, arguments: Mapping[str, Any]) -> Evaluation:
|
|
165
|
+
if self.decision is not None:
|
|
166
|
+
return Evaluation(self.decision, BARE_DECISION)
|
|
167
|
+
for index, rule in enumerate(self.rules):
|
|
168
|
+
if rule.matches(action_name, arguments):
|
|
169
|
+
return Evaluation(rule.decision, f"rule[{index}]")
|
|
170
|
+
return Evaluation(Decision.DENY, NO_MATCHING_RULE)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class Policy:
|
|
175
|
+
"""Action-level autonomy policy: which actions may run, and under which conditions.
|
|
176
|
+
|
|
177
|
+
Load with `Policy.from_file()`. A policy that cannot be loaded is an error, never an
|
|
178
|
+
empty permissive policy (SPEC-v0.1 §3.4).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
actions: Mapping[str, _ActionPolicy]
|
|
182
|
+
source: str
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_file(cls, path: str | os.PathLike[str] | None = None) -> Policy:
|
|
186
|
+
"""Load a policy from `path`, else `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`."""
|
|
187
|
+
resolved = Path(path) if path is not None else discover_policy_path()
|
|
188
|
+
try:
|
|
189
|
+
text = resolved.read_text(encoding="utf-8")
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
raise PolicyError(f"policy file {resolved} could not be read: {exc}") from exc
|
|
192
|
+
return cls.from_yaml(text, source=str(resolved))
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_yaml(cls, text: str, *, source: str = "<string>") -> Policy:
|
|
196
|
+
"""Parse and validate a policy document. Anything malformed raises `PolicyError`."""
|
|
197
|
+
try:
|
|
198
|
+
document = yaml.safe_load(text)
|
|
199
|
+
except yaml.YAMLError as exc:
|
|
200
|
+
raise PolicyError(f"{source}: not valid YAML: {exc}") from exc
|
|
201
|
+
return cls._from_document(document, source)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def _from_document(cls, document: object, source: str) -> Policy:
|
|
205
|
+
if not isinstance(document, Mapping):
|
|
206
|
+
raise PolicyError(
|
|
207
|
+
f"{source}: policy must be a mapping with 'schema' and 'actions' keys, "
|
|
208
|
+
f"got {_type_name(document)}"
|
|
209
|
+
)
|
|
210
|
+
# SPEC: §3.4 — an absent `schema` key is an unknown schema, never "assume v1".
|
|
211
|
+
schema = document.get("schema")
|
|
212
|
+
if schema != POLICY_SCHEMA:
|
|
213
|
+
raise PolicyError(
|
|
214
|
+
f"{source}: unknown policy schema {schema!r}, expected {POLICY_SCHEMA!r}"
|
|
215
|
+
)
|
|
216
|
+
# SPEC: §3.1 — key sets are closed, so a typo such as `action:` fails at load
|
|
217
|
+
# instead of silently denying everything at runtime.
|
|
218
|
+
_reject_unknown_keys(document, _TOP_LEVEL_KEYS, f"{source}: top level")
|
|
219
|
+
|
|
220
|
+
entries = document.get("actions")
|
|
221
|
+
if not isinstance(entries, Mapping):
|
|
222
|
+
raise PolicyError(
|
|
223
|
+
f"{source}: 'actions' must be a mapping of action name to entry, "
|
|
224
|
+
f"got {_type_name(entries)}"
|
|
225
|
+
)
|
|
226
|
+
actions: dict[str, _ActionPolicy] = {}
|
|
227
|
+
for name, entry in entries.items():
|
|
228
|
+
if not isinstance(name, str) or not name:
|
|
229
|
+
raise PolicyError(f"{source}: action names must be non-empty strings, got {name!r}")
|
|
230
|
+
actions[name] = _parse_entry(entry, f"{source}: action {name!r}")
|
|
231
|
+
return cls(actions=MappingProxyType(actions), source=source)
|
|
232
|
+
|
|
233
|
+
def evaluate(self, action: Action) -> Evaluation:
|
|
234
|
+
"""Decide an action. No side effects; an unlisted action is denied (SPEC-v0.1 §3.4)."""
|
|
235
|
+
entry = self.actions.get(action.name)
|
|
236
|
+
if entry is None:
|
|
237
|
+
return Evaluation(Decision.DENY, UNKNOWN_ACTION)
|
|
238
|
+
# Conditions see exactly what the executor will receive (SPEC-v0.1 §2.2), which is
|
|
239
|
+
# also why a list argument compares equal to a list operand.
|
|
240
|
+
return entry.evaluate(action.name, action.canonical_arguments)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def discover_policy_path() -> Path:
|
|
244
|
+
"""The policy file this process would load: `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`."""
|
|
245
|
+
configured = os.environ.get(CONFIG_ENV_VAR)
|
|
246
|
+
if configured is None:
|
|
247
|
+
return Path.cwd() / DEFAULT_POLICY_FILENAME
|
|
248
|
+
if not configured.strip():
|
|
249
|
+
raise PolicyError(f"{CONFIG_ENV_VAR} is set but empty; unset it or point it at a policy")
|
|
250
|
+
return Path(configured)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _reject_unknown_keys(mapping: Mapping[Any, Any], allowed: Iterable[str], where: str) -> None:
|
|
254
|
+
unknown = sorted(repr(key) for key in mapping if key not in set(allowed))
|
|
255
|
+
if unknown:
|
|
256
|
+
raise PolicyError(
|
|
257
|
+
f"{where}: unknown key(s) {', '.join(unknown)}; allowed: {', '.join(sorted(allowed))}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parse_entry(entry: object, where: str) -> _ActionPolicy:
|
|
262
|
+
if not isinstance(entry, Mapping):
|
|
263
|
+
raise PolicyError(
|
|
264
|
+
f"{where}: entry must be a mapping with 'decision' or 'rules', got {_type_name(entry)}"
|
|
265
|
+
)
|
|
266
|
+
_reject_unknown_keys(entry, _ENTRY_KEYS, where)
|
|
267
|
+
has_decision = "decision" in entry
|
|
268
|
+
has_rules = "rules" in entry
|
|
269
|
+
if has_decision == has_rules:
|
|
270
|
+
raise PolicyError(f"{where}: entry must have exactly one of 'decision' or 'rules'")
|
|
271
|
+
|
|
272
|
+
if has_decision:
|
|
273
|
+
return _ActionPolicy(decision=_parse_decision(entry["decision"], where), rules=())
|
|
274
|
+
|
|
275
|
+
rules = entry["rules"]
|
|
276
|
+
if not isinstance(rules, list) or not rules:
|
|
277
|
+
raise PolicyError(f"{where}: 'rules' must be a non-empty list, got {_type_name(rules)}")
|
|
278
|
+
return _ActionPolicy(
|
|
279
|
+
decision=None,
|
|
280
|
+
rules=tuple(
|
|
281
|
+
_parse_rule(rule, f"{where} rule[{index}]") for index, rule in enumerate(rules)
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_decision(value: object, where: str) -> Decision:
|
|
287
|
+
allowed = ", ".join(member.value for member in Decision)
|
|
288
|
+
if not isinstance(value, str):
|
|
289
|
+
raise PolicyError(f"{where}: decision must be one of {allowed}, got {_type_name(value)}")
|
|
290
|
+
try:
|
|
291
|
+
return Decision(value)
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
raise PolicyError(
|
|
294
|
+
f"{where}: unknown decision {value!r}, expected one of {allowed}"
|
|
295
|
+
) from exc
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _parse_rule(rule: object, where: str) -> _Rule:
|
|
299
|
+
if not isinstance(rule, Mapping):
|
|
300
|
+
raise PolicyError(f"{where}: a rule must be a mapping, got {_type_name(rule)}")
|
|
301
|
+
_reject_unknown_keys(rule, _RULE_KEYS, where)
|
|
302
|
+
if "decision" not in rule:
|
|
303
|
+
raise PolicyError(f"{where}: a rule must have a 'decision'")
|
|
304
|
+
decision = _parse_decision(rule["decision"], where)
|
|
305
|
+
if "when" not in rule:
|
|
306
|
+
return _Rule(decision=decision, conditions=())
|
|
307
|
+
|
|
308
|
+
when = rule["when"]
|
|
309
|
+
# SPEC: §3.2 — `when` is either absent (always matches) or a non-empty mapping. An
|
|
310
|
+
# empty mapping is a truncated edit, and the catch-all is already spelled "no `when`".
|
|
311
|
+
if not isinstance(when, Mapping) or not when:
|
|
312
|
+
raise PolicyError(
|
|
313
|
+
f"{where}: 'when' must be a non-empty mapping of conditions, got {_type_name(when)}"
|
|
314
|
+
)
|
|
315
|
+
return _Rule(
|
|
316
|
+
decision=decision,
|
|
317
|
+
conditions=tuple(_parse_condition(key, operand, where) for key, operand in when.items()),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_condition(key: object, operand: object, where: str) -> _Condition:
|
|
322
|
+
if not isinstance(key, str):
|
|
323
|
+
raise PolicyError(f"{where}: condition keys must be strings, got {key!r}")
|
|
324
|
+
argument, op = _split_condition_key(key, where)
|
|
325
|
+
return _Condition(
|
|
326
|
+
key=key,
|
|
327
|
+
argument=argument,
|
|
328
|
+
op=op,
|
|
329
|
+
operand=_parse_operand(op, operand, where, key),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _split_condition_key(key: str, where: str) -> tuple[str, str]:
|
|
334
|
+
for op in _OPERATORS_BY_LENGTH:
|
|
335
|
+
suffix = f"_{op}"
|
|
336
|
+
if key.endswith(suffix) and len(key) > len(suffix):
|
|
337
|
+
argument = key[: -len(suffix)]
|
|
338
|
+
if argument in RESERVED_ARGUMENTS:
|
|
339
|
+
raise PolicyError(
|
|
340
|
+
f"{where}: condition {key!r} names the Action field {argument!r}, not an "
|
|
341
|
+
"argument; a v0.1 condition can only address the action's arguments, so "
|
|
342
|
+
"this rule would never match. If the protected function really does take "
|
|
343
|
+
f"an argument called {argument!r}, rename it."
|
|
344
|
+
)
|
|
345
|
+
return argument, op
|
|
346
|
+
raise PolicyError(
|
|
347
|
+
f"{where}: condition {key!r} must be '<argument>_<op>' where op is one of "
|
|
348
|
+
f"{', '.join(sorted(_OPERATORS))}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parse_operand(op: str, operand: object, where: str, key: str) -> object:
|
|
353
|
+
if op in _NUMERIC_COMPARE:
|
|
354
|
+
if not _is_int(operand):
|
|
355
|
+
raise PolicyError(
|
|
356
|
+
f"{where}: condition {key!r}: a numeric operator needs an int operand, "
|
|
357
|
+
f"got {_type_name(operand)}"
|
|
358
|
+
)
|
|
359
|
+
return operand
|
|
360
|
+
if op == "in":
|
|
361
|
+
if not isinstance(operand, list):
|
|
362
|
+
raise PolicyError(
|
|
363
|
+
f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}"
|
|
364
|
+
)
|
|
365
|
+
return tuple(_checked_operand(item, where, key) for item in operand)
|
|
366
|
+
return _checked_operand(operand, where, key)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _checked_operand(operand: object, where: str, key: str) -> object:
|
|
370
|
+
"""Validate an operand against the argument types allowed by SPEC-v0.1 §2.3."""
|
|
371
|
+
if isinstance(operand, float):
|
|
372
|
+
raise PolicyError(
|
|
373
|
+
f"{where}: condition {key!r}: float operands are not allowed; use integer minor "
|
|
374
|
+
"units (amount_lte: 50000) or a decimal string"
|
|
375
|
+
)
|
|
376
|
+
if operand is None or isinstance(operand, str | int): # bool is a subclass of int
|
|
377
|
+
return operand
|
|
378
|
+
if isinstance(operand, Mapping):
|
|
379
|
+
for name in operand:
|
|
380
|
+
if not isinstance(name, str):
|
|
381
|
+
raise PolicyError(
|
|
382
|
+
f"{where}: condition {key!r}: operand keys must be strings, got {name!r}"
|
|
383
|
+
)
|
|
384
|
+
return {name: _checked_operand(value, where, key) for name, value in operand.items()}
|
|
385
|
+
if isinstance(operand, list):
|
|
386
|
+
return [_checked_operand(item, where, key) for item in operand]
|
|
387
|
+
raise PolicyError(
|
|
388
|
+
f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type"
|
|
389
|
+
)
|
ctrlrun/py.typed
ADDED
|
File without changes
|
ctrlrun/receipt.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Receipts and the event log. Build-list item 8; SPEC-v0.1 §6.
|
|
2
|
+
|
|
3
|
+
`Control` produces a `Receipt` for every action that reaches a terminal state, and an `Event`
|
|
4
|
+
for every step it takes. `EventLog` is where those land on disk: `.ctrlrun/receipts.jsonl`
|
|
5
|
+
and `.ctrlrun/events.jsonl`, one JSON object per line, in append order.
|
|
6
|
+
|
|
7
|
+
A receipt is evidence, and evidence has to outlive the tool that wrote it — so the file form
|
|
8
|
+
is plain JSON with enums rendered by value, readable by anything that can read a line.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Final
|
|
22
|
+
|
|
23
|
+
from .action import Principal
|
|
24
|
+
from .policy import Decision
|
|
25
|
+
|
|
26
|
+
RECEIPT_SCHEMA: Final = "ctrlrun.receipt/v1"
|
|
27
|
+
|
|
28
|
+
#: The two files of SPEC-v0.1 §6, written beside the state database.
|
|
29
|
+
RECEIPTS_FILENAME: Final = "receipts.jsonl"
|
|
30
|
+
EVENTS_FILENAME: Final = "events.jsonl"
|
|
31
|
+
|
|
32
|
+
_ID_HEX_BYTES: Final = 16 # "ctr_" + 32 hex chars
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def new_receipt_id() -> str:
|
|
36
|
+
return f"ctr_{secrets.token_hex(_ID_HEX_BYTES)}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def iso_timestamp(moment: datetime) -> str:
|
|
40
|
+
"""UTC ISO-8601 with a `Z` suffix, as in SPEC-v0.1 §6.1."""
|
|
41
|
+
return moment.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ReceiptResult(StrEnum):
|
|
45
|
+
"""The terminal outcome recorded on a receipt (SPEC-v0.1 §6.1).
|
|
46
|
+
|
|
47
|
+
`BLOCKED` covers duplicate, ambiguous-retry and approval-mismatch refusals.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
COMMITTED = "committed"
|
|
51
|
+
FAILED = "failed"
|
|
52
|
+
AMBIGUOUS = "ambiguous"
|
|
53
|
+
DENIED = "denied"
|
|
54
|
+
BLOCKED = "blocked"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventType(StrEnum):
|
|
58
|
+
"""The closed set of event types in SPEC-v0.1 §6.2."""
|
|
59
|
+
|
|
60
|
+
ACTION_PROPOSED = "ACTION_PROPOSED"
|
|
61
|
+
POLICY_EVALUATED = "POLICY_EVALUATED"
|
|
62
|
+
APPROVAL_REQUESTED = "APPROVAL_REQUESTED"
|
|
63
|
+
APPROVAL_GRANTED = "APPROVAL_GRANTED"
|
|
64
|
+
APPROVAL_DENIED = "APPROVAL_DENIED"
|
|
65
|
+
APPROVAL_EXPIRED = "APPROVAL_EXPIRED"
|
|
66
|
+
APPROVAL_INVALIDATED = "APPROVAL_INVALIDATED"
|
|
67
|
+
APPROVAL_CONSUMED = "APPROVAL_CONSUMED"
|
|
68
|
+
EFFECT_RESERVED = "EFFECT_RESERVED"
|
|
69
|
+
EFFECT_RESERVATION_REFUSED = "EFFECT_RESERVATION_REFUSED"
|
|
70
|
+
EXECUTION_STARTED = "EXECUTION_STARTED"
|
|
71
|
+
EXECUTION_COMMITTED = "EXECUTION_COMMITTED"
|
|
72
|
+
EXECUTION_FAILED = "EXECUTION_FAILED"
|
|
73
|
+
EXECUTION_AMBIGUOUS = "EXECUTION_AMBIGUOUS"
|
|
74
|
+
EFFECT_RESOLVED = "EFFECT_RESOLVED"
|
|
75
|
+
ACTION_DENIED = "ACTION_DENIED"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Event:
|
|
80
|
+
"""One ordered step in the life of an action (SPEC-v0.1 §6.2).
|
|
81
|
+
|
|
82
|
+
`event_id` is assigned by the StateStore on append, not by the caller.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
type: EventType
|
|
86
|
+
action_id: str
|
|
87
|
+
ts: datetime
|
|
88
|
+
data: Mapping[str, Any] = field(default_factory=dict)
|
|
89
|
+
effect_key: str | None = None
|
|
90
|
+
approval_id: str | None = None
|
|
91
|
+
event_id: int | None = None
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"event_id": self.event_id,
|
|
96
|
+
"ts": iso_timestamp(self.ts),
|
|
97
|
+
"type": str(self.type),
|
|
98
|
+
"action_id": self.action_id,
|
|
99
|
+
"effect_key": self.effect_key,
|
|
100
|
+
"approval_id": self.approval_id,
|
|
101
|
+
"data": dict(self.data),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def to_json(self) -> str:
|
|
105
|
+
"""One JSONL line. Enums render by value, for readers that never imported CTRLRun."""
|
|
106
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Receipt:
|
|
111
|
+
"""Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1)."""
|
|
112
|
+
|
|
113
|
+
receipt_id: str
|
|
114
|
+
action_id: str
|
|
115
|
+
action: str
|
|
116
|
+
action_hash: str
|
|
117
|
+
principal: Principal
|
|
118
|
+
resource: str | None
|
|
119
|
+
arguments: Mapping[str, Any]
|
|
120
|
+
environment: str
|
|
121
|
+
decision: Decision
|
|
122
|
+
decision_reason: str
|
|
123
|
+
result: ReceiptResult
|
|
124
|
+
started_at: datetime
|
|
125
|
+
finished_at: datetime
|
|
126
|
+
approval_id: str | None = None
|
|
127
|
+
approver: str | None = None
|
|
128
|
+
effect_key: str | None = None
|
|
129
|
+
attempt: int = 1
|
|
130
|
+
error: str | None = None
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
"""The receipt as plain JSON-serializable data, in the field order of SPEC §6.1."""
|
|
134
|
+
return {
|
|
135
|
+
"schema": RECEIPT_SCHEMA,
|
|
136
|
+
"receipt_id": self.receipt_id,
|
|
137
|
+
"action_id": self.action_id,
|
|
138
|
+
"action": self.action,
|
|
139
|
+
"action_hash": self.action_hash,
|
|
140
|
+
"principal": {"agent": self.principal.agent, "user": self.principal.user},
|
|
141
|
+
"resource": self.resource,
|
|
142
|
+
"arguments": dict(self.arguments),
|
|
143
|
+
"environment": self.environment,
|
|
144
|
+
"decision": str(self.decision),
|
|
145
|
+
"decision_reason": self.decision_reason,
|
|
146
|
+
"approval_id": self.approval_id,
|
|
147
|
+
"approver": self.approver,
|
|
148
|
+
"effect_key": self.effect_key,
|
|
149
|
+
"attempt": self.attempt,
|
|
150
|
+
"result": str(self.result),
|
|
151
|
+
"error": self.error,
|
|
152
|
+
"started_at": iso_timestamp(self.started_at),
|
|
153
|
+
"finished_at": iso_timestamp(self.finished_at),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def to_json(self) -> str:
|
|
157
|
+
"""One JSONL line. Enums render by value (SPEC-v0.1 §6.1)."""
|
|
158
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_dict(cls, document: Mapping[str, Any]) -> Receipt:
|
|
162
|
+
"""The inverse of `to_dict`: a receipt read back out of a store or a JSONL file."""
|
|
163
|
+
principal = document["principal"]
|
|
164
|
+
return cls(
|
|
165
|
+
receipt_id=document["receipt_id"],
|
|
166
|
+
action_id=document["action_id"],
|
|
167
|
+
action=document["action"],
|
|
168
|
+
action_hash=document["action_hash"],
|
|
169
|
+
principal=Principal(agent=principal["agent"], user=principal["user"]),
|
|
170
|
+
resource=document["resource"],
|
|
171
|
+
arguments=document["arguments"],
|
|
172
|
+
environment=document["environment"],
|
|
173
|
+
decision=Decision(document["decision"]),
|
|
174
|
+
decision_reason=document["decision_reason"],
|
|
175
|
+
approval_id=document["approval_id"],
|
|
176
|
+
approver=document["approver"],
|
|
177
|
+
effect_key=document["effect_key"],
|
|
178
|
+
attempt=document["attempt"],
|
|
179
|
+
result=ReceiptResult(document["result"]),
|
|
180
|
+
error=document["error"],
|
|
181
|
+
started_at=datetime.fromisoformat(document["started_at"]),
|
|
182
|
+
finished_at=datetime.fromisoformat(document["finished_at"]),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_json(cls, line: str) -> Receipt:
|
|
187
|
+
"""Parse one JSONL line written by `to_json`."""
|
|
188
|
+
document: dict[str, Any] = json.loads(line)
|
|
189
|
+
return cls.from_dict(document)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class EventLog:
|
|
193
|
+
"""The JSONL half of the evidence: two append-only files in one directory (SPEC §6).
|
|
194
|
+
|
|
195
|
+
`receipts.jsonl` and `events.jsonl` beside the state database, so `.ctrlrun/` holds the
|
|
196
|
+
whole record of what an agent did. The store is authoritative — these files are the
|
|
197
|
+
portable copy, written after the store accepted the same record.
|
|
198
|
+
|
|
199
|
+
Each write opens, appends one line and closes, so several processes sharing a store
|
|
200
|
+
(SPEC-v0.1 §5.3 E1) interleave whole lines rather than fragments of them.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, directory: str | os.PathLike[str]) -> None:
|
|
204
|
+
self._directory = Path(directory)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def directory(self) -> Path:
|
|
208
|
+
return self._directory
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def receipts_path(self) -> Path:
|
|
212
|
+
return self._directory / RECEIPTS_FILENAME
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def events_path(self) -> Path:
|
|
216
|
+
return self._directory / EVENTS_FILENAME
|
|
217
|
+
|
|
218
|
+
def put_receipt(self, receipt: Receipt) -> None:
|
|
219
|
+
"""Append one receipt as a JSON line."""
|
|
220
|
+
self._append(self.receipts_path, receipt.to_json())
|
|
221
|
+
|
|
222
|
+
def append_event(self, event: Event) -> None:
|
|
223
|
+
"""Append one event as a JSON line, in the order the store assigned it."""
|
|
224
|
+
self._append(self.events_path, event.to_json())
|
|
225
|
+
|
|
226
|
+
def _append(self, path: Path, line: str) -> None:
|
|
227
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
229
|
+
handle.write(f"{line}\n")
|