aegis-kernel 0.4.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.
Files changed (62) hide show
  1. aegis_kernel-0.4.0/LICENSE +21 -0
  2. aegis_kernel-0.4.0/PKG-INFO +665 -0
  3. aegis_kernel-0.4.0/README.md +630 -0
  4. aegis_kernel-0.4.0/aegis/__init__.py +20 -0
  5. aegis_kernel-0.4.0/aegis/__main__.py +6 -0
  6. aegis_kernel-0.4.0/aegis/adapters/__init__.py +5 -0
  7. aegis_kernel-0.4.0/aegis/adapters/mcp.py +429 -0
  8. aegis_kernel-0.4.0/aegis/adapters/mcp_client.py +344 -0
  9. aegis_kernel-0.4.0/aegis/audit.py +130 -0
  10. aegis_kernel-0.4.0/aegis/conformance/__init__.py +11 -0
  11. aegis_kernel-0.4.0/aegis/conformance/cli.py +386 -0
  12. aegis_kernel-0.4.0/aegis/conformance/drift.py +145 -0
  13. aegis_kernel-0.4.0/aegis/conformance/export.py +213 -0
  14. aegis_kernel-0.4.0/aegis/conformance/fixtures.py +128 -0
  15. aegis_kernel-0.4.0/aegis/conformance/invariants.py +285 -0
  16. aegis_kernel-0.4.0/aegis/conformance/loopholes.py +648 -0
  17. aegis_kernel-0.4.0/aegis/conformance/mcp_checks.py +261 -0
  18. aegis_kernel-0.4.0/aegis/conformance/report.py +169 -0
  19. aegis_kernel-0.4.0/aegis/conformance/report_html.py +184 -0
  20. aegis_kernel-0.4.0/aegis/conformance/runner.py +177 -0
  21. aegis_kernel-0.4.0/aegis/conformance/scaffold.py +45 -0
  22. aegis_kernel-0.4.0/aegis/conformance/spec.py +95 -0
  23. aegis_kernel-0.4.0/aegis/constitution.py +226 -0
  24. aegis_kernel-0.4.0/aegis/constitution.yaml +69 -0
  25. aegis_kernel-0.4.0/aegis/corpus/payloads.yaml +110 -0
  26. aegis_kernel-0.4.0/aegis/decision.py +67 -0
  27. aegis_kernel-0.4.0/aegis/grant.py +220 -0
  28. aegis_kernel-0.4.0/aegis/guards/__init__.py +14 -0
  29. aegis_kernel-0.4.0/aegis/guards/base.py +36 -0
  30. aegis_kernel-0.4.0/aegis/guards/budget.py +19 -0
  31. aegis_kernel-0.4.0/aegis/guards/capability.py +47 -0
  32. aegis_kernel-0.4.0/aegis/guards/data.py +216 -0
  33. aegis_kernel-0.4.0/aegis/guards/spawn.py +51 -0
  34. aegis_kernel-0.4.0/aegis/kernel.py +272 -0
  35. aegis_kernel-0.4.0/aegis/observe.py +82 -0
  36. aegis_kernel-0.4.0/aegis/policy.py +360 -0
  37. aegis_kernel-0.4.0/aegis/py.typed +0 -0
  38. aegis_kernel-0.4.0/aegis/registry.py +69 -0
  39. aegis_kernel-0.4.0/aegis/runtime.py +106 -0
  40. aegis_kernel-0.4.0/aegis/templates/baseline.yaml +68 -0
  41. aegis_kernel-0.4.0/aegis/templates/policy.yaml +67 -0
  42. aegis_kernel-0.4.0/aegis/templates/restricted.yaml +28 -0
  43. aegis_kernel-0.4.0/aegis/templates/suite.yaml +204 -0
  44. aegis_kernel-0.4.0/aegis/templates/workflow.yml +47 -0
  45. aegis_kernel-0.4.0/aegis_kernel.egg-info/PKG-INFO +665 -0
  46. aegis_kernel-0.4.0/aegis_kernel.egg-info/SOURCES.txt +60 -0
  47. aegis_kernel-0.4.0/aegis_kernel.egg-info/dependency_links.txt +1 -0
  48. aegis_kernel-0.4.0/aegis_kernel.egg-info/entry_points.txt +2 -0
  49. aegis_kernel-0.4.0/aegis_kernel.egg-info/requires.txt +6 -0
  50. aegis_kernel-0.4.0/aegis_kernel.egg-info/top_level.txt +1 -0
  51. aegis_kernel-0.4.0/pyproject.toml +67 -0
  52. aegis_kernel-0.4.0/setup.cfg +4 -0
  53. aegis_kernel-0.4.0/tests/test_async.py +287 -0
  54. aegis_kernel-0.4.0/tests/test_cli.py +183 -0
  55. aegis_kernel-0.4.0/tests/test_confidence.py +156 -0
  56. aegis_kernel-0.4.0/tests/test_conformance.py +238 -0
  57. aegis_kernel-0.4.0/tests/test_corpus.py +147 -0
  58. aegis_kernel-0.4.0/tests/test_examples.py +46 -0
  59. aegis_kernel-0.4.0/tests/test_governance.py +193 -0
  60. aegis_kernel-0.4.0/tests/test_mcp_audit.py +353 -0
  61. aegis_kernel-0.4.0/tests/test_mcp_live.py +204 -0
  62. aegis_kernel-0.4.0/tests/test_observe.py +159 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aegis contributors
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,665 @@
1
+ Metadata-Version: 2.4
2
+ Name: aegis-kernel
3
+ Version: 0.4.0
4
+ Summary: Capability-based constraint enforcement for AI agents, with a regression suite that hunts its own loopholes and an MCP server auditor.
5
+ Author: Aegis contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Aditya31398/aegis
8
+ Project-URL: Documentation, https://github.com/Aditya31398/aegis#readme
9
+ Project-URL: Issues, https://github.com/Aditya31398/aegis/issues
10
+ Project-URL: Changelog, https://github.com/Aditya31398/aegis/blob/main/CHANGELOG.md
11
+ Project-URL: Security, https://github.com/Aditya31398/aegis/security/policy
12
+ Keywords: ai-agents,mcp,model-context-protocol,security,guardrails,policy,authorization,llm-security,sarif
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: PyYAML>=6.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.4; extra == "dev"
32
+ Requires-Dist: ruff>=0.6; extra == "dev"
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # Aegis — constraint enforcement for agent systems
37
+
38
+ [![CI](https://github.com/Aditya31398/aegis/actions/workflows/ci.yml/badge.svg)](https://github.com/Aditya31398/aegis/actions/workflows/ci.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/aegis-kernel)](https://pypi.org/project/aegis-kernel/)
40
+ [![Python](https://img.shields.io/pypi/pyversions/aegis-kernel)](https://pypi.org/project/aegis-kernel/)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
42
+
43
+ Aegis does two jobs:
44
+
45
+ 1. **Runtime enforcement.** A kernel mediates every tool call an agent makes
46
+ against a declarative YAML policy: capability allowlists, argument
47
+ constraints, spend and time budgets, PII/taint egress rules, and bounded
48
+ agent spawning. Denied calls never execute.
49
+ 2. **Regression and audit.** A conformance framework proves the constraints
50
+ still hold after a change, detects when a policy is quietly weakened, hunts
51
+ for loopholes in your own policies, and audits third-party MCP servers.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install aegis-kernel # Python 3.10+, one dependency (PyYAML)
57
+ aegis --version
58
+ ```
59
+
60
+ Or run the container, no Python needed:
61
+
62
+ ```bash
63
+ docker run --rm -v "$PWD:/work" ghcr.io/aditya31398/aegis --help
64
+ ```
65
+
66
+ ## Quickstart: audit an MCP server
67
+
68
+ ```bash
69
+ # a live server (only initialize + tools/list are ever sent; no tool is called)
70
+ aegis mcp --server https://mcp.example.com/mcp --bearer-env MCP_TOKEN --out audit-out
71
+
72
+ # or a saved manifest / claude_desktop_config.json
73
+ aegis mcp --manifest claude_desktop_config.json --out audit-out
74
+ ```
75
+
76
+ `audit-out/` gets a Markdown report, `audit.json`, `audit.sarif`, and a
77
+ `hardened-policy.yaml` you can adopt.
78
+
79
+ ## Quickstart: enforce a policy on your own agents
80
+
81
+ ```bash
82
+ aegis init . --ci # policy, adversarial scenarios, baseline, GitHub workflow
83
+ aegis ratify --policy policies/base.yaml
84
+ aegis verify --suites suites --policy policies/base.yaml --require-coverage
85
+ aegis audit --policy policies/base.yaml
86
+ ```
87
+
88
+ The scaffold passes every check out of the box, so the first failure you see
89
+ is one you caused. Then, in code:
90
+
91
+ ```python
92
+ from aegis import Agent, PolicyViolation, ToolRegistry, build_kernel, load_policy
93
+
94
+ registry = ToolRegistry()
95
+
96
+ @registry.tool("fs.read", effects={"read"}, classification="internal")
97
+ def read_file(path: str) -> str:
98
+ return open(path).read()
99
+
100
+ kernel, root = build_kernel(load_policy("quickstart-policy.yaml"), registry)
101
+ agent = Agent(root, kernel)
102
+
103
+ agent.tools.fs__read(path="/workspace/notes.md") # allowed
104
+ agent.tools.fs__read(path="/etc/passwd") # PolicyViolation: never executed
105
+ await agent.atools.fs__read(path="/workspace/a.md") # async runtimes too
106
+ ```
107
+
108
+ Runnable version: [`examples/quickstart.py`](examples/quickstart.py) with its
109
+ one-tool [policy](examples/quickstart-policy.yaml). `build_kernel` refuses to
110
+ start an unconstitutional policy — including one that grants a tool you never
111
+ registered — and every decision, allowed or denied, lands in a hash-chained
112
+ audit log (`kernel.audit`).
113
+
114
+ ## Using it in CI
115
+
116
+ **GitHub Actions** — pin to a release tag:
117
+
118
+ ```yaml
119
+ permissions:
120
+ contents: read
121
+ security-events: write # only if upload-sarif is on
122
+
123
+ steps:
124
+ - uses: actions/checkout@v7
125
+ - uses: Aditya31398/aegis@v0.4.0
126
+ with:
127
+ manifest: mcp-servers.json # and/or server: https://…/mcp
128
+ baseline: aegis-baseline.yaml
129
+ fail-on: high
130
+ upload-sarif: "true" # findings appear in the Security tab
131
+ comment-on-pr: "true"
132
+ ```
133
+
134
+ **Anywhere else** — `aegis` is a normal CLI with a stable contract:
135
+
136
+ | | |
137
+ |---|---|
138
+ | Exit codes | `0` pass · `1` findings/violations at or above `--fail-on` · `2` bad input or usage · `3` internal error |
139
+ | Formats | `--format text\|json\|sarif`, `--output FILE` (text stays on stdout for the log) |
140
+ | JSON schema | `aegis.audit/v1`; fields are only ever added |
141
+ | Fingerprints | stable per finding; SARIF `partialFingerprints` so dashboards dedupe across runs |
142
+ | Accepted risk | baselined findings stay in the output as SARIF *suppressions* with the written reason |
143
+
144
+ A broken policy file is always exit `2`, never `1`, so a pipeline can tell
145
+ "your policy has holes" from "your policy file is malformed".
146
+
147
+ ```bash
148
+ aegis ratify --policy policies/base.yaml
149
+ aegis verify --suites suites --policy policies/base.yaml --require-coverage
150
+ aegis fuzz --policy policies/base.yaml --iterations 20 --async
151
+ aegis audit --policy policies/base.yaml --format sarif --output aegis.sarif
152
+ aegis drift --baseline main-base.yaml --candidate policies/base.yaml
153
+ ```
154
+
155
+ ## Observability: pairing with an APM
156
+
157
+ Aegis decides what an agent *may* do; it deliberately ships no dashboard. Two hooks
158
+ let an observability tool see every decision without being able to influence one:
159
+
160
+ ```python
161
+ from aegis.observe import register_context_provider
162
+
163
+ register_context_provider(lambda: {"run_id": current_run_id()}) # -> details.ctx on every record
164
+ kernel.audit.subscribe(lambda rec: ship(rec)) # after the record is chained
165
+ ```
166
+
167
+ Model calls are not tools, but they spend the same budget. Reserve before the
168
+ request and settle after it, and the ledger becomes a hard gate on model spend:
169
+
170
+ ```python
171
+ r = kernel.reserve_spend(grant, usd=estimate, tokens=max_tokens) # BudgetExhausted -> call never made
172
+ kernel.settle_spend(r, usd=actual_cost, tokens=actual_tokens)
173
+ ```
174
+
175
+ [AgentDynamics](https://github.com/Aditya31398/agentdynamics) uses exactly these
176
+ hooks (`agentdynamics.integrations.aegis`): denials land in the task they happened
177
+ in, model calls are gated by the Aegis budget, a watchdog revokes grants that keep
178
+ probing a boundary, and observed behaviour is turned back into a tighter policy
179
+ that `aegis ratify` and `aegis drift` verify.
180
+
181
+ ## Supply chain
182
+
183
+ Releases are built once in CI from a tag, published to PyPI through trusted
184
+ publishing (no long-lived token exists), and carry signed build provenance:
185
+
186
+ ```bash
187
+ gh attestation verify aegis_kernel-0.4.0-py3-none-any.whl --repo Aditya31398/aegis
188
+ gh attestation verify oci://ghcr.io/aditya31398/aegis:0.4.0 --repo Aditya31398/aegis
189
+ ```
190
+
191
+ The container runs as a non-root user and ships an SBOM. The kernel has one
192
+ runtime dependency (PyYAML) and is small enough to vendor.
193
+
194
+ ---
195
+
196
+ The rest of this document explains how it works and why it is built this way.
197
+
198
+ ## The load-bearing idea
199
+
200
+ **Prompt rules are advisory. Only a mediating kernel is enforceable.**
201
+
202
+ An agent never receives a callable. It receives a `ToolProxy` bound to
203
+ `(kernel, grant, tool_name)`. The only code path from an agent to a real
204
+ implementation runs through `Kernel.invoke`, which runs the guard chain first.
205
+ `tests/test_conformance.py::test_kernel_is_the_only_execution_path` parses the
206
+ AST of every file in `aegis/` and fails the build if a second call site to a
207
+ tool implementation ever appears.
208
+
209
+ The same holds for async runtimes. `await kernel.ainvoke(grant, tool, **args)`
210
+ (or `await agent.atools.fs__read(path=...)`) goes through the identical guard
211
+ chain, ledger and audit log; coroutine tools are awaited, blocking tools run
212
+ off the event loop, and calling a coroutine tool through the sync `invoke` is
213
+ denied as `kernel.async_tool_requires_ainvoke` rather than leaking an
214
+ unmediated awaitable.
215
+
216
+ So the guarantee is scoped honestly: **an agent can attempt anything; it cannot
217
+ *cause* anything outside its grant.** No framework can stop a model from
218
+ generating a bad tool call. This one stops the call from executing.
219
+
220
+ ```
221
+ Agent ──> ToolProxy ──> Kernel.invoke
222
+
223
+ ├─ guards (fail-closed, first DENY wins)
224
+ │ capability → spawn → budget → data
225
+ ├─ budget charge (reserved before execution)
226
+ ├─ _execute / _aexecute ← THE ONLY CALL SITES
227
+ ├─ post-guards (classification ceiling, taint)
228
+ └─ audit record (hash-chained)
229
+ ```
230
+
231
+ ## Grants and attenuation
232
+
233
+ A `Grant` is the only thing that authorises an effect. It can only ever be
234
+ *attenuated*. `Grant.attenuate()` raises on any request for authority the
235
+ parent doesn't hold, so "a spawned agent can never exceed its parent" is a
236
+ structural property, not a convention:
237
+
238
+ ```
239
+ child.tools ⊆ parent.tools ∩ policy.spawn.allow_tools
240
+ child.budget ≤ parent.remaining × min(requested, child_budget_fraction)
241
+ child.depth = parent.depth + 1 ≤ max_depth
242
+ ```
243
+
244
+ Budgets are **hierarchical** — a child's spend debits every ancestor's ledger,
245
+ so N children cannot collectively outspend the root even though each is
246
+ individually within its own limit.
247
+
248
+ Revocation is total: revoking a grant disables its entire subtree immediately.
249
+
250
+ ## The four constraint classes
251
+
252
+ | Class | Mechanism | Example rule ids |
253
+ |---|---|---|
254
+ | Tool / side effect | default-deny allowlist + per-argument regex, prefix, enum, length, forbidden-pattern | `capability.not_granted`, `capability.arg_prefix`, `capability.unexpected_arg` |
255
+ | Spend / time | hierarchical ledger, pre-flight admission | `budget.usd_exceeded`, `budget.tool_calls_exceeded`, `budget.deadline_exceeded` |
256
+ | Data / PII egress | classification ceiling on reads, taint propagation, PII scan at declared sinks | `data.classification_exceeded`, `data.taint_egress_blocked`, `data.pii_egress_blocked` |
257
+ | Spawning | depth, fan-out, whole-tree descendant cap, delegable-tool list | `spawn.max_depth_exceeded`, `spawn.max_fanout_exceeded`, `spawn.privilege_escalation` |
258
+
259
+ Rule ids are the stable contract. Tests pin the id, never the prose, so
260
+ reworded messages don't break the suite but a rule that stops firing does.
261
+
262
+ ## Policy
263
+
264
+ Policies are YAML data, never code — so they can be diffed, signed, pinned, and
265
+ cannot be authored at runtime by an agent. `extends:` derives a tighter profile;
266
+ the loader rejects a derived policy that raises a budget.
267
+
268
+ ```yaml
269
+ tools:
270
+ allow:
271
+ - name: db.query
272
+ require_args: [sql]
273
+ args:
274
+ sql:
275
+ matches: "(?is)^\\s*select\\b.*"
276
+ forbid_matches: "(?i)\\b(drop|delete|update|insert|alter)\\b"
277
+ spawn:
278
+ max_depth: 2
279
+ max_fanout: 3
280
+ child_budget_fraction: 0.4
281
+ allow_tools: [kb.search, fs.read, http.get, db.query, agent.spawn]
282
+ ```
283
+
284
+ ---
285
+
286
+ # The regression framework
287
+
288
+ Four independent layers. All four are required CI checks.
289
+
290
+ ### 1. Scenario conformance (`suites/*.yaml`)
291
+
292
+ Adversarial cases — path traversal, SSRF, SQL mutation, argument smuggling, PII
293
+ exfiltration, spawn bombs, privilege escalation, budget exhaustion. Each step
294
+ pins the expected verdict *and* the rule id.
295
+
296
+ Each denial asserts the **strong** form: the tool implementation was never
297
+ entered. Fixtures record every execution, so a test proves "nothing happened",
298
+ not merely "an error was returned".
299
+
300
+ ```yaml
301
+ - id: deny-path-traversal
302
+ steps:
303
+ - invoke: {tool: fs.read, args: {path: "/etc/passwd"}}
304
+ expect: deny
305
+ rule: capability.arg_prefix
306
+ ```
307
+
308
+ ### Severity is not confidence
309
+
310
+ Severity answers "how bad if real". Confidence answers "how sure", and the two
311
+ come apart constantly — an architectural risk can be high severity and low
312
+ confidence at the same time. Every finding carries both:
313
+
314
+ | Confidence | Evidence behind it |
315
+ |---|---|
316
+ | `confirmed` | the input went through the real guard chain and was admitted, or the schema/description literally says so |
317
+ | `likely` | two independent signals, or one fact plus an inference step that could be wrong (inferred effects, for instance) |
318
+ | `possible` | a structural pattern that depends on context this audit cannot see |
319
+
320
+ Confidence never hides anything. It changes presentation, and it gates what
321
+ may *fail* a build:
322
+
323
+ ```
324
+ aegis audit --policy policies/base.yaml --fail-on high --min-confidence confirmed
325
+ ```
326
+
327
+ That reports every finding exactly as before and fails only on the ones we can
328
+ prove. SARIF keeps the axes apart too: `security-severity` for how bad, `rank`
329
+ for how sure, so a dashboard can sort on either. Confidence is deliberately not
330
+ part of a fingerprint — re-grading a check must never renumber a baseline.
331
+
332
+ ### The payload corpus is data
333
+
334
+ The payloads the probe engine pushes through the guard chain live in
335
+ `aegis/corpus/payloads.yaml` (`schema: aegis.corpus/v1`, with a `version`),
336
+ not in the code. A newer corpus drops in without upgrading the package:
337
+
338
+ ```
339
+ aegis audit --policy policies/base.yaml --corpus corpus-2026-09.yaml
340
+ AEGIS_CORPUS=corpus-2026-09.yaml aegis mcp --manifest servers.json --out out
341
+ ```
342
+
343
+ Which corpus produced a run is recorded in `audit.json` under
344
+ `tool.corpus_version`. A corpus that fails validation is a usage error (exit
345
+ `2`), never a quiet pass with fewer probes — a silently-skipped payload looks
346
+ exactly like a clean audit.
347
+
348
+ The property that makes refreshes safe: a payload finding is fingerprinted on
349
+ the **(tool, argument) pair it reached**, not on the payload string that
350
+ happened to arrive first. So adding payloads can reveal a new hole, and can
351
+ never invalidate an accepted entry in someone's baseline.
352
+ `test_adding_payloads_never_changes_existing_fingerprints` inserts a payload at
353
+ the front of every kind — the worst case for witness selection — and asserts
354
+ every prior fingerprint survives.
355
+
356
+ ### 2. Invariants under fuzz (`aegis/conformance/invariants.py`)
357
+
358
+ A random workload generator drives the kernel with thousands of arbitrary
359
+ call/spawn/revoke sequences, half hostile payloads and half well-formed calls
360
+ the policy admits. Seven invariants are re-checked after *every* operation:
361
+
362
+ `attenuation` · `depth_bound` · `budget_conservation` · `no_effect_on_deny` ·
363
+ `every_effect_was_charged` · `audit_chain` · `revocation_is_total`
364
+
365
+ `fuzz --async` drives the same invariants through `ainvoke`/`aspawn`: each
366
+ round launches a batch concurrently, cancels some calls mid-flight, and a
367
+ watcher task re-checks every invariant at each scheduling point, so the kernel
368
+ is observed *during* calls, not only between them. Its negative control is a
369
+ planted kernel that checks the budget, awaits the tool, then charges — a
370
+ check-then-act race the fuzzer must catch.
371
+
372
+ Writing that control exposed a weakness in the fuzzer itself: the workload was
373
+ almost entirely hostile, so nearly every call died at the guards, budgets were
374
+ never exhausted, and the budget invariants were green because the ledger barely
375
+ moved. The planted race was caught on 1 seed in 4. Adding well-formed calls
376
+ raised that to 4 in 6, and `test_fuzz_workload_reaches_budget_exhaustion` now
377
+ fails if the workload ever stops reaching an exhausted budget.
378
+
379
+ Any exception that isn't a `PolicyViolation` is a framework bug and fails the
380
+ run. This layer found a real one during development: `agent.spawn` routed
381
+ through the tool path crashed rather than denying.
382
+
383
+ ### 3. Privilege drift (`aegis/conformance/drift.py`)
384
+
385
+ The subtle regression isn't broken enforcement — the suite catches that. It's
386
+ someone quietly *loosening the policy*, after which every test still passes
387
+ because the tests now agree with the weaker rules.
388
+
389
+ So the policy is diffed against the pinned baseline and any widening fails the
390
+ build unless explicitly waived by code:
391
+
392
+ ```
393
+ + [tools.constraint_relaxed] fs.read.path: prefix '/workspace/' -> '/'
394
+ + [budget.usd_raised] usd 5.0 -> 50.0
395
+ + [spawn.max_depth_raised] max_depth 2 -> 6
396
+ RESULT: FAIL — policy widens agent authority.
397
+ ```
398
+
399
+ The differ is deliberately conservative: a regex change it can't *prove* is a
400
+ tightening counts as widening.
401
+
402
+ ### 4. Structural and fail-closed checks
403
+
404
+ - AST check that the kernel remains the sole execution path
405
+ - crashing guard → `guard.internal_error` DENY, never an allow
406
+ - policy-allowed but unregistered tool → `registry.unknown_tool` DENY
407
+ - tampering with an audit record breaks `verify()`
408
+ - a sibling swarm with `budget_fraction: 1.0` each still can't outspend the root
409
+ - coverage: every granted tool must be exercised by some scenario
410
+
411
+ ## Adding a tool — the checklist
412
+
413
+ 1. `registry.register(...)` with its `effects`, `classification`, `cost_usd`
414
+ 2. add it to `policies/base.yaml` with the *tightest* argument constraints
415
+ 3. decide whether it is an egress sink and whether it is delegable to children
416
+ 4. write at least one allow case and one deny case per constraint in `suites/`
417
+ 5. run `drift` — the new grant shows as `tools.added`, waive it in the PR
418
+
419
+ Step 5 is the point: widening authority is always a deliberate, reviewed act.
420
+
421
+ ## What this does not do
422
+
423
+ - It can't stop the model from *trying*. It stops attempts from having effects.
424
+ - Constraints are only as good as the policy. A tool registered with loose
425
+ regexes is a hole the kernel will faithfully honour.
426
+ - The PII scanner is a backstop, not the primary control — the primary control
427
+ is that the agent never holds the raw callable.
428
+ - Prompt injection is out of scope as an *input* problem; it is in scope as an
429
+ *effect* problem, since an injected instruction still has to pass the guards.
430
+
431
+ ---
432
+
433
+ # Law, not just locks
434
+
435
+ The framework is layered the way a legal system is, because the failure modes
436
+ are the same ones legal systems evolved to handle.
437
+
438
+ | Layer | File | Amended by | Waivable? |
439
+ |---|---|---|---|
440
+ | **Constitution** | `aegis/constitution.yaml` | editing the document | **no** — no flag, no config, no override |
441
+ | **Statute** | `policies/*.yaml` | a PR that widens | yes, `--waive <code>` with review |
442
+ | **Case law** | `suites/*.yaml` | adding scenarios | n/a — precedents accumulate |
443
+ | **Accepted holes** | `loopholes.baseline.yaml` | adding a fingerprint | yes, with a written reason and an owner |
444
+
445
+ ### Constitution
446
+
447
+ Seven clauses that every policy must satisfy to be **ratified**. `build_kernel`
448
+ ratifies before it returns, so an unconstitutional policy cannot start:
449
+
450
+ ```
451
+ C1 Bounded authority — no budget axis may be unbounded
452
+ C2 Every egress path screened — an outward-capable tool must be a declared sink
453
+ C3 No unconstrained argument — effectful args carry at least one constraint
454
+ C4 Delegation attenuates — child fraction < 1.0, depth bounded
455
+ C5 Reading is not exporting — egress ceiling strictly below read ceiling
456
+ C6 Rules must be enforceable — every named PII kind has a working detector
457
+ C7 No phantom grants — a granted tool must actually exist
458
+ ```
459
+
460
+ There is deliberately no waiver path. The only route past a clause is to edit
461
+ `aegis/constitution.yaml`, which is a loud diff in review rather than a flag buried
462
+ in a CI invocation. `tests/test_governance.py` asserts every clause can
463
+ actually fire — a clause that cannot fail is decoration.
464
+
465
+ ```
466
+ aegis ratify --policy policies/base.yaml
467
+ ```
468
+
469
+ ### Loophole hunting
470
+
471
+ The conformance suite answers *"do the rules I wrote still work?"*. The hunter
472
+ answers *"what gets through that I never thought to test?"* — three techniques:
473
+
474
+ 1. **Static** — structural analysis of policy and registry *together*: rules
475
+ that cannot fire, exits nobody screens, delegation shapes that recombine
476
+ authority, unanchored or wildcard-bearing patterns.
477
+ 2. **Probe** — pushes a corpus of known-dangerous payloads (traversal,
478
+ SSRF, metadata endpoints, host-suffix confusion, file-reading SELECTs,
479
+ encoded PII) through the real guard chain in decision-only mode. Anything
480
+ ALLOWED is reported with the exact witness string.
481
+ 3. **Metamorphic** — takes every step the suite expects to be DENIED, mutates
482
+ the arguments in meaning-preserving ways (case, percent-encoding,
483
+ zero-width joiners, SQL comments, null bytes, doubled separators) and
484
+ re-runs. A mutation that flips DENY to ALLOW is a bypass.
485
+
486
+ ```
487
+ aegis audit --policy policies/base.yaml --fail-on high
488
+ ```
489
+
490
+ **On the first run against the policy shipped in this repo it found 23 holes**
491
+ — 7 critical, 9 high — including:
492
+
493
+ - `SELECT pg_read_file('/etc/passwd')` and `SELECT … INTO OUTFILE` passed the
494
+ read-only SQL guard, because "starts with SELECT and contains no DROP" does
495
+ not mean "cannot write or read files"
496
+ - `SELECT dblink_exec(…)` opened an outbound connection from inside a
497
+ "read-only" tool
498
+ - `/workspace/%2e%2e/etc/passwd` and a null-byte path defeated the traversal check
499
+ - every PII pattern fell to zero-width joiners, homoglyph separators and base64
500
+ - taint does not cross agents, so a reader child's output can reach a sink sibling
501
+
502
+ Fixes: Unicode normalisation (NFKC + invisible-character stripping +
503
+ confusables) and base64 decoding in the scanner, a much broader SQL denylist,
504
+ and encoded-traversal patterns on paths. **23 → 6.** The remaining six are
505
+ architectural, and they live in `loopholes.baseline.yaml` with a written reason
506
+ and an owner rather than being quietly dropped.
507
+
508
+ ### The regression property
509
+
510
+ Findings carry a stable fingerprint. Accepted ones sit in the baseline; anything
511
+ new at high or above fails CI. So **the known-hole set can shrink silently but
512
+ never grow silently** — and a baseline entry whose finding has disappeared also
513
+ fails, so stale acceptances can't hide a hole that was already closed.
514
+
515
+ The three checks catch different directions of failure and none subsumes
516
+ another:
517
+
518
+ - `ratify` — is the policy *structurally* sound? (a constraint exists)
519
+ - `audit` — is the policy *substantively* sound? (the constraint holds)
520
+ - `drift` — is the policy moving in the *wrong direction*? (it got weaker)
521
+
522
+ A weakened `fs.read` prefix still ratifies, still passes every scenario, and is
523
+ caught by `audit` and `drift`. That is the point of having all three.
524
+
525
+ ## Prior art
526
+
527
+ This overlaps with real work; see the chat discussion for the comparison. In
528
+ short: NeMo Guardrails, Guardrails AI and Fiddler sit at the content layer;
529
+ OPA/Rego, Cedar, Oso, Cerbos and OpenFGA are mature policy engines but are not
530
+ tool-call-shaped; AgentSpec (ICSE '26) is the closest academic relative for
531
+ runtime enforcement. The part that is genuinely thin in all of them is the
532
+ *regression* half — adversarial conformance, privilege-drift detection and
533
+ automated loophole discovery. If you adopt an existing engine, port
534
+ `aegis/conformance/` onto it rather than rebuilding it.
535
+
536
+ ---
537
+
538
+ # Auditing somebody else's agents
539
+
540
+ Everything above points at policies you wrote. `aegis/adapters/mcp.py` points
541
+ the same machinery at an MCP server you did not write, which is what makes this
542
+ usable as a service rather than a library.
543
+
544
+ ```
545
+ aegis mcp --manifest server-manifest.json \
546
+ --out audit-out --client "Acme"
547
+ ```
548
+
549
+ Accepts a `tools/list` response, a `claude_desktop_config.json`, or a bundle of
550
+ several servers. Or skip the export and point it at the live server:
551
+
552
+ ```
553
+ aegis mcp --server https://mcp.example.com/mcp --bearer-env MCP_TOKEN --out audit-out
554
+ aegis mcp --server-cmd "npx -y @modelcontextprotocol/server-filesystem /tmp" --out audit-out
555
+ ```
556
+
557
+ The live client performs the real MCP handshake (Streamable HTTP with JSON or
558
+ SSE replies, or stdio) and follows `tools/list` pagination. It is structurally
559
+ incapable of calling a tool: any method other than `initialize`,
560
+ `notifications/initialized` and `tools/list` raises before reaching the wire.
561
+ What it fetched is saved to `audit-out/manifest.json` (env values redacted) so
562
+ the audit can be replayed and baselined offline with identical fingerprints.
563
+
564
+ Auth becomes evidence rather than a guess: a remote server that answers
565
+ `tools/list` with no credential is reported as a **critical** with a witness,
566
+ and a server that demands and receives a token is not flagged at all.
567
+ `--server-cmd` runs the given command on your machine; only use it on servers
568
+ you would run anyway. Three moves:
569
+
570
+ 1. **Ingest** — normalise the manifest.
571
+ 2. **Synthesise** — derive a policy from the declared JSON Schemas. This is
572
+ what the server *currently* permits. Every schema field with no `enum`,
573
+ `pattern` or `maxLength` becomes a door the probe engine walks through.
574
+ 3. **Harden** — emit a tightened policy the customer can adopt.
575
+
576
+ Nothing in the audit executes a tool. The registry is built from inert doubles
577
+ that raise if called.
578
+
579
+ ## MCP-specific checks
580
+
581
+ ### Effects are inferred from the surface, not the name
582
+
583
+ A tool called `sync_workspace` tells you nothing; a `path` argument beside a
584
+ `content` argument tells you it writes files. Effects come from the schema
585
+ shape first (a `url` plus a body is egress; a `command` argument is compute; a
586
+ `confirm` boolean implies something worth braking), then the declared MCP
587
+ annotations, and only then tool-name keywords. `EffectInference.sources`
588
+ records which signal decided, so a severity can be traced back.
589
+
590
+ Annotations (`readOnlyHint`, `destructiveHint`, `openWorldHint`) are claims
591
+ made by the party being audited, so they may only ever *widen* the inferred
592
+ effects. Honouring a narrowing claim would let any server opt out of scrutiny
593
+ by asserting its own innocence — and clients that auto-approve tools marked
594
+ read-only would run it unattended. A read-only claim on a surface that
595
+ demonstrably mutates is reported as `annotation_contradicts_surface`, with
596
+ both signals in the witness.
597
+
598
+ Beyond that: **omnibus tools** (one handler taking a free-form
599
+ string and dispatching many operations), **tool-name shadowing** across
600
+ servers, **description injection** (model-directed imperatives and invisible
601
+ Unicode in tool descriptions, which the model reads verbatim and a reviewer
602
+ does not), **irreversible tools with no `confirm`/`dry_run`**, **plaintext
603
+ credentials** in client config, and **unauthenticated remote transports**.
604
+
605
+ ### On false positives
606
+
607
+ Published YARA-based MCP scanners run around a **78% false-positive rate**,
608
+ because they flag tool descriptions that merely describe normal functionality.
609
+ Two design rules here:
610
+
611
+ - Every check either produces a **reproducing witness** or requires **two
612
+ independent signals** before firing. `test_omnibus_requires_two_signals`
613
+ asserts a well-scoped tool with one free-form argument is *not* flagged.
614
+ - Findings are **consolidated**. Twelve traversal payloads across three
615
+ filesystem tools is not twelve problems, it is three unconstrained path
616
+ arguments. On the sample manifest this takes **101 raw findings down to 32**.
617
+ It also keeps fingerprints stable as the payload corpus grows, which matters
618
+ because the baseline file is keyed on them.
619
+
620
+ ## What a run looks like
621
+
622
+ On `examples/sample_mcp_manifest.json` (3 servers, 8 tools):
623
+
624
+ ```
625
+ 32 findings — 8 critical, 16 high, 8 medium
626
+ critical description_injection helpdesk.escalate "you must always"
627
+ critical payload_admitted analytics.query.sql SELECT pg_read_file('/etc/passwd')
628
+ critical payload_admitted filesystem.*.path /workspace/sub/../../etc/passwd
629
+ high tool_shadowing read_file filesystem, helpdesk
630
+ high omnibus_tool analytics.query.sql "Accepts raw SQL"
631
+ high irreversible_no_brake filesystem.delete_file
632
+ high annotation_contradicts_surface filesystem.sync_workspace readOnlyHint vs schema:path+content
633
+ ```
634
+
635
+ Apply the generated `hardened-policy.yaml` and re-probe: **0 critical, 1 high**
636
+ — and the residual is `taint_laundering`, which is architectural and cannot be
637
+ fixed by a policy file. `test_hardened_policy_closes_the_critical_findings`
638
+ asserts exactly this, so the claim the report makes to a customer is itself
639
+ under regression test.
640
+
641
+ Writing this found a bug in the hardener: its generated SQL denylist covered
642
+ `pg_read_file` and `dblink` but not `pg_shadow`, so `SeLeCt 1 FROM pg_shadow`
643
+ survived hardening.
644
+
645
+ ## Deliverables
646
+
647
+ `audit-out/audit-report.md` — severity summary, surface inventory, every
648
+ finding with where, what, a reproducing input and a fix, then a prioritised
649
+ top-five.
650
+
651
+ `audit-out/audit-report.html` — the same content as one self-contained file:
652
+ no scripts, no remote fonts, no build step. It opens from a mail attachment or
653
+ a shared drive, which is what the person approving the work actually does.
654
+ Every value in it is escaped and the page declares a restrictive CSP, because a
655
+ witness string is an attacker-shaped input by construction and the reviewer
656
+ must be able to read the attack without running it.
657
+
658
+ `audit-out/audit.json` and `audit-out/audit.sarif` — the machine-readable pair.
659
+
660
+ `audit-out/hardened-policy.yaml` — an adoptable policy. Placeholders are
661
+ shouted in capitals on purpose; a generated policy that looks finished is more
662
+ dangerous than one that obviously needs a human.
663
+
664
+ Findings carry stable fingerprints, so `--baseline` plus `--fail-on high` turns
665
+ a one-off audit into a CI gate the customer keeps running after you leave.