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,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
|