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,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Local Model Registry
|
|
4
|
+
|
|
5
|
+
Manages the catalog of local security models, downloads from HuggingFace,
|
|
6
|
+
and handles model directory lifecycle.
|
|
7
|
+
|
|
8
|
+
Models are stored in ~/.tweek/models/<model-name>/ with:
|
|
9
|
+
- model.onnx — ONNX model file
|
|
10
|
+
- tokenizer.json — Tokenizer configuration
|
|
11
|
+
- model_meta.yaml — Metadata (catalog info + download timestamps)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import shutil
|
|
16
|
+
import urllib.request
|
|
17
|
+
import urllib.error
|
|
18
|
+
import os
|
|
19
|
+
import ssl
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ModelDefinition:
|
|
30
|
+
"""Definition of a model in the catalog."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
display_name: str
|
|
34
|
+
hf_repo: str
|
|
35
|
+
description: str
|
|
36
|
+
num_labels: int
|
|
37
|
+
label_map: Dict[int, str]
|
|
38
|
+
risk_map: Dict[str, str] # label -> risk level (safe/suspicious/dangerous)
|
|
39
|
+
max_length: int = 512
|
|
40
|
+
license: str = "unknown"
|
|
41
|
+
size_mb: float = 0.0 # approximate download size
|
|
42
|
+
files: List[str] = field(default_factory=list)
|
|
43
|
+
hf_subfolder: str = "" # subfolder in the HF repo (e.g., "onnx")
|
|
44
|
+
requires_auth: bool = False
|
|
45
|
+
default: bool = False
|
|
46
|
+
|
|
47
|
+
# Confidence thresholds for escalation
|
|
48
|
+
escalate_min_confidence: float = 0.1
|
|
49
|
+
escalate_max_confidence: float = 0.9
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ============================================================================
|
|
53
|
+
# MODEL CATALOG
|
|
54
|
+
# ============================================================================
|
|
55
|
+
|
|
56
|
+
MODEL_CATALOG: Dict[str, ModelDefinition] = {
|
|
57
|
+
"deberta-v3-injection": ModelDefinition(
|
|
58
|
+
name="deberta-v3-injection",
|
|
59
|
+
display_name="ProtectAI DeBERTa v3 Prompt Injection v2",
|
|
60
|
+
hf_repo="protectai/deberta-v3-base-prompt-injection-v2",
|
|
61
|
+
description=(
|
|
62
|
+
"Binary prompt injection classifier based on DeBERTa-v3-base. "
|
|
63
|
+
"Detects prompt injection attacks in English text. "
|
|
64
|
+
"Apache 2.0 license, no authentication required."
|
|
65
|
+
),
|
|
66
|
+
num_labels=2,
|
|
67
|
+
label_map={0: "safe", 1: "injection"},
|
|
68
|
+
risk_map={
|
|
69
|
+
"safe": "safe",
|
|
70
|
+
"injection": "dangerous",
|
|
71
|
+
},
|
|
72
|
+
max_length=512,
|
|
73
|
+
license="Apache-2.0",
|
|
74
|
+
size_mb=750.0,
|
|
75
|
+
files=["model.onnx", "tokenizer.json"],
|
|
76
|
+
hf_subfolder="onnx",
|
|
77
|
+
requires_auth=False,
|
|
78
|
+
default=True,
|
|
79
|
+
escalate_min_confidence=0.1,
|
|
80
|
+
escalate_max_confidence=0.9,
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
DEFAULT_MODEL = "deberta-v3-injection"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ============================================================================
|
|
88
|
+
# DIRECTORY MANAGEMENT
|
|
89
|
+
# ============================================================================
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_models_dir() -> Path:
|
|
93
|
+
"""Get the models directory (~/.tweek/models/)."""
|
|
94
|
+
models_dir = Path.home() / ".tweek" / "models"
|
|
95
|
+
return models_dir
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_model_dir(name: str) -> Path:
|
|
99
|
+
"""Get the directory for a specific model."""
|
|
100
|
+
return get_models_dir() / name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_default_model_name() -> str:
|
|
104
|
+
"""Get the configured default model name.
|
|
105
|
+
|
|
106
|
+
Checks user config first, falls back to catalog default.
|
|
107
|
+
"""
|
|
108
|
+
config_path = Path.home() / ".tweek" / "config.yaml"
|
|
109
|
+
if config_path.exists():
|
|
110
|
+
try:
|
|
111
|
+
with open(config_path) as f:
|
|
112
|
+
config = yaml.safe_load(f) or {}
|
|
113
|
+
local_model_cfg = config.get("local_model", {})
|
|
114
|
+
model = local_model_cfg.get("model", "auto")
|
|
115
|
+
if model != "auto" and model in MODEL_CATALOG:
|
|
116
|
+
return model
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
return DEFAULT_MODEL
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_model_installed(name: str) -> bool:
|
|
124
|
+
"""Check if a model is installed with all required files."""
|
|
125
|
+
if name not in MODEL_CATALOG:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
model_dir = get_model_dir(name)
|
|
129
|
+
if not model_dir.exists():
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
definition = MODEL_CATALOG[name]
|
|
133
|
+
for filename in definition.files:
|
|
134
|
+
if not (model_dir / filename).exists():
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_installed_models() -> List[str]:
|
|
141
|
+
"""List all installed model names."""
|
|
142
|
+
models_dir = get_models_dir()
|
|
143
|
+
if not models_dir.exists():
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
installed = []
|
|
147
|
+
for name in MODEL_CATALOG:
|
|
148
|
+
if is_model_installed(name):
|
|
149
|
+
installed.append(name)
|
|
150
|
+
|
|
151
|
+
return installed
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_model_definition(name: str) -> Optional[ModelDefinition]:
|
|
155
|
+
"""Get the catalog definition for a model."""
|
|
156
|
+
return MODEL_CATALOG.get(name)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ============================================================================
|
|
160
|
+
# MODEL DOWNLOAD
|
|
161
|
+
# ============================================================================
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ModelDownloadError(Exception):
|
|
165
|
+
"""Error during model download."""
|
|
166
|
+
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _build_hf_url(repo: str, filename: str, subfolder: str = "") -> str:
|
|
171
|
+
"""Build a HuggingFace CDN download URL."""
|
|
172
|
+
if subfolder:
|
|
173
|
+
return f"https://huggingface.co/{repo}/resolve/main/{subfolder}/{filename}"
|
|
174
|
+
return f"https://huggingface.co/{repo}/resolve/main/{filename}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_hf_headers() -> Dict[str, str]:
|
|
178
|
+
"""Get HTTP headers for HuggingFace requests."""
|
|
179
|
+
headers = {
|
|
180
|
+
"User-Agent": "tweek/0.1.0",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Support HF_TOKEN for gated models (like Prompt Guard)
|
|
184
|
+
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
|
185
|
+
if hf_token:
|
|
186
|
+
headers["Authorization"] = f"Bearer {hf_token}"
|
|
187
|
+
|
|
188
|
+
return headers
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def download_model(
|
|
192
|
+
name: str,
|
|
193
|
+
progress_callback: Optional[Callable[[str, int, int], None]] = None,
|
|
194
|
+
force: bool = False,
|
|
195
|
+
) -> Path:
|
|
196
|
+
"""Download a model from HuggingFace.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
name: Model name from the catalog.
|
|
200
|
+
progress_callback: Optional callback(filename, bytes_downloaded, total_bytes).
|
|
201
|
+
force: If True, re-download even if already installed.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Path to the model directory.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ModelDownloadError: If the model is not in the catalog or download fails.
|
|
208
|
+
"""
|
|
209
|
+
definition = MODEL_CATALOG.get(name)
|
|
210
|
+
if definition is None:
|
|
211
|
+
available = ", ".join(MODEL_CATALOG.keys())
|
|
212
|
+
raise ModelDownloadError(
|
|
213
|
+
f"Unknown model '{name}'. Available models: {available}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
model_dir = get_model_dir(name)
|
|
217
|
+
|
|
218
|
+
if is_model_installed(name) and not force:
|
|
219
|
+
return model_dir
|
|
220
|
+
|
|
221
|
+
# Create directory
|
|
222
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
headers = _get_hf_headers()
|
|
225
|
+
|
|
226
|
+
if definition.requires_auth and "Authorization" not in headers:
|
|
227
|
+
raise ModelDownloadError(
|
|
228
|
+
f"Model '{name}' requires HuggingFace authentication. "
|
|
229
|
+
f"Set HF_TOKEN environment variable with a token that has "
|
|
230
|
+
f"access to {definition.hf_repo}. "
|
|
231
|
+
f"Get a token at https://huggingface.co/settings/tokens"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Create SSL context
|
|
235
|
+
ssl_context = ssl.create_default_context()
|
|
236
|
+
|
|
237
|
+
# Download each file
|
|
238
|
+
for filename in definition.files:
|
|
239
|
+
url = _build_hf_url(definition.hf_repo, filename, definition.hf_subfolder)
|
|
240
|
+
dest = model_dir / filename
|
|
241
|
+
tmp_dest = model_dir / f".{filename}.tmp"
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
request = urllib.request.Request(url, headers=headers)
|
|
245
|
+
response = urllib.request.urlopen(request, context=ssl_context)
|
|
246
|
+
|
|
247
|
+
total = int(response.headers.get("Content-Length", 0))
|
|
248
|
+
downloaded = 0
|
|
249
|
+
chunk_size = 1024 * 1024 # 1MB chunks
|
|
250
|
+
|
|
251
|
+
with open(tmp_dest, "wb") as f:
|
|
252
|
+
while True:
|
|
253
|
+
chunk = response.read(chunk_size)
|
|
254
|
+
if not chunk:
|
|
255
|
+
break
|
|
256
|
+
f.write(chunk)
|
|
257
|
+
downloaded += len(chunk)
|
|
258
|
+
if progress_callback:
|
|
259
|
+
progress_callback(filename, downloaded, total)
|
|
260
|
+
|
|
261
|
+
# Atomic rename
|
|
262
|
+
tmp_dest.rename(dest)
|
|
263
|
+
|
|
264
|
+
except urllib.error.HTTPError as e:
|
|
265
|
+
tmp_dest.unlink(missing_ok=True)
|
|
266
|
+
if e.code == 401:
|
|
267
|
+
raise ModelDownloadError(
|
|
268
|
+
f"Authentication failed for '{name}'. "
|
|
269
|
+
f"Check your HF_TOKEN has access to {definition.hf_repo}. "
|
|
270
|
+
f"You may need to accept the license at "
|
|
271
|
+
f"https://huggingface.co/{definition.hf_repo}"
|
|
272
|
+
) from e
|
|
273
|
+
elif e.code == 404:
|
|
274
|
+
raise ModelDownloadError(
|
|
275
|
+
f"File '{filename}' not found in {definition.hf_repo}. "
|
|
276
|
+
f"The model may have been moved or renamed."
|
|
277
|
+
) from e
|
|
278
|
+
else:
|
|
279
|
+
raise ModelDownloadError(
|
|
280
|
+
f"HTTP {e.code} downloading {filename}: {e.reason}"
|
|
281
|
+
) from e
|
|
282
|
+
except urllib.error.URLError as e:
|
|
283
|
+
tmp_dest.unlink(missing_ok=True)
|
|
284
|
+
raise ModelDownloadError(
|
|
285
|
+
f"Network error downloading {filename}: {e.reason}"
|
|
286
|
+
) from e
|
|
287
|
+
except Exception as e:
|
|
288
|
+
tmp_dest.unlink(missing_ok=True)
|
|
289
|
+
raise ModelDownloadError(
|
|
290
|
+
f"Failed to download {filename}: {e}"
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
# Write metadata
|
|
294
|
+
meta = {
|
|
295
|
+
"name": definition.name,
|
|
296
|
+
"display_name": definition.display_name,
|
|
297
|
+
"hf_repo": definition.hf_repo,
|
|
298
|
+
"num_labels": definition.num_labels,
|
|
299
|
+
"label_map": definition.label_map,
|
|
300
|
+
"risk_map": definition.risk_map,
|
|
301
|
+
"max_length": definition.max_length,
|
|
302
|
+
"license": definition.license,
|
|
303
|
+
"downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
304
|
+
"files": definition.files,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
with open(model_dir / "model_meta.yaml", "w") as f:
|
|
308
|
+
yaml.dump(meta, f, default_flow_style=False, sort_keys=False)
|
|
309
|
+
|
|
310
|
+
return model_dir
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def remove_model(name: str) -> bool:
|
|
314
|
+
"""Remove a downloaded model.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
name: Model name.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
True if the model was removed, False if not found.
|
|
321
|
+
"""
|
|
322
|
+
model_dir = get_model_dir(name)
|
|
323
|
+
if model_dir.exists():
|
|
324
|
+
shutil.rmtree(model_dir)
|
|
325
|
+
return True
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def verify_model(name: str) -> Dict[str, bool]:
|
|
330
|
+
"""Verify a model installation.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
name: Model name.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Dict mapping filename to exists status.
|
|
337
|
+
"""
|
|
338
|
+
definition = MODEL_CATALOG.get(name)
|
|
339
|
+
if definition is None:
|
|
340
|
+
return {}
|
|
341
|
+
|
|
342
|
+
model_dir = get_model_dir(name)
|
|
343
|
+
status = {}
|
|
344
|
+
|
|
345
|
+
for filename in definition.files:
|
|
346
|
+
status[filename] = (model_dir / filename).exists()
|
|
347
|
+
|
|
348
|
+
status["model_meta.yaml"] = (model_dir / "model_meta.yaml").exists()
|
|
349
|
+
|
|
350
|
+
return status
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def get_model_size(name: str) -> Optional[int]:
|
|
354
|
+
"""Get the total size of an installed model in bytes.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
name: Model name.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Total size in bytes, or None if not installed.
|
|
361
|
+
"""
|
|
362
|
+
model_dir = get_model_dir(name)
|
|
363
|
+
if not model_dir.exists():
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
total = 0
|
|
367
|
+
for path in model_dir.iterdir():
|
|
368
|
+
if path.is_file():
|
|
369
|
+
total += path.stat().st_size
|
|
370
|
+
|
|
371
|
+
return total
|
tweek/security/rate_limiter.py
CHANGED
|
@@ -120,7 +120,7 @@ class CircuitBreaker:
|
|
|
120
120
|
- OPEN: Too many failures, requests blocked, waiting for timeout
|
|
121
121
|
- HALF_OPEN: Testing recovery, limited requests allowed
|
|
122
122
|
|
|
123
|
-
Based on
|
|
123
|
+
Based on OpenClaw's circuit breaker implementation for resilience.
|
|
124
124
|
"""
|
|
125
125
|
|
|
126
126
|
def __init__(self, config: Optional[CircuitBreakerConfig] = None):
|
|
@@ -459,8 +459,12 @@ class RateLimiter:
|
|
|
459
459
|
RateLimitResult with allowed status and any violations
|
|
460
460
|
"""
|
|
461
461
|
if not session_id:
|
|
462
|
-
# No session
|
|
463
|
-
|
|
462
|
+
# No session ID - generate unique one per process invocation
|
|
463
|
+
import os as _os
|
|
464
|
+
import uuid as _uuid
|
|
465
|
+
session_id = hashlib.sha256(
|
|
466
|
+
f"tweek-{_os.getpid()}-{_os.getcwd()}-{_uuid.getnode()}".encode()
|
|
467
|
+
).hexdigest()[:16]
|
|
464
468
|
|
|
465
469
|
# Check circuit breaker first
|
|
466
470
|
circuit_key = f"session:{session_id}"
|
|
@@ -531,10 +535,11 @@ class RateLimiter:
|
|
|
531
535
|
details["velocity_ratio"] = round(current / baseline, 2)
|
|
532
536
|
|
|
533
537
|
except Exception as e:
|
|
534
|
-
# Database error - fail
|
|
538
|
+
# Database error - fail closed for safety
|
|
535
539
|
return RateLimitResult(
|
|
536
|
-
allowed=
|
|
537
|
-
|
|
540
|
+
allowed=False,
|
|
541
|
+
violations=[RateLimitViolation.BURST],
|
|
542
|
+
message=f"Rate limit check failed (blocking for safety): {e}",
|
|
538
543
|
details={"error": str(e)}
|
|
539
544
|
)
|
|
540
545
|
|
tweek/security/secret_scanner.py
CHANGED
|
@@ -5,7 +5,7 @@ Tweek Secret Scanner
|
|
|
5
5
|
Scans configuration files for hardcoded secrets and credentials.
|
|
6
6
|
Enforces environment-variable-only secrets policy.
|
|
7
7
|
|
|
8
|
-
Based on
|
|
8
|
+
Based on OpenClaw's secret-guard security hardening initiative.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import os
|
|
@@ -150,6 +150,68 @@ class SecretScanner:
|
|
|
150
150
|
# Slack tokens
|
|
151
151
|
(r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*', SecretType.TOKEN, "critical"),
|
|
152
152
|
|
|
153
|
+
# Stripe keys (live and test)
|
|
154
|
+
(r'sk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "critical"),
|
|
155
|
+
(r'pk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "high"),
|
|
156
|
+
(r'rk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "critical"),
|
|
157
|
+
|
|
158
|
+
# SendGrid API key
|
|
159
|
+
(r'SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}', SecretType.API_KEY, "critical"),
|
|
160
|
+
|
|
161
|
+
# Anthropic API key
|
|
162
|
+
(r'sk-ant-[A-Za-z0-9_-]{36,}', SecretType.API_KEY, "critical"),
|
|
163
|
+
|
|
164
|
+
# OpenAI API key
|
|
165
|
+
(r'sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}', SecretType.API_KEY, "critical"),
|
|
166
|
+
|
|
167
|
+
# npm token
|
|
168
|
+
(r'npm_[A-Za-z0-9]{36,}', SecretType.TOKEN, "critical"),
|
|
169
|
+
|
|
170
|
+
# PyPI token
|
|
171
|
+
(r'pypi-[A-Za-z0-9_-]{50,}', SecretType.TOKEN, "critical"),
|
|
172
|
+
|
|
173
|
+
# Azure subscription key / connection string
|
|
174
|
+
(r'(?i)DefaultEndpointProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{60,}', SecretType.CONNECTION_STRING, "critical"),
|
|
175
|
+
|
|
176
|
+
# Google API key
|
|
177
|
+
(r'AIza[A-Za-z0-9_-]{35}', SecretType.API_KEY, "high"),
|
|
178
|
+
|
|
179
|
+
# Google OAuth client secret
|
|
180
|
+
(r'GOCSPX-[A-Za-z0-9_-]{28}', SecretType.OAUTH_SECRET, "critical"),
|
|
181
|
+
|
|
182
|
+
# Twilio API key
|
|
183
|
+
(r'SK[a-f0-9]{32}', SecretType.API_KEY, "high"),
|
|
184
|
+
|
|
185
|
+
# Mailchimp API key
|
|
186
|
+
(r'[a-f0-9]{32}-us[0-9]{1,2}', SecretType.API_KEY, "high"),
|
|
187
|
+
|
|
188
|
+
# Discord bot token
|
|
189
|
+
(r'[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}', SecretType.TOKEN, "critical"),
|
|
190
|
+
|
|
191
|
+
# Heroku API key
|
|
192
|
+
(r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', SecretType.API_KEY, "medium"),
|
|
193
|
+
|
|
194
|
+
# Vercel token
|
|
195
|
+
(r'vercel_[A-Za-z0-9_-]{24,}', SecretType.TOKEN, "critical"),
|
|
196
|
+
|
|
197
|
+
# Supabase key
|
|
198
|
+
(r'sbp_[A-Za-z0-9]{40,}', SecretType.API_KEY, "critical"),
|
|
199
|
+
|
|
200
|
+
# Databricks token
|
|
201
|
+
(r'dapi[a-f0-9]{32}', SecretType.TOKEN, "critical"),
|
|
202
|
+
|
|
203
|
+
# Hashicorp Vault token
|
|
204
|
+
(r'hvs\.[A-Za-z0-9_-]{24,}', SecretType.TOKEN, "critical"),
|
|
205
|
+
|
|
206
|
+
# GitLab tokens (personal, pipeline, runner)
|
|
207
|
+
(r'glpat-[A-Za-z0-9_-]{20,}', SecretType.TOKEN, "critical"),
|
|
208
|
+
|
|
209
|
+
# Figma token
|
|
210
|
+
(r'figd_[A-Za-z0-9_-]{40,}', SecretType.TOKEN, "high"),
|
|
211
|
+
|
|
212
|
+
# Linear API key
|
|
213
|
+
(r'lin_api_[A-Za-z0-9]{40,}', SecretType.API_KEY, "high"),
|
|
214
|
+
|
|
153
215
|
# Generic high-entropy strings that look like secrets
|
|
154
216
|
(r'[\'"][A-Za-z0-9+/]{40,}={0,2}[\'"]', SecretType.API_KEY, "medium"),
|
|
155
217
|
]
|
|
@@ -347,15 +409,19 @@ class SecretScanner:
|
|
|
347
409
|
return False
|
|
348
410
|
|
|
349
411
|
def _redact_value(self, value: Any) -> str:
|
|
350
|
-
"""Redact a secret value for safe display.
|
|
412
|
+
"""Redact a secret value for safe display.
|
|
413
|
+
|
|
414
|
+
Only shows first 4 characters (provider prefix) — never reveals suffix
|
|
415
|
+
to minimize information leakage.
|
|
416
|
+
"""
|
|
351
417
|
if not isinstance(value, str):
|
|
352
418
|
return "<redacted>"
|
|
353
419
|
|
|
354
420
|
if len(value) <= 8:
|
|
355
421
|
return "*" * len(value)
|
|
356
422
|
|
|
357
|
-
# Show first 4
|
|
358
|
-
return f"{value[:4]}{'*' * (len(value) -
|
|
423
|
+
# Show first 4 chars only (identifies provider without revealing key material)
|
|
424
|
+
return f"{value[:4]}{'*' * (len(value) - 4)}"
|
|
359
425
|
|
|
360
426
|
def _redact_line(self, line: str) -> str:
|
|
361
427
|
"""Redact sensitive parts of a line."""
|
|
@@ -475,6 +475,28 @@ class SessionAnalyzer:
|
|
|
475
475
|
anomalies.append(AnomalyType.CAPABILITY_AGGREGATION)
|
|
476
476
|
all_details["capability_aggregation"] = aggregation_details
|
|
477
477
|
|
|
478
|
+
# Memory: read cross-session workflow baselines
|
|
479
|
+
try:
|
|
480
|
+
from tweek.memory.queries import memory_get_workflow_baseline
|
|
481
|
+
from tweek.memory.store import hash_project
|
|
482
|
+
# Use session_id as a proxy for project context
|
|
483
|
+
baseline = memory_get_workflow_baseline(session_id)
|
|
484
|
+
if baseline:
|
|
485
|
+
all_details["memory_baseline"] = baseline
|
|
486
|
+
# Flag if current denial ratio significantly exceeds baseline
|
|
487
|
+
current_denial_ratio = len(
|
|
488
|
+
[e for e in events if e.get("decision") in ("block", "ask")]
|
|
489
|
+
) / max(len(events), 1)
|
|
490
|
+
baseline_ratio = baseline.get("denial_ratio", 0)
|
|
491
|
+
if (
|
|
492
|
+
current_denial_ratio > baseline_ratio * 2
|
|
493
|
+
and current_denial_ratio > 0.2
|
|
494
|
+
and baseline.get("total_invocations", 0) >= 20
|
|
495
|
+
):
|
|
496
|
+
all_details["memory_baseline_exceeded"] = True
|
|
497
|
+
except Exception:
|
|
498
|
+
pass # Memory is best-effort
|
|
499
|
+
|
|
478
500
|
# Calculate risk score
|
|
479
501
|
risk_score = self._calculate_risk_score(anomalies, events, all_details)
|
|
480
502
|
|
|
@@ -523,10 +545,12 @@ class SessionAnalyzer:
|
|
|
523
545
|
)
|
|
524
546
|
|
|
525
547
|
except Exception as e:
|
|
548
|
+
# Fail closed: analysis failure is itself suspicious
|
|
526
549
|
return SessionAnalysis(
|
|
527
550
|
session_id=session_id,
|
|
528
|
-
risk_score=0.
|
|
529
|
-
|
|
551
|
+
risk_score=0.5,
|
|
552
|
+
anomalies=[AnomalyType.SUSPICIOUS_PATTERN],
|
|
553
|
+
details={"error": str(e), "fail_closed": True}
|
|
530
554
|
)
|
|
531
555
|
|
|
532
556
|
def _update_session_profile(
|