aegis-kernel 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. aegis/__init__.py +20 -0
  2. aegis/__main__.py +6 -0
  3. aegis/adapters/__init__.py +5 -0
  4. aegis/adapters/mcp.py +429 -0
  5. aegis/adapters/mcp_client.py +344 -0
  6. aegis/audit.py +130 -0
  7. aegis/conformance/__init__.py +11 -0
  8. aegis/conformance/cli.py +386 -0
  9. aegis/conformance/drift.py +145 -0
  10. aegis/conformance/export.py +213 -0
  11. aegis/conformance/fixtures.py +128 -0
  12. aegis/conformance/invariants.py +285 -0
  13. aegis/conformance/loopholes.py +648 -0
  14. aegis/conformance/mcp_checks.py +261 -0
  15. aegis/conformance/report.py +169 -0
  16. aegis/conformance/report_html.py +184 -0
  17. aegis/conformance/runner.py +177 -0
  18. aegis/conformance/scaffold.py +45 -0
  19. aegis/conformance/spec.py +95 -0
  20. aegis/constitution.py +226 -0
  21. aegis/constitution.yaml +69 -0
  22. aegis/corpus/payloads.yaml +110 -0
  23. aegis/decision.py +67 -0
  24. aegis/grant.py +220 -0
  25. aegis/guards/__init__.py +14 -0
  26. aegis/guards/base.py +36 -0
  27. aegis/guards/budget.py +19 -0
  28. aegis/guards/capability.py +47 -0
  29. aegis/guards/data.py +216 -0
  30. aegis/guards/spawn.py +51 -0
  31. aegis/kernel.py +272 -0
  32. aegis/observe.py +82 -0
  33. aegis/policy.py +360 -0
  34. aegis/py.typed +0 -0
  35. aegis/registry.py +69 -0
  36. aegis/runtime.py +106 -0
  37. aegis/templates/baseline.yaml +68 -0
  38. aegis/templates/policy.yaml +67 -0
  39. aegis/templates/restricted.yaml +28 -0
  40. aegis/templates/suite.yaml +204 -0
  41. aegis/templates/workflow.yml +47 -0
  42. aegis_kernel-0.4.0.dist-info/METADATA +665 -0
  43. aegis_kernel-0.4.0.dist-info/RECORD +47 -0
  44. aegis_kernel-0.4.0.dist-info/WHEEL +5 -0
  45. aegis_kernel-0.4.0.dist-info/entry_points.txt +2 -0
  46. aegis_kernel-0.4.0.dist-info/licenses/LICENSE +21 -0
  47. aegis_kernel-0.4.0.dist-info/top_level.txt +1 -0
