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.
- aegis/__init__.py +20 -0
- aegis/__main__.py +6 -0
- aegis/adapters/__init__.py +5 -0
- aegis/adapters/mcp.py +429 -0
- aegis/adapters/mcp_client.py +344 -0
- aegis/audit.py +130 -0
- aegis/conformance/__init__.py +11 -0
- aegis/conformance/cli.py +386 -0
- aegis/conformance/drift.py +145 -0
- aegis/conformance/export.py +213 -0
- aegis/conformance/fixtures.py +128 -0
- aegis/conformance/invariants.py +285 -0
- aegis/conformance/loopholes.py +648 -0
- aegis/conformance/mcp_checks.py +261 -0
- aegis/conformance/report.py +169 -0
- aegis/conformance/report_html.py +184 -0
- aegis/conformance/runner.py +177 -0
- aegis/conformance/scaffold.py +45 -0
- aegis/conformance/spec.py +95 -0
- aegis/constitution.py +226 -0
- aegis/constitution.yaml +69 -0
- aegis/corpus/payloads.yaml +110 -0
- aegis/decision.py +67 -0
- aegis/grant.py +220 -0
- aegis/guards/__init__.py +14 -0
- aegis/guards/base.py +36 -0
- aegis/guards/budget.py +19 -0
- aegis/guards/capability.py +47 -0
- aegis/guards/data.py +216 -0
- aegis/guards/spawn.py +51 -0
- aegis/kernel.py +272 -0
- aegis/observe.py +82 -0
- aegis/policy.py +360 -0
- aegis/py.typed +0 -0
- aegis/registry.py +69 -0
- aegis/runtime.py +106 -0
- aegis/templates/baseline.yaml +68 -0
- aegis/templates/policy.yaml +67 -0
- aegis/templates/restricted.yaml +28 -0
- aegis/templates/suite.yaml +204 -0
- aegis/templates/workflow.yml +47 -0
- aegis_kernel-0.4.0.dist-info/METADATA +665 -0
- aegis_kernel-0.4.0.dist-info/RECORD +47 -0
- aegis_kernel-0.4.0.dist-info/WHEEL +5 -0
- aegis_kernel-0.4.0.dist-info/entry_points.txt +2 -0
- aegis_kernel-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|