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.
Files changed (85) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5303 -2396
  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.0.dist-info/METADATA +281 -0
  78. tweek-0.2.0.dist-info/RECORD +121 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
  81. tweek/integrations/moltbot.py +0 -243
  82. tweek-0.1.0.dist-info/METADATA +0 -335
  83. tweek-0.1.0.dist-info/RECORD +0 -85
  84. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
  85. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,469 @@
1
+ """
2
+ Tweek Skill Isolation Chamber — Lifecycle Manager
3
+
4
+ Manages the full skill lifecycle: accept → scan → approve/jail → install.
5
+ Skills enter the chamber as inert files and only become active after passing
6
+ the 7-layer security scan.
7
+ """
8
+
9
+ import json
10
+ import shutil
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ from tweek.skills import (
17
+ CHAMBER_DIR,
18
+ CLAUDE_GLOBAL_SKILLS,
19
+ JAIL_DIR,
20
+ REPORTS_DIR,
21
+ ensure_directories,
22
+ get_claude_project_skills,
23
+ )
24
+ from tweek.skills.config import IsolationConfig, load_isolation_config
25
+ from tweek.skills.scanner import SkillScanner, SkillScanReport
26
+
27
+
28
+ class SkillIsolationChamber:
29
+ """
30
+ Manages the skill isolation chamber lifecycle.
31
+
32
+ Skills flow: accept → scan → approve/jail → install
33
+ """
34
+
35
+ def __init__(self, config: Optional[IsolationConfig] = None):
36
+ self.config = config or load_isolation_config()
37
+ self.scanner = SkillScanner(config=self.config)
38
+ ensure_directories()
39
+
40
+ # =========================================================================
41
+ # Accept: Place a skill in the chamber
42
+ # =========================================================================
43
+
44
+ def accept_skill(
45
+ self, source_path: Path, skill_name: Optional[str] = None
46
+ ) -> Tuple[bool, str]:
47
+ """
48
+ Accept a skill into the isolation chamber.
49
+
50
+ Copies the skill directory to ~/.tweek/skills/chamber/<name>/.
51
+ Does NOT activate it — the skill is inert until approved.
52
+
53
+ Args:
54
+ source_path: Path to the skill directory or SKILL.md file
55
+ skill_name: Override skill name (defaults to directory name)
56
+
57
+ Returns:
58
+ (success, message)
59
+ """
60
+ source = Path(source_path).resolve()
61
+
62
+ # Handle both directory and file paths
63
+ if source.is_file() and source.name == "SKILL.md":
64
+ source = source.parent
65
+ elif not source.is_dir():
66
+ return False, f"Source path does not exist or is not a directory: {source}"
67
+
68
+ if not (source / "SKILL.md").exists():
69
+ return False, f"No SKILL.md found in {source}"
70
+
71
+ name = skill_name or source.name
72
+ target = CHAMBER_DIR / name
73
+
74
+ if target.exists():
75
+ return False, (
76
+ f"Skill '{name}' already in chamber. "
77
+ f"Remove it first or use a different name."
78
+ )
79
+
80
+ try:
81
+ shutil.copytree(source, target)
82
+ # Set restrictive permissions
83
+ target.chmod(0o700)
84
+ self._log_event("skill_chamber_intake", name, {
85
+ "source": str(source),
86
+ "target": str(target),
87
+ })
88
+ return True, f"Skill '{name}' placed in isolation chamber."
89
+ except Exception as e:
90
+ return False, f"Failed to copy skill to chamber: {e}"
91
+
92
+ # =========================================================================
93
+ # Scan: Run the 7-layer security pipeline
94
+ # =========================================================================
95
+
96
+ def scan_skill(self, skill_name: str) -> Tuple[SkillScanReport, str]:
97
+ """
98
+ Scan a skill in the chamber using the 7-layer pipeline.
99
+
100
+ Args:
101
+ skill_name: Name of the skill in the chamber
102
+
103
+ Returns:
104
+ (report, message)
105
+ """
106
+ skill_dir = CHAMBER_DIR / skill_name
107
+ if not skill_dir.exists():
108
+ empty_report = SkillScanReport(skill_name=skill_name, verdict="fail")
109
+ return empty_report, f"Skill '{skill_name}' not found in chamber."
110
+
111
+ report = self.scanner.scan(skill_dir)
112
+
113
+ # Save report
114
+ self._save_report(report)
115
+
116
+ self._log_event("skill_scan_complete", skill_name, {
117
+ "verdict": report.verdict,
118
+ "risk_level": report.risk_level,
119
+ "critical": report.critical_count,
120
+ "high": report.high_count,
121
+ "duration_ms": report.scan_duration_ms,
122
+ })
123
+
124
+ return report, self._format_verdict_message(report)
125
+
126
+ # =========================================================================
127
+ # Accept and Scan: Combined operation
128
+ # =========================================================================
129
+
130
+ def accept_and_scan(
131
+ self,
132
+ source_path: Path,
133
+ skill_name: Optional[str] = None,
134
+ target: str = "global",
135
+ ) -> Tuple[SkillScanReport, str]:
136
+ """
137
+ Accept a skill into the chamber and immediately scan it.
138
+
139
+ In auto mode, also installs if the scan passes.
140
+
141
+ Args:
142
+ source_path: Path to the skill directory
143
+ skill_name: Override name
144
+ target: "global" or "project"
145
+
146
+ Returns:
147
+ (report, message)
148
+ """
149
+ source = Path(source_path).resolve()
150
+ if source.is_file() and source.name == "SKILL.md":
151
+ source = source.parent
152
+
153
+ name = skill_name or source.name
154
+
155
+ # Accept
156
+ ok, msg = self.accept_skill(source, name)
157
+ if not ok:
158
+ return SkillScanReport(skill_name=name, verdict="fail"), msg
159
+
160
+ # Scan
161
+ report, scan_msg = self.scan_skill(name)
162
+
163
+ # Auto-install if configured and passed
164
+ if report.verdict == "pass" and self.config.mode == "auto":
165
+ ok, install_msg = self.approve_skill(name, target=target)
166
+ if ok:
167
+ return report, f"{scan_msg}\n{install_msg}"
168
+ else:
169
+ return report, f"{scan_msg}\nAuto-install failed: {install_msg}"
170
+
171
+ # Auto-jail if failed
172
+ if report.verdict == "fail":
173
+ self.jail_skill(name, report)
174
+ return report, f"{scan_msg}\nQuarantined to jail."
175
+
176
+ return report, scan_msg
177
+
178
+ # =========================================================================
179
+ # Approve: Move from chamber to Claude's skill directory
180
+ # =========================================================================
181
+
182
+ def approve_skill(
183
+ self, skill_name: str, target: str = "global"
184
+ ) -> Tuple[bool, str]:
185
+ """
186
+ Approve a skill and install it to Claude's skill directory.
187
+
188
+ Args:
189
+ skill_name: Name of the skill in the chamber
190
+ target: "global" (~/.claude/skills/) or "project" (./.claude/skills/)
191
+
192
+ Returns:
193
+ (success, message)
194
+ """
195
+ skill_dir = CHAMBER_DIR / skill_name
196
+ if not skill_dir.exists():
197
+ return False, f"Skill '{skill_name}' not found in chamber."
198
+
199
+ if target == "project":
200
+ install_dir = get_claude_project_skills() / skill_name
201
+ else:
202
+ install_dir = CLAUDE_GLOBAL_SKILLS / skill_name
203
+
204
+ try:
205
+ install_dir.parent.mkdir(parents=True, exist_ok=True)
206
+
207
+ # Atomic-ish: copy first, then remove from chamber
208
+ if install_dir.exists():
209
+ shutil.rmtree(install_dir)
210
+ shutil.copytree(skill_dir, install_dir)
211
+ shutil.rmtree(skill_dir)
212
+
213
+ self._log_event("skill_approved", skill_name, {
214
+ "install_path": str(install_dir),
215
+ "target": target,
216
+ })
217
+
218
+ return True, f"Skill '{skill_name}' installed to {install_dir}"
219
+
220
+ except Exception as e:
221
+ return False, f"Failed to install skill: {e}"
222
+
223
+ # =========================================================================
224
+ # Jail: Quarantine a failed skill
225
+ # =========================================================================
226
+
227
+ def jail_skill(
228
+ self,
229
+ skill_name: str,
230
+ report: Optional[SkillScanReport] = None,
231
+ ) -> Tuple[bool, str]:
232
+ """
233
+ Move a skill from the chamber to the jail.
234
+
235
+ Args:
236
+ skill_name: Name of the skill
237
+ report: Optional scan report to embed in jail
238
+
239
+ Returns:
240
+ (success, message)
241
+ """
242
+ skill_dir = CHAMBER_DIR / skill_name
243
+ if not skill_dir.exists():
244
+ return False, f"Skill '{skill_name}' not found in chamber."
245
+
246
+ jail_target = JAIL_DIR / skill_name
247
+
248
+ try:
249
+ if jail_target.exists():
250
+ shutil.rmtree(jail_target)
251
+
252
+ shutil.move(str(skill_dir), str(jail_target))
253
+
254
+ # Embed scan report in jail
255
+ if report:
256
+ report_path = jail_target / "scan-report.json"
257
+ report_path.write_text(report.to_json())
258
+
259
+ self._log_event("skill_jailed", skill_name, {
260
+ "verdict": report.verdict if report else "unknown",
261
+ "risk_level": report.risk_level if report else "unknown",
262
+ })
263
+
264
+ if self.config.notify_on_jail:
265
+ print(
266
+ f"TWEEK SECURITY: Skill '{skill_name}' FAILED security scan. "
267
+ f"Quarantined to jail.",
268
+ file=sys.stderr,
269
+ )
270
+
271
+ return True, f"Skill '{skill_name}' quarantined to jail."
272
+
273
+ except Exception as e:
274
+ return False, f"Failed to jail skill: {e}"
275
+
276
+ # =========================================================================
277
+ # Release: Re-scan and potentially release from jail
278
+ # =========================================================================
279
+
280
+ def release_from_jail(
281
+ self, skill_name: str, force: bool = False
282
+ ) -> Tuple[bool, str]:
283
+ """
284
+ Re-scan a jailed skill and release if it now passes.
285
+
286
+ Args:
287
+ skill_name: Name of the jailed skill
288
+ force: Force release without re-scanning (dangerous)
289
+
290
+ Returns:
291
+ (success, message)
292
+ """
293
+ jail_path = JAIL_DIR / skill_name
294
+ if not jail_path.exists():
295
+ return False, f"Skill '{skill_name}' not found in jail."
296
+
297
+ if force:
298
+ # Move back to chamber for manual approval
299
+ target = CHAMBER_DIR / skill_name
300
+ if target.exists():
301
+ shutil.rmtree(target)
302
+ shutil.move(str(jail_path), str(target))
303
+ return True, (
304
+ f"Skill '{skill_name}' force-released to chamber. "
305
+ f"Use 'tweek skills chamber approve {skill_name}' to install."
306
+ )
307
+
308
+ # Move to chamber for re-scan
309
+ chamber_path = CHAMBER_DIR / skill_name
310
+ if chamber_path.exists():
311
+ shutil.rmtree(chamber_path)
312
+ shutil.copytree(jail_path, chamber_path)
313
+
314
+ # Re-scan
315
+ report, msg = self.scan_skill(skill_name)
316
+
317
+ if report.verdict == "pass":
318
+ shutil.rmtree(jail_path)
319
+ return True, f"Skill '{skill_name}' now passes. {msg}"
320
+ elif report.verdict == "manual_review":
321
+ shutil.rmtree(jail_path)
322
+ return True, f"Skill '{skill_name}' needs manual review. {msg}"
323
+ else:
324
+ # Still fails — remove from chamber, keep in jail
325
+ shutil.rmtree(chamber_path)
326
+ return False, f"Skill '{skill_name}' still fails. {msg}"
327
+
328
+ # =========================================================================
329
+ # List and Query
330
+ # =========================================================================
331
+
332
+ def list_chamber(self) -> List[Dict[str, str]]:
333
+ """List skills currently in the isolation chamber."""
334
+ if not CHAMBER_DIR.exists():
335
+ return []
336
+ return [
337
+ {
338
+ "name": d.name,
339
+ "path": str(d),
340
+ "has_skill_md": (d / "SKILL.md").exists(),
341
+ }
342
+ for d in sorted(CHAMBER_DIR.iterdir())
343
+ if d.is_dir()
344
+ ]
345
+
346
+ def list_jail(self) -> List[Dict[str, str]]:
347
+ """List skills currently in the jail."""
348
+ if not JAIL_DIR.exists():
349
+ return []
350
+ results = []
351
+ for d in sorted(JAIL_DIR.iterdir()):
352
+ if not d.is_dir():
353
+ continue
354
+ info = {"name": d.name, "path": str(d)}
355
+ report_path = d / "scan-report.json"
356
+ if report_path.exists():
357
+ try:
358
+ report_data = json.loads(report_path.read_text())
359
+ info["verdict"] = report_data.get("verdict", "unknown")
360
+ info["risk_level"] = report_data.get("risk_level", "unknown")
361
+ info["critical"] = report_data.get("summary", {}).get("critical", 0)
362
+ info["high"] = report_data.get("summary", {}).get("high", 0)
363
+ except (json.JSONDecodeError, IOError):
364
+ pass
365
+ results.append(info)
366
+ return results
367
+
368
+ def get_report(self, skill_name: str) -> Optional[Dict]:
369
+ """Get the latest scan report for a skill."""
370
+ # Check jail first (has embedded report)
371
+ jail_report = JAIL_DIR / skill_name / "scan-report.json"
372
+ if jail_report.exists():
373
+ try:
374
+ return json.loads(jail_report.read_text())
375
+ except (json.JSONDecodeError, IOError):
376
+ pass
377
+
378
+ # Check reports directory
379
+ if REPORTS_DIR.exists():
380
+ reports = sorted(
381
+ REPORTS_DIR.glob(f"{skill_name}-*.json"),
382
+ reverse=True,
383
+ )
384
+ if reports:
385
+ try:
386
+ return json.loads(reports[0].read_text())
387
+ except (json.JSONDecodeError, IOError):
388
+ pass
389
+
390
+ return None
391
+
392
+ def purge_jail(self) -> Tuple[int, str]:
393
+ """Delete all jailed skills."""
394
+ if not JAIL_DIR.exists():
395
+ return 0, "Jail is empty."
396
+
397
+ count = 0
398
+ for d in JAIL_DIR.iterdir():
399
+ if d.is_dir():
400
+ shutil.rmtree(d)
401
+ count += 1
402
+
403
+ return count, f"Purged {count} skill(s) from jail."
404
+
405
+ # =========================================================================
406
+ # Internal Helpers
407
+ # =========================================================================
408
+
409
+ def _save_report(self, report: SkillScanReport) -> Path:
410
+ """Save a scan report to the reports directory."""
411
+ ensure_directories()
412
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
413
+ filename = f"{report.skill_name}-{ts}.json"
414
+ report_path = REPORTS_DIR / filename
415
+ report_path.write_text(report.to_json())
416
+ return report_path
417
+
418
+ def _format_verdict_message(self, report: SkillScanReport) -> str:
419
+ """Format a human-readable verdict message."""
420
+ lines = [f"Skill scan: {report.skill_name}"]
421
+ lines.append(f"Verdict: {report.verdict.upper()}")
422
+ lines.append(f"Risk: {report.risk_level}")
423
+ lines.append(
424
+ f"Findings: {report.critical_count} critical, {report.high_count} high, "
425
+ f"{report.medium_count} medium, {report.low_count} low"
426
+ )
427
+ lines.append(f"Duration: {report.scan_duration_ms}ms")
428
+
429
+ if report.verdict == "pass":
430
+ if self.config.mode == "auto":
431
+ lines.append("Action: Auto-installing.")
432
+ else:
433
+ lines.append(
434
+ f"Action: Awaiting approval. "
435
+ f"Run 'tweek skills chamber approve {report.skill_name}'"
436
+ )
437
+ elif report.verdict == "fail":
438
+ lines.append("Action: Quarantined to jail.")
439
+ elif report.verdict == "manual_review":
440
+ lines.append(
441
+ f"Action: Manual review required. "
442
+ f"Run 'tweek skills chamber approve {report.skill_name}' or "
443
+ f"'tweek skills chamber reject {report.skill_name}'"
444
+ )
445
+
446
+ return "\n".join(lines)
447
+
448
+ def _log_event(self, event_type: str, skill_name: str, details: Dict) -> None:
449
+ """Log a security event for the isolation chamber."""
450
+ try:
451
+ from tweek.logging.security_log import get_security_logger, EventType
452
+
453
+ logger = get_security_logger()
454
+ # Use the closest matching event type, or fall back to generic
455
+ try:
456
+ et = EventType(event_type)
457
+ except ValueError:
458
+ et = EventType.TOOL_INVOKED
459
+
460
+ logger.log_quick(
461
+ et,
462
+ "SkillIsolation",
463
+ skill_name=skill_name,
464
+ source="skill_isolation",
465
+ **details,
466
+ )
467
+ except (ImportError, Exception):
468
+ # Logging is best-effort — don't break the chamber if logger fails
469
+ pass