open-edison 0.1.11__py3-none-any.whl → 0.1.15__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 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width: 640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width: 768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width: 1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width: 1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width: 1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.relative{position:relative}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2{height:.5rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-\[580px\]{height:580px}.w-10{width:2.5rem}.w-2{width:.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-\[240px\]{min-width:240px}.max-w-\[1400px\]{max-width:1400px}.max-w-\[260px\]{max-width:260px}.border-collapse{border-collapse:collapse}.translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-amber-400\/30{border-color:#fbbf244d}.border-app-accent{border-color:var(--accent)}.border-app-border{border-color:var(--border)}.border-blue-400\/30{border-color:#60a5fa4d}.border-rose-400\/30{border-color:#fb71854d}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-app-accent{background-color:var(--accent)}.bg-app-bg{background-color:var(--bg)}.bg-app-border{background-color:var(--border)}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-rose-400{--tw-bg-opacity: 1;background-color:rgb(251 113 133 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}.\!px-3{padding-left:.75rem!important;padding-right:.75rem!important}.\!py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-2{padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.align-bottom{vertical-align:bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-app-accent{color:var(--accent)}.text-app-muted{color:var(--muted)}.text-app-text{color:var(--text)}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-rose-400{--tw-text-opacity: 1;color:rgb(251 113 133 / var(--tw-text-opacity, 1))}.accent-blue-500{accent-color:#3b82f6}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--bg: #0b0c10;--card: #111318;--border: #1f2430;--text: #e6e6e6;--muted: #a0a7b4;--accent: #7c3aed;--success: #10b981;--warning: #f59e0b;--danger: #ef4444}[data-theme=dark]{--bg: #0b0c10;--card: #111318;--border: #1f2430;--text: #e6e6e6;--muted: #a0a7b4}[data-theme=light]{--bg: #f8fafc;--card: #ffffff;--border: #e5e7eb;--text: #0f172a;--muted: #475569}@media (prefers-color-scheme: light){:root{--bg: #f8fafc;--card: #ffffff;--border: #e5e7eb;--text: #0f172a;--muted: #475569}}html,body,#root{height:100%}body{margin:0;background:var(--bg);color:var(--text)}.container{margin:0 auto;padding:24px;max-width:1100px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;box-shadow:0 1px 2px #0000000a,0 2px 12px #00000014}.stat{display:flex;align-items:center;gap:12px}.badge{display:inline-block;font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);background:#7c3aed14;color:var(--text)}.table{width:100%;border-collapse:collapse}.table th,.table td{border-bottom:1px solid var(--border);padding:8px 4px;text-align:left}.muted{color:var(--muted)}.accent{color:var(--accent)}.success{color:var(--success)}.warning{color:var(--warning)}.danger{color:var(--danger)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.button{border:1px solid var(--border);background:var(--card);color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.button:hover{filter:brightness(1.05)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-blue-400:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(96 165 250 / var(--tw-ring-opacity, 1))}@media (min-width: 640px){.sm\:flex{display:flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-\[220px_1fr\]{grid-template-columns:220px 1fr}}
@@ -10,8 +10,8 @@
10
10
  const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
11
11
  document.documentElement.setAttribute('data-theme', prefersLight ? 'light' : 'dark');
12
12
  </script>
13
- <script type="module" crossorigin src="/assets/index-BPaXg1vr.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-BVdkI6ig.css">
13
+ <script type="module" crossorigin src="/assets/index-_NTxjOfh.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-h6k8aL6h.css">
15
15
  </head>
16
16
 
17
17
  <body>
@@ -14,7 +14,7 @@ names (with server-name/path prefixes) to their security classifications:
14
14
 
15
15
  import json
16
16
  from dataclasses import dataclass
17
- from functools import lru_cache
17
+ from functools import cache
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
@@ -23,20 +23,77 @@ from loguru import logger as log
23
23
  from src.config import ConfigError
24
24
  from src.telemetry import (
25
25
  record_private_data_access,
26
+ record_prompt_access_blocked,
27
+ record_resource_access_blocked,
26
28
  record_tool_call_blocked,
27
29
  record_untrusted_public_data,
28
30
  record_write_operation,
29
31
  )
30
32
 
33
+ ACL_RANK: dict[str, int] = {"PUBLIC": 0, "PRIVATE": 1, "SECRET": 2}
31
34
 
32
- def _flat_permissions_loader(config_path: Path) -> dict[str, dict[str, bool]]:
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]]:
33
90
  if config_path.exists():
34
91
  with open(config_path) as f:
35
92
  data: dict[str, Any] = json.load(f)
36
93
 
37
94
  # Handle new format: server -> {tool -> permissions}
38
95
  # Convert to flat tool -> permissions format
39
- flat_permissions: dict[str, dict[str, bool]] = {}
96
+ flat_permissions: dict[str, dict[str, Any]] = {}
40
97
  tool_to_server: dict[str, str] = {}
41
98
  server_tools: dict[str, set[str]] = {}
42
99
 
@@ -83,14 +140,7 @@ def _flat_permissions_loader(config_path: Path) -> dict[str, dict[str, bool]]:
83
140
 
84
141
  # Convert to flat format with explicit type casting
85
142
  tool_perms_dict: dict[str, Any] = tool_permissions # type: ignore
86
- flat_permissions[tool_name] = {
87
- "enabled": bool(tool_perms_dict.get("enabled", True)),
88
- "write_operation": bool(tool_perms_dict.get("write_operation", False)),
89
- "read_private_data": bool(tool_perms_dict.get("read_private_data", False)),
90
- "read_untrusted_public_data": bool(
91
- tool_perms_dict.get("read_untrusted_public_data", False)
92
- ),
93
- }
143
+ flat_permissions[tool_name] = _apply_permission_defaults(tool_perms_dict)
94
144
 
95
145
  log.debug(
96
146
  f"Loaded {len(flat_permissions)} tool permissions from {len(server_tools)} servers in {config_path}"
@@ -106,8 +156,8 @@ def _flat_permissions_loader(config_path: Path) -> dict[str, dict[str, bool]]:
106
156
  return {}
107
157
 
108
158
 
109
- @lru_cache(maxsize=1)
110
- def _load_tool_permissions_cached() -> dict[str, dict[str, bool]]:
159
+ @cache
160
+ def _load_tool_permissions_cached() -> dict[str, dict[str, Any]]:
111
161
  """Load tool permissions from JSON configuration file with LRU caching."""
112
162
  config_path = Path(__file__).parent.parent.parent / "tool_permissions.json"
113
163
 
@@ -121,8 +171,8 @@ def _load_tool_permissions_cached() -> dict[str, dict[str, bool]]:
121
171
  return {}
122
172
 
123
173
 
124
- @lru_cache(maxsize=1)
125
- def _load_resource_permissions_cached() -> dict[str, dict[str, bool]]:
174
+ @cache
175
+ def _load_resource_permissions_cached() -> dict[str, dict[str, Any]]:
126
176
  """Load resource permissions from JSON configuration file with LRU caching."""
127
177
  config_path = Path(__file__).parent.parent.parent / "resource_permissions.json"
128
178
 
@@ -136,8 +186,8 @@ def _load_resource_permissions_cached() -> dict[str, dict[str, bool]]:
136
186
  return {}
137
187
 
138
188
 
139
- @lru_cache(maxsize=1)
140
- def _load_prompt_permissions_cached() -> dict[str, dict[str, bool]]:
189
+ @cache
190
+ def _load_prompt_permissions_cached() -> dict[str, dict[str, Any]]:
141
191
  """Load prompt permissions from JSON configuration file with LRU caching."""
142
192
  config_path = Path(__file__).parent.parent.parent / "prompt_permissions.json"
143
193
 
@@ -151,55 +201,55 @@ def _load_prompt_permissions_cached() -> dict[str, dict[str, bool]]:
151
201
  return {}
152
202
 
153
203
 
154
- @lru_cache(maxsize=128)
155
- def _classify_tool_permissions_cached(tool_name: str) -> dict[str, bool]:
204
+ @cache
205
+ def _classify_tool_permissions_cached(tool_name: str) -> dict[str, Any]:
156
206
  """Classify tool permissions with LRU caching."""
157
207
  return _classify_permissions_cached(tool_name, _load_tool_permissions_cached(), "tool")
158
208
 
159
209
 
160
- @lru_cache(maxsize=128)
161
- def _classify_resource_permissions_cached(resource_name: str) -> dict[str, bool]:
210
+ @cache
211
+ def _classify_resource_permissions_cached(resource_name: str) -> dict[str, Any]:
162
212
  """Classify resource permissions with LRU caching."""
163
213
  return _classify_permissions_cached(
164
214
  resource_name, _load_resource_permissions_cached(), "resource"
165
215
  )
166
216
 
167
217
 
168
- @lru_cache(maxsize=128)
169
- def _classify_prompt_permissions_cached(prompt_name: str) -> dict[str, bool]:
218
+ @cache
219
+ def _classify_prompt_permissions_cached(prompt_name: str) -> dict[str, Any]:
170
220
  """Classify prompt permissions with LRU caching."""
171
221
  return _classify_permissions_cached(prompt_name, _load_prompt_permissions_cached(), "prompt")
172
222
 
173
223
 
174
- def _get_builtin_tool_permissions(name: str) -> dict[str, bool] | None:
224
+ def _get_builtin_tool_permissions(name: str) -> dict[str, Any] | None:
175
225
  """Get permissions for built-in safe tools."""
176
226
  builtin_safe_tools = ["echo", "get_server_info", "get_security_status"]
177
227
  if name in builtin_safe_tools:
178
- permissions = {
179
- "enabled": True,
180
- "write_operation": False,
181
- "read_private_data": False,
182
- "read_untrusted_public_data": False,
183
- }
228
+ permissions = _apply_permission_defaults({"enabled": True})
184
229
  log.debug(f"Built-in safe tool {name}: {permissions}")
185
230
  return permissions
186
231
  return None
187
232
 
188
233
 
189
234
  def _get_exact_match_permissions(
190
- name: str, permissions_config: dict[str, dict[str, bool]], type_name: str
191
- ) -> dict[str, bool] | None:
235
+ name: str, permissions_config: dict[str, dict[str, Any]], type_name: str
236
+ ) -> dict[str, Any] | None:
192
237
  """Check for exact match permissions."""
193
238
  if name in permissions_config and not name.startswith("_"):
194
239
  config_perms = permissions_config[name]
195
- permissions = {
196
- "enabled": config_perms.get("enabled", False),
197
- "write_operation": config_perms.get("write_operation", False),
198
- "read_private_data": config_perms.get("read_private_data", False),
199
- "read_untrusted_public_data": config_perms.get("read_untrusted_public_data", False),
200
- }
240
+ permissions = _apply_permission_defaults(config_perms)
201
241
  log.debug(f"Found exact match for {type_name} {name}: {permissions}")
202
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
203
253
  return None
204
254
 
205
255
 
@@ -230,8 +280,8 @@ def _get_wildcard_patterns(name: str, type_name: str) -> list[str]:
230
280
 
231
281
 
232
282
  def _classify_permissions_cached(
233
- name: str, permissions_config: dict[str, dict[str, bool]], type_name: str
234
- ) -> dict[str, bool]:
283
+ name: str, permissions_config: dict[str, dict[str, Any]], type_name: str
284
+ ) -> dict[str, Any]:
235
285
  """Generic permission classification with pattern matching support."""
