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.
Files changed (87) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5398 -2392
  5. tweek/cli_model.py +380 -0
  6. tweek/config/families.yaml +609 -0
  7. tweek/config/manager.py +42 -5
  8. tweek/config/patterns.yaml +1510 -8
  9. tweek/config/tiers.yaml +161 -11
  10. tweek/diagnostics.py +71 -2
  11. tweek/hooks/break_glass.py +163 -0
  12. tweek/hooks/feedback.py +223 -0
  13. tweek/hooks/overrides.py +531 -0
  14. tweek/hooks/post_tool_use.py +472 -0
  15. tweek/hooks/pre_tool_use.py +1024 -62
  16. tweek/integrations/openclaw.py +443 -0
  17. tweek/integrations/openclaw_server.py +385 -0
  18. tweek/licensing.py +14 -54
  19. tweek/logging/bundle.py +2 -2
  20. tweek/logging/security_log.py +56 -13
  21. tweek/mcp/approval.py +57 -16
  22. tweek/mcp/proxy.py +18 -0
  23. tweek/mcp/screening.py +5 -5
  24. tweek/mcp/server.py +4 -1
  25. tweek/memory/__init__.py +24 -0
  26. tweek/memory/queries.py +223 -0
  27. tweek/memory/safety.py +140 -0
  28. tweek/memory/schemas.py +80 -0
  29. tweek/memory/store.py +989 -0
  30. tweek/platform/__init__.py +4 -4
  31. tweek/plugins/__init__.py +40 -24
  32. tweek/plugins/base.py +1 -1
  33. tweek/plugins/detectors/__init__.py +3 -3
  34. tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
  35. tweek/plugins/git_discovery.py +16 -4
  36. tweek/plugins/git_registry.py +8 -2
  37. tweek/plugins/git_security.py +21 -9
  38. tweek/plugins/screening/__init__.py +10 -1
  39. tweek/plugins/screening/heuristic_scorer.py +477 -0
  40. tweek/plugins/screening/llm_reviewer.py +14 -6
  41. tweek/plugins/screening/local_model_reviewer.py +161 -0
  42. tweek/proxy/__init__.py +38 -37
  43. tweek/proxy/addon.py +22 -3
  44. tweek/proxy/interceptor.py +1 -0
  45. tweek/proxy/server.py +4 -2
  46. tweek/sandbox/__init__.py +11 -0
  47. tweek/sandbox/docker_bridge.py +143 -0
  48. tweek/sandbox/executor.py +9 -6
  49. tweek/sandbox/layers.py +97 -0
  50. tweek/sandbox/linux.py +1 -0
  51. tweek/sandbox/project.py +548 -0
  52. tweek/sandbox/registry.py +149 -0
  53. tweek/security/__init__.py +9 -0
  54. tweek/security/language.py +250 -0
  55. tweek/security/llm_reviewer.py +1146 -60
  56. tweek/security/local_model.py +331 -0
  57. tweek/security/local_reviewer.py +146 -0
  58. tweek/security/model_registry.py +371 -0
  59. tweek/security/rate_limiter.py +11 -6
  60. tweek/security/secret_scanner.py +70 -4
  61. tweek/security/session_analyzer.py +26 -2
  62. tweek/skill_template/SKILL.md +200 -0
  63. tweek/skill_template/__init__.py +0 -0
  64. tweek/skill_template/cli-reference.md +331 -0
  65. tweek/skill_template/overrides-reference.md +184 -0
  66. tweek/skill_template/scripts/__init__.py +0 -0
  67. tweek/skill_template/scripts/check_installed.py +170 -0
  68. tweek/skills/__init__.py +38 -0
  69. tweek/skills/config.py +150 -0
  70. tweek/skills/fingerprints.py +198 -0
  71. tweek/skills/guard.py +293 -0
  72. tweek/skills/isolation.py +469 -0
  73. tweek/skills/scanner.py +715 -0
  74. tweek/vault/__init__.py +0 -1
  75. tweek/vault/cross_platform.py +12 -1
  76. tweek/vault/keychain.py +87 -29
  77. tweek-0.2.1.dist-info/METADATA +281 -0
  78. tweek-0.2.1.dist-info/RECORD +122 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/licenses/LICENSE +80 -0
  81. tweek-0.2.1.dist-info/top_level.txt +2 -0
  82. tweek-openclaw-plugin/node_modules/flatted/python/flatted.py +149 -0
  83. tweek/integrations/moltbot.py +0 -243
  84. tweek-0.1.0.dist-info/METADATA +0 -335
  85. tweek-0.1.0.dist-info/RECORD +0 -85
  86. tweek-0.1.0.dist-info/top_level.txt +0 -1
  87. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/WHEEL +0 -0
@@ -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