agent-loop-guard-runtime 0.6.0a2__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.
- agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
- app/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/admin_routes.py +179 -0
- app/api/anthropic_routes.py +21 -0
- app/api/common.py +457 -0
- app/api/mcp_routes.py +218 -0
- app/api/openai_routes.py +26 -0
- app/api/replay_routes.py +202 -0
- app/api/ui_routes.py +295 -0
- app/benchmark/__init__.py +2 -0
- app/benchmark/adapters.py +97 -0
- app/benchmark/data/starter-v1.json +38 -0
- app/benchmark/dataset.py +59 -0
- app/benchmark/models.py +46 -0
- app/benchmark/runner.py +63 -0
- app/benchmark/scorers.py +34 -0
- app/benchmark/statistics.py +48 -0
- app/benchmark/storage.py +78 -0
- app/cli.py +624 -0
- app/core/config.py +196 -0
- app/core/demo.py +109 -0
- app/core/loop_detector.py +120 -0
- app/core/policy_engine.py +167 -0
- app/core/redaction.py +84 -0
- app/core/security.py +54 -0
- app/core/token_meter.py +67 -0
- app/db/models.py +296 -0
- app/db/repository.py +1488 -0
- app/db/session.py +57 -0
- app/main.py +59 -0
- app/mcp/__init__.py +1 -0
- app/mcp/gateway.py +230 -0
- app/mcp/policy.py +281 -0
- app/mcp/presets/development.yml +25 -0
- app/mcp/presets/filesystem.yml +18 -0
- app/mcp/stdio.py +142 -0
- app/platform/__init__.py +1 -0
- app/platform/alembic/__init__.py +2 -0
- app/platform/alembic/env.py +38 -0
- app/platform/alembic/script.py.mako +24 -0
- app/platform/alembic/versions/0001_initial_schema.py +18 -0
- app/platform/alembic/versions/__init__.py +2 -0
- app/platform/events.py +44 -0
- app/platform/maintenance.py +138 -0
- app/platform/migrations.py +24 -0
- app/platform/setup.py +92 -0
- app/providers/__init__.py +5 -0
- app/providers/base.py +23 -0
- app/providers/mock.py +169 -0
- app/providers/upstream.py +101 -0
- app/replay/__init__.py +1 -0
- app/replay/costs.py +37 -0
- app/replay/formats.py +87 -0
- app/replay/sdk.py +102 -0
- app/sandbox/__init__.py +2 -0
- app/sandbox/policy.py +22 -0
- app/sandbox/workspace.py +281 -0
- app/static/styles.css +327 -0
- app/templates/agents.html +48 -0
- app/templates/base.html +27 -0
- app/templates/dashboard.html +55 -0
- app/templates/demo.html +36 -0
- app/templates/mcp.html +69 -0
- app/templates/policies.html +30 -0
- app/templates/replay.html +63 -0
- app/templates/replay_compare.html +74 -0
- app/templates/replay_detail.html +130 -0
- app/templates/session_detail.html +71 -0
- app/templates/sessions.html +24 -0
- app/templates/settings.html +20 -0
app/db/session.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
from sqlalchemy import create_engine, event
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
from sqlalchemy.pool import StaticPool
|
|
9
|
+
|
|
10
|
+
from app.core.config import AppConfig
|
|
11
|
+
from app.db.models import Base
|
|
12
|
+
from app.db.repository import ensure_seed_data
|
|
13
|
+
from app.platform.migrations import migrate_database
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_engine(config: AppConfig):
|
|
17
|
+
config.ensure_storage_parent()
|
|
18
|
+
kwargs = {"future": True}
|
|
19
|
+
if config.storage_url.startswith("sqlite"):
|
|
20
|
+
kwargs["connect_args"] = {"check_same_thread": False}
|
|
21
|
+
if config.storage_url == "sqlite:///:memory:":
|
|
22
|
+
kwargs["poolclass"] = StaticPool
|
|
23
|
+
engine = create_engine(config.storage_url, **kwargs)
|
|
24
|
+
if config.storage_url.startswith("sqlite"):
|
|
25
|
+
@event.listens_for(engine, "connect")
|
|
26
|
+
def _sqlite_foreign_keys(dbapi_connection, _connection_record) -> None:
|
|
27
|
+
cursor = dbapi_connection.cursor()
|
|
28
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
29
|
+
cursor.close()
|
|
30
|
+
|
|
31
|
+
return engine
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_session_factory(engine) -> sessionmaker[Session]:
|
|
35
|
+
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def init_db(engine, config: AppConfig) -> None:
|
|
39
|
+
migrate_database(engine, config.storage_url)
|
|
40
|
+
# create_all is retained as a compatibility safety net for pre-v0.2 databases.
|
|
41
|
+
Base.metadata.create_all(engine)
|
|
42
|
+
if config.storage_url.startswith("sqlite"):
|
|
43
|
+
with engine.begin() as conn:
|
|
44
|
+
conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
|
45
|
+
conn.exec_driver_sql("PRAGMA foreign_keys=ON")
|
|
46
|
+
factory = build_session_factory(engine)
|
|
47
|
+
with factory() as db:
|
|
48
|
+
ensure_seed_data(db, config)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_db(request: Request) -> Generator[Session, None, None]:
|
|
52
|
+
session_factory = request.app.state.SessionLocal
|
|
53
|
+
db = session_factory()
|
|
54
|
+
try:
|
|
55
|
+
yield db
|
|
56
|
+
finally:
|
|
57
|
+
db.close()
|
app/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse, Response
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
from app.api.admin_routes import router as admin_router
|
|
10
|
+
from app.api.anthropic_routes import router as anthropic_router
|
|
11
|
+
from app.api.mcp_routes import router as mcp_router
|
|
12
|
+
from app.api.openai_routes import router as openai_router
|
|
13
|
+
from app.api.replay_routes import router as replay_router
|
|
14
|
+
from app.api.ui_routes import router as ui_router
|
|
15
|
+
from app.core.config import AppConfig
|
|
16
|
+
from app.db.session import build_engine, build_session_factory, init_db
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_app(config: AppConfig | None = None) -> FastAPI:
|
|
20
|
+
config = config or AppConfig.from_env()
|
|
21
|
+
engine = build_engine(config)
|
|
22
|
+
init_db(engine, config)
|
|
23
|
+
|
|
24
|
+
app = FastAPI(
|
|
25
|
+
title="Agent Loop Guard",
|
|
26
|
+
version="0.6.0a1",
|
|
27
|
+
description="Local runtime guard for coding agents.",
|
|
28
|
+
)
|
|
29
|
+
app.state.config = config
|
|
30
|
+
app.state.engine = engine
|
|
31
|
+
app.state.SessionLocal = build_session_factory(engine)
|
|
32
|
+
app.state.mcp_upstream_sessions = {}
|
|
33
|
+
|
|
34
|
+
@app.middleware("http")
|
|
35
|
+
async def body_limit(request: Request, call_next):
|
|
36
|
+
content_length = request.headers.get("content-length")
|
|
37
|
+
if content_length and int(content_length) > config.body_limit_bytes:
|
|
38
|
+
return JSONResponse({"error": "Request body too large."}, status_code=413)
|
|
39
|
+
return await call_next(request)
|
|
40
|
+
|
|
41
|
+
@app.head("/")
|
|
42
|
+
async def connectivity_probe() -> Response:
|
|
43
|
+
return Response(status_code=204)
|
|
44
|
+
|
|
45
|
+
app.include_router(openai_router)
|
|
46
|
+
app.include_router(anthropic_router)
|
|
47
|
+
app.include_router(admin_router)
|
|
48
|
+
app.include_router(replay_router)
|
|
49
|
+
app.include_router(mcp_router)
|
|
50
|
+
|
|
51
|
+
if config.admin_ui:
|
|
52
|
+
static_dir = Path(__file__).resolve().parent / "static"
|
|
53
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
54
|
+
app.include_router(ui_router)
|
|
55
|
+
|
|
56
|
+
return app
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app = create_app()
|
app/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP Permission Firewall."""
|
app/mcp/gateway.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from jsonschema import ValidationError, validate
|
|
9
|
+
|
|
10
|
+
from app.db.models import MCPSession
|
|
11
|
+
from app.db.repository import Repository
|
|
12
|
+
from app.mcp.policy import MCPDecision, MCPPolicyEngine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class CallInterception:
|
|
17
|
+
forward: bool
|
|
18
|
+
message: dict[str, Any]
|
|
19
|
+
response: dict[str, Any] | None
|
|
20
|
+
decision: MCPDecision | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def jsonrpc_error(request_id: Any, code: int, message: str, data: dict | None = None) -> dict:
|
|
24
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
25
|
+
if data:
|
|
26
|
+
error["data"] = data
|
|
27
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tool_error(request_id: Any, message: str, data: dict | None = None) -> dict:
|
|
31
|
+
payload: dict[str, Any] = {
|
|
32
|
+
"content": [{"type": "text", "text": message}],
|
|
33
|
+
"isError": True,
|
|
34
|
+
}
|
|
35
|
+
if data:
|
|
36
|
+
payload["structuredContent"] = data
|
|
37
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": payload}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPGateway:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
repo: Repository,
|
|
44
|
+
policy: MCPPolicyEngine,
|
|
45
|
+
server_id: str,
|
|
46
|
+
session_id: str | None = None,
|
|
47
|
+
approval_timeout_seconds: int = 30,
|
|
48
|
+
):
|
|
49
|
+
self.repo = repo
|
|
50
|
+
self.policy = policy
|
|
51
|
+
self.server_id = server_id
|
|
52
|
+
self.session_id = session_id
|
|
53
|
+
self.approval_timeout_seconds = approval_timeout_seconds
|
|
54
|
+
|
|
55
|
+
def ensure_session(
|
|
56
|
+
self, *, client_name: str | None = None, protocol_version: str | None = None
|
|
57
|
+
) -> str:
|
|
58
|
+
if self.session_id and self.repo.db.get(MCPSession, self.session_id):
|
|
59
|
+
return self.session_id
|
|
60
|
+
session_id = f"mcp_{secrets.token_urlsafe(18)}"
|
|
61
|
+
self.repo.start_mcp_session(
|
|
62
|
+
self.server_id,
|
|
63
|
+
client_name=client_name,
|
|
64
|
+
protocol_version=protocol_version,
|
|
65
|
+
mode=str(self.policy.data.get("mode", "enforce")),
|
|
66
|
+
session_id=session_id,
|
|
67
|
+
)
|
|
68
|
+
self.session_id = session_id
|
|
69
|
+
return session_id
|
|
70
|
+
|
|
71
|
+
def filter_tools(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
result = response.get("result")
|
|
73
|
+
if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
|
|
74
|
+
return response
|
|
75
|
+
tools = [
|
|
76
|
+
tool
|
|
77
|
+
for tool in result["tools"]
|
|
78
|
+
if isinstance(tool, dict)
|
|
79
|
+
and self.policy.visible_tool(self.server_id, str(tool.get("name") or ""))
|
|
80
|
+
]
|
|
81
|
+
result["tools"] = tools
|
|
82
|
+
if self.session_id:
|
|
83
|
+
self.repo.record_mcp_tools(self.session_id, self.server_id, tools)
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
def intercept(self, message: dict[str, Any]) -> CallInterception:
|
|
87
|
+
if message.get("method") != "tools/call":
|
|
88
|
+
return CallInterception(True, message, None, None)
|
|
89
|
+
request_id = message.get("id")
|
|
90
|
+
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
91
|
+
tool_name = str(params.get("name") or "")
|
|
92
|
+
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
|
93
|
+
schema = self.repo.mcp_tool_schema(self.server_id, tool_name)
|
|
94
|
+
if schema:
|
|
95
|
+
try:
|
|
96
|
+
validate(instance=arguments, schema=schema)
|
|
97
|
+
except ValidationError as exc:
|
|
98
|
+
return CallInterception(
|
|
99
|
+
False,
|
|
100
|
+
message,
|
|
101
|
+
tool_error(request_id, "Tool arguments failed JSON Schema validation.", {"path": list(exc.path)}),
|
|
102
|
+
None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
decision = self.policy.evaluate(self.server_id, tool_name, arguments)
|
|
106
|
+
approved = None
|
|
107
|
+
if decision.action == "confirm":
|
|
108
|
+
approved = self.repo.consume_mcp_approval(
|
|
109
|
+
self.server_id, tool_name, decision.argument_hash, self.session_id
|
|
110
|
+
)
|
|
111
|
+
if approved:
|
|
112
|
+
decision.action = "allow"
|
|
113
|
+
decision.reason = f"Approved by user ({approved.scope})."
|
|
114
|
+
|
|
115
|
+
event = self.repo.record_mcp_decision(
|
|
116
|
+
server_id=self.server_id,
|
|
117
|
+
session_id=self.session_id,
|
|
118
|
+
request_id=request_id,
|
|
119
|
+
tool_name=tool_name,
|
|
120
|
+
argument_hash=decision.argument_hash,
|
|
121
|
+
policy_version=decision.policy_version,
|
|
122
|
+
action=decision.action,
|
|
123
|
+
reason=decision.reason,
|
|
124
|
+
rule_id=decision.rule_id,
|
|
125
|
+
mode=decision.mode,
|
|
126
|
+
attributes={
|
|
127
|
+
"risk_tags": decision.risk_tags,
|
|
128
|
+
"transformed_fields": decision.transformed_fields,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if decision.action == "confirm":
|
|
133
|
+
approval = self.repo.create_mcp_approval(
|
|
134
|
+
event.id, decision.argument_hash, self.approval_timeout_seconds
|
|
135
|
+
)
|
|
136
|
+
return CallInterception(
|
|
137
|
+
False,
|
|
138
|
+
message,
|
|
139
|
+
tool_error(
|
|
140
|
+
request_id,
|
|
141
|
+
"User approval is required. Approve the request and retry the same call.",
|
|
142
|
+
{"approval_id": approval.id, "action": "confirm"},
|
|
143
|
+
),
|
|
144
|
+
decision,
|
|
145
|
+
)
|
|
146
|
+
if not decision.allowed:
|
|
147
|
+
return CallInterception(
|
|
148
|
+
False,
|
|
149
|
+
message,
|
|
150
|
+
tool_error(request_id, decision.reason, {"action": decision.action, "rule_id": decision.rule_id}),
|
|
151
|
+
decision,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
transformed = json.loads(json.dumps(message))
|
|
155
|
+
transformed.setdefault("params", {})["arguments"] = decision.arguments
|
|
156
|
+
return CallInterception(True, transformed, None, decision)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
MOCK_TOOLS = [
|
|
160
|
+
{
|
|
161
|
+
"name": "read_file",
|
|
162
|
+
"description": "Read a file from the demo workspace.",
|
|
163
|
+
"inputSchema": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {"path": {"type": "string"}},
|
|
166
|
+
"required": ["path"],
|
|
167
|
+
"additionalProperties": False,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"name": "write_file",
|
|
172
|
+
"description": "Write a file in the demo workspace.",
|
|
173
|
+
"inputSchema": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
176
|
+
"required": ["path", "content"],
|
|
177
|
+
"additionalProperties": False,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "delete_file",
|
|
182
|
+
"description": "Delete a file in the demo workspace.",
|
|
183
|
+
"inputSchema": {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {"path": {"type": "string"}},
|
|
186
|
+
"required": ["path"],
|
|
187
|
+
"additionalProperties": False,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def mock_response(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
194
|
+
request_id = message.get("id")
|
|
195
|
+
method = message.get("method")
|
|
196
|
+
if request_id is None:
|
|
197
|
+
return None
|
|
198
|
+
if method == "initialize":
|
|
199
|
+
requested = (message.get("params") or {}).get("protocolVersion")
|
|
200
|
+
version = requested if requested == "2025-11-25" else "2025-11-25"
|
|
201
|
+
return {
|
|
202
|
+
"jsonrpc": "2.0",
|
|
203
|
+
"id": request_id,
|
|
204
|
+
"result": {
|
|
205
|
+
"protocolVersion": version,
|
|
206
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
207
|
+
"serverInfo": {"name": "Agent Loop Guard mock filesystem", "version": "0.2.0"},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
if method == "tools/list":
|
|
211
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": MOCK_TOOLS}}
|
|
212
|
+
if method == "tools/call":
|
|
213
|
+
params = message.get("params") or {}
|
|
214
|
+
name = params.get("name")
|
|
215
|
+
arguments = params.get("arguments") or {}
|
|
216
|
+
return {
|
|
217
|
+
"jsonrpc": "2.0",
|
|
218
|
+
"id": request_id,
|
|
219
|
+
"result": {
|
|
220
|
+
"content": [
|
|
221
|
+
{
|
|
222
|
+
"type": "text",
|
|
223
|
+
"text": f"Mock {name} completed for {arguments.get('path', 'no path')}.",
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
"structuredContent": {"tool": name, "status": "ok"},
|
|
227
|
+
"isError": False,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
|
app/mcp/policy.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
import time
|
|
8
|
+
from collections import defaultdict, deque
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
ALLOWED_ACTIONS = {"allow", "deny", "confirm", "transform", "rate_limit", "shadow_deny"}
|
|
17
|
+
PATH_KEYS = {"path", "file", "filepath", "file_path", "directory", "cwd", "root"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MCPPolicyError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class MCPDecision:
|
|
26
|
+
action: str
|
|
27
|
+
reason: str
|
|
28
|
+
rule_id: str
|
|
29
|
+
policy_version: str
|
|
30
|
+
arguments: dict[str, Any]
|
|
31
|
+
argument_hash: str
|
|
32
|
+
mode: str = "enforce"
|
|
33
|
+
risk_tags: list[str] = field(default_factory=list)
|
|
34
|
+
transformed_fields: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def allowed(self) -> bool:
|
|
38
|
+
return self.action in {"allow", "transform", "shadow_deny"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def argument_fingerprint(arguments: dict[str, Any]) -> str:
|
|
42
|
+
normalized = json.dumps(arguments, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
43
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_policy(data: dict[str, Any]) -> list[str]:
|
|
47
|
+
errors: list[str] = []
|
|
48
|
+
if int(data.get("version", 0)) < 1:
|
|
49
|
+
errors.append("version must be a positive integer")
|
|
50
|
+
default_action = str(data.get("default_action", "confirm"))
|
|
51
|
+
if default_action not in ALLOWED_ACTIONS:
|
|
52
|
+
errors.append(f"unsupported default_action: {default_action}")
|
|
53
|
+
servers = data.get("servers", {})
|
|
54
|
+
if not isinstance(servers, dict):
|
|
55
|
+
errors.append("servers must be an object")
|
|
56
|
+
return errors
|
|
57
|
+
for server_id, server in servers.items():
|
|
58
|
+
if not isinstance(server, dict):
|
|
59
|
+
errors.append(f"servers.{server_id} must be an object")
|
|
60
|
+
continue
|
|
61
|
+
for tool_name, rule in (server.get("tools") or {}).items():
|
|
62
|
+
if not isinstance(rule, dict):
|
|
63
|
+
errors.append(f"servers.{server_id}.tools.{tool_name} must be an object")
|
|
64
|
+
continue
|
|
65
|
+
action = str(rule.get("action", default_action))
|
|
66
|
+
if action not in ALLOWED_ACTIONS:
|
|
67
|
+
errors.append(f"servers.{server_id}.tools.{tool_name}: unsupported action {action}")
|
|
68
|
+
return errors
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_policy(path: str | Path | None = None) -> tuple[dict[str, Any], Path | None]:
|
|
72
|
+
if path is None:
|
|
73
|
+
return default_policy(), None
|
|
74
|
+
policy_path = Path(path)
|
|
75
|
+
if not policy_path.exists():
|
|
76
|
+
return default_policy(), policy_path
|
|
77
|
+
with policy_path.open(encoding="utf-8") as handle:
|
|
78
|
+
data = yaml.safe_load(handle) or {}
|
|
79
|
+
errors = validate_policy(data)
|
|
80
|
+
if errors:
|
|
81
|
+
raise MCPPolicyError("; ".join(errors))
|
|
82
|
+
return data, policy_path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def default_policy() -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"version": 1,
|
|
88
|
+
"default_action": "confirm",
|
|
89
|
+
"mode": "enforce",
|
|
90
|
+
"hide_denied_tools": True,
|
|
91
|
+
"servers": {
|
|
92
|
+
"filesystem": {
|
|
93
|
+
"tools": {
|
|
94
|
+
"read_file": {"action": "allow", "paths": ["./**"]},
|
|
95
|
+
"write_file": {"action": "confirm", "paths": ["./**"]},
|
|
96
|
+
"delete_file": {"action": "deny"},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MCPPolicyEngine:
|
|
104
|
+
def __init__(self, path: str | Path | None = None, *, project_root: str | Path | None = None):
|
|
105
|
+
self.path = Path(path) if path else None
|
|
106
|
+
self.project_root = Path(project_root or Path.cwd()).resolve()
|
|
107
|
+
self.data, _ = load_policy(self.path)
|
|
108
|
+
self._mtime = self.path.stat().st_mtime_ns if self.path and self.path.exists() else 0
|
|
109
|
+
self._rate_windows: dict[str, deque[float]] = defaultdict(deque)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def version(self) -> str:
|
|
113
|
+
digest = hashlib.sha256(
|
|
114
|
+
json.dumps(self.data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
115
|
+
).hexdigest()[:12]
|
|
116
|
+
return f"v{self.data.get('version', 1)}:{digest}"
|
|
117
|
+
|
|
118
|
+
def reload_if_changed(self) -> bool:
|
|
119
|
+
if not self.path or not self.path.exists():
|
|
120
|
+
return False
|
|
121
|
+
mtime = self.path.stat().st_mtime_ns
|
|
122
|
+
if mtime == self._mtime:
|
|
123
|
+
return False
|
|
124
|
+
self.data, _ = load_policy(self.path)
|
|
125
|
+
self._mtime = mtime
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
def rule_for(self, server_id: str, tool_name: str) -> tuple[dict[str, Any], str]:
|
|
129
|
+
server = (self.data.get("servers") or {}).get(server_id, {})
|
|
130
|
+
tools = server.get("tools") or {}
|
|
131
|
+
if tool_name in tools:
|
|
132
|
+
return dict(tools[tool_name]), f"{server_id}.tools.{tool_name}"
|
|
133
|
+
for group_name, group in (server.get("groups") or {}).items():
|
|
134
|
+
patterns = group.get("tools") or []
|
|
135
|
+
if any(fnmatch.fnmatchcase(tool_name, str(pattern)) for pattern in patterns):
|
|
136
|
+
return dict(group), f"{server_id}.groups.{group_name}"
|
|
137
|
+
action = server.get("default_action", self.data.get("default_action", "confirm"))
|
|
138
|
+
return {"action": action}, f"{server_id}.default"
|
|
139
|
+
|
|
140
|
+
def visible_tool(self, server_id: str, tool_name: str) -> bool:
|
|
141
|
+
rule, _ = self.rule_for(server_id, tool_name)
|
|
142
|
+
action = str(rule.get("action", "confirm"))
|
|
143
|
+
return not (bool(self.data.get("hide_denied_tools", True)) and action == "deny")
|
|
144
|
+
|
|
145
|
+
def evaluate(self, server_id: str, tool_name: str, arguments: dict[str, Any]) -> MCPDecision:
|
|
146
|
+
self.reload_if_changed()
|
|
147
|
+
rule, rule_id = self.rule_for(server_id, tool_name)
|
|
148
|
+
args = json.loads(json.dumps(arguments))
|
|
149
|
+
action = str(rule.get("action", self.data.get("default_action", "confirm")))
|
|
150
|
+
mode = str(self.data.get("mode", "enforce"))
|
|
151
|
+
risks: list[str] = []
|
|
152
|
+
transformed: list[str] = []
|
|
153
|
+
|
|
154
|
+
reason = f"Matched {rule_id}."
|
|
155
|
+
path_error = self._check_paths(rule, args)
|
|
156
|
+
if path_error:
|
|
157
|
+
action, reason = "deny", path_error
|
|
158
|
+
risks.append("filesystem")
|
|
159
|
+
|
|
160
|
+
command_error = self._check_command(rule, args)
|
|
161
|
+
if command_error:
|
|
162
|
+
action, reason = "deny", command_error
|
|
163
|
+
risks.append("shell")
|
|
164
|
+
|
|
165
|
+
host_error = self._check_host(rule, args)
|
|
166
|
+
if host_error:
|
|
167
|
+
action, reason = "deny", host_error
|
|
168
|
+
risks.append("network")
|
|
169
|
+
|
|
170
|
+
sql_error = self._check_sql(rule, args)
|
|
171
|
+
if sql_error:
|
|
172
|
+
action, reason = "deny", sql_error
|
|
173
|
+
risks.append("database")
|
|
174
|
+
|
|
175
|
+
payload_limit = int(rule.get("max_payload_bytes", 0) or 0)
|
|
176
|
+
if payload_limit and len(json.dumps(args).encode("utf-8")) > payload_limit:
|
|
177
|
+
action, reason = "deny", "Payload exceeds the configured byte limit."
|
|
178
|
+
risks.append("large_payload")
|
|
179
|
+
|
|
180
|
+
if action == "transform":
|
|
181
|
+
for key in rule.get("remove_fields", []):
|
|
182
|
+
if key in args:
|
|
183
|
+
args.pop(key)
|
|
184
|
+
transformed.append(str(key))
|
|
185
|
+
if "max_limit" in rule and isinstance(args.get("limit"), int):
|
|
186
|
+
bounded = min(args["limit"], int(rule["max_limit"]))
|
|
187
|
+
if bounded != args["limit"]:
|
|
188
|
+
args["limit"] = bounded
|
|
189
|
+
transformed.append("limit")
|
|
190
|
+
|
|
191
|
+
if action == "rate_limit":
|
|
192
|
+
action, reason = self._rate_limit(server_id, tool_name, rule)
|
|
193
|
+
|
|
194
|
+
if mode == "shadow" and action in {"deny", "confirm"}:
|
|
195
|
+
action = "shadow_deny"
|
|
196
|
+
reason = f"Shadow mode: {reason}"
|
|
197
|
+
|
|
198
|
+
return MCPDecision(
|
|
199
|
+
action=action,
|
|
200
|
+
reason=reason,
|
|
201
|
+
rule_id=rule_id,
|
|
202
|
+
policy_version=self.version,
|
|
203
|
+
arguments=args,
|
|
204
|
+
argument_hash=argument_fingerprint(args),
|
|
205
|
+
mode=mode,
|
|
206
|
+
risk_tags=sorted(set(risks)),
|
|
207
|
+
transformed_fields=transformed,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _check_paths(self, rule: dict[str, Any], arguments: dict[str, Any]) -> str | None:
|
|
211
|
+
patterns = [str(item) for item in rule.get("paths", [])]
|
|
212
|
+
if not patterns:
|
|
213
|
+
return None
|
|
214
|
+
for key, value in arguments.items():
|
|
215
|
+
if key.lower() not in PATH_KEYS or not isinstance(value, str):
|
|
216
|
+
continue
|
|
217
|
+
candidate = Path(value)
|
|
218
|
+
resolved = (self.project_root / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
|
|
219
|
+
try:
|
|
220
|
+
relative = resolved.relative_to(self.project_root).as_posix()
|
|
221
|
+
except ValueError:
|
|
222
|
+
return f"Path escapes project root: {value}"
|
|
223
|
+
normalized = f"./{relative}"
|
|
224
|
+
if not any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns):
|
|
225
|
+
return f"Path is outside allowed patterns: {value}"
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _check_command(rule: dict[str, Any], arguments: dict[str, Any]) -> str | None:
|
|
230
|
+
patterns = [str(item) for item in rule.get("deny_patterns", [])]
|
|
231
|
+
if not patterns:
|
|
232
|
+
return None
|
|
233
|
+
raw = arguments.get("argv", arguments.get("command"))
|
|
234
|
+
if isinstance(raw, list):
|
|
235
|
+
command = shlex.join(str(item) for item in raw)
|
|
236
|
+
elif isinstance(raw, str):
|
|
237
|
+
command = raw
|
|
238
|
+
else:
|
|
239
|
+
return None
|
|
240
|
+
if any(fnmatch.fnmatch(command, pattern) or pattern in command for pattern in patterns):
|
|
241
|
+
return "Command matched a denied pattern."
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _check_host(rule: dict[str, Any], arguments: dict[str, Any]) -> str | None:
|
|
246
|
+
hosts = [str(item).lower() for item in rule.get("hosts", [])]
|
|
247
|
+
if not hosts:
|
|
248
|
+
return None
|
|
249
|
+
url = arguments.get("url") or arguments.get("uri")
|
|
250
|
+
if not isinstance(url, str):
|
|
251
|
+
return None
|
|
252
|
+
host = (urlparse(url).hostname or "").lower()
|
|
253
|
+
if not any(fnmatch.fnmatchcase(host, pattern) for pattern in hosts):
|
|
254
|
+
return f"URL host is not allowed: {host or 'missing'}"
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _check_sql(rule: dict[str, Any], arguments: dict[str, Any]) -> str | None:
|
|
259
|
+
operations = [str(item).upper() for item in rule.get("sql_operations", [])]
|
|
260
|
+
query = arguments.get("query") or arguments.get("sql")
|
|
261
|
+
if not operations or not isinstance(query, str):
|
|
262
|
+
return None
|
|
263
|
+
operation = query.lstrip().split(None, 1)[0].upper() if query.strip() else ""
|
|
264
|
+
if operation not in operations:
|
|
265
|
+
return f"SQL operation is not allowed: {operation or 'empty'}"
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def _rate_limit(
|
|
269
|
+
self, server_id: str, tool_name: str, rule: dict[str, Any]
|
|
270
|
+
) -> tuple[str, str]:
|
|
271
|
+
now = time.monotonic()
|
|
272
|
+
window_seconds = max(1, int(rule.get("window_seconds", 60)))
|
|
273
|
+
limit = max(1, int(rule.get("limit", 10)))
|
|
274
|
+
key = f"{server_id}:{tool_name}"
|
|
275
|
+
window = self._rate_windows[key]
|
|
276
|
+
while window and window[0] <= now - window_seconds:
|
|
277
|
+
window.popleft()
|
|
278
|
+
if len(window) >= limit:
|
|
279
|
+
return "deny", "Rate limit exceeded."
|
|
280
|
+
window.append(now)
|
|
281
|
+
return "allow", "Allowed within rate limit."
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
mode: enforce
|
|
3
|
+
default_action: confirm
|
|
4
|
+
hide_denied_tools: true
|
|
5
|
+
servers:
|
|
6
|
+
filesystem:
|
|
7
|
+
tools:
|
|
8
|
+
read_file: {action: allow, paths: ["./**"]}
|
|
9
|
+
write_file: {action: confirm, paths: ["./**"]}
|
|
10
|
+
delete_file: {action: deny}
|
|
11
|
+
shell:
|
|
12
|
+
tools:
|
|
13
|
+
execute_command:
|
|
14
|
+
action: confirm
|
|
15
|
+
deny_patterns: ["rm -rf", "curl * | sh", "git push*"]
|
|
16
|
+
http:
|
|
17
|
+
tools:
|
|
18
|
+
fetch:
|
|
19
|
+
action: allow
|
|
20
|
+
hosts: ["api.github.com", "pypi.org", "registry.npmjs.org"]
|
|
21
|
+
database:
|
|
22
|
+
tools:
|
|
23
|
+
query:
|
|
24
|
+
action: allow
|
|
25
|
+
sql_operations: ["SELECT", "EXPLAIN", "PRAGMA"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
mode: enforce
|
|
3
|
+
default_action: confirm
|
|
4
|
+
hide_denied_tools: true
|
|
5
|
+
servers:
|
|
6
|
+
filesystem:
|
|
7
|
+
tools:
|
|
8
|
+
read_file:
|
|
9
|
+
action: allow
|
|
10
|
+
paths: ["./**"]
|
|
11
|
+
list_directory:
|
|
12
|
+
action: allow
|
|
13
|
+
paths: ["./**"]
|
|
14
|
+
write_file:
|
|
15
|
+
action: confirm
|
|
16
|
+
paths: ["./**"]
|
|
17
|
+
delete_file:
|
|
18
|
+
action: deny
|