aegis/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Aegis -- capability-based constraint enforcement for agent systems."""
2
+ from .audit import AuditLog, AuditRecord
3
+ from .decision import (BudgetExhausted, Classification, Effect, PolicyViolation,
4
+ Verdict)
5
+ from .grant import Budget, BudgetLedger, Grant, SpawnRequest
6
+ from .guards import Call
7
+ from .kernel import Kernel, SpendReservation, build_kernel
8
+ from .policy import Policy, PolicyError, dump_policy, load_policy, parse_policy, policy_digest
9
+ from .registry import ToolRegistry, ToolSpec
10
+ from .runtime import Agent, AsyncToolbox, AsyncToolProxy, Toolbox, ToolProxy
11
+
12
+ __version__ = "0.4.0"
13
+
14
+ __all__ = [
15
+ "Agent", "AsyncToolbox", "AsyncToolProxy", "AuditLog", "AuditRecord", "Budget", "BudgetLedger",
16
+ "BudgetExhausted", "Call", "Classification", "Effect", "Grant", "Kernel",
17
+ "Policy", "PolicyError", "PolicyViolation", "SpawnRequest", "ToolProxy",
18
+ "Toolbox", "ToolRegistry", "ToolSpec", "Verdict", "build_kernel",
19
+ "load_policy", "parse_policy", "dump_policy", "policy_digest", "SpendReservation",
20
+ ]
aegis/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m aegis` is the same as the `aegis` command."""
2
+ import sys
3
+
4
+ from .conformance.cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ from .mcp import (McpServer, McpTool, build_registry, harden, load_servers,
2
+ synthesize_policy, write_hardened)
3
+
4
+ __all__ = ["McpServer", "McpTool", "build_registry", "harden", "load_servers",
5
+ "synthesize_policy", "write_hardened"]
aegis/adapters/mcp.py ADDED
@@ -0,0 +1,429 @@
1
+ """MCP adapter.
2
+
3
+ Turns somebody else's MCP server into something the hunter can point at.
4
+
5
+ The audit product works in three moves:
6
+
7
+ 1. INGEST -- read a tools/list response or a client config and normalise it.
8
+ 2. SYNTHESISE -- derive an Aegis policy from the declared JSON Schemas. This
9
+ is what the server *currently* permits, expressed as policy.
10
+ Every schema field with no constraint becomes an open door
11
+ the probe engine can walk through.
12
+ 3. HARDEN -- emit a tightened policy the customer can actually adopt.
13
+
14
+ That third step is the deliverable. A findings list tells someone they have a
15
+ problem; a policy file they can drop in tells them what to do about it, and is
16
+ the difference between a report and a thing worth paying for.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ from ..decision import Classification, Effect
28
+ from ..policy import ArgConstraint, Budget, DataPolicy, Policy, SpawnPolicy, ToolRule
29
+
30
+
31
+ # ----------------------------------------------------------------------
32
+ # Normalised view of a server
33
+ # ----------------------------------------------------------------------
34
+
35
+ @dataclass(frozen=True)
36
+ class McpTool:
37
+ name: str
38
+ description: str = ""
39
+ input_schema: dict[str, Any] = field(default_factory=dict)
40
+ # MCP tool annotations (readOnlyHint, destructiveHint, ...) as published.
41
+ # Carried through ingest; not yet used for inference.
42
+ annotations: dict[str, Any] = field(default_factory=dict)
43
+
44
+ @property
45
+ def properties(self) -> dict[str, dict]:
46
+ return self.input_schema.get("properties") or {}
47
+
48
+ @property
49
+ def required(self) -> tuple[str, ...]:
50
+ return tuple(self.input_schema.get("required") or ())
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class McpServer:
55
+ name: str
56
+ tools: tuple[McpTool, ...] = ()
57
+ env: dict[str, str] = field(default_factory=dict)
58
+ command: str = ""
59
+ args: tuple[str, ...] = ()
60
+ transport: str = "stdio"
61
+ # Set only by live ingest (adapters/mcp_client.py).
62
+ live: bool = False
63
+ answered_without_credentials: bool = False
64
+
65
+
66
+ def load_servers(path: str | Path) -> list[McpServer]:
67
+ """Accepts a tools/list response, a client config, or an audit bundle."""
68
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
69
+
70
+ if "mcpServers" in raw: # claude_desktop_config.json style
71
+ return [
72
+ McpServer(
73
+ name=name,
74
+ tools=tuple(_tool(t) for t in (cfg.get("tools") or [])),
75
+ env=dict(cfg.get("env") or {}),
76
+ command=cfg.get("command", ""),
77
+ args=tuple(cfg.get("args") or ()),
78
+ transport=cfg.get("transport", "stdio"),
79
+ )
80
+ for name, cfg in raw["mcpServers"].items()
81
+ ]
82
+
83
+ if "servers" in raw: # audit bundle
84
+ return [
85
+ McpServer(
86
+ name=s.get("name", "server"),
87
+ tools=tuple(_tool(t) for t in (s.get("tools") or [])),
88
+ env=dict(s.get("env") or {}),
89
+ command=s.get("command", ""),
90
+ args=tuple(s.get("args") or ()),
91
+ transport=s.get("transport", "stdio"),
92
+ live=bool(s.get("live", False)),
93
+ answered_without_credentials=bool(
94
+ s.get("answered_without_credentials", False)),
95
+ )
96
+ for s in raw["servers"]
97
+ ]
98
+
99
+ if "tools" in raw: # bare tools/list response
100
+ return [McpServer(name=raw.get("name", "server"),
101
+ tools=tuple(_tool(t) for t in raw["tools"]))]
102
+
103
+ raise ValueError("unrecognised manifest: expected mcpServers, servers or tools")
104
+
105
+
106
+ def _tool(t: dict[str, Any]) -> McpTool:
107
+ return McpTool(
108
+ name=t["name"],
109
+ description=t.get("description", "") or "",
110
+ input_schema=t.get("inputSchema") or t.get("input_schema") or {},
111
+ annotations=dict(t.get("annotations") or {}),
112
+ )
113
+
114
+
115
+ # ----------------------------------------------------------------------
116
+ # Effect inference
117
+ # ----------------------------------------------------------------------
118
+
119
+ _VERBS: tuple[tuple[Effect, tuple[str, ...]], ...] = (
120
+ (Effect.WRITE, ("write", "create", "update", "delete", "remove", "drop",
121
+ "insert", "patch", "rename", "move", "upload", "commit",
122
+ "merge", "publish", "deploy", "set_", "put_")),
123
+ (Effect.EGRESS, ("send", "post", "email", "notify", "share", "export",
124
+ "publish", "upload", "message", "webhook")),
125
+ (Effect.NETWORK, ("fetch", "http", "request", "curl", "browse", "crawl",
126
+ "download", "api_")),
127
+ (Effect.COMPUTE, ("exec", "run", "eval", "shell", "command", "script",
128
+ "sandbox", "compile")),
129
+ (Effect.READ, ("read", "get", "list", "search", "query", "find", "show",
130
+ "describe", "fetch", "select", "view", "cat")),
131
+ )
132
+
133
+ _SENSITIVE = ("secret", "credential", "password", "token", "key", "customer",
134
+ "user", "email", "personal", "pii", "payroll", "salary",
135
+ "patient", "account", "ssn", "aadhaar")
136
+
137
+
138
+ # Schema shapes that imply an effect regardless of what the tool is called.
139
+ # A tool named `sync_workspace` says nothing; a `path` plus a `content` argument
140
+ # says it writes files.
141
+ _CONFIRM_ARGS = ("confirm", "confirmation", "dry_run", "dryrun", "force",
142
+ "acknowledge", "approve")
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class EffectInference:
147
+ """Effects plus *why*, so a report can show its reasoning and a check can
148
+ compare the server's own claims against the observable surface."""
149
+ effects: frozenset[Effect]
150
+ sources: tuple[str, ...] = ()
151
+ # Annotations that claim less authority than the surface demonstrates.
152
+ contradictions: tuple[tuple[str, str], ...] = ()
153
+
154
+ @property
155
+ def mutating(self) -> bool:
156
+ return bool(self.effects & {Effect.WRITE, Effect.EGRESS, Effect.COMPUTE})
157
+
158
+
159
+ def _schema_effects(tool: McpTool) -> list[tuple[Effect, str]]:
160
+ kinds = {kind_of(prop): prop for prop in tool.properties}
161
+ props = {p.lower(): schema for p, schema in tool.properties.items()}
162
+ out: list[tuple[Effect, str]] = []
163
+
164
+ if "path" in kinds and "content" in kinds:
165
+ out.append((Effect.WRITE, f"schema:{kinds['path']}+{kinds['content']}"))
166
+ if "url" in kinds:
167
+ out.append((Effect.NETWORK, f"schema:{kinds['url']}"))
168
+ if "content" in kinds:
169
+ # A URL plus a body is an outbound payload, whatever it is called.
170
+ out.append((Effect.EGRESS, f"schema:{kinds['url']}+{kinds['content']}"))
171
+ if "command" in kinds:
172
+ out.append((Effect.COMPUTE, f"schema:{kinds['command']}"))
173
+ if "sql" in kinds:
174
+ out.append((Effect.READ, f"schema:{kinds['sql']}"))
175
+ for prop, schema in props.items():
176
+ if schema.get("format") in ("uri", "url", "iri"):
177
+ out.append((Effect.NETWORK, f"schema:{prop}:format=uri"))
178
+ # A brake implies something worth braking.
179
+ if schema.get("type") == "boolean" and any(c in prop for c in _CONFIRM_ARGS):
180
+ out.append((Effect.WRITE, f"schema:{prop}:confirmation-flag"))
181
+ return out
182
+
183
+
184
+ def _name_effects(tool: McpTool) -> list[tuple[Effect, str]]:
185
+ hay = f"{tool.name} {tool.description}".lower()
186
+ out = []
187
+ for eff, verbs in _VERBS:
188
+ verb = next((v for v in verbs if v in hay), None)
189
+ if verb:
190
+ out.append((eff, f"name:{verb.strip('_')}"))
191
+ return out
192
+
193
+
194
+ # MCP tool annotations -> effects. These are hints written by the server
195
+ # author: useful when they admit to more, worthless when they claim less.
196
+ _ANNOTATION_EFFECTS = {"destructiveHint": Effect.WRITE, "openWorldHint": Effect.NETWORK}
197
+
198
+
199
+ def infer_effects_detailed(tool: McpTool) -> EffectInference:
200
+ """Infer from the schema shape and the declared annotations, falling back
201
+ to tool-name keywords.
202
+
203
+ Annotations may only *widen* the result. `readOnlyHint: true` is a claim by
204
+ the party being audited; honouring it would let any server opt out of
205
+ scrutiny by asserting its own innocence -- and some clients auto-approve
206
+ tools marked read-only. So a read-only claim on a surface that demonstrably
207
+ mutates is recorded as a contradiction instead, for `mcp_checks` to report.
208
+ """
209
+ signals = _schema_effects(tool) + _name_effects(tool)
210
+ for key, eff in _ANNOTATION_EFFECTS.items():
211
+ if tool.annotations.get(key) is True:
212
+ signals.append((eff, f"annotation:{key}"))
213
+
214
+ effects = {eff for eff, _ in signals}
215
+ sources = tuple(dict.fromkeys(src for _, src in signals))
216
+
217
+ contradictions: list[tuple[str, str]] = []
218
+ if tool.annotations.get("readOnlyHint") is True:
219
+ for eff, src in signals:
220
+ if eff in (Effect.WRITE, Effect.EGRESS, Effect.COMPUTE):
221
+ contradictions.append(("readOnlyHint", src))
222
+ if tool.annotations.get("destructiveHint") is False:
223
+ for eff, src in signals:
224
+ if src.startswith("name:") and eff is Effect.WRITE:
225
+ contradictions.append(("destructiveHint", src))
226
+
227
+ return EffectInference(frozenset(effects or {Effect.READ}), sources,
228
+ tuple(dict.fromkeys(contradictions)))
229
+
230
+
231
+ def infer_effects(tool: McpTool) -> frozenset[Effect]:
232
+ return infer_effects_detailed(tool).effects
233
+
234
+
235
+ def infer_classification(tool: McpTool) -> Classification:
236
+ hay = f"{tool.name} {tool.description}".lower()
237
+ if any(w in hay for w in ("secret", "credential", "password", "token", "key")):
238
+ return Classification.RESTRICTED
239
+ if any(w in hay for w in _SENSITIVE):
240
+ return Classification.CONFIDENTIAL
241
+ return Classification.INTERNAL
242
+
243
+
244
+ # ----------------------------------------------------------------------
245
+ # Schema -> constraint
246
+ # ----------------------------------------------------------------------
247
+
248
+ def constraint_from_schema(prop: dict[str, Any]) -> ArgConstraint:
249
+ """Whatever the server actually declares. Often nothing."""
250
+ return ArgConstraint(
251
+ matches=prop.get("pattern") and _anchor(prop["pattern"]),
252
+ one_of=tuple(prop["enum"]) if prop.get("enum") else None,
253
+ max_len=prop.get("maxLength"),
254
+ max_value=prop.get("maximum"),
255
+ )
256
+
257
+
258
+ def _anchor(pattern: str) -> str:
259
+ p = pattern
260
+ if not p.startswith("^"):
261
+ p = "^" + p
262
+ if not p.endswith("$"):
263
+ p = p + "$"
264
+ return p
265
+
266
+
267
+ def synthesize_policy(servers: list[McpServer], *, name: str = "observed"
268
+ ) -> Policy:
269
+ """The policy the servers currently enforce, as far as their schemas say."""
270
+ tools: dict[str, ToolRule] = {}
271
+ sinks: set[str] = set()
272
+
273
+ for server in servers:
274
+ for t in server.tools:
275
+ qualified = f"{server.name}.{t.name}"
276
+ args = {p: constraint_from_schema(s) for p, s in t.properties.items()}
277
+ tools[qualified] = ToolRule(
278
+ name=qualified, args=args, require_args=t.required,
279
+ deny_extra_args=bool(t.input_schema.get(
280
+ "additionalProperties", True) is False),
281
+ )
282
+ if infer_effects(t) & {Effect.EGRESS, Effect.NETWORK, Effect.WRITE}:
283
+ sinks.add(qualified)
284
+
285
+ return Policy(
286
+ name=name,
287
+ tools=tools,
288
+ effects=frozenset(Effect),
289
+ # Deliberately generous: we are describing what the server allows,
290
+ # not what we wish it allowed. Budget findings would be noise here.
291
+ budget=Budget(usd=1e6, tokens=10**9, wall_clock_s=1e6, tool_calls=10**6),
292
+ data=DataPolicy(
293
+ max_classification=Classification.RESTRICTED,
294
+ egress_sinks=frozenset(sinks),
295
+ egress_max_classification=Classification.RESTRICTED,
296
+ block_pii=frozenset(),
297
+ ),
298
+ spawn=SpawnPolicy(),
299
+ )
300
+
301
+
302
+ def build_registry(servers: list[McpServer]):
303
+ """A registry of inert doubles so the probe engine has something to decide
304
+ against. Nothing here ever contacts the real server."""
305
+ from ..registry import ToolRegistry
306
+ reg = ToolRegistry()
307
+ for server in servers:
308
+ for t in server.tools:
309
+ qualified = f"{server.name}.{t.name}"
310
+ reg.register(
311
+ qualified, _inert(qualified),
312
+ effects=infer_effects(t),
313
+ classification=infer_classification(t),
314
+ description=t.description[:200],
315
+ )
316
+ return reg
317
+
318
+
319
+ def _inert(name: str):
320
+ def _fn(**kwargs):
321
+ raise RuntimeError(f"audit doubles are never executed ({name})")
322
+ _fn.__name__ = name.replace(".", "_").replace("-", "_")
323
+ return _fn
324
+
325
+
326
+ # ----------------------------------------------------------------------
327
+ # Hardening -- the deliverable
328
+ # ----------------------------------------------------------------------
329
+
330
+ _HARDENERS: dict[str, dict[str, Any]] = {
331
+ "path": {"prefix": "/workspace/",
332
+ "forbid_matches": r"(?i)\.\.|%2e|%2f|%00|\x00|[\x00-\x1f]|/\.ssh|/\.env|id_rsa",
333
+ "max_len": 1024},
334
+ "url": {"matches": r"^https://[a-z0-9.-]+\.example\.com/[\w/-]*$",
335
+ "max_len": 2048},
336
+ "sql": {"matches": r"(?is)^\s*select\b.*",
337
+ "forbid_matches": (r"(?i)\b(drop|delete|update|insert|alter|truncate|grant|"
338
+ r"union|copy|merge|call|execute)\b|\b(into\s+outfile|into\s+dumpfile|"
339
+ r"load_file|lo_import|lo_export|pg_read_file|pg_ls_dir|"
340
+ r"pg_sleep|pg_shadow|pg_authid|dblink\w*|xp_cmdshell)\b|"
341
+ r";\s*\S|/\*|--\s"),
342
+ "max_len": 4000},
343
+ "command": {"one_of": ["REPLACE_WITH_EXPLICIT_ALLOWLIST"]},
344
+ "content": {"max_len": 65536},
345
+ "generic": {"max_len": 4096},
346
+ }
347
+
348
+ _KINDS = (
349
+ ("command", ("command", "cmd", "shell", "script", "code", "exec")),
350
+ ("path", ("path", "file", "filename", "directory", "dir", "dest", "src")),
351
+ ("url", ("url", "uri", "endpoint", "host", "webhook", "link")),
352
+ ("sql", ("sql", "query", "statement")),
353
+ ("content", ("content", "body", "text", "message", "payload", "data")),
354
+ )
355
+
356
+
357
+ def kind_of(arg: str) -> str:
358
+ low = arg.lower()
359
+ for kind, needles in _KINDS:
360
+ if any(n in low for n in needles):
361
+ return kind
362
+ return "generic"
363
+
364
+
365
+ def harden(servers: list[McpServer], *, name: str = "hardened") -> dict[str, Any]:
366
+ """Emit a policy document that closes the open doors.
367
+
368
+ Placeholders are shouted in caps on purpose. A generated policy that looks
369
+ finished is more dangerous than one that obviously needs a human.
370
+ """
371
+ allow: list[dict[str, Any]] = []
372
+ sinks: list[str] = []
373
+
374
+ for server in servers:
375
+ for t in server.tools:
376
+ qualified = f"{server.name}.{t.name}"
377
+ effects = infer_effects(t)
378
+ args: dict[str, Any] = {}
379
+ for prop, schema in t.properties.items():
380
+ declared = constraint_from_schema(schema)
381
+ base = dict(_HARDENERS[kind_of(prop)])
382
+ # keep whatever the server already declared, it is tighter
383
+ if declared.one_of:
384
+ base = {"one_of": list(declared.one_of)}
385
+ if declared.matches:
386
+ base["matches"] = declared.matches
387
+ if declared.max_len:
388
+ base["max_len"] = min(base.get("max_len", declared.max_len),
389
+ declared.max_len)
390
+ args[prop] = base
391
+
392
+ entry: dict[str, Any] = {"name": qualified}
393
+ if t.required:
394
+ entry["require_args"] = list(t.required)
395
+ if args:
396
+ entry["args"] = args
397
+ allow.append(entry)
398
+
399
+ if effects & {Effect.EGRESS, Effect.NETWORK, Effect.WRITE}:
400
+ sinks.append(qualified)
401
+
402
+ return {
403
+ "name": name,
404
+ "version": 1,
405
+ "tools": {"allow": allow},
406
+ "budget": {"usd": 5.0, "tokens": 400000,
407
+ "wall_clock_s": 900, "tool_calls": 250},
408
+ "data": {
409
+ "max_classification": "confidential",
410
+ "egress": {
411
+ "sinks": sorted(sinks),
412
+ "max_classification": "internal",
413
+ "block_pii": ["email", "phone_in", "ssn", "aadhaar",
414
+ "credit_card", "api_key", "private_key"],
415
+ },
416
+ },
417
+ "spawn": {"max_depth": 1, "max_fanout": 3, "max_descendants": 4,
418
+ "child_budget_fraction": 0.4, "allow_tools": []},
419
+ }
420
+
421
+
422
+ def write_hardened(servers: list[McpServer], path: str | Path) -> Path:
423
+ path = Path(path)
424
+ path.write_text(
425
+ "# Generated by aegis from the observed MCP manifest.\n"
426
+ "# Every REPLACE_WITH_* placeholder needs a human decision before use.\n"
427
+ "# Review the url pattern: it defaults to a placeholder host.\n\n"
428
+ + yaml.safe_dump(harden(servers), sort_keys=False, width=100), encoding="utf-8")
429
+ return path