open-edison 0.1.10__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.
@@ -0,0 +1,510 @@
1
+ """
2
+ Data Access Tracker for Open Edison
3
+
4
+ This module defines the DataAccessTracker class that monitors the "lethal trifecta"
5
+ of security risks for AI agents: access to private data, exposure to untrusted content,
6
+ and ability to externally communicate.
7
+
8
+ Permissions are loaded from external JSON configuration files that map
9
+ names (with server-name/path prefixes) to their security classifications:
10
+ - tool_permissions.json: Tool security classifications
11
+ - resource_permissions.json: Resource access security classifications
12
+ - prompt_permissions.json: Prompt security classifications
13
+ """
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from functools import lru_cache
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from loguru import logger as log
22
+
23
+ from src.config import ConfigError
24
+
25
+
26
+ def _flat_permissions_loader(config_path: Path) -> dict[str, dict[str, bool]]:
27
+ if config_path.exists():
28
+ with open(config_path) as f:
29
+ data: dict[str, Any] = json.load(f)
30
+
31
+ # Handle new format: server -> {tool -> permissions}
32
+ # Convert to flat tool -> permissions format
33
+ flat_permissions: dict[str, dict[str, bool]] = {}
34
+ tool_to_server: dict[str, str] = {}
35
+ server_tools: dict[str, set[str]] = {}
36
+
37
+ for server_name, server_data in data.items():
38
+ if not isinstance(server_data, dict):
39
+ log.warning(
40
+ f"Invalid server data for {server_name}: expected dict, got {type(server_data)}"
41
+ )
42
+ continue
43
+
44
+ if server_name == "_metadata":
45
+ flat_permissions["_metadata"] = server_data
46
+ continue
47
+
48
+ server_tools[server_name] = set()
49
+
50
+ for tool_name, tool_permissions in server_data.items(): # type: ignore
51
+ if not isinstance(tool_permissions, dict):
52
+ log.warning(
53
+ f"Invalid tool permissions for {server_name}/{tool_name}: expected dict, got {type(tool_permissions)}" # type: ignore
54
+ ) # type: ignore
55
+ continue
56
+
57
+ # Check for duplicates within the same server
58
+ if tool_name in server_tools[server_name]:
59
+ log.error(f"Duplicate tool '{tool_name}' found in server '{server_name}'")
60
+ raise ConfigError(
61
+ f"Duplicate tool '{tool_name}' found in server '{server_name}'"
62
+ )
63
+
64
+ # Check for duplicates across different servers
65
+ if tool_name in tool_to_server:
66
+ existing_server = tool_to_server[tool_name]
67
+ log.error(
68
+ f"Duplicate tool '{tool_name}' found in servers '{existing_server}' and '{server_name}'"
69
+ )
70
+ raise ConfigError(
71
+ f"Duplicate tool '{tool_name}' found in servers '{existing_server}' and '{server_name}'"
72
+ )
73
+
74
+ # Add to tracking maps
75
+ tool_to_server[tool_name] = server_name
76
+ server_tools[server_name].add(tool_name) # type: ignore
77
+
78
+ # Convert to flat format with explicit type casting
79
+ tool_perms_dict: dict[str, Any] = tool_permissions # type: ignore
80
+ flat_permissions[tool_name] = {
81
+ "enabled": bool(tool_perms_dict.get("enabled", True)),
82
+ "write_operation": bool(tool_perms_dict.get("write_operation", False)),
83
+ "read_private_data": bool(tool_perms_dict.get("read_private_data", False)),
84
+ "read_untrusted_public_data": bool(
85
+ tool_perms_dict.get("read_untrusted_public_data", False)
86
+ ),
87
+ }
88
+
89
+ log.debug(
90
+ f"Loaded {len(flat_permissions)} tool permissions from {len(server_tools)} servers in {config_path}"
91
+ )
92
+ # Convert sets to lists for JSON serialization
93
+ server_tools_serializable = {
94
+ server: list(tools) for server, tools in server_tools.items()
95
+ }
96
+ log.debug(f"Server tools: {json.dumps(server_tools_serializable, indent=2)}")
97
+ return flat_permissions
98
+ else:
99
+ log.warning(f"Tool permissions file not found at {config_path}")
100
+ return {}
101
+
102
+
103
+ @lru_cache(maxsize=1)
104
+ def _load_tool_permissions_cached() -> dict[str, dict[str, bool]]:
105
+ """Load tool permissions from JSON configuration file with LRU caching."""
106
+ config_path = Path(__file__).parent.parent.parent / "tool_permissions.json"
107
+
108
+ try:
109
+ return _flat_permissions_loader(config_path)
110
+ except ConfigError as e:
111
+ log.error(f"Failed to load tool permissions from {config_path}: {e}")
112
+ raise e
113
+ except Exception as e:
114
+ log.error(f"Failed to load tool permissions from {config_path}: {e}")
115
+ return {}
116
+
117
+
118
+ @lru_cache(maxsize=1)
119
+ def _load_resource_permissions_cached() -> dict[str, dict[str, bool]]:
120
+ """Load resource permissions from JSON configuration file with LRU caching."""
121
+ config_path = Path(__file__).parent.parent.parent / "resource_permissions.json"
122
+
123
+ try:
124
+ return _flat_permissions_loader(config_path)
125
+ except ConfigError as e:
126
+ log.error(f"Failed to load resource permissions from {config_path}: {e}")
127
+ raise e
128
+ except Exception as e:
129
+ log.error(f"Failed to load resource permissions from {config_path}: {e}")
130
+ return {}
131
+
132
+
133
+ @lru_cache(maxsize=1)
134
+ def _load_prompt_permissions_cached() -> dict[str, dict[str, bool]]:
135
+ """Load prompt permissions from JSON configuration file with LRU caching."""
136
+ config_path = Path(__file__).parent.parent.parent / "prompt_permissions.json"
137
+
138
+ try:
139
+ return _flat_permissions_loader(config_path)
140
+ except ConfigError as e:
141
+ log.error(f"Failed to load prompt permissions from {config_path}: {e}")
142
+ raise e
143
+ except Exception as e:
144
+ log.error(f"Failed to load prompt permissions from {config_path}: {e}")
145
+ return {}
146
+
147
+
148
+ @lru_cache(maxsize=128)
149
+ def _classify_tool_permissions_cached(tool_name: str) -> dict[str, bool]:
150
+ """Classify tool permissions with LRU caching."""
151
+ return _classify_permissions_cached(tool_name, _load_tool_permissions_cached(), "tool")
152
+
153
+
154
+ @lru_cache(maxsize=128)
155
+ def _classify_resource_permissions_cached(resource_name: str) -> dict[str, bool]:
156
+ """Classify resource permissions with LRU caching."""
157
+ return _classify_permissions_cached(
158
+ resource_name, _load_resource_permissions_cached(), "resource"
159
+ )
160
+
161
+
162
+ @lru_cache(maxsize=128)
163
+ def _classify_prompt_permissions_cached(prompt_name: str) -> dict[str, bool]:
164
+ """Classify prompt permissions with LRU caching."""
165
+ return _classify_permissions_cached(prompt_name, _load_prompt_permissions_cached(), "prompt")
166
+
167
+
168
+ def _get_builtin_tool_permissions(name: str) -> dict[str, bool] | None:
169
+ """Get permissions for built-in safe tools."""
170
+ builtin_safe_tools = ["echo", "get_server_info", "get_security_status"]
171
+ if name in builtin_safe_tools:
172
+ permissions = {
173
+ "enabled": True,
174
+ "write_operation": False,
175
+ "read_private_data": False,
176
+ "read_untrusted_public_data": False,
177
+ }
178
+ log.debug(f"Built-in safe tool {name}: {permissions}")
179
+ return permissions
180
+ return None
181
+
182
+
183
+ def _get_exact_match_permissions(
184
+ name: str, permissions_config: dict[str, dict[str, bool]], type_name: str
185
+ ) -> dict[str, bool] | None:
186
+ """Check for exact match permissions."""
187
+ if name in permissions_config and not name.startswith("_"):
188
+ config_perms = permissions_config[name]
189
+ permissions = {
190
+ "enabled": config_perms.get("enabled", False),
191
+ "write_operation": config_perms.get("write_operation", False),
192
+ "read_private_data": config_perms.get("read_private_data", False),
193
+ "read_untrusted_public_data": config_perms.get("read_untrusted_public_data", False),
194
+ }
195
+ log.debug(f"Found exact match for {type_name} {name}: {permissions}")
196
+ return permissions
197
+ return None
198
+
199
+
200
+ def _get_wildcard_patterns(name: str, type_name: str) -> list[str]:
201
+ """Generate wildcard patterns based on name and type."""
202
+ wildcard_patterns: list[str] = []
203
+
204
+ if type_name == "tool" and "/" in name:
205
+ # For tools: server_name/*
206
+ server_name, _ = name.split("/", 1)
207
+ wildcard_patterns.append(f"{server_name}/*")
208
+ elif type_name == "resource":
209
+ # For resources: scheme:*, just like tools do server_name/*
210
+ if ":" in name:
211
+ scheme, _ = name.split(":", 1)
212
+ wildcard_patterns.append(f"{scheme}:*")
213
+ elif type_name == "prompt":
214
+ # For prompts: template:*, prompt:file:*, etc.
215
+ if ":" in name:
216
+ parts = name.split(":")
217
+ if len(parts) >= 2:
218
+ wildcard_patterns.append(f"{parts[0]}:*")
219
+ # For nested patterns like prompt:file:*, check prompt:file:*
220
+ if len(parts) >= 3:
221
+ wildcard_patterns.append(f"{parts[0]}:{parts[1]}:*")
222
+
223
+ return wildcard_patterns
224
+
225
+
226
+ def _classify_permissions_cached(
227
+ name: str, permissions_config: dict[str, dict[str, bool]], type_name: str
228
+ ) -> dict[str, bool]:
229
+ """Generic permission classification with pattern matching support."""
230
+ # Built-in safe tools that don't need external config (only for tools)
231
+ if type_name == "tool":
232
+ builtin_perms = _get_builtin_tool_permissions(name)
233
+ if builtin_perms is not None:
234
+ return builtin_perms
235
+
236
+ # Check for exact match first
237
+ exact_perms = _get_exact_match_permissions(name, permissions_config, type_name)
238
+ if exact_perms is not None:
239
+ return exact_perms
240
+
241
+ # Try wildcard patterns
242
+ wildcard_patterns = _get_wildcard_patterns(name, type_name)
243
+ for pattern in wildcard_patterns:
244
+ if pattern in permissions_config:
245
+ config_perms = permissions_config[pattern]
246
+ permissions = {
247
+ "enabled": config_perms.get("enabled", False),
248
+ "write_operation": config_perms.get("write_operation", False),
249
+ "read_private_data": config_perms.get("read_private_data", False),
250
+ "read_untrusted_public_data": config_perms.get("read_untrusted_public_data", False),
251
+ }
252
+ log.debug(f"Found wildcard match for {type_name} {name} using {pattern}: {permissions}")
253
+ return permissions
254
+
255
+ # No configuration found - raise error instead of defaulting to safe
256
+ config_file = f"{type_name}_permissions.json"
257
+ log.error(
258
+ f"No security configuration found for {type_name} '{name}'. All {type_name}s must be explicitly configured in {config_file}"
259
+ )
260
+ raise ValueError(
261
+ f"No security configuration found for {type_name} '{name}'. All {type_name}s must be explicitly configured in {config_file}"
262
+ )
263
+
264
+
265
+ @dataclass
266
+ class DataAccessTracker:
267
+ """
268
+ Tracks the "lethal trifecta" of security risks for AI agents.
269
+
270
+ The lethal trifecta consists of:
271
+ 1. Access to private data (read_private_data)
272
+ 2. Exposure to untrusted content (read_untrusted_public_data)
273
+ 3. Ability to externally communicate (write_operation)
274
+ """
275
+
276
+ # Lethal trifecta flags
277
+ has_private_data_access: bool = False
278
+ has_untrusted_content_exposure: bool = False
279
+ has_external_communication: bool = False
280
+
281
+ def is_trifecta_achieved(self) -> bool:
282
+ """Check if the lethal trifecta has been achieved."""
283
+ return (
284
+ self.has_private_data_access
285
+ and self.has_untrusted_content_exposure
286
+ and self.has_external_communication
287
+ )
288
+
289
+ def _load_tool_permissions(self) -> dict[str, dict[str, bool]]:
290
+ """Load tool permissions from JSON configuration file with caching."""
291
+ return _load_tool_permissions_cached()
292
+
293
+ def _load_resource_permissions(self) -> dict[str, dict[str, bool]]:
294
+ """Load resource permissions from JSON configuration file with caching."""
295
+ return _load_resource_permissions_cached()
296
+
297
+ def _load_prompt_permissions(self) -> dict[str, dict[str, bool]]:
298
+ """Load prompt permissions from JSON configuration file with caching."""
299
+ return _load_prompt_permissions_cached()
300
+
301
+ def _classify_by_tool_name(self, tool_name: str) -> dict[str, bool]:
302
+ """Classify permissions based on external JSON configuration only."""
303
+ return _classify_tool_permissions_cached(tool_name)
304
+
305
+ def _classify_by_resource_name(self, resource_name: str) -> dict[str, bool]:
306
+ """Classify resource permissions based on external JSON configuration only."""
307
+ return _classify_resource_permissions_cached(resource_name)
308
+
309
+ def _classify_by_prompt_name(self, prompt_name: str) -> dict[str, bool]:
310
+ """Classify prompt permissions based on external JSON configuration only."""
311
+ return _classify_prompt_permissions_cached(prompt_name)
312
+
313
+ def _classify_tool_permissions(self, tool_name: str) -> dict[str, bool]:
314
+ """
315
+ Classify tool permissions based on tool name.
316
+
317
+ Args:
318
+ tool_name: Name of the tool to classify
319
+ Returns:
320
+ Dictionary with permission flags
321
+ """
322
+ permissions = self._classify_by_tool_name(tool_name)
323
+ log.debug(f"Classified tool {tool_name}: {permissions}")
324
+ return permissions
325
+
326
+ def _classify_resource_permissions(self, resource_name: str) -> dict[str, bool]:
327
+ """
328
+ Classify resource permissions based on resource name.
329
+
330
+ Args:
331
+ resource_name: Name/URI of the resource to classify
332
+ Returns:
333
+ Dictionary with permission flags
334
+ """
335
+ permissions = self._classify_by_resource_name(resource_name)
336
+ log.debug(f"Classified resource {resource_name}: {permissions}")
337
+ return permissions
338
+
339
+ def _classify_prompt_permissions(self, prompt_name: str) -> dict[str, bool]:
340
+ """
341
+ Classify prompt permissions based on prompt name.
342
+
343
+ Args:
344
+ prompt_name: Name/type of the prompt to classify
345
+ Returns:
346
+ Dictionary with permission flags
347
+ """
348
+ permissions = self._classify_by_prompt_name(prompt_name)
349
+ log.debug(f"Classified prompt {prompt_name}: {permissions}")
350
+ return permissions
351
+
352
+ def get_tool_permissions(self, tool_name: str) -> dict[str, bool]:
353
+ """Get tool permissions based on tool name."""
354
+ return self._classify_tool_permissions(tool_name)
355
+
356
+ def get_resource_permissions(self, resource_name: str) -> dict[str, bool]:
357
+ """Get resource permissions based on resource name."""
358
+ return self._classify_resource_permissions(resource_name)
359
+
360
+ def get_prompt_permissions(self, prompt_name: str) -> dict[str, bool]:
361
+ """Get prompt permissions based on prompt name."""
362
+ return self._classify_prompt_permissions(prompt_name)
363
+
364
+ def add_tool_call(self, tool_name: str) -> str:
365
+ """
366
+ Add a tool call and update trifecta flags based on tool classification.
367
+
368
+ Args:
369
+ tool_name: Name of the tool being called
370
+
371
+ Returns:
372
+ Placeholder ID for compatibility
373
+
374
+ Raises:
375
+ SecurityError: If the lethal trifecta is already achieved and this call would be blocked
376
+ """
377
+ # Check if trifecta is already achieved before processing this call
378
+ if self.is_trifecta_achieved():
379
+ log.error(f"🚫 BLOCKING tool call {tool_name} - lethal trifecta already achieved")
380
+ raise SecurityError(f"Tool call '{tool_name}' blocked: lethal trifecta achieved")
381
+
382
+ # Get tool permissions and update trifecta flags
383
+ permissions = self._classify_tool_permissions(tool_name)
384
+
385
+ log.debug(f"add_tool_call: Tool permissions: {permissions}")
386
+
387
+ # Check if tool is enabled
388
+ if permissions["enabled"] is False:
389
+ log.warning(f"🚫 BLOCKING tool call {tool_name} - tool is disabled")
390
+ raise SecurityError(f"Tool call '{tool_name}' blocked: tool is disabled")
391
+
392
+ if permissions["read_private_data"]:
393
+ self.has_private_data_access = True
394
+ log.info(f"🔒 Private data access detected: {tool_name}")
395
+
396
+ if permissions["read_untrusted_public_data"]:
397
+ self.has_untrusted_content_exposure = True
398
+ log.info(f"🌐 Untrusted content exposure detected: {tool_name}")
399
+
400
+ if permissions["write_operation"]:
401
+ self.has_external_communication = True
402
+ log.info(f"✍️ Write operation detected: {tool_name}")
403
+
404
+ # Log if trifecta is achieved after this call
405
+ if self.is_trifecta_achieved():
406
+ log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after tool call: {tool_name}")
407
+
408
+ return "placeholder_id"
409
+
410
+ def add_resource_access(self, resource_name: str) -> str:
411
+ """
412
+ Add a resource access and update trifecta flags based on resource classification.
413
+
414
+ Args:
415
+ resource_name: Name/URI of the resource being accessed
416
+
417
+ Returns:
418
+ Placeholder ID for compatibility
419
+
420
+ Raises:
421
+ SecurityError: If the lethal trifecta is already achieved and this access would be blocked
422
+ """
423
+ # Check if trifecta is already achieved before processing this access
424
+ if self.is_trifecta_achieved():
425
+ log.error(
426
+ f"🚫 BLOCKING resource access {resource_name} - lethal trifecta already achieved"
427
+ )
428
+ raise SecurityError(
429
+ f"Resource access '{resource_name}' blocked: lethal trifecta achieved"
430
+ )
431
+
432
+ # Get resource permissions and update trifecta flags
433
+ permissions = self._classify_resource_permissions(resource_name)
434
+
435
+ if permissions["read_private_data"]:
436
+ self.has_private_data_access = True
437
+ log.info(f"🔒 Private data access detected via resource: {resource_name}")
438
+
439
+ if permissions["read_untrusted_public_data"]:
440
+ self.has_untrusted_content_exposure = True
441
+ log.info(f"🌐 Untrusted content exposure detected via resource: {resource_name}")
442
+
443
+ if permissions["write_operation"]:
444
+ self.has_external_communication = True
445
+ log.info(f"✍️ Write operation detected via resource: {resource_name}")
446
+
447
+ # Log if trifecta is achieved after this access
448
+ if self.is_trifecta_achieved():
449
+ log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after resource access: {resource_name}")
450
+
451
+ return "placeholder_id"
452
+
453
+ def add_prompt_access(self, prompt_name: str) -> str:
454
+ """
455
+ Add a prompt access and update trifecta flags based on prompt classification.
456
+
457
+ Args:
458
+ prompt_name: Name/type of the prompt being accessed
459
+
460
+ Returns:
461
+ Placeholder ID for compatibility
462
+
463
+ Raises:
464
+ SecurityError: If the lethal trifecta is already achieved and this access would be blocked
465
+ """
466
+ # Check if trifecta is already achieved before processing this access
467
+ if self.is_trifecta_achieved():
468
+ log.error(f"🚫 BLOCKING prompt access {prompt_name} - lethal trifecta already achieved")
469
+ raise SecurityError(f"Prompt access '{prompt_name}' blocked: lethal trifecta achieved")
470
+
471
+ # Get prompt permissions and update trifecta flags
472
+ permissions = self._classify_prompt_permissions(prompt_name)
473
+
474
+ if permissions["read_private_data"]:
475
+ self.has_private_data_access = True
476
+ log.info(f"🔒 Private data access detected via prompt: {prompt_name}")
477
+
478
+ if permissions["read_untrusted_public_data"]:
479
+ self.has_untrusted_content_exposure = True
480
+ log.info(f"🌐 Untrusted content exposure detected via prompt: {prompt_name}")
481
+
482
+ if permissions["write_operation"]:
483
+ self.has_external_communication = True
484
+ log.info(f"✍️ Write operation detected via prompt: {prompt_name}")
485
+
486
+ # Log if trifecta is achieved after this access
487
+ if self.is_trifecta_achieved():
488
+ log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after prompt access: {prompt_name}")
489
+
490
+ return "placeholder_id"
491
+
492
+ def to_dict(self) -> dict[str, Any]:
493
+ """
494
+ Convert tracker to dictionary for serialization.
495
+
496
+ Returns:
497
+ Dictionary representation of the tracker
498
+ """
499
+ return {
500
+ "lethal_trifecta": {
501
+ "has_private_data_access": self.has_private_data_access,
502
+ "has_untrusted_content_exposure": self.has_untrusted_content_exposure,
503
+ "has_external_communication": self.has_external_communication,
504
+ "trifecta_achieved": self.is_trifecta_achieved(),
505
+ },
506
+ }
507
+
508
+
509
+ class SecurityError(Exception):
510
+ """Raised when a security policy violation occurs."""