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/hooks/overrides.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Security Overrides
|
|
3
|
+
|
|
4
|
+
Loads and applies human-only security overrides from ~/.tweek/overrides.yaml.
|
|
5
|
+
Used by both PreToolUse and PostToolUse hooks.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Whitelist: Exempt specific paths/tools/URLs from screening
|
|
9
|
+
- Pattern Toggles: Enable/disable individual detection patterns
|
|
10
|
+
- Trust Levels: Different severity thresholds for interactive vs automated sessions
|
|
11
|
+
|
|
12
|
+
IMPORTANT: The overrides.yaml file is protected from AI modification.
|
|
13
|
+
The PreToolUse hook blocks Write/Edit/Bash commands that target this file.
|
|
14
|
+
Only a human editing the file directly can change security overrides.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# The canonical location of the overrides config file
|
|
29
|
+
OVERRIDES_PATH = Path.home() / ".tweek" / "overrides.yaml"
|
|
30
|
+
|
|
31
|
+
# Files protected from AI modification
|
|
32
|
+
PROTECTED_CONFIG_FILES = [
|
|
33
|
+
OVERRIDES_PATH,
|
|
34
|
+
Path.home() / ".tweek" / "skills", # Entire skills management directory
|
|
35
|
+
Path.home() / ".tweek" / "projects", # Project registry
|
|
36
|
+
Path.home() / ".tweek" / "memory.db", # Agentic memory database
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SecurityOverrides:
|
|
41
|
+
"""Loads and queries the ~/.tweek/overrides.yaml configuration."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
44
|
+
self.config_path = config_path or OVERRIDES_PATH
|
|
45
|
+
self.config = self._load()
|
|
46
|
+
self._whitelist_rules: List[Dict] = self.config.get("whitelist", [])
|
|
47
|
+
self._pattern_config: Dict = self.config.get("patterns", {})
|
|
48
|
+
self._trust_config: Dict = self.config.get("trust", {})
|
|
49
|
+
|
|
50
|
+
def _load(self) -> dict:
|
|
51
|
+
"""Load overrides YAML. Returns empty dict if file missing (backward compatible)."""
|
|
52
|
+
if not self.config_path.exists():
|
|
53
|
+
return {}
|
|
54
|
+
self._check_permissions()
|
|
55
|
+
try:
|
|
56
|
+
with open(self.config_path) as f:
|
|
57
|
+
return yaml.safe_load(f) or {}
|
|
58
|
+
except Exception:
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
def _check_permissions(self):
|
|
62
|
+
"""Warn via stderr if overrides file has insecure permissions."""
|
|
63
|
+
try:
|
|
64
|
+
import stat
|
|
65
|
+
mode = self.config_path.stat().st_mode
|
|
66
|
+
if mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
67
|
+
print(
|
|
68
|
+
f"WARNING: {self.config_path} has group/other write permissions. "
|
|
69
|
+
"Run: chmod 600 ~/.tweek/overrides.yaml",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def check_whitelist(
|
|
76
|
+
self, tool_name: str, tool_input: Dict[str, Any], content: str
|
|
77
|
+
) -> Optional[Dict]:
|
|
78
|
+
"""
|
|
79
|
+
Check if this invocation matches a whitelist rule.
|
|
80
|
+
|
|
81
|
+
Returns the matching rule dict if whitelisted, None otherwise.
|
|
82
|
+
"""
|
|
83
|
+
for rule in self._whitelist_rules:
|
|
84
|
+
if self._matches_rule(rule, tool_name, tool_input, content):
|
|
85
|
+
return rule
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _matches_rule(
|
|
89
|
+
self, rule: Dict, tool_name: str, tool_input: Dict[str, Any], content: str
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Check if a single whitelist rule matches the current invocation."""
|
|
92
|
+
# If rule specifies tools, tool_name must be in that list
|
|
93
|
+
rule_tools = rule.get("tools")
|
|
94
|
+
if rule_tools and tool_name not in rule_tools:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# If rule specifies a specific tool (singular), check that
|
|
98
|
+
rule_tool = rule.get("tool")
|
|
99
|
+
if rule_tool and tool_name != rule_tool:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Path matching
|
|
103
|
+
rule_path = rule.get("path")
|
|
104
|
+
if rule_path:
|
|
105
|
+
target_path = tool_input.get("file_path", "")
|
|
106
|
+
if not target_path:
|
|
107
|
+
return False
|
|
108
|
+
if not self._path_matches(rule_path, target_path):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# URL prefix matching
|
|
112
|
+
url_prefix = rule.get("url_prefix")
|
|
113
|
+
if url_prefix:
|
|
114
|
+
target_url = tool_input.get("url", "")
|
|
115
|
+
if not target_url or not target_url.startswith(url_prefix):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Command prefix matching (for Bash)
|
|
119
|
+
cmd_prefix = rule.get("command_prefix")
|
|
120
|
+
if cmd_prefix:
|
|
121
|
+
command = tool_input.get("command", "")
|
|
122
|
+
if not command or not command.strip().startswith(cmd_prefix):
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
# If no path/url/command_prefix specified, the rule matches on tool alone
|
|
126
|
+
# (or matches everything if no tool filter either)
|
|
127
|
+
has_filter = rule_path or url_prefix or cmd_prefix
|
|
128
|
+
has_tool_filter = rule_tools or rule_tool
|
|
129
|
+
if not has_filter and not has_tool_filter:
|
|
130
|
+
# Rule with no filters matches nothing (safety)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def _path_matches(self, rule_path: str, target_path: str) -> bool:
|
|
136
|
+
"""Check if target_path matches a rule path (prefix match with resolution)."""
|
|
137
|
+
try:
|
|
138
|
+
rule_resolved = Path(rule_path).expanduser().resolve()
|
|
139
|
+
target_resolved = Path(target_path).expanduser().resolve()
|
|
140
|
+
|
|
141
|
+
# Exact match
|
|
142
|
+
if target_resolved == rule_resolved:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
# Prefix match (target is inside the rule directory)
|
|
146
|
+
try:
|
|
147
|
+
target_resolved.relative_to(rule_resolved)
|
|
148
|
+
return True
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
pass
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def filter_patterns(
|
|
156
|
+
self, matches: List[Dict], working_path: str
|
|
157
|
+
) -> List[Dict]:
|
|
158
|
+
"""
|
|
159
|
+
Remove disabled and scoped-disabled patterns from a match list.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
matches: List of pattern match dicts (each has 'name' key)
|
|
163
|
+
working_path: The file path or working directory for scoped checks
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Filtered list with disabled patterns removed
|
|
167
|
+
"""
|
|
168
|
+
disabled_names = {
|
|
169
|
+
p["name"] for p in self._pattern_config.get("disabled", [])
|
|
170
|
+
if "name" in p
|
|
171
|
+
}
|
|
172
|
+
scoped_disables = self._pattern_config.get("scoped_disables", [])
|
|
173
|
+
force_enabled = set(self._pattern_config.get("force_enabled", []))
|
|
174
|
+
|
|
175
|
+
filtered = []
|
|
176
|
+
for match in matches:
|
|
177
|
+
name = match.get("name", "")
|
|
178
|
+
|
|
179
|
+
# Force-enabled patterns are never filtered
|
|
180
|
+
if name in force_enabled:
|
|
181
|
+
filtered.append(match)
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Globally disabled
|
|
185
|
+
if name in disabled_names:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Scoped disables
|
|
189
|
+
scoped_disabled = False
|
|
190
|
+
for scope in scoped_disables:
|
|
191
|
+
if scope.get("name") != name:
|
|
192
|
+
continue
|
|
193
|
+
for scope_path in scope.get("paths", []):
|
|
194
|
+
if self._path_matches(scope_path, working_path):
|
|
195
|
+
scoped_disabled = True
|
|
196
|
+
break
|
|
197
|
+
if scoped_disabled:
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
if not scoped_disabled:
|
|
201
|
+
filtered.append(match)
|
|
202
|
+
|
|
203
|
+
return filtered
|
|
204
|
+
|
|
205
|
+
def get_min_severity(self, trust_mode: str) -> str:
|
|
206
|
+
"""Get minimum severity threshold for the given trust mode."""
|
|
207
|
+
mode_config = self._trust_config.get(trust_mode, {})
|
|
208
|
+
return mode_config.get("min_severity", "low")
|
|
209
|
+
|
|
210
|
+
def get_trust_default(self) -> str:
|
|
211
|
+
"""Get default trust mode from config."""
|
|
212
|
+
return self._trust_config.get("default_mode", "interactive")
|
|
213
|
+
|
|
214
|
+
def should_skip_llm_for_default_tier(self, trust_mode: str) -> bool:
|
|
215
|
+
"""Check if LLM review should be skipped for default-tier tools."""
|
|
216
|
+
mode_config = self._trust_config.get(trust_mode, {})
|
|
217
|
+
return mode_config.get("skip_llm_for_default_tier", False)
|
|
218
|
+
|
|
219
|
+
def get_enforcement_policy(self) -> "EnforcementPolicy":
|
|
220
|
+
"""Get the enforcement policy from the config's 'enforcement' section.
|
|
221
|
+
|
|
222
|
+
Returns an EnforcementPolicy initialized from the config, or the
|
|
223
|
+
default policy if no enforcement section exists.
|
|
224
|
+
"""
|
|
225
|
+
enforcement_config = self.config.get("enforcement", {})
|
|
226
|
+
return EnforcementPolicy(enforcement_config)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# =========================================================================
|
|
230
|
+
# Module-level singleton
|
|
231
|
+
# =========================================================================
|
|
232
|
+
|
|
233
|
+
_overrides: Optional[SecurityOverrides] = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_overrides(config_path: Optional[Path] = None) -> Optional[SecurityOverrides]:
|
|
237
|
+
"""
|
|
238
|
+
Get the singleton SecurityOverrides instance.
|
|
239
|
+
|
|
240
|
+
Returns None if no config file exists (backward compatible -- all
|
|
241
|
+
screening runs as before).
|
|
242
|
+
"""
|
|
243
|
+
global _overrides
|
|
244
|
+
if _overrides is None:
|
|
245
|
+
_overrides = SecurityOverrides(config_path)
|
|
246
|
+
if not _overrides.config:
|
|
247
|
+
return None
|
|
248
|
+
return _overrides
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def reset_overrides():
|
|
252
|
+
"""Reset the singleton (for testing)."""
|
|
253
|
+
global _overrides
|
|
254
|
+
_overrides = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# =========================================================================
|
|
258
|
+
# Trust Level Detection
|
|
259
|
+
# =========================================================================
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_trust_mode(overrides: Optional[SecurityOverrides] = None) -> str:
|
|
263
|
+
"""
|
|
264
|
+
Determine whether the current session is interactive or automated.
|
|
265
|
+
|
|
266
|
+
Detection hierarchy (first match wins):
|
|
267
|
+
1. Explicit env var: TWEEK_TRUST_LEVEL=interactive|automated
|
|
268
|
+
2. Parent process check: launchd/cron/systemd → automated
|
|
269
|
+
3. CI environment variables → automated
|
|
270
|
+
4. Default from overrides.yaml config
|
|
271
|
+
5. Fallback: interactive
|
|
272
|
+
|
|
273
|
+
Returns: "interactive" or "automated"
|
|
274
|
+
"""
|
|
275
|
+
# 1. Explicit environment variable (highest priority)
|
|
276
|
+
env_trust = os.environ.get("TWEEK_TRUST_LEVEL", "").lower().strip()
|
|
277
|
+
if env_trust in ("interactive", "automated"):
|
|
278
|
+
return env_trust
|
|
279
|
+
|
|
280
|
+
# 2. Parent process heuristic (macOS/Linux)
|
|
281
|
+
try:
|
|
282
|
+
ppid = os.getppid()
|
|
283
|
+
result = subprocess.run(
|
|
284
|
+
["ps", "-p", str(ppid), "-o", "comm="],
|
|
285
|
+
capture_output=True, text=True, timeout=1,
|
|
286
|
+
)
|
|
287
|
+
parent_name = result.stdout.strip().lower()
|
|
288
|
+
automated_parents = {
|
|
289
|
+
"launchd", "cron", "crond", "systemd", "atd",
|
|
290
|
+
"supervisord", "init",
|
|
291
|
+
}
|
|
292
|
+
if parent_name in automated_parents:
|
|
293
|
+
return "automated"
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
# 3. CI environment variables
|
|
298
|
+
ci_vars = [
|
|
299
|
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
|
|
300
|
+
"JENKINS_HOME", "GITLAB_CI", "CIRCLECI", "BUILDKITE",
|
|
301
|
+
]
|
|
302
|
+
for var in ci_vars:
|
|
303
|
+
if os.environ.get(var):
|
|
304
|
+
return "automated"
|
|
305
|
+
|
|
306
|
+
# 4. Config default
|
|
307
|
+
if overrides:
|
|
308
|
+
return overrides.get_trust_default()
|
|
309
|
+
|
|
310
|
+
# 5. Fallback
|
|
311
|
+
return "interactive"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# =========================================================================
|
|
315
|
+
# Self-Protection: prevent AI from modifying the overrides file
|
|
316
|
+
# =========================================================================
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def is_protected_config_file(file_path: str) -> bool:
|
|
320
|
+
"""
|
|
321
|
+
Check if a file path points to a human-only config file.
|
|
322
|
+
|
|
323
|
+
Used by PreToolUse to block Write/Edit targeting overrides.yaml,
|
|
324
|
+
project sandbox config, and other protected paths.
|
|
325
|
+
"""
|
|
326
|
+
if not file_path:
|
|
327
|
+
return False
|
|
328
|
+
try:
|
|
329
|
+
resolved = Path(file_path).expanduser().resolve()
|
|
330
|
+
|
|
331
|
+
# Check explicit protected paths
|
|
332
|
+
for protected in PROTECTED_CONFIG_FILES:
|
|
333
|
+
protected_resolved = protected.resolve()
|
|
334
|
+
if resolved == protected_resolved:
|
|
335
|
+
return True
|
|
336
|
+
# Check if target is inside a protected directory
|
|
337
|
+
try:
|
|
338
|
+
resolved.relative_to(protected_resolved)
|
|
339
|
+
return True
|
|
340
|
+
except ValueError:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
# Protect project-level .tweek/ directories (sandbox state)
|
|
344
|
+
# Any file inside a .tweek/ directory is protected
|
|
345
|
+
for part in resolved.parts:
|
|
346
|
+
if part == ".tweek":
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
except (OSError, ValueError):
|
|
350
|
+
pass
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def bash_targets_protected_config(command: str) -> bool:
|
|
355
|
+
"""
|
|
356
|
+
Check if a bash command would modify the protected config files.
|
|
357
|
+
|
|
358
|
+
Catches common shell patterns: redirects, cp, mv, sed -i, rm, tee, etc.
|
|
359
|
+
"""
|
|
360
|
+
if not command:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# Patterns that indicate writing to the overrides file
|
|
364
|
+
write_patterns = [
|
|
365
|
+
r'(>|>>)\s*.*overrides\.yaml',
|
|
366
|
+
r'(cp|mv|rsync)\s+.*overrides\.yaml',
|
|
367
|
+
r'tee\s+.*overrides\.yaml',
|
|
368
|
+
r'sed\s+-i.*overrides\.yaml',
|
|
369
|
+
r'perl\s+-[pi].*overrides\.yaml',
|
|
370
|
+
r'(echo|cat|printf)\s+.*>\s*.*overrides\.yaml',
|
|
371
|
+
r'rm\s+.*overrides\.yaml',
|
|
372
|
+
r'unlink\s+.*overrides\.yaml',
|
|
373
|
+
r'truncate\s+.*overrides\.yaml',
|
|
374
|
+
r'python[3]?\s+.*overrides\.yaml',
|
|
375
|
+
r'ruby\s+.*overrides\.yaml',
|
|
376
|
+
r'node\s+.*overrides\.yaml',
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
for pattern in write_patterns:
|
|
380
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
# Also check for the full path
|
|
384
|
+
overrides_str = str(OVERRIDES_PATH)
|
|
385
|
+
full_path_patterns = [
|
|
386
|
+
rf'(>|>>)\s*.*{re.escape(overrides_str)}',
|
|
387
|
+
rf'(cp|mv|rsync)\s+.*{re.escape(overrides_str)}',
|
|
388
|
+
rf'rm\s+.*{re.escape(overrides_str)}',
|
|
389
|
+
rf'tee\s+.*{re.escape(overrides_str)}',
|
|
390
|
+
]
|
|
391
|
+
for pattern in full_path_patterns:
|
|
392
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
# Protect project-level .tweek/ directories (sandbox state)
|
|
396
|
+
tweek_dir_patterns = [
|
|
397
|
+
r'(>|>>)\s*.*\.tweek/',
|
|
398
|
+
r'(cp|mv|rsync)\s+.*\.tweek/',
|
|
399
|
+
r'rm\s+(-rf?\s+)?.*\.tweek/',
|
|
400
|
+
r'tee\s+.*\.tweek/',
|
|
401
|
+
r'sed\s+-i.*\.tweek/',
|
|
402
|
+
r'(echo|cat|printf)\s+.*>\s*.*\.tweek/',
|
|
403
|
+
]
|
|
404
|
+
for pattern in tweek_dir_patterns:
|
|
405
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# =========================================================================
|
|
412
|
+
# Severity Filtering
|
|
413
|
+
# =========================================================================
|
|
414
|
+
|
|
415
|
+
SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
416
|
+
|
|
417
|
+
# Decision escalation order: log < ask < deny
|
|
418
|
+
DECISION_RANK = {"log": 0, "ask": 1, "deny": 2}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def filter_by_severity(
|
|
422
|
+
matches: List[Dict], min_severity: str
|
|
423
|
+
) -> tuple[List[Dict], List[Dict]]:
|
|
424
|
+
"""
|
|
425
|
+
Filter pattern matches by severity threshold.
|
|
426
|
+
|
|
427
|
+
Returns (kept, suppressed) -- two lists.
|
|
428
|
+
'kept' contains matches at or above the threshold.
|
|
429
|
+
'suppressed' contains matches below the threshold.
|
|
430
|
+
"""
|
|
431
|
+
min_rank = SEVERITY_RANK.get(min_severity, 3)
|
|
432
|
+
kept = []
|
|
433
|
+
suppressed = []
|
|
434
|
+
for match in matches:
|
|
435
|
+
match_rank = SEVERITY_RANK.get(match.get("severity", "medium"), 2)
|
|
436
|
+
if match_rank <= min_rank:
|
|
437
|
+
kept.append(match)
|
|
438
|
+
else:
|
|
439
|
+
suppressed.append(match)
|
|
440
|
+
return kept, suppressed
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# =========================================================================
|
|
444
|
+
# Enforcement Policy
|
|
445
|
+
# =========================================================================
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class EnforcementPolicy:
|
|
449
|
+
"""Configurable severity+confidence → decision matrix.
|
|
450
|
+
|
|
451
|
+
Determines the enforcement decision (deny/ask/log) for a given
|
|
452
|
+
pattern match based on its severity and confidence level.
|
|
453
|
+
|
|
454
|
+
Default matrix:
|
|
455
|
+
CRITICAL + deterministic → deny (hard block)
|
|
456
|
+
CRITICAL + heuristic/contextual → ask
|
|
457
|
+
HIGH → ask
|
|
458
|
+
MEDIUM → ask
|
|
459
|
+
LOW → log (silent allow with logging)
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
DEFAULT_MATRIX = {
|
|
463
|
+
"critical": {"deterministic": "deny", "heuristic": "ask", "contextual": "ask"},
|
|
464
|
+
"high": {"deterministic": "ask", "heuristic": "ask", "contextual": "ask"},
|
|
465
|
+
"medium": {"deterministic": "ask", "heuristic": "ask", "contextual": "ask"},
|
|
466
|
+
"low": {"deterministic": "log", "heuristic": "log", "contextual": "log"},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
VALID_DECISIONS = {"deny", "ask", "log"}
|
|
470
|
+
VALID_SEVERITIES = {"critical", "high", "medium", "low"}
|
|
471
|
+
VALID_CONFIDENCES = {"deterministic", "heuristic", "contextual"}
|
|
472
|
+
|
|
473
|
+
def __init__(self, config: Optional[Dict] = None):
|
|
474
|
+
self.matrix = self._merge_with_defaults(config or {})
|
|
475
|
+
|
|
476
|
+
def resolve(self, severity: str, confidence: str) -> str:
|
|
477
|
+
"""Determine the enforcement decision for a severity+confidence pair.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
severity: Pattern severity (critical/high/medium/low)
|
|
481
|
+
confidence: Pattern confidence (deterministic/heuristic/contextual)
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Decision string: "deny", "ask", or "log"
|
|
485
|
+
"""
|
|
486
|
+
row = self.matrix.get(severity, self.matrix.get("medium", {}))
|
|
487
|
+
return row.get(confidence, "ask")
|
|
488
|
+
|
|
489
|
+
def _merge_with_defaults(self, config: Dict) -> Dict:
|
|
490
|
+
"""Merge user config with defaults, validating all values."""
|
|
491
|
+
merged = {}
|
|
492
|
+
for severity in self.VALID_SEVERITIES:
|
|
493
|
+
default_row = self.DEFAULT_MATRIX[severity]
|
|
494
|
+
user_row = config.get(severity, {})
|
|
495
|
+
merged_row = {}
|
|
496
|
+
for confidence in self.VALID_CONFIDENCES:
|
|
497
|
+
user_decision = user_row.get(confidence)
|
|
498
|
+
if user_decision and user_decision in self.VALID_DECISIONS:
|
|
499
|
+
merged_row[confidence] = user_decision
|
|
500
|
+
else:
|
|
501
|
+
merged_row[confidence] = default_row[confidence]
|
|
502
|
+
merged[severity] = merged_row
|
|
503
|
+
return merged
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def stricter_decision(a: str, b: str) -> str:
|
|
507
|
+
"""Return the stricter of two decisions (deny > ask > log)."""
|
|
508
|
+
rank_a = DECISION_RANK.get(a, 1)
|
|
509
|
+
rank_b = DECISION_RANK.get(b, 1)
|
|
510
|
+
if rank_a >= rank_b:
|
|
511
|
+
return a
|
|
512
|
+
return b
|
|
513
|
+
|
|
514
|
+
@staticmethod
|
|
515
|
+
def merge_additive_only(global_policy: "EnforcementPolicy",
|
|
516
|
+
project_policy: "EnforcementPolicy") -> "EnforcementPolicy":
|
|
517
|
+
"""Merge project policy with global, enforcing additive-only (escalation only).
|
|
518
|
+
|
|
519
|
+
Project can escalate decisions (log→ask, ask→deny) but never downgrade.
|
|
520
|
+
"""
|
|
521
|
+
merged_config = {}
|
|
522
|
+
for severity in EnforcementPolicy.VALID_SEVERITIES:
|
|
523
|
+
merged_row = {}
|
|
524
|
+
for confidence in EnforcementPolicy.VALID_CONFIDENCES:
|
|
525
|
+
global_decision = global_policy.resolve(severity, confidence)
|
|
526
|
+
project_decision = project_policy.resolve(severity, confidence)
|
|
527
|
+
merged_row[confidence] = EnforcementPolicy.stricter_decision(
|
|
528
|
+
global_decision, project_decision
|
|
529
|
+
)
|
|
530
|
+
merged_config[severity] = merged_row
|
|
531
|
+
return EnforcementPolicy(merged_config)
|