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.
@@ -0,0 +1,7 @@
1
+ """Intellagent Runtime v0.1.
2
+
3
+ A governed epistemic state transition engine. See INTELLAGENT-RUNTIME.md for
4
+ the spec; this package implements that spec exactly.
5
+ """
6
+
7
+ __version__ = "0.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
+ )