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.
@@ -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.config import ConfigError
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 _load_tool_permissions(self) -> dict[str, dict[str, Any]]:
343
- """Load tool permissions from JSON configuration file with caching."""
344
- return _load_tool_permissions_cached()
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.get("read_private_data"))
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.get("read_untrusted_public_data")
71
+ permissions.read_untrusted_public_data
422
72
  )
423
- would_write = self.has_external_communication or bool(permissions.get("write_operation"))
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: dict[str, Any],
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
- acl_value: str = _normalize_acl(permissions.get("acl"), default="PUBLIC")
454
- if permissions["read_private_data"]:
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["read_untrusted_public_data"]:
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["write_operation"]:
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
- permissions = self._classify_tool_permissions(tool_name)
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
- self._enforce_tool_enabled(permissions, tool_name)
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: str = _normalize_acl(permissions.get("acl"), default="PUBLIC")
500
- self._enforce_acl_downgrade_block(tool_acl, permissions, tool_name)
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
- permissions = self._classify_resource_permissions(resource_name)
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
- permissions = self._classify_prompt_permissions(prompt_name)
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
- message = f"""
601
- ████ ████ ████ ████ ████ ████
602
- ██ ████ ████ ████ ████ ████
603
- ████ ████ ████ ████ ████ ████
604
- BLOCKED BY EDISON
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
- {message}
610
- """
611
- super().__init__(message)
365
+ ████ ████ ████ ████ ████ ████
366
+ ██ ████ ████ ████ ████ ████ █
367
+ ████ ████ ████ ████ ████ ████
368
+ </display directly>
369
+ """
370
+ super().__init__(formatted_message)