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.
- bizteamai_smcp-1.13.1.dist-info/METADATA +117 -0
- bizteamai_smcp-1.13.1.dist-info/RECORD +21 -0
- bizteamai_smcp-1.13.1.dist-info/WHEEL +5 -0
- bizteamai_smcp-1.13.1.dist-info/entry_points.txt +3 -0
- bizteamai_smcp-1.13.1.dist-info/top_level.txt +1 -0
- smcp/__init__.py +29 -0
- smcp/allowlist.py +169 -0
- smcp/app_wrapper.py +216 -0
- smcp/cli/__init__.py +3 -0
- smcp/cli/approve.py +261 -0
- smcp/cli/gen_key.py +73 -0
- smcp/cli/mkcert.py +327 -0
- smcp/cli/revoke.py +73 -0
- smcp/confirm.py +262 -0
- smcp/cpu.py +67 -0
- smcp/decorators.py +97 -0
- smcp/enforce.py +100 -0
- smcp/filters.py +176 -0
- smcp/license.py +113 -0
- smcp/logchain.py +270 -0
- smcp/tls.py +160 -0
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
|