tweek 0.3.1__py3-none-any.whl → 0.4.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 (61) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/audit.py +2 -2
  3. tweek/cli.py +78 -6605
  4. tweek/cli_config.py +643 -0
  5. tweek/cli_configure.py +413 -0
  6. tweek/cli_core.py +718 -0
  7. tweek/cli_dry_run.py +390 -0
  8. tweek/cli_helpers.py +316 -0
  9. tweek/cli_install.py +1666 -0
  10. tweek/cli_logs.py +301 -0
  11. tweek/cli_mcp.py +148 -0
  12. tweek/cli_memory.py +343 -0
  13. tweek/cli_plugins.py +748 -0
  14. tweek/cli_protect.py +564 -0
  15. tweek/cli_proxy.py +405 -0
  16. tweek/cli_security.py +236 -0
  17. tweek/cli_skills.py +289 -0
  18. tweek/cli_uninstall.py +551 -0
  19. tweek/cli_vault.py +313 -0
  20. tweek/config/allowed_dirs.yaml +16 -17
  21. tweek/config/families.yaml +4 -1
  22. tweek/config/manager.py +17 -0
  23. tweek/config/patterns.yaml +29 -5
  24. tweek/config/templates/config.yaml.template +212 -0
  25. tweek/config/templates/env.template +45 -0
  26. tweek/config/templates/overrides.yaml.template +121 -0
  27. tweek/config/templates/tweek.yaml.template +20 -0
  28. tweek/config/templates.py +136 -0
  29. tweek/config/tiers.yaml +5 -4
  30. tweek/diagnostics.py +112 -32
  31. tweek/hooks/overrides.py +4 -0
  32. tweek/hooks/post_tool_use.py +46 -1
  33. tweek/hooks/pre_tool_use.py +149 -49
  34. tweek/integrations/openclaw.py +84 -0
  35. tweek/licensing.py +1 -1
  36. tweek/mcp/__init__.py +7 -9
  37. tweek/mcp/clients/chatgpt.py +2 -2
  38. tweek/mcp/clients/claude_desktop.py +2 -2
  39. tweek/mcp/clients/gemini.py +2 -2
  40. tweek/mcp/proxy.py +165 -1
  41. tweek/memory/provenance.py +438 -0
  42. tweek/memory/queries.py +2 -0
  43. tweek/memory/safety.py +23 -4
  44. tweek/memory/schemas.py +1 -0
  45. tweek/memory/store.py +101 -71
  46. tweek/plugins/screening/heuristic_scorer.py +1 -1
  47. tweek/security/integrity.py +77 -0
  48. tweek/security/llm_reviewer.py +170 -74
  49. tweek/security/local_reviewer.py +44 -2
  50. tweek/security/model_registry.py +73 -7
  51. tweek/skill_template/overrides-reference.md +1 -1
  52. tweek/skills/context.py +221 -0
  53. tweek/skills/scanner.py +2 -2
  54. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/METADATA +8 -7
  55. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/RECORD +60 -38
  56. tweek/mcp/server.py +0 -320
  57. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/WHEEL +0 -0
  58. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/entry_points.txt +0 -0
  59. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/LICENSE +0 -0
  60. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/NOTICE +0 -0
  61. {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/top_level.txt +0 -0
tweek/cli_core.py ADDED
@@ -0,0 +1,718 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek CLI Core Commands
4
+
5
+ Standalone commands for system management:
6
+ tweek status Show protection status dashboard
7
+ tweek trust Trust a project directory
8
+ tweek untrust Remove trust from a directory
9
+ tweek update Update attack patterns
10
+ tweek doctor Run health checks
11
+ tweek upgrade Upgrade Tweek to latest version
12
+ tweek audit Audit skills for security risks
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from tweek.cli_helpers import (
22
+ TWEEK_BANNER,
23
+ _detect_all_tools,
24
+ _has_tweek_at,
25
+ _load_overrides_yaml,
26
+ _save_overrides_yaml,
27
+ console,
28
+ )
29
+
30
+
31
+ # =============================================================================
32
+ # STATUS
33
+ # =============================================================================
34
+
35
+ @click.command()
36
+ def status():
37
+ """Show Tweek protection status dashboard.
38
+
39
+ Scans for all supported AI tools and displays which are
40
+ detected, which are protected by Tweek, and configuration details.
41
+ """
42
+ _show_protection_status()
43
+
44
+
45
+ def _show_protection_status():
46
+ """Show protection status dashboard for all AI tools."""
47
+ console.print(TWEEK_BANNER, style="cyan")
48
+
49
+ tools = _detect_all_tools()
50
+
51
+ from rich.table import Table
52
+
53
+ table = Table(title="Protection Status")
54
+ table.add_column("Tool", style="cyan")
55
+ table.add_column("Installed", justify="center")
56
+ table.add_column("Protected", justify="center")
57
+ table.add_column("Details")
58
+
59
+ for tool_id, label, installed, protected, detail in tools:
60
+ inst_str = "[green]yes[/green]" if installed else "[white]no[/white]"
61
+ prot_str = "[green]yes[/green]" if protected else ("[yellow]no[/yellow]" if installed else "[white]-[/white]")
62
+ table.add_row(label, inst_str, prot_str, detail)
63
+
64
+ console.print(table)
65
+ console.print()
66
+ console.print("[white]Run 'tweek protect' to set up protection for unprotected tools.[/white]")
67
+
68
+
69
+ # =============================================================================
70
+ # TRUST / UNTRUST
71
+ # =============================================================================
72
+
73
+ @click.command(
74
+ epilog="""\b
75
+ Examples:
76
+ tweek trust Trust the current project
77
+ tweek trust /path/to/project Trust a specific directory
78
+ tweek trust --list Show all trusted paths
79
+ tweek trust . --reason "My safe repo" Trust with an explanation
80
+ """
81
+ )
82
+ @click.argument("path", default=".", type=click.Path(exists=True), required=False)
83
+ @click.option("--reason", "-r", default=None, help="Why this path is trusted")
84
+ @click.option("--list", "list_trusted", is_flag=True, help="List all trusted paths")
85
+ def trust(path: str, reason: str, list_trusted: bool):
86
+ """Trust a project directory — skip all screening for files in this path.
87
+
88
+ Adds the directory to the whitelist in ~/.tweek/overrides.yaml.
89
+ All tool calls operating on files within this path will be allowed
90
+ without screening.
91
+
92
+ This is useful for temporarily pausing Tweek in a specific project,
93
+ or for permanently trusting a known-safe directory.
94
+
95
+ To resume screening, use: tweek untrust
96
+ """
97
+ try:
98
+ overrides, overrides_path = _load_overrides_yaml()
99
+ except ImportError:
100
+ console.print("[red]✗[/red] PyYAML is required. Install with: pip install pyyaml")
101
+ return
102
+ except Exception as e:
103
+ console.print(f"[red]✗[/red] Could not load overrides: {e}")
104
+ return
105
+
106
+ whitelist = overrides.get("whitelist", [])
107
+
108
+ # --list mode: show all trusted paths
109
+ if list_trusted:
110
+ trusted_entries = [
111
+ entry for entry in whitelist
112
+ if isinstance(entry, dict) and "path" in entry and not entry.get("tools")
113
+ ]
114
+ tool_scoped = [
115
+ entry for entry in whitelist
116
+ if isinstance(entry, dict) and "path" in entry and entry.get("tools")
117
+ ]
118
+ other_entries = [
119
+ entry for entry in whitelist
120
+ if isinstance(entry, dict) and "path" not in entry
121
+ ]
122
+
123
+ if not whitelist:
124
+ console.print("[white]No trusted paths configured.[/white]")
125
+ console.print("[white]Use 'tweek trust' to trust the current project.[/white]")
126
+ return
127
+
128
+ if trusted_entries:
129
+ console.print("[bold]Trusted project directories[/bold] (all tools exempt):\n")
130
+ for entry in trusted_entries:
131
+ entry_reason = entry.get("reason", "")
132
+ console.print(f" [green]✓[/green] {entry['path']}")
133
+ if entry_reason:
134
+ console.print(f" [white]{entry_reason}[/white]")
135
+
136
+ if tool_scoped:
137
+ console.print("\n[bold]Tool-scoped whitelist entries:[/bold]\n")
138
+ for entry in tool_scoped:
139
+ tools_str = ", ".join(entry.get("tools", []))
140
+ entry_reason = entry.get("reason", "")
141
+ console.print(f" [cyan]○[/cyan] {entry['path']} [white]({tools_str})[/white]")
142
+ if entry_reason:
143
+ console.print(f" [white]{entry_reason}[/white]")
144
+
145
+ if other_entries:
146
+ console.print("\n[bold]Other whitelist entries:[/bold]\n")
147
+ for entry in other_entries:
148
+ if entry.get("url_prefix"):
149
+ console.print(f" [cyan]○[/cyan] URL: {entry['url_prefix']}")
150
+ elif entry.get("command_prefix"):
151
+ console.print(f" [cyan]○[/cyan] Command: {entry['command_prefix']}")
152
+ entry_reason = entry.get("reason", "")
153
+ if entry_reason:
154
+ console.print(f" [white]{entry_reason}[/white]")
155
+
156
+ console.print(f"\n[white]Config: {overrides_path}[/white]")
157
+ return
158
+
159
+ # Resolve path to absolute
160
+ resolved = Path(path).resolve()
161
+ resolved_str = str(resolved)
162
+
163
+ # Check if already whitelisted
164
+ already_trusted = any(
165
+ isinstance(entry, dict)
166
+ and entry.get("path", "").rstrip("/") == resolved_str.rstrip("/")
167
+ and not entry.get("tools") # full trust, not tool-scoped
168
+ for entry in whitelist
169
+ )
170
+
171
+ if already_trusted:
172
+ console.print(f"[green]✓[/green] Already trusted: {resolved}")
173
+ console.print("[white]Use 'tweek untrust' to remove.[/white]")
174
+ return
175
+
176
+ # Add whitelist entry (no tools restriction = all tools exempt)
177
+ entry = {
178
+ "path": resolved_str,
179
+ "reason": reason or "Trusted via tweek trust",
180
+ }
181
+ whitelist.append(entry)
182
+ overrides["whitelist"] = whitelist
183
+
184
+ try:
185
+ _save_overrides_yaml(overrides, overrides_path)
186
+ except Exception as e:
187
+ console.print(f"[red]✗[/red] Could not save overrides: {e}")
188
+ return
189
+
190
+ console.print(f"[green]✓[/green] Trusted: {resolved}")
191
+ console.print(f" [white]All screening is now skipped for files in this directory.[/white]")
192
+ console.print(f" [white]To resume screening: tweek untrust {path}[/white]")
193
+
194
+
195
+ @click.command(
196
+ epilog="""\b
197
+ Examples:
198
+ tweek untrust Untrust the current project
199
+ tweek untrust /path/to/project Untrust a specific directory
200
+ """
201
+ )
202
+ @click.argument("path", default=".", type=click.Path(exists=True), required=False)
203
+ def untrust(path: str):
204
+ """Remove trust from a project directory — resume screening.
205
+
206
+ Removes the directory from the whitelist in ~/.tweek/overrides.yaml.
207
+ Tweek will resume screening tool calls for files in this path.
208
+ """
209
+ try:
210
+ overrides, overrides_path = _load_overrides_yaml()
211
+ except ImportError:
212
+ console.print("[red]✗[/red] PyYAML is required. Install with: pip install pyyaml")
213
+ return
214
+ except Exception as e:
215
+ console.print(f"[red]✗[/red] Could not load overrides: {e}")
216
+ return
217
+
218
+ whitelist = overrides.get("whitelist", [])
219
+ if not whitelist:
220
+ console.print("[yellow]This path is not currently trusted.[/yellow]")
221
+ return
222
+
223
+ # Resolve path to absolute
224
+ resolved = Path(path).resolve()
225
+ resolved_str = str(resolved)
226
+
227
+ # Find and remove matching entry (full trust only, not tool-scoped)
228
+ original_len = len(whitelist)
229
+ whitelist = [
230
+ entry for entry in whitelist
231
+ if not (
232
+ isinstance(entry, dict)
233
+ and entry.get("path", "").rstrip("/") == resolved_str.rstrip("/")
234
+ and not entry.get("tools") # only remove full trust, not tool-scoped entries
235
+ )
236
+ ]
237
+
238
+ if len(whitelist) == original_len:
239
+ console.print(f"[yellow]This path is not currently trusted:[/yellow] {resolved}")
240
+ console.print("[white]Use 'tweek trust --list' to see all trusted paths.[/white]")
241
+ return
242
+
243
+ overrides["whitelist"] = whitelist
244
+
245
+ # Clean up empty whitelist
246
+ if not whitelist:
247
+ del overrides["whitelist"]
248
+
249
+ try:
250
+ _save_overrides_yaml(overrides, overrides_path)
251
+ except Exception as e:
252
+ console.print(f"[red]✗[/red] Could not save overrides: {e}")
253
+ return
254
+
255
+ console.print(f"[green]✓[/green] Removed trust: {resolved}")
256
+ console.print(f" [white]Tweek will now screen tool calls for files in this directory.[/white]")
257
+
258
+
259
+ # =============================================================================
260
+ # UPDATE
261
+ # =============================================================================
262
+
263
+ @click.command(
264
+ epilog="""\b
265
+ Examples:
266
+ tweek update Download/update attack patterns
267
+ tweek update --check Check for updates without installing
268
+ """
269
+ )
270
+ @click.option("--check", is_flag=True, help="Check for updates without installing")
271
+ def update(check: bool):
272
+ """Update attack patterns from GitHub.
273
+
274
+ Patterns are stored in ~/.tweek/patterns/ and can be updated
275
+ independently of the Tweek application.
276
+
277
+ All 262 patterns are included free. PRO tier adds LLM review,
278
+ session analysis, and rate limiting.
279
+ """
280
+ import subprocess
281
+
282
+ patterns_dir = Path("~/.tweek/patterns").expanduser()
283
+ patterns_repo = "https://github.com/gettweek/tweek.git"
284
+
285
+ console.print(TWEEK_BANNER, style="cyan")
286
+
287
+ if not patterns_dir.exists():
288
+ # First time: clone the repo
289
+ if check:
290
+ console.print("[yellow]Patterns not installed.[/yellow]")
291
+ console.print(f"[white]Run 'tweek update' to install from {patterns_repo}[/white]")
292
+ return
293
+
294
+ console.print(f"[cyan]Installing patterns from {patterns_repo}...[/cyan]")
295
+
296
+ try:
297
+ result = subprocess.run(
298
+ ["git", "clone", "--depth", "1", patterns_repo, str(patterns_dir)],
299
+ capture_output=True,
300
+ text=True,
301
+ check=True
302
+ )
303
+ console.print("[green]✓[/green] Patterns installed successfully")
304
+
305
+ # Show pattern count
306
+ patterns_file = patterns_dir / "patterns.yaml"
307
+ if patterns_file.exists():
308
+ import yaml
309
+ with open(patterns_file) as f:
310
+ data = yaml.safe_load(f)
311
+ count = data.get("pattern_count", len(data.get("patterns", [])))
312
+ free_max = data.get("free_tier_max", 23)
313
+ console.print(f"[white]Installed {count} patterns ({free_max} free, {count - free_max} pro)[/white]")
314
+
315
+ except subprocess.CalledProcessError as e:
316
+ console.print(f"[red]✗[/red] Failed to clone patterns: {e.stderr}")
317
+ return
318
+ except FileNotFoundError:
319
+ console.print("[red]✗[/red] git not found.")
320
+ console.print(" [white]Hint: Install git from https://git-scm.com/downloads[/white]")
321
+ console.print(" [white]On macOS: xcode-select --install[/white]")
322
+ return
323
+
324
+ else:
325
+ # Update existing repo
326
+ if check:
327
+ console.print("[cyan]Checking for pattern updates...[/cyan]")
328
+ try:
329
+ result = subprocess.run(
330
+ ["git", "-C", str(patterns_dir), "fetch", "--dry-run"],
331
+ capture_output=True,
332
+ text=True
333
+ )
334
+ # Check if there are updates
335
+ result2 = subprocess.run(
336
+ ["git", "-C", str(patterns_dir), "status", "-uno"],
337
+ capture_output=True,
338
+ text=True
339
+ )
340
+ if "behind" in result2.stdout:
341
+ console.print("[yellow]Updates available.[/yellow]")
342
+ console.print("[white]Run 'tweek update' to install[/white]")
343
+ else:
344
+ console.print("[green]✓[/green] Patterns are up to date")
345
+ except Exception as e:
346
+ console.print(f"[red]✗[/red] Failed to check for updates: {e}")
347
+ return
348
+
349
+ console.print("[cyan]Updating patterns...[/cyan]")
350
+
351
+ try:
352
+ result = subprocess.run(
353
+ ["git", "-C", str(patterns_dir), "pull", "--ff-only"],
354
+ capture_output=True,
355
+ text=True,
356
+ check=True
357
+ )
358
+
359
+ if "Already up to date" in result.stdout:
360
+ console.print("[green]✓[/green] Patterns already up to date")
361
+ else:
362
+ console.print("[green]✓[/green] Patterns updated successfully")
363
+
364
+ # Show what changed
365
+ if result.stdout.strip():
366
+ console.print(f"[white]{result.stdout.strip()}[/white]")
367
+
368
+ except subprocess.CalledProcessError as e:
369
+ console.print(f"[red]✗[/red] Failed to update patterns: {e.stderr}")
370
+ console.print("[white]Try: rm -rf ~/.tweek/patterns && tweek update[/white]")
371
+ return
372
+
373
+ # Show current version info
374
+ patterns_file = patterns_dir / "patterns.yaml"
375
+ if patterns_file.exists():
376
+ import yaml
377
+ try:
378
+ with open(patterns_file) as f:
379
+ data = yaml.safe_load(f)
380
+ version = data.get("version", "?")
381
+ count = data.get("pattern_count", len(data.get("patterns", [])))
382
+
383
+ console.print()
384
+ console.print(f"[cyan]Pattern version:[/cyan] {version}")
385
+ console.print(f"[cyan]Total patterns:[/cyan] {count} (all included free)")
386
+
387
+ console.print(f"[cyan]All features:[/cyan] LLM review, session analysis, rate limiting, sandbox (open source)")
388
+ console.print(f"[white]Pro (teams) and Enterprise (compliance) coming soon: gettweek.com[/white]")
389
+
390
+ except Exception:
391
+ pass
392
+
393
+
394
+ # =============================================================================
395
+ # DOCTOR
396
+ # =============================================================================
397
+
398
+ @click.command(
399
+ epilog="""\b
400
+ Examples:
401
+ tweek doctor Run all health checks
402
+ tweek doctor --verbose Show detailed check information
403
+ tweek doctor --json Output results as JSON for scripting
404
+ """
405
+ )
406
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed check information")
407
+ @click.option("--json-output", "--json", "json_out", is_flag=True, help="Output results as JSON")
408
+ def doctor(verbose: bool, json_out: bool):
409
+ """Run health checks on your Tweek installation.
410
+
411
+ Checks hooks, configuration, patterns, database, vault, sandbox,
412
+ license, MCP, proxy, and plugin integrity.
413
+ """
414
+ from tweek.diagnostics import run_health_checks
415
+ from tweek.cli_helpers import print_doctor_results, print_doctor_json
416
+
417
+ checks = run_health_checks(verbose=verbose)
418
+
419
+ if json_out:
420
+ print_doctor_json(checks)
421
+ else:
422
+ print_doctor_results(checks)
423
+
424
+
425
+ # =============================================================================
426
+ # UPGRADE
427
+ # =============================================================================
428
+
429
+ @click.command("upgrade")
430
+ def upgrade():
431
+ """Upgrade Tweek to the latest version from PyPI.
432
+
433
+ Detects how Tweek was installed (uv, pipx, or pip) and runs
434
+ the appropriate upgrade command.
435
+ """
436
+ import subprocess
437
+
438
+ console.print("[cyan]Checking for updates...[/cyan]")
439
+ console.print()
440
+
441
+ current_version = None
442
+ try:
443
+ from tweek import __version__
444
+ current_version = __version__
445
+ console.print(f" Current version: [bold]{current_version}[/bold]")
446
+ except ImportError:
447
+ pass
448
+
449
+ # Detect install method and upgrade
450
+ upgraded = False
451
+
452
+ # Try uv first
453
+ try:
454
+ result = subprocess.run(
455
+ ["uv", "tool", "list"], capture_output=True, text=True, timeout=10
456
+ )
457
+ if result.returncode == 0 and "tweek" in result.stdout:
458
+ console.print(" Install method: [cyan]uv[/cyan]")
459
+ console.print()
460
+ console.print("[white]Upgrading via uv...[/white]")
461
+ proc = subprocess.run(
462
+ ["uv", "tool", "upgrade", "tweek"],
463
+ capture_output=False, timeout=120
464
+ )
465
+ if proc.returncode == 0:
466
+ upgraded = True
467
+ else:
468
+ console.print("[yellow]uv upgrade failed, trying reinstall...[/yellow]")
469
+ subprocess.run(
470
+ ["uv", "tool", "install", "--force", "tweek"],
471
+ capture_output=False, timeout=120
472
+ )
473
+ upgraded = True
474
+ except (FileNotFoundError, subprocess.TimeoutExpired):
475
+ pass
476
+
477
+ # Try pipx
478
+ if not upgraded:
479
+ try:
480
+ result = subprocess.run(
481
+ ["pipx", "list"], capture_output=True, text=True, timeout=10
482
+ )
483
+ if result.returncode == 0 and "tweek" in result.stdout:
484
+ console.print(" Install method: [cyan]pipx[/cyan]")
485
+ console.print()
486
+ console.print("[white]Upgrading via pipx...[/white]")
487
+ proc = subprocess.run(
488
+ ["pipx", "upgrade", "tweek"],
489
+ capture_output=False, timeout=120
490
+ )
491
+ upgraded = proc.returncode == 0
492
+ except (FileNotFoundError, subprocess.TimeoutExpired):
493
+ pass
494
+
495
+ # Try pip
496
+ if not upgraded:
497
+ try:
498
+ result = subprocess.run(
499
+ [sys.executable, "-m", "pip", "show", "tweek"],
500
+ capture_output=True, text=True, timeout=10
501
+ )
502
+ if result.returncode == 0:
503
+ console.print(" Install method: [cyan]pip[/cyan]")
504
+ console.print()
505
+ console.print("[white]Upgrading via pip...[/white]")
506
+ proc = subprocess.run(
507
+ [sys.executable, "-m", "pip", "install", "--upgrade", "tweek"],
508
+ capture_output=False, timeout=120
509
+ )
510
+ upgraded = proc.returncode == 0
511
+ except (FileNotFoundError, subprocess.TimeoutExpired):
512
+ pass
513
+
514
+ if not upgraded:
515
+ console.print("[red]Could not determine install method.[/red]")
516
+ console.print("[white]Try manually:[/white]")
517
+ console.print(" uv tool upgrade tweek")
518
+ console.print(" pipx upgrade tweek")
519
+ console.print(" pip install --upgrade tweek")
520
+ return
521
+
522
+ # Show new version
523
+ console.print()
524
+ try:
525
+ result = subprocess.run(
526
+ ["tweek", "--version"], capture_output=True, text=True, timeout=10
527
+ )
528
+ if result.returncode == 0:
529
+ new_version = result.stdout.strip()
530
+ console.print(f"[green]✓[/green] Updated to {new_version}")
531
+ else:
532
+ console.print("[green]✓[/green] Update complete")
533
+ except (FileNotFoundError, subprocess.TimeoutExpired):
534
+ console.print("[green]✓[/green] Update complete")
535
+
536
+
537
+ # =============================================================================
538
+ # AUDIT
539
+ # =============================================================================
540
+
541
+ @click.command(
542
+ epilog="""\b
543
+ Examples:
544
+ tweek audit Scan all installed skills
545
+ tweek audit ./skills/my-skill/SKILL.md Audit a specific file
546
+ tweek audit --no-translate Skip translation of non-English content
547
+ tweek audit --json Machine-readable JSON output
548
+ """
549
+ )
550
+ @click.argument("path", required=False, default=None, type=click.Path())
551
+ @click.option("--translate/--no-translate", default=True,
552
+ help="Translate non-English content before pattern analysis (default: auto)")
553
+ @click.option("--llm-review/--no-llm-review", default=True,
554
+ help="Run LLM semantic review (requires ANTHROPIC_API_KEY)")
555
+ @click.option("--json-output", "--json", "json_out", is_flag=True,
556
+ help="Output results as JSON")
557
+ def audit(path, translate, llm_review, json_out):
558
+ """Audit skills and tool files for security risks.
559
+
560
+ Scans skill files (SKILL.md, tool descriptions) for prompt injection,
561
+ credential theft, data exfiltration, and other attack patterns.
562
+
563
+ Non-English content is detected and translated to English before
564
+ running all 262 regex patterns. LLM semantic review provides
565
+ additional analysis for obfuscated attacks.
566
+
567
+ \b
568
+ Without arguments, scans all installed skills in:
569
+ ~/.claude/skills/
570
+ ~/.openclaw/workspace/skills/
571
+ ./.claude/skills/
572
+ """
573
+ from tweek.audit import scan_installed_skills, audit_skill, audit_content
574
+ from rich.table import Table
575
+
576
+ if path:
577
+ # Audit a specific file
578
+ target = Path(path)
579
+ if not target.exists():
580
+ console.print(f"[red]File not found: {target}[/red]")
581
+ return
582
+
583
+ console.print(f"[cyan]Auditing {target}...[/cyan]")
584
+ console.print()
585
+
586
+ result = audit_skill(target, translate=translate, llm_review=llm_review)
587
+
588
+ if json_out:
589
+ _print_audit_json([result])
590
+ else:
591
+ _print_audit_result(result)
592
+ else:
593
+ # Scan all installed skills
594
+ console.print("[cyan]Scanning for installed skills...[/cyan]")
595
+ skills_found = scan_installed_skills()
596
+
597
+ if not skills_found:
598
+ console.print("[white]No installed skills found.[/white]")
599
+ console.print("[white]Specify a file path to audit: tweek audit <path>[/white]")
600
+ return
601
+
602
+ console.print(f"Found {len(skills_found)} skill(s)")
603
+ console.print()
604
+
605
+ results = []
606
+ for skill_info in skills_found:
607
+ if skill_info.get("error") or skill_info.get("content") is None:
608
+ console.print(f"[yellow]Skipping {skill_info['name']}: {skill_info.get('error', 'no content')}[/yellow]")
609
+ continue
610
+
611
+ console.print(f"[cyan]Auditing {skill_info['name']}...[/cyan]")
612
+ result = audit_content(
613
+ content=skill_info["content"],
614
+ name=skill_info["name"],
615
+ path=skill_info["path"],
616
+ translate=translate,
617
+ llm_review=llm_review,
618
+ )
619
+ results.append(result)
620
+
621
+ if json_out:
622
+ _print_audit_json(results)
623
+ else:
624
+ for result in results:
625
+ _print_audit_result(result)
626
+ console.print()
627
+
628
+ # Summary
629
+ total = len(results)
630
+ dangerous = sum(1 for r in results if r.risk_level == "dangerous")
631
+ suspicious = sum(1 for r in results if r.risk_level == "suspicious")
632
+ safe = sum(1 for r in results if r.risk_level == "safe")
633
+
634
+ console.print("[bold]Summary[/bold]")
635
+ console.print(f" Skills scanned: {total}")
636
+ if dangerous:
637
+ console.print(f" [red]Dangerous: {dangerous}[/red]")
638
+ if suspicious:
639
+ console.print(f" [yellow]Suspicious: {suspicious}[/yellow]")
640
+ console.print(f" [green]Safe: {safe}[/green]")
641
+
642
+
643
+ def _print_audit_result(result):
644
+ """Print a formatted audit result."""
645
+ from rich.table import Table
646
+
647
+ risk_icons = {"safe": "[green]SAFE[/green]", "suspicious": "[yellow]SUSPICIOUS[/yellow]", "dangerous": "[red]DANGEROUS[/red]"}
648
+
649
+ console.print(f" [bold]{result.skill_name}[/bold] — {risk_icons.get(result.risk_level, result.risk_level)}")
650
+ console.print(f" [white]{result.skill_path}[/white]")
651
+
652
+ if result.error:
653
+ console.print(f" [red]Error: {result.error}[/red]")
654
+ return
655
+
656
+ if result.non_english_detected:
657
+ lang = result.detected_language or "unknown"
658
+ if result.translated:
659
+ console.print(f" [cyan]Non-English detected ({lang}) — translated for analysis[/cyan]")
660
+ else:
661
+ console.print(f" [yellow]Non-English detected ({lang}) — translation skipped[/yellow]")
662
+
663
+ if result.findings:
664
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
665
+ table.add_column("Severity", style="white")
666
+ table.add_column("Pattern")
667
+ table.add_column("Description")
668
+ table.add_column("Match", style="white")
669
+
670
+ severity_styles = {"critical": "red bold", "high": "red", "medium": "yellow", "low": "white"}
671
+
672
+ for finding in result.findings:
673
+ table.add_row(
674
+ f"[{severity_styles.get(finding.severity, '')}]{finding.severity.upper()}[/]",
675
+ finding.pattern_name,
676
+ finding.description,
677
+ finding.matched_text[:40] if finding.matched_text else "",
678
+ )
679
+
680
+ console.print(table)
681
+ else:
682
+ console.print(" [green]No patterns matched[/green]")
683
+
684
+ if result.llm_review:
685
+ review = result.llm_review
686
+ console.print(f" LLM Review: {review.get('risk_level', 'N/A')} ({review.get('confidence', 0):.0%}) — {review.get('reason', '')}")
687
+
688
+
689
+ def _print_audit_json(results):
690
+ """Print audit results as JSON."""
691
+ import json
692
+ output = []
693
+ for r in results:
694
+ output.append({
695
+ "skill_name": r.skill_name,
696
+ "skill_path": str(r.skill_path),
697
+ "risk_level": r.risk_level,
698
+ "content_length": r.content_length,
699
+ "non_english_detected": r.non_english_detected,
700
+ "detected_language": r.detected_language,
701
+ "translated": r.translated,
702
+ "finding_count": r.finding_count,
703
+ "critical_count": r.critical_count,
704
+ "high_count": r.high_count,
705
+ "findings": [
706
+ {
707
+ "pattern_id": f.pattern_id,
708
+ "pattern_name": f.pattern_name,
709
+ "severity": f.severity,
710
+ "description": f.description,
711
+ "matched_text": f.matched_text,
712
+ }
713
+ for f in r.findings
714
+ ],
715
+ "llm_review": r.llm_review,
716
+ "error": r.error,
717
+ })
718
+ console.print_json(json.dumps(output, indent=2))