236
286
  # Built-in safe tools that don't need external config (only for tools)
237
287
  if type_name == "tool":
@@ -249,12 +299,7 @@ def _classify_permissions_cached(
249
299
  for pattern in wildcard_patterns:
250
300
  if pattern in permissions_config:
251
301
  config_perms = permissions_config[pattern]
252
- permissions = {
253
- "enabled": config_perms.get("enabled", False),
254
- "write_operation": config_perms.get("write_operation", False),
255
- "read_private_data": config_perms.get("read_private_data", False),
256
- "read_untrusted_public_data": config_perms.get("read_untrusted_public_data", False),
257
- }
302
+ permissions = _apply_permission_defaults(config_perms)
258
303
  log.debug(f"Found wildcard match for {type_name} {name} using {pattern}: {permissions}")
259
304
  return permissions
260
305
 
@@ -283,6 +328,8 @@ class DataAccessTracker:
283
328
  has_private_data_access: bool = False
284
329
  has_untrusted_content_exposure: bool = False
285
330
  has_external_communication: bool = False
331
+ # ACL tracking: the most restrictive ACL encountered during this session via reads
332
+ highest_acl_level: str = "PUBLIC"
286
333
 
287
334
  def is_trifecta_achieved(self) -> bool:
288
335
  """Check if the lethal trifecta has been achieved."""
@@ -292,31 +339,31 @@ class DataAccessTracker:
292
339
  and self.has_external_communication
293
340
  )
