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
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
|
|
12
|
-
# 2. Pattern Match
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
#
|
|
68
|
-
Read:
|
|
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:
|
|
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
|
|
548
|
+
from tweek.plugins import init_plugins
|
|
548
549
|
|
|
549
|
-
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
|
tweek/hooks/feedback.py
ADDED
|
@@ -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
|