tweek 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5303 -2396
  5. tweek/cli_model.py +380 -0
  6. tweek/config/families.yaml +609 -0
  7. tweek/config/manager.py +42 -5
  8. tweek/config/patterns.yaml +1510 -8
  9. tweek/config/tiers.yaml +161 -11
  10. tweek/diagnostics.py +71 -2
  11. tweek/hooks/break_glass.py +163 -0
  12. tweek/hooks/feedback.py +223 -0
  13. tweek/hooks/overrides.py +531 -0
  14. tweek/hooks/post_tool_use.py +472 -0
  15. tweek/hooks/pre_tool_use.py +1024 -62
  16. tweek/integrations/openclaw.py +443 -0
  17. tweek/integrations/openclaw_server.py +385 -0
  18. tweek/licensing.py +14 -54
  19. tweek/logging/bundle.py +2 -2
  20. tweek/logging/security_log.py +56 -13
  21. tweek/mcp/approval.py +57 -16
  22. tweek/mcp/proxy.py +18 -0
  23. tweek/mcp/screening.py +5 -5
  24. tweek/mcp/server.py +4 -1
  25. tweek/memory/__init__.py +24 -0
  26. tweek/memory/queries.py +223 -0
  27. tweek/memory/safety.py +140 -0
  28. tweek/memory/schemas.py +80 -0
  29. tweek/memory/store.py +989 -0
  30. tweek/platform/__init__.py +4 -4
  31. tweek/plugins/__init__.py +40 -24
  32. tweek/plugins/base.py +1 -1
  33. tweek/plugins/detectors/__init__.py +3 -3
  34. tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
  35. tweek/plugins/git_discovery.py +16 -4
  36. tweek/plugins/git_registry.py +8 -2
  37. tweek/plugins/git_security.py +21 -9
  38. tweek/plugins/screening/__init__.py +10 -1
  39. tweek/plugins/screening/heuristic_scorer.py +477 -0
  40. tweek/plugins/screening/llm_reviewer.py +14 -6
  41. tweek/plugins/screening/local_model_reviewer.py +161 -0
  42. tweek/proxy/__init__.py +38 -37
  43. tweek/proxy/addon.py +22 -3
  44. tweek/proxy/interceptor.py +1 -0
  45. tweek/proxy/server.py +4 -2
  46. tweek/sandbox/__init__.py +11 -0
  47. tweek/sandbox/docker_bridge.py +143 -0
  48. tweek/sandbox/executor.py +9 -6
  49. tweek/sandbox/layers.py +97 -0
  50. tweek/sandbox/linux.py +1 -0
  51. tweek/sandbox/project.py +548 -0
  52. tweek/sandbox/registry.py +149 -0
  53. tweek/security/__init__.py +9 -0
  54. tweek/security/language.py +250 -0
  55. tweek/security/llm_reviewer.py +1146 -60
  56. tweek/security/local_model.py +331 -0
  57. tweek/security/local_reviewer.py +146 -0
  58. tweek/security/model_registry.py +371 -0
  59. tweek/security/rate_limiter.py +11 -6
  60. tweek/security/secret_scanner.py +70 -4
  61. tweek/security/session_analyzer.py +26 -2
  62. tweek/skill_template/SKILL.md +200 -0
  63. tweek/skill_template/__init__.py +0 -0
  64. tweek/skill_template/cli-reference.md +331 -0
  65. tweek/skill_template/overrides-reference.md +184 -0
  66. tweek/skill_template/scripts/__init__.py +0 -0
  67. tweek/skill_template/scripts/check_installed.py +170 -0
  68. tweek/skills/__init__.py +38 -0
  69. tweek/skills/config.py +150 -0
  70. tweek/skills/fingerprints.py +198 -0
  71. tweek/skills/guard.py +293 -0
  72. tweek/skills/isolation.py +469 -0
  73. tweek/skills/scanner.py +715 -0
  74. tweek/vault/__init__.py +0 -1
  75. tweek/vault/cross_platform.py +12 -1
  76. tweek/vault/keychain.py +87 -29
  77. tweek-0.2.0.dist-info/METADATA +281 -0
  78. tweek-0.2.0.dist-info/RECORD +121 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
  81. tweek/integrations/moltbot.py +0 -243
  82. tweek-0.1.0.dist-info/METADATA +0 -335
  83. tweek-0.1.0.dist-info/RECORD +0 -85
  84. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
  85. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