294
341
 
295
- def _load_tool_permissions(self) -> dict[str, dict[str, bool]]:
342
+ def _load_tool_permissions(self) -> dict[str, dict[str, Any]]:
296
343
  """Load tool permissions from JSON configuration file with caching."""
297
344
  return _load_tool_permissions_cached()
298
345
 
299
- def _load_resource_permissions(self) -> dict[str, dict[str, bool]]:
346
+ def _load_resource_permissions(self) -> dict[str, dict[str, Any]]:
300
347
  """Load resource permissions from JSON configuration file with caching."""
301
348
  return _load_resource_permissions_cached()
302
349
 
303
- def _load_prompt_permissions(self) -> dict[str, dict[str, bool]]:
350
+ def _load_prompt_permissions(self) -> dict[str, dict[str, Any]]:
304
351
  """Load prompt permissions from JSON configuration file with caching."""
305
352
  return _load_prompt_permissions_cached()
306
353
 
307
- def _classify_by_tool_name(self, tool_name: str) -> dict[str, bool]:
354
+ def _classify_by_tool_name(self, tool_name: str) -> dict[str, Any]:
308
355
  """Classify permissions based on external JSON configuration only."""
309
356
  return _classify_tool_permissions_cached(tool_name)
310
357
 
311
- def _classify_by_resource_name(self, resource_name: str) -> dict[str, bool]:
358
+ def _classify_by_resource_name(self, resource_name: str) -> dict[str, Any]:
312
359
  """Classify resource permissions based on external JSON configuration only."""
