bizteamai-smcp-biz 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,76 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ License key revocation utility for SMCP Business Edition.
4
+ """
5
+ import sys
6
+ import hmac
7
+ import hashlib
8
+ import argparse
9
+ import json
10
+ import os
11
+ from datetime import datetime
12
+
13
+ def revoke_license_key(license_key: str, reason: str = "") -> str:
14
+ """Revoke a license key by adding its hash to the revocation list."""
15
+ # Calculate key hash
16
+ key_hash = hashlib.sha256(license_key.encode()).hexdigest()
17
+
18
+ # Create revocation entry
19
+ revocation_entry = {
20
+ "hash": key_hash,
21
+ "revoked_at": datetime.utcnow().isoformat() + "Z",
22
+ "reason": reason
23
+ }
24
+
25
+ return key_hash, revocation_entry
26
+
27
+ def main():
28
+ parser = argparse.ArgumentParser(description='Revoke SMCP Business Edition license keys')
29
+ parser.add_argument('--key', '-k', required=True, help='License key to revoke')
30
+ parser.add_argument('--reason', '-r', help='Reason for revocation')
31
+ parser.add_argument('--revocation-db', '-d',
32
+ default='/var/lib/bizteam/revocation.db',
33
+ help='Revocation database file')
34
+ parser.add_argument('--output-json', '-o', help='Output revocation list as JSON')
35
+
36
+ args = parser.parse_args()
37
+
38
+ try:
39
+ key_hash, revocation_entry = revoke_license_key(args.key, args.reason or "")
40
+
41
+ # Load existing revocation database
42
+ revocation_list = {}
43
+ if os.path.exists(args.revocation_db):
44
+ try:
45
+ with open(args.revocation_db, 'r') as f:
46
+ revocation_list = json.load(f)
47
+ except (json.JSONDecodeError, FileNotFoundError):
48
+ revocation_list = {}
49
+
50
+ # Add new revocation
51
+ revocation_list[key_hash] = revocation_entry
52
+
53
+ # Save updated database
54
+ os.makedirs(os.path.dirname(args.revocation_db), exist_ok=True)
55
+ with open(args.revocation_db, 'w') as f:
56
+ json.dump(revocation_list, f, indent=2)
57
+
58
+ print(f"License key revoked: {key_hash[:16]}...")
59
+ print(f"Revocation database updated: {args.revocation_db}")
60
+
61
+ # Output JSON list if requested
62
+ if args.output_json:
63
+ json_output = {
64
+ "revoked": list(revocation_list.keys()),
65
+ "updated_at": datetime.utcnow().isoformat() + "Z"
66
+ }
67
+ with open(args.output_json, 'w') as f:
68
+ json.dump(json_output, f, indent=2)
69
+ print(f"JSON revocation list written to: {args.output_json}")
70
+
71
+ except Exception as e:
72
+ print(f"Error revoking license key: {e}", file=sys.stderr)
73
+ sys.exit(1)
74
+
75
+ if __name__ == '__main__':
76
+ 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,132 @@
1
+ """
2
+ License enforcement with grace period and core monitoring.
3
+ """
4
+ import os
5
+ import sys
6
+ import time
7
+ import threading
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from .cpu import detect_cores
12
+ from .license import get_licensed_cores, get_customer_id
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class EnforcementTimer:
17
+ """Manages the 15-minute grace period timer."""
18
+
19
+ def __init__(self):
20
+ self._timer: Optional[threading.Timer] = None
21
+ self._lock = threading.Lock()
22
+ self._violations_logged = set()
23
+
24
+ def start_timer(self, used_cores: int, licensed_cores: int):
25
+ """Start or restart the 15-minute grace period timer."""
26
+ with self._lock:
27
+ # Cancel existing timer
28
+ if self._timer:
29
+ self._timer.cancel()
30
+
31
+ # Log violation (once per cores combination)
32
+ violation_key = f"{used_cores}>{licensed_cores}"
33
+ if violation_key not in self._violations_logged:
34
+ logger.warning(
35
+ f"License violation: using {used_cores} cores, licensed for {licensed_cores}. "
36
+ f"Grace period: 15 minutes."
37
+ )
38
+ self._violations_logged.add(violation_key)
39
+
40
+ # Start new timer for 15 minutes (900 seconds)
41
+ self._timer = threading.Timer(900.0, self._enforce_shutdown)
42
+ self._timer.daemon = True
43
+ self._timer.start()
44
+ logger.info("Grace period timer started (15 minutes)")
45
+
46
+ def cancel_timer(self):
47
+ """Cancel the grace period timer."""
48
+ with self._lock:
49
+ if self._timer:
50
+ self._timer.cancel()
51
+ self._timer = None
52
+ logger.info("Grace period timer cancelled - back within license limits")
53
+
54
+ def _enforce_shutdown(self):
55
+ """Called when grace period expires."""
56
+ customer_id = get_customer_id() or "unknown"
57
+ used_cores = detect_cores()
58
+ licensed_cores = get_licensed_cores()
59
+
60
+ logger.error(
61
+ f"License enforcement: Grace period expired. "
62
+ f"Customer: {customer_id}, Used: {used_cores}, Licensed: {licensed_cores}"
63
+ )
64
+
65
+ # Check for emergency override
66
+ if os.getenv('BIZTEAM_EMERGENCY_OVERRIDE') == '1':
67
+ logger.critical("EMERGENCY OVERRIDE ACTIVE - Continuing with unlicensed usage")
68
+ # Exit after 30 seconds as specified
69
+ threading.Timer(30.0, lambda: os._exit(1)).start()
70
+ return
71
+
72
+ # Force shutdown
73
+ logger.critical("Shutting down due to license violation")
74
+ os._exit(1)
75
+
76
+ # Global enforcement timer instance
77
+ _enforcement_timer = EnforcementTimer()
78
+
79
+ def check_compliance() -> bool:
80
+ """
81
+ Check if current core usage is within license limits.
82
+
83
+ Returns:
84
+ True if compliant, False if in violation
85
+ """
86
+ used_cores = detect_cores()
87
+ licensed_cores = get_licensed_cores()
88
+ customer_id = get_customer_id() or "unknown"
89
+
90
+ # Log current status
91
+ logger.info(f"bizteam cores licensed={licensed_cores} used={used_cores}")
92
+
93
+ if used_cores <= licensed_cores:
94
+ # Within limits - cancel any active timer
95
+ _enforcement_timer.cancel_timer()
96
+ return True
97
+ else:
98
+ # Violation - start/restart timer
99
+ _enforcement_timer.start_timer(used_cores, licensed_cores)
100
+ return False
101
+
102
+ def start_enforcement():
103
+ """Start the enforcement monitoring system."""
104
+ # Check for emergency flags
105
+ if os.getenv('BIZTEAM_IGNORE_CORES') == '1':
106
+ logger.warning("Core enforcement disabled via BIZTEAM_IGNORE_CORES")
107
+ # Still log usage but don't enforce
108
+ used_cores = detect_cores()
109
+ licensed_cores = get_licensed_cores()
110
+ logger.warning(f"UNLICENSED USAGE: cores used={used_cores}, licensed={licensed_cores}")
111
+ # Exit after 30 seconds as specified for emergency runs
112
+ threading.Timer(30.0, lambda: os._exit(0)).start()
113
+ return
114
+
115
+ # Initial compliance check
116
+ compliant = check_compliance()
117
+
118
+ if not compliant:
119
+ logger.warning("Starting in license violation state")
120
+
121
+ # Start periodic monitoring (every 30 seconds)
122
+ def monitor_loop():
123
+ while True:
124
+ time.sleep(30)
125
+ try:
126
+ check_compliance()
127
+ except Exception as e:
128
+ logger.error(f"Error during compliance check: {e}")
129
+
130
+ monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
131
+ monitor_thread.start()
132
+ logger.info("License enforcement monitoring started")