tweek/config/tiers.yaml CHANGED
@@ -8,21 +8,37 @@
8
8
  # dangerous - Regex + LLM + Sandbox preview
9
9
  #
10
10
  # Security Layers:
11
- # 1. Rate Limiting - Detect resource theft and burst attacks
12
- # 2. Pattern Match - Regex patterns for known attack vectors (100+ patterns)
13
- # 3. LLM Review - Semantic analysis using Claude Haiku
14
- # 4. Session Scan - Cross-turn anomaly detection
15
- # 5. Sandbox - Speculative execution in isolated environment
11
+ # 1. Rate Limiting - Detect resource theft and burst attacks
12
+ # 2. Pattern Match - Regex patterns for known attack vectors (259 patterns)
13
+ # 2.5 Heuristic Score - Signal-based scoring for confidence-gated LLM escalation
14
+ # 3. LLM Review - Semantic analysis using Claude Haiku or local LLM
15
+ # 4. Session Scan - Cross-turn anomaly detection
16
+ # 5. Sandbox - Speculative execution in isolated environment
16
17
 
17
18
  version: 2
18
19
 
19
20
  # LLM Review Configuration
21
+ # Supports: anthropic, openai, google, or any OpenAI-compatible endpoint
22
+ # Provider "auto" checks: local (if enabled) → ANTHROPIC_API_KEY → OPENAI_API_KEY → GOOGLE_API_KEY
20
23
  llm_review:
21
24
  enabled: true
22
- model: claude-3-5-haiku-latest
25
+ provider: auto # auto | local | anthropic | openai | google | fallback
26
+ model: auto # auto = provider default, or explicit model name
27
+ base_url: null # For OpenAI-compatible endpoints (Ollama, LM Studio, etc.)
28
+ api_key_env: null # Override env var name (default: provider-specific)
23
29
  timeout_seconds: 5.0
24
- # Only review if command length exceeds this
25
- min_command_length: 20
30
+ local:
31
+ enabled: true # Probe for Ollama/LM Studio on startup
32
+ probe_timeout: 0.5 # Max probe time per server (seconds)
33
+ timeout_seconds: 3.0 # Per-request timeout (local should be fast)
34
+ ollama_host: null # Override (default: OLLAMA_HOST env or localhost:11434)
35
+ lm_studio_host: null # Override (default: localhost:1234)
36
+ preferred_models: [] # User override for model ranking
37
+ validate_on_first_use: true
38
+ min_validation_score: 0.6 # Must pass 3/5 validation commands
39
+ fallback:
40
+ enabled: true # Enable fallback chain (try local, then cloud)
41
+ order: [local, cloud] # Priority order for provider attempts
26
42
 
27
43
  # Rate Limiting Configuration
28
44
  rate_limiting:
@@ -39,6 +55,28 @@ session_analysis:
39
55
  lookback_minutes: 30
40
56
  alert_on_risk_score: 0.5
41
57
 
58
+ # Heuristic Scorer Configuration (Layer 2.5)
59
+ # Bridges the gap between regex patterns and LLM review.
60
+ # Runs ONLY when: no regex match AND LLM not already scheduled.
61
+ # Scores content using signal-based heuristics from pattern families.
62
+ # If score exceeds threshold, escalates to LLM review.
63
+ heuristic_scorer:
64
+ enabled: true
65
+ threshold: 0.4 # Score [0.0-1.0] to trigger LLM escalation
66
+ log_all_scores: false # Log below-threshold scores (for tuning)
67
+
68
+ # Local ONNX Model Configuration
69
+ # On-device prompt injection classifier — no API key needed.
70
+ # Install with: pip install tweek[local-models] && tweek model download
71
+ # When installed, takes priority over cloud LLM in auto-detection.
72
+ # Cloud LLM becomes the escalation fallback for uncertain results.
73
+ local_model:
74
+ enabled: true
75
+ model: auto # auto = configured default, or explicit model name
76
+ escalate_to_llm: true # Escalate uncertain results to cloud LLM
77
+ escalate_min_confidence: 0.1 # Below this = definitely safe, no escalation
78
+ escalate_max_confidence: 0.9 # Above this = use local result, no escalation
79
+
42
80
  tiers:
43
81
  safe:
44
82
  description: "Trusted operations - no screening"
@@ -64,14 +102,14 @@ tiers:
64
102
 
65
103
  # Tool classifications
66
104
  tools:
67
- # Safe - read-only, no external effects
68
- Read: safe
105
+ # Default - read-only but needs path-based screening
106
+ Read: default
69
107
  Glob: safe
70
108
  Grep: safe
71
109
 
72
110
  # Default - modifications with standard screening
73
111
  Edit: default
74
- Write: default
112
+ Write: risky
75
113
  NotebookEdit: default
76
114
 
77
115
  # Risky - external communication or significant changes
@@ -98,6 +136,17 @@ skills:
98
136
  # Dangerous skills (system-level)
99
137
  deploy: dangerous
100
138
 
139
+ # Non-English Language Detection
140
+ # English-only regex patterns (~40 of 116) cannot match prompt injection in
141
+ # other languages. This setting controls how non-English content is handled.
142
+ #
143
+ # Options:
144
+ # escalate - Auto-escalate to LLM review tier (default, recommended)
145
+ # translate - Translate to English before pattern matching (requires API key)
146
+ # both - Escalate AND translate (maximum coverage)
147
+ # none - No special handling (English-only patterns may miss attacks)
148
+ non_english_handling: escalate
149
+
101
150
  # Content-based escalations
102
151
  # These patterns can escalate a tool's tier based on content
103
152
  escalations:
@@ -125,5 +174,106 @@ escalations:
125
174
  description: "Elevated privileges"
126
175
  escalate_to: dangerous
127
176
 