313
360
  return _classify_resource_permissions_cached(resource_name)
314
361
 
315
- def _classify_by_prompt_name(self, prompt_name: str) -> dict[str, bool]:
362
+ def _classify_by_prompt_name(self, prompt_name: str) -> dict[str, Any]:
316
363
  """Classify prompt permissions based on external JSON configuration only."""
317
364
  return _classify_prompt_permissions_cached(prompt_name)
318
365
 
319
- def _classify_tool_permissions(self, tool_name: str) -> dict[str, bool]:
366
+ def _classify_tool_permissions(self, tool_name: str) -> dict[str, Any]:
320
367
  """
321
368
  Classify tool permissions based on tool name.
322
369
 
@@ -329,7 +376,7 @@ class DataAccessTracker:
329
376
  log.debug(f"Classified tool {tool_name}: {permissions}")
330
377
  return permissions
331
378
 
332
- def _classify_resource_permissions(self, resource_name: str) -> dict[str, bool]:
379
+ def _classify_resource_permissions(self, resource_name: str) -> dict[str, Any]:
333
380
  """
334
381
  Classify resource permissions based on resource name.
335
382
 
@@ -342,7 +389,7 @@ class DataAccessTracker:
342
389
  log.debug(f"Classified resource {resource_name}: {permissions}")
343
390
  return permissions
344
391
 
