bizteamai-smcp 1.13.1__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.
smcp/cli/revoke.py ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ License revocation utility for SMCP Business Edition.
4
+ """
5
+ import sys
6
+ import hashlib
7
+ import argparse
8
+ import json
9
+ import os
10
+ from datetime import datetime, timezone
11
+
12
+ REVOCATION_FILE = '/etc/bizteam/revocation.json'
13
+
14
+ def load_revocation_list():
15
+ """Load the revocation list."""
16
+ try:
17
+ with open(REVOCATION_FILE, 'r') as f:
18
+ return json.load(f)
19
+ except FileNotFoundError:
20
+ return {'revoked_keys': [], 'last_updated': None}
21
+
22
+ def save_revocation_list(revocation_data):
23
+ """Save the revocation list."""
24
+ os.makedirs(os.path.dirname(REVOCATION_FILE), exist_ok=True)
25
+ with open(REVOCATION_FILE, 'w') as f:
26
+ json.dump(revocation_data, f, indent=2)
27
+
28
+ def revoke_license_key(license_key: str, reason: str = ""):
29
+ """Revoke a license key by adding its hash to the revocation list."""
30
+ key_hash = hashlib.sha256(license_key.encode()).hexdigest()
31
+
32
+ revocation_data = load_revocation_list()
33
+
34
+ # Check if already revoked
35
+ for entry in revocation_data['revoked_keys']:
36
+ if entry['hash'] == key_hash:
37
+ print(f"License key already revoked: {key_hash}")
38
+ return
39
+
40
+ # Add to revocation list
41
+ revocation_data['revoked_keys'].append({
42
+ 'hash': key_hash,
43
+ 'revoked_at': datetime.now(timezone.utc).isoformat(),
44
+ 'reason': reason
45
+ })
46
+ revocation_data['last_updated'] = datetime.now(timezone.utc).isoformat()
47
+
48
+ save_revocation_list(revocation_data)
49
+ print(f"License key revoked: {key_hash}")
50
+
51
+ def main():
52
+ parser = argparse.ArgumentParser(description='Revoke SMCP Business Edition license keys')
53
+ parser.add_argument('license_key', help='License key to revoke')
54
+ parser.add_argument('--reason', '-r', default='', help='Reason for revocation')
55
+ parser.add_argument('--list', '-l', action='store_true', help='List all revoked keys')
56
+
57
+ args = parser.parse_args()
58
+
59
+ if args.list:
60
+ revocation_data = load_revocation_list()
61
+ print("Revoked license keys:")
62
+ for entry in revocation_data['revoked_keys']:
63
+ print(f" {entry['hash']} - {entry['revoked_at']} - {entry['reason']}")
64
+ return
65
+
66
+ try:
67
+ revoke_license_key(args.license_key, args.reason)
68
+ except Exception as e:
69
+ print(f"Error revoking license key: {e}", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+ if __name__ == '__main__':
73
+ main()
smcp/confirm.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ Destructive action confirmation queue and approval system.
3
+ """
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Dict, List, Optional, Tuple
10
+
11
+
12
+ class PendingApproval(Exception):
13
+ """Raised when an action is queued for approval."""
14
+ pass
15
+
16
+
17
+ class ActionQueue:
18
+ """Manages queued actions awaiting approval."""
19
+
20
+ def __init__(self, queue_file: Optional[str] = None):
21
+ """
22
+ Initialize the action queue.
23
+
24
+ Args:
25
+ queue_file: Path to the queue file (defaults to temp file)
26
+ """
27
+ self.queue_file = Path(queue_file or "/tmp/smcp_queue.json")
28
+ self._ensure_queue_file()
29
+
30
+ def _ensure_queue_file(self) -> None:
31
+ """Ensure the queue file exists."""
32
+ if not self.queue_file.exists():
33
+ self.queue_file.parent.mkdir(parents=True, exist_ok=True)
34
+ self._save_queue([])
35
+
36
+ def _load_queue(self) -> List[Dict[str, Any]]:
37
+ """Load the current queue from disk."""
38
+ try:
39
+ with open(self.queue_file, 'r') as f:
40
+ return json.load(f)
41
+ except (FileNotFoundError, json.JSONDecodeError):
42
+ return []
43
+
44
+ def _save_queue(self, queue: List[Dict[str, Any]]) -> None:
45
+ """Save the queue to disk."""
46
+ with open(self.queue_file, 'w') as f:
47
+ json.dump(queue, f, indent=2, default=str)
48
+
49
+ def add_action(self, action_id: str, function: Callable, args: Tuple, kwargs: Dict[str, Any]) -> None:
50
+ """
51
+ Add an action to the approval queue.
52
+
53
+ Args:
54
+ action_id: Unique identifier for the action
55
+ function: Function to be executed
56
+ args: Function arguments
57
+ kwargs: Function keyword arguments
58
+ """
59
+ queue = self._load_queue()
60
+
61
+ action = {
62
+ "id": action_id,
63
+ "function_name": function.__name__,
64
+ "module": function.__module__,
65
+ "args": args,
66
+ "kwargs": kwargs,
67
+ "timestamp": time.time(),
68
+ "status": "pending"
69
+ }
70
+
71
+ queue.append(action)
72
+ self._save_queue(queue)
73
+
74
+ def get_pending_actions(self) -> List[Dict[str, Any]]:
75
+ """Get all pending actions."""
76
+ queue = self._load_queue()
77
+ return [action for action in queue if action["status"] == "pending"]
78
+
79
+ def approve_action(self, action_id: str) -> Optional[Dict[str, Any]]:
80
+ """
81
+ Approve an action for execution.
82
+
83
+ Args:
84
+ action_id: ID of the action to approve
85
+
86
+ Returns:
87
+ The approved action data, or None if not found
88
+ """
89
+ queue = self._load_queue()
90
+
91
+ for action in queue:
92
+ if action["id"] == action_id and action["status"] == "pending":
93
+ action["status"] = "approved"
94
+ action["approved_at"] = time.time()
95
+ self._save_queue(queue)
96
+ return action
97
+
98
+ return None
99
+
100
+ def reject_action(self, action_id: str) -> bool:
101
+ """
102
+ Reject an action.
103
+
104
+ Args:
105
+ action_id: ID of the action to reject
106
+
107
+ Returns:
108
+ True if the action was found and rejected
109
+ """
110
+ queue = self._load_queue()
111
+
112
+ for action in queue:
113
+ if action["id"] == action_id and action["status"] == "pending":
114
+ action["status"] = "rejected"
115
+ action["rejected_at"] = time.time()
116
+ self._save_queue(queue)
117
+ return True
118
+
119
+ return False
120
+
121
+ def cleanup_old_actions(self, max_age_hours: int = 24) -> int:
122
+ """
123
+ Remove old actions from the queue.
124
+
125
+ Args:
126
+ max_age_hours: Maximum age in hours before removal
127
+
128
+ Returns:
129
+ Number of actions removed
130
+ """
131
+ queue = self._load_queue()
132
+ cutoff_time = time.time() - (max_age_hours * 3600)
133
+
134
+ old_count = len(queue)
135
+ queue = [action for action in queue if action["timestamp"] > cutoff_time]
136
+ new_count = len(queue)
137
+
138
+ if old_count != new_count:
139
+ self._save_queue(queue)
140
+
141
+ return old_count - new_count
142
+
143
+
144
+ # Global action queue instance
145
+ _action_queue = None
146
+
147
+
148
+ def get_action_queue(cfg: Dict[str, Any]) -> ActionQueue:
149
+ """Get or create the global action queue."""
150
+ global _action_queue
151
+ if _action_queue is None:
152
+ queue_file = cfg.get("QUEUE_FILE")
153
+ _action_queue = ActionQueue(queue_file)
154
+ return _action_queue
155
+
156
+
157
+ def maybe_queue(function: Callable, args: Tuple, kwargs: Dict[str, Any], cfg: Dict[str, Any]) -> bool:
158
+ """
159
+ Queue an action for approval if confirmation is required.
160
+
161
+ Args:
162
+ function: Function to potentially queue
163
+ args: Function arguments
164
+ kwargs: Function keyword arguments
165
+ cfg: Configuration dictionary
166
+
167
+ Returns:
168
+ True if the action was queued (requiring approval)
169
+ False if the action should proceed immediately
170
+ """
171
+ # Check if confirmation is enabled globally
172
+ if not cfg.get("CONFIRMATION_ENABLED", True):
173
+ return False
174
+
175
+ # Generate unique action ID
176
+ action_id = str(uuid.uuid4())
177
+
178
+ # Add to queue
179
+ queue = get_action_queue(cfg)
180
+ queue.add_action(action_id, function, args, kwargs)
181
+
182
+ # Store action ID for potential approval
183
+ cfg["_last_action_id"] = action_id
184
+
185
+ return True
186
+
187
+
188
+ def execute_approved_action(action_id: str, cfg: Dict[str, Any]) -> Any:
189
+ """
190
+ Execute a previously approved action.
191
+
192
+ Args:
193
+ action_id: ID of the action to execute
194
+ cfg: Configuration dictionary
195
+
196
+ Returns:
197
+ Result of the executed function
198
+
199
+ Raises:
200
+ ValueError: If the action is not found or not approved
201
+ """
202
+ queue = get_action_queue(cfg)
203
+ action_queue_data = queue._load_queue()
204
+
205
+ # Find the action
206
+ action = None
207
+ for a in action_queue_data:
208
+ if a["id"] == action_id:
209
+ action = a
210
+ break
211
+
212
+ if not action:
213
+ raise ValueError(f"Action {action_id} not found")
214
+
215
+ if action["status"] != "approved":
216
+ raise ValueError(f"Action {action_id} is not approved")
217
+
218
+ # Import and execute the function
219
+ # Note: This is a simplified version - in practice, you'd need
220
+ # to properly reconstruct the function from module/name
221
+ function_name = action["function_name"]
222
+ args = tuple(action["args"])
223
+ kwargs = action["kwargs"]
224
+
225
+ # This would need to be implemented based on your specific requirements
226
+ # for function reconstruction and execution
227
+ raise NotImplementedError("Function execution from queue not implemented")
228
+
229
+
230
+ def list_pending_actions(cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
231
+ """
232
+ List all pending actions.
233
+
234
+ Args:
235
+ cfg: Configuration dictionary
236
+
237
+ Returns:
238
+ List of pending action dictionaries
239
+ """
240
+ queue = get_action_queue(cfg)
241
+ return queue.get_pending_actions()
242
+
243
+
244
+ def format_action_summary(action: Dict[str, Any]) -> str:
245
+ """
246
+ Format an action for display.
247
+
248
+ Args:
249
+ action: Action dictionary
250
+
251
+ Returns:
252
+ Human-readable action summary
253
+ """
254
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(action["timestamp"]))
255
+ return (
256
+ f"ID: {action['id']}\n"
257
+ f"Function: {action['function_name']}\n"
258
+ f"Time: {timestamp}\n"
259
+ f"Args: {action['args']}\n"
260
+ f"Kwargs: {action['kwargs']}\n"
261
+ f"Status: {action['status']}"
262
+ )
smcp/cpu.py ADDED
@@ -0,0 +1,67 @@
1
+ ""
2
+ CPU core detection for licensing enforcement.
3
+ """
4
+ import os
5
+ import logging
6
+ from typing import Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def get_physical_cores() -> int:
11
+ """Get physical CPU core count."""
12
+ try:
13
+ import psutil
14
+ physical = psutil.cpu_count(logical=False)
15
+ if physical is None:
16
+ physical = psutil.cpu_count(logical=True)
17
+ return physical or 1
18
+ except ImportError:
19
+ # Fallback to os.cpu_count if psutil not available
20
+ return os.cpu_count() or 1
21
+
22
+ def get_cgroup_cores() -> Optional[int]:
23
+ """Get CPU limit from cgroup v2."""
24
+ try:
25
+ with open("/sys/fs/cgroup/cpu.max", "r") as f:
26
+ content = f.read().strip()
27
+ if content == "max":
28
+ return None
29
+
30
+ parts = content.split()
31
+ if len(parts) >= 2:
32
+ quota = int(parts[0])
33
+ period = int(parts[1])
34
+ return quota // period
35
+ return None
36
+ except (FileNotFoundError, ValueError, PermissionError):
37
+ # Try cgroup v1
38
+ try:
39
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "r") as f:
40
+ quota = int(f.read().strip())
41
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us", "r") as f:
42
+ period = int(f.read().strip())
43
+
44
+ if quota > 0:
45
+ return quota // period
46
+ return None
47
+ except (FileNotFoundError, ValueError, PermissionError):
48
+ return None
49
+
50
+ def detect_cores() -> int:
51
+ """
52
+ Detect available CPU cores considering both physical and cgroup limits.
53
+
54
+ Returns:
55
+ Number of detected cores
56
+ """
57
+ physical = get_physical_cores()
58
+ cgroup = get_cgroup_cores()
59
+
60
+ if cgroup is not None:
61
+ detected = min(physical, cgroup)
62
+ logger.debug(f"Core detection: physical={physical}, cgroup={cgroup}, detected={detected}")
63
+ else:
64
+ detected = physical
65
+ logger.debug(f"Core detection: physical={physical}, cgroup=None, detected={detected}")
66
+
67
+ return max(1, detected) # Ensure at least 1 core
smcp/decorators.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ Decorator builders for securing MCP tools, prompts, and retrieval functions.
3
+
4
+ Provides a unified decorator factory that wraps the original MCP decorators
5
+ with conditional security guards.
6
+ """
7
+
8
+ from functools import wraps
9
+ from typing import Any, Callable, Dict, Optional
10
+
11
+ try:
12
+ from mcp import tool as base_tool, prompt as base_prompt, retrieval as base_retrieval
13
+ except ImportError:
14
+ # Fallback for testing or when mcp is not available
15
+ def base_tool(*args, **kwargs):
16
+ def decorator(fn):
17
+ return fn
18
+ return decorator
19
+
20
+ def base_prompt(*args, **kwargs):
21
+ def decorator(fn):
22
+ return fn
23
+ return decorator
24
+
25
+ def base_retrieval(*args, **kwargs):
26
+ def decorator(fn):
27
+ return fn
28
+ return decorator
29
+
30
+ from .filters import sanitize_prompt
31
+ from .allowlist import validate_host
32
+ from .confirm import maybe_queue, PendingApproval
33
+ from .logchain import log_event
34
+
35
+
36
+ def _secure(base_deco: Callable) -> Callable:
37
+ """
38
+ Create a security-enhanced decorator factory from a base MCP decorator.
39
+
40
+ Args:
41
+ base_deco: The original MCP decorator (tool, prompt, or retrieval)
42
+
43
+ Returns:
44
+ A decorator factory that applies security guards conditionally
45
+ """
46
+ def builder(*dargs, confirm: bool = False, **dkwargs) -> Callable:
47
+ """
48
+ Build a secured decorator with optional confirmation requirement.
49
+
50
+ Args:
51
+ *dargs: Positional arguments passed to base decorator
52
+ confirm: Whether to require approval for destructive actions
53
+ **dkwargs: Keyword arguments passed to base decorator
54
+
55
+ Returns:
56
+ A decorator that applies security guards to the wrapped function
57
+ """
58
+ def inner(fn: Callable) -> Callable:
59
+ @base_deco(*dargs, **dkwargs)
60
+ @wraps(fn)
61
+ def wrapper(*args, **kwargs) -> Any:
62
+ # Extract SMCP configuration injected by runtime adapter
63
+ cfg = kwargs.pop("_smcp_cfg", {})
64
+
65
+ # Input sanitization guard (activates if SAFE_RE configured)
66
+ if cfg.get("SAFE_RE"):
67
+ prompt_text = kwargs.get("prompt", "")
68
+ if prompt_text:
69
+ sanitize_prompt(prompt_text, cfg)
70
+
71
+ # Host allowlist guard (activates if ALLOWED_HOSTS configured)
72
+ if cfg.get("ALLOWED_HOSTS"):
73
+ target_host = kwargs.get("target", "")
74
+ if target_host:
75
+ validate_host(target_host, cfg)
76
+
77
+ # Destructive action confirmation guard
78
+ if confirm and maybe_queue(fn, args, kwargs, cfg):
79
+ raise PendingApproval(f"Action {fn.__name__} queued for approval")
80
+
81
+ # Execute the wrapped function
82
+ result = fn(*args, **kwargs)
83
+
84
+ # Audit logging guard (activates if LOG_PATH configured)
85
+ if cfg.get("LOG_PATH"):
86
+ log_event(fn.__name__, args, kwargs, result, cfg)
87
+
88
+ return result
89
+ return wrapper
90
+ return inner
91
+ return builder
92
+
93
+
94
+ # Create secured versions of MCP decorators
95
+ tool = _secure(base_tool)
96
+ prompt = _secure(base_prompt)
97
+ retrieval = _secure(base_retrieval)
smcp/enforce.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ License enforcement with grace period and core limit checking.
3
+ """
4
+ import os
5
+ import time
6
+ import threading
7
+ import logging
8
+ from typing import Optional
9
+ from .cpu import detect_cores
10
+ from .license import get_licensed_cores, CoreLimitExceededError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class LicenseEnforcer:
15
+ """Handles license enforcement with grace periods."""
16
+
17
+ def __init__(self):
18
+ self._timer: Optional[threading.Timer] = None
19
+ self._grace_active = False
20
+ self._warned = False
21
+ self._ignore_cores = '--ignore-cores' in os.sys.argv if hasattr(os, 'sys') else False
22
+
23
+ def check_core_limit(self) -> None:
24
+ """Check if current core usage exceeds license limit."""
25
+ licensed_cores = get_licensed_cores()
26
+ detected_cores = detect_cores()
27
+
28
+ logger.info(f"SMCP cores licensed={licensed_cores} detected={detected_cores}")
29
+
30
+ # If no license or ignore flag, allow operation
31
+ if licensed_cores == 0 and not self._ignore_cores:
32
+ logger.warning("No valid license found - running in evaluation mode")
33
+ return
34
+
35
+ if self._ignore_cores:
36
+ logger.warning("Running with --ignore-cores flag - this is for emergency use only")
37
+ # Start emergency timer (30 seconds)
38
+ threading.Timer(30.0, self._emergency_abort).start()
39
+ return
40
+
41
+ if detected_cores > licensed_cores:
42
+ if not self._warned:
43
+ logger.warning(f"Core limit exceeded: using {detected_cores} cores, licensed for {licensed_cores}")
44
+ self._warned = True
45
+ self._start_grace_timer()
46
+ elif not self._grace_active:
47
+ # Grace period expired
48
+ raise CoreLimitExceededError(
49
+ f"Core limit exceeded: using {detected_cores} cores, licensed for {licensed_cores}. "
50
+ f"Grace period expired."
51
+ )
52
+ else:
53
+ # Within limits - reset warning and cancel timer
54
+ if self._grace_active:
55
+ logger.info("Core usage within limits - grace period cancelled")
56
+ self._cancel_grace_timer()
57
+ self._warned = False
58
+
59
+ def _start_grace_timer(self) -> None:
60
+ """Start 15-minute grace period timer."""
61
+ if self._timer:
62
+ self._timer.cancel()
63
+
64
+ self._grace_active = True
65
+ logger.warning("Starting 15-minute grace period for core limit violation")
66
+ self._timer = threading.Timer(15 * 60.0, self._grace_expired) # 15 minutes
67
+ self._timer.start()
68
+
69
+ def _cancel_grace_timer(self) -> None:
70
+ """Cancel active grace timer."""
71
+ if self._timer:
72
+ self._timer.cancel()
73
+ self._timer = None
74
+ self._grace_active = False
75
+
76
+ def _grace_expired(self) -> None:
77
+ """Called when grace period expires."""
78
+ self._grace_active = False
79
+ logger.error("Grace period expired - core limit still exceeded")
80
+ # In a real implementation, this would terminate the process
81
+ # For now, we'll raise an exception
82
+ raise CoreLimitExceededError("Grace period expired - core limit exceeded")
83
+
84
+ def _emergency_abort(self) -> None:
85
+ """Called when emergency ignore timer expires."""
86
+ logger.error("Emergency operation time expired")
87
+ # Exit after 30 seconds when using --ignore-cores
88
+ os._exit(1)
89
+
90
+ # Global enforcer instance
91
+ _enforcer = LicenseEnforcer()
92
+
93
+ def check_license_compliance() -> None:
94
+ """Check license compliance - call this at application startup."""
95
+ global _enforcer
96
+ _enforcer.check_core_limit()
97
+
98
+ def is_licensed() -> bool:
99
+ """Check if application has a valid license."""
100
+ return get_licensed_cores() > 0