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
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Installation Status Checker
|
|
4
|
+
|
|
5
|
+
Checks whether tweek is installed and operational on this system.
|
|
6
|
+
Returns structured JSON for deterministic parsing by AI assistants.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 check_installed.py # Check status (read-only)
|
|
10
|
+
python3 check_installed.py --decline # Record that user declined install
|
|
11
|
+
python3 check_installed.py --reset # Clear the decline preference
|
|
12
|
+
|
|
13
|
+
This script uses ONLY Python stdlib — no tweek imports, no pip imports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _preferences_path():
|
|
25
|
+
"""Path to the preferences file stored alongside this script."""
|
|
26
|
+
return Path(__file__).resolve().parent.parent / ".preferences.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_preferences():
|
|
30
|
+
"""Load preferences from disk. Returns empty dict if missing/corrupt."""
|
|
31
|
+
path = _preferences_path()
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
with open(path) as f:
|
|
36
|
+
return json.load(f)
|
|
37
|
+
except (json.JSONDecodeError, IOError):
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_preferences(prefs):
|
|
42
|
+
"""Write preferences to disk."""
|
|
43
|
+
path = _preferences_path()
|
|
44
|
+
with open(path, "w") as f:
|
|
45
|
+
json.dump(prefs, f, indent=2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decline_install():
|
|
49
|
+
"""Record that the user declined tweek installation."""
|
|
50
|
+
prefs = _load_preferences()
|
|
51
|
+
prefs["install_declined"] = True
|
|
52
|
+
prefs["declined_at"] = datetime.now(timezone.utc).isoformat()
|
|
53
|
+
_save_preferences(prefs)
|
|
54
|
+
print(json.dumps({"action": "decline_saved", "path": str(_preferences_path())}))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reset_preference():
|
|
58
|
+
"""Clear the decline preference so the offer can be made again."""
|
|
59
|
+
prefs = _load_preferences()
|
|
60
|
+
prefs.pop("install_declined", None)
|
|
61
|
+
prefs.pop("declined_at", None)
|
|
62
|
+
_save_preferences(prefs)
|
|
63
|
+
print(json.dumps({"action": "preference_reset", "path": str(_preferences_path())}))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_installation():
|
|
67
|
+
"""Check tweek installation status across multiple signals."""
|
|
68
|
+
|
|
69
|
+
result = {
|
|
70
|
+
"tweek_in_path": False,
|
|
71
|
+
"tweek_path": None,
|
|
72
|
+
"tweek_data_dir": False,
|
|
73
|
+
"hooks_registered": False,
|
|
74
|
+
"hooks_detail": {
|
|
75
|
+
"pre_tool_use": False,
|
|
76
|
+
"post_tool_use": False,
|
|
77
|
+
},
|
|
78
|
+
"install_declined": False,
|
|
79
|
+
"declined_at": None,
|
|
80
|
+
"pip_available": False,
|
|
81
|
+
"pipx_available": False,
|
|
82
|
+
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
83
|
+
"status": "not_installed",
|
|
84
|
+
"install_command": None,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Check user preferences
|
|
88
|
+
prefs = _load_preferences()
|
|
89
|
+
if prefs.get("install_declined"):
|
|
90
|
+
result["install_declined"] = True
|
|
91
|
+
result["declined_at"] = prefs.get("declined_at")
|
|
92
|
+
|
|
93
|
+
# Check 1: Is tweek in PATH?
|
|
94
|
+
tweek_path = shutil.which("tweek")
|
|
95
|
+
if tweek_path:
|
|
96
|
+
result["tweek_in_path"] = True
|
|
97
|
+
result["tweek_path"] = tweek_path
|
|
98
|
+
|
|
99
|
+
# Check 2: Does ~/.tweek/ data directory exist?
|
|
100
|
+
tweek_dir = Path.home() / ".tweek"
|
|
101
|
+
if tweek_dir.is_dir():
|
|
102
|
+
result["tweek_data_dir"] = True
|
|
103
|
+
|
|
104
|
+
# Check 3: Are hooks registered in ~/.claude/settings.json?
|
|
105
|
+
for settings_path in [
|
|
106
|
+
Path.home() / ".claude" / "settings.json",
|
|
107
|
+
Path.cwd() / ".claude" / "settings.json",
|
|
108
|
+
]:
|
|
109
|
+
if settings_path.is_file():
|
|
110
|
+
try:
|
|
111
|
+
with open(settings_path) as f:
|
|
112
|
+
settings = json.load(f)
|
|
113
|
+
hooks = settings.get("hooks", {})
|
|
114
|
+
|
|
115
|
+
pre_hooks = hooks.get("PreToolUse", [])
|
|
116
|
+
for hook_group in pre_hooks:
|
|
117
|
+
for hook in hook_group.get("hooks", []):
|
|
118
|
+
cmd = hook.get("command", "")
|
|
119
|
+
if "pre_tool_use.py" in cmd and "tweek" in cmd:
|
|
120
|
+
result["hooks_detail"]["pre_tool_use"] = True
|
|
121
|
+
|
|
122
|
+
post_hooks = hooks.get("PostToolUse", [])
|
|
123
|
+
for hook_group in post_hooks:
|
|
124
|
+
for hook in hook_group.get("hooks", []):
|
|
125
|
+
cmd = hook.get("command", "")
|
|
126
|
+
if "post_tool_use.py" in cmd and "tweek" in cmd:
|
|
127
|
+
result["hooks_detail"]["post_tool_use"] = True
|
|
128
|
+
|
|
129
|
+
if result["hooks_detail"]["pre_tool_use"] or result["hooks_detail"]["post_tool_use"]:
|
|
130
|
+
result["hooks_registered"] = True
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
except (json.JSONDecodeError, IOError, KeyError):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
# Check 4: Package manager availability
|
|
137
|
+
if shutil.which("pip") or shutil.which("pip3"):
|
|
138
|
+
result["pip_available"] = True
|
|
139
|
+
if shutil.which("pipx"):
|
|
140
|
+
result["pipx_available"] = True
|
|
141
|
+
|
|
142
|
+
# Determine overall status
|
|
143
|
+
if result["tweek_in_path"] and result["hooks_registered"]:
|
|
144
|
+
result["status"] = "fully_operational"
|
|
145
|
+
elif result["tweek_in_path"] and not result["hooks_registered"]:
|
|
146
|
+
result["status"] = "installed_no_hooks"
|
|
147
|
+
result["install_command"] = "tweek install"
|
|
148
|
+
elif not result["tweek_in_path"] and result["hooks_registered"]:
|
|
149
|
+
result["status"] = "hooks_only"
|
|
150
|
+
result["install_command"] = "pip install tweek"
|
|
151
|
+
else:
|
|
152
|
+
result["status"] = "not_installed"
|
|
153
|
+
if result["pipx_available"]:
|
|
154
|
+
result["install_command"] = "pipx install tweek && tweek install"
|
|
155
|
+
elif result["pip_available"]:
|
|
156
|
+
result["install_command"] = "pip install tweek && tweek install"
|
|
157
|
+
else:
|
|
158
|
+
result["install_command"] = "python3 -m pip install tweek && tweek install"
|
|
159
|
+
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
if "--decline" in sys.argv:
|
|
165
|
+
decline_install()
|
|
166
|
+
elif "--reset" in sys.argv:
|
|
167
|
+
reset_preference()
|
|
168
|
+
else:
|
|
169
|
+
status = check_installation()
|
|
170
|
+
print(json.dumps(status, indent=2))
|
tweek/skills/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Skill Isolation Chamber
|
|
3
|
+
|
|
4
|
+
Security quarantine layer for Claude Code skills. Skills are placed in an
|
|
5
|
+
isolation chamber, scanned by a multi-layer pipeline, and either auto-installed
|
|
6
|
+
or jailed based on scan results.
|
|
7
|
+
|
|
8
|
+
Components:
|
|
9
|
+
- config: IsolationConfig dataclass and loader
|
|
10
|
+
- scanner: 7-layer security scanning pipeline
|
|
11
|
+
- isolation: Chamber lifecycle management
|
|
12
|
+
- guard: Self-protection against AI circumvention
|
|
13
|
+
- fingerprints: SHA-256 fingerprint cache for git-arrived skill detection
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Directory constants
|
|
19
|
+
TWEEK_HOME = Path.home() / ".tweek"
|
|
20
|
+
SKILLS_DIR = TWEEK_HOME / "skills"
|
|
21
|
+
CHAMBER_DIR = SKILLS_DIR / "chamber"
|
|
22
|
+
JAIL_DIR = SKILLS_DIR / "jail"
|
|
23
|
+
REPORTS_DIR = SKILLS_DIR / "reports"
|
|
24
|
+
|
|
25
|
+
# Claude's actual skill install locations
|
|
26
|
+
CLAUDE_GLOBAL_SKILLS = Path.home() / ".claude" / "skills"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_claude_project_skills(working_dir: Path = None) -> Path:
|
|
30
|
+
"""Get the project-level Claude skills directory."""
|
|
31
|
+
base = working_dir or Path.cwd()
|
|
32
|
+
return base / ".claude" / "skills"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ensure_directories():
|
|
36
|
+
"""Create all required isolation chamber directories."""
|
|
37
|
+
for d in (CHAMBER_DIR, JAIL_DIR, REPORTS_DIR):
|
|
38
|
+
d.mkdir(parents=True, exist_ok=True)
|
tweek/skills/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Isolation Chamber Configuration
|
|
3
|
+
|
|
4
|
+
Defines the IsolationConfig dataclass and loading logic.
|
|
5
|
+
Configuration is stored in ~/.tweek/config.yaml under the 'isolation_chamber' key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Any, Optional
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Default allowed file extensions for skills
|
|
16
|
+
DEFAULT_ALLOWED_EXTENSIONS = [
|
|
17
|
+
".md", ".py", ".json", ".yaml", ".yml", ".txt", ".sh", ".toml",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Extensions that should never appear in a skill
|
|
21
|
+
DEFAULT_BLOCKED_EXTENSIONS = [
|
|
22
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".msi", ".dmg",
|
|
23
|
+
".com", ".bat", ".cmd", ".scr", ".pif",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class IsolationConfig:
|
|
29
|
+
"""Configuration for the Skill Isolation Chamber."""
|
|
30
|
+
|
|
31
|
+
enabled: bool = True
|
|
32
|
+
mode: str = "auto" # "auto" or "manual"
|
|
33
|
+
scan_timeout_seconds: float = 30.0
|
|
34
|
+
llm_review_enabled: bool = True
|
|
35
|
+
max_skill_size_bytes: int = 1_048_576 # 1MB
|
|
36
|
+
max_file_count: int = 50
|
|
37
|
+
max_directory_depth: int = 3
|
|
38
|
+
allowed_file_extensions: List[str] = field(
|
|
39
|
+
default_factory=lambda: list(DEFAULT_ALLOWED_EXTENSIONS)
|
|
40
|
+
)
|
|
41
|
+
blocked_file_extensions: List[str] = field(
|
|
42
|
+
default_factory=lambda: list(DEFAULT_BLOCKED_EXTENSIONS)
|
|
43
|
+
)
|
|
44
|
+
trusted_sources: List[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
# Verdict thresholds
|
|
47
|
+
fail_on_critical: bool = True
|
|
48
|
+
fail_on_high_count: int = 3
|
|
49
|
+
review_on_high_count: int = 1
|
|
50
|
+
|
|
51
|
+
# Notifications
|
|
52
|
+
notify_on_jail: bool = True
|
|
53
|
+
|
|
54
|
+
def validate(self) -> List[str]:
|
|
55
|
+
"""Validate configuration values. Returns list of issues."""
|
|
56
|
+
issues = []
|
|
57
|
+
if self.mode not in ("auto", "manual"):
|
|
58
|
+
issues.append(f"Invalid mode '{self.mode}': must be 'auto' or 'manual'")
|
|
59
|
+
if self.scan_timeout_seconds <= 0:
|
|
60
|
+
issues.append("scan_timeout_seconds must be positive")
|
|
61
|
+
if self.max_skill_size_bytes <= 0:
|
|
62
|
+
issues.append("max_skill_size_bytes must be positive")
|
|
63
|
+
if self.fail_on_high_count < 1:
|
|
64
|
+
issues.append("fail_on_high_count must be >= 1")
|
|
65
|
+
if self.review_on_high_count < 1:
|
|
66
|
+
issues.append("review_on_high_count must be >= 1")
|
|
67
|
+
return issues
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_isolation_config(config_path: Optional[Path] = None) -> IsolationConfig:
|
|
71
|
+
"""
|
|
72
|
+
Load isolation chamber configuration from ~/.tweek/config.yaml.
|
|
73
|
+
|
|
74
|
+
Falls back to defaults if file is missing or section is absent.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config_path: Override config file path (for testing)
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
IsolationConfig with loaded or default values
|
|
81
|
+
"""
|
|
82
|
+
if config_path is None:
|
|
83
|
+
config_path = Path.home() / ".tweek" / "config.yaml"
|
|
84
|
+
|
|
85
|
+
if not config_path.exists():
|
|
86
|
+
return IsolationConfig()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with open(config_path) as f:
|
|
90
|
+
full_config = yaml.safe_load(f) or {}
|
|
91
|
+
except Exception:
|
|
92
|
+
return IsolationConfig()
|
|
93
|
+
|
|
94
|
+
section = full_config.get("isolation_chamber", {})
|
|
95
|
+
if not section or not isinstance(section, dict):
|
|
96
|
+
return IsolationConfig()
|
|
97
|
+
|
|
98
|
+
return _config_from_dict(section)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _config_from_dict(data: Dict[str, Any]) -> IsolationConfig:
|
|
102
|
+
"""Build IsolationConfig from a dict, ignoring unknown keys."""
|
|
103
|
+
known_fields = {f.name for f in IsolationConfig.__dataclass_fields__.values()}
|
|
104
|
+
filtered = {k: v for k, v in data.items() if k in known_fields}
|
|
105
|
+
return IsolationConfig(**filtered)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_isolation_config(
|
|
109
|
+
config: IsolationConfig,
|
|
110
|
+
config_path: Optional[Path] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Save isolation chamber configuration to ~/.tweek/config.yaml.
|
|
114
|
+
|
|
115
|
+
Preserves other sections in the config file.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
config: The configuration to save
|
|
119
|
+
config_path: Override config file path (for testing)
|
|
120
|
+
"""
|
|
121
|
+
if config_path is None:
|
|
122
|
+
config_path = Path.home() / ".tweek" / "config.yaml"
|
|
123
|
+
|
|
124
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
# Load existing config to preserve other sections
|
|
127
|
+
full_config = {}
|
|
128
|
+
if config_path.exists():
|
|
129
|
+
try:
|
|
130
|
+
with open(config_path) as f:
|
|
131
|
+
full_config = yaml.safe_load(f) or {}
|
|
132
|
+
except Exception:
|
|
133
|
+
full_config = {}
|
|
134
|
+
|
|
135
|
+
# Update just the isolation_chamber section
|
|
136
|
+
section = {}
|
|
137
|
+
for field_name in IsolationConfig.__dataclass_fields__:
|
|
138
|
+
value = getattr(config, field_name)
|
|
139
|
+
default_value = getattr(IsolationConfig(), field_name)
|
|
140
|
+
# Only write non-default values to keep config clean
|
|
141
|
+
if value != default_value:
|
|
142
|
+
section[field_name] = value
|
|
143
|
+
|
|
144
|
+
if section:
|
|
145
|
+
full_config["isolation_chamber"] = section
|
|
146
|
+
elif "isolation_chamber" in full_config:
|
|
147
|
+
del full_config["isolation_chamber"]
|
|
148
|
+
|
|
149
|
+
with open(config_path, "w") as f:
|
|
150
|
+
yaml.dump(full_config, f, default_flow_style=False, sort_keys=False)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Skill Fingerprint Cache
|
|
3
|
+
|
|
4
|
+
Tracks SHA-256 hashes of known-approved SKILL.md files to detect
|
|
5
|
+
new or modified skills that arrive via git pull, clone, or branch switch.
|
|
6
|
+
|
|
7
|
+
When the PreToolUse hook encounters a SKILL.md with no fingerprint or a
|
|
8
|
+
changed hash, it routes the skill through the isolation chamber.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from tweek.skills import SKILLS_DIR, get_claude_project_skills
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FINGERPRINT_PATH = SKILLS_DIR / "fingerprints.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SkillFingerprints:
|
|
24
|
+
"""
|
|
25
|
+
SHA-256 fingerprint cache for known-approved skills.
|
|
26
|
+
|
|
27
|
+
Format:
|
|
28
|
+
{
|
|
29
|
+
"schema_version": 1,
|
|
30
|
+
"skills": {
|
|
31
|
+
"/abs/path/.claude/skills/my-skill/SKILL.md": {
|
|
32
|
+
"sha256": "abc123...",
|
|
33
|
+
"scanned_at": "2026-02-01T15:30:00Z",
|
|
34
|
+
"verdict": "pass",
|
|
35
|
+
"report_path": "..."
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, cache_path: Optional[Path] = None):
|
|
42
|
+
self.cache_path = cache_path or FINGERPRINT_PATH
|
|
43
|
+
self._data = self._load()
|
|
44
|
+
|
|
45
|
+
def _load(self) -> Dict:
|
|
46
|
+
"""Load fingerprints from disk."""
|
|
47
|
+
if not self.cache_path.exists():
|
|
48
|
+
return {"schema_version": 1, "skills": {}}
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(self.cache_path.read_text())
|
|
51
|
+
if not isinstance(data, dict) or "skills" not in data:
|
|
52
|
+
return {"schema_version": 1, "skills": {}}
|
|
53
|
+
return data
|
|
54
|
+
except (json.JSONDecodeError, IOError):
|
|
55
|
+
return {"schema_version": 1, "skills": {}}
|
|
56
|
+
|
|
57
|
+
def _save(self) -> None:
|
|
58
|
+
"""Persist fingerprints to disk."""
|
|
59
|
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self.cache_path.write_text(json.dumps(self._data, indent=2))
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _hash_file(file_path: Path) -> str:
|
|
64
|
+
"""Compute SHA-256 hash of a file."""
|
|
65
|
+
h = hashlib.sha256()
|
|
66
|
+
with open(file_path, "rb") as f:
|
|
67
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
68
|
+
h.update(chunk)
|
|
69
|
+
return h.hexdigest()
|
|
70
|
+
|
|
71
|
+
def is_known(self, skill_md_path: Path) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Check if a SKILL.md file is known and unchanged.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
skill_md_path: Absolute path to a SKILL.md file
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if the file hash matches the stored fingerprint
|
|
80
|
+
"""
|
|
81
|
+
key = str(skill_md_path.resolve())
|
|
82
|
+
entry = self._data["skills"].get(key)
|
|
83
|
+
if not entry:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
current_hash = self._hash_file(skill_md_path)
|
|
88
|
+
except (IOError, OSError):
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
return entry.get("sha256") == current_hash
|
|
92
|
+
|
|
93
|
+
def register(
|
|
94
|
+
self,
|
|
95
|
+
skill_md_path: Path,
|
|
96
|
+
verdict: str = "pass",
|
|
97
|
+
report_path: Optional[str] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Register a SKILL.md file as known-approved.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
skill_md_path: Absolute path to the SKILL.md
|
|
104
|
+
verdict: The scan verdict that approved it
|
|
105
|
+
report_path: Path to the scan report
|
|
106
|
+
"""
|
|
107
|
+
key = str(skill_md_path.resolve())
|
|
108
|
+
try:
|
|
109
|
+
current_hash = self._hash_file(skill_md_path)
|
|
110
|
+
except (IOError, OSError):
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self._data["skills"][key] = {
|
|
114
|
+
"sha256": current_hash,
|
|
115
|
+
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
|
116
|
+
"verdict": verdict,
|
|
117
|
+
"report_path": report_path or "",
|
|
118
|
+
}
|
|
119
|
+
self._save()
|
|
120
|
+
|
|
121
|
+
def remove(self, skill_md_path: Path) -> None:
|
|
122
|
+
"""Remove a fingerprint entry."""
|
|
123
|
+
key = str(skill_md_path.resolve())
|
|
124
|
+
if key in self._data["skills"]:
|
|
125
|
+
del self._data["skills"][key]
|
|
126
|
+
self._save()
|
|
127
|
+
|
|
128
|
+
def check_project_skills(
|
|
129
|
+
self, working_dir: Optional[Path] = None
|
|
130
|
+
) -> List[Tuple[Path, str]]:
|
|
131
|
+
"""
|
|
132
|
+
Check all SKILL.md files in a project's .claude/skills/ directory.
|
|
133
|
+
|
|
134
|
+
Returns a list of (path, status) tuples where status is:
|
|
135
|
+
- "known": file hash matches fingerprint
|
|
136
|
+
- "new": file has no fingerprint
|
|
137
|
+
- "modified": file hash differs from fingerprint
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
working_dir: Project working directory (defaults to cwd)
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of (skill_md_path, status) tuples for unknown/modified skills
|
|
144
|
+
"""
|
|
145
|
+
skills_dir = get_claude_project_skills(working_dir)
|
|
146
|
+
if not skills_dir.exists():
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
results = []
|
|
150
|
+
for skill_md in skills_dir.rglob("SKILL.md"):
|
|
151
|
+
resolved = skill_md.resolve()
|
|
152
|
+
key = str(resolved)
|
|
153
|
+
entry = self._data["skills"].get(key)
|
|
154
|
+
|
|
155
|
+
if not entry:
|
|
156
|
+
results.append((skill_md, "new"))
|
|
157
|
+
else:
|
|
158
|
+
try:
|
|
159
|
+
current_hash = self._hash_file(skill_md)
|
|
160
|
+
if current_hash != entry.get("sha256"):
|
|
161
|
+
results.append((skill_md, "modified"))
|
|
162
|
+
# "known" skills are not returned (nothing to do)
|
|
163
|
+
except (IOError, OSError):
|
|
164
|
+
results.append((skill_md, "new"))
|
|
165
|
+
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
def cleanup_stale(self) -> int:
|
|
169
|
+
"""
|
|
170
|
+
Remove fingerprints for files that no longer exist.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Number of stale entries removed
|
|
174
|
+
"""
|
|
175
|
+
stale_keys = []
|
|
176
|
+
for key in self._data["skills"]:
|
|
177
|
+
if not Path(key).exists():
|
|
178
|
+
stale_keys.append(key)
|
|
179
|
+
|
|
180
|
+
for key in stale_keys:
|
|
181
|
+
del self._data["skills"][key]
|
|
182
|
+
|
|
183
|
+
if stale_keys:
|
|
184
|
+
self._save()
|
|
185
|
+
|
|
186
|
+
return len(stale_keys)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Module-level singleton
|
|
190
|
+
_fingerprints: Optional[SkillFingerprints] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_fingerprints() -> SkillFingerprints:
|
|
194
|
+
"""Get the singleton SkillFingerprints instance."""
|
|
195
|
+
global _fingerprints
|
|
196
|
+
if _fingerprints is None:
|
|
197
|
+
_fingerprints = SkillFingerprints()
|
|
198
|
+
return _fingerprints
|