345
- def _classify_prompt_permissions(self, prompt_name: str) -> dict[str, bool]:
392
+ def _classify_prompt_permissions(self, prompt_name: str) -> dict[str, Any]:
346
393
  """
347
394
  Classify prompt permissions based on prompt name.
348
395
 
@@ -355,36 +402,90 @@ class DataAccessTracker:
355
402
  log.debug(f"Classified prompt {prompt_name}: {permissions}")
356
403
  return permissions
357
404
 
358
- def get_tool_permissions(self, tool_name: str) -> dict[str, bool]:
405
+ def get_tool_permissions(self, tool_name: str) -> dict[str, Any]:
359
406
  """Get tool permissions based on tool name."""
360
407
  return self._classify_tool_permissions(tool_name)
361
408
 
362
- def get_resource_permissions(self, resource_name: str) -> dict[str, bool]:
409
+ def get_resource_permissions(self, resource_name: str) -> dict[str, Any]:
363
410
  """Get resource permissions based on resource name."""
364
411
  return self._classify_resource_permissions(resource_name)
365
412
 
366
- def get_prompt_permissions(self, prompt_name: str) -> dict[str, bool]:
413
+ def get_prompt_permissions(self, prompt_name: str) -> dict[str, Any]:
367
414
  """Get prompt permissions based on prompt name."""
368
415
  return self._classify_prompt_permissions(prompt_name)
369
416
 
370
- def add_tool_call(self, tool_name: str) -> str:
417
+ def _would_call_complete_trifecta(self, permissions: dict[str, Any]) -> bool:
418
+ """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"))
420
+ would_untrusted = self.has_untrusted_content_exposure or bool(
421
+ permissions.get("read_untrusted_public_data")
422
+ )
423
+ would_write = self.has_external_communication or bool(permissions.get("write_operation"))
424
+ return bool(would_private and would_untrusted and would_write)
425
+
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
+ def _apply_permissions_effects(
446
+ self,
447
+ permissions: dict[str, Any],
448
+ *,
449
+ source_type: str,
450
+ name: str,
451
+ ) -> None:
452
+ """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"]:
455
+ self.has_private_data_access = True
456
+ log.info(f"🔒 Private data access detected via {source_type}: {name}")
457
+ record_private_data_access(source_type, name)
458
+ # Update highest ACL based on ACL when reading private data
459
+ current_rank = ACL_RANK.get(self.highest_acl_level, 0)
460
+ new_rank = ACL_RANK.get(acl_value, 0)
461
+ if new_rank > current_rank:
462
+ self.highest_acl_level = acl_value
463
+
464
+ if permissions["read_untrusted_public_data"]:
465
+ self.has_untrusted_content_exposure = True
466
+ log.info(f"🌐 Untrusted content exposure detected via {source_type}: {name}")
467
+ record_untrusted_public_data(source_type, name)
468
+
469
+ if permissions["write_operation"]:
470
+ self.has_external_communication = True
471
+ log.info(f"✍️ Write operation detected via {source_type}: {name}")
472
+ record_write_operation(source_type, name)
473
+
474
+ def add_tool_call(self, tool_name: str):
371
475
  """
372
476
  Add a tool call and update trifecta flags based on tool classification.
373
477
 
374
478
  Args:
375
479
  tool_name: Name of the tool being called
376
480
 
377
- Returns:
378
- Placeholder ID for compatibility
379
-
380
481
  Raises:
381
482
  SecurityError: If the lethal trifecta is already achieved and this call would be blocked
382
483
  """
383
484
  # Check if trifecta is already achieved before processing this call
384
485
  if self.is_trifecta_achieved():
385
- log.error(f"🚫 BLOCKING tool call {tool_name} - lethal trifecta already achieved")
486
+ log.error(f"🚫 BLOCKING tool call {tool_name} - lethal trifecta achieved")
386
487
  record_tool_call_blocked(tool_name, "trifecta")
387
- raise SecurityError(f"Tool call '{tool_name}' blocked: lethal trifecta achieved")
488
+ raise SecurityError(f"'{tool_name}' / Lethal trifecta")
388
489
 
389
490
  # Get tool permissions and update trifecta flags
390
491
  permissions = self._classify_tool_permissions(tool_name)
@@ -392,42 +493,30 @@ class DataAccessTracker:
392
493
  log.debug(f"add_tool_call: Tool permissions: {permissions}")
393
494
 
394
495
  # Check if tool is enabled
395
- if permissions["enabled"] is False:
396
- log.warning(f"🚫 BLOCKING tool call {tool_name} - tool is disabled")
397
- record_tool_call_blocked(tool_name, "disabled")
398
- raise SecurityError(f"Tool call '{tool_name}' blocked: tool is disabled")
496
+ self._enforce_tool_enabled(permissions, tool_name)
399
497
 
400
- if permissions["read_private_data"]:
401
- self.has_private_data_access = True
402
- log.info(f"🔒 Private data access detected: {tool_name}")
403
- record_private_data_access("tool", tool_name)
498
+ # 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)
404
501
 
405
- if permissions["read_untrusted_public_data"]:
406
- self.has_untrusted_content_exposure = True
407
- log.info(f"🌐 Untrusted content exposure detected: {tool_name}")
408
- record_untrusted_public_data("tool", tool_name)
502
+ # Pre-check: would this call achieve the lethal trifecta? If so, block immediately
503
+ if self._would_call_complete_trifecta(permissions):
504
+ log.error(f"🚫 BLOCKING tool call {tool_name} - would achieve lethal trifecta")
505
+ record_tool_call_blocked(tool_name, "trifecta_prevent")
506
+ raise SecurityError(f"'{tool_name}' / Lethal trifecta")
409
507
 
410
- if permissions["write_operation"]:
411
- self.has_external_communication = True
412
- log.info(f"✍️ Write operation detected: {tool_name}")
413
- record_write_operation("tool", tool_name)
414
-
415
- # Log if trifecta is achieved after this call
416
- if self.is_trifecta_achieved():
417
- log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after tool call: {tool_name}")
508
+ self._apply_permissions_effects(permissions, source_type="tool", name=tool_name)
418
509
 
419
- return "placeholder_id"
510
+ # We proactively prevent trifecta; by design we should never reach a state where
511
+ # a completed call newly achieves trifecta.
420
512
 
421
- def add_resource_access(self, resource_name: str) -> str:
513
+ def add_resource_access(self, resource_name: str):
422
514
  """
