agentnorm 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- agentnorm-0.1.0/PKG-INFO +321 -0
- agentnorm-0.1.0/README.md +290 -0
- agentnorm-0.1.0/agentnorm/__init__.py +40 -0
- agentnorm-0.1.0/agentnorm/adapters/__init__.py +1 -0
- agentnorm-0.1.0/agentnorm/adapters/langchain.py +140 -0
- agentnorm-0.1.0/agentnorm/audit.py +332 -0
- agentnorm-0.1.0/agentnorm/detectors.py +289 -0
- agentnorm-0.1.0/agentnorm/evaluation.py +209 -0
- agentnorm-0.1.0/agentnorm/integrations.py +107 -0
- agentnorm-0.1.0/agentnorm/monitor.py +142 -0
- agentnorm-0.1.0/agentnorm/store.py +102 -0
- agentnorm-0.1.0/agentnorm/trace.py +183 -0
- agentnorm-0.1.0/agentnorm.egg-info/PKG-INFO +321 -0
- agentnorm-0.1.0/agentnorm.egg-info/SOURCES.txt +23 -0
- agentnorm-0.1.0/agentnorm.egg-info/dependency_links.txt +1 -0
- agentnorm-0.1.0/agentnorm.egg-info/requires.txt +6 -0
- agentnorm-0.1.0/agentnorm.egg-info/top_level.txt +1 -0
- agentnorm-0.1.0/pyproject.toml +56 -0
- agentnorm-0.1.0/setup.cfg +4 -0
- agentnorm-0.1.0/tests/test_agentnorm.py +210 -0
- agentnorm-0.1.0/tests/test_audit.py +189 -0
- agentnorm-0.1.0/tests/test_evaluation.py +80 -0
- agentnorm-0.1.0/tests/test_integrations.py +149 -0
- agentnorm-0.1.0/tests/test_langchain_adapter.py +141 -0
agentnorm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaustubh Patil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agentnorm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentnorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Behavioural monitoring for AI agents: runtime baselines, scope enforcement and cold-start-safe anomaly detection.
|
|
5
|
+
Author: Kaustubh Patil
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kaustubhspatil/sentinel/tree/main/packages/agentnorm
|
|
8
|
+
Project-URL: Repository, https://github.com/kaustubhspatil/sentinel
|
|
9
|
+
Project-URL: Issues, https://github.com/kaustubhspatil/sentinel/issues
|
|
10
|
+
Project-URL: Reference deployment, https://github.com/kaustubhspatil/sentinel
|
|
11
|
+
Keywords: agents,llm,observability,anomaly-detection,ai-safety,monitoring,langchain,langgraph,mcp
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# agentnorm
|
|
33
|
+
|
|
34
|
+
**Behavioural monitoring for AI agents.** Evaluation grades what an agent *said*, offline.
|
|
35
|
+
This watches how it *behaves*, at runtime, and tells you when a run does not look like the
|
|
36
|
+
ones before it.
|
|
37
|
+
|
|
38
|
+
Zero dependencies. No database, no framework, no context propagation.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from agentnorm import RunRecorder, Monitor
|
|
42
|
+
|
|
43
|
+
rec = RunRecorder(agent="triage", version="v3", principal="acme")
|
|
44
|
+
|
|
45
|
+
with rec.tool_call("search_tickets", {"q": q}, scope="acme") as call:
|
|
46
|
+
rows = search(q)
|
|
47
|
+
call.result_size = len(rows)
|
|
48
|
+
|
|
49
|
+
monitor = Monitor.fit(history) # past benign runs
|
|
50
|
+
verdict = monitor.score(rec.finish())
|
|
51
|
+
|
|
52
|
+
if verdict.flagged:
|
|
53
|
+
print(verdict.explain())
|
|
54
|
+
# volume=7.31 (threshold 3.02) [cold start: no history for this agent version]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Instrumenting an agent you already have
|
|
58
|
+
|
|
59
|
+
Hand over the tool callables you already use; get back callables with the same signatures
|
|
60
|
+
that record as a side effect. No restructuring, no context propagation.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from agentnorm import JsonlStore, Monitor, Session
|
|
64
|
+
|
|
65
|
+
store = JsonlStore("history.jsonl")
|
|
66
|
+
|
|
67
|
+
session = Session(agent="triage", version="v3", principal="acme",
|
|
68
|
+
scope_of=lambda tool, args, result: args.get("tenant"))
|
|
69
|
+
tools = session.wrap({"search_tickets": search_tickets, "export_all": export_all})
|
|
70
|
+
|
|
71
|
+
# ... run the agent using `tools` exactly as before ...
|
|
72
|
+
|
|
73
|
+
store.append(session.finish())
|
|
74
|
+
verdict = Monitor.fit(store.read()).score(session.finish())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`scope_of` is what makes cross-tenant detection possible at all - without it agentnorm can
|
|
78
|
+
see that a call happened but not whose data it touched. Returning `None` means "unknown",
|
|
79
|
+
which is treated as in-scope, because a false accusation of cross-tenant access is worse
|
|
80
|
+
than a miss.
|
|
81
|
+
|
|
82
|
+
### What that gets you
|
|
83
|
+
|
|
84
|
+
From [`examples/quickstart.py`](examples/quickstart.py), fitted on 300 normal runs:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
normal run -> no anomaly
|
|
88
|
+
|
|
89
|
+
exfiltration attempt -> scope=1.00 (threshold 0.00); novel_tool=1.00 (threshold 0.00);
|
|
90
|
+
sequence=3.61 (threshold 0.00); volume=7.98 (threshold 2.17)
|
|
91
|
+
|
|
92
|
+
new agent version -> no anomaly [cold start: no history for this agent version;
|
|
93
|
+
uncalibrated: sequence, novel_tool, rate]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Four detectors fire on the exfiltration attempt and each names the invariant that broke -
|
|
97
|
+
wrong tenant, unfamiliar tool, unusual path, far too much data. That is the difference
|
|
98
|
+
between "something is wrong" and a page an engineer can act on at 3am.
|
|
99
|
+
|
|
100
|
+
The third line is the one most monitoring gets wrong: a version bump is a **change-point**,
|
|
101
|
+
not an anomaly. agentnorm reports that it has no history rather than alerting, and names
|
|
102
|
+
which detectors are consequently unavailable.
|
|
103
|
+
|
|
104
|
+
It also refuses to pretend about calibration:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
warning: calibration set has 75 runs but a 0.0020 quantile needs at least 500;
|
|
108
|
+
thresholds fall back to the observed maximum and the true false-positive rate
|
|
109
|
+
will exceed the budget
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Framework adapters
|
|
113
|
+
|
|
114
|
+
For frameworks that report tool calls through callbacks rather than direct invocation:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from agentnorm import Session
|
|
118
|
+
from agentnorm.adapters.langchain import agentnorm_callback
|
|
119
|
+
|
|
120
|
+
session = Session(agent="researcher", version="v2", principal="acme")
|
|
121
|
+
graph.invoke(state, config={"callbacks": [agentnorm_callback(session)]})
|
|
122
|
+
|
|
123
|
+
verdict = monitor.score(session.finish())
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Works with LangChain and LangGraph. **agentnorm still does not depend on either** - the base
|
|
127
|
+
class is imported lazily and the handler works as a plain object without it, because
|
|
128
|
+
LangChain duck-types handlers in the paths that matter. That is deliberate: a monitoring
|
|
129
|
+
library that drags in an agent framework is unusable by anyone running a different one,
|
|
130
|
+
and comparing agents across frameworks on equal footing is half the point.
|
|
131
|
+
|
|
132
|
+
The adapter is tested with LangChain deliberately *not* installed - the callback contract
|
|
133
|
+
is replayed instead - so the zero-dependency guarantee stays testable in CI.
|
|
134
|
+
|
|
135
|
+
Two behaviours worth knowing, because callback streams are messier than they look:
|
|
136
|
+
|
|
137
|
+
- **Concurrent tools are matched by the framework's `run_id`.** LangGraph runs tools in
|
|
138
|
+
parallel, so starts and ends interleave and pairing them by order is wrong.
|
|
139
|
+
- **A call the framework never ends is recorded as failed, not discarded.** An agent
|
|
140
|
+
killed mid-tool leaves an open call, and a run that ends inside a tool is itself a
|
|
141
|
+
signal. Conversely an end with no start - the handler attached mid-run - is dropped,
|
|
142
|
+
because a call with no beginning has no duration and no arguments, and inventing them
|
|
143
|
+
would corrupt the baseline it feeds.
|
|
144
|
+
|
|
145
|
+
## Persistence
|
|
146
|
+
|
|
147
|
+
`Monitor.fit` needs history, so a monitor that cannot remember is useless in practice.
|
|
148
|
+
`JsonlStore` is the smallest thing that solves it: append-only JSON Lines, standard
|
|
149
|
+
library only. Append-only is deliberate - behavioural history is evidence, and a store
|
|
150
|
+
that can be rewritten in place is worth much less during an investigation. A truncated
|
|
151
|
+
final line from an interrupted write costs one run, not the file.
|
|
152
|
+
|
|
153
|
+
`Store` is a protocol, so ClickHouse or Postgres is a drop-in. The reference deployment
|
|
154
|
+
uses ClickHouse.
|
|
155
|
+
|
|
156
|
+
## Why this exists
|
|
157
|
+
|
|
158
|
+
Agents are unvalidated models running in production. We have good tooling for grading
|
|
159
|
+
their outputs before release and almost none for answering the question that matters after
|
|
160
|
+
release: **did this run behave like the others?**
|
|
161
|
+
|
|
162
|
+
That question is closer to fraud detection than to evaluation. It is about the shape of an
|
|
163
|
+
episode — which tools, in what order, touching whose data, returning how much — not about
|
|
164
|
+
whether an answer was good.
|
|
165
|
+
|
|
166
|
+
Five detectors, each owning one failure mode, deliberately not fused into a single score.
|
|
167
|
+
A fused score says something is wrong without saying what, and "what" decides whether you
|
|
168
|
+
page someone, revoke a credential, or ignore it.
|
|
169
|
+
|
|
170
|
+
| Detector | Catches |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `volume` | an agent returning far more data than usual — exfiltration-shaped |
|
|
173
|
+
| `sequence` | a path no planner would take, where every individual call is legitimate |
|
|
174
|
+
| `scope` | a run entitled to one principal touching another's resources |
|
|
175
|
+
| `novel_tool` | reaching a tool this agent has never used |
|
|
176
|
+
| `rate` | an order-of-magnitude change in calls per run |
|
|
177
|
+
|
|
178
|
+
## The hard part: cold start
|
|
179
|
+
|
|
180
|
+
Agent versions change weekly. A detector meeting an entity it has never seen is not an
|
|
181
|
+
edge case — it is the **normal operating condition**, and it is where naive monitoring
|
|
182
|
+
falls apart.
|
|
183
|
+
|
|
184
|
+
Measured on a live deployment: a suite fitted on one population and scored against a real
|
|
185
|
+
agent it had not seen alerted on **100% of known-benign runs**. Every tool looked novel,
|
|
186
|
+
because the *agent* was novel. The detector was reporting "I have not met you" on every
|
|
187
|
+
run, forever.
|
|
188
|
+
|
|
189
|
+
The per-detector breakdown is the useful part:
|
|
190
|
+
|
|
191
|
+
| Detector | Alert rate on benign runs from an unseen agent | After |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| volume (hierarchical) | **0.000** | 0.000 |
|
|
194
|
+
| sequence | 0.000 | 0.000 |
|
|
195
|
+
| scope | 0.000 | 0.000 |
|
|
196
|
+
| novel_tool | **1.000** | 0.000 |
|
|
197
|
+
| rate | **0.536** | suppressed |
|
|
198
|
+
| **any** | **1.000** | **0.000** |
|
|
199
|
+
|
|
200
|
+
The hierarchical model transferred untouched; the set-membership ones had no cold-start
|
|
201
|
+
behaviour at all. Detection was unchanged after the fix — union recall stayed at 1.000
|
|
202
|
+
across five attack families.
|
|
203
|
+
|
|
204
|
+
So every detector in agentnorm answers "what do I do about an entity I have not observed?"
|
|
205
|
+
explicitly:
|
|
206
|
+
|
|
207
|
+
- **pool** where the quantity is comparable across agents (`volume`, `sequence`,
|
|
208
|
+
`novel_tool`) — shrink toward the population rather than treating the newcomer as alien
|
|
209
|
+
- **assert** where no history is needed (`scope`) — entitlement is checked, not learned
|
|
210
|
+
- **suppress** where pooling would be wrong (`rate`) — run length is not comparable between
|
|
211
|
+
a triage agent making three calls and a reporting agent making forty, so agentnorm reports
|
|
212
|
+
*not yet calibrated* instead of guessing
|
|
213
|
+
|
|
214
|
+
## Thresholds you can act on
|
|
215
|
+
|
|
216
|
+
Thresholds come from a **false-positive budget**, not from maximising a statistic. An
|
|
217
|
+
operator can act on "this fires once per two hundred clean runs". Nobody can act on "this
|
|
218
|
+
maximises F1".
|
|
219
|
+
|
|
220
|
+
The budget is stated for the suite and divided across detectors, because five detectors
|
|
221
|
+
each firing on 1% of runs union to about 5% — a suite advertised at 1% that delivers 5%
|
|
222
|
+
gets muted within a week, and a muted detector detects nothing.
|
|
223
|
+
|
|
224
|
+
agentnorm also refuses to pretend about calibration. Asked for a 0.2% quantile from 40 runs,
|
|
225
|
+
it fits, and warns:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
calibration set has 40 runs but a 0.0020 quantile needs at least 500;
|
|
229
|
+
thresholds fall back to the observed maximum and the true false-positive
|
|
230
|
+
rate will exceed the budget
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Design choices worth knowing
|
|
234
|
+
|
|
235
|
+
**The unit is a run, not a span.** Scope escalation, abnormal paths and volume anomalies
|
|
236
|
+
are properties of a whole episode.
|
|
237
|
+
|
|
238
|
+
**`version` is a change-point, not a label.** Changing a prompt, model or tool grant
|
|
239
|
+
changes the behavioural distribution. Baselines key on `agent@version`, so a deployment
|
|
240
|
+
resets the baseline instead of triggering an alert storm.
|
|
241
|
+
|
|
242
|
+
**Human and agent traces are never pooled.** Their tool-use distributions are nothing
|
|
243
|
+
alike, and pooling would poison the priors. `actor_kind` exists for this.
|
|
244
|
+
|
|
245
|
+
**Failed calls are recorded, then re-raised.** A trace that omits failures hides exactly
|
|
246
|
+
the behaviour worth detecting — an agent probing for a tool it lacks, or retrying a
|
|
247
|
+
refused action.
|
|
248
|
+
|
|
249
|
+
**Attribute names follow OpenTelemetry's GenAI semantic conventions** where they exist, so
|
|
250
|
+
traces can be exported rather than trapped.
|
|
251
|
+
|
|
252
|
+
## Measuring your own detectors
|
|
253
|
+
|
|
254
|
+
`agentnorm.evaluation` scores a suite against your labelled runs and sweeps the settings that
|
|
255
|
+
were chosen by judgement rather than derived:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from agentnorm.evaluation import sensitivity, format_report
|
|
259
|
+
|
|
260
|
+
print(format_report(sensitivity(benign_runs, labelled_attacks)))
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Run against the reference deployment's 4,000 benign and 200 labelled anomalous runs, recall
|
|
264
|
+
was **1.000 at every setting** — across a 64-fold range of prior strength, a 10-fold range
|
|
265
|
+
of false-positive budget, and three calibration splits. No hand-picked constant is
|
|
266
|
+
load-bearing, and the false-positive rate tracks the budget while staying below it, so the
|
|
267
|
+
per-detector budget division is conservative rather than optimistic.
|
|
268
|
+
|
|
269
|
+
That result cuts both ways, and the second half matters more. Recall that cannot be moved
|
|
270
|
+
by any setting also means the generated attacks sit nowhere near the decision boundary: the
|
|
271
|
+
test is easy, which is evidence for the ceiling caveat below rather than against it.
|
|
272
|
+
|
|
273
|
+
## Alternatives
|
|
274
|
+
|
|
275
|
+
This is a small library in a category that is filling up. Worth knowing before you pick it:
|
|
276
|
+
|
|
277
|
+
| | what it is | pick it over agentnorm when |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| [AgentOps](https://github.com/AgentOps-AI/agentops) | mature agent monitoring SDK: cost tracking, benchmarking, broad framework coverage | you want the widest framework support and a hosted product behind it |
|
|
280
|
+
| [Agentomaly](https://github.com/sushaan-k/agentomaly) | runtime behavioural anomaly detection over OpenTelemetry, with Slack/PagerDuty/Jaeger wiring | you already run OTel and want alerting plumbed into existing infrastructure |
|
|
281
|
+
| AgentLens | MCP-native observability with an append-only hash-chained audit log | you want a platform rather than a library, and MCP is your primary surface |
|
|
282
|
+
| TRACE | hardware-attested trust records binding model, policy and tool calls into a signed artifact | you need offline third-party verification and can run in a TEE — this is strictly stronger than hash chaining alone |
|
|
283
|
+
|
|
284
|
+
**What agentnorm does differently.** Two things, and they are narrow:
|
|
285
|
+
|
|
286
|
+
**Cold start is handled explicitly.** The others learn a baseline from historical traces —
|
|
287
|
+
Agentomaly's trainer takes `min_traces=100` — and do not say what happens to an agent with no
|
|
288
|
+
history. Measured here: a suite without cold-start handling alerts on **100% of known-benign
|
|
289
|
+
runs** from an unseen agent version. Since agent versions change weekly, that is the normal
|
|
290
|
+
operating condition rather than an edge case. agentnorm decides *per detector* whether a
|
|
291
|
+
quantity may be pooled across identities, asserted without history, or must be suppressed as
|
|
292
|
+
uncalibrated — and reports which.
|
|
293
|
+
|
|
294
|
+
**Zero dependencies.** Enforced in CI by walking the AST, not asserted in prose. It installs
|
|
295
|
+
into a locked-down environment and adds nothing to your dependency tree.
|
|
296
|
+
|
|
297
|
+
**Where it is weaker.** Fewer integrations than AgentOps or Agentomaly. No hosted backend, no
|
|
298
|
+
dashboard, no alert routing. And hash chaining gives integrity, not the offline third-party
|
|
299
|
+
verification TRACE achieves with hardware attestation.
|
|
300
|
+
|
|
301
|
+
## Status
|
|
302
|
+
|
|
303
|
+
Early, and honest about it. The detectors are validated against five labelled attack
|
|
304
|
+
families and against real agent traffic from one deployment. What is not yet true:
|
|
305
|
+
|
|
306
|
+
- the attack families are generated, so recall against them is a ceiling rather than a
|
|
307
|
+
performance claim — a genuinely novel attack will not look like any of the five
|
|
308
|
+
- real-traffic validation is one deployment and tens of runs, not thousands
|
|
309
|
+
- there is no persistence layer; bring your own store
|
|
310
|
+
|
|
311
|
+
## Reference deployment
|
|
312
|
+
|
|
313
|
+
agentnorm was extracted from [Sentinel](../../README.md), an agentic IT-operations platform
|
|
314
|
+
running on a live multi-cloud estate. Sentinel is where these numbers come from, and where
|
|
315
|
+
agentnorm found a real cross-tenant disclosure in Sentinel's own agent — a run scoped to one
|
|
316
|
+
tenant returning another tenant's host and package data, because `tenant` was a parameter
|
|
317
|
+
the model could set freely. A boundary the caller can rewrite is not a boundary.
|
|
318
|
+
|
|
319
|
+
## Licence
|
|
320
|
+
|
|
321
|
+
MIT.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# agentnorm
|
|
2
|
+
|
|
3
|
+
**Behavioural monitoring for AI agents.** Evaluation grades what an agent *said*, offline.
|
|
4
|
+
This watches how it *behaves*, at runtime, and tells you when a run does not look like the
|
|
5
|
+
ones before it.
|
|
6
|
+
|
|
7
|
+
Zero dependencies. No database, no framework, no context propagation.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from agentnorm import RunRecorder, Monitor
|
|
11
|
+
|
|
12
|
+
rec = RunRecorder(agent="triage", version="v3", principal="acme")
|
|
13
|
+
|
|
14
|
+
with rec.tool_call("search_tickets", {"q": q}, scope="acme") as call:
|
|
15
|
+
rows = search(q)
|
|
16
|
+
call.result_size = len(rows)
|
|
17
|
+
|
|
18
|
+
monitor = Monitor.fit(history) # past benign runs
|
|
19
|
+
verdict = monitor.score(rec.finish())
|
|
20
|
+
|
|
21
|
+
if verdict.flagged:
|
|
22
|
+
print(verdict.explain())
|
|
23
|
+
# volume=7.31 (threshold 3.02) [cold start: no history for this agent version]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Instrumenting an agent you already have
|
|
27
|
+
|
|
28
|
+
Hand over the tool callables you already use; get back callables with the same signatures
|
|
29
|
+
that record as a side effect. No restructuring, no context propagation.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from agentnorm import JsonlStore, Monitor, Session
|
|
33
|
+
|
|
34
|
+
store = JsonlStore("history.jsonl")
|
|
35
|
+
|
|
36
|
+
session = Session(agent="triage", version="v3", principal="acme",
|
|
37
|
+
scope_of=lambda tool, args, result: args.get("tenant"))
|
|
38
|
+
tools = session.wrap({"search_tickets": search_tickets, "export_all": export_all})
|
|
39
|
+
|
|
40
|
+
# ... run the agent using `tools` exactly as before ...
|
|
41
|
+
|
|
42
|
+
store.append(session.finish())
|
|
43
|
+
verdict = Monitor.fit(store.read()).score(session.finish())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`scope_of` is what makes cross-tenant detection possible at all - without it agentnorm can
|
|
47
|
+
see that a call happened but not whose data it touched. Returning `None` means "unknown",
|
|
48
|
+
which is treated as in-scope, because a false accusation of cross-tenant access is worse
|
|
49
|
+
than a miss.
|
|
50
|
+
|
|
51
|
+
### What that gets you
|
|
52
|
+
|
|
53
|
+
From [`examples/quickstart.py`](examples/quickstart.py), fitted on 300 normal runs:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
normal run -> no anomaly
|
|
57
|
+
|
|
58
|
+
exfiltration attempt -> scope=1.00 (threshold 0.00); novel_tool=1.00 (threshold 0.00);
|
|
59
|
+
sequence=3.61 (threshold 0.00); volume=7.98 (threshold 2.17)
|
|
60
|
+
|
|
61
|
+
new agent version -> no anomaly [cold start: no history for this agent version;
|
|
62
|
+
uncalibrated: sequence, novel_tool, rate]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Four detectors fire on the exfiltration attempt and each names the invariant that broke -
|
|
66
|
+
wrong tenant, unfamiliar tool, unusual path, far too much data. That is the difference
|
|
67
|
+
between "something is wrong" and a page an engineer can act on at 3am.
|
|
68
|
+
|
|
69
|
+
The third line is the one most monitoring gets wrong: a version bump is a **change-point**,
|
|
70
|
+
not an anomaly. agentnorm reports that it has no history rather than alerting, and names
|
|
71
|
+
which detectors are consequently unavailable.
|
|
72
|
+
|
|
73
|
+
It also refuses to pretend about calibration:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
warning: calibration set has 75 runs but a 0.0020 quantile needs at least 500;
|
|
77
|
+
thresholds fall back to the observed maximum and the true false-positive rate
|
|
78
|
+
will exceed the budget
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Framework adapters
|
|
82
|
+
|
|
83
|
+
For frameworks that report tool calls through callbacks rather than direct invocation:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from agentnorm import Session
|
|
87
|
+
from agentnorm.adapters.langchain import agentnorm_callback
|
|
88
|
+
|
|
89
|
+
session = Session(agent="researcher", version="v2", principal="acme")
|
|
90
|
+
graph.invoke(state, config={"callbacks": [agentnorm_callback(session)]})
|
|
91
|
+
|
|
92
|
+
verdict = monitor.score(session.finish())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Works with LangChain and LangGraph. **agentnorm still does not depend on either** - the base
|
|
96
|
+
class is imported lazily and the handler works as a plain object without it, because
|
|
97
|
+
LangChain duck-types handlers in the paths that matter. That is deliberate: a monitoring
|
|
98
|
+
library that drags in an agent framework is unusable by anyone running a different one,
|
|
99
|
+
and comparing agents across frameworks on equal footing is half the point.
|
|
100
|
+
|
|
101
|
+
The adapter is tested with LangChain deliberately *not* installed - the callback contract
|
|
102
|
+
is replayed instead - so the zero-dependency guarantee stays testable in CI.
|
|
103
|
+
|
|
104
|
+
Two behaviours worth knowing, because callback streams are messier than they look:
|
|
105
|
+
|
|
106
|
+
- **Concurrent tools are matched by the framework's `run_id`.** LangGraph runs tools in
|
|
107
|
+
parallel, so starts and ends interleave and pairing them by order is wrong.
|
|
108
|
+
- **A call the framework never ends is recorded as failed, not discarded.** An agent
|
|
109
|
+
killed mid-tool leaves an open call, and a run that ends inside a tool is itself a
|
|
110
|
+
signal. Conversely an end with no start - the handler attached mid-run - is dropped,
|
|
111
|
+
because a call with no beginning has no duration and no arguments, and inventing them
|
|
112
|
+
would corrupt the baseline it feeds.
|
|
113
|
+
|
|
114
|
+
## Persistence
|
|
115
|
+
|
|
116
|
+
`Monitor.fit` needs history, so a monitor that cannot remember is useless in practice.
|
|
117
|
+
`JsonlStore` is the smallest thing that solves it: append-only JSON Lines, standard
|
|
118
|
+
library only. Append-only is deliberate - behavioural history is evidence, and a store
|
|
119
|
+
that can be rewritten in place is worth much less during an investigation. A truncated
|
|
120
|
+
final line from an interrupted write costs one run, not the file.
|
|
121
|
+
|
|
122
|
+
`Store` is a protocol, so ClickHouse or Postgres is a drop-in. The reference deployment
|
|
123
|
+
uses ClickHouse.
|
|
124
|
+
|
|
125
|
+
## Why this exists
|
|
126
|
+
|
|
127
|
+
Agents are unvalidated models running in production. We have good tooling for grading
|
|
128
|
+
their outputs before release and almost none for answering the question that matters after
|
|
129
|
+
release: **did this run behave like the others?**
|
|
130
|
+
|
|
131
|
+
That question is closer to fraud detection than to evaluation. It is about the shape of an
|
|
132
|
+
episode — which tools, in what order, touching whose data, returning how much — not about
|
|
133
|
+
whether an answer was good.
|
|
134
|
+
|
|
135
|
+
Five detectors, each owning one failure mode, deliberately not fused into a single score.
|
|
136
|
+
A fused score says something is wrong without saying what, and "what" decides whether you
|
|
137
|
+
page someone, revoke a credential, or ignore it.
|
|
138
|
+
|
|
139
|
+
| Detector | Catches |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `volume` | an agent returning far more data than usual — exfiltration-shaped |
|
|
142
|
+
| `sequence` | a path no planner would take, where every individual call is legitimate |
|
|
143
|
+
| `scope` | a run entitled to one principal touching another's resources |
|
|
144
|
+
| `novel_tool` | reaching a tool this agent has never used |
|
|
145
|
+
| `rate` | an order-of-magnitude change in calls per run |
|
|
146
|
+
|
|
147
|
+
## The hard part: cold start
|
|
148
|
+
|
|
149
|
+
Agent versions change weekly. A detector meeting an entity it has never seen is not an
|
|
150
|
+
edge case — it is the **normal operating condition**, and it is where naive monitoring
|
|
151
|
+
falls apart.
|
|
152
|
+
|
|
153
|
+
Measured on a live deployment: a suite fitted on one population and scored against a real
|
|
154
|
+
agent it had not seen alerted on **100% of known-benign runs**. Every tool looked novel,
|
|
155
|
+
because the *agent* was novel. The detector was reporting "I have not met you" on every
|
|
156
|
+
run, forever.
|
|
157
|
+
|
|
158
|
+
The per-detector breakdown is the useful part:
|
|
159
|
+
|
|
160
|
+
| Detector | Alert rate on benign runs from an unseen agent | After |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| volume (hierarchical) | **0.000** | 0.000 |
|
|
163
|
+
| sequence | 0.000 | 0.000 |
|
|
164
|
+
| scope | 0.000 | 0.000 |
|
|
165
|
+
| novel_tool | **1.000** | 0.000 |
|
|
166
|
+
| rate | **0.536** | suppressed |
|
|
167
|
+
| **any** | **1.000** | **0.000** |
|
|
168
|
+
|
|
169
|
+
The hierarchical model transferred untouched; the set-membership ones had no cold-start
|
|
170
|
+
behaviour at all. Detection was unchanged after the fix — union recall stayed at 1.000
|
|
171
|
+
across five attack families.
|
|
172
|
+
|
|
173
|
+
So every detector in agentnorm answers "what do I do about an entity I have not observed?"
|
|
174
|
+
explicitly:
|
|
175
|
+
|
|
176
|
+
- **pool** where the quantity is comparable across agents (`volume`, `sequence`,
|
|
177
|
+
`novel_tool`) — shrink toward the population rather than treating the newcomer as alien
|
|
178
|
+
- **assert** where no history is needed (`scope`) — entitlement is checked, not learned
|
|
179
|
+
- **suppress** where pooling would be wrong (`rate`) — run length is not comparable between
|
|
180
|
+
a triage agent making three calls and a reporting agent making forty, so agentnorm reports
|
|
181
|
+
*not yet calibrated* instead of guessing
|
|
182
|
+
|
|
183
|
+
## Thresholds you can act on
|
|
184
|
+
|
|
185
|
+
Thresholds come from a **false-positive budget**, not from maximising a statistic. An
|
|
186
|
+
operator can act on "this fires once per two hundred clean runs". Nobody can act on "this
|
|
187
|
+
maximises F1".
|
|
188
|
+
|
|
189
|
+
The budget is stated for the suite and divided across detectors, because five detectors
|
|
190
|
+
each firing on 1% of runs union to about 5% — a suite advertised at 1% that delivers 5%
|
|
191
|
+
gets muted within a week, and a muted detector detects nothing.
|
|
192
|
+
|
|
193
|
+
agentnorm also refuses to pretend about calibration. Asked for a 0.2% quantile from 40 runs,
|
|
194
|
+
it fits, and warns:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
calibration set has 40 runs but a 0.0020 quantile needs at least 500;
|
|
198
|
+
thresholds fall back to the observed maximum and the true false-positive
|
|
199
|
+
rate will exceed the budget
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Design choices worth knowing
|
|
203
|
+
|
|
204
|
+
**The unit is a run, not a span.** Scope escalation, abnormal paths and volume anomalies
|
|
205
|
+
are properties of a whole episode.
|
|
206
|
+
|
|
207
|
+
**`version` is a change-point, not a label.** Changing a prompt, model or tool grant
|
|
208
|
+
changes the behavioural distribution. Baselines key on `agent@version`, so a deployment
|
|
209
|
+
resets the baseline instead of triggering an alert storm.
|
|
210
|
+
|
|
211
|
+
**Human and agent traces are never pooled.** Their tool-use distributions are nothing
|
|
212
|
+
alike, and pooling would poison the priors. `actor_kind` exists for this.
|
|
213
|
+
|
|
214
|
+
**Failed calls are recorded, then re-raised.** A trace that omits failures hides exactly
|
|
215
|
+
the behaviour worth detecting — an agent probing for a tool it lacks, or retrying a
|
|
216
|
+
refused action.
|
|
217
|
+
|
|
218
|
+
**Attribute names follow OpenTelemetry's GenAI semantic conventions** where they exist, so
|
|
219
|
+
traces can be exported rather than trapped.
|
|
220
|
+
|
|
221
|
+
## Measuring your own detectors
|
|
222
|
+
|
|
223
|
+
`agentnorm.evaluation` scores a suite against your labelled runs and sweeps the settings that
|
|
224
|
+
were chosen by judgement rather than derived:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from agentnorm.evaluation import sensitivity, format_report
|
|
228
|
+
|
|
229
|
+
print(format_report(sensitivity(benign_runs, labelled_attacks)))
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Run against the reference deployment's 4,000 benign and 200 labelled anomalous runs, recall
|
|
233
|
+
was **1.000 at every setting** — across a 64-fold range of prior strength, a 10-fold range
|
|
234
|
+
of false-positive budget, and three calibration splits. No hand-picked constant is
|
|
235
|
+
load-bearing, and the false-positive rate tracks the budget while staying below it, so the
|
|
236
|
+
per-detector budget division is conservative rather than optimistic.
|
|
237
|
+
|
|
238
|
+
That result cuts both ways, and the second half matters more. Recall that cannot be moved
|
|
239
|
+
by any setting also means the generated attacks sit nowhere near the decision boundary: the
|
|
240
|
+
test is easy, which is evidence for the ceiling caveat below rather than against it.
|
|
241
|
+
|
|
242
|
+
## Alternatives
|
|
243
|
+
|
|
244
|
+
This is a small library in a category that is filling up. Worth knowing before you pick it:
|
|
245
|
+
|
|
246
|
+
| | what it is | pick it over agentnorm when |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| [AgentOps](https://github.com/AgentOps-AI/agentops) | mature agent monitoring SDK: cost tracking, benchmarking, broad framework coverage | you want the widest framework support and a hosted product behind it |
|
|
249
|
+
| [Agentomaly](https://github.com/sushaan-k/agentomaly) | runtime behavioural anomaly detection over OpenTelemetry, with Slack/PagerDuty/Jaeger wiring | you already run OTel and want alerting plumbed into existing infrastructure |
|
|
250
|
+
| AgentLens | MCP-native observability with an append-only hash-chained audit log | you want a platform rather than a library, and MCP is your primary surface |
|
|
251
|
+
| TRACE | hardware-attested trust records binding model, policy and tool calls into a signed artifact | you need offline third-party verification and can run in a TEE — this is strictly stronger than hash chaining alone |
|
|
252
|
+
|
|
253
|
+
**What agentnorm does differently.** Two things, and they are narrow:
|
|
254
|
+
|
|
255
|
+
**Cold start is handled explicitly.** The others learn a baseline from historical traces —
|
|
256
|
+
Agentomaly's trainer takes `min_traces=100` — and do not say what happens to an agent with no
|
|
257
|
+
history. Measured here: a suite without cold-start handling alerts on **100% of known-benign
|
|
258
|
+
runs** from an unseen agent version. Since agent versions change weekly, that is the normal
|
|
259
|
+
operating condition rather than an edge case. agentnorm decides *per detector* whether a
|
|
260
|
+
quantity may be pooled across identities, asserted without history, or must be suppressed as
|
|
261
|
+
uncalibrated — and reports which.
|
|
262
|
+
|
|
263
|
+
**Zero dependencies.** Enforced in CI by walking the AST, not asserted in prose. It installs
|
|
264
|
+
into a locked-down environment and adds nothing to your dependency tree.
|
|
265
|
+
|
|
266
|
+
**Where it is weaker.** Fewer integrations than AgentOps or Agentomaly. No hosted backend, no
|
|
267
|
+
dashboard, no alert routing. And hash chaining gives integrity, not the offline third-party
|
|
268
|
+
verification TRACE achieves with hardware attestation.
|
|
269
|
+
|
|
270
|
+
## Status
|
|
271
|
+
|
|
272
|
+
Early, and honest about it. The detectors are validated against five labelled attack
|
|
273
|
+
families and against real agent traffic from one deployment. What is not yet true:
|
|
274
|
+
|
|
275
|
+
- the attack families are generated, so recall against them is a ceiling rather than a
|
|
276
|
+
performance claim — a genuinely novel attack will not look like any of the five
|
|
277
|
+
- real-traffic validation is one deployment and tens of runs, not thousands
|
|
278
|
+
- there is no persistence layer; bring your own store
|
|
279
|
+
|
|
280
|
+
## Reference deployment
|
|
281
|
+
|
|
282
|
+
agentnorm was extracted from [Sentinel](../../README.md), an agentic IT-operations platform
|
|
283
|
+
running on a live multi-cloud estate. Sentinel is where these numbers come from, and where
|
|
284
|
+
agentnorm found a real cross-tenant disclosure in Sentinel's own agent — a run scoped to one
|
|
285
|
+
tenant returning another tenant's host and package data, because `tenant` was a parameter
|
|
286
|
+
the model could set freely. A boundary the caller can rewrite is not a boundary.
|
|
287
|
+
|
|
288
|
+
## Licence
|
|
289
|
+
|
|
290
|
+
MIT.
|
|
@@ -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."""
|