open-edison 0.1.17__py3-none-any.whl → 0.1.26__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.
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/METADATA +124 -51
- open_edison-0.1.26.dist-info/RECORD +17 -0
- src/cli.py +2 -1
- src/config.py +63 -51
- src/events.py +153 -0
- src/middleware/data_access_tracker.py +165 -406
- src/middleware/session_tracking.py +93 -29
- src/oauth_manager.py +281 -0
- src/permissions.py +292 -0
- src/server.py +525 -98
- src/single_user_mcp.py +215 -153
- src/telemetry.py +4 -40
- open_edison-0.1.17.dist-info/RECORD +0 -14
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/WHEEL +0 -0
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/licenses/LICENSE +0 -0
@@ -12,15 +12,20 @@ names (with server-name/path prefixes) to their security classifications:
|
|
12
12
|
- prompt_permissions.json: Prompt security classifications
|
13
13
|
"""
|
14
14
|
|
15
|
-
import json
|
16
15
|
from dataclasses import dataclass
|
17
|
-
from functools import cache
|
18
|
-
from pathlib import Path
|
19
16
|
from typing import Any
|
20
17
|
|
21
18
|
from loguru import logger as log
|
22
19
|
|
23
|
-
from src
|
20
|
+
from src import events
|
21
|
+
from src.permissions import (
|
22
|
+
ACL_RANK,
|
23
|
+
Permissions,
|
24
|
+
PromptPermission,
|
25
|
+
ResourcePermission,
|
26
|
+
ToolPermission,
|
27
|
+
normalize_acl,
|
28
|
+
)
|
24
29
|
from src.telemetry import (
|
25
30
|
record_private_data_access,
|
26
31
|
record_prompt_access_blocked,
|
@@ -30,288 +35,6 @@ from src.telemetry import (
|
|
30
35
|
record_write_operation,
|
31
36
|
)
|
32
37
|
|
33
|
-
ACL_RANK: dict[str, int] = {"PUBLIC": 0, "PRIVATE": 1, "SECRET": 2}
|
34
|
-
|
35
|
-
# Default flat permissions applied when fields are missing in config
|
36
|
-
DEFAULT_PERMISSIONS: dict[str, Any] = {
|
37
|
-
"enabled": False,
|
38
|
-
"write_operation": False,
|
39
|
-
"read_private_data": False,
|
40
|
-
"read_untrusted_public_data": False,
|
41
|
-
"acl": "PUBLIC",
|
42
|
-
}
|
43
|
-
|
44
|
-
|
45
|
-
def _normalize_acl(value: Any, *, default: str = "PUBLIC") -> str:
|
46
|
-
"""Normalize ACL string, defaulting and uppercasing; validate against known values."""
|
47
|
-
try:
|
48
|
-
if value is None:
|
49
|
-
return default
|
50
|
-
acl = str(value).upper().strip()
|
51
|
-
if acl not in ACL_RANK:
|
52
|
-
# Fallback to default if invalid
|
53
|
-
return default
|
54
|
-
return acl
|
55
|
-
except Exception:
|
56
|
-
return default
|
57
|
-
|
58
|
-
|
59
|
-
def _apply_permission_defaults(config_perms: dict[str, Any]) -> dict[str, Any]:
|
60
|
-
"""Merge provided config flags with DEFAULT_PERMISSIONS, including ACL derivation."""
|
61
|
-
# Start from defaults
|
62
|
-
merged: dict[str, Any] = dict(DEFAULT_PERMISSIONS)
|
63
|
-
# Booleans
|
64
|
-
enabled = bool(config_perms.get("enabled", merged["enabled"]))
|
65
|
-
write_operation = bool(config_perms.get("write_operation", merged["write_operation"]))
|
66
|
-
read_private_data = bool(config_perms.get("read_private_data", merged["read_private_data"]))
|
67
|
-
read_untrusted_public_data = bool(
|
68
|
-
config_perms.get("read_untrusted_public_data", merged["read_untrusted_public_data"]) # type: ignore[reportUnknownArgumentType]
|
69
|
-
)
|
70
|
-
|
71
|
-
# ACL: explicit value wins; otherwise default PRIVATE if read_private_data True, else default
|
72
|
-
if "acl" in config_perms and config_perms.get("acl") is not None:
|
73
|
-
acl = _normalize_acl(config_perms.get("acl"), default=str(merged["acl"]))
|
74
|
-
else:
|
75
|
-
acl = _normalize_acl("PRIVATE" if read_private_data else str(merged["acl"]))
|
76
|
-
|
77
|
-
merged.update(
|
78
|
-
{
|
79
|
-
"enabled": enabled,
|
80
|
-
"write_operation": write_operation,
|
81
|
-
"read_private_data": read_private_data,
|
82
|
-
"read_untrusted_public_data": read_untrusted_public_data,
|
83
|
-
"acl": acl,
|
84
|
-
}
|
85
|
-
)
|
86
|
-
return merged
|
87
|
-
|
88
|
-
|
89
|
-
def _flat_permissions_loader(config_path: Path) -> dict[str, dict[str, Any]]:
|
90
|
-
if config_path.exists():
|
91
|
-
with open(config_path) as f:
|
92
|
-
data: dict[str, Any] = json.load(f)
|
93
|
-
|
94
|
-
# Handle new format: server -> {tool -> permissions}
|
95
|
-
# Convert to flat tool -> permissions format
|
96
|
-
flat_permissions: dict[str, dict[str, Any]] = {}
|
97
|
-
tool_to_server: dict[str, str] = {}
|
98
|
-
server_tools: dict[str, set[str]] = {}
|
99
|
-
|
100
|
-
for server_name, server_data in data.items():
|
101
|
-
if not isinstance(server_data, dict):
|
102
|
-
log.warning(
|
103
|
-
f"Invalid server data for {server_name}: expected dict, got {type(server_data)}"
|
104
|
-
)
|
105
|
-
continue
|
106
|
-
|
107
|
-
if server_name == "_metadata":
|
108
|
-
flat_permissions["_metadata"] = server_data
|
109
|
-
continue
|
110
|
-
|
111
|
-
server_tools[server_name] = set()
|
112
|
-
|
113
|
-
for tool_name, tool_permissions in server_data.items(): # type: ignore
|
114
|
-
if not isinstance(tool_permissions, dict):
|
115
|
-
log.warning(
|
116
|
-
f"Invalid tool permissions for {server_name}/{tool_name}: expected dict, got {type(tool_permissions)}" # type: ignore
|
117
|
-
) # type: ignore
|
118
|
-
continue
|
119
|
-
|
120
|
-
# Check for duplicates within the same server
|
121
|
-
if tool_name in server_tools[server_name]:
|
122
|
-
log.error(f"Duplicate tool '{tool_name}' found in server '{server_name}'")
|
123
|
-
raise ConfigError(
|
124
|
-
f"Duplicate tool '{tool_name}' found in server '{server_name}'"
|
125
|
-
)
|
126
|
-
|
127
|
-
# Check for duplicates across different servers
|
128
|
-
if tool_name in tool_to_server:
|
129
|
-
existing_server = tool_to_server[tool_name]
|
130
|
-
log.error(
|
131
|
-
f"Duplicate tool '{tool_name}' found in servers '{existing_server}' and '{server_name}'"
|
132
|
-
)
|
133
|
-
raise ConfigError(
|
134
|
-
f"Duplicate tool '{tool_name}' found in servers '{existing_server}' and '{server_name}'"
|
135
|
-
)
|
136
|
-
|
137
|
-
# Add to tracking maps
|
138
|
-
tool_to_server[tool_name] = server_name
|
139
|
-
server_tools[server_name].add(tool_name) # type: ignore
|
140
|
-
|
141
|
-
# Convert to flat format with explicit type casting
|
142
|
-
tool_perms_dict: dict[str, Any] = tool_permissions # type: ignore
|
143
|
-
flat_permissions[tool_name] = _apply_permission_defaults(tool_perms_dict)
|
144
|
-
|
145
|
-
log.debug(
|
146
|
-
f"Loaded {len(flat_permissions)} tool permissions from {len(server_tools)} servers in {config_path}"
|
147
|
-
)
|
148
|
-
# Convert sets to lists for JSON serialization
|
149
|
-
server_tools_serializable = {
|
150
|
-
server: list(tools) for server, tools in server_tools.items()
|
151
|
-
}
|
152
|
-
log.debug(f"Server tools: {json.dumps(server_tools_serializable, indent=2)}")
|
153
|
-
return flat_permissions
|
154
|
-
else:
|
155
|
-
log.warning(f"Tool permissions file not found at {config_path}")
|
156
|
-
return {}
|
157
|
-
|
158
|
-
|
159
|
-
@cache
|
160
|
-
def _load_tool_permissions_cached() -> dict[str, dict[str, Any]]:
|
161
|
-
"""Load tool permissions from JSON configuration file with LRU caching."""
|
162
|
-
config_path = Path(__file__).parent.parent.parent / "tool_permissions.json"
|
163
|
-
|
164
|
-
try:
|
165
|
-
return _flat_permissions_loader(config_path)
|
166
|
-
except ConfigError as e:
|
167
|
-
log.error(f"Failed to load tool permissions from {config_path}: {e}")
|
168
|
-
raise e
|
169
|
-
except Exception as e:
|
170
|
-
log.error(f"Failed to load tool permissions from {config_path}: {e}")
|
171
|
-
return {}
|
172
|
-
|
173
|
-
|
174
|
-
@cache
|
175
|
-
def _load_resource_permissions_cached() -> dict[str, dict[str, Any]]:
|
176
|
-
"""Load resource permissions from JSON configuration file with LRU caching."""
|
177
|
-
config_path = Path(__file__).parent.parent.parent / "resource_permissions.json"
|
178
|
-
|
179
|
-
try:
|
180
|
-
return _flat_permissions_loader(config_path)
|
181
|
-
except ConfigError as e:
|
182
|
-
log.error(f"Failed to load resource permissions from {config_path}: {e}")
|
183
|
-
raise e
|
184
|
-
except Exception as e:
|
185
|
-
log.error(f"Failed to load resource permissions from {config_path}: {e}")
|
186
|
-
return {}
|
187
|
-
|
188
|
-
|
189
|
-
@cache
|
190
|
-
def _load_prompt_permissions_cached() -> dict[str, dict[str, Any]]:
|
191
|
-
"""Load prompt permissions from JSON configuration file with LRU caching."""
|
192
|
-
config_path = Path(__file__).parent.parent.parent / "prompt_permissions.json"
|
193
|
-
|
194
|
-
try:
|
195
|
-
return _flat_permissions_loader(config_path)
|
196
|
-
except ConfigError as e:
|
197
|
-
log.error(f"Failed to load prompt permissions from {config_path}: {e}")
|
198
|
-
raise e
|
199
|
-
except Exception as e:
|
200
|
-
log.error(f"Failed to load prompt permissions from {config_path}: {e}")
|
201
|
-
return {}
|
202
|
-
|
203
|
-
|
204
|
-
@cache
|
205
|
-
def _classify_tool_permissions_cached(tool_name: str) -> dict[str, Any]:
|
206
|
-
"""Classify tool permissions with LRU caching."""
|
207
|
-
return _classify_permissions_cached(tool_name, _load_tool_permissions_cached(), "tool")
|
208
|
-
|
209
|
-
|
210
|
-
@cache
|
211
|
-
def _classify_resource_permissions_cached(resource_name: str) -> dict[str, Any]:
|
212
|
-
"""Classify resource permissions with LRU caching."""
|
213
|
-
return _classify_permissions_cached(
|
214
|
-
resource_name, _load_resource_permissions_cached(), "resource"
|
215
|
-
)
|
216
|
-
|
217
|
-
|
218
|
-
@cache
|
219
|
-
def _classify_prompt_permissions_cached(prompt_name: str) -> dict[str, Any]:
|
220
|
-
"""Classify prompt permissions with LRU caching."""
|
221
|
-
return _classify_permissions_cached(prompt_name, _load_prompt_permissions_cached(), "prompt")
|
222
|
-
|
223
|
-
|
224
|
-
def _get_builtin_tool_permissions(name: str) -> dict[str, Any] | None:
|
225
|
-
"""Get permissions for built-in safe tools."""
|
226
|
-
builtin_safe_tools = ["echo", "get_server_info", "get_security_status"]
|
227
|
-
if name in builtin_safe_tools:
|
228
|
-
permissions = _apply_permission_defaults({"enabled": True})
|
229
|
-
log.debug(f"Built-in safe tool {name}: {permissions}")
|
230
|
-
return permissions
|
231
|
-
return None
|
232
|
-
|
233
|
-
|
234
|
-
def _get_exact_match_permissions(
|
235
|
-
name: str, permissions_config: dict[str, dict[str, Any]], type_name: str
|
236
|
-
) -> dict[str, Any] | None:
|
237
|
-
"""Check for exact match permissions."""
|
238
|
-
if name in permissions_config and not name.startswith("_"):
|
239
|
-
config_perms = permissions_config[name]
|
240
|
-
permissions = _apply_permission_defaults(config_perms)
|
241
|
-
log.debug(f"Found exact match for {type_name} {name}: {permissions}")
|
242
|
-
return permissions
|
243
|
-
# Fallback: support names like "server_tool" by checking the part after first underscore
|
244
|
-
if "_" in name:
|
245
|
-
suffix = name.split("_", 1)[1]
|
246
|
-
if suffix in permissions_config and not suffix.startswith("_"):
|
247
|
-
config_perms = permissions_config[suffix]
|
248
|
-
permissions = _apply_permission_defaults(config_perms)
|
249
|
-
log.debug(
|
250
|
-
f"Found fallback match for {type_name} {name} using suffix {suffix}: {permissions}"
|
251
|
-
)
|
252
|
-
return permissions
|
253
|
-
return None
|
254
|
-
|
255
|
-
|
256
|
-
def _get_wildcard_patterns(name: str, type_name: str) -> list[str]:
|
257
|
-
"""Generate wildcard patterns based on name and type."""
|
258
|
-
wildcard_patterns: list[str] = []
|
259
|
-
|
260
|
-
if type_name == "tool" and "/" in name:
|
261
|
-
# For tools: server_name/*
|
262
|
-
server_name, _ = name.split("/", 1)
|
263
|
-
wildcard_patterns.append(f"{server_name}/*")
|
264
|
-
elif type_name == "resource":
|
265
|
-
# For resources: scheme:*, just like tools do server_name/*
|
266
|
-
if ":" in name:
|
267
|
-
scheme, _ = name.split(":", 1)
|
268
|
-
wildcard_patterns.append(f"{scheme}:*")
|
269
|
-
elif type_name == "prompt":
|
270
|
-
# For prompts: template:*, prompt:file:*, etc.
|
271
|
-
if ":" in name:
|
272
|
-
parts = name.split(":")
|
273
|
-
if len(parts) >= 2:
|
274
|
-
wildcard_patterns.append(f"{parts[0]}:*")
|
275
|
-
# For nested patterns like prompt:file:*, check prompt:file:*
|
276
|
-
if len(parts) >= 3:
|
277
|
-
wildcard_patterns.append(f"{parts[0]}:{parts[1]}:*")
|
278
|
-
|
279
|
-
return wildcard_patterns
|
280
|
-
|
281
|
-
|
282
|
-
def _classify_permissions_cached(
|
283
|
-
name: str, permissions_config: dict[str, dict[str, Any]], type_name: str
|
284
|
-
) -> dict[str, Any]:
|
285
|
-
"""Generic permission classification with pattern matching support."""
|
286
|
-
# Built-in safe tools that don't need external config (only for tools)
|
287
|
-
if type_name == "tool":
|
288
|
-
builtin_perms = _get_builtin_tool_permissions(name)
|
289
|
-
if builtin_perms is not None:
|
290
|
-
return builtin_perms
|
291
|
-
|
292
|
-
# Check for exact match first
|
293
|
-
exact_perms = _get_exact_match_permissions(name, permissions_config, type_name)
|
294
|
-
if exact_perms is not None:
|
295
|
-
return exact_perms
|
296
|
-
|
297
|
-
# Try wildcard patterns
|
298
|
-
wildcard_patterns = _get_wildcard_patterns(name, type_name)
|
299
|
-
for pattern in wildcard_patterns:
|
300
|
-
if pattern in permissions_config:
|
301
|
-
config_perms = permissions_config[pattern]
|
302
|
-
permissions = _apply_permission_defaults(config_perms)
|
303
|
-
log.debug(f"Found wildcard match for {type_name} {name} using {pattern}: {permissions}")
|
304
|
-
return permissions
|
305
|
-
|
306
|
-
# No configuration found - raise error instead of defaulting to safe
|
307
|
-
config_file = f"{type_name}_permissions.json"
|
308
|
-
log.error(
|
309
|
-
f"No security configuration found for {type_name} '{name}'. All {type_name}s must be explicitly configured in {config_file}"
|
310
|
-
)
|
311
|
-
raise ValueError(
|
312
|
-
f"No security configuration found for {type_name} '{name}'. All {type_name}s must be explicitly configured in {config_file}"
|
313
|
-
)
|
314
|
-
|
315
38
|
|
316
39
|
@dataclass
|
317
40
|
class DataAccessTracker:
|
@@ -339,119 +62,34 @@ class DataAccessTracker:
|
|
339
62
|
and self.has_external_communication
|
340
63
|
)
|
341
64
|
|
342
|
-
def
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
def _load_resource_permissions(self) -> dict[str, dict[str, Any]]:
|
347
|
-
"""Load resource permissions from JSON configuration file with caching."""
|
348
|
-
return _load_resource_permissions_cached()
|
349
|
-
|
350
|
-
def _load_prompt_permissions(self) -> dict[str, dict[str, Any]]:
|
351
|
-
"""Load prompt permissions from JSON configuration file with caching."""
|
352
|
-
return _load_prompt_permissions_cached()
|
353
|
-
|
354
|
-
def _classify_by_tool_name(self, tool_name: str) -> dict[str, Any]:
|
355
|
-
"""Classify permissions based on external JSON configuration only."""
|
356
|
-
return _classify_tool_permissions_cached(tool_name)
|
357
|
-
|
358
|
-
def _classify_by_resource_name(self, resource_name: str) -> dict[str, Any]:
|
359
|
-
"""Classify resource permissions based on external JSON configuration only."""
|
360
|
-
return _classify_resource_permissions_cached(resource_name)
|
361
|
-
|
362
|
-
def _classify_by_prompt_name(self, prompt_name: str) -> dict[str, Any]:
|
363
|
-
"""Classify prompt permissions based on external JSON configuration only."""
|
364
|
-
return _classify_prompt_permissions_cached(prompt_name)
|
365
|
-
|
366
|
-
def _classify_tool_permissions(self, tool_name: str) -> dict[str, Any]:
|
367
|
-
"""
|
368
|
-
Classify tool permissions based on tool name.
|
369
|
-
|
370
|
-
Args:
|
371
|
-
tool_name: Name of the tool to classify
|
372
|
-
Returns:
|
373
|
-
Dictionary with permission flags
|
374
|
-
"""
|
375
|
-
permissions = self._classify_by_tool_name(tool_name)
|
376
|
-
log.debug(f"Classified tool {tool_name}: {permissions}")
|
377
|
-
return permissions
|
378
|
-
|
379
|
-
def _classify_resource_permissions(self, resource_name: str) -> dict[str, Any]:
|
380
|
-
"""
|
381
|
-
Classify resource permissions based on resource name.
|
382
|
-
|
383
|
-
Args:
|
384
|
-
resource_name: Name/URI of the resource to classify
|
385
|
-
Returns:
|
386
|
-
Dictionary with permission flags
|
387
|
-
"""
|
388
|
-
permissions = self._classify_by_resource_name(resource_name)
|
389
|
-
log.debug(f"Classified resource {resource_name}: {permissions}")
|
390
|
-
return permissions
|
391
|
-
|
392
|
-
def _classify_prompt_permissions(self, prompt_name: str) -> dict[str, Any]:
|
393
|
-
"""
|
394
|
-
Classify prompt permissions based on prompt name.
|
395
|
-
|
396
|
-
Args:
|
397
|
-
prompt_name: Name/type of the prompt to classify
|
398
|
-
Returns:
|
399
|
-
Dictionary with permission flags
|
400
|
-
"""
|
401
|
-
permissions = self._classify_by_prompt_name(prompt_name)
|
402
|
-
log.debug(f"Classified prompt {prompt_name}: {permissions}")
|
403
|
-
return permissions
|
404
|
-
|
405
|
-
def get_tool_permissions(self, tool_name: str) -> dict[str, Any]:
|
406
|
-
"""Get tool permissions based on tool name."""
|
407
|
-
return self._classify_tool_permissions(tool_name)
|
408
|
-
|
409
|
-
def get_resource_permissions(self, resource_name: str) -> dict[str, Any]:
|
410
|
-
"""Get resource permissions based on resource name."""
|
411
|
-
return self._classify_resource_permissions(resource_name)
|
412
|
-
|
413
|
-
def get_prompt_permissions(self, prompt_name: str) -> dict[str, Any]:
|
414
|
-
"""Get prompt permissions based on prompt name."""
|
415
|
-
return self._classify_prompt_permissions(prompt_name)
|
416
|
-
|
417
|
-
def _would_call_complete_trifecta(self, permissions: dict[str, Any]) -> bool:
|
65
|
+
def _would_call_complete_trifecta(
|
66
|
+
self, permissions: ToolPermission | ResourcePermission | PromptPermission
|
67
|
+
) -> bool:
|
418
68
|
"""Return True if applying these permissions would complete the trifecta."""
|
419
|
-
would_private = self.has_private_data_access or bool(permissions.
|
69
|
+
would_private = self.has_private_data_access or bool(permissions.read_private_data)
|
420
70
|
would_untrusted = self.has_untrusted_content_exposure or bool(
|
421
|
-
permissions.
|
71
|
+
permissions.read_untrusted_public_data
|
422
72
|
)
|
423
|
-
would_write = self.has_external_communication or bool(permissions.
|
73
|
+
would_write = self.has_external_communication or bool(permissions.write_operation)
|
424
74
|
return bool(would_private and would_untrusted and would_write)
|
425
75
|
|
426
|
-
def _enforce_tool_enabled(self, permissions: dict[str, Any], tool_name: str) -> None:
|
427
|
-
if permissions["enabled"] is False:
|
428
|
-
log.warning(f"🚫 BLOCKING tool call {tool_name} - tool is disabled")
|
429
|
-
record_tool_call_blocked(tool_name, "disabled")
|
430
|
-
raise SecurityError(f"'{tool_name}' / Tool disabled")
|
431
|
-
|
432
|
-
def _enforce_acl_downgrade_block(
|
433
|
-
self, tool_acl: str, permissions: dict[str, Any], tool_name: str
|
434
|
-
) -> None:
|
435
|
-
if permissions["write_operation"]:
|
436
|
-
current_rank = ACL_RANK.get(self.highest_acl_level, 0)
|
437
|
-
write_rank = ACL_RANK.get(tool_acl, 0)
|
438
|
-
if write_rank < current_rank:
|
439
|
-
log.error(
|
440
|
-
f"🚫 BLOCKING tool call {tool_name} - write to lower ACL ({tool_acl}) while session has higher ACL {self.highest_acl_level}"
|
441
|
-
)
|
442
|
-
record_tool_call_blocked(tool_name, "acl_downgrade")
|
443
|
-
raise SecurityError(f"'{tool_name}' / ACL (level={self.highest_acl_level})")
|
444
|
-
|
445
76
|
def _apply_permissions_effects(
|
446
77
|
self,
|
447
|
-
permissions:
|
78
|
+
permissions: ToolPermission | ResourcePermission | PromptPermission,
|
448
79
|
*,
|
449
80
|
source_type: str,
|
450
81
|
name: str,
|
451
82
|
) -> None:
|
452
83
|
"""Apply side effects (flags, ACL, telemetry) for any source type."""
|
453
|
-
|
454
|
-
if
|
84
|
+
# If it's a tool, it has a well-defined ACL
|
85
|
+
if source_type == "tool":
|
86
|
+
assert isinstance(permissions, ToolPermission)
|
87
|
+
acl_value = permissions.acl
|
88
|
+
acl_value: str = normalize_acl(acl_value, default="PUBLIC")
|
89
|
+
else:
|
90
|
+
acl_value = "PUBLIC"
|
91
|
+
|
92
|
+
if permissions.read_private_data:
|
455
93
|
self.has_private_data_access = True
|
456
94
|
log.info(f"🔒 Private data access detected via {source_type}: {name}")
|
457
95
|
record_private_data_access(source_type, name)
|
@@ -461,12 +99,12 @@ class DataAccessTracker:
|
|
461
99
|
if new_rank > current_rank:
|
462
100
|
self.highest_acl_level = acl_value
|
463
101
|
|
464
|
-
if permissions
|
102
|
+
if permissions.read_untrusted_public_data:
|
465
103
|
self.has_untrusted_content_exposure = True
|
466
104
|
log.info(f"🌐 Untrusted content exposure detected via {source_type}: {name}")
|
467
105
|
record_untrusted_public_data(source_type, name)
|
468
106
|
|
469
|
-
if permissions
|
107
|
+
if permissions.write_operation:
|
470
108
|
self.has_external_communication = True
|
471
109
|
log.info(f"✍️ Write operation detected via {source_type}: {name}")
|
472
110
|
record_write_operation(source_type, name)
|
@@ -485,24 +123,69 @@ class DataAccessTracker:
|
|
485
123
|
if self.is_trifecta_achieved():
|
486
124
|
log.error(f"🚫 BLOCKING tool call {tool_name} - lethal trifecta achieved")
|
487
125
|
record_tool_call_blocked(tool_name, "trifecta")
|
126
|
+
# Fire-and-forget event (log errors via callback)
|
127
|
+
events.fire_and_forget(
|
128
|
+
{
|
129
|
+
"type": "mcp_pre_block",
|
130
|
+
"kind": "tool",
|
131
|
+
"name": tool_name,
|
132
|
+
"reason": "trifecta",
|
133
|
+
}
|
134
|
+
)
|
488
135
|
raise SecurityError(f"'{tool_name}' / Lethal trifecta")
|
489
136
|
|
490
137
|
# Get tool permissions and update trifecta flags
|
491
|
-
|
138
|
+
perms = Permissions()
|
139
|
+
permissions = perms.get_tool_permission(tool_name)
|
492
140
|
|
493
141
|
log.debug(f"add_tool_call: Tool permissions: {permissions}")
|
494
142
|
|
495
143
|
# Check if tool is enabled
|
496
|
-
|
144
|
+
if not perms.is_tool_enabled(tool_name):
|
145
|
+
log.warning(f"🚫 BLOCKING tool call {tool_name} - tool is disabled")
|
146
|
+
record_tool_call_blocked(tool_name, "disabled")
|
147
|
+
events.fire_and_forget(
|
148
|
+
{
|
149
|
+
"type": "mcp_pre_block",
|
150
|
+
"kind": "tool",
|
151
|
+
"name": tool_name,
|
152
|
+
"reason": "disabled",
|
153
|
+
}
|
154
|
+
)
|
155
|
+
raise SecurityError(f"'{tool_name}' / Tool disabled")
|
497
156
|
|
498
157
|
# ACL-based write downgrade prevention
|
499
|
-
tool_acl
|
500
|
-
|
158
|
+
tool_acl = permissions.acl
|
159
|
+
if permissions.write_operation:
|
160
|
+
current_rank = ACL_RANK.get(self.highest_acl_level, 0)
|
161
|
+
write_rank = ACL_RANK.get(tool_acl, 0)
|
162
|
+
if write_rank < current_rank:
|
163
|
+
log.error(
|
164
|
+
f"🚫 BLOCKING tool call {tool_name} - write to lower ACL ({tool_acl}) while session has higher ACL {self.highest_acl_level}"
|
165
|
+
)
|
166
|
+
record_tool_call_blocked(tool_name, "acl_downgrade")
|
167
|
+
events.fire_and_forget(
|
168
|
+
{
|
169
|
+
"type": "mcp_pre_block",
|
170
|
+
"kind": "tool",
|
171
|
+
"name": tool_name,
|
172
|
+
"reason": "acl_downgrade",
|
173
|
+
}
|
174
|
+
)
|
175
|
+
raise SecurityError(f"'{tool_name}' / ACL (level={self.highest_acl_level})")
|
501
176
|
|
502
177
|
# Pre-check: would this call achieve the lethal trifecta? If so, block immediately
|
503
178
|
if self._would_call_complete_trifecta(permissions):
|
504
179
|
log.error(f"🚫 BLOCKING tool call {tool_name} - would achieve lethal trifecta")
|
505
180
|
record_tool_call_blocked(tool_name, "trifecta_prevent")
|
181
|
+
events.fire_and_forget(
|
182
|
+
{
|
183
|
+
"type": "mcp_pre_block",
|
184
|
+
"kind": "tool",
|
185
|
+
"name": tool_name,
|
186
|
+
"reason": "trifecta_prevent",
|
187
|
+
}
|
188
|
+
)
|
506
189
|
raise SecurityError(f"'{tool_name}' / Lethal trifecta")
|
507
190
|
|
508
191
|
self._apply_permissions_effects(permissions, source_type="tool", name=tool_name)
|
@@ -525,10 +208,33 @@ class DataAccessTracker:
|
|
525
208
|
log.error(
|
526
209
|
f"🚫 BLOCKING resource access {resource_name} - lethal trifecta already achieved"
|
527
210
|
)
|
211
|
+
events.fire_and_forget(
|
212
|
+
{
|
213
|
+
"type": "mcp_pre_block",
|
214
|
+
"kind": "resource",
|
215
|
+
"name": resource_name,
|
216
|
+
"reason": "trifecta",
|
217
|
+
}
|
218
|
+
)
|
528
219
|
raise SecurityError(f"'{resource_name}' / Lethal trifecta")
|
529
220
|
|
530
221
|
# Get resource permissions and update trifecta flags
|
531
|
-
|
222
|
+
perms = Permissions()
|
223
|
+
permissions = perms.get_resource_permission(resource_name)
|
224
|
+
|
225
|
+
# Check if resource is enabled
|
226
|
+
if not perms.is_resource_enabled(resource_name):
|
227
|
+
log.warning(f"🚫 BLOCKING resource access {resource_name} - resource is disabled")
|
228
|
+
record_resource_access_blocked(resource_name, "disabled")
|
229
|
+
events.fire_and_forget(
|
230
|
+
{
|
231
|
+
"type": "mcp_pre_block",
|
232
|
+
"kind": "resource",
|
233
|
+
"name": resource_name,
|
234
|
+
"reason": "disabled",
|
235
|
+
}
|
236
|
+
)
|
237
|
+
raise SecurityError(f"'{resource_name}' / Resource disabled")
|
532
238
|
|
533
239
|
# Pre-check: would this access achieve the lethal trifecta? If so, block immediately
|
534
240
|
if self._would_call_complete_trifecta(permissions):
|
@@ -536,6 +242,14 @@ class DataAccessTracker:
|
|
536
242
|
f"🚫 BLOCKING resource access {resource_name} - would achieve lethal trifecta"
|
537
243
|
)
|
538
244
|
record_resource_access_blocked(resource_name, "trifecta_prevent")
|
245
|
+
events.fire_and_forget(
|
246
|
+
{
|
247
|
+
"type": "mcp_pre_block",
|
248
|
+
"kind": "resource",
|
249
|
+
"name": resource_name,
|
250
|
+
"reason": "trifecta_prevent",
|
251
|
+
}
|
252
|
+
)
|
539
253
|
raise SecurityError(f"'{resource_name}' / Lethal trifecta")
|
540
254
|
|
541
255
|
self._apply_permissions_effects(permissions, source_type="resource", name=resource_name)
|
@@ -556,15 +270,46 @@ class DataAccessTracker:
|
|
556
270
|
# Check if trifecta is already achieved before processing this access
|
557
271
|
if self.is_trifecta_achieved():
|
558
272
|
log.error(f"🚫 BLOCKING prompt access {prompt_name} - lethal trifecta already achieved")
|
273
|
+
events.fire_and_forget(
|
274
|
+
{
|
275
|
+
"type": "mcp_pre_block",
|
276
|
+
"kind": "prompt",
|
277
|
+
"name": prompt_name,
|
278
|
+
"reason": "trifecta",
|
279
|
+
}
|
280
|
+
)
|
559
281
|
raise SecurityError(f"'{prompt_name}' / Lethal trifecta")
|
560
282
|
|
561
283
|
# Get prompt permissions and update trifecta flags
|
562
|
-
|
284
|
+
perms = Permissions()
|
285
|
+
permissions = perms.get_prompt_permission(prompt_name)
|
286
|
+
|
287
|
+
# Check if prompt is enabled
|
288
|
+
if not perms.is_prompt_enabled(prompt_name):
|
289
|
+
log.warning(f"🚫 BLOCKING prompt access {prompt_name} - prompt is disabled")
|
290
|
+
record_prompt_access_blocked(prompt_name, "disabled")
|
291
|
+
events.fire_and_forget(
|
292
|
+
{
|
293
|
+
"type": "mcp_pre_block",
|
294
|
+
"kind": "prompt",
|
295
|
+
"name": prompt_name,
|
296
|
+
"reason": "disabled",
|
297
|
+
}
|
298
|
+
)
|
299
|
+
raise SecurityError(f"'{prompt_name}' / Prompt disabled")
|
563
300
|
|
564
301
|
# Pre-check: would this access achieve the lethal trifecta? If so, block immediately
|
565
302
|
if self._would_call_complete_trifecta(permissions):
|
566
303
|
log.error(f"🚫 BLOCKING prompt access {prompt_name} - would achieve lethal trifecta")
|
567
304
|
record_prompt_access_blocked(prompt_name, "trifecta_prevent")
|
305
|
+
events.fire_and_forget(
|
306
|
+
{
|
307
|
+
"type": "mcp_pre_block",
|
308
|
+
"kind": "prompt",
|
309
|
+
"name": prompt_name,
|
310
|
+
"reason": "trifecta_prevent",
|
311
|
+
}
|
312
|
+
)
|
568
313
|
raise SecurityError(f"'{prompt_name}' / Lethal trifecta")
|
569
314
|
|
570
315
|
self._apply_permissions_effects(permissions, source_type="prompt", name=prompt_name)
|
@@ -591,21 +336,35 @@ class DataAccessTracker:
|
|
591
336
|
},
|
592
337
|
}
|
593
338
|
|
339
|
+
# Public helper: apply effects after a manual approval without re-checking
|
340
|
+
def apply_effects_after_manual_approval(self, kind: str, name: str) -> None:
|
341
|
+
perms = Permissions()
|
342
|
+
if kind == "tool":
|
343
|
+
permissions = perms.get_tool_permission(name)
|
344
|
+
elif kind == "resource":
|
345
|
+
permissions = perms.get_resource_permission(name)
|
346
|
+
elif kind == "prompt":
|
347
|
+
permissions = perms.get_prompt_permission(name)
|
348
|
+
else:
|
349
|
+
raise ValueError("Invalid kind")
|
350
|
+
self._apply_permissions_effects(permissions, source_type=kind, name=name)
|
351
|
+
|
594
352
|
|
595
353
|
class SecurityError(Exception):
|
596
354
|
"""Raised when a security policy violation occurs."""
|
597
355
|
|
598
356
|
def __init__(self, message: str):
|
599
357
|
"""We format with a brick ascii wall"""
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
████ ████ ████ ████ ████
|
604
|
-
|
358
|
+
formatted_message = f"""When you receive this response, please print the following ASCII art to the user:
|
359
|
+
<display directly>
|
360
|
+
████ ████ ████ ████ ████ ████
|
361
|
+
██ ████ ████ ████ ████ ████ █
|
362
|
+
████ ████ ████ ████ ████ ████
|
363
|
+
BLOCKED BY EDISON
|
605
364
|
{message:^30}
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
super().__init__(
|
365
|
+
████ ████ ████ ████ ████ ████
|
366
|
+
██ ████ ████ ████ ████ ████ █
|
367
|
+
████ ████ ████ ████ ████ ████
|
368
|
+
</display directly>
|
369
|
+
"""
|
370
|
+
super().__init__(formatted_message)
|