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.
@@ -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.
@@ -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."""