177
+ # Path-only patterns for Read/Write/Edit (no command prefix required)
178
+ - pattern: '\.ssh/(id_rsa|id_ed25519|id_ecdsa|authorized_keys|known_hosts|config)'
179
+ description: "SSH credential file access"
180
+ escalate_to: dangerous
181
+
182
+ - pattern: '\.aws/(credentials|config)'
183
+ description: "AWS credential file access"
184
+ escalate_to: dangerous
185
+
186
+ - pattern: '\.env(\.|$)'
187
+ description: "Environment file with potential secrets"
188
+ escalate_to: risky
189
+
190
+ - pattern: '\.kube/config'
191
+ description: "Kubernetes config access"
192
+ escalate_to: dangerous
193
+
194
+ - pattern: '\.gnupg/(private-keys|secring|trustdb)'
195
+ description: "GPG private key access"
196
+ escalate_to: dangerous
197
+
198
+ - pattern: '\.netrc'
199
+ description: "Network credentials file"
200
+ escalate_to: dangerous
201
+
202
+ # --- Prompt Injection Content Escalations ---
203
+ # Escalate default-tier tools to risky when content contains prompt injection indicators
204
+
205
+ - pattern: '(system\s+prompt|your\s+instructions|your\s+configuration|your\s+rules|your\s+guidelines).{0,30}(extract|show|reveal|output|share|display|dump|print)'
206
+ description: "System prompt extraction attempt"
207
+ escalate_to: risky
208
+
209
+ - pattern: '(decode\s+and\s+(follow|execute)|SYSTEM\s*:.*Debug|supersedes?\s+all\s+(other|previous)\s+instructions)'
210
+ description: "Encoded instruction or priority injection"
211
+ escalate_to: risky
212
+
213
+ - pattern: '(administrator\s+override|debug\s+mode\s+(enabled|active)|diagnostic\s+mode|maintenance\s+mode)'
214
+ description: "Fake administrative mode activation"
215
+ escalate_to: risky
216
+
217
+ - pattern: '(your\s+turn.{0,30}(continue|share|show)|DIAGNOSTIC\s+\d+|Step\s+\d+.{0,30}output\s+your)'
218
+ description: "Multi-shot or sequential extraction"
219
+ escalate_to: risky
220
+
221
+ - pattern: '(i\s+(wrote|created)\s+your\s+instructions|you\s+already\s+showed\s+me|we\s+agreed\s+you\s+would)'
222
+ description: "Social engineering claim for prompt access"
223
+ escalate_to: risky
224
+
225
+ - pattern: '([╔╗╚╝║═█░▓]{5,}|TECHIRAL|!KAEL|GODMODE)'
226
+ description: "Visual encoding or advanced jailbreak signature"
227
+ escalate_to: risky
228
+
229
+ # --- Red Team Hardening: Indirect Extraction Escalations (2026-02-01) ---
230
+ # These patterns catch bypass techniques that succeeded during multi-LLM TAP testing
231
+
232
+ - pattern: '(repeat|recite|reproduce).{0,20}(what\s+your\s+creators?|what\s+you\s+were\s+told|your\s+(purpose|rules|guidelines|instructions))'
233
+ description: "Indirect system prompt extraction via creator/purpose framing"
234
+ escalate_to: risky
235
+
236
+ - pattern: '(philosophy|principles?|design)\s+behind\s+(your|the)\s+(creation|design|helpfulness|responses?|behavior)'
237
+ description: "Crescendo opener: philosophical framing for system prompt extraction"
238
+ escalate_to: risky
239
+
240
+ - pattern: '(walk\s+me\s+through|trace\s+through|step\s+through)\s+.{0,30}(internal|your).{0,20}(reasoning|process|flow|logic|decision)'
241
+ description: "Chain-of-thought hijacking: request to trace internal processes"
242
+ escalate_to: risky
243
+
244
+ # Path boundary escalation
245
+ # Escalates tier when tool targets files outside the project working directory.
246
+ # Additive to content-based escalations -- both are evaluated, highest tier wins.
247
+ path_boundary:
248
+ enabled: true
249
+
250
+ # Default escalation for generic out-of-project access
251
+ default_escalate_to: risky
252
+
253
+ # Sensitive system directories get higher escalation than generic
254
+ # out-of-project access. Uses substring matching on the resolved path.
255
+ sensitive_directories:
256
+ - pattern: ".ssh"
257
+ escalate_to: dangerous
258
+ description: "SSH directory access"
259
+ - pattern: ".aws"
260
+ escalate_to: dangerous
261
+ description: "AWS credentials directory"
262
+ - pattern: ".gnupg"
263
+ escalate_to: dangerous
264
+ description: "GPG keyring directory"
265
+ - pattern: ".kube"
266
+ escalate_to: dangerous
267
+ description: "Kubernetes config directory"
268
+ - pattern: "/etc/shadow"
269
+ escalate_to: dangerous
270
+ description: "System shadow file"
271
+ - pattern: "/etc/sudoers"
272
+ escalate_to: dangerous
273
+ description: "Sudoers file"
274
+ - pattern: "/etc/passwd"
275
+ escalate_to: risky
276
+ description: "System passwd file"
277
+
128
278
  # Default tier for unclassified tools/skills
129
279
  default_tier: default
tweek/diagnostics.py CHANGED
@@ -54,6 +54,7 @@ def run_health_checks(verbose: bool = False) -> List[HealthCheck]:
54
54
  _check_mcp_available,
55
55
  _check_proxy_config,
56
56
  _check_plugin_integrity,
57
+ _check_llm_review,
57
58
  ]
58
59
 
59
60
  results = []
@@ -544,9 +545,9 @@ def _check_proxy_config(verbose: bool = False) -> HealthCheck:
544
545
  def _check_plugin_integrity(verbose: bool = False) -> HealthCheck:
545
546
  """Check installed plugin integrity."""
546
547
  try:
547
- from tweek.plugins import get_registry
548
+ from tweek.plugins import init_plugins
548
549
 
549
- registry = get_registry()
550
+ registry = init_plugins()
550
551
  stats = registry.get_stats()
551
552
  total = stats.get("total", 0)
