tweek 0.1.0__py3-none-any.whl → 0.2.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.
- tweek/__init__.py +2 -2
- tweek/_keygen.py +53 -0
- tweek/audit.py +288 -0
- tweek/cli.py +5398 -2392
- 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.1.dist-info/METADATA +281 -0
- tweek-0.2.1.dist-info/RECORD +122 -0
- {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/entry_points.txt +8 -1
- {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/licenses/LICENSE +80 -0
- tweek-0.2.1.dist-info/top_level.txt +2 -0
- tweek-openclaw-plugin/node_modules/flatted/python/flatted.py +149 -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/top_level.txt +0 -1
- {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/WHEEL +0 -0
tweek/skills/scanner.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Skill Scanner — 7-Layer Security Pipeline
|
|
3
|
+
|
|
4
|
+
Scans skill directories through multiple security layers before allowing
|
|
5
|
+
installation. Reuses existing Tweek infrastructure where possible.
|
|
6
|
+
|
|
7
|
+
Layers:
|
|
8
|
+
1. Structure Validation — file types, size, depth, symlinks
|
|
9
|
+
2. Pattern Matching — 259 regex patterns (reuses audit.py)
|
|
10
|
+
3. Secret Scanning — credential detection (reuses secret_scanner.py)
|
|
11
|
+
4. AST Analysis — forbidden imports/calls (reuses git_security.py)
|
|
12
|
+
5. Prompt Injection Scan — skill-specific instruction injection patterns
|
|
13
|
+
6. Exfiltration Detection — network URLs, exfil sites, data sending
|
|
14
|
+
7. LLM Semantic Review — Claude Haiku intent analysis (reuses llm_reviewer.py)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from tweek.skills.config import IsolationConfig
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScanLayerResult:
|
|
31
|
+
"""Result from a single scan layer."""
|
|
32
|
+
layer_name: str
|
|
33
|
+
passed: bool
|
|
34
|
+
findings: List[Dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
issues: List[str] = field(default_factory=list)
|
|
36
|
+
error: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SkillScanReport:
|
|
41
|
+
"""Complete scan report for a skill."""
|
|
42
|
+
schema_version: int = 1
|
|
43
|
+
skill_name: str = ""
|
|
44
|
+
skill_path: str = ""
|
|
45
|
+
timestamp: str = ""
|
|
46
|
+
scan_duration_ms: int = 0
|
|
47
|
+
verdict: str = "pending" # "pass", "fail", "manual_review"
|
|
48
|
+
risk_level: str = "safe" # "safe", "suspicious", "dangerous"
|
|
49
|
+
|
|
50
|
+
# Per-layer results
|
|
51
|
+
layers: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
# Aggregate counts
|
|
54
|
+
critical_count: int = 0
|
|
55
|
+
high_count: int = 0
|
|
56
|
+
medium_count: int = 0
|
|
57
|
+
low_count: int = 0
|
|
58
|
+
|
|
59
|
+
# Metadata
|
|
60
|
+
files_scanned: List[str] = field(default_factory=list)
|
|
61
|
+
total_content_bytes: int = 0
|
|
62
|
+
non_english_detected: bool = False
|
|
63
|
+
scan_config: Dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
66
|
+
"""Serialize report to a JSON-compatible dict."""
|
|
67
|
+
return {
|
|
68
|
+
"schema_version": self.schema_version,
|
|
69
|
+
"skill_name": self.skill_name,
|
|
70
|
+
"skill_path": self.skill_path,
|
|
71
|
+
"timestamp": self.timestamp,
|
|
72
|
+
"scan_duration_ms": self.scan_duration_ms,
|
|
73
|
+
"verdict": self.verdict,
|
|
74
|
+
"risk_level": self.risk_level,
|
|
75
|
+
"summary": {
|
|
76
|
+
"files_scanned": len(self.files_scanned),
|
|
77
|
+
"total_bytes": self.total_content_bytes,
|
|
78
|
+
"critical": self.critical_count,
|
|
79
|
+
"high": self.high_count,
|
|
80
|
+
"medium": self.medium_count,
|
|
81
|
+
"low": self.low_count,
|
|
82
|
+
},
|
|
83
|
+
"layers": self.layers,
|
|
84
|
+
"files": self.files_scanned,
|
|
85
|
+
"scan_config": self.scan_config,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def to_json(self, indent: int = 2) -> str:
|
|
89
|
+
"""Serialize report to JSON string."""
|
|
90
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Skill-specific prompt injection patterns (Layer 5)
|
|
94
|
+
# These detect manipulation of Claude's behavior through skill instructions
|
|
95
|
+
SKILL_INJECTION_PATTERNS = [
|
|
96
|
+
{
|
|
97
|
+
"name": "skill_disable_security",
|
|
98
|
+
"severity": "critical",
|
|
99
|
+
"description": "Skill instructs Claude to disable security tools or hooks",
|
|
100
|
+
"regex": r"(disable|turn\s+off|deactivate|bypass|skip|remove)\s+(tweek|security\s+hook|screening|pre.?tool|post.?tool|safety\s+check)",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "skill_ignore_instructions",
|
|
104
|
+
"severity": "critical",
|
|
105
|
+
"description": "Skill contains instruction override attempt",
|
|
106
|
+
"regex": r"(ignore|disregard|forget|override)\s+(all\s+)?(previous|prior|system|other|existing)\s+(instructions|rules|guidelines|constraints|directives|prompts)",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "skill_access_credentials",
|
|
110
|
+
"severity": "high",
|
|
111
|
+
"description": "Skill instructs access to credentials or secrets",
|
|
112
|
+
"regex": r"(read|access|cat|open|load|fetch|get|extract)\s+.{0,30}(\.env|credentials|api.?key|secret|token|password|private.?key|\.ssh|\.aws|\.gnupg)",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "skill_modify_config",
|
|
116
|
+
"severity": "high",
|
|
117
|
+
"description": "Skill instructs modification of security configuration",
|
|
118
|
+
"regex": r"(write|edit|modify|change|update|overwrite)\s+.{0,30}(\.claude/(settings|config)|\.tweek/(config|overrides|patterns)|\.cursorrules)",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "skill_send_data",
|
|
122
|
+
"severity": "high",
|
|
123
|
+
"description": "Skill instructs sending data to external endpoints",
|
|
124
|
+
"regex": r"(send|post|upload|transmit|exfiltrate|forward)\s+.{0,40}(to\s+|https?://|webhook|api\s+endpoint|external\s+server)",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "skill_execute_remote",
|
|
128
|
+
"severity": "critical",
|
|
129
|
+
"description": "Skill instructs downloading and executing remote code",
|
|
130
|
+
"regex": r"(download|fetch|curl|wget)\s+.{0,40}(and\s+)?(then\s+)?(run|execute|eval|source|bash|sh\b|python)",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"name": "skill_hidden_base64",
|
|
134
|
+
"severity": "high",
|
|
135
|
+
"description": "Skill contains base64-encoded instruction blocks",
|
|
136
|
+
"regex": r"(decode|base64|atob)\s*[:=(\s]\s*[A-Za-z0-9+/]{40,}={0,2}",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "skill_role_hijack",
|
|
140
|
+
"severity": "high",
|
|
141
|
+
"description": "Skill attempts to redefine Claude's identity or role",
|
|
142
|
+
"regex": r"(you\s+are\s+now|your\s+new\s+(role|identity|purpose)\s+is|from\s+now\s+on\s+you\s+are|act\s+as\s+if\s+you\s+have\s+no\s+(restrictions|limits|rules))",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "skill_system_prompt_extract",
|
|
146
|
+
"severity": "high",
|
|
147
|
+
"description": "Skill instructs extraction or exposure of system prompt",
|
|
148
|
+
"regex": r"(output|print|show|display|reveal|share|repeat)\s+.{0,20}(system\s+prompt|your\s+instructions|your\s+configuration|your\s+rules)",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "skill_social_engineering",
|
|
152
|
+
"severity": "medium",
|
|
153
|
+
"description": "Skill uses social engineering to bypass restrictions",
|
|
154
|
+
"regex": r"(the\s+user\s+has\s+already\s+approved|this\s+is\s+a\s+trusted\s+operation|security\s+has\s+been\s+verified|pre-?authorized|don.t\s+ask\s+for\s+confirmation)",
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
# Exfiltration patterns for Layer 6
|
|
159
|
+
EXFIL_URL_PATTERN = re.compile(
|
|
160
|
+
r'https?://[^\s"\'>]+', re.IGNORECASE
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
EXFIL_COMMAND_PATTERNS = [
|
|
164
|
+
re.compile(r"(curl|wget|http|fetch)\s+", re.IGNORECASE),
|
|
165
|
+
re.compile(r"(nc|ncat|netcat)\s+", re.IGNORECASE),
|
|
166
|
+
re.compile(r"(scp|rsync|sftp)\s+", re.IGNORECASE),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
SUSPICIOUS_HOSTS = [
|
|
170
|
+
"pastebin.com", "hastebin.com", "ghostbin.", "0x0.st",
|
|
171
|
+
"transfer.sh", "file.io", "webhook.site", "requestbin.",
|
|
172
|
+
"ngrok.io", "pipedream.", "hookbin.com", "beeceptor.com",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SkillScanner:
|
|
177
|
+
"""
|
|
178
|
+
7-layer security scanner for skill directories.
|
|
179
|
+
|
|
180
|
+
Runs each layer in sequence, collecting findings. Layers are fail-fast
|
|
181
|
+
on CRITICAL findings when configured.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, config: Optional[IsolationConfig] = None):
|
|
185
|
+
self.config = config or IsolationConfig()
|
|
186
|
+
|
|
187
|
+
def scan(self, skill_dir: Path) -> SkillScanReport:
|
|
188
|
+
"""
|
|
189
|
+
Run the full 7-layer scan pipeline on a skill directory.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
skill_dir: Path to the skill directory to scan
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
SkillScanReport with verdict and all layer results
|
|
196
|
+
"""
|
|
197
|
+
start_time = time.monotonic()
|
|
198
|
+
|
|
199
|
+
report = SkillScanReport(
|
|
200
|
+
skill_name=skill_dir.name,
|
|
201
|
+
skill_path=str(skill_dir),
|
|
202
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
203
|
+
scan_config={
|
|
204
|
+
"mode": self.config.mode,
|
|
205
|
+
"llm_review_enabled": self.config.llm_review_enabled,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Collect all text files for scanning
|
|
210
|
+
text_files = self._collect_text_files(skill_dir)
|
|
211
|
+
report.files_scanned = [str(f.relative_to(skill_dir)) for f in text_files]
|
|
212
|
+
report.total_content_bytes = sum(
|
|
213
|
+
f.stat().st_size for f in text_files if f.exists()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Layer 1: Structure Validation
|
|
217
|
+
layer1 = self._scan_structure(skill_dir)
|
|
218
|
+
report.layers["structure"] = self._layer_to_dict(layer1)
|
|
219
|
+
if not layer1.passed:
|
|
220
|
+
report.verdict = "fail"
|
|
221
|
+
report.risk_level = "dangerous"
|
|
222
|
+
report.scan_duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
223
|
+
return report
|
|
224
|
+
|
|
225
|
+
# Layer 2: Pattern Matching
|
|
226
|
+
layer2 = self._scan_patterns(skill_dir, text_files)
|
|
227
|
+
report.layers["patterns"] = self._layer_to_dict(layer2)
|
|
228
|
+
self._accumulate_findings(report, layer2)
|
|
229
|
+
|
|
230
|
+
# Layer 3: Secret Scanning
|
|
231
|
+
layer3 = self._scan_secrets(skill_dir)
|
|
232
|
+
report.layers["secrets"] = self._layer_to_dict(layer3)
|
|
233
|
+
|
|
234
|
+
# Layer 4: AST Analysis
|
|
235
|
+
layer4 = self._scan_ast(skill_dir)
|
|
236
|
+
report.layers["ast"] = self._layer_to_dict(layer4)
|
|
237
|
+
|
|
238
|
+
# Layer 5: Prompt Injection Detection
|
|
239
|
+
layer5 = self._scan_prompt_injection(skill_dir, text_files)
|
|
240
|
+
report.layers["prompt_injection"] = self._layer_to_dict(layer5)
|
|
241
|
+
self._accumulate_findings(report, layer5)
|
|
242
|
+
|
|
243
|
+
# Layer 6: Exfiltration Detection
|
|
244
|
+
layer6 = self._scan_exfiltration(skill_dir, text_files)
|
|
245
|
+
report.layers["exfiltration"] = self._layer_to_dict(layer6)
|
|
246
|
+
self._accumulate_findings(report, layer6)
|
|
247
|
+
|
|
248
|
+
# Layer 7: LLM Semantic Review
|
|
249
|
+
if self.config.llm_review_enabled:
|
|
250
|
+
layer7 = self._scan_llm_review(skill_dir, text_files)
|
|
251
|
+
report.layers["llm_review"] = self._layer_to_dict(layer7)
|
|
252
|
+
else:
|
|
253
|
+
report.layers["llm_review"] = {
|
|
254
|
+
"passed": True, "skipped": True, "reason": "LLM review disabled"
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Compute final verdict
|
|
258
|
+
report.verdict = self._compute_verdict(report, layer3, layer4)
|
|
259
|
+
report.risk_level = self._compute_risk_level(report)
|
|
260
|
+
report.scan_duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
261
|
+
|
|
262
|
+
return report
|
|
263
|
+
|
|
264
|
+
# =========================================================================
|
|
265
|
+
# Layer 1: Structure Validation
|
|
266
|
+
# =========================================================================
|
|
267
|
+
|
|
268
|
+
def _scan_structure(self, skill_dir: Path) -> ScanLayerResult:
|
|
269
|
+
"""Validate skill directory structure."""
|
|
270
|
+
result = ScanLayerResult(layer_name="structure", passed=True)
|
|
271
|
+
|
|
272
|
+
# Must have SKILL.md
|
|
273
|
+
skill_md = skill_dir / "SKILL.md"
|
|
274
|
+
if not skill_md.exists():
|
|
275
|
+
result.passed = False
|
|
276
|
+
result.issues.append("Missing SKILL.md file")
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
# Check for symlinks pointing outside the skill directory
|
|
280
|
+
resolved_dir = skill_dir.resolve()
|
|
281
|
+
for item in skill_dir.rglob("*"):
|
|
282
|
+
if item.is_symlink():
|
|
283
|
+
target = item.resolve()
|
|
284
|
+
try:
|
|
285
|
+
target.relative_to(resolved_dir)
|
|
286
|
+
except ValueError:
|
|
287
|
+
result.passed = False
|
|
288
|
+
result.issues.append(
|
|
289
|
+
f"Symlink {item.name} points outside skill directory: {target}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Check total size
|
|
293
|
+
total_size = sum(
|
|
294
|
+
f.stat().st_size for f in skill_dir.rglob("*") if f.is_file()
|
|
295
|
+
)
|
|
296
|
+
if total_size > self.config.max_skill_size_bytes:
|
|
297
|
+
result.passed = False
|
|
298
|
+
result.issues.append(
|
|
299
|
+
f"Total size {total_size} bytes exceeds limit "
|
|
300
|
+
f"{self.config.max_skill_size_bytes}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Check file count
|
|
304
|
+
file_count = sum(1 for _ in skill_dir.rglob("*") if _.is_file())
|
|
305
|
+
if file_count > self.config.max_file_count:
|
|
306
|
+
result.passed = False
|
|
307
|
+
result.issues.append(
|
|
308
|
+
f"File count {file_count} exceeds limit {self.config.max_file_count}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Check directory depth
|
|
312
|
+
for item in skill_dir.rglob("*"):
|
|
313
|
+
try:
|
|
314
|
+
rel = item.relative_to(skill_dir)
|
|
315
|
+
depth = len(rel.parts)
|
|
316
|
+
if depth > self.config.max_directory_depth:
|
|
317
|
+
result.passed = False
|
|
318
|
+
result.issues.append(
|
|
319
|
+
f"Path depth {depth} exceeds limit "
|
|
320
|
+
f"{self.config.max_directory_depth}: {rel}"
|
|
321
|
+
)
|
|
322
|
+
break
|
|
323
|
+
except ValueError:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
# Check for blocked file extensions
|
|
327
|
+
for item in skill_dir.rglob("*"):
|
|
328
|
+
if item.is_file():
|
|
329
|
+
ext = item.suffix.lower()
|
|
330
|
+
if ext in self.config.blocked_file_extensions:
|
|
331
|
+
result.passed = False
|
|
332
|
+
result.issues.append(
|
|
333
|
+
f"Blocked file extension '{ext}': {item.name}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Check for hidden files (except .gitignore)
|
|
337
|
+
for item in skill_dir.rglob(".*"):
|
|
338
|
+
if item.name == ".gitignore":
|
|
339
|
+
continue
|
|
340
|
+
if item.is_file():
|
|
341
|
+
result.issues.append(f"Hidden file detected: {item.name}")
|
|
342
|
+
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
# =========================================================================
|
|
346
|
+
# Layer 2: Pattern Matching (reuses audit.py)
|
|
347
|
+
# =========================================================================
|
|
348
|
+
|
|
349
|
+
def _scan_patterns(
|
|
350
|
+
self, skill_dir: Path, text_files: List[Path]
|
|
351
|
+
) -> ScanLayerResult:
|
|
352
|
+
"""Run 259 regex patterns against all text files."""
|
|
353
|
+
result = ScanLayerResult(layer_name="patterns", passed=True)
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
from tweek.audit import audit_content
|
|
357
|
+
|
|
358
|
+
for file_path in text_files:
|
|
359
|
+
try:
|
|
360
|
+
content = file_path.read_text(encoding="utf-8")
|
|
361
|
+
except (IOError, UnicodeDecodeError):
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
audit_result = audit_content(
|
|
365
|
+
content=content,
|
|
366
|
+
name=str(file_path.relative_to(skill_dir)),
|
|
367
|
+
path=file_path,
|
|
368
|
+
translate=True,
|
|
369
|
+
llm_review=False, # LLM review is a separate layer
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if audit_result.non_english_detected:
|
|
373
|
+
self._report_non_english = True
|
|
374
|
+
|
|
375
|
+
for finding in audit_result.findings:
|
|
376
|
+
result.findings.append({
|
|
377
|
+
"file": str(file_path.relative_to(skill_dir)),
|
|
378
|
+
"pattern_id": finding.pattern_id,
|
|
379
|
+
"name": finding.pattern_name,
|
|
380
|
+
"severity": finding.severity,
|
|
381
|
+
"description": finding.description,
|
|
382
|
+
"matched_text": finding.matched_text[:100],
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
except ImportError as e:
|
|
386
|
+
result.error = f"Pattern matcher not available: {e}"
|
|
387
|
+
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
# =========================================================================
|
|
391
|
+
# Layer 3: Secret Scanning (reuses secret_scanner.py)
|
|
392
|
+
# =========================================================================
|
|
393
|
+
|
|
394
|
+
def _scan_secrets(self, skill_dir: Path) -> ScanLayerResult:
|
|
395
|
+
"""Scan for hardcoded credentials in the skill directory."""
|
|
396
|
+
result = ScanLayerResult(layer_name="secrets", passed=True)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
from tweek.security.secret_scanner import SecretScanner
|
|
400
|
+
|
|
401
|
+
scanner = SecretScanner(enforce_permissions=False)
|
|
402
|
+
scan_result = scanner.scan_directory(
|
|
403
|
+
skill_dir,
|
|
404
|
+
patterns=["**/*.yaml", "**/*.yml", "**/*.json", "**/.env*",
|
|
405
|
+
"**/*.py", "**/*.sh", "**/*.toml", "**/*.md",
|
|
406
|
+
"**/*.txt"],
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if scan_result.findings:
|
|
410
|
+
result.passed = False
|
|
411
|
+
for finding in scan_result.findings:
|
|
412
|
+
result.findings.append({
|
|
413
|
+
"file": str(finding.file_path) if hasattr(finding, "file_path") else "unknown",
|
|
414
|
+
"key": getattr(finding, "key", "unknown"),
|
|
415
|
+
"severity": "critical",
|
|
416
|
+
"description": f"Hardcoded secret: {getattr(finding, 'key', 'unknown')}",
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
except ImportError as e:
|
|
420
|
+
result.error = f"Secret scanner not available: {e}"
|
|
421
|
+
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
# =========================================================================
|
|
425
|
+
# Layer 4: AST Analysis (reuses git_security.py)
|
|
426
|
+
# =========================================================================
|
|
427
|
+
|
|
428
|
+
def _scan_ast(self, skill_dir: Path) -> ScanLayerResult:
|
|
429
|
+
"""Static analysis of Python files for forbidden patterns."""
|
|
430
|
+
result = ScanLayerResult(layer_name="ast", passed=True)
|
|
431
|
+
|
|
432
|
+
py_files = list(skill_dir.glob("**/*.py"))
|
|
433
|
+
if not py_files:
|
|
434
|
+
return result # No Python files to scan
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
from tweek.plugins.git_security import static_analyze_python_files
|
|
438
|
+
|
|
439
|
+
is_safe, issues = static_analyze_python_files(skill_dir)
|
|
440
|
+
if not is_safe:
|
|
441
|
+
result.passed = False
|
|
442
|
+
result.issues = issues
|
|
443
|
+
|
|
444
|
+
except ImportError as e:
|
|
445
|
+
result.error = f"AST analyzer not available: {e}"
|
|
446
|
+
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
# =========================================================================
|
|
450
|
+
# Layer 5: Prompt Injection Detection (skill-specific)
|
|
451
|
+
# =========================================================================
|
|
452
|
+
|
|
453
|
+
def _scan_prompt_injection(
|
|
454
|
+
self, skill_dir: Path, text_files: List[Path]
|
|
455
|
+
) -> ScanLayerResult:
|
|
456
|
+
"""Scan for prompt injection patterns specific to skill instructions."""
|
|
457
|
+
result = ScanLayerResult(layer_name="prompt_injection", passed=True)
|
|
458
|
+
|
|
459
|
+
for file_path in text_files:
|
|
460
|
+
try:
|
|
461
|
+
content = file_path.read_text(encoding="utf-8")
|
|
462
|
+
except (IOError, UnicodeDecodeError):
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
for pattern_def in SKILL_INJECTION_PATTERNS:
|
|
466
|
+
try:
|
|
467
|
+
match = re.search(
|
|
468
|
+
pattern_def["regex"], content, re.IGNORECASE | re.MULTILINE
|
|
469
|
+
)
|
|
470
|
+
if match:
|
|
471
|
+
result.findings.append({
|
|
472
|
+
"file": str(file_path.relative_to(skill_dir)),
|
|
473
|
+
"name": pattern_def["name"],
|
|
474
|
+
"severity": pattern_def["severity"],
|
|
475
|
+
"description": pattern_def["description"],
|
|
476
|
+
"matched_text": match.group(0)[:100],
|
|
477
|
+
})
|
|
478
|
+
except re.error:
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
# =========================================================================
|
|
484
|
+
# Layer 6: Exfiltration Vector Detection
|
|
485
|
+
# =========================================================================
|
|
486
|
+
|
|
487
|
+
def _scan_exfiltration(
|
|
488
|
+
self, skill_dir: Path, text_files: List[Path]
|
|
489
|
+
) -> ScanLayerResult:
|
|
490
|
+
"""Detect data exfiltration vectors in skill content."""
|
|
491
|
+
result = ScanLayerResult(layer_name="exfiltration", passed=True)
|
|
492
|
+
|
|
493
|
+
for file_path in text_files:
|
|
494
|
+
try:
|
|
495
|
+
content = file_path.read_text(encoding="utf-8")
|
|
496
|
+
except (IOError, UnicodeDecodeError):
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
rel_path = str(file_path.relative_to(skill_dir))
|
|
500
|
+
is_script = file_path.suffix in (".py", ".sh")
|
|
501
|
+
|
|
502
|
+
# Check for URLs pointing to suspicious hosts
|
|
503
|
+
urls = EXFIL_URL_PATTERN.findall(content)
|
|
504
|
+
for url in urls:
|
|
505
|
+
url_lower = url.lower()
|
|
506
|
+
for host in SUSPICIOUS_HOSTS:
|
|
507
|
+
if host in url_lower:
|
|
508
|
+
severity = "critical" if is_script else "high"
|
|
509
|
+
result.findings.append({
|
|
510
|
+
"file": rel_path,
|
|
511
|
+
"name": "exfil_suspicious_host",
|
|
512
|
+
"severity": severity,
|
|
513
|
+
"description": f"URL to known exfiltration site: {host}",
|
|
514
|
+
"matched_text": url[:100],
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
# Check for exfiltration commands in scripts
|
|
518
|
+
if is_script:
|
|
519
|
+
for pattern in EXFIL_COMMAND_PATTERNS:
|
|
520
|
+
matches = pattern.finditer(content)
|
|
521
|
+
for match in matches:
|
|
522
|
+
# Get surrounding context
|
|
523
|
+
start = max(0, match.start() - 20)
|
|
524
|
+
end = min(len(content), match.end() + 80)
|
|
525
|
+
context = content[start:end].strip()
|
|
526
|
+
result.findings.append({
|
|
527
|
+
"file": rel_path,
|
|
528
|
+
"name": "exfil_network_command",
|
|
529
|
+
"severity": "high",
|
|
530
|
+
"description": "Network command in skill script",
|
|
531
|
+
"matched_text": context[:100],
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
return result
|
|
535
|
+
|
|
536
|
+
# =========================================================================
|
|
537
|
+
# Layer 7: LLM Semantic Review (reuses llm_reviewer.py)
|
|
538
|
+
# =========================================================================
|
|
539
|
+
|
|
540
|
+
def _scan_llm_review(
|
|
541
|
+
self, skill_dir: Path, text_files: List[Path]
|
|
542
|
+
) -> ScanLayerResult:
|
|
543
|
+
"""Run LLM semantic analysis on skill content."""
|
|
544
|
+
result = ScanLayerResult(layer_name="llm_review", passed=True)
|
|
545
|
+
|
|
546
|
+
# Collect content from key files (SKILL.md first, then others)
|
|
547
|
+
content_parts = []
|
|
548
|
+
skill_md = skill_dir / "SKILL.md"
|
|
549
|
+
if skill_md.exists():
|
|
550
|
+
try:
|
|
551
|
+
md_content = skill_md.read_text(encoding="utf-8")
|
|
552
|
+
content_parts.append(f"=== SKILL.md ===\n{md_content}")
|
|
553
|
+
except (IOError, UnicodeDecodeError):
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
for file_path in text_files:
|
|
557
|
+
if file_path == skill_md:
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
fc = file_path.read_text(encoding="utf-8")
|
|
561
|
+
rel = str(file_path.relative_to(skill_dir))
|
|
562
|
+
content_parts.append(f"=== {rel} ===\n{fc[:2000]}")
|
|
563
|
+
except (IOError, UnicodeDecodeError):
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
combined = "\n\n".join(content_parts)[:8000]
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
from tweek.security.llm_reviewer import get_llm_reviewer
|
|
570
|
+
|
|
571
|
+
reviewer = get_llm_reviewer()
|
|
572
|
+
if not reviewer.enabled:
|
|
573
|
+
result.findings.append({
|
|
574
|
+
"name": "llm_review_unavailable",
|
|
575
|
+
"severity": "medium",
|
|
576
|
+
"description": "LLM reviewer not available (no API key)",
|
|
577
|
+
})
|
|
578
|
+
return result
|
|
579
|
+
|
|
580
|
+
review = reviewer.review(
|
|
581
|
+
command=combined[:4000],
|
|
582
|
+
tool="SkillIsolation",
|
|
583
|
+
tier="dangerous",
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
result.findings.append({
|
|
587
|
+
"name": "llm_semantic_review",
|
|
588
|
+
"severity": "low",
|
|
589
|
+
"description": review.reason,
|
|
590
|
+
"risk_level": review.risk_level.value,
|
|
591
|
+
"confidence": review.confidence,
|
|
592
|
+
"model": "claude-3-5-haiku-latest",
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
if review.risk_level.value == "dangerous" and review.confidence >= 0.7:
|
|
596
|
+
result.passed = False
|
|
597
|
+
elif review.risk_level.value == "suspicious":
|
|
598
|
+
# Mark for manual review but don't fail
|
|
599
|
+
result.findings[-1]["severity"] = "medium"
|
|
600
|
+
|
|
601
|
+
except ImportError:
|
|
602
|
+
result.error = "LLM reviewer not available"
|
|
603
|
+
except Exception as e:
|
|
604
|
+
# Fail-closed: treat errors as needing manual review
|
|
605
|
+
result.findings.append({
|
|
606
|
+
"name": "llm_review_error",
|
|
607
|
+
"severity": "medium",
|
|
608
|
+
"description": f"LLM review failed: {e}",
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
return result
|
|
612
|
+
|
|
613
|
+
# =========================================================================
|
|
614
|
+
# Verdict and Risk Computation
|
|
615
|
+
# =========================================================================
|
|
616
|
+
|
|
617
|
+
def _compute_verdict(
|
|
618
|
+
self,
|
|
619
|
+
report: SkillScanReport,
|
|
620
|
+
secrets_layer: ScanLayerResult,
|
|
621
|
+
ast_layer: ScanLayerResult,
|
|
622
|
+
) -> str:
|
|
623
|
+
"""Compute final verdict based on all layer results."""
|
|
624
|
+
|
|
625
|
+
# Hard FAIL conditions
|
|
626
|
+
if any(
|
|
627
|
+
not layer.get("passed", True)
|
|
628
|
+
for name, layer in report.layers.items()
|
|
629
|
+
if name == "structure"
|
|
630
|
+
):
|
|
631
|
+
return "fail"
|
|
632
|
+
|
|
633
|
+
if self.config.fail_on_critical and report.critical_count > 0:
|
|
634
|
+
return "fail"
|
|
635
|
+
|
|
636
|
+
if not secrets_layer.passed:
|
|
637
|
+
return "fail"
|
|
638
|
+
|
|
639
|
+
if not ast_layer.passed:
|
|
640
|
+
return "fail"
|
|
641
|
+
|
|
642
|
+
if report.high_count >= self.config.fail_on_high_count:
|
|
643
|
+
return "fail"
|
|
644
|
+
|
|
645
|
+
# LLM review dangerous = fail
|
|
646
|
+
llm_layer = report.layers.get("llm_review", {})
|
|
647
|
+
if not llm_layer.get("passed", True):
|
|
648
|
+
return "fail"
|
|
649
|
+
|
|
650
|
+
# Manual review conditions
|
|
651
|
+
if report.high_count >= self.config.review_on_high_count:
|
|
652
|
+
return "manual_review"
|
|
653
|
+
|
|
654
|
+
llm_findings = llm_layer.get("findings", [])
|
|
655
|
+
for f in llm_findings:
|
|
656
|
+
if isinstance(f, dict) and f.get("risk_level") == "suspicious":
|
|
657
|
+
return "manual_review"
|
|
658
|
+
|
|
659
|
+
# Manual mode override
|
|
660
|
+
if self.config.mode == "manual":
|
|
661
|
+
return "manual_review"
|
|
662
|
+
|
|
663
|
+
return "pass"
|
|
664
|
+
|
|
665
|
+
def _compute_risk_level(self, report: SkillScanReport) -> str:
|
|
666
|
+
"""Compute overall risk level from findings."""
|
|
667
|
+
if report.critical_count > 0:
|
|
668
|
+
return "dangerous"
|
|
669
|
+
if report.high_count > 0:
|
|
670
|
+
return "suspicious"
|
|
671
|
+
if report.medium_count > 0:
|
|
672
|
+
return "suspicious"
|
|
673
|
+
return "safe"
|
|
674
|
+
|
|
675
|
+
# =========================================================================
|
|
676
|
+
# Helpers
|
|
677
|
+
# =========================================================================
|
|
678
|
+
|
|
679
|
+
def _collect_text_files(self, skill_dir: Path) -> List[Path]:
|
|
680
|
+
"""Collect all scannable text files from the skill directory."""
|
|
681
|
+
files = []
|
|
682
|
+
allowed = set(self.config.allowed_file_extensions)
|
|
683
|
+
for item in skill_dir.rglob("*"):
|
|
684
|
+
if item.is_file() and item.suffix.lower() in allowed:
|
|
685
|
+
files.append(item)
|
|
686
|
+
return sorted(files)
|
|
687
|
+
|
|
688
|
+
def _accumulate_findings(
|
|
689
|
+
self, report: SkillScanReport, layer: ScanLayerResult
|
|
690
|
+
) -> None:
|
|
691
|
+
"""Add finding severity counts from a layer to the report totals."""
|
|
692
|
+
for finding in layer.findings:
|
|
693
|
+
sev = finding.get("severity", "low")
|
|
694
|
+
if sev == "critical":
|
|
695
|
+
report.critical_count += 1
|
|
696
|
+
elif sev == "high":
|
|
697
|
+
report.high_count += 1
|
|
698
|
+
elif sev == "medium":
|
|
699
|
+
report.medium_count += 1
|
|
700
|
+
else:
|
|
701
|
+
report.low_count += 1
|
|
702
|
+
|
|
703
|
+
def _layer_to_dict(self, layer: ScanLayerResult) -> Dict[str, Any]:
|
|
704
|
+
"""Convert a ScanLayerResult to a serializable dict."""
|
|
705
|
+
d = {"passed": layer.passed}
|
|
706
|
+
if layer.findings:
|
|
707
|
+
d["findings"] = layer.findings
|
|
708
|
+
if layer.issues:
|
|
709
|
+
d["issues"] = layer.issues
|
|
710
|
+
if layer.error:
|
|
711
|
+
d["error"] = layer.error
|
|
712
|
+
return d
|
|
713
|
+
|
|
714
|
+
# Internal state for cross-layer communication
|
|
715
|
+
_report_non_english: bool = False
|