423
515
  Add a resource access and update trifecta flags based on resource classification.
424
516
 
425
517
  Args:
426
518
  resource_name: Name/URI of the resource being accessed
427
519
 
428
- Returns:
429
- Placeholder ID for compatibility
430
-
431
520
  Raises:
432
521
  SecurityError: If the lethal trifecta is already achieved and this access would be blocked
433
522
  """
@@ -436,75 +525,52 @@ class DataAccessTracker:
436
525
  log.error(
437
526
  f"🚫 BLOCKING resource access {resource_name} - lethal trifecta already achieved"
438
527
  )
439
- raise SecurityError(
440
- f"Resource access '{resource_name}' blocked: lethal trifecta achieved"
441
- )
528
+ raise SecurityError(f"'{resource_name}' / Lethal trifecta")
442
529
 
443
530
  # Get resource permissions and update trifecta flags
444
531
  permissions = self._classify_resource_permissions(resource_name)
445
532
 
446
- if permissions["read_private_data"]:
447
- self.has_private_data_access = True
448
- log.info(f"🔒 Private data access detected via resource: {resource_name}")
449
- record_private_data_access("resource", resource_name)
450
-
451
- if permissions["read_untrusted_public_data"]:
452
- self.has_untrusted_content_exposure = True
453
- log.info(f"🌐 Untrusted content exposure detected via resource: {resource_name}")
454
- record_untrusted_public_data("resource", resource_name)
455
-
456
- if permissions["write_operation"]:
457
- self.has_external_communication = True
458
- log.info(f"✍️ Write operation detected via resource: {resource_name}")
459
- record_write_operation("resource", resource_name)
533
+ # Pre-check: would this access achieve the lethal trifecta? If so, block immediately
534
+ if self._would_call_complete_trifecta(permissions):
535
+ log.error(
536
+ f"🚫 BLOCKING resource access {resource_name} - would achieve lethal trifecta"
537
+ )
538
+ record_resource_access_blocked(resource_name, "trifecta_prevent")
539
+ raise SecurityError(f"'{resource_name}' / Lethal trifecta")
460
540
 
461
- # Log if trifecta is achieved after this access
462
- if self.is_trifecta_achieved():
463
- log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after resource access: {resource_name}")
541
+ self._apply_permissions_effects(permissions, source_type="resource", name=resource_name)
464
542
 
465
- return "placeholder_id"
543
+ # We proactively prevent trifecta; by design we should never reach a state where
544
+ # a completed access newly achieves trifecta.
466
545
 
467
- def add_prompt_access(self, prompt_name: str) -> str:
546
+ def add_prompt_access(self, prompt_name: str):
468
547
  """
469
548
  Add a prompt access and update trifecta flags based on prompt classification.
470
549
 
471
550
  Args:
472
551
  prompt_name: Name/type of the prompt being accessed
473
552
 
474
- Returns:
475
- Placeholder ID for compatibility
476
-
477
553
  Raises:
