wiseorder 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.
- intellagent_runtime/__init__.py +7 -0
- intellagent_runtime/audit_memory.py +382 -0
- intellagent_runtime/authorization.py +213 -0
- intellagent_runtime/canonical.py +141 -0
- intellagent_runtime/chain.py +376 -0
- intellagent_runtime/cli.py +880 -0
- intellagent_runtime/execution_plan.py +417 -0
- intellagent_runtime/iii.py +240 -0
- intellagent_runtime/kernel.py +321 -0
- intellagent_runtime/memory.py +314 -0
- intellagent_runtime/policies/README.md +14 -0
- intellagent_runtime/policies/always_deny.json +5 -0
- intellagent_runtime/policies/test_allowlist.json +9 -0
- intellagent_runtime/prompts/class_a_fragment.md +24 -0
- intellagent_runtime/prompts/class_b_fragment.md +22 -0
- intellagent_runtime/prompts/class_c_fragment.md +32 -0
- intellagent_runtime/prompts/class_d_fragment.md +36 -0
- intellagent_runtime/prompts/system_preamble.md +10 -0
- intellagent_runtime/proposer.py +111 -0
- intellagent_runtime/proposer_transformer.py +785 -0
- intellagent_runtime/refusal.py +98 -0
- intellagent_runtime/runtime.py +222 -0
- intellagent_runtime/state.py +165 -0
- intellagent_runtime/transitions.py +165 -0
- intellagent_runtime/work_order_parser.py +525 -0
- intellagent_runtime/workflow_grammar.py +343 -0
- wiseorder-0.1.0.dist-info/METADATA +292 -0
- wiseorder-0.1.0.dist-info/RECORD +32 -0
- wiseorder-0.1.0.dist-info/WHEEL +5 -0
- wiseorder-0.1.0.dist-info/entry_points.txt +2 -0
- wiseorder-0.1.0.dist-info/licenses/LICENSE +202 -0
- wiseorder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Append-only audit memory for the WiseOrder runtime core.
|
|
2
|
+
|
|
3
|
+
Audit events are written to a JSONL file. Each event is a JSON object
|
|
4
|
+
containing at minimum::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"seq": <int starting at 1>,
|
|
8
|
+
"ts": "<ISO-8601 UTC>",
|
|
9
|
+
"event": "<event type string>",
|
|
10
|
+
"payload": { ... },
|
|
11
|
+
"prev_hash": "sha256:<hex>" | null,
|
|
12
|
+
"hash": "sha256:<hex>"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
The ``hash`` field is the SHA-256 of the canonical bytes of the event
|
|
16
|
+
object *with the ``hash`` field removed*. ``prev_hash`` chains each event
|
|
17
|
+
to the prior event's ``hash``; the first event has ``prev_hash: null``.
|
|
18
|
+
|
|
19
|
+
Statuses returned by :func:`verify_chain`:
|
|
20
|
+
|
|
21
|
+
- ``AUDIT_CHAIN_VALID`` — every event verifies and chain is intact.
|
|
22
|
+
- ``AUDIT_CHAIN_TAMPERED`` — at least one event's hash or prev_hash
|
|
23
|
+
does not match the recomputed value.
|
|
24
|
+
- ``AUDIT_CHAIN_EMPTY`` — file does not exist or contains no events.
|
|
25
|
+
- ``AUDIT_CHAIN_INVALID`` — file exists but at least one event is
|
|
26
|
+
structurally malformed (bad JSON or
|
|
27
|
+
missing required field).
|
|
28
|
+
|
|
29
|
+
The module relies on :mod:`intellagent_runtime.canonical` for byte-
|
|
30
|
+
stable JSON and SHA-256 helpers. It does not import the kernel, state,
|
|
31
|
+
or runtime modules — there is no cyclic dependency.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import datetime
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from intellagent_runtime import canonical
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
AUDIT_CHAIN_VALID = "AUDIT_CHAIN_VALID"
|
|
47
|
+
AUDIT_CHAIN_TAMPERED = "AUDIT_CHAIN_TAMPERED"
|
|
48
|
+
AUDIT_CHAIN_EMPTY = "AUDIT_CHAIN_EMPTY"
|
|
49
|
+
AUDIT_CHAIN_INVALID = "AUDIT_CHAIN_INVALID"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class AuditEvent:
|
|
54
|
+
"""One row of an audit log file."""
|
|
55
|
+
|
|
56
|
+
seq: int
|
|
57
|
+
ts: str
|
|
58
|
+
event: str
|
|
59
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
prev_hash: str | None = None
|
|
61
|
+
hash: str = ""
|
|
62
|
+
|
|
63
|
+
def core_dict(self) -> dict:
|
|
64
|
+
"""Return the event dict *without* the hash field — this is the
|
|
65
|
+
input that ``hash`` commits to."""
|
|
66
|
+
return {
|
|
67
|
+
"seq": self.seq,
|
|
68
|
+
"ts": self.ts,
|
|
69
|
+
"event": self.event,
|
|
70
|
+
"payload": self.payload,
|
|
71
|
+
"prev_hash": self.prev_hash,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
d = self.core_dict()
|
|
76
|
+
d["hash"] = self.hash
|
|
77
|
+
return d
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ChainStatus:
|
|
82
|
+
"""Result of verifying an audit chain."""
|
|
83
|
+
|
|
84
|
+
status: str
|
|
85
|
+
count: int
|
|
86
|
+
last_hash: str | None
|
|
87
|
+
first_failure_seq: int | None = None
|
|
88
|
+
reason: str = ""
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"status": self.status,
|
|
93
|
+
"count": self.count,
|
|
94
|
+
"last_hash": self.last_hash,
|
|
95
|
+
"first_failure_seq": self.first_failure_seq,
|
|
96
|
+
"reason": self.reason,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# I/O primitives
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _utcnow_iso() -> str:
|
|
106
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _compute_event_hash(event_core: dict) -> str:
|
|
110
|
+
bytes_for_hash = canonical.canonical_json_bytes(event_core)
|
|
111
|
+
return canonical.sha256_hex(bytes_for_hash)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ensure_parent(path: Path) -> None:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_last_hash(path: Path) -> tuple[int, str | None]:
|
|
119
|
+
"""Return (last_seq, last_hash). If the file is missing or empty,
|
|
120
|
+
returns (0, None). Raises :class:`AuditMemoryError` on malformed
|
|
121
|
+
trailing data."""
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return 0, None
|
|
124
|
+
last_seq = 0
|
|
125
|
+
last_hash: str | None = None
|
|
126
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
127
|
+
for line_no, raw in enumerate(fh, start=1):
|
|
128
|
+
line = raw.strip()
|
|
129
|
+
if not line:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
obj = json.loads(line)
|
|
133
|
+
except json.JSONDecodeError as exc:
|
|
134
|
+
raise AuditMemoryError(
|
|
135
|
+
f"malformed JSONL at {path}:{line_no}: {exc}"
|
|
136
|
+
) from exc
|
|
137
|
+
if not isinstance(obj, dict):
|
|
138
|
+
raise AuditMemoryError(f"non-object event at {path}:{line_no}")
|
|
139
|
+
for key in ("seq", "ts", "event", "payload", "prev_hash", "hash"):
|
|
140
|
+
if key not in obj:
|
|
141
|
+
raise AuditMemoryError(
|
|
142
|
+
f"missing field {key!r} at {path}:{line_no}"
|
|
143
|
+
)
|
|
144
|
+
if not isinstance(obj["seq"], int) or obj["seq"] <= 0:
|
|
145
|
+
raise AuditMemoryError(f"non-positive seq at {path}:{line_no}")
|
|
146
|
+
last_seq = obj["seq"]
|
|
147
|
+
last_hash = obj["hash"]
|
|
148
|
+
return last_seq, last_hash
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AuditMemoryError(RuntimeError):
|
|
152
|
+
"""Raised when the audit log is structurally unusable."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Public API
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def append_event(
|
|
161
|
+
path: str | Path,
|
|
162
|
+
event: str,
|
|
163
|
+
payload: dict[str, Any] | None = None,
|
|
164
|
+
*,
|
|
165
|
+
ts: str | None = None,
|
|
166
|
+
) -> AuditEvent:
|
|
167
|
+
"""Append a new audit event to ``path`` (JSONL).
|
|
168
|
+
|
|
169
|
+
Returns the materialized :class:`AuditEvent` (including its computed
|
|
170
|
+
``hash`` and the captured ``prev_hash``).
|
|
171
|
+
|
|
172
|
+
The append is atomic at the line level: events are written via an
|
|
173
|
+
``a`` open-append-mode handle, fsync'd, then closed. Truncation
|
|
174
|
+
between read-of-last-hash and write-of-this-line is the only
|
|
175
|
+
interleaving risk, and would be caught by :func:`verify_chain` on
|
|
176
|
+
the next pass.
|
|
177
|
+
"""
|
|
178
|
+
p = Path(path)
|
|
179
|
+
_ensure_parent(p)
|
|
180
|
+
last_seq, last_hash = _read_last_hash(p)
|
|
181
|
+
|
|
182
|
+
new_event = AuditEvent(
|
|
183
|
+
seq=last_seq + 1,
|
|
184
|
+
ts=ts or _utcnow_iso(),
|
|
185
|
+
event=str(event),
|
|
186
|
+
payload=dict(payload or {}),
|
|
187
|
+
prev_hash=last_hash,
|
|
188
|
+
)
|
|
189
|
+
new_event.hash = _compute_event_hash(new_event.core_dict())
|
|
190
|
+
|
|
191
|
+
line = json.dumps(
|
|
192
|
+
new_event.to_dict(), sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
|
193
|
+
)
|
|
194
|
+
with p.open("a", encoding="utf-8") as fh:
|
|
195
|
+
fh.write(line + "\n")
|
|
196
|
+
fh.flush()
|
|
197
|
+
os.fsync(fh.fileno())
|
|
198
|
+
return new_event
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def read_events(path: str | Path) -> list[AuditEvent]:
|
|
202
|
+
"""Read every event from ``path`` and return them as
|
|
203
|
+
:class:`AuditEvent` objects. Empty / missing file returns ``[]``."""
|
|
204
|
+
p = Path(path)
|
|
205
|
+
if not p.is_file():
|
|
206
|
+
return []
|
|
207
|
+
out: list[AuditEvent] = []
|
|
208
|
+
with p.open("r", encoding="utf-8") as fh:
|
|
209
|
+
for line_no, raw in enumerate(fh, start=1):
|
|
210
|
+
line = raw.strip()
|
|
211
|
+
if not line:
|
|
212
|
+
continue
|
|
213
|
+
obj = json.loads(line)
|
|
214
|
+
out.append(
|
|
215
|
+
AuditEvent(
|
|
216
|
+
seq=obj["seq"],
|
|
217
|
+
ts=obj["ts"],
|
|
218
|
+
event=obj["event"],
|
|
219
|
+
payload=dict(obj.get("payload") or {}),
|
|
220
|
+
prev_hash=obj.get("prev_hash"),
|
|
221
|
+
hash=obj["hash"],
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def verify_chain(path: str | Path) -> ChainStatus:
|
|
228
|
+
"""Verify the audit chain at ``path`` and return a :class:`ChainStatus`."""
|
|
229
|
+
p = Path(path)
|
|
230
|
+
if not p.is_file():
|
|
231
|
+
return ChainStatus(status=AUDIT_CHAIN_EMPTY, count=0, last_hash=None,
|
|
232
|
+
reason="file does not exist")
|
|
233
|
+
|
|
234
|
+
count = 0
|
|
235
|
+
last_hash: str | None = None
|
|
236
|
+
expected_seq = 1
|
|
237
|
+
with p.open("r", encoding="utf-8") as fh:
|
|
238
|
+
for line_no, raw in enumerate(fh, start=1):
|
|
239
|
+
line = raw.strip()
|
|
240
|
+
if not line:
|
|
241
|
+
continue
|
|
242
|
+
count += 1
|
|
243
|
+
try:
|
|
244
|
+
obj = json.loads(line)
|
|
245
|
+
except json.JSONDecodeError as exc:
|
|
246
|
+
return ChainStatus(
|
|
247
|
+
status=AUDIT_CHAIN_INVALID, count=count, last_hash=last_hash,
|
|
248
|
+
first_failure_seq=count,
|
|
249
|
+
reason=f"malformed JSONL at line {line_no}: {exc}",
|
|
250
|
+
)
|
|
251
|
+
if not isinstance(obj, dict):
|
|
252
|
+
return ChainStatus(
|
|
253
|
+
status=AUDIT_CHAIN_INVALID, count=count, last_hash=last_hash,
|
|
254
|
+
first_failure_seq=count, reason=f"non-object event at line {line_no}",
|
|
255
|
+
)
|
|
256
|
+
for key in ("seq", "ts", "event", "payload", "prev_hash", "hash"):
|
|
257
|
+
if key not in obj:
|
|
258
|
+
return ChainStatus(
|
|
259
|
+
status=AUDIT_CHAIN_INVALID, count=count, last_hash=last_hash,
|
|
260
|
+
first_failure_seq=count, reason=f"missing field {key!r} at line {line_no}",
|
|
261
|
+
)
|
|
262
|
+
if obj["seq"] != expected_seq:
|
|
263
|
+
return ChainStatus(
|
|
264
|
+
status=AUDIT_CHAIN_INVALID, count=count, last_hash=last_hash,
|
|
265
|
+
first_failure_seq=expected_seq,
|
|
266
|
+
reason=f"non-monotonic seq at line {line_no}: expected {expected_seq}, got {obj['seq']}",
|
|
267
|
+
)
|
|
268
|
+
if obj["prev_hash"] != last_hash:
|
|
269
|
+
return ChainStatus(
|
|
270
|
+
status=AUDIT_CHAIN_TAMPERED, count=count, last_hash=last_hash,
|
|
271
|
+
first_failure_seq=obj["seq"],
|
|
272
|
+
reason=(
|
|
273
|
+
f"prev_hash mismatch at seq {obj['seq']}: "
|
|
274
|
+
f"expected {last_hash!r}, got {obj['prev_hash']!r}"
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
recomputed = _compute_event_hash({
|
|
278
|
+
"seq": obj["seq"],
|
|
279
|
+
"ts": obj["ts"],
|
|
280
|
+
"event": obj["event"],
|
|
281
|
+
"payload": obj["payload"],
|
|
282
|
+
"prev_hash": obj["prev_hash"],
|
|
283
|
+
})
|
|
284
|
+
if recomputed != obj["hash"]:
|
|
285
|
+
return ChainStatus(
|
|
286
|
+
status=AUDIT_CHAIN_TAMPERED, count=count, last_hash=last_hash,
|
|
287
|
+
first_failure_seq=obj["seq"],
|
|
288
|
+
reason=f"hash mismatch at seq {obj['seq']}: recomputed {recomputed!r}, stored {obj['hash']!r}",
|
|
289
|
+
)
|
|
290
|
+
last_hash = obj["hash"]
|
|
291
|
+
expected_seq += 1
|
|
292
|
+
|
|
293
|
+
if count == 0:
|
|
294
|
+
return ChainStatus(status=AUDIT_CHAIN_EMPTY, count=0, last_hash=None,
|
|
295
|
+
reason="file present but empty")
|
|
296
|
+
return ChainStatus(status=AUDIT_CHAIN_VALID, count=count, last_hash=last_hash, reason="")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def export_summary(path: str | Path) -> dict:
|
|
300
|
+
"""Return a small JSON-friendly summary of the audit log."""
|
|
301
|
+
p = Path(path)
|
|
302
|
+
status = verify_chain(p)
|
|
303
|
+
events = read_events(p) if p.is_file() else []
|
|
304
|
+
event_types: dict[str, int] = {}
|
|
305
|
+
for e in events:
|
|
306
|
+
event_types[e.event] = event_types.get(e.event, 0) + 1
|
|
307
|
+
return {
|
|
308
|
+
"path": str(p),
|
|
309
|
+
"chain_status": status.to_dict(),
|
|
310
|
+
"event_count": len(events),
|
|
311
|
+
"first_seq": events[0].seq if events else None,
|
|
312
|
+
"last_seq": events[-1].seq if events else None,
|
|
313
|
+
"event_types": dict(sorted(event_types.items())),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# Self-check
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def self_check() -> int:
|
|
323
|
+
import shutil
|
|
324
|
+
import tempfile
|
|
325
|
+
|
|
326
|
+
failures: list[str] = []
|
|
327
|
+
|
|
328
|
+
def expect(name: str, condition: bool, detail: str = "") -> None:
|
|
329
|
+
print(f" [{'PASS' if condition else 'FAIL'}] {name}")
|
|
330
|
+
if not condition:
|
|
331
|
+
failures.append(f"{name}: {detail}")
|
|
332
|
+
|
|
333
|
+
workdir = Path(tempfile.mkdtemp(prefix="wo-audit-selfcheck-"))
|
|
334
|
+
try:
|
|
335
|
+
path = workdir / "run.jsonl"
|
|
336
|
+
|
|
337
|
+
# Empty.
|
|
338
|
+
status = verify_chain(path)
|
|
339
|
+
expect("empty_status", status.status == AUDIT_CHAIN_EMPTY, status.reason)
|
|
340
|
+
|
|
341
|
+
# Append two events.
|
|
342
|
+
e1 = append_event(path, "run.started", {"work_order": "WO-001"})
|
|
343
|
+
e2 = append_event(path, "plan.built", {"command_count": 3})
|
|
344
|
+
expect("seq_monotonic", e1.seq == 1 and e2.seq == 2)
|
|
345
|
+
expect("prev_hash_chained", e2.prev_hash == e1.hash)
|
|
346
|
+
|
|
347
|
+
# Verify.
|
|
348
|
+
status = verify_chain(path)
|
|
349
|
+
expect("chain_valid_after_append",
|
|
350
|
+
status.status == AUDIT_CHAIN_VALID and status.count == 2, status.reason)
|
|
351
|
+
|
|
352
|
+
# Tamper by editing the second event in place.
|
|
353
|
+
text = path.read_text(encoding="utf-8").splitlines()
|
|
354
|
+
edited = text[1].replace('"command_count":3', '"command_count":999')
|
|
355
|
+
path.write_text(text[0] + "\n" + edited + "\n", encoding="utf-8")
|
|
356
|
+
status = verify_chain(path)
|
|
357
|
+
expect("tamper_detected", status.status == AUDIT_CHAIN_TAMPERED, status.reason)
|
|
358
|
+
|
|
359
|
+
# Invalid: malformed JSON line.
|
|
360
|
+
path.write_text(text[0] + "\n" + "{garbage}\n", encoding="utf-8")
|
|
361
|
+
status = verify_chain(path)
|
|
362
|
+
expect("invalid_detected", status.status == AUDIT_CHAIN_INVALID, status.reason)
|
|
363
|
+
|
|
364
|
+
# Summary.
|
|
365
|
+
path.write_text(text[0] + "\n" + text[1] + "\n", encoding="utf-8")
|
|
366
|
+
summary = export_summary(path)
|
|
367
|
+
expect("summary_has_status", "chain_status" in summary)
|
|
368
|
+
expect("summary_event_types", summary["event_types"].get("run.started", 0) == 1)
|
|
369
|
+
finally:
|
|
370
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
371
|
+
|
|
372
|
+
if failures:
|
|
373
|
+
print(f"\nFAIL: {len(failures)} self-check failures")
|
|
374
|
+
for f in failures:
|
|
375
|
+
print(f" ↳ {f}")
|
|
376
|
+
return 1
|
|
377
|
+
print("\nPASS: audit_memory self-check")
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(self_check())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Authorization gate.
|
|
2
|
+
|
|
3
|
+
The gate enforces AG1–AG3 *separately* from the kernel's verification verdicts.
|
|
4
|
+
A transition that the kernel accepts can still be refused at this gate; a
|
|
5
|
+
transition that is not action-bearing bypasses the gate entirely (the gate
|
|
6
|
+
returns ``authorized=True`` with rationale ``not-action-bearing``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from intellagent_runtime.canonical import short_id, utcnow_iso8601
|
|
18
|
+
from intellagent_runtime.transitions import EpistemicTransition
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AuthorizationDecision:
|
|
23
|
+
decision_id: str
|
|
24
|
+
transition_id: str
|
|
25
|
+
authorized: bool
|
|
26
|
+
authorization_source: str | None
|
|
27
|
+
rationale: str
|
|
28
|
+
decided_at: str
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"decision_id": self.decision_id,
|
|
33
|
+
"transition_id": self.transition_id,
|
|
34
|
+
"authorized": self.authorized,
|
|
35
|
+
"authorization_source": self.authorization_source,
|
|
36
|
+
"rationale": self.rationale,
|
|
37
|
+
"decided_at": self.decided_at,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Policies
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Policy(ABC):
|
|
47
|
+
"""Abstract base class for an authorization-source policy.
|
|
48
|
+
|
|
49
|
+
Subclasses MUST implement ``evaluate``. The ABC machinery prevents
|
|
50
|
+
instantiation of the base class itself.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
rationale: str = ""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def evaluate(self, transition: EpistemicTransition) -> tuple[bool, str]:
|
|
57
|
+
"""Return (authorized, rationale) for an action-bearing transition."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AlwaysDenyPolicy(Policy):
|
|
61
|
+
def __init__(self, rationale: str = "always_deny"):
|
|
62
|
+
self.rationale = rationale
|
|
63
|
+
|
|
64
|
+
def evaluate(self, transition: EpistemicTransition) -> tuple[bool, str]:
|
|
65
|
+
return False, f"always_deny: {self.rationale}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AllowlistPolicy(Policy):
|
|
69
|
+
def __init__(self, allowed: list[dict[str, str]], rationale: str = ""):
|
|
70
|
+
self.allowed = allowed
|
|
71
|
+
self.rationale = rationale
|
|
72
|
+
|
|
73
|
+
def evaluate(self, transition: EpistemicTransition) -> tuple[bool, str]:
|
|
74
|
+
action = transition.action
|
|
75
|
+
if action is None: # defensive; gate should not call us in that case
|
|
76
|
+
return False, "policy invoked on non-action-bearing transition"
|
|
77
|
+
for entry in self.allowed:
|
|
78
|
+
if entry.get("kind") != action.kind:
|
|
79
|
+
continue
|
|
80
|
+
target = entry.get("target")
|
|
81
|
+
if target == "any" or target == action.target:
|
|
82
|
+
return True, (
|
|
83
|
+
f"allowlist: ({entry['kind']}, {target}) permits "
|
|
84
|
+
f"({action.kind}, {action.target})"
|
|
85
|
+
)
|
|
86
|
+
return False, (
|
|
87
|
+
f"allowlist: no entry permits ({action.kind}, {action.target})"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PolicySchemaError(ValueError):
|
|
92
|
+
"""Raised when a policy JSON body fails surface-syntax validation."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_policy(body: dict[str, Any]) -> Policy | None:
|
|
96
|
+
"""Validate and construct a Policy. Returns None for unknown ``kind``;
|
|
97
|
+
raises PolicySchemaError for known kinds with malformed bodies."""
|
|
98
|
+
if not isinstance(body, dict):
|
|
99
|
+
raise PolicySchemaError(f"policy body must be a dict, got {type(body).__name__}")
|
|
100
|
+
|
|
101
|
+
kind = body.get("kind")
|
|
102
|
+
rationale = body.get("rationale", "")
|
|
103
|
+
if not isinstance(rationale, str):
|
|
104
|
+
raise PolicySchemaError("policy.rationale must be a string")
|
|
105
|
+
|
|
106
|
+
if kind == "always_deny":
|
|
107
|
+
return AlwaysDenyPolicy(rationale=rationale)
|
|
108
|
+
|
|
109
|
+
if kind == "allowlist":
|
|
110
|
+
allowed = body.get("allowed") or []
|
|
111
|
+
if not isinstance(allowed, list):
|
|
112
|
+
raise PolicySchemaError("policy.allowed must be a list")
|
|
113
|
+
for i, entry in enumerate(allowed):
|
|
114
|
+
if not isinstance(entry, dict):
|
|
115
|
+
raise PolicySchemaError(f"policy.allowed[{i}] must be a dict, got {type(entry).__name__}")
|
|
116
|
+
if not isinstance(entry.get("kind"), str) or not entry["kind"]:
|
|
117
|
+
raise PolicySchemaError(f"policy.allowed[{i}].kind must be a non-empty string")
|
|
118
|
+
if not isinstance(entry.get("target"), str) or not entry["target"]:
|
|
119
|
+
raise PolicySchemaError(f"policy.allowed[{i}].target must be a non-empty string")
|
|
120
|
+
return AllowlistPolicy(allowed=list(allowed), rationale=rationale)
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Gate
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AuthorizationGate:
|
|
131
|
+
"""Resolves a transition's declared ``authorization.source_id`` against a
|
|
132
|
+
local policy directory and returns an :class:`AuthorizationDecision`."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, policies_dir: Path):
|
|
135
|
+
self.policies_dir = Path(policies_dir)
|
|
136
|
+
|
|
137
|
+
def resolve_policy(self, source_id: str) -> Policy | None:
|
|
138
|
+
"""Load and validate the policy file for the given source_id.
|
|
139
|
+
|
|
140
|
+
Returns None for "policy file not present" or "unknown policy kind"
|
|
141
|
+
(both legitimate — caller treats absence as AG2 refusal).
|
|
142
|
+
Raises :class:`PolicySchemaError` for malformed-but-known policies
|
|
143
|
+
so a typo in a known policy fails LOUDLY rather than silently
|
|
144
|
+
denying everything.
|
|
145
|
+
"""
|
|
146
|
+
path = self.policies_dir / f"{source_id}.json"
|
|
147
|
+
if not path.is_file():
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
body = json.loads(path.read_text(encoding="utf-8"))
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise PolicySchemaError(f"policy {source_id!r} at {path}: invalid JSON: {exc}") from exc
|
|
153
|
+
return _load_policy(body)
|
|
154
|
+
|
|
155
|
+
def evaluate(
|
|
156
|
+
self,
|
|
157
|
+
transition: EpistemicTransition,
|
|
158
|
+
prior_state, # EpistemicState (avoid circular import)
|
|
159
|
+
) -> AuthorizationDecision:
|
|
160
|
+
if not transition.is_action_bearing:
|
|
161
|
+
return AuthorizationDecision(
|
|
162
|
+
decision_id=short_id(),
|
|
163
|
+
transition_id=transition.transition_id,
|
|
164
|
+
authorized=True,
|
|
165
|
+
authorization_source="not-action-bearing",
|
|
166
|
+
rationale=("Pure state transition; no external action; "
|
|
167
|
+
"AG1 not engaged."),
|
|
168
|
+
decided_at=utcnow_iso8601(),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if transition.authorization is None:
|
|
172
|
+
return AuthorizationDecision(
|
|
173
|
+
decision_id=short_id(),
|
|
174
|
+
transition_id=transition.transition_id,
|
|
175
|
+
authorized=False,
|
|
176
|
+
authorization_source=None,
|
|
177
|
+
rationale=("AG1: action-bearing transition without declared "
|
|
178
|
+
"authorization. Refused before policy lookup."),
|
|
179
|
+
decided_at=utcnow_iso8601(),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
source_id = transition.authorization.source_id
|
|
183
|
+
if not source_id:
|
|
184
|
+
return AuthorizationDecision(
|
|
185
|
+
decision_id=short_id(),
|
|
186
|
+
transition_id=transition.transition_id,
|
|
187
|
+
authorized=False,
|
|
188
|
+
authorization_source=None,
|
|
189
|
+
rationale="AG3: authorization object missing source_id.",
|
|
190
|
+
decided_at=utcnow_iso8601(),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
policy = self.resolve_policy(source_id)
|
|
194
|
+
if policy is None:
|
|
195
|
+
return AuthorizationDecision(
|
|
196
|
+
decision_id=short_id(),
|
|
197
|
+
transition_id=transition.transition_id,
|
|
198
|
+
authorized=False,
|
|
199
|
+
authorization_source=source_id,
|
|
200
|
+
rationale=(f"AG2: authorization_source {source_id!r} has no "
|
|
201
|
+
"resolvable policy."),
|
|
202
|
+
decided_at=utcnow_iso8601(),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
allow, rationale = policy.evaluate(transition)
|
|
206
|
+
return AuthorizationDecision(
|
|
207
|
+
decision_id=short_id(),
|
|
208
|
+
transition_id=transition.transition_id,
|
|
209
|
+
authorized=allow,
|
|
210
|
+
authorization_source=source_id,
|
|
211
|
+
rationale=rationale,
|
|
212
|
+
decided_at=utcnow_iso8601(),
|
|
213
|
+
)
|