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 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()