tweek 0.2.1__py3-none-any.whl → 0.3.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.
- tweek/__init__.py +1 -1
- tweek/audit.py +2 -2
- tweek/cli.py +698 -439
- tweek/cli_helpers.py +6 -6
- tweek/cli_model.py +7 -7
- tweek/config/__init__.py +8 -0
- tweek/config/manager.py +33 -1
- tweek/config/models.py +307 -0
- tweek/config/patterns.yaml +1 -1
- tweek/diagnostics.py +59 -7
- tweek/hooks/post_tool_use.py +1 -1
- tweek/hooks/pre_tool_use.py +3 -3
- tweek/licensing.py +1 -1
- tweek/mcp/approval_cli.py +4 -4
- tweek/sandbox/linux.py +5 -5
- tweek/skill_template/SKILL.md +2 -3
- tweek/skill_template/cli-reference.md +33 -18
- tweek/skill_template/scripts/check_installed.py +4 -4
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/METADATA +22 -15
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/RECORD +25 -23
- tweek-0.3.1.dist-info/licenses/NOTICE +199 -0
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/WHEEL +0 -0
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/entry_points.txt +0 -0
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.2.1.dist-info → tweek-0.3.1.dist-info}/top_level.txt +0 -0
tweek/cli_helpers.py
CHANGED
|
@@ -32,7 +32,7 @@ def print_error(message: str, fix_hint: str = "") -> None:
|
|
|
32
32
|
"""Print an error message with red X and optional fix hint."""
|
|
33
33
|
console.print(f"[red]\u2717[/red] {message}")
|
|
34
34
|
if fix_hint:
|
|
35
|
-
console.print(f" [
|
|
35
|
+
console.print(f" [white]Hint: {fix_hint}[/white]")
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def print_health_banner(checks: "List") -> None:
|
|
@@ -51,7 +51,7 @@ def print_health_banner(checks: "List") -> None:
|
|
|
51
51
|
|
|
52
52
|
panel = Panel(
|
|
53
53
|
f"[bold {color}]{verdict_text}[/bold {color}]\n"
|
|
54
|
-
f"[
|
|
54
|
+
f"[white]Run 'tweek doctor' for details[/white]",
|
|
55
55
|
border_style=color,
|
|
56
56
|
padding=(0, 2),
|
|
57
57
|
)
|
|
@@ -63,11 +63,11 @@ def format_command_example(command: str, description: str) -> str:
|
|
|
63
63
|
Format a single command example line.
|
|
64
64
|
|
|
65
65
|
Args:
|
|
66
|
-
command: The command string, e.g., "tweek
|
|
66
|
+
command: The command string, e.g., "tweek protect claude-code --scope global"
|
|
67
67
|
description: Brief explanation of what it does.
|
|
68
68
|
|
|
69
69
|
Returns:
|
|
70
|
-
Formatted string like " tweek
|
|
70
|
+
Formatted string like " tweek protect claude-code --scope global Install globally"
|
|
71
71
|
"""
|
|
72
72
|
return f" {command:<40s} {description}"
|
|
73
73
|
|
|
@@ -141,11 +141,11 @@ def print_doctor_results(checks: "List") -> None:
|
|
|
141
141
|
CheckStatus.OK: ("[green]OK[/green] ", "green"),
|
|
142
142
|
CheckStatus.WARNING: ("[yellow]WARN[/yellow] ", "yellow"),
|
|
143
143
|
CheckStatus.ERROR: ("[red]ERROR[/red] ", "red"),
|
|
144
|
-
CheckStatus.SKIPPED: ("[
|
|
144
|
+
CheckStatus.SKIPPED: ("[white]SKIP[/white] ", "white"),
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
for check in checks:
|
|
148
|
-
style_text, _ = status_styles.get(check.status, ("[
|
|
148
|
+
style_text, _ = status_styles.get(check.status, ("[white]???[/white] ", "white"))
|
|
149
149
|
console.print(f" {style_text} {check.label:<22s} {check.message}")
|
|
150
150
|
|
|
151
151
|
# Verdict
|
tweek/cli_model.py
CHANGED
|
@@ -90,7 +90,7 @@ def model_download(name: str, force: bool):
|
|
|
90
90
|
console.print()
|
|
91
91
|
console.print(f"[green]Model downloaded to {model_dir}[/green]")
|
|
92
92
|
console.print(
|
|
93
|
-
f"[
|
|
93
|
+
f"[white]Local screening is now active for risky/dangerous operations.[/white]"
|
|
94
94
|
)
|
|
95
95
|
|
|
96
96
|
except ModelDownloadError as e:
|
|
@@ -130,8 +130,8 @@ def model_list(available: bool):
|
|
|
130
130
|
defn.display_name,
|
|
131
131
|
f"~{defn.size_mb:.0f} MB",
|
|
132
132
|
defn.license,
|
|
133
|
-
"[green]yes[/green]" if installed else "[
|
|
134
|
-
"[green]yes[/green]" if active else "[
|
|
133
|
+
"[green]yes[/green]" if installed else "[white]no[/white]",
|
|
134
|
+
"[green]yes[/green]" if active else "[white]-[/white]",
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
console.print(table)
|
|
@@ -158,7 +158,7 @@ def model_list(available: bool):
|
|
|
158
158
|
name,
|
|
159
159
|
defn.display_name if defn else name,
|
|
160
160
|
size_str,
|
|
161
|
-
"[green]yes[/green]" if active else "[
|
|
161
|
+
"[green]yes[/green]" if active else "[white]-[/white]",
|
|
162
162
|
)
|
|
163
163
|
|
|
164
164
|
console.print(table)
|
|
@@ -221,10 +221,10 @@ def model_status():
|
|
|
221
221
|
)
|
|
222
222
|
else:
|
|
223
223
|
fallback_lines.append(
|
|
224
|
-
" Cloud LLM: [
|
|
224
|
+
" Cloud LLM: [white]none (no API keys configured)[/white]"
|
|
225
225
|
)
|
|
226
226
|
except Exception:
|
|
227
|
-
fallback_lines.append(" Cloud LLM: [
|
|
227
|
+
fallback_lines.append(" Cloud LLM: [white]unavailable[/white]")
|
|
228
228
|
|
|
229
229
|
# Overall status
|
|
230
230
|
if LOCAL_MODEL_AVAILABLE and installed:
|
|
@@ -232,7 +232,7 @@ def model_status():
|
|
|
232
232
|
elif LOCAL_MODEL_AVAILABLE and not installed:
|
|
233
233
|
status = "[yellow]Ready[/yellow] - Dependencies installed, model not downloaded"
|
|
234
234
|
else:
|
|
235
|
-
status = "[
|
|
235
|
+
status = "[white]Inactive[/white] - Install dependencies: pip install tweek[local-models]"
|
|
236
236
|
|
|
237
237
|
content = f"Status: {status}\n\n"
|
|
238
238
|
content += "[bold]Dependencies[/bold]\n" + "\n".join(deps_lines) + "\n\n"
|
tweek/config/__init__.py
CHANGED
|
@@ -10,4 +10,12 @@ PATTERNS_FILE = CONFIG_DIR / "patterns.yaml"
|
|
|
10
10
|
__all__ = [
|
|
11
11
|
"ConfigManager", "SecurityTier", "ConfigIssue", "ConfigChange",
|
|
12
12
|
"get_config", "CONFIG_DIR", "PATTERNS_FILE",
|
|
13
|
+
"TweekConfig", "PatternsConfig",
|
|
13
14
|
]
|
|
15
|
+
|
|
16
|
+
# Lazy imports for Pydantic models to avoid import cost when not needed
|
|
17
|
+
def __getattr__(name):
|
|
18
|
+
if name in ("TweekConfig", "PatternsConfig"):
|
|
19
|
+
from tweek.config.models import TweekConfig, PatternsConfig
|
|
20
|
+
return {"TweekConfig": TweekConfig, "PatternsConfig": PatternsConfig}[name]
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
tweek/config/manager.py
CHANGED
|
@@ -173,7 +173,7 @@ class ConfigManager:
|
|
|
173
173
|
"plugins", "mcp", "proxy", "sandbox", "isolation_chamber",
|
|
174
174
|
"llm_review", "local_model", "rate_limiting", "session_analysis",
|
|
175
175
|
"path_boundary", "non_english_handling", "version", "tiers",
|
|
176
|
-
"heuristic_scorer",
|
|
176
|
+
"heuristic_scorer", "openclaw",
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
def __init__(
|
|
@@ -685,8 +685,40 @@ class ConfigManager:
|
|
|
685
685
|
suggestion=suggestion,
|
|
686
686
|
))
|
|
687
687
|
|
|
688
|
+
# Run Pydantic structural validation on merged config
|
|
689
|
+
try:
|
|
690
|
+
merged = self._get_merged()
|
|
691
|
+
pydantic_issues = self._validate_with_pydantic(merged)
|
|
692
|
+
# Deduplicate: only add Pydantic issues not already caught above
|
|
693
|
+
existing_messages = {i.message for i in issues}
|
|
694
|
+
for pi in pydantic_issues:
|
|
695
|
+
if pi.message not in existing_messages:
|
|
696
|
+
issues.append(pi)
|
|
697
|
+
except Exception:
|
|
698
|
+
pass # Pydantic validation is additive, never blocks
|
|
699
|
+
|
|
688
700
|
return issues
|
|
689
701
|
|
|
702
|
+
def _validate_with_pydantic(self, config: Dict) -> List[ConfigIssue]:
|
|
703
|
+
"""Run Pydantic model validation on merged config."""
|
|
704
|
+
from pydantic import ValidationError
|
|
705
|
+
from tweek.config.models import TweekConfig
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
TweekConfig.model_validate(config)
|
|
709
|
+
return []
|
|
710
|
+
except ValidationError as e:
|
|
711
|
+
issues = []
|
|
712
|
+
for err in e.errors():
|
|
713
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
714
|
+
issues.append(ConfigIssue(
|
|
715
|
+
level="error",
|
|
716
|
+
key=loc,
|
|
717
|
+
message=err["msg"],
|
|
718
|
+
suggestion="",
|
|
719
|
+
))
|
|
720
|
+
return issues
|
|
721
|
+
|
|
690
722
|
def diff_preset(self, preset_name: str) -> List[ConfigChange]:
|
|
691
723
|
"""
|
|
692
724
|
Show what would change if a preset were applied.
|
tweek/config/models.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models for Tweek configuration validation.
|
|
3
|
+
|
|
4
|
+
These models define the schema for tiers.yaml (the main configuration file)
|
|
5
|
+
and patterns.yaml (attack pattern definitions). They provide:
|
|
6
|
+
- Type-safe configuration loading with automatic validation
|
|
7
|
+
- Human-readable error messages for invalid configuration
|
|
8
|
+
- JSON Schema export for documentation and IDE support
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ============================================================================
|
|
21
|
+
# Enums
|
|
22
|
+
# ============================================================================
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityTierValue(str, Enum):
|
|
26
|
+
"""Valid security tier values."""
|
|
27
|
+
SAFE = "safe"
|
|
28
|
+
DEFAULT = "default"
|
|
29
|
+
RISKY = "risky"
|
|
30
|
+
DANGEROUS = "dangerous"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NonEnglishHandling(str, Enum):
|
|
34
|
+
"""How non-English content is handled during screening."""
|
|
35
|
+
ESCALATE = "escalate"
|
|
36
|
+
TRANSLATE = "translate"
|
|
37
|
+
BOTH = "both"
|
|
38
|
+
NONE = "none"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PatternSeverity(str, Enum):
|
|
42
|
+
"""Severity levels for attack patterns."""
|
|
43
|
+
CRITICAL = "critical"
|
|
44
|
+
HIGH = "high"
|
|
45
|
+
MEDIUM = "medium"
|
|
46
|
+
LOW = "low"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PatternConfidence(str, Enum):
|
|
50
|
+
"""Confidence levels for attack pattern matches."""
|
|
51
|
+
DETERMINISTIC = "deterministic"
|
|
52
|
+
HEURISTIC = "heuristic"
|
|
53
|
+
CONTEXTUAL = "contextual"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ============================================================================
|
|
57
|
+
# Configuration Section Models
|
|
58
|
+
# ============================================================================
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LLMReviewLocalConfig(BaseModel):
|
|
62
|
+
"""Configuration for local LLM server (Ollama, LM Studio)."""
|
|
63
|
+
enabled: bool = True
|
|
64
|
+
probe_timeout: float = Field(default=2.0, gt=0)
|
|
65
|
+
timeout_seconds: float = Field(default=30.0, gt=0)
|
|
66
|
+
ollama_host: Optional[str] = None
|
|
67
|
+
lm_studio_host: Optional[str] = None
|
|
68
|
+
preferred_models: List[str] = Field(default_factory=list)
|
|
69
|
+
validate_on_first_use: bool = True
|
|
70
|
+
min_validation_score: float = Field(default=0.6, ge=0.0, le=1.0)
|
|
71
|
+
|
|
72
|
+
model_config = {"extra": "allow"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LLMReviewFallbackConfig(BaseModel):
|
|
76
|
+
"""Configuration for LLM fallback chain."""
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
order: List[str] = Field(default_factory=lambda: ["local", "anthropic", "openai"])
|
|
79
|
+
|
|
80
|
+
model_config = {"extra": "allow"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LLMReviewConfig(BaseModel):
|
|
84
|
+
"""Configuration for LLM-based semantic review."""
|
|
85
|
+
enabled: bool = True
|
|
86
|
+
provider: str = "auto"
|
|
87
|
+
model: str = "auto"
|
|
88
|
+
base_url: Optional[str] = None
|
|
89
|
+
api_key_env: Optional[str] = None
|
|
90
|
+
timeout_seconds: float = Field(default=15.0, gt=0)
|
|
91
|
+
local: LLMReviewLocalConfig = Field(default_factory=LLMReviewLocalConfig)
|
|
92
|
+
fallback: LLMReviewFallbackConfig = Field(default_factory=LLMReviewFallbackConfig)
|
|
93
|
+
|
|
94
|
+
model_config = {"extra": "allow"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RateLimitingConfig(BaseModel):
|
|
98
|
+
"""Configuration for request rate limiting."""
|
|
99
|
+
enabled: bool = True
|
|
100
|
+
burst_window_seconds: int = Field(default=10, gt=0)
|
|
101
|
+
burst_threshold: int = Field(default=5, gt=0)
|
|
102
|
+
max_per_minute: int = Field(default=60, gt=0)
|
|
103
|
+
max_dangerous_per_minute: int = Field(default=10, gt=0)
|
|
104
|
+
max_same_command_per_minute: int = Field(default=5, gt=0)
|
|
105
|
+
|
|
106
|
+
model_config = {"extra": "allow"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SessionAnalysisConfig(BaseModel):
|
|
110
|
+
"""Configuration for session-level analysis."""
|
|
111
|
+
enabled: bool = True
|
|
112
|
+
lookback_minutes: int = Field(default=30, gt=0)
|
|
113
|
+
alert_on_risk_score: float = Field(default=0.7, ge=0.0, le=1.0)
|
|
114
|
+
|
|
115
|
+
model_config = {"extra": "allow"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class HeuristicScorerConfig(BaseModel):
|
|
119
|
+
"""Configuration for heuristic scoring bridge."""
|
|
120
|
+
enabled: bool = True
|
|
121
|
+
threshold: float = Field(default=0.4, ge=0.0, le=1.0)
|
|
122
|
+
log_all_scores: bool = False
|
|
123
|
+
|
|
124
|
+
model_config = {"extra": "allow"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LocalModelConfig(BaseModel):
|
|
128
|
+
"""Configuration for local ONNX model inference."""
|
|
129
|
+
enabled: bool = True
|
|
130
|
+
model: str = "auto"
|
|
131
|
+
escalate_to_llm: bool = True
|
|
132
|
+
escalate_min_confidence: float = Field(default=0.1, ge=0.0, le=1.0)
|
|
133
|
+
escalate_max_confidence: float = Field(default=0.9, ge=0.0, le=1.0)
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def check_escalation_bounds(self) -> "LocalModelConfig":
|
|
137
|
+
if self.escalate_min_confidence >= self.escalate_max_confidence:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"escalate_min_confidence ({self.escalate_min_confidence}) "
|
|
140
|
+
f"must be less than escalate_max_confidence ({self.escalate_max_confidence})"
|
|
141
|
+
)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
model_config = {"extra": "allow"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TierDefinition(BaseModel):
|
|
148
|
+
"""Definition of a security tier."""
|
|
149
|
+
description: str
|
|
150
|
+
screening: List[str] = Field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class EscalationRule(BaseModel):
|
|
154
|
+
"""A content-based escalation rule."""
|
|
155
|
+
pattern: str
|
|
156
|
+
description: str
|
|
157
|
+
escalate_to: SecurityTierValue
|
|
158
|
+
|
|
159
|
+
@field_validator("pattern")
|
|
160
|
+
@classmethod
|
|
161
|
+
def validate_regex(cls, v: str) -> str:
|
|
162
|
+
try:
|
|
163
|
+
re.compile(v)
|
|
164
|
+
except re.error as e:
|
|
165
|
+
raise ValueError(f"Invalid regex pattern: {e}") from e
|
|
166
|
+
return v
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SensitiveDirectory(BaseModel):
|
|
170
|
+
"""A sensitive directory that triggers path boundary escalation."""
|
|
171
|
+
pattern: str
|
|
172
|
+
escalate_to: SecurityTierValue
|
|
173
|
+
description: str = ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PathBoundaryConfig(BaseModel):
|
|
177
|
+
"""Configuration for path boundary escalation."""
|
|
178
|
+
enabled: bool = True
|
|
179
|
+
default_escalate_to: SecurityTierValue = SecurityTierValue.RISKY
|
|
180
|
+
sensitive_directories: List[SensitiveDirectory] = Field(default_factory=list)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class OpenClawConfig(BaseModel):
|
|
184
|
+
"""Configuration for OpenClaw integration."""
|
|
185
|
+
enabled: bool = False
|
|
186
|
+
gateway_port: int = Field(default=18789, gt=0, le=65535)
|
|
187
|
+
scanner_port: int = Field(default=9878, gt=0, le=65535)
|
|
188
|
+
plugin_installed: bool = False
|
|
189
|
+
preset: str = "cautious"
|
|
190
|
+
|
|
191
|
+
model_config = {"extra": "allow"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ============================================================================
|
|
195
|
+
# Root Configuration Model
|
|
196
|
+
# ============================================================================
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TweekConfig(BaseModel):
|
|
200
|
+
"""
|
|
201
|
+
Root Pydantic model for Tweek configuration (tiers.yaml / config.yaml).
|
|
202
|
+
|
|
203
|
+
Validates the merged configuration from builtin, user, and project layers.
|
|
204
|
+
Uses extra="allow" at the root level to be forward-compatible with new
|
|
205
|
+
config keys added in future versions.
|
|
206
|
+
"""
|
|
207
|
+
version: Optional[int] = None
|
|
208
|
+
|
|
209
|
+
# Core tool/skill classification
|
|
210
|
+
tools: Dict[str, SecurityTierValue] = Field(default_factory=dict)
|
|
211
|
+
skills: Dict[str, SecurityTierValue] = Field(default_factory=dict)
|
|
212
|
+
default_tier: SecurityTierValue = SecurityTierValue.DEFAULT
|
|
213
|
+
|
|
214
|
+
# Tier definitions
|
|
215
|
+
tiers: Dict[str, TierDefinition] = Field(default_factory=dict)
|
|
216
|
+
|
|
217
|
+
# Screening configuration
|
|
218
|
+
llm_review: Optional[LLMReviewConfig] = None
|
|
219
|
+
rate_limiting: Optional[RateLimitingConfig] = None
|
|
220
|
+
session_analysis: Optional[SessionAnalysisConfig] = None
|
|
221
|
+
heuristic_scorer: Optional[HeuristicScorerConfig] = None
|
|
222
|
+
local_model: Optional[LocalModelConfig] = None
|
|
223
|
+
|
|
224
|
+
# Escalation rules
|
|
225
|
+
escalations: List[EscalationRule] = Field(default_factory=list)
|
|
226
|
+
|
|
227
|
+
# Path boundary
|
|
228
|
+
path_boundary: Optional[PathBoundaryConfig] = None
|
|
229
|
+
|
|
230
|
+
# Non-English handling
|
|
231
|
+
non_english_handling: NonEnglishHandling = NonEnglishHandling.ESCALATE
|
|
232
|
+
|
|
233
|
+
# Integration configs
|
|
234
|
+
proxy: Optional[Dict[str, Any]] = None
|
|
235
|
+
mcp: Optional[Dict[str, Any]] = None
|
|
236
|
+
sandbox: Optional[Dict[str, Any]] = None
|
|
237
|
+
isolation_chamber: Optional[Dict[str, Any]] = None
|
|
238
|
+
plugins: Optional[Dict[str, Any]] = None
|
|
239
|
+
openclaw: Optional[OpenClawConfig] = None
|
|
240
|
+
|
|
241
|
+
model_config = {"extra": "allow"}
|
|
242
|
+
|
|
243
|
+
@field_validator("tools", mode="before")
|
|
244
|
+
@classmethod
|
|
245
|
+
def coerce_tool_tiers(cls, v: Any) -> Any:
|
|
246
|
+
"""Accept string tier values and coerce to SecurityTierValue."""
|
|
247
|
+
if isinstance(v, dict):
|
|
248
|
+
return {k: SecurityTierValue(val) if isinstance(val, str) else val for k, val in v.items()}
|
|
249
|
+
return v
|
|
250
|
+
|
|
251
|
+
@field_validator("skills", mode="before")
|
|
252
|
+
@classmethod
|
|
253
|
+
def coerce_skill_tiers(cls, v: Any) -> Any:
|
|
254
|
+
"""Accept string tier values and coerce to SecurityTierValue."""
|
|
255
|
+
if isinstance(v, dict):
|
|
256
|
+
return {k: SecurityTierValue(val) if isinstance(val, str) else val for k, val in v.items()}
|
|
257
|
+
return v
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ============================================================================
|
|
261
|
+
# Pattern Schema Model
|
|
262
|
+
# ============================================================================
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class PatternDefinition(BaseModel):
|
|
266
|
+
"""A single attack pattern definition from patterns.yaml."""
|
|
267
|
+
id: int
|
|
268
|
+
name: str
|
|
269
|
+
description: str
|
|
270
|
+
regex: str
|
|
271
|
+
severity: PatternSeverity
|
|
272
|
+
confidence: PatternConfidence
|
|
273
|
+
family: Optional[str] = None
|
|
274
|
+
|
|
275
|
+
@field_validator("regex")
|
|
276
|
+
@classmethod
|
|
277
|
+
def validate_regex(cls, v: str) -> str:
|
|
278
|
+
try:
|
|
279
|
+
re.compile(v)
|
|
280
|
+
except re.error as e:
|
|
281
|
+
raise ValueError(f"Invalid regex in pattern: {e}") from e
|
|
282
|
+
return v
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class PatternsConfig(BaseModel):
|
|
286
|
+
"""Root model for patterns.yaml."""
|
|
287
|
+
version: int
|
|
288
|
+
pattern_count: int = 0
|
|
289
|
+
patterns: List[PatternDefinition] = Field(default_factory=list)
|
|
290
|
+
|
|
291
|
+
@model_validator(mode="after")
|
|
292
|
+
def check_pattern_count(self) -> "PatternsConfig":
|
|
293
|
+
actual = len(self.patterns)
|
|
294
|
+
if self.pattern_count > 0 and actual != self.pattern_count:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"pattern_count ({self.pattern_count}) does not match "
|
|
297
|
+
f"actual number of patterns ({actual})"
|
|
298
|
+
)
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
@model_validator(mode="after")
|
|
302
|
+
def check_unique_ids(self) -> "PatternsConfig":
|
|
303
|
+
ids = [p.id for p in self.patterns]
|
|
304
|
+
if len(ids) != len(set(ids)):
|
|
305
|
+
dupes = [i for i in ids if ids.count(i) > 1]
|
|
306
|
+
raise ValueError(f"Duplicate pattern IDs: {set(dupes)}")
|
|
307
|
+
return self
|
tweek/config/patterns.yaml
CHANGED
tweek/diagnostics.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Tweek Diagnostics Engine
|
|
4
4
|
|
|
5
5
|
Health check system for verifying Tweek installation, configuration,
|
|
6
|
-
and runtime dependencies. Used by `tweek doctor` and the
|
|
6
|
+
and runtime dependencies. Used by `tweek doctor` and the health banner.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import json
|
|
@@ -30,7 +30,7 @@ class HealthCheck:
|
|
|
30
30
|
label: str # Human label: "Hook Installation"
|
|
31
31
|
status: CheckStatus
|
|
32
32
|
message: str # Description: "Global hooks installed at ~/.claude/"
|
|
33
|
-
fix_hint: str = "" # Recovery: "Run: tweek
|
|
33
|
+
fix_hint: str = "" # Recovery: "Run: tweek protect claude-code --global"
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def run_health_checks(verbose: bool = False) -> List[HealthCheck]:
|
|
@@ -168,7 +168,7 @@ def _check_hooks_installed(verbose: bool = False) -> HealthCheck:
|
|
|
168
168
|
label="Hook Installation",
|
|
169
169
|
status=CheckStatus.WARNING,
|
|
170
170
|
message="Installed in project only (./.claude)",
|
|
171
|
-
fix_hint="Run: tweek
|
|
171
|
+
fix_hint="Run: tweek protect claude-code --global (to protect all projects)",
|
|
172
172
|
)
|
|
173
173
|
else:
|
|
174
174
|
return HealthCheck(
|
|
@@ -176,7 +176,7 @@ def _check_hooks_installed(verbose: bool = False) -> HealthCheck:
|
|
|
176
176
|
label="Hook Installation",
|
|
177
177
|
status=CheckStatus.ERROR,
|
|
178
178
|
message="No hooks installed",
|
|
179
|
-
fix_hint="Run: tweek
|
|
179
|
+
fix_hint="Run: tweek protect claude-code",
|
|
180
180
|
)
|
|
181
181
|
|
|
182
182
|
|
|
@@ -597,18 +597,53 @@ def _check_llm_review(verbose: bool = False) -> HealthCheck:
|
|
|
597
597
|
get_llm_reviewer,
|
|
598
598
|
_detect_local_server,
|
|
599
599
|
FallbackReviewProvider,
|
|
600
|
+
DEFAULT_API_KEY_ENVS,
|
|
600
601
|
)
|
|
602
|
+
from tweek.security.local_model import LOCAL_MODEL_AVAILABLE
|
|
601
603
|
|
|
602
604
|
reviewer = get_llm_reviewer()
|
|
603
605
|
|
|
604
606
|
if not reviewer.enabled:
|
|
607
|
+
# Check which env vars are missing to give specific guidance
|
|
608
|
+
missing_keys = []
|
|
609
|
+
for provider, env_names in DEFAULT_API_KEY_ENVS.items():
|
|
610
|
+
if isinstance(env_names, list):
|
|
611
|
+
if not any(os.environ.get(e) for e in env_names):
|
|
612
|
+
missing_keys.append(f"{' or '.join(env_names)} ({provider})")
|
|
613
|
+
else:
|
|
614
|
+
if not os.environ.get(env_names):
|
|
615
|
+
missing_keys.append(f"{env_names} ({provider})")
|
|
616
|
+
|
|
617
|
+
hint_parts = [
|
|
618
|
+
"To enable cloud LLM review for uncertain classifications:",
|
|
619
|
+
" Set one of: " + ", ".join(
|
|
620
|
+
k.split(" (")[0] for k in missing_keys
|
|
621
|
+
),
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
if not LOCAL_MODEL_AVAILABLE:
|
|
625
|
+
hint_parts.append(
|
|
626
|
+
" Or install the local model: pip install 'tweek[local]'"
|
|
627
|
+
)
|
|
628
|
+
else:
|
|
629
|
+
hint_parts.append(
|
|
630
|
+
" Local ONNX model is available but could not initialize"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
hint_parts.append(
|
|
634
|
+
" Or install Ollama (https://ollama.ai) for local LLM review"
|
|
635
|
+
)
|
|
636
|
+
hint_parts.append(
|
|
637
|
+
" See: docs/CONFIGURATION.md or ~/.tweek/config.yaml"
|
|
638
|
+
)
|
|
639
|
+
|
|
605
640
|
return HealthCheck(
|
|
606
641
|
name="llm_review",
|
|
607
642
|
label="LLM Review",
|
|
608
643
|
status=CheckStatus.WARNING,
|
|
609
|
-
message="No LLM provider available
|
|
610
|
-
|
|
611
|
-
|
|
644
|
+
message="No LLM provider available — review disabled, "
|
|
645
|
+
"pattern matching and heuristic scoring still active",
|
|
646
|
+
fix_hint="\n".join(hint_parts),
|
|
612
647
|
)
|
|
613
648
|
|
|
614
649
|
# Build status message
|
|
@@ -636,6 +671,23 @@ def _check_llm_review(verbose: bool = False) -> HealthCheck:
|
|
|
636
671
|
except Exception:
|
|
637
672
|
pass
|
|
638
673
|
|
|
674
|
+
# In verbose mode, report escalation path
|
|
675
|
+
if verbose:
|
|
676
|
+
if provider_name == "local":
|
|
677
|
+
# Check if cloud escalation is available
|
|
678
|
+
cloud_env_found = any(
|
|
679
|
+
os.environ.get(e) if isinstance(e, str)
|
|
680
|
+
else any(os.environ.get(v) for v in e)
|
|
681
|
+
for e in DEFAULT_API_KEY_ENVS.values()
|
|
682
|
+
)
|
|
683
|
+
if cloud_env_found:
|
|
684
|
+
parts.append("cloud escalation: available")
|
|
685
|
+
else:
|
|
686
|
+
parts.append("cloud escalation: not configured")
|
|
687
|
+
|
|
688
|
+
if LOCAL_MODEL_AVAILABLE:
|
|
689
|
+
parts.append("local ONNX: available")
|
|
690
|
+
|
|
639
691
|
return HealthCheck(
|
|
640
692
|
name="llm_review",
|
|
641
693
|
label="LLM Review",
|
tweek/hooks/post_tool_use.py
CHANGED
|
@@ -11,7 +11,7 @@ web pages, documents, and other ingested content.
|
|
|
11
11
|
|
|
12
12
|
Screening Pipeline:
|
|
13
13
|
1. Language Detection — identify non-English content
|
|
14
|
-
2. Pattern Matching —
|
|
14
|
+
2. Pattern Matching — 259 regex patterns for known attack vectors
|
|
15
15
|
3. LLM Review — semantic analysis if non-English escalation triggers
|
|
16
16
|
|
|
17
17
|
Claude Code PostToolUse Protocol:
|
tweek/hooks/pre_tool_use.py
CHANGED
|
@@ -925,10 +925,10 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
925
925
|
}
|
|
926
926
|
}
|
|
927
927
|
|
|
928
|
-
# Block AI from running tweek trust/untrust/uninstall — human-only commands
|
|
928
|
+
# Block AI from running tweek trust/untrust/uninstall/unprotect — human-only commands
|
|
929
929
|
command_stripped = command.strip()
|
|
930
|
-
if re.match(r"tweek\s+(trust|untrust|uninstall)\b", command_stripped):
|
|
931
|
-
if "uninstall" in command_stripped:
|
|
930
|
+
if re.match(r"tweek\s+(trust|untrust|uninstall|unprotect)\b", command_stripped):
|
|
931
|
+
if "uninstall" in command_stripped or "unprotect" in command_stripped:
|
|
932
932
|
reason = (
|
|
933
933
|
"TWEEK SELF-PROTECTION: Uninstall must be done by a human.\n"
|
|
934
934
|
"Run this command directly in your terminal:\n"
|
tweek/licensing.py
CHANGED
|
@@ -74,7 +74,7 @@ class LicenseInfo:
|
|
|
74
74
|
# Only compliance and team management features require a license.
|
|
75
75
|
TIER_FEATURES = {
|
|
76
76
|
Tier.FREE: [
|
|
77
|
-
"pattern_matching", # All
|
|
77
|
+
"pattern_matching", # All 259 patterns included free
|
|
78
78
|
"basic_logging",
|
|
79
79
|
"vault_storage",
|
|
80
80
|
"cli_commands",
|
tweek/mcp/approval_cli.py
CHANGED
|
@@ -39,7 +39,7 @@ def display_pending(queue: ApprovalQueue) -> int:
|
|
|
39
39
|
pending = queue.get_pending()
|
|
40
40
|
|
|
41
41
|
if not pending:
|
|
42
|
-
console.print("[
|
|
42
|
+
console.print("[white]No pending approval requests.[/white]")
|
|
43
43
|
return 0
|
|
44
44
|
|
|
45
45
|
table = Table(title="Pending Approval Requests", show_lines=True)
|
|
@@ -112,7 +112,7 @@ def display_request_detail(request: ApprovalRequest):
|
|
|
112
112
|
remaining = request.time_remaining
|
|
113
113
|
if remaining > 0:
|
|
114
114
|
lines.append("")
|
|
115
|
-
lines.append(f"[
|
|
115
|
+
lines.append(f"[white]Auto-deny in {int(remaining)}s[/white]")
|
|
116
116
|
|
|
117
117
|
panel = Panel(
|
|
118
118
|
"\n".join(lines),
|
|
@@ -160,7 +160,7 @@ def run_approval_daemon(
|
|
|
160
160
|
# Prompt for decision
|
|
161
161
|
decision = _prompt_decision(console, req)
|
|
162
162
|
if decision == "quit":
|
|
163
|
-
console.print("[
|
|
163
|
+
console.print("[white]Exiting approval daemon.[/white]")
|
|
164
164
|
return
|
|
165
165
|
elif decision == "skip":
|
|
166
166
|
continue
|
|
@@ -200,7 +200,7 @@ def run_approval_daemon(
|
|
|
200
200
|
time.sleep(poll_interval)
|
|
201
201
|
|
|
202
202
|
except KeyboardInterrupt:
|
|
203
|
-
console.print("\n[
|
|
203
|
+
console.print("\n[white]Approval daemon stopped.[/white]")
|
|
204
204
|
|
|
205
205
|
# Print summary
|
|
206
206
|
stats = queue.get_stats()
|