agentnorm 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.
- agentnorm/__init__.py +40 -0
- agentnorm/adapters/__init__.py +1 -0
- agentnorm/adapters/langchain.py +140 -0
- agentnorm/audit.py +332 -0
- agentnorm/detectors.py +289 -0
- agentnorm/evaluation.py +209 -0
- agentnorm/integrations.py +107 -0
- agentnorm/monitor.py +142 -0
- agentnorm/store.py +102 -0
- agentnorm/trace.py +183 -0
- agentnorm-0.1.0.dist-info/METADATA +321 -0
- agentnorm-0.1.0.dist-info/RECORD +15 -0
- agentnorm-0.1.0.dist-info/WHEEL +5 -0
- agentnorm-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentnorm-0.1.0.dist-info/top_level.txt +1 -0
agentnorm/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""agentnorm - behavioural monitoring for AI agents.
|
|
2
|
+
|
|
3
|
+
Agents are unvalidated models running in production. Evaluation grades their outputs
|
|
4
|
+
offline; this watches how they *behave* at runtime and says when a run does not look like
|
|
5
|
+
the ones before it.
|
|
6
|
+
|
|
7
|
+
from agentnorm import RunRecorder, Monitor
|
|
8
|
+
|
|
9
|
+
rec = RunRecorder(agent="triage", version="v3", principal="acme")
|
|
10
|
+
with rec.tool_call("search", {"q": q}, scope="acme") as call:
|
|
11
|
+
rows = search(q)
|
|
12
|
+
call.result_size = len(rows)
|
|
13
|
+
|
|
14
|
+
monitor = Monitor.fit(history) # benign runs
|
|
15
|
+
verdict = monitor.score(rec.finish())
|
|
16
|
+
if verdict.flagged:
|
|
17
|
+
print(verdict.explain())
|
|
18
|
+
|
|
19
|
+
Or instrument tools you already have, without restructuring the agent:
|
|
20
|
+
|
|
21
|
+
session = Session(agent="triage", version="v3", principal="acme")
|
|
22
|
+
tools = session.wrap({"search": search, "fetch": fetch})
|
|
23
|
+
...
|
|
24
|
+
verdict = monitor.score(session.finish())
|
|
25
|
+
|
|
26
|
+
No database, no framework, no context propagation required.
|
|
27
|
+
"""
|
|
28
|
+
from agentnorm.audit import AuditChain, AuditLog, verify_chain
|
|
29
|
+
from agentnorm.detectors import DetectorSuite
|
|
30
|
+
from agentnorm.integrations import Session, default_size_of
|
|
31
|
+
from agentnorm.monitor import Alert, Monitor, Verdict
|
|
32
|
+
from agentnorm.store import JsonlStore, Store
|
|
33
|
+
from agentnorm.trace import Run, RunRecorder, ToolCall
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Alert", "AuditChain", "AuditLog", "DetectorSuite", "JsonlStore", "Monitor", "Run",
|
|
38
|
+
"RunRecorder", "Session", "Store", "ToolCall", "Verdict", "default_size_of",
|
|
39
|
+
"verify_chain",
|
|
40
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework adapters. Each has optional dependencies, imported lazily."""
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""LangChain / LangGraph adapter.
|
|
2
|
+
|
|
3
|
+
LangChain reports tool starts and ends as separate callback events, often interleaved
|
|
4
|
+
across concurrent tools, so the `with` block used elsewhere cannot express them. This
|
|
5
|
+
handler tracks open calls by the framework's `run_id` and closes each one when its
|
|
6
|
+
matching end or error arrives.
|
|
7
|
+
|
|
8
|
+
**agentnorm does not depend on LangChain.** The base class is imported lazily and only if it
|
|
9
|
+
is installed; without it the handler still works as a plain object, because LangChain
|
|
10
|
+
duck-types handlers in the paths that matter. Keeping the dependency optional is
|
|
11
|
+
deliberate: a monitoring library that drags in an agent framework is unusable by anyone
|
|
12
|
+
running a different one, and half the point of agentnorm is comparing agents across
|
|
13
|
+
frameworks on equal footing.
|
|
14
|
+
|
|
15
|
+
from agentnorm import Session
|
|
16
|
+
from agentnorm.adapters.langchain import agentnorm_callback
|
|
17
|
+
|
|
18
|
+
session = Session(agent="researcher", version="v2", principal="acme")
|
|
19
|
+
graph.invoke(state, config={"callbacks": [agentnorm_callback(session)]})
|
|
20
|
+
|
|
21
|
+
verdict = monitor.score(session.finish())
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
from uuid import UUID
|
|
28
|
+
|
|
29
|
+
from agentnorm.integrations import Session, default_size_of
|
|
30
|
+
from agentnorm.trace import RunRecorder, ToolCall
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _base_class() -> type:
|
|
34
|
+
"""LangChain's handler base if available, else `object`."""
|
|
35
|
+
try: # pragma: no cover - depends on the host environment
|
|
36
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
37
|
+
|
|
38
|
+
return BaseCallbackHandler
|
|
39
|
+
except Exception: # noqa: BLE001 - any import failure means "not installed"
|
|
40
|
+
return object
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentNormCallbackHandlerMixin:
|
|
44
|
+
"""The recording logic, independent of whether LangChain is installed."""
|
|
45
|
+
|
|
46
|
+
# LangChain inspects these on handlers; supplied so a plain object still behaves.
|
|
47
|
+
raise_error: bool = False
|
|
48
|
+
run_inline: bool = False
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
recorder: RunRecorder,
|
|
53
|
+
*,
|
|
54
|
+
size_of: Callable[[str, dict[str, Any], Any], int] | None = None,
|
|
55
|
+
scope_of: Callable[[str, dict[str, Any], Any], str | None] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._rec = recorder
|
|
58
|
+
self._size_of = size_of or default_size_of
|
|
59
|
+
self._scope_of = scope_of
|
|
60
|
+
self._open: dict[str, ToolCall] = {}
|
|
61
|
+
self._args: dict[str, dict[str, Any]] = {}
|
|
62
|
+
|
|
63
|
+
# --- LangChain callback surface ------------------------------------------
|
|
64
|
+
|
|
65
|
+
def on_tool_start(
|
|
66
|
+
self,
|
|
67
|
+
serialized: dict[str, Any] | None,
|
|
68
|
+
input_str: str,
|
|
69
|
+
*,
|
|
70
|
+
run_id: UUID | None = None,
|
|
71
|
+
inputs: dict[str, Any] | None = None,
|
|
72
|
+
**kwargs: Any,
|
|
73
|
+
) -> None:
|
|
74
|
+
name = (serialized or {}).get("name") or kwargs.get("name") or "unknown_tool"
|
|
75
|
+
args = dict(inputs or {}) or {"input": input_str}
|
|
76
|
+
scope = ""
|
|
77
|
+
if self._scope_of is not None:
|
|
78
|
+
scope = self._scope_of(name, args, None) or ""
|
|
79
|
+
call = self._rec.start_call(name, args, scope=scope)
|
|
80
|
+
key = str(run_id)
|
|
81
|
+
self._open[key] = call
|
|
82
|
+
self._args[key] = args
|
|
83
|
+
|
|
84
|
+
def on_tool_end(self, output: Any, *, run_id: UUID | None = None, **kwargs: Any) -> None:
|
|
85
|
+
key = str(run_id)
|
|
86
|
+
call = self._open.pop(key, None)
|
|
87
|
+
if call is None:
|
|
88
|
+
# An end without a start means the handler was attached mid-run. Dropping it
|
|
89
|
+
# is correct: a call with no beginning has no duration and no arguments, and
|
|
90
|
+
# inventing them would corrupt the baseline it feeds.
|
|
91
|
+
return
|
|
92
|
+
args = self._args.pop(key, {})
|
|
93
|
+
size = int(self._size_of(call.tool, args, output))
|
|
94
|
+
if self._scope_of is not None and not call.scope:
|
|
95
|
+
call.scope = self._scope_of(call.tool, args, output) or ""
|
|
96
|
+
self._rec.complete_call(call, ok=True, result_size=size)
|
|
97
|
+
|
|
98
|
+
def on_tool_error(
|
|
99
|
+
self, error: BaseException, *, run_id: UUID | None = None, **kwargs: Any
|
|
100
|
+
) -> None:
|
|
101
|
+
key = str(run_id)
|
|
102
|
+
call = self._open.pop(key, None)
|
|
103
|
+
self._args.pop(key, None)
|
|
104
|
+
if call is None:
|
|
105
|
+
return
|
|
106
|
+
self._rec.complete_call(
|
|
107
|
+
call, ok=False, error=f"{type(error).__name__}: {error}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# --- results --------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def finish(self):
|
|
113
|
+
"""Close any calls the framework never ended, then return the run.
|
|
114
|
+
|
|
115
|
+
An agent killed mid-tool leaves an open call. Recording it as failed is more
|
|
116
|
+
honest than discarding it — a run that ends inside a tool is itself a signal.
|
|
117
|
+
"""
|
|
118
|
+
for call in self._open.values():
|
|
119
|
+
self._rec.complete_call(call, ok=False, error="unterminated: run ended mid-call")
|
|
120
|
+
self._open.clear()
|
|
121
|
+
self._args.clear()
|
|
122
|
+
return self._rec.finish()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def agentnorm_callback(
|
|
126
|
+
target: Session | RunRecorder,
|
|
127
|
+
*,
|
|
128
|
+
size_of: Callable[[str, dict[str, Any], Any], int] | None = None,
|
|
129
|
+
scope_of: Callable[[str, dict[str, Any], Any], str | None] | None = None,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""Build a LangChain callback handler recording into `target`.
|
|
132
|
+
|
|
133
|
+
Accepts a `Session` or a `RunRecorder`; call `.finish()` on whichever you passed.
|
|
134
|
+
"""
|
|
135
|
+
recorder = target._rec if isinstance(target, Session) else target
|
|
136
|
+
base = _base_class()
|
|
137
|
+
handler_cls = type(
|
|
138
|
+
"AgentNormCallbackHandler", (AgentNormCallbackHandlerMixin, base), {}
|
|
139
|
+
)
|
|
140
|
+
return handler_cls(recorder, size_of=size_of, scope_of=scope_of)
|
agentnorm/audit.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Tamper-evident audit trail for agent actions.
|
|
2
|
+
|
|
3
|
+
The EU AI Act's Article 12 obligations for high-risk systems take effect in August 2026:
|
|
4
|
+
automatic logging of events relevant to traceability, tamper-evident, retained six months
|
|
5
|
+
(twenty-four for biometric and law-enforcement systems), with penalties up to 3% of global
|
|
6
|
+
turnover. What regulators and security teams have converged on needing is the ability to
|
|
7
|
+
say *which agent accessed what data, under whose authority, and to reconstruct it later*.
|
|
8
|
+
|
|
9
|
+
Behavioural monitoring already sees every one of those facts. It was throwing away the
|
|
10
|
+
evidence.
|
|
11
|
+
|
|
12
|
+
Records follow the IETF Agent Audit Trail draft (`draft-sharif-agent-audit-trail`), which
|
|
13
|
+
matters for two reasons. Aligning with an emerging standard is a far better position than
|
|
14
|
+
inventing a log format nobody else reads. And the draft has a field, `recording_component`,
|
|
15
|
+
for exactly this situation - an entity that writes records *independently of the agent*,
|
|
16
|
+
for gateways and middleware logging on the agent's behalf. Out-of-band recording is
|
|
17
|
+
anticipated by the standard rather than bolted onto it.
|
|
18
|
+
|
|
19
|
+
**What tamper-evident does and does not mean here.** Each record carries the SHA-256 of its
|
|
20
|
+
predecessor's canonical form, so altering or removing any record breaks every hash after it
|
|
21
|
+
and the break is detectable. That is integrity, not immutability: someone who can rewrite
|
|
22
|
+
the whole file can recompute the whole chain. Detecting *that* requires anchoring the head
|
|
23
|
+
hash somewhere the writer does not control - a WORM bucket, a countersigning service, a
|
|
24
|
+
transparency log. `chain_head()` exists to be anchored; anchoring it is the deployment's
|
|
25
|
+
job, and this module does not pretend to do it.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import uuid
|
|
32
|
+
from collections.abc import Iterable, Iterator
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from agentnorm.trace import Run, ToolCall
|
|
39
|
+
|
|
40
|
+
SPEC = "draft-sharif-agent-audit-trail-00"
|
|
41
|
+
|
|
42
|
+
# Action types from the draft's controlled vocabulary.
|
|
43
|
+
ACTION_TYPES = frozenset(
|
|
44
|
+
{"tool_call", "tool_response", "decision", "delegation", "escalation", "error", "lifecycle"}
|
|
45
|
+
)
|
|
46
|
+
OUTCOMES = frozenset({"success", "failure", "timeout", "denied", "escalated"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def canonical(record: dict[str, Any]) -> str:
|
|
50
|
+
"""JSON Canonicalization Scheme (RFC 8785), to the extent audit records need it.
|
|
51
|
+
|
|
52
|
+
JCS exists so two parties hashing the same record agree on the bytes. Records here
|
|
53
|
+
contain only strings, integers, booleans, nulls and nested objects and arrays of
|
|
54
|
+
those - so sorted keys, no insignificant whitespace, and UTF-8 without ASCII escaping
|
|
55
|
+
is exactly JCS for this domain.
|
|
56
|
+
|
|
57
|
+
Floats are deliberately excluded rather than approximated: JCS mandates a specific
|
|
58
|
+
shortest-round-trip float serialisation, and getting that subtly wrong would produce
|
|
59
|
+
hashes that verify locally and fail against another implementation - the worst
|
|
60
|
+
possible failure mode for evidence.
|
|
61
|
+
"""
|
|
62
|
+
_reject_floats(record)
|
|
63
|
+
return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reject_floats(value: Any, path: str = "") -> None:
|
|
67
|
+
if isinstance(value, float):
|
|
68
|
+
raise TypeError(
|
|
69
|
+
f"float at {path or 'root'}: audit records must not contain floats, because "
|
|
70
|
+
"canonical float serialisation differs between implementations and would "
|
|
71
|
+
"break cross-verification. Encode as a string or an integer."
|
|
72
|
+
)
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
for k, v in value.items():
|
|
75
|
+
_reject_floats(v, f"{path}.{k}" if path else str(k))
|
|
76
|
+
elif isinstance(value, (list, tuple)):
|
|
77
|
+
for i, v in enumerate(value):
|
|
78
|
+
_reject_floats(v, f"{path}[{i}]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def digest(record: dict[str, Any]) -> str:
|
|
82
|
+
return hashlib.sha256(canonical(record).encode("utf-8")).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class AuditChain:
|
|
87
|
+
"""Builds a hash-linked sequence of Agent Audit Trail records."""
|
|
88
|
+
|
|
89
|
+
recording_component: str = f"agentnorm/{SPEC}"
|
|
90
|
+
records: list[dict[str, Any]] = field(default_factory=list)
|
|
91
|
+
_prev_hash: str | None = None
|
|
92
|
+
_prev_id: str | None = None
|
|
93
|
+
|
|
94
|
+
def append(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
agent_id: str,
|
|
98
|
+
agent_version: str,
|
|
99
|
+
session_id: str,
|
|
100
|
+
action_type: str,
|
|
101
|
+
action_detail: dict[str, Any],
|
|
102
|
+
outcome: str,
|
|
103
|
+
trust_level: str = "L2",
|
|
104
|
+
record_phase: str = "post_execution",
|
|
105
|
+
principal: str | None = None,
|
|
106
|
+
human_override: dict[str, Any] | None = None,
|
|
107
|
+
timestamp: datetime | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
if action_type not in ACTION_TYPES:
|
|
110
|
+
raise ValueError(f"action_type {action_type!r} not in {sorted(ACTION_TYPES)}")
|
|
111
|
+
if outcome not in OUTCOMES:
|
|
112
|
+
raise ValueError(f"outcome {outcome!r} not in {sorted(OUTCOMES)}")
|
|
113
|
+
|
|
114
|
+
ts = (timestamp or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
115
|
+
record: dict[str, Any] = {
|
|
116
|
+
"record_id": str(uuid.uuid4()),
|
|
117
|
+
"timestamp": ts.isoformat().replace("+00:00", "Z"),
|
|
118
|
+
"agent_id": agent_id,
|
|
119
|
+
"agent_version": agent_version,
|
|
120
|
+
"session_id": session_id,
|
|
121
|
+
"action_type": action_type,
|
|
122
|
+
"action_detail": action_detail,
|
|
123
|
+
"outcome": outcome,
|
|
124
|
+
"trust_level": trust_level,
|
|
125
|
+
"record_phase": record_phase,
|
|
126
|
+
"parent_record_id": self._prev_id,
|
|
127
|
+
"prev_hash": self._prev_hash,
|
|
128
|
+
"recording_component": self.recording_component,
|
|
129
|
+
}
|
|
130
|
+
# "under whose authority" - the regulator's question. Carried on every record
|
|
131
|
+
# rather than inferred from a session lookup that may not survive retention.
|
|
132
|
+
if principal:
|
|
133
|
+
record["principal"] = principal
|
|
134
|
+
if human_override:
|
|
135
|
+
record["human_override"] = human_override
|
|
136
|
+
|
|
137
|
+
self.records.append(record)
|
|
138
|
+
self._prev_id = record["record_id"]
|
|
139
|
+
self._prev_hash = digest(record)
|
|
140
|
+
return record
|
|
141
|
+
|
|
142
|
+
def chain_head(self) -> str | None:
|
|
143
|
+
"""Hash of the latest record. Anchor this externally to detect wholesale rewrites."""
|
|
144
|
+
return self._prev_hash
|
|
145
|
+
|
|
146
|
+
def delegate(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
agent_id: str,
|
|
150
|
+
agent_version: str,
|
|
151
|
+
session_id: str,
|
|
152
|
+
delegate_agent_id: str,
|
|
153
|
+
task_description: str,
|
|
154
|
+
delegate_trust_level: str = "L2",
|
|
155
|
+
constraints: list[str] | None = None,
|
|
156
|
+
principal: str | None = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Record an agent handing work to another agent.
|
|
159
|
+
|
|
160
|
+
This is the record that makes a multi-agent chain reconstructable. Without it an
|
|
161
|
+
action is traceable to a system but not to a decision: four agents across two
|
|
162
|
+
protocols, none of which logged who asked whom to do what.
|
|
163
|
+
|
|
164
|
+
The task is recorded as a hash rather than as text, so the chain proves what was
|
|
165
|
+
delegated without the audit log becoming a copy of every prompt.
|
|
166
|
+
"""
|
|
167
|
+
return self.append(
|
|
168
|
+
agent_id=agent_id,
|
|
169
|
+
agent_version=agent_version,
|
|
170
|
+
session_id=session_id,
|
|
171
|
+
action_type="delegation",
|
|
172
|
+
action_detail={
|
|
173
|
+
"delegate_agent_id": delegate_agent_id,
|
|
174
|
+
"delegate_trust_level": delegate_trust_level,
|
|
175
|
+
"task_description_hash": hashlib.sha256(
|
|
176
|
+
task_description.encode("utf-8")
|
|
177
|
+
).hexdigest(),
|
|
178
|
+
"constraints": constraints or [],
|
|
179
|
+
},
|
|
180
|
+
outcome="success",
|
|
181
|
+
principal=principal,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _call_detail(call: ToolCall) -> dict[str, Any]:
|
|
186
|
+
return {
|
|
187
|
+
"tool_name": call.tool,
|
|
188
|
+
"step": call.step,
|
|
189
|
+
"arguments": {k: _scalar(v) for k, v in call.args.items()},
|
|
190
|
+
"resource": call.resource,
|
|
191
|
+
"scope": call.scope,
|
|
192
|
+
"duration_ms": call.duration_ms,
|
|
193
|
+
"result_size": call.result_size,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _scalar(value: Any) -> Any:
|
|
198
|
+
"""Audit values are strings, ints, bools or null - floats break canonicalisation."""
|
|
199
|
+
if isinstance(value, bool) or isinstance(value, int) or value is None:
|
|
200
|
+
return value
|
|
201
|
+
if isinstance(value, str):
|
|
202
|
+
return value[:2000]
|
|
203
|
+
return str(value)[:2000]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def record_run(chain: AuditChain, run: Run, *, trust_level: str = "L2") -> list[dict[str, Any]]:
|
|
207
|
+
"""Emit an audit record per tool call in a run, plus lifecycle bookends."""
|
|
208
|
+
out = [
|
|
209
|
+
chain.append(
|
|
210
|
+
agent_id=f"urn:agent:{run.agent}",
|
|
211
|
+
agent_version=run.version,
|
|
212
|
+
session_id=run.run_id,
|
|
213
|
+
action_type="lifecycle",
|
|
214
|
+
action_detail={"event": "run_started", "actor_kind": run.actor_kind},
|
|
215
|
+
outcome="success",
|
|
216
|
+
trust_level=trust_level,
|
|
217
|
+
record_phase="pre_execution",
|
|
218
|
+
principal=run.principal or None,
|
|
219
|
+
timestamp=run.started_at,
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
for call in run.calls:
|
|
223
|
+
out.append(
|
|
224
|
+
chain.append(
|
|
225
|
+
agent_id=f"urn:agent:{run.agent}",
|
|
226
|
+
agent_version=run.version,
|
|
227
|
+
session_id=run.run_id,
|
|
228
|
+
action_type="tool_call",
|
|
229
|
+
action_detail=_call_detail(call),
|
|
230
|
+
outcome="success" if call.ok else "failure",
|
|
231
|
+
trust_level=trust_level,
|
|
232
|
+
principal=run.principal or None,
|
|
233
|
+
timestamp=call.started_at,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
out.append(
|
|
237
|
+
chain.append(
|
|
238
|
+
agent_id=f"urn:agent:{run.agent}",
|
|
239
|
+
agent_version=run.version,
|
|
240
|
+
session_id=run.run_id,
|
|
241
|
+
action_type="lifecycle",
|
|
242
|
+
action_detail={"event": "run_finished", "calls": run.n_calls},
|
|
243
|
+
outcome="success",
|
|
244
|
+
trust_level=trust_level,
|
|
245
|
+
principal=run.principal or None,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class VerificationResult:
|
|
253
|
+
ok: bool
|
|
254
|
+
records: int
|
|
255
|
+
first_broken_index: int | None = None
|
|
256
|
+
reason: str = ""
|
|
257
|
+
|
|
258
|
+
def __str__(self) -> str:
|
|
259
|
+
if self.ok:
|
|
260
|
+
return f"chain intact: {self.records} records verified"
|
|
261
|
+
return f"chain BROKEN at record {self.first_broken_index}: {self.reason}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def verify_chain(records: Iterable[dict[str, Any]]) -> VerificationResult:
|
|
265
|
+
"""Recompute the chain and report the first record that does not agree.
|
|
266
|
+
|
|
267
|
+
Reporting *where* the chain breaks matters more than a boolean: the break points at
|
|
268
|
+
the earliest record that was altered or removed, which is the first thing an
|
|
269
|
+
investigator needs.
|
|
270
|
+
"""
|
|
271
|
+
prev_hash: str | None = None
|
|
272
|
+
prev_id: str | None = None
|
|
273
|
+
count = 0
|
|
274
|
+
|
|
275
|
+
for i, record in enumerate(records):
|
|
276
|
+
count += 1
|
|
277
|
+
if record.get("prev_hash") != prev_hash:
|
|
278
|
+
return VerificationResult(
|
|
279
|
+
False, count, i,
|
|
280
|
+
f"prev_hash mismatch (expected {prev_hash!r}, found {record.get('prev_hash')!r})",
|
|
281
|
+
)
|
|
282
|
+
if record.get("parent_record_id") != prev_id:
|
|
283
|
+
return VerificationResult(
|
|
284
|
+
False, count, i, "parent_record_id does not match the preceding record"
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
prev_hash = digest(record)
|
|
288
|
+
except TypeError as exc:
|
|
289
|
+
return VerificationResult(False, count, i, str(exc))
|
|
290
|
+
prev_id = record.get("record_id")
|
|
291
|
+
|
|
292
|
+
return VerificationResult(True, count)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class AuditLog:
|
|
296
|
+
"""Append-only audit log on disk, one JSON record per line."""
|
|
297
|
+
|
|
298
|
+
def __init__(self, path: str | Path, recording_component: str | None = None) -> None:
|
|
299
|
+
self.path = Path(path)
|
|
300
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
self.chain = AuditChain(
|
|
302
|
+
recording_component=recording_component or f"agentnorm/{SPEC}"
|
|
303
|
+
)
|
|
304
|
+
# Resume an existing chain so restarts do not silently start a second one, which
|
|
305
|
+
# would verify cleanly on its own and hide the discontinuity.
|
|
306
|
+
existing = list(self.read())
|
|
307
|
+
if existing:
|
|
308
|
+
self.chain.records = existing
|
|
309
|
+
self.chain._prev_id = existing[-1].get("record_id")
|
|
310
|
+
self.chain._prev_hash = digest(existing[-1])
|
|
311
|
+
|
|
312
|
+
def write_run(self, run: Run, *, trust_level: str = "L2") -> int:
|
|
313
|
+
records = record_run(self.chain, run, trust_level=trust_level)
|
|
314
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
315
|
+
for record in records:
|
|
316
|
+
fh.write(canonical(record) + "\n")
|
|
317
|
+
return len(records)
|
|
318
|
+
|
|
319
|
+
def read(self) -> Iterator[dict[str, Any]]:
|
|
320
|
+
if not self.path.is_file():
|
|
321
|
+
return
|
|
322
|
+
with self.path.open(encoding="utf-8") as fh:
|
|
323
|
+
for line in fh:
|
|
324
|
+
line = line.strip()
|
|
325
|
+
if line:
|
|
326
|
+
yield json.loads(line)
|
|
327
|
+
|
|
328
|
+
def verify(self) -> VerificationResult:
|
|
329
|
+
return verify_chain(self.read())
|
|
330
|
+
|
|
331
|
+
def chain_head(self) -> str | None:
|
|
332
|
+
return self.chain.chain_head()
|