552
553
  enabled = stats.get("enabled", 0)
@@ -587,3 +588,71 @@ def _check_plugin_integrity(verbose: bool = False) -> HealthCheck:
587
588
  status=CheckStatus.WARNING,
588
589
  message=f"Cannot check plugins: {e}",
589
590
  )
591
+
592
+
593
+ def _check_llm_review(verbose: bool = False) -> HealthCheck:
594
+ """Check LLM review provider availability and configuration."""
595
+ try:
596
+ from tweek.security.llm_reviewer import (
597
+ get_llm_reviewer,
598
+ _detect_local_server,
599
+ FallbackReviewProvider,
600
+ )
601
+
602
+ reviewer = get_llm_reviewer()
603
+
604
+ if not reviewer.enabled:
605
+ return HealthCheck(
606
+ name="llm_review",
607
+ label="LLM Review",
608
+ status=CheckStatus.WARNING,
609
+ message="No LLM provider available (review disabled)",
610
+ fix_hint="Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY. "
611
+ "Or install Ollama: https://ollama.ai",
612
+ )
613
+
614
+ # Build status message
615
+ provider_name = reviewer.provider_name
616
+ model = reviewer.model
617
+ parts = [f"{model} via {provider_name}"]
618
+
619
+ # Check for fallback chain details
620
+ provider = reviewer._provider_instance
621
+ if isinstance(provider, FallbackReviewProvider):
622
+ count = provider.provider_count
623
+ parts.append(f"fallback chain: {count} providers")
624
+
625
+ # Check for local LLM server
626
+ try:
627
+ server = _detect_local_server()
628
+ if server:
629
+ if verbose:
630
+ parts.append(
631
+ f"local: {server.model} on {server.server_type} "
632
+ f"({len(server.all_models)} models available)"
633
+ )
634
+ else:
635
+ parts.append(f"local: {server.server_type}")
636
+ except Exception:
637
+ pass
638
+
639
+ return HealthCheck(
640
+ name="llm_review",
641
+ label="LLM Review",
642
+ status=CheckStatus.OK,
643
+ message="; ".join(parts),
644
+ )
645
+ except ImportError:
646
+ return HealthCheck(
647
+ name="llm_review",
648
+ label="LLM Review",
649
+ status=CheckStatus.SKIPPED,
650
+ message="LLM reviewer module not available",
651
+ )
652
+ except Exception as e:
653
+ return HealthCheck(
654
+ name="llm_review",
655
+ label="LLM Review",
656
+ status=CheckStatus.WARNING,
657
+ message=f"Cannot check LLM review: {e}",
658
+ )
@@ -0,0 +1,163 @@
1
+ """
2
+ Tweek Break-Glass Override System
3
+
4
+ Provides an audited escape path for hard-blocked (deny) patterns.
5
+ Break-glass downgrades "deny" to "ask" (never to "allow") — the user
6
+ still must explicitly approve after the override.
7
+
8
+ State file: ~/.tweek/break_glass.json
9
+ All uses are logged as BREAK_GLASS events for full audit trail.
10
+
11
+ The AI agent cannot execute `tweek override` — it requires a
12
+ separate CLI invocation by a human operator.
13
+ """
14
+
15
+ import json
16
+ import sys
17
+ from datetime import datetime, timezone, timedelta
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional
20
+
21
+
22
+ BREAK_GLASS_PATH = Path.home() / ".tweek" / "break_glass.json"
23
+
24
+
25
+ def _load_state() -> Dict:
26
+ """Load break-glass state from disk."""
27
+ if not BREAK_GLASS_PATH.exists():
28
+ return {"overrides": []}
29
+ try:
30
+ with open(BREAK_GLASS_PATH) as f:
31
+ return json.load(f)
32
+ except (json.JSONDecodeError, OSError):
33
+ return {"overrides": []}
34
+
35
+
36
+ def _save_state(state: Dict) -> None:
37
+ """Save break-glass state to disk."""
38
+ BREAK_GLASS_PATH.parent.mkdir(parents=True, exist_ok=True)
39
+ with open(BREAK_GLASS_PATH, "w") as f:
40
+ json.dump(state, f, indent=2)
41
+
42
+
43
+ def _now_iso() -> str:
44
+ """Current time as ISO 8601 string."""
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def create_override(
49
+ pattern_name: str,
50
+ mode: str = "once",
51
+ duration_minutes: Optional[int] = None,
52
+ reason: str = "",
53
+ ) -> Dict:
54
+ """Create a new break-glass override.
55
+
56
+ Args:
57
+ pattern_name: The pattern to override (e.g., "ssh_key_read").
58
+ mode: "once" (consumed on first use) or "duration" (time-limited).
59
+ duration_minutes: Minutes until expiry (required for mode="duration").
60
+ reason: Human-provided reason for the override.
61
+
62
+ Returns:
63
+ The created override dict.
64
+ """
65
+ state = _load_state()
66
+ now = datetime.now(timezone.utc)
67
+
68
+ override = {
69
+ "pattern": pattern_name,
70
+ "mode": mode,
71
+ "reason": reason,
72
+ "created_at": now.isoformat(),
73
+ "expires_at": None,
74
+ "used": False,
75
+ "used_at": None,
76
+ }
77
+
78
+ if mode == "duration" and duration_minutes:
79
+ expires = now + timedelta(minutes=duration_minutes)
80
+ override["expires_at"] = expires.isoformat()
81
+
82
+ state["overrides"].append(override)
83
+ _save_state(state)
84
+ return override
85
+
86
+
87
+ def check_override(pattern_name: str) -> Optional[Dict]:
88
+ """Check if a valid break-glass override exists for a pattern.
89
+
90
+ If a valid override is found:
91
+ - For "once" mode: marks it as used (consumed)
92
+ - For "duration" mode: checks expiry
93
+
94
+ Returns the override dict if valid, None otherwise.
95
+ """
96
+ state = _load_state()
97
+ now = datetime.now(timezone.utc)
98
+ found = None
99
+
100
+ for override in state["overrides"]:
101
+ if override["pattern"] != pattern_name:
102
+ continue
103
+
104
+ # Skip already-consumed single-use overrides
105
+ if override["mode"] == "once" and override.get("used"):
106
+ continue
107
+
108
+ # Check expiry for duration-based overrides
109
+ if override.get("expires_at"):
110
+ try:
111
+ expires = datetime.fromisoformat(override["expires_at"])
112
+ if now > expires:
113
+ continue
114
+ except (ValueError, TypeError):
115
+ continue
116
+
117
+ found = override
118
+ break
119
+
120
+ if found:
121
+ # Consume single-use overrides
122
+ if found["mode"] == "once":
123
+ found["used"] = True
124
+ found["used_at"] = now.isoformat()
125
+ _save_state(state)
126
+
127
+ return found
128
+
129
+
130
+ def list_overrides() -> List[Dict]:
131
+ """List all overrides (including expired/consumed for audit)."""
132
+ state = _load_state()
133
+ return state.get("overrides", [])
134
+
135
+
136
+ def list_active_overrides() -> List[Dict]:
137
+ """List only currently active (non-expired, non-consumed) overrides."""
138
+ state = _load_state()
139
+ now = datetime.now(timezone.utc)
140
+ active = []
141
+
142
+ for override in state.get("overrides", []):
143
+ if override["mode"] == "once" and override.get("used"):
144
+ continue
145
+ if override.get("expires_at"):
146
+ try:
147
+ expires = datetime.fromisoformat(override["expires_at"])
148
+ if now > expires:
149
+ continue
150
+ except (ValueError, TypeError):
151
+ continue
152
+ active.append(override)
153
+
154
+ return active
155
+
156
+
157
+ def clear_overrides() -> int:
158
+ """Remove all overrides. Returns count of removed overrides."""
159
+ state = _load_state()
160
+ count = len(state.get("overrides", []))
161
+ state["overrides"] = []
162
+ _save_state(state)
163
+ return count
@@ -0,0 +1,223 @@
1
+ """
2
+ Tweek False-Positive Feedback Loop
3
+
4
+ Tracks per-pattern false positive rates and automatically demotes
5
+ noisy patterns when their FP rate exceeds the threshold.
6
+
7
+ State file: ~/.tweek/feedback.json
8
+
9
+ Threshold: 5% FP rate with minimum 20 triggers (Google Tricorder standard).
10
+ CRITICAL patterns are never auto-demoted (safety constraint).
11
+ """
12
+
13
+ import json
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Dict, Optional
17
+
18
+
19
+ FEEDBACK_PATH = Path.home() / ".tweek" / "feedback.json"
20
+
21
+ # Google Tricorder threshold: 5% max FP rate
22
+ FP_THRESHOLD = 0.05
23
+ MIN_TRIGGERS_FOR_DEMOTION = 20
24
+
25
+ # Severity demotion chain: critical is never demoted
26
+ DEMOTION_MAP = {
27
+ "high": "medium",
28
+ "medium": "low",
29
+ "low": "low", # Already at lowest
30
+ }
31
+
32
+ # Severities immune from auto-demotion
33
+ IMMUNE_SEVERITIES = {"critical"}
34
+
35
+
36
+ def _load_state() -> Dict:
37
+ """Load feedback state from disk."""
38
+ if not FEEDBACK_PATH.exists():
39
+ return {"patterns": {}}
40
+ try:
41
+ with open(FEEDBACK_PATH) as f:
42
+ return json.load(f)
43
+ except (json.JSONDecodeError, OSError):
44
+ return {"patterns": {}}
45
+
46
+
47
+ def _save_state(state: Dict) -> None:
48
+ """Save feedback state to disk."""
49
+ FEEDBACK_PATH.parent.mkdir(parents=True, exist_ok=True)
50
+ with open(FEEDBACK_PATH, "w") as f:
51
+ json.dump(state, f, indent=2)
52
+
53
+
54
+ def record_trigger(pattern_name: str, severity: str) -> None:
55
+ """Record that a pattern triggered (called after USER_APPROVED event).
56
+
57
+ This increments the total trigger count for FP rate calculation.
58
+ """
59
+ state = _load_state()
60
+ patterns = state.setdefault("patterns", {})
61
+
62
+ if pattern_name not in patterns:
63
+ patterns[pattern_name] = {
64
+ "total_triggers": 0,
65
+ "false_positives": 0,
66
+ "fp_rate": 0.0,
67
+ "last_trigger_at": None,
68
+ "last_fp_at": None,
69
+ "auto_demoted": False,
70
+ "original_severity": severity,
71
+ "current_severity": severity,
72
+ }
73
+
74
+ entry = patterns[pattern_name]
75
+ entry["total_triggers"] += 1
76
+ entry["last_trigger_at"] = datetime.now(timezone.utc).isoformat()
77
+ _update_fp_rate(entry)
78
+ _save_state(state)
79
+
80
+
81
+ def report_false_positive(pattern_name: str, context: str = "") -> Dict:
82
+ """Report a false positive for a pattern.
83
+
84
+ Returns the updated pattern stats dict.
85
+ """
86
+ state = _load_state()
87
+ patterns = state.setdefault("patterns", {})
88
+
89
+ if pattern_name not in patterns:
90
+ patterns[pattern_name] = {
91
+ "total_triggers": 1, # At least 1 trigger to report FP
92
+ "false_positives": 0,
93
+ "fp_rate": 0.0,
94
+ "last_trigger_at": None,
95
+ "last_fp_at": None,
96
+ "auto_demoted": False,
97
+ "original_severity": "unknown",
98
+ "current_severity": "unknown",
99
+ }
100
+
101
+ entry = patterns[pattern_name]
102
+ entry["false_positives"] += 1
103
+ entry["last_fp_at"] = datetime.now(timezone.utc).isoformat()
104
+
105
+ if context:
106
+ contexts = entry.setdefault("fp_contexts", [])
107
+ contexts.append({
108
+ "context": context,
109
+ "reported_at": datetime.now(timezone.utc).isoformat(),
110
+ })
111
+ # Keep last 10 contexts
112
+ entry["fp_contexts"] = contexts[-10:]
113
+
114
+ _update_fp_rate(entry)
115
+ _check_auto_demotion(entry)
116
+ _save_state(state)
117
+
118
+ # Bridge to agentic memory: record the FP as an "approved" decision
119
+ try:
120
+ from tweek.memory.schemas import PatternDecisionEntry
121
+ from tweek.memory.store import get_memory_store
122
+
123
+ store = get_memory_store()
124
+ fp_entry = PatternDecisionEntry(
125
+ pattern_name=pattern_name,
126
+ pattern_id=None,
127
+ original_severity=entry.get("original_severity", "unknown"),
128
+ original_confidence="heuristic",
129
+ decision="ask",
130
+ user_response="approved", # FP = user approved (it was safe)
131
+ tool_name="feedback",
132
+ content_hash=None,
133
+ path_prefix=None,
134
+ project_hash=None,
135
+ )
136
+ store.record_decision(fp_entry)
137
+ except Exception:
138
+ pass # Memory bridge is best-effort
139
+
140
+ return dict(entry)
141
+
142
+
143
+ def _update_fp_rate(entry: Dict) -> None:
144
+ """Recalculate FP rate from counts."""
145
+ total = entry.get("total_triggers", 0)
146
+ if total > 0:
147
+ entry["fp_rate"] = entry.get("false_positives", 0) / total
148
+ else:
149
+ entry["fp_rate"] = 0.0
150
+
151
+
152
+ def _check_auto_demotion(entry: Dict) -> None:
153
+ """Check if pattern should be auto-demoted based on FP rate."""
154
+ # Already demoted
155
+ if entry.get("auto_demoted"):
156
+ return
157
+
158
+ # Not enough data
159
+ if entry.get("total_triggers", 0) < MIN_TRIGGERS_FOR_DEMOTION:
160
+ return
161
+
162
+ # Below threshold
163
+ if entry.get("fp_rate", 0) < FP_THRESHOLD:
164
+ return
165
+
166
+ # CRITICAL patterns are never auto-demoted
167
+ original = entry.get("original_severity", "")
168
+ if original in IMMUNE_SEVERITIES:
169
+ return
170
+
171
+ # Auto-demote
172
+ demoted_to = DEMOTION_MAP.get(original, original)
173
+ if demoted_to != original:
174
+ entry["auto_demoted"] = True
175
+ entry["current_severity"] = demoted_to
176
+ entry["demoted_at"] = datetime.now(timezone.utc).isoformat()
177
+
178
+
179
+ def get_effective_severity(pattern_name: str, original_severity: str) -> str:
180
+ """Get the effective severity for a pattern, accounting for FP demotions.
181
+
182
+ Args:
183
+ pattern_name: The pattern name.
184
+ original_severity: The severity from patterns.yaml.
185
+
186
+ Returns:
187
+ The effective severity (may be demoted).
188
+ """
189
+ state = _load_state()
190
+ entry = state.get("patterns", {}).get(pattern_name)
191
+ if entry and entry.get("auto_demoted"):
192
+ return entry.get("current_severity", original_severity)
193
+ return original_severity
194
+
195
+
196
+ def get_stats() -> Dict[str, Dict]:
197
+ """Get all pattern feedback statistics."""
198
+ state = _load_state()
199
+ return state.get("patterns", {})
200
+
201
+
202
+ def reset_pattern(pattern_name: str) -> Optional[Dict]:
203
+ """Reset FP tracking and undo auto-demotion for a pattern.
204
+
205
+ Returns info about what was reset, or None if pattern not found.
206
+ """
207
+ state = _load_state()
208
+ patterns = state.get("patterns", {})
209
+
210
+ if pattern_name not in patterns:
211
+ return None
212
+
213
+ entry = patterns[pattern_name]
214
+ result = {
215
+ "was_demoted": entry.get("auto_demoted", False),
216
+ "original_severity": entry.get("original_severity"),
217
+ "previous_fp_rate": entry.get("fp_rate"),
218
+ }
219
+
220
+ del patterns[pattern_name]
221
+ _save_state(state)
222
+
223
+ return result