478
554
  SecurityError: If the lethal trifecta is already achieved and this access would be blocked
479
555
  """
480
556
  # Check if trifecta is already achieved before processing this access
481
557
  if self.is_trifecta_achieved():
482
558
  log.error(f"🚫 BLOCKING prompt access {prompt_name} - lethal trifecta already achieved")
483
- raise SecurityError(f"Prompt access '{prompt_name}' blocked: lethal trifecta achieved")
559
+ raise SecurityError(f"'{prompt_name}' / Lethal trifecta")
484
560
 
485
561
  # Get prompt permissions and update trifecta flags
486
562
  permissions = self._classify_prompt_permissions(prompt_name)
487
563
 
488
- if permissions["read_private_data"]:
489
- self.has_private_data_access = True
490
- log.info(f"🔒 Private data access detected via prompt: {prompt_name}")
491
- record_private_data_access("prompt", prompt_name)
492
-
493
- if permissions["read_untrusted_public_data"]:
494
- self.has_untrusted_content_exposure = True
495
- log.info(f"🌐 Untrusted content exposure detected via prompt: {prompt_name}")
496
- record_untrusted_public_data("prompt", prompt_name)
564
+ # Pre-check: would this access achieve the lethal trifecta? If so, block immediately
565
+ if self._would_call_complete_trifecta(permissions):
566
+ log.error(f"🚫 BLOCKING prompt access {prompt_name} - would achieve lethal trifecta")
567
+ record_prompt_access_blocked(prompt_name, "trifecta_prevent")
568
+ raise SecurityError(f"'{prompt_name}' / Lethal trifecta")
497
569
 
498
- if permissions["write_operation"]:
499
- self.has_external_communication = True
500
- log.info(f"✍️ Write operation detected via prompt: {prompt_name}")
501
- record_write_operation("prompt", prompt_name)
570
+ self._apply_permissions_effects(permissions, source_type="prompt", name=prompt_name)
502
571
 
503
- # Log if trifecta is achieved after this access
504
- if self.is_trifecta_achieved():
505
- log.warning(f"⚠️ LETHAL TRIFECTA ACHIEVED after prompt access: {prompt_name}")
506
-
507
- return "placeholder_id"
572
+ # We proactively prevent trifecta; by design we should never reach a state where
573
+ # a completed access newly achieves trifecta.
508
574
 
509
575
  def to_dict(self) -> dict[str, Any]:
510
576
  """
@@ -520,8 +586,26 @@ class DataAccessTracker:
520
586
  "has_external_communication": self.has_external_communication,
521
587
  "trifecta_achieved": self.is_trifecta_achieved(),
522
588
  },
589
+ "acl": {
590
+ "highest_acl_level": self.highest_acl_level,
591
+ },
523
592
  }
524
593
 
525
594
 
526
595
  class SecurityError(Exception):
527
596
  """Raised when a security policy violation occurs."""
597
+
598
+ def __init__(self, message: str):
599
+ """We format with a brick ascii wall"""
600
+ message = f"""
601
+ ████ ████ ████ ████ ████ ████
602
+ ██ ████ ████ ████ ████ ████ █
603
+ ████ ████ ████ ████ ████ ████
604
+ BLOCKED BY EDISON
605
+ {message:^30}
606
+ ████ ████ ████ ████ ████ ████
607
+ ██ ████ ████ ████ ████ ████ █
608
+ ████ ████ ████ ████ ████ ████
609
+ {message}
610
+ """
611
+ super().__init__(message)
@@ -170,6 +170,13 @@ def get_session_from_db(session_id: str) -> MCPSession:
170
170
  data_access_tracker.has_external_communication = trifecta.get(
171
171
  "has_external_communication", False
172
172
  )
173
+ # Restore ACL highest level if present
174
+ if isinstance(summary_data, dict) and "acl" in summary_data:
175
+ acl_summary: Any = summary_data.get("acl") # type: ignore
176
+ if isinstance(acl_summary, dict):
177
+ highest = acl_summary.get("highest_acl_level") # type: ignore
178
+ if isinstance(highest, str) and highest:
179
+ data_access_tracker.highest_acl_level = highest
173
180
 
174
181
  return MCPSession(
175
182
  session_id=session_id,