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.
Files changed (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. 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