kubectl-mcp-server 1.16.0__py3-none-any.whl → 1.18.0__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.
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/METADATA +48 -1
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/RECORD +30 -15
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/cli/cli.py +83 -9
- kubectl_mcp_tool/cli/output.py +14 -0
- kubectl_mcp_tool/config/__init__.py +46 -0
- kubectl_mcp_tool/config/loader.py +386 -0
- kubectl_mcp_tool/config/schema.py +184 -0
- kubectl_mcp_tool/k8s_config.py +127 -1
- kubectl_mcp_tool/mcp_server.py +219 -8
- kubectl_mcp_tool/observability/__init__.py +59 -0
- kubectl_mcp_tool/observability/metrics.py +223 -0
- kubectl_mcp_tool/observability/stats.py +255 -0
- kubectl_mcp_tool/observability/tracing.py +335 -0
- kubectl_mcp_tool/prompts/__init__.py +43 -0
- kubectl_mcp_tool/prompts/builtin.py +695 -0
- kubectl_mcp_tool/prompts/custom.py +298 -0
- kubectl_mcp_tool/prompts/prompts.py +180 -4
- kubectl_mcp_tool/providers.py +347 -0
- kubectl_mcp_tool/safety.py +155 -0
- kubectl_mcp_tool/tools/cluster.py +384 -0
- tests/test_config.py +386 -0
- tests/test_mcp_integration.py +251 -0
- tests/test_observability.py +521 -0
- tests/test_prompts.py +716 -0
- tests/test_safety.py +218 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("mcp-server")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProviderType(Enum):
|
|
12
|
+
"""Kubernetes provider types."""
|
|
13
|
+
KUBECONFIG = "kubeconfig"
|
|
14
|
+
IN_CLUSTER = "in-cluster"
|
|
15
|
+
SINGLE = "single"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownContextError(Exception):
|
|
19
|
+
"""Raised when a requested context is not found."""
|
|
20
|
+
def __init__(self, context: str, available: List[str] = None):
|
|
21
|
+
self.context = context
|
|
22
|
+
self.available = available or []
|
|
23
|
+
msg = f"Context '{context}' not found"
|
|
24
|
+
if self.available:
|
|
25
|
+
msg += f". Available contexts: {', '.join(self.available)}"
|
|
26
|
+
super().__init__(msg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderError(Exception):
|
|
30
|
+
"""Raised when provider configuration is invalid."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ProviderConfig:
|
|
36
|
+
"""Configuration for Kubernetes provider."""
|
|
37
|
+
provider_type: ProviderType = ProviderType.KUBECONFIG
|
|
38
|
+
kubeconfig_path: str = ""
|
|
39
|
+
context: str = ""
|
|
40
|
+
qps: float = 100.0
|
|
41
|
+
burst: int = 200
|
|
42
|
+
timeout: int = 30
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_env(cls) -> "ProviderConfig":
|
|
46
|
+
"""Create config from environment variables."""
|
|
47
|
+
provider_str = os.environ.get("MCP_K8S_PROVIDER", "kubeconfig").lower()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
provider_type = ProviderType(provider_str)
|
|
51
|
+
except ValueError:
|
|
52
|
+
logger.warning(f"Unknown provider type '{provider_str}', using kubeconfig")
|
|
53
|
+
provider_type = ProviderType.KUBECONFIG
|
|
54
|
+
|
|
55
|
+
kubeconfig_path = os.environ.get(
|
|
56
|
+
"MCP_K8S_KUBECONFIG",
|
|
57
|
+
os.environ.get("KUBECONFIG", "~/.kube/config")
|
|
58
|
+
)
|
|
59
|
+
kubeconfig_path = os.path.expanduser(kubeconfig_path)
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
provider_type=provider_type,
|
|
63
|
+
kubeconfig_path=kubeconfig_path,
|
|
64
|
+
context=os.environ.get("MCP_K8S_CONTEXT", ""),
|
|
65
|
+
qps=float(os.environ.get("MCP_K8S_QPS", "100")),
|
|
66
|
+
burst=int(os.environ.get("MCP_K8S_BURST", "200")),
|
|
67
|
+
timeout=int(os.environ.get("MCP_K8S_TIMEOUT", "30")),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ContextInfo:
|
|
73
|
+
"""Information about a kubeconfig context."""
|
|
74
|
+
name: str
|
|
75
|
+
cluster: str
|
|
76
|
+
user: str
|
|
77
|
+
namespace: str = "default"
|
|
78
|
+
is_active: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class KubernetesProvider:
|
|
82
|
+
"""
|
|
83
|
+
Multi-cluster Kubernetes provider.
|
|
84
|
+
|
|
85
|
+
Manages connections to multiple Kubernetes clusters based on
|
|
86
|
+
kubeconfig contexts or in-cluster configuration.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_instance: Optional["KubernetesProvider"] = None
|
|
90
|
+
|
|
91
|
+
def __init__(self, config: Optional[ProviderConfig] = None):
|
|
92
|
+
"""Initialize provider with configuration."""
|
|
93
|
+
self.config = config or ProviderConfig.from_env()
|
|
94
|
+
self._api_clients: Dict[str, Any] = {}
|
|
95
|
+
self._in_cluster = False
|
|
96
|
+
self._contexts_cache: Optional[List[ContextInfo]] = None
|
|
97
|
+
self._active_context: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
self._initialize()
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def get_instance(cls) -> "KubernetesProvider":
|
|
103
|
+
"""Get singleton provider instance."""
|
|
104
|
+
if cls._instance is None:
|
|
105
|
+
cls._instance = cls()
|
|
106
|
+
return cls._instance
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def reset_instance(cls):
|
|
110
|
+
"""Reset singleton instance (for testing)."""
|
|
111
|
+
cls._instance = None
|
|
112
|
+
|
|
113
|
+
def _initialize(self):
|
|
114
|
+
"""Initialize the provider based on type."""
|
|
115
|
+
if self.config.provider_type == ProviderType.IN_CLUSTER:
|
|
116
|
+
self._initialize_in_cluster()
|
|
117
|
+
elif self.config.provider_type == ProviderType.SINGLE:
|
|
118
|
+
self._initialize_single()
|
|
119
|
+
else:
|
|
120
|
+
self._initialize_kubeconfig()
|
|
121
|
+
|
|
122
|
+
def _initialize_in_cluster(self):
|
|
123
|
+
"""Initialize for in-cluster provider."""
|
|
124
|
+
from kubernetes import config
|
|
125
|
+
from kubernetes.config.config_exception import ConfigException
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
config.load_incluster_config()
|
|
129
|
+
self._in_cluster = True
|
|
130
|
+
self._active_context = "in-cluster"
|
|
131
|
+
logger.info("Initialized in-cluster Kubernetes provider")
|
|
132
|
+
except ConfigException as e:
|
|
133
|
+
raise ProviderError(f"Failed to load in-cluster config: {e}")
|
|
134
|
+
|
|
135
|
+
def _initialize_single(self):
|
|
136
|
+
"""Initialize for single-context provider."""
|
|
137
|
+
if not self.config.context:
|
|
138
|
+
raise ProviderError("MCP_K8S_CONTEXT must be set for 'single' provider")
|
|
139
|
+
|
|
140
|
+
from kubernetes import config
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
config.load_kube_config(
|
|
144
|
+
config_file=self.config.kubeconfig_path,
|
|
145
|
+
context=self.config.context
|
|
146
|
+
)
|
|
147
|
+
self._active_context = self.config.context
|
|
148
|
+
logger.info(f"Initialized single-context provider: {self.config.context}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise ProviderError(f"Failed to load context '{self.config.context}': {e}")
|
|
151
|
+
|
|
152
|
+
def _initialize_kubeconfig(self):
|
|
153
|
+
"""Initialize for multi-cluster kubeconfig provider."""
|
|
154
|
+
from kubernetes import config
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
contexts, active = config.list_kube_config_contexts(
|
|
158
|
+
config_file=self.config.kubeconfig_path
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if active:
|
|
162
|
+
self._active_context = active.get("name")
|
|
163
|
+
|
|
164
|
+
self._contexts_cache = [
|
|
165
|
+
ContextInfo(
|
|
166
|
+
name=ctx.get("name", ""),
|
|
167
|
+
cluster=ctx.get("context", {}).get("cluster", ""),
|
|
168
|
+
user=ctx.get("context", {}).get("user", ""),
|
|
169
|
+
namespace=ctx.get("context", {}).get("namespace", "default"),
|
|
170
|
+
is_active=ctx.get("name") == self._active_context
|
|
171
|
+
)
|
|
172
|
+
for ctx in contexts
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
logger.info(
|
|
176
|
+
f"Initialized kubeconfig provider with {len(self._contexts_cache)} contexts, "
|
|
177
|
+
f"active: {self._active_context}"
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.warning(f"Failed to list contexts: {e}")
|
|
181
|
+
self._contexts_cache = []
|
|
182
|
+
|
|
183
|
+
def list_contexts(self) -> List[ContextInfo]:
|
|
184
|
+
"""
|
|
185
|
+
List all available contexts.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of ContextInfo objects
|
|
189
|
+
"""
|
|
190
|
+
if self._in_cluster:
|
|
191
|
+
return [ContextInfo(
|
|
192
|
+
name="in-cluster",
|
|
193
|
+
cluster="in-cluster",
|
|
194
|
+
user="service-account",
|
|
195
|
+
namespace="default",
|
|
196
|
+
is_active=True
|
|
197
|
+
)]
|
|
198
|
+
|
|
199
|
+
if self.config.provider_type == ProviderType.SINGLE:
|
|
200
|
+
from kubernetes import config
|
|
201
|
+
try:
|
|
202
|
+
contexts, _ = config.list_kube_config_contexts(
|
|
203
|
+
config_file=self.config.kubeconfig_path
|
|
204
|
+
)
|
|
205
|
+
for ctx in contexts:
|
|
206
|
+
if ctx.get("name") == self.config.context:
|
|
207
|
+
return [ContextInfo(
|
|
208
|
+
name=ctx.get("name", ""),
|
|
209
|
+
cluster=ctx.get("context", {}).get("cluster", ""),
|
|
210
|
+
user=ctx.get("context", {}).get("user", ""),
|
|
211
|
+
namespace=ctx.get("context", {}).get("namespace", "default"),
|
|
212
|
+
is_active=True
|
|
213
|
+
)]
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
self._refresh_contexts_cache()
|
|
219
|
+
return self._contexts_cache or []
|
|
220
|
+
|
|
221
|
+
def _refresh_contexts_cache(self):
|
|
222
|
+
"""Refresh the contexts cache from kubeconfig."""
|
|
223
|
+
if self._in_cluster or self.config.provider_type == ProviderType.SINGLE:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
from kubernetes import config
|
|
227
|
+
try:
|
|
228
|
+
contexts, active = config.list_kube_config_contexts(
|
|
229
|
+
config_file=self.config.kubeconfig_path
|
|
230
|
+
)
|
|
231
|
+
self._active_context = active.get("name") if active else None
|
|
232
|
+
self._contexts_cache = [
|
|
233
|
+
ContextInfo(
|
|
234
|
+
name=ctx.get("name", ""),
|
|
235
|
+
cluster=ctx.get("context", {}).get("cluster", ""),
|
|
236
|
+
user=ctx.get("context", {}).get("user", ""),
|
|
237
|
+
namespace=ctx.get("context", {}).get("namespace", "default"),
|
|
238
|
+
is_active=ctx.get("name") == self._active_context
|
|
239
|
+
)
|
|
240
|
+
for ctx in contexts
|
|
241
|
+
]
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning(f"Failed to refresh contexts: {e}")
|
|
244
|
+
|
|
245
|
+
def get_current_context(self) -> Optional[str]:
|
|
246
|
+
"""Get the current active context name."""
|
|
247
|
+
if self._in_cluster:
|
|
248
|
+
return "in-cluster"
|
|
249
|
+
return self._active_context
|
|
250
|
+
|
|
251
|
+
def _get_context_names(self) -> List[str]:
|
|
252
|
+
"""Get list of available context names."""
|
|
253
|
+
contexts = self.list_contexts()
|
|
254
|
+
return [ctx.name for ctx in contexts]
|
|
255
|
+
|
|
256
|
+
def validate_context(self, context: str) -> str:
|
|
257
|
+
"""
|
|
258
|
+
Validate and resolve a context name.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
context: Context name (empty string uses default)
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Resolved context name
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
UnknownContextError: If context is not found
|
|
268
|
+
"""
|
|
269
|
+
if self._in_cluster:
|
|
270
|
+
return "in-cluster"
|
|
271
|
+
|
|
272
|
+
if self.config.provider_type == ProviderType.SINGLE:
|
|
273
|
+
if context and context != self.config.context:
|
|
274
|
+
raise UnknownContextError(
|
|
275
|
+
context,
|
|
276
|
+
[self.config.context]
|
|
277
|
+
)
|
|
278
|
+
return self.config.context
|
|
279
|
+
|
|
280
|
+
if not context:
|
|
281
|
+
return self._active_context or ""
|
|
282
|
+
|
|
283
|
+
available = self._get_context_names()
|
|
284
|
+
if context not in available:
|
|
285
|
+
raise UnknownContextError(context, available)
|
|
286
|
+
|
|
287
|
+
return context
|
|
288
|
+
|
|
289
|
+
def get_api_client(self, context: str = "") -> Any:
|
|
290
|
+
"""
|
|
291
|
+
Get an API client configuration for a specific context.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
context: Context name (empty uses default)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
kubernetes.client.ApiClient configured for the context
|
|
298
|
+
"""
|
|
299
|
+
from kubernetes import client, config
|
|
300
|
+
|
|
301
|
+
resolved_context = self.validate_context(context)
|
|
302
|
+
|
|
303
|
+
if resolved_context in self._api_clients:
|
|
304
|
+
return self._api_clients[resolved_context]
|
|
305
|
+
|
|
306
|
+
if self._in_cluster:
|
|
307
|
+
api_client = client.ApiClient()
|
|
308
|
+
else:
|
|
309
|
+
api_config = client.Configuration()
|
|
310
|
+
config.load_kube_config(
|
|
311
|
+
config_file=self.config.kubeconfig_path,
|
|
312
|
+
context=resolved_context,
|
|
313
|
+
client_configuration=api_config
|
|
314
|
+
)
|
|
315
|
+
api_client = client.ApiClient(configuration=api_config)
|
|
316
|
+
|
|
317
|
+
self._api_clients[resolved_context] = api_client
|
|
318
|
+
|
|
319
|
+
return api_client
|
|
320
|
+
|
|
321
|
+
def clear_client_cache(self, context: str = ""):
|
|
322
|
+
"""Clear cached API client(s)."""
|
|
323
|
+
if context:
|
|
324
|
+
self._api_clients.pop(context, None)
|
|
325
|
+
else:
|
|
326
|
+
self._api_clients.clear()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_provider() -> KubernetesProvider:
|
|
330
|
+
"""Get the global Kubernetes provider instance."""
|
|
331
|
+
return KubernetesProvider.get_instance()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def get_context_names() -> List[str]:
|
|
335
|
+
"""Get list of available context names."""
|
|
336
|
+
provider = get_provider()
|
|
337
|
+
return [ctx.name for ctx in provider.list_contexts()]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def get_current_context() -> Optional[str]:
|
|
341
|
+
"""Get the current active context name."""
|
|
342
|
+
return get_provider().get_current_context()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def validate_context(context: str) -> str:
|
|
346
|
+
"""Validate and resolve a context name."""
|
|
347
|
+
return get_provider().validate_context(context)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safety mode implementation for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Provides read-only and disable-destructive modes to prevent accidental cluster mutations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any, Callable, Dict, Set
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("mcp-server")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SafetyMode(Enum):
|
|
16
|
+
"""Safety mode levels for the MCP server."""
|
|
17
|
+
NORMAL = "normal"
|
|
18
|
+
READ_ONLY = "read_only"
|
|
19
|
+
DISABLE_DESTRUCTIVE = "disable_destructive"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Global safety mode state
|
|
23
|
+
_current_mode: SafetyMode = SafetyMode.NORMAL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Operations that modify cluster state (blocked in READ_ONLY mode)
|
|
27
|
+
WRITE_OPERATIONS: Set[str] = {
|
|
28
|
+
# Pod operations
|
|
29
|
+
"run_pod", "delete_pod",
|
|
30
|
+
# Deployment operations
|
|
31
|
+
"scale_deployment", "restart_deployment", "delete_deployment",
|
|
32
|
+
"rollback_deployment", "create_deployment", "update_deployment",
|
|
33
|
+
# StatefulSet operations
|
|
34
|
+
"scale_statefulset", "restart_statefulset", "delete_statefulset",
|
|
35
|
+
# DaemonSet operations
|
|
36
|
+
"restart_daemonset", "delete_daemonset",
|
|
37
|
+
# Service operations
|
|
38
|
+
"create_service", "delete_service", "update_service",
|
|
39
|
+
# ConfigMap/Secret operations
|
|
40
|
+
"create_configmap", "delete_configmap", "update_configmap",
|
|
41
|
+
"create_secret", "delete_secret", "update_secret",
|
|
42
|
+
# Namespace operations
|
|
43
|
+
"create_namespace", "delete_namespace",
|
|
44
|
+
# Helm operations
|
|
45
|
+
"install_helm_chart", "upgrade_helm_chart", "uninstall_helm_chart",
|
|
46
|
+
"rollback_helm_release",
|
|
47
|
+
# kubectl operations
|
|
48
|
+
"apply_manifest", "delete_resource", "patch_resource",
|
|
49
|
+
"create_resource", "replace_resource",
|
|
50
|
+
# Context operations
|
|
51
|
+
"switch_context", "set_namespace_for_context",
|
|
52
|
+
# Rollout operations
|
|
53
|
+
"rollout_promote_tool", "rollout_abort_tool", "rollout_retry_tool",
|
|
54
|
+
"rollout_restart_tool",
|
|
55
|
+
# KubeVirt operations
|
|
56
|
+
"kubevirt_vm_start_tool", "kubevirt_vm_stop_tool", "kubevirt_vm_restart_tool",
|
|
57
|
+
"kubevirt_vm_pause_tool", "kubevirt_vm_unpause_tool", "kubevirt_vm_migrate_tool",
|
|
58
|
+
# CAPI operations
|
|
59
|
+
"capi_machinedeployment_scale_tool",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Operations that are destructive (blocked in DISABLE_DESTRUCTIVE mode)
|
|
63
|
+
DESTRUCTIVE_OPERATIONS: Set[str] = {
|
|
64
|
+
# Delete operations
|
|
65
|
+
"delete_pod", "delete_deployment", "delete_statefulset", "delete_daemonset",
|
|
66
|
+
"delete_service", "delete_configmap", "delete_secret", "delete_namespace",
|
|
67
|
+
"delete_resource",
|
|
68
|
+
# Helm uninstall
|
|
69
|
+
"uninstall_helm_chart",
|
|
70
|
+
# Rollout abort
|
|
71
|
+
"rollout_abort_tool",
|
|
72
|
+
# VM stop
|
|
73
|
+
"kubevirt_vm_stop_tool",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_safety_mode() -> SafetyMode:
|
|
78
|
+
"""Get the current safety mode."""
|
|
79
|
+
return _current_mode
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_safety_mode(mode: SafetyMode) -> None:
|
|
83
|
+
"""Set the safety mode globally."""
|
|
84
|
+
global _current_mode
|
|
85
|
+
_current_mode = mode
|
|
86
|
+
logger.info(f"Safety mode set to: {mode.value}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_operation_allowed(operation_name: str) -> tuple[bool, str]:
|
|
90
|
+
"""
|
|
91
|
+
Check if an operation is allowed under the current safety mode.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Tuple of (allowed: bool, reason: str)
|
|
95
|
+
"""
|
|
96
|
+
mode = get_safety_mode()
|
|
97
|
+
|
|
98
|
+
if mode == SafetyMode.NORMAL:
|
|
99
|
+
return True, ""
|
|
100
|
+
|
|
101
|
+
if mode == SafetyMode.READ_ONLY:
|
|
102
|
+
if operation_name in WRITE_OPERATIONS or operation_name in DESTRUCTIVE_OPERATIONS:
|
|
103
|
+
return False, f"Operation '{operation_name}' blocked: read-only mode is enabled"
|
|
104
|
+
|
|
105
|
+
if mode == SafetyMode.DISABLE_DESTRUCTIVE:
|
|
106
|
+
if operation_name in DESTRUCTIVE_OPERATIONS:
|
|
107
|
+
return False, f"Operation '{operation_name}' blocked: destructive operations are disabled"
|
|
108
|
+
|
|
109
|
+
return True, ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_safety_mode(func: Callable) -> Callable:
|
|
113
|
+
"""
|
|
114
|
+
Decorator to check safety mode before executing a tool function.
|
|
115
|
+
|
|
116
|
+
Usage:
|
|
117
|
+
@check_safety_mode
|
|
118
|
+
def delete_pod(...):
|
|
119
|
+
...
|
|
120
|
+
"""
|
|
121
|
+
@wraps(func)
|
|
122
|
+
def wrapper(*args, **kwargs) -> Dict[str, Any]:
|
|
123
|
+
operation_name = func.__name__
|
|
124
|
+
allowed, reason = is_operation_allowed(operation_name)
|
|
125
|
+
|
|
126
|
+
if not allowed:
|
|
127
|
+
logger.warning(f"Blocked operation: {operation_name} (mode: {get_safety_mode().value})")
|
|
128
|
+
return {
|
|
129
|
+
"success": False,
|
|
130
|
+
"error": reason,
|
|
131
|
+
"blocked_by": get_safety_mode().value,
|
|
132
|
+
"operation": operation_name
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return func(*args, **kwargs)
|
|
136
|
+
|
|
137
|
+
return wrapper
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_mode_info() -> Dict[str, Any]:
|
|
141
|
+
"""Get information about the current safety mode."""
|
|
142
|
+
mode = get_safety_mode()
|
|
143
|
+
return {
|
|
144
|
+
"mode": mode.value,
|
|
145
|
+
"description": {
|
|
146
|
+
SafetyMode.NORMAL: "All operations allowed",
|
|
147
|
+
SafetyMode.READ_ONLY: "Only read operations allowed (no create/update/delete)",
|
|
148
|
+
SafetyMode.DISABLE_DESTRUCTIVE: "Create/update allowed, delete operations blocked",
|
|
149
|
+
}[mode],
|
|
150
|
+
"blocked_operations": {
|
|
151
|
+
SafetyMode.NORMAL: [],
|
|
152
|
+
SafetyMode.READ_ONLY: sorted(WRITE_OPERATIONS | DESTRUCTIVE_OPERATIONS),
|
|
153
|
+
SafetyMode.DISABLE_DESTRUCTIVE: sorted(DESTRUCTIVE_OPERATIONS),
|
|
154
|
+
}[mode]
|
|
155
|
+
}
|