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/pre_tool_use.py
CHANGED
|
@@ -25,6 +25,7 @@ Claude Code Hook Protocol:
|
|
|
25
25
|
- "permissionDecision": "ask" - prompt user for confirmation
|
|
26
26
|
- "permissionDecision": "deny" - block execution
|
|
27
27
|
"""
|
|
28
|
+
from __future__ import annotations
|
|
28
29
|
|
|
29
30
|
import json
|
|
30
31
|
import os
|
|
@@ -42,6 +43,17 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
42
43
|
from tweek.logging.security_log import (
|
|
43
44
|
SecurityLogger, SecurityEvent, EventType, get_logger
|
|
44
45
|
)
|
|
46
|
+
from tweek.hooks.overrides import (
|
|
47
|
+
get_overrides, get_trust_mode, is_protected_config_file,
|
|
48
|
+
bash_targets_protected_config, filter_by_severity, SEVERITY_RANK,
|
|
49
|
+
EnforcementPolicy,
|
|
50
|
+
)
|
|
51
|
+
from tweek.skills.guard import (
|
|
52
|
+
get_skill_guard_reason,
|
|
53
|
+
get_skill_download_prompt,
|
|
54
|
+
)
|
|
55
|
+
from tweek.skills.fingerprints import get_fingerprints
|
|
56
|
+
from tweek.sandbox.project import get_project_sandbox
|
|
45
57
|
|
|
46
58
|
|
|
47
59
|
# =============================================================================
|
|
@@ -195,6 +207,139 @@ def run_screening_plugins(
|
|
|
195
207
|
return True, False, None, []
|
|
196
208
|
|
|
197
209
|
|
|
210
|
+
# Module-level caches for hot-path performance (avoid YAML re-parsing per invocation)
|
|
211
|
+
_cached_tier_mgr: Optional["TierManager"] = None
|
|
212
|
+
_cached_pattern_matcher: Optional["PatternMatcher"] = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _get_tier_manager() -> "TierManager":
|
|
216
|
+
"""Get or create cached TierManager singleton."""
|
|
217
|
+
global _cached_tier_mgr
|
|
218
|
+
if _cached_tier_mgr is None:
|
|
219
|
+
_cached_tier_mgr = TierManager()
|
|
220
|
+
return _cached_tier_mgr
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_pattern_matcher() -> "PatternMatcher":
|
|
224
|
+
"""Get or create cached PatternMatcher singleton."""
|
|
225
|
+
global _cached_pattern_matcher
|
|
226
|
+
if _cached_pattern_matcher is None:
|
|
227
|
+
_cached_pattern_matcher = PatternMatcher()
|
|
228
|
+
return _cached_pattern_matcher
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_cached_heuristic_scorer: Optional[Any] = None
|
|
232
|
+
_heuristic_scorer_checked: bool = False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_heuristic_scorer(tier_mgr: "TierManager") -> Optional[Any]:
|
|
236
|
+
"""Get or create cached HeuristicScorerPlugin singleton.
|
|
237
|
+
|
|
238
|
+
Returns None if the scorer is disabled in config or unavailable.
|
|
239
|
+
Uses a checked-flag pattern to avoid re-importing on every call
|
|
240
|
+
if the module isn't available.
|
|
241
|
+
"""
|
|
242
|
+
global _cached_heuristic_scorer, _heuristic_scorer_checked
|
|
243
|
+
if _heuristic_scorer_checked:
|
|
244
|
+
return _cached_heuristic_scorer
|
|
245
|
+
_heuristic_scorer_checked = True
|
|
246
|
+
|
|
247
|
+
# Check config: heuristic_scorer.enabled
|
|
248
|
+
hs_config = tier_mgr.config.get("heuristic_scorer", {})
|
|
249
|
+
if not hs_config.get("enabled", True):
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
from tweek.plugins.screening.heuristic_scorer import HeuristicScorerPlugin
|
|
254
|
+
_cached_heuristic_scorer = HeuristicScorerPlugin()
|
|
255
|
+
return _cached_heuristic_scorer
|
|
256
|
+
except ImportError:
|
|
257
|
+
return None
|
|
258
|
+
except Exception:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _resolve_enforcement(
|
|
263
|
+
pattern_match: Optional[Dict],
|
|
264
|
+
enforcement_policy: Optional[EnforcementPolicy],
|
|
265
|
+
has_non_pattern_trigger: bool = False,
|
|
266
|
+
memory_adjustment: Optional[Dict] = None,
|
|
267
|
+
) -> str:
|
|
268
|
+
"""Determine the enforcement decision based on severity + confidence.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
pattern_match: The highest-severity pattern match dict, or None.
|
|
272
|
+
enforcement_policy: The active EnforcementPolicy (from overrides config).
|
|
273
|
+
has_non_pattern_trigger: True if LLM/session/sandbox/compliance triggered.
|
|
274
|
+
memory_adjustment: Optional memory-based adjustment suggestion.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Decision string: "deny", "ask", or "log".
|
|
278
|
+
"""
|
|
279
|
+
# Non-pattern triggers (LLM review, session anomaly, etc.) always "ask"
|
|
280
|
+
if not pattern_match:
|
|
281
|
+
if has_non_pattern_trigger:
|
|
282
|
+
return "ask"
|
|
283
|
+
return "log"
|
|
284
|
+
|
|
285
|
+
severity = pattern_match.get("severity", "medium")
|
|
286
|
+
confidence = pattern_match.get("confidence", "heuristic")
|
|
287
|
+
|
|
288
|
+
# Use the enforcement policy matrix if available
|
|
289
|
+
if enforcement_policy:
|
|
290
|
+
decision = enforcement_policy.resolve(severity, confidence)
|
|
291
|
+
elif severity == "critical" and confidence == "deterministic":
|
|
292
|
+
decision = "deny"
|
|
293
|
+
elif severity == "low":
|
|
294
|
+
decision = "log"
|
|
295
|
+
else:
|
|
296
|
+
decision = "ask"
|
|
297
|
+
|
|
298
|
+
# Break-glass: downgrade "deny" to "ask" if active override exists
|
|
299
|
+
if decision == "deny":
|
|
300
|
+
try:
|
|
301
|
+
from tweek.hooks.break_glass import check_override
|
|
302
|
+
override = check_override(pattern_match.get("name", ""))
|
|
303
|
+
if override:
|
|
304
|
+
# Log the break-glass usage
|
|
305
|
+
_log(
|
|
306
|
+
EventType.BREAK_GLASS,
|
|
307
|
+
"break_glass",
|
|
308
|
+
decision="ask",
|
|
309
|
+
decision_reason=(
|
|
310
|
+
f"Break-glass override active for pattern "
|
|
311
|
+
f"'{pattern_match.get('name')}': {override.get('reason', 'no reason')}"
|
|
312
|
+
),
|
|
313
|
+
metadata={
|
|
314
|
+
"pattern_name": pattern_match.get("name"),
|
|
315
|
+
"override_mode": override.get("mode"),
|
|
316
|
+
"override_reason": override.get("reason"),
|
|
317
|
+
"override_created_at": override.get("created_at"),
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
decision = "ask" # Downgrade deny → ask (user still sees prompt)
|
|
321
|
+
except ImportError:
|
|
322
|
+
pass # Break-glass module not available
|
|
323
|
+
|
|
324
|
+
# Memory-based adjustment: only touches "ask" decisions
|
|
325
|
+
if memory_adjustment and decision == "ask":
|
|
326
|
+
try:
|
|
327
|
+
from tweek.memory.safety import validate_memory_adjustment
|
|
328
|
+
suggested = memory_adjustment.get("adjusted_decision")
|
|
329
|
+
if suggested:
|
|
330
|
+
decision = validate_memory_adjustment(
|
|
331
|
+
pattern_name=pattern_match.get("name", "") if pattern_match else "",
|
|
332
|
+
original_severity=pattern_match.get("severity", "medium") if pattern_match else "medium",
|
|
333
|
+
original_confidence=pattern_match.get("confidence", "heuristic") if pattern_match else "heuristic",
|
|
334
|
+
suggested_decision=suggested,
|
|
335
|
+
current_decision=decision,
|
|
336
|
+
)
|
|
337
|
+
except Exception:
|
|
338
|
+
pass # Memory is best-effort
|
|
339
|
+
|
|
340
|
+
return decision
|
|
341
|
+
|
|
342
|
+
|
|
198
343
|
class TierManager:
|
|
199
344
|
"""Manages security tier classification and escalation."""
|
|
200
345
|
|
|
@@ -239,7 +384,7 @@ class TierManager:
|
|
|
239
384
|
for escalation in self.escalations:
|
|
240
385
|
pattern = escalation.get("pattern", "")
|
|
241
386
|
try:
|
|
242
|
-
if re.search(pattern, content, re.IGNORECASE):
|
|
387
|
+
if re.search(pattern, content, re.IGNORECASE | re.DOTALL):
|
|
243
388
|
target_tier = escalation.get("escalate_to", "default")
|
|
244
389
|
priority = tier_priority.get(target_tier, 1)
|
|
245
390
|
if priority > highest_priority:
|
|
@@ -250,33 +395,134 @@ class TierManager:
|
|
|
250
395
|
|
|
251
396
|
return highest_match
|
|
252
397
|
|
|
398
|
+
def check_path_escalation(
|
|
399
|
+
self,
|
|
400
|
+
target_paths: List[str],
|
|
401
|
+
working_dir: Optional[str],
|
|
402
|
+
) -> Optional[Dict]:
|
|
403
|
+
"""Check if any target paths are outside the working directory.
|
|
404
|
+
|
|
405
|
+
Returns an escalation dict if an out-of-project path is detected,
|
|
406
|
+
or None. The dict has the same shape as content-based escalations
|
|
407
|
+
(escalate_to, description keys) plus path_boundary and target_path.
|
|
408
|
+
"""
|
|
409
|
+
if not working_dir or not target_paths:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
cwd_resolved = Path(working_dir).expanduser().resolve()
|
|
414
|
+
except (OSError, ValueError):
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
path_config = self.config.get("path_boundary", {})
|
|
418
|
+
if not path_config.get("enabled", True):
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
sensitive_dirs = path_config.get("sensitive_directories", [
|
|
422
|
+
{"pattern": ".ssh", "escalate_to": "dangerous", "description": "SSH directory access"},
|
|
423
|
+
{"pattern": ".aws", "escalate_to": "dangerous", "description": "AWS credentials directory"},
|
|
424
|
+
{"pattern": ".gnupg", "escalate_to": "dangerous", "description": "GPG keyring directory"},
|
|
425
|
+
{"pattern": ".kube", "escalate_to": "dangerous", "description": "Kubernetes config directory"},
|
|
426
|
+
{"pattern": "/etc/shadow", "escalate_to": "dangerous", "description": "System shadow file"},
|
|
427
|
+
{"pattern": "/etc/sudoers", "escalate_to": "dangerous", "description": "Sudoers file"},
|
|
428
|
+
{"pattern": "/etc/passwd", "escalate_to": "risky", "description": "System passwd file"},
|
|
429
|
+
])
|
|
430
|
+
|
|
431
|
+
default_escalate_to = path_config.get("default_escalate_to", "risky")
|
|
432
|
+
|
|
433
|
+
tier_priority = {"safe": 0, "default": 1, "risky": 2, "dangerous": 3}
|
|
434
|
+
highest_escalation = None
|
|
435
|
+
highest_priority = -1
|
|
436
|
+
|
|
437
|
+
for target_path_str in target_paths:
|
|
438
|
+
try:
|
|
439
|
+
target_resolved = Path(target_path_str).expanduser().resolve()
|
|
440
|
+
except (OSError, ValueError):
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Check if path is inside the project
|
|
444
|
+
try:
|
|
445
|
+
target_resolved.relative_to(cwd_resolved)
|
|
446
|
+
continue # Inside project -- no escalation
|
|
447
|
+
except ValueError:
|
|
448
|
+
pass # Outside project -- check further
|
|
449
|
+
|
|
450
|
+
# Path is outside project. Check sensitive directories.
|
|
451
|
+
target_str_lower = str(target_resolved).lower()
|
|
452
|
+
|
|
453
|
+
matched_sensitive = False
|
|
454
|
+
for sensitive in sensitive_dirs:
|
|
455
|
+
pattern = sensitive.get("pattern", "")
|
|
456
|
+
if pattern and pattern in target_str_lower:
|
|
457
|
+
esc_to = sensitive.get("escalate_to", "dangerous")
|
|
458
|
+
priority = tier_priority.get(esc_to, 2)
|
|
459
|
+
if priority > highest_priority:
|
|
460
|
+
highest_priority = priority
|
|
461
|
+
highest_escalation = {
|
|
462
|
+
"escalate_to": esc_to,
|
|
463
|
+
"description": f"Out-of-project access: {sensitive.get('description', pattern)}",
|
|
464
|
+
"path_boundary": True,
|
|
465
|
+
"target_path": target_path_str,
|
|
466
|
+
}
|
|
467
|
+
matched_sensitive = True
|
|
468
|
+
break
|
|
469
|
+
|
|
470
|
+
if not matched_sensitive:
|
|
471
|
+
priority = tier_priority.get(default_escalate_to, 2)
|
|
472
|
+
if priority > highest_priority:
|
|
473
|
+
highest_priority = priority
|
|
474
|
+
highest_escalation = {
|
|
475
|
+
"escalate_to": default_escalate_to,
|
|
476
|
+
"description": f"Out-of-project file access: {target_path_str}",
|
|
477
|
+
"path_boundary": True,
|
|
478
|
+
"target_path": target_path_str,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return highest_escalation
|
|
482
|
+
|
|
253
483
|
def get_effective_tier(
|
|
254
484
|
self,
|
|
255
485
|
tool_name: str,
|
|
256
486
|
content: str,
|
|
257
|
-
skill_name: Optional[str] = None
|
|
487
|
+
skill_name: Optional[str] = None,
|
|
488
|
+
target_paths: Optional[List[str]] = None,
|
|
489
|
+
working_dir: Optional[str] = None,
|
|
258
490
|
) -> tuple[str, Optional[Dict]]:
|
|
259
|
-
"""Get the effective tier after checking escalations.
|
|
491
|
+
"""Get the effective tier after checking content and path escalations.
|
|
260
492
|
|
|
261
493
|
Returns (tier, escalation_match) where escalation_match is None
|
|
262
|
-
if no escalation occurred.
|
|
494
|
+
if no escalation occurred. Takes the highest of base tier,
|
|
495
|
+
content-based escalation, and path-boundary escalation.
|
|
263
496
|
"""
|
|
497
|
+
tier_priority = {"safe": 0, "default": 1, "risky": 2, "dangerous": 3}
|
|
498
|
+
|
|
264
499
|
base_tier = self.get_base_tier(tool_name, skill_name)
|
|
265
|
-
|
|
500
|
+
content_escalation = self.check_escalations(content)
|
|
501
|
+
path_escalation = self.check_path_escalation(
|
|
502
|
+
target_paths or [], working_dir
|
|
503
|
+
)
|
|
266
504
|
|
|
267
|
-
|
|
268
|
-
|
|
505
|
+
best_tier = base_tier
|
|
506
|
+
best_priority = tier_priority.get(base_tier, 1)
|
|
507
|
+
best_escalation = None
|
|
269
508
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
509
|
+
if content_escalation:
|
|
510
|
+
esc_tier = content_escalation.get("escalate_to", "default")
|
|
511
|
+
esc_priority = tier_priority.get(esc_tier, 1)
|
|
512
|
+
if esc_priority > best_priority:
|
|
513
|
+
best_tier = esc_tier
|
|
514
|
+
best_priority = esc_priority
|
|
515
|
+
best_escalation = content_escalation
|
|
274
516
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
517
|
+
if path_escalation:
|
|
518
|
+
esc_tier = path_escalation.get("escalate_to", "default")
|
|
519
|
+
esc_priority = tier_priority.get(esc_tier, 1)
|
|
520
|
+
if esc_priority > best_priority:
|
|
521
|
+
best_tier = esc_tier
|
|
522
|
+
best_priority = esc_priority
|
|
523
|
+
best_escalation = path_escalation
|
|
278
524
|
|
|
279
|
-
return
|
|
525
|
+
return best_tier, best_escalation
|
|
280
526
|
|
|
281
527
|
def get_screening_methods(self, tier: str) -> List[str]:
|
|
282
528
|
"""Get the screening methods for a tier."""
|
|
@@ -288,16 +534,34 @@ class PatternMatcher:
|
|
|
288
534
|
"""Matches commands against hostile patterns."""
|
|
289
535
|
|
|
290
536
|
def __init__(self, patterns_path: Optional[Path] = None):
|
|
291
|
-
#
|
|
537
|
+
# Always load bundled patterns first, then merge user patterns on top
|
|
292
538
|
user_patterns = Path.home() / ".tweek" / "patterns" / "patterns.yaml"
|
|
293
539
|
bundled_patterns = Path(__file__).parent.parent / "config" / "patterns.yaml"
|
|
294
540
|
|
|
295
541
|
if patterns_path is not None:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
542
|
+
# Explicit path: load bundled + explicit (for testing)
|
|
543
|
+
self.patterns = self._load_patterns(bundled_patterns)
|
|
544
|
+
extra = self._load_patterns(patterns_path)
|
|
545
|
+
self._merge_patterns(extra)
|
|
299
546
|
else:
|
|
547
|
+
# Always start with bundled patterns
|
|
300
548
|
self.patterns = self._load_patterns(bundled_patterns)
|
|
549
|
+
# Merge user patterns on top (additive, not replacement)
|
|
550
|
+
if user_patterns.exists():
|
|
551
|
+
extra = self._load_patterns(user_patterns)
|
|
552
|
+
self._merge_patterns(extra)
|
|
553
|
+
|
|
554
|
+
def _merge_patterns(self, extra_patterns: List[dict]):
|
|
555
|
+
"""Merge additional patterns into existing set (additive).
|
|
556
|
+
|
|
557
|
+
User patterns supplement bundled patterns — they cannot replace or
|
|
558
|
+
remove bundled patterns. Duplicate names are skipped.
|
|
559
|
+
"""
|
|
560
|
+
existing_names = {p.get("name") for p in self.patterns}
|
|
561
|
+
for pattern in extra_patterns:
|
|
562
|
+
if pattern.get("name") not in existing_names:
|
|
563
|
+
self.patterns.append(pattern)
|
|
564
|
+
existing_names.add(pattern.get("name"))
|
|
301
565
|
|
|
302
566
|
def _load_patterns(self, path: Path) -> List[dict]:
|
|
303
567
|
"""Load patterns from YAML config.
|
|
@@ -313,14 +577,21 @@ class PatternMatcher:
|
|
|
313
577
|
|
|
314
578
|
return config.get("patterns", [])
|
|
315
579
|
|
|
580
|
+
@staticmethod
|
|
581
|
+
def _normalize(content: str) -> str:
|
|
582
|
+
"""Normalize Unicode to defeat homoglyph evasion (e.g., Cyrillic 'а' → Latin 'a')."""
|
|
583
|
+
import unicodedata
|
|
584
|
+
return unicodedata.normalize("NFKC", content)
|
|
585
|
+
|
|
316
586
|
def check(self, content: str) -> Optional[dict]:
|
|
317
587
|
"""Check content against all patterns.
|
|
318
588
|
|
|
319
589
|
Returns the first matching pattern, or None.
|
|
320
590
|
"""
|
|
591
|
+
content = self._normalize(content)
|
|
321
592
|
for pattern in self.patterns:
|
|
322
593
|
try:
|
|
323
|
-
if re.search(pattern.get("regex", ""), content, re.IGNORECASE):
|
|
594
|
+
if re.search(pattern.get("regex", ""), content, re.IGNORECASE | re.DOTALL):
|
|
324
595
|
return pattern
|
|
325
596
|
except re.error:
|
|
326
597
|
continue
|
|
@@ -328,10 +599,11 @@ class PatternMatcher:
|
|
|
328
599
|
|
|
329
600
|
def check_all(self, content: str) -> List[dict]:
|
|
330
601
|
"""Check content against all patterns, returning all matches."""
|
|
602
|
+
content = self._normalize(content)
|
|
331
603
|
matches = []
|
|
332
604
|
for pattern in self.patterns:
|
|
333
605
|
try:
|
|
334
|
-
if re.search(pattern.get("regex", ""), content, re.IGNORECASE):
|
|
606
|
+
if re.search(pattern.get("regex", ""), content, re.IGNORECASE | re.DOTALL):
|
|
335
607
|
matches.append(pattern)
|
|
336
608
|
except re.error:
|
|
337
609
|
continue
|
|
@@ -402,6 +674,150 @@ def format_prompt_message(
|
|
|
402
674
|
return "\n".join(lines)
|
|
403
675
|
|
|
404
676
|
|
|
677
|
+
def _check_project_skill_fingerprints(working_dir: Optional[str], _log) -> None:
|
|
678
|
+
"""
|
|
679
|
+
Check project skills for new/modified SKILL.md files via fingerprint cache.
|
|
680
|
+
|
|
681
|
+
If a new or modified skill is detected (e.g. from git pull/clone), it is
|
|
682
|
+
routed through the isolation chamber for security scanning. Results:
|
|
683
|
+
- PASS: skill stays in place, fingerprint recorded
|
|
684
|
+
- FAIL: skill removed from project dir, jailed, user warned
|
|
685
|
+
- MANUAL_REVIEW: skill moved to chamber, user warned
|
|
686
|
+
"""
|
|
687
|
+
if not working_dir:
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
from tweek.skills.config import load_isolation_config
|
|
691
|
+
|
|
692
|
+
config = load_isolation_config()
|
|
693
|
+
if not config.enabled:
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
fingerprints = get_fingerprints()
|
|
697
|
+
changed = fingerprints.check_project_skills(Path(working_dir))
|
|
698
|
+
|
|
699
|
+
if not changed:
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Import chamber lazily to avoid circular imports and overhead on clean runs
|
|
703
|
+
from tweek.skills.isolation import SkillIsolationChamber
|
|
704
|
+
|
|
705
|
+
chamber = SkillIsolationChamber(config=config)
|
|
706
|
+
|
|
707
|
+
for skill_path, status in changed:
|
|
708
|
+
skill_name = skill_path.parent.name
|
|
709
|
+
_log(
|
|
710
|
+
EventType.TOOL_INVOKED,
|
|
711
|
+
"SkillFingerprint",
|
|
712
|
+
tier="skill_guard",
|
|
713
|
+
decision="scan",
|
|
714
|
+
decision_reason=f"Git-arrived skill detected: {skill_name} ({status})",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
report, msg = chamber.accept_and_scan(
|
|
718
|
+
skill_path.parent,
|
|
719
|
+
skill_name=f"git-{skill_name}",
|
|
720
|
+
target="project",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
if report.verdict == "pass":
|
|
724
|
+
# Record the fingerprint so we don't re-scan
|
|
725
|
+
fingerprints.register(
|
|
726
|
+
skill_path,
|
|
727
|
+
verdict="pass",
|
|
728
|
+
report_path=msg,
|
|
729
|
+
)
|
|
730
|
+
elif report.verdict == "fail":
|
|
731
|
+
# Remove from project dir — it's now in jail
|
|
732
|
+
import shutil
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
shutil.rmtree(skill_path.parent)
|
|
736
|
+
except OSError:
|
|
737
|
+
pass
|
|
738
|
+
print(
|
|
739
|
+
f"TWEEK SECURITY: Skill '{skill_name}' FAILED security scan "
|
|
740
|
+
f"and was removed from project. See: tweek skills jail list",
|
|
741
|
+
file=sys.stderr,
|
|
742
|
+
)
|
|
743
|
+
elif report.verdict == "manual_review":
|
|
744
|
+
print(
|
|
745
|
+
f"TWEEK SECURITY: Skill '{skill_name}' requires manual review. "
|
|
746
|
+
f"Run: tweek skills chamber approve git-{skill_name}",
|
|
747
|
+
file=sys.stderr,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# =============================================================================
|
|
752
|
+
# PATH EXTRACTION: Extract filesystem target paths from tool inputs
|
|
753
|
+
# =============================================================================
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _extract_paths_from_bash(command: str) -> List[str]:
|
|
757
|
+
"""Best-effort extraction of filesystem paths from a bash command.
|
|
758
|
+
|
|
759
|
+
Catches obvious cases like:
|
|
760
|
+
cat /etc/passwd
|
|
761
|
+
cp ~/.ssh/id_rsa /tmp/key
|
|
762
|
+
python3 /home/user/script.py
|
|
763
|
+
|
|
764
|
+
Intentionally simple -- does NOT handle pipes, subshells, or variable
|
|
765
|
+
expansion. Content-based escalations (259 patterns) cover the rest.
|
|
766
|
+
"""
|
|
767
|
+
import shlex
|
|
768
|
+
|
|
769
|
+
paths: List[str] = []
|
|
770
|
+
if not command:
|
|
771
|
+
return paths
|
|
772
|
+
|
|
773
|
+
# Only look at the first simple command (before |, &&, ||, ;)
|
|
774
|
+
first_cmd = re.split(r'[|;&]', command)[0].strip()
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
tokens = shlex.split(first_cmd)
|
|
778
|
+
except ValueError:
|
|
779
|
+
# Malformed quotes -- fall back to simple split
|
|
780
|
+
tokens = first_cmd.split()
|
|
781
|
+
|
|
782
|
+
for token in tokens:
|
|
783
|
+
if token.startswith('-'):
|
|
784
|
+
continue
|
|
785
|
+
if token.startswith('/') or token.startswith('~'):
|
|
786
|
+
paths.append(token)
|
|
787
|
+
|
|
788
|
+
return paths
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def extract_target_paths(tool_name: str, tool_input: Dict[str, Any]) -> List[str]:
|
|
792
|
+
"""Extract filesystem paths from a tool invocation for boundary checking.
|
|
793
|
+
|
|
794
|
+
Returns a list of path strings. For tools with no filesystem target
|
|
795
|
+
(WebFetch, WebSearch), returns an empty list.
|
|
796
|
+
"""
|
|
797
|
+
paths: List[str] = []
|
|
798
|
+
|
|
799
|
+
if tool_name in ("Read", "Write", "Edit"):
|
|
800
|
+
fp = tool_input.get("file_path", "")
|
|
801
|
+
if fp:
|
|
802
|
+
paths.append(fp)
|
|
803
|
+
elif tool_name == "NotebookEdit":
|
|
804
|
+
np = tool_input.get("notebook_path", "")
|
|
805
|
+
if np:
|
|
806
|
+
paths.append(np)
|
|
807
|
+
elif tool_name == "Glob":
|
|
808
|
+
gp = tool_input.get("path", "")
|
|
809
|
+
if gp:
|
|
810
|
+
paths.append(gp)
|
|
811
|
+
elif tool_name == "Grep":
|
|
812
|
+
gp = tool_input.get("path", "")
|
|
813
|
+
if gp:
|
|
814
|
+
paths.append(gp)
|
|
815
|
+
elif tool_name == "Bash":
|
|
816
|
+
paths.extend(_extract_paths_from_bash(tool_input.get("command", "")))
|
|
817
|
+
|
|
818
|
+
return paths
|
|
819
|
+
|
|
820
|
+
|
|
405
821
|
def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
406
822
|
"""Main hook logic with tiered security screening.
|
|
407
823
|
|
|
@@ -425,7 +841,7 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
425
841
|
working_dir = input_data.get("cwd")
|
|
426
842
|
|
|
427
843
|
# Generate correlation ID to link all events in this screening pass
|
|
428
|
-
correlation_id = uuid.uuid4().hex[:
|
|
844
|
+
correlation_id = uuid.uuid4().hex[:16]
|
|
429
845
|
|
|
430
846
|
def _log(event_type, tool, **kwargs):
|
|
431
847
|
"""Log with correlation_id, source, and session_id automatically included."""
|
|
@@ -436,19 +852,241 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
436
852
|
**kwargs
|
|
437
853
|
)
|
|
438
854
|
|
|
439
|
-
#
|
|
855
|
+
# =========================================================================
|
|
856
|
+
# PROJECT SANDBOX: Initialize per-project security state isolation
|
|
857
|
+
# Provides project-scoped logger, overrides, and fingerprints (Layer 2)
|
|
858
|
+
# =========================================================================
|
|
859
|
+
_sandbox = None
|
|
860
|
+
try:
|
|
861
|
+
_sandbox = get_project_sandbox(working_dir)
|
|
862
|
+
if _sandbox:
|
|
863
|
+
# Replace logger with project-scoped one (Python closures are
|
|
864
|
+
# late-binding, so _log will use the new logger automatically)
|
|
865
|
+
logger = _sandbox.get_logger()
|
|
866
|
+
except Exception:
|
|
867
|
+
# Sandbox init is best-effort — fall back to global state
|
|
868
|
+
_sandbox = None
|
|
869
|
+
|
|
870
|
+
# =========================================================================
|
|
871
|
+
# SKILL FINGERPRINT CHECK: Detect new/modified git-arrived skills
|
|
872
|
+
# Lightweight SHA-256 check; full scan only on first encounter or change
|
|
873
|
+
# =========================================================================
|
|
874
|
+
try:
|
|
875
|
+
_check_project_skill_fingerprints(working_dir, _log)
|
|
876
|
+
except Exception:
|
|
877
|
+
# Fingerprint check is best-effort — never break the hook
|
|
878
|
+
pass
|
|
879
|
+
|
|
880
|
+
# =========================================================================
|
|
881
|
+
# SELF-PROTECTION: Block AI from modifying security override config
|
|
882
|
+
# This file can only be edited by a human directly.
|
|
883
|
+
# =========================================================================
|
|
884
|
+
if tool_name in ("Write", "Edit"):
|
|
885
|
+
target_path = tool_input.get("file_path", "")
|
|
886
|
+
if is_protected_config_file(target_path):
|
|
887
|
+
_log(
|
|
888
|
+
EventType.BLOCKED,
|
|
889
|
+
tool_name,
|
|
890
|
+
tier="self_protection",
|
|
891
|
+
decision="deny",
|
|
892
|
+
decision_reason=f"BLOCKED: AI cannot modify security override config: {target_path}",
|
|
893
|
+
)
|
|
894
|
+
return {
|
|
895
|
+
"hookSpecificOutput": {
|
|
896
|
+
"hookEventName": "PreToolUse",
|
|
897
|
+
"permissionDecision": "deny",
|
|
898
|
+
"permissionDecisionReason": (
|
|
899
|
+
"TWEEK SELF-PROTECTION: This file can only be edited by a human.\n"
|
|
900
|
+
f"File: {target_path}\n"
|
|
901
|
+
"Security overrides (whitelist, pattern toggles, trust levels) are human-only.\n"
|
|
902
|
+
"Edit this file directly in your text editor."
|
|
903
|
+
),
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if tool_name == "Bash":
|
|
908
|
+
command = tool_input.get("command", "")
|
|
909
|
+
if bash_targets_protected_config(command):
|
|
910
|
+
_log(
|
|
911
|
+
EventType.BLOCKED,
|
|
912
|
+
tool_name,
|
|
913
|
+
tier="self_protection",
|
|
914
|
+
decision="deny",
|
|
915
|
+
decision_reason="BLOCKED: Bash command targets protected config file",
|
|
916
|
+
)
|
|
917
|
+
return {
|
|
918
|
+
"hookSpecificOutput": {
|
|
919
|
+
"hookEventName": "PreToolUse",
|
|
920
|
+
"permissionDecision": "deny",
|
|
921
|
+
"permissionDecisionReason": (
|
|
922
|
+
"TWEEK SELF-PROTECTION: Cannot modify security override config via shell.\n"
|
|
923
|
+
"Security overrides can only be edited by a human directly."
|
|
924
|
+
),
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
# Block AI from running tweek trust/untrust/uninstall — human-only commands
|
|
929
|
+
command_stripped = command.strip()
|
|
930
|
+
if re.match(r"tweek\s+(trust|untrust|uninstall)\b", command_stripped):
|
|
931
|
+
if "uninstall" in command_stripped:
|
|
932
|
+
reason = (
|
|
933
|
+
"TWEEK SELF-PROTECTION: Uninstall must be done by a human.\n"
|
|
934
|
+
"Run this command directly in your terminal:\n"
|
|
935
|
+
f" {command_stripped}\n"
|
|
936
|
+
"AI agents cannot remove security protections."
|
|
937
|
+
)
|
|
938
|
+
log_reason = "BLOCKED: AI cannot run tweek uninstall"
|
|
939
|
+
else:
|
|
940
|
+
reason = (
|
|
941
|
+
"TWEEK SELF-PROTECTION: Trust decisions must be made by a human.\n"
|
|
942
|
+
"Run this command directly in your terminal:\n"
|
|
943
|
+
f" {command_stripped}\n"
|
|
944
|
+
"Trust settings control which projects are exempt from security screening."
|
|
945
|
+
)
|
|
946
|
+
log_reason = "BLOCKED: AI cannot modify trust settings"
|
|
947
|
+
_log(
|
|
948
|
+
EventType.BLOCKED,
|
|
949
|
+
tool_name,
|
|
950
|
+
tier="self_protection",
|
|
951
|
+
decision="deny",
|
|
952
|
+
decision_reason=log_reason,
|
|
953
|
+
)
|
|
954
|
+
return {
|
|
955
|
+
"hookSpecificOutput": {
|
|
956
|
+
"hookEventName": "PreToolUse",
|
|
957
|
+
"permissionDecision": "deny",
|
|
958
|
+
"permissionDecisionReason": reason,
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
# =========================================================================
|
|
963
|
+
# SKILL GUARD: Block direct skill installation bypassing isolation chamber
|
|
964
|
+
# =========================================================================
|
|
965
|
+
skill_block_reason = get_skill_guard_reason(tool_name, tool_input)
|
|
966
|
+
if skill_block_reason:
|
|
967
|
+
_log(
|
|
968
|
+
EventType.BLOCKED,
|
|
969
|
+
tool_name,
|
|
970
|
+
tier="skill_guard",
|
|
971
|
+
decision="deny",
|
|
972
|
+
decision_reason=skill_block_reason,
|
|
973
|
+
)
|
|
974
|
+
return {
|
|
975
|
+
"hookSpecificOutput": {
|
|
976
|
+
"hookEventName": "PreToolUse",
|
|
977
|
+
"permissionDecision": "deny",
|
|
978
|
+
"permissionDecisionReason": skill_block_reason,
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
# Skill download detection: prompt user instead of blocking
|
|
983
|
+
if tool_name == "Bash":
|
|
984
|
+
download_prompt = get_skill_download_prompt(tool_input.get("command", ""))
|
|
985
|
+
if download_prompt:
|
|
986
|
+
_log(
|
|
987
|
+
EventType.TOOL_INVOKED,
|
|
988
|
+
tool_name,
|
|
989
|
+
tier="skill_guard",
|
|
990
|
+
decision="ask",
|
|
991
|
+
decision_reason="Potential skill download detected",
|
|
992
|
+
)
|
|
993
|
+
return {
|
|
994
|
+
"hookSpecificOutput": {
|
|
995
|
+
"hookEventName": "PreToolUse",
|
|
996
|
+
"permissionDecision": "ask",
|
|
997
|
+
"permissionDecisionReason": download_prompt,
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
# Extract target filesystem paths for boundary checking
|
|
1002
|
+
target_paths = extract_target_paths(tool_name, tool_input)
|
|
1003
|
+
|
|
1004
|
+
# Extract content to analyze (command for Bash, path + payload for Write/Edit, etc.)
|
|
440
1005
|
if tool_name == "Bash":
|
|
441
1006
|
content = tool_input.get("command", "")
|
|
442
|
-
elif tool_name
|
|
1007
|
+
elif tool_name == "Read":
|
|
443
1008
|
content = tool_input.get("file_path", "")
|
|
1009
|
+
elif tool_name == "Write":
|
|
1010
|
+
# Screen both path and content payload
|
|
1011
|
+
file_path = tool_input.get("file_path", "")
|
|
1012
|
+
file_content = tool_input.get("content", "")
|
|
1013
|
+
content = f"{file_path}\n{file_content}" if file_content else file_path
|
|
1014
|
+
elif tool_name == "Edit":
|
|
1015
|
+
# Screen path, old content (may carry injection context), and new content
|
|
1016
|
+
file_path = tool_input.get("file_path", "")
|
|
1017
|
+
old_string = tool_input.get("old_string", "")
|
|
1018
|
+
new_string = tool_input.get("new_string", "")
|
|
1019
|
+
content = "\n".join(filter(None, [file_path, old_string, new_string]))
|
|
1020
|
+
elif tool_name == "NotebookEdit":
|
|
1021
|
+
# Screen notebook path and cell source content
|
|
1022
|
+
notebook_path = tool_input.get("notebook_path", "")
|
|
1023
|
+
new_source = tool_input.get("new_source", "")
|
|
1024
|
+
content = f"{notebook_path}\n{new_source}" if new_source else notebook_path
|
|
444
1025
|
elif tool_name == "WebFetch":
|
|
445
|
-
|
|
1026
|
+
# Screen both URL and prompt (prompt could carry injection)
|
|
1027
|
+
url = tool_input.get("url", "")
|
|
1028
|
+
prompt = tool_input.get("prompt", "")
|
|
1029
|
+
content = f"{url}\n{prompt}" if prompt else url
|
|
1030
|
+
elif tool_name == "WebSearch":
|
|
1031
|
+
content = tool_input.get("query", "")
|
|
1032
|
+
elif tool_name == "Glob":
|
|
1033
|
+
content = tool_input.get("pattern", "")
|
|
1034
|
+
glob_path = tool_input.get("path", "")
|
|
1035
|
+
if glob_path:
|
|
1036
|
+
content = f"{glob_path}\n{content}"
|
|
1037
|
+
elif tool_name == "Grep":
|
|
1038
|
+
content = tool_input.get("pattern", "")
|
|
1039
|
+
grep_path = tool_input.get("path", "")
|
|
1040
|
+
if grep_path:
|
|
1041
|
+
content = f"{grep_path}\n{content}"
|
|
446
1042
|
else:
|
|
447
1043
|
content = json.dumps(tool_input)
|
|
448
1044
|
|
|
449
1045
|
if not content:
|
|
450
1046
|
return {}
|
|
451
1047
|
|
|
1048
|
+
# =========================================================================
|
|
1049
|
+
# SELF-TRUST: Skip screening when reading verified Tweek source files.
|
|
1050
|
+
# Content-based (SHA-256), not path-based. Prevents false positives
|
|
1051
|
+
# when AI reads Tweek's own patterns, hooks, and security modules.
|
|
1052
|
+
# =========================================================================
|
|
1053
|
+
if tool_name in ("Read", "Grep"):
|
|
1054
|
+
try:
|
|
1055
|
+
from tweek.security.integrity import is_trusted_tweek_file
|
|
1056
|
+
read_path = tool_input.get("file_path") or tool_input.get("path") or ""
|
|
1057
|
+
if read_path and is_trusted_tweek_file(read_path):
|
|
1058
|
+
_log(
|
|
1059
|
+
EventType.ALLOWED,
|
|
1060
|
+
tool_name,
|
|
1061
|
+
tier="self_trust",
|
|
1062
|
+
decision="allow",
|
|
1063
|
+
decision_reason=f"Self-trust: verified Tweek file {Path(read_path).name}",
|
|
1064
|
+
metadata={"self_trust": True, "file": read_path},
|
|
1065
|
+
)
|
|
1066
|
+
return {}
|
|
1067
|
+
except Exception:
|
|
1068
|
+
pass # Best-effort — fall through to normal screening
|
|
1069
|
+
|
|
1070
|
+
# =========================================================================
|
|
1071
|
+
# WHITELIST CHECK: Skip screening for whitelisted tool+path combinations
|
|
1072
|
+
# Uses project-scoped overrides (additive-only merge) if sandbox active
|
|
1073
|
+
# =========================================================================
|
|
1074
|
+
overrides = _sandbox.get_overrides() if _sandbox else get_overrides()
|
|
1075
|
+
enforcement_policy = overrides.get_enforcement_policy() if overrides else EnforcementPolicy()
|
|
1076
|
+
if overrides:
|
|
1077
|
+
whitelist_match = overrides.check_whitelist(tool_name, tool_input, content)
|
|
1078
|
+
if whitelist_match:
|
|
1079
|
+
_log(
|
|
1080
|
+
EventType.ALLOWED,
|
|
1081
|
+
tool_name,
|
|
1082
|
+
command=content if tool_name == "Bash" else None,
|
|
1083
|
+
tier="whitelisted",
|
|
1084
|
+
decision="allow",
|
|
1085
|
+
decision_reason=f"Whitelisted: {whitelist_match.get('reason', 'matched whitelist rule')}",
|
|
1086
|
+
metadata={"whitelist_rule": whitelist_match}
|
|
1087
|
+
)
|
|
1088
|
+
return {}
|
|
1089
|
+
|
|
452
1090
|
# =========================================================================
|
|
453
1091
|
# LAYER 0: Compliance Scanning (INPUT direction)
|
|
454
1092
|
# Scan incoming content for sensitive data before processing
|
|
@@ -470,14 +1108,69 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
470
1108
|
}
|
|
471
1109
|
}
|
|
472
1110
|
|
|
473
|
-
#
|
|
474
|
-
tier_mgr =
|
|
475
|
-
pattern_matcher =
|
|
1111
|
+
# Use cached managers (avoids YAML re-parsing on every invocation)
|
|
1112
|
+
tier_mgr = _get_tier_manager()
|
|
1113
|
+
pattern_matcher = _get_pattern_matcher()
|
|
476
1114
|
|
|
477
1115
|
# Determine effective tier
|
|
478
|
-
effective_tier, escalation = tier_mgr.get_effective_tier(
|
|
1116
|
+
effective_tier, escalation = tier_mgr.get_effective_tier(
|
|
1117
|
+
tool_name, content,
|
|
1118
|
+
target_paths=target_paths,
|
|
1119
|
+
working_dir=working_dir,
|
|
1120
|
+
)
|
|
479
1121
|
screening_methods = tier_mgr.get_screening_methods(effective_tier)
|
|
480
1122
|
|
|
1123
|
+
# =========================================================================
|
|
1124
|
+
# Non-English Language Detection
|
|
1125
|
+
# Escalate tier when non-English content detected so LLM review can catch
|
|
1126
|
+
# prompt injection in other languages that bypass English-only regex patterns
|
|
1127
|
+
# =========================================================================
|
|
1128
|
+
try:
|
|
1129
|
+
from tweek.security.language import detect_non_english, NonEnglishHandling
|
|
1130
|
+
|
|
1131
|
+
# Load handling mode from config (default: escalate)
|
|
1132
|
+
ne_handling_str = tier_mgr.config.get("non_english_handling", "escalate")
|
|
1133
|
+
try:
|
|
1134
|
+
ne_handling = NonEnglishHandling(ne_handling_str)
|
|
1135
|
+
except ValueError:
|
|
1136
|
+
ne_handling = NonEnglishHandling.ESCALATE
|
|
1137
|
+
|
|
1138
|
+
if ne_handling != NonEnglishHandling.NONE:
|
|
1139
|
+
lang_result = detect_non_english(content)
|
|
1140
|
+
|
|
1141
|
+
if lang_result.has_non_english and lang_result.confidence >= 0.3:
|
|
1142
|
+
_log(
|
|
1143
|
+
EventType.ESCALATION,
|
|
1144
|
+
tool_name,
|
|
1145
|
+
command=content if tool_name == "Bash" else None,
|
|
1146
|
+
tier=effective_tier,
|
|
1147
|
+
decision_reason=f"Non-English content detected ({', '.join(lang_result.detected_scripts)}, confidence: {lang_result.confidence:.0%})",
|
|
1148
|
+
metadata={
|
|
1149
|
+
"language_detection": {
|
|
1150
|
+
"scripts": lang_result.detected_scripts,
|
|
1151
|
+
"confidence": lang_result.confidence,
|
|
1152
|
+
"ratio": lang_result.non_english_ratio,
|
|
1153
|
+
"sample": lang_result.sample,
|
|
1154
|
+
"handling": ne_handling.value,
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
if ne_handling in (NonEnglishHandling.ESCALATE, NonEnglishHandling.BOTH):
|
|
1160
|
+
# Escalate to at least risky tier to trigger LLM review
|
|
1161
|
+
tier_priority = {"safe": 0, "default": 1, "risky": 2, "dangerous": 3}
|
|
1162
|
+
if tier_priority.get(effective_tier, 1) < tier_priority["risky"]:
|
|
1163
|
+
effective_tier = "risky"
|
|
1164
|
+
screening_methods = tier_mgr.get_screening_methods(effective_tier)
|
|
1165
|
+
except ImportError:
|
|
1166
|
+
pass # Language detection module not available
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
_log(
|
|
1169
|
+
EventType.ERROR,
|
|
1170
|
+
tool_name,
|
|
1171
|
+
decision_reason=f"Language detection error: {e}",
|
|
1172
|
+
)
|
|
1173
|
+
|
|
481
1174
|
# Log tool invocation
|
|
482
1175
|
_log(
|
|
483
1176
|
EventType.TOOL_INVOKED,
|
|
@@ -563,8 +1256,27 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
563
1256
|
# LAYER 2: Pattern Matching
|
|
564
1257
|
# =========================================================================
|
|
565
1258
|
pattern_match = None
|
|
1259
|
+
all_pattern_matches = []
|
|
566
1260
|
if "regex" in screening_methods:
|
|
567
|
-
|
|
1261
|
+
all_pattern_matches = pattern_matcher.check_all(content)
|
|
1262
|
+
|
|
1263
|
+
# Apply pattern toggles from overrides (disabled/scoped_disabled patterns)
|
|
1264
|
+
if overrides and all_pattern_matches:
|
|
1265
|
+
working_path = (
|
|
1266
|
+
tool_input.get("file_path", "")
|
|
1267
|
+
or tool_input.get("url", "")
|
|
1268
|
+
or working_dir
|
|
1269
|
+
or ""
|
|
1270
|
+
)
|
|
1271
|
+
all_pattern_matches = overrides.filter_patterns(
|
|
1272
|
+
all_pattern_matches, working_path
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
# Use highest-severity match as primary (critical > high > medium > low)
|
|
1276
|
+
if all_pattern_matches:
|
|
1277
|
+
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
1278
|
+
all_pattern_matches.sort(key=lambda p: severity_order.get(p.get("severity", "medium"), 4))
|
|
1279
|
+
pattern_match = all_pattern_matches[0]
|
|
568
1280
|
|
|
569
1281
|
if pattern_match:
|
|
570
1282
|
_log(
|
|
@@ -578,11 +1290,87 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
578
1290
|
)
|
|
579
1291
|
|
|
580
1292
|
# =========================================================================
|
|
581
|
-
#
|
|
1293
|
+
# MEMORY: Read pattern history for confidence adjustment
|
|
1294
|
+
# =========================================================================
|
|
1295
|
+
memory_adjustment = None
|
|
1296
|
+
if pattern_match:
|
|
1297
|
+
try:
|
|
1298
|
+
from tweek.memory.queries import memory_read_for_pattern
|
|
1299
|
+
from tweek.memory.store import hash_project, normalize_path_prefix
|
|
1300
|
+
_mem_path = (
|
|
1301
|
+
tool_input.get("file_path", "")
|
|
1302
|
+
or tool_input.get("url", "")
|
|
1303
|
+
or working_dir
|
|
1304
|
+
or ""
|
|
1305
|
+
)
|
|
1306
|
+
memory_adjustment = memory_read_for_pattern(
|
|
1307
|
+
pattern_name=pattern_match.get("name", ""),
|
|
1308
|
+
pattern_severity=pattern_match.get("severity", "medium"),
|
|
1309
|
+
pattern_confidence=pattern_match.get("confidence", "heuristic"),
|
|
1310
|
+
tool_name=tool_name,
|
|
1311
|
+
path_prefix=_mem_path,
|
|
1312
|
+
project_hash=hash_project(working_dir) if working_dir else None,
|
|
1313
|
+
)
|
|
1314
|
+
except Exception:
|
|
1315
|
+
pass # Memory is best-effort
|
|
1316
|
+
|
|
1317
|
+
# =========================================================================
|
|
1318
|
+
# LAYER 2.5: Heuristic Scoring (Confidence-Gated LLM Escalation)
|
|
1319
|
+
# Bridges the gap between regex patterns (Layer 2) and LLM review (Layer 3).
|
|
1320
|
+
# Runs ONLY when: no regex match AND LLM not already scheduled.
|
|
1321
|
+
# If score exceeds threshold, adds "llm" to screening_methods.
|
|
1322
|
+
# =========================================================================
|
|
1323
|
+
heuristic_escalated = False
|
|
1324
|
+
if not pattern_match and "llm" not in screening_methods:
|
|
1325
|
+
try:
|
|
1326
|
+
scorer = _get_heuristic_scorer(tier_mgr)
|
|
1327
|
+
if scorer:
|
|
1328
|
+
heuristic_result = scorer.screen(
|
|
1329
|
+
tool_name=tool_name,
|
|
1330
|
+
content=content,
|
|
1331
|
+
context={
|
|
1332
|
+
"tier": effective_tier,
|
|
1333
|
+
"tool_input": tool_input,
|
|
1334
|
+
"working_dir": working_dir,
|
|
1335
|
+
}
|
|
1336
|
+
)
|
|
1337
|
+
heuristic_score = heuristic_result.details.get("heuristic_score", 0.0)
|
|
1338
|
+
should_escalate = heuristic_result.details.get("should_escalate", False)
|
|
1339
|
+
|
|
1340
|
+
if should_escalate:
|
|
1341
|
+
heuristic_escalated = True
|
|
1342
|
+
screening_methods = list(screening_methods) + ["llm"]
|
|
1343
|
+
_log(
|
|
1344
|
+
EventType.ESCALATION,
|
|
1345
|
+
tool_name,
|
|
1346
|
+
command=content if tool_name == "Bash" else None,
|
|
1347
|
+
tier=effective_tier,
|
|
1348
|
+
decision_reason=f"Heuristic scorer escalation (score: {heuristic_score:.3f})",
|
|
1349
|
+
metadata={
|
|
1350
|
+
"heuristic_score": heuristic_score,
|
|
1351
|
+
"family_scores": heuristic_result.details.get("family_scores", {}),
|
|
1352
|
+
"signals": heuristic_result.details.get("signals", []),
|
|
1353
|
+
"threshold": heuristic_result.details.get("threshold", 0.4),
|
|
1354
|
+
}
|
|
1355
|
+
)
|
|
1356
|
+
elif tier_mgr.config.get("heuristic_scorer", {}).get("log_all_scores", False):
|
|
1357
|
+
_log(
|
|
1358
|
+
EventType.TOOL_INVOKED,
|
|
1359
|
+
tool_name,
|
|
1360
|
+
metadata={
|
|
1361
|
+
"heuristic_score": heuristic_score,
|
|
1362
|
+
"heuristic_below_threshold": True,
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
except Exception:
|
|
1366
|
+
pass # Heuristic scorer is best-effort
|
|
1367
|
+
|
|
1368
|
+
# =========================================================================
|
|
1369
|
+
# LAYER 3: LLM Review (for risky/dangerous tiers, or heuristic-escalated)
|
|
582
1370
|
# =========================================================================
|
|
583
1371
|
llm_msg = None
|
|
584
1372
|
llm_triggered = False
|
|
585
|
-
if "llm" in screening_methods
|
|
1373
|
+
if "llm" in screening_methods:
|
|
586
1374
|
try:
|
|
587
1375
|
from tweek.security.llm_reviewer import get_llm_reviewer
|
|
588
1376
|
|
|
@@ -703,32 +1491,56 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
703
1491
|
decision_reason=f"Sandbox preview error: {e}",
|
|
704
1492
|
)
|
|
705
1493
|
|
|
1494
|
+
# =========================================================================
|
|
1495
|
+
# TRUST LEVEL: Filter pattern matches by severity threshold
|
|
1496
|
+
# Interactive (human at CLI) → only prompt on high/critical
|
|
1497
|
+
# Automated (launchd/cron) → prompt on everything
|
|
1498
|
+
# =========================================================================
|
|
1499
|
+
trust_mode = get_trust_mode(overrides)
|
|
1500
|
+
if overrides and pattern_match:
|
|
1501
|
+
min_severity = overrides.get_min_severity(trust_mode)
|
|
1502
|
+
kept, suppressed = filter_by_severity(
|
|
1503
|
+
all_pattern_matches if all_pattern_matches else [pattern_match],
|
|
1504
|
+
min_severity,
|
|
1505
|
+
)
|
|
1506
|
+
if suppressed:
|
|
1507
|
+
_log(
|
|
1508
|
+
EventType.ALLOWED,
|
|
1509
|
+
tool_name,
|
|
1510
|
+
command=content if tool_name == "Bash" else None,
|
|
1511
|
+
tier=effective_tier,
|
|
1512
|
+
decision="allow",
|
|
1513
|
+
decision_reason=(
|
|
1514
|
+
f"Trust level '{trust_mode}': {len(suppressed)} pattern(s) "
|
|
1515
|
+
f"below {min_severity} severity threshold"
|
|
1516
|
+
),
|
|
1517
|
+
metadata={
|
|
1518
|
+
"trust_mode": trust_mode,
|
|
1519
|
+
"min_severity": min_severity,
|
|
1520
|
+
"suppressed_patterns": [
|
|
1521
|
+
p.get("name") for p in suppressed
|
|
1522
|
+
],
|
|
1523
|
+
},
|
|
1524
|
+
)
|
|
1525
|
+
if kept:
|
|
1526
|
+
all_pattern_matches = kept
|
|
1527
|
+
kept.sort(key=lambda p: SEVERITY_RANK.get(p.get("severity", "medium"), 4))
|
|
1528
|
+
pattern_match = kept[0]
|
|
1529
|
+
else:
|
|
1530
|
+
all_pattern_matches = []
|
|
1531
|
+
pattern_match = None
|
|
1532
|
+
|
|
706
1533
|
# =========================================================================
|
|
707
1534
|
# Decision: Prompt if any layer triggered
|
|
708
1535
|
# =========================================================================
|
|
709
1536
|
compliance_triggered = bool(compliance_findings)
|
|
710
1537
|
|
|
711
1538
|
if pattern_match or llm_triggered or session_triggered or sandbox_triggered or compliance_triggered:
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
command=content if tool_name == "Bash" else None,
|
|
716
|
-
tier=effective_tier,
|
|
717
|
-
pattern_name=pattern_match.get("name") if pattern_match else "multi_layer",
|
|
718
|
-
pattern_severity=pattern_match.get("severity") if pattern_match else "high",
|
|
719
|
-
decision="ask",
|
|
720
|
-
decision_reason="Security check triggered",
|
|
721
|
-
metadata={
|
|
722
|
-
"pattern_triggered": pattern_match is not None,
|
|
723
|
-
"llm_triggered": llm_triggered,
|
|
724
|
-
"session_triggered": session_triggered,
|
|
725
|
-
"sandbox_triggered": sandbox_triggered,
|
|
726
|
-
"compliance_triggered": compliance_triggered,
|
|
727
|
-
"compliance_findings": len(compliance_findings) if compliance_findings else 0,
|
|
728
|
-
}
|
|
729
|
-
)
|
|
1539
|
+
# Determine graduated enforcement decision
|
|
1540
|
+
has_non_pattern = llm_triggered or session_triggered or sandbox_triggered or compliance_triggered
|
|
1541
|
+
decision = _resolve_enforcement(pattern_match, enforcement_policy, has_non_pattern, memory_adjustment)
|
|
730
1542
|
|
|
731
|
-
#
|
|
1543
|
+
# Build the warning message for ask/deny decisions
|
|
732
1544
|
final_msg = format_prompt_message(
|
|
733
1545
|
pattern_match, escalation, content, effective_tier,
|
|
734
1546
|
rate_limit_msg=rate_limit_msg,
|
|
@@ -736,21 +1548,153 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
736
1548
|
session_msg=session_msg
|
|
737
1549
|
)
|
|
738
1550
|
|
|
739
|
-
# Add sandbox message if applicable
|
|
740
1551
|
if sandbox_msg:
|
|
741
1552
|
final_msg += f"\n\n{sandbox_msg}"
|
|
742
1553
|
|
|
743
|
-
# Add compliance message if applicable
|
|
744
1554
|
if compliance_msg:
|
|
745
1555
|
final_msg += f"\n\n COMPLIANCE NOTICE\n{compliance_msg}"
|
|
746
1556
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1557
|
+
if decision == "deny":
|
|
1558
|
+
# Hard block: critical + deterministic patterns (or configured deny)
|
|
1559
|
+
_log(
|
|
1560
|
+
EventType.BLOCKED,
|
|
1561
|
+
tool_name,
|
|
1562
|
+
command=content if tool_name == "Bash" else None,
|
|
1563
|
+
tier=effective_tier,
|
|
1564
|
+
pattern_name=pattern_match.get("name") if pattern_match else "multi_layer",
|
|
1565
|
+
pattern_severity=pattern_match.get("severity") if pattern_match else "high",
|
|
1566
|
+
decision="deny",
|
|
1567
|
+
decision_reason="Hard block: critical deterministic pattern",
|
|
1568
|
+
metadata={
|
|
1569
|
+
"pattern_triggered": pattern_match is not None,
|
|
1570
|
+
"pattern_confidence": pattern_match.get("confidence") if pattern_match else None,
|
|
1571
|
+
"llm_triggered": llm_triggered,
|
|
1572
|
+
"session_triggered": session_triggered,
|
|
1573
|
+
"sandbox_triggered": sandbox_triggered,
|
|
1574
|
+
"compliance_triggered": compliance_triggered,
|
|
1575
|
+
"enforcement_decision": "deny",
|
|
1576
|
+
}
|
|
1577
|
+
)
|
|
1578
|
+
# Memory: record denied decision
|
|
1579
|
+
try:
|
|
1580
|
+
from tweek.memory.queries import memory_write_after_decision, memory_update_workflow
|
|
1581
|
+
from tweek.memory.store import hash_project, normalize_path_prefix
|
|
1582
|
+
if pattern_match:
|
|
1583
|
+
memory_write_after_decision(
|
|
1584
|
+
pattern_name=pattern_match.get("name", ""),
|
|
1585
|
+
pattern_id=pattern_match.get("id"),
|
|
1586
|
+
original_severity=pattern_match.get("severity", "medium"),
|
|
1587
|
+
original_confidence=pattern_match.get("confidence", "heuristic"),
|
|
1588
|
+
decision="deny", user_response="denied",
|
|
1589
|
+
tool_name=tool_name, content=content,
|
|
1590
|
+
path_prefix=tool_input.get("file_path", "") or tool_input.get("url", "") or working_dir or "",
|
|
1591
|
+
project_hash=hash_project(working_dir) if working_dir else None,
|
|
1592
|
+
)
|
|
1593
|
+
memory_update_workflow(
|
|
1594
|
+
project_hash=hash_project(working_dir) if working_dir else "global",
|
|
1595
|
+
tool_name=tool_name, was_denied=True,
|
|
1596
|
+
)
|
|
1597
|
+
except Exception:
|
|
1598
|
+
pass
|
|
1599
|
+
return {
|
|
1600
|
+
"hookSpecificOutput": {
|
|
1601
|
+
"hookEventName": "PreToolUse",
|
|
1602
|
+
"permissionDecision": "deny",
|
|
1603
|
+
"permissionDecisionReason": final_msg,
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
elif decision == "log":
|
|
1608
|
+
# Silent allow with logging: low-severity or low-confidence patterns
|
|
1609
|
+
_log(
|
|
1610
|
+
EventType.ALLOWED,
|
|
1611
|
+
tool_name,
|
|
1612
|
+
command=content if tool_name == "Bash" else None,
|
|
1613
|
+
tier=effective_tier,
|
|
1614
|
+
pattern_name=pattern_match.get("name") if pattern_match else "multi_layer",
|
|
1615
|
+
pattern_severity=pattern_match.get("severity") if pattern_match else "low",
|
|
1616
|
+
decision="log",
|
|
1617
|
+
decision_reason="Low-severity pattern logged only",
|
|
1618
|
+
metadata={
|
|
1619
|
+
"pattern_triggered": pattern_match is not None,
|
|
1620
|
+
"pattern_confidence": pattern_match.get("confidence") if pattern_match else None,
|
|
1621
|
+
"enforcement_decision": "log",
|
|
1622
|
+
"memory_adjusted": memory_adjustment is not None,
|
|
1623
|
+
}
|
|
1624
|
+
)
|
|
1625
|
+
# Memory: record logged decision
|
|
1626
|
+
try:
|
|
1627
|
+
from tweek.memory.queries import memory_write_after_decision, memory_update_workflow
|
|
1628
|
+
from tweek.memory.store import hash_project
|
|
1629
|
+
if pattern_match:
|
|
1630
|
+
memory_write_after_decision(
|
|
1631
|
+
pattern_name=pattern_match.get("name", ""),
|
|
1632
|
+
pattern_id=pattern_match.get("id"),
|
|
1633
|
+
original_severity=pattern_match.get("severity", "medium"),
|
|
1634
|
+
original_confidence=pattern_match.get("confidence", "heuristic"),
|
|
1635
|
+
decision="log", user_response=None,
|
|
1636
|
+
tool_name=tool_name, content=content,
|
|
1637
|
+
path_prefix=tool_input.get("file_path", "") or tool_input.get("url", "") or working_dir or "",
|
|
1638
|
+
project_hash=hash_project(working_dir) if working_dir else None,
|
|
1639
|
+
)
|
|
1640
|
+
memory_update_workflow(
|
|
1641
|
+
project_hash=hash_project(working_dir) if working_dir else "global",
|
|
1642
|
+
tool_name=tool_name, was_denied=False,
|
|
1643
|
+
)
|
|
1644
|
+
except Exception:
|
|
1645
|
+
pass
|
|
1646
|
+
return {}
|
|
1647
|
+
|
|
1648
|
+
else:
|
|
1649
|
+
# Ask: prompt user for confirmation (default behavior)
|
|
1650
|
+
_log(
|
|
1651
|
+
EventType.USER_PROMPTED,
|
|
1652
|
+
tool_name,
|
|
1653
|
+
command=content if tool_name == "Bash" else None,
|
|
1654
|
+
tier=effective_tier,
|
|
1655
|
+
pattern_name=pattern_match.get("name") if pattern_match else "multi_layer",
|
|
1656
|
+
pattern_severity=pattern_match.get("severity") if pattern_match else "high",
|
|
1657
|
+
decision="ask",
|
|
1658
|
+
decision_reason="Security check triggered",
|
|
1659
|
+
metadata={
|
|
1660
|
+
"pattern_triggered": pattern_match is not None,
|
|
1661
|
+
"pattern_confidence": pattern_match.get("confidence") if pattern_match else None,
|
|
1662
|
+
"llm_triggered": llm_triggered,
|
|
1663
|
+
"session_triggered": session_triggered,
|
|
1664
|
+
"sandbox_triggered": sandbox_triggered,
|
|
1665
|
+
"compliance_triggered": compliance_triggered,
|
|
1666
|
+
"compliance_findings": len(compliance_findings) if compliance_findings else 0,
|
|
1667
|
+
"enforcement_decision": "ask",
|
|
1668
|
+
}
|
|
1669
|
+
)
|
|
1670
|
+
# Memory: record ask decision (user_response set to None until feedback)
|
|
1671
|
+
try:
|
|
1672
|
+
from tweek.memory.queries import memory_write_after_decision, memory_update_workflow
|
|
1673
|
+
from tweek.memory.store import hash_project
|
|
1674
|
+
if pattern_match:
|
|
1675
|
+
memory_write_after_decision(
|
|
1676
|
+
pattern_name=pattern_match.get("name", ""),
|
|
1677
|
+
pattern_id=pattern_match.get("id"),
|
|
1678
|
+
original_severity=pattern_match.get("severity", "medium"),
|
|
1679
|
+
original_confidence=pattern_match.get("confidence", "heuristic"),
|
|
1680
|
+
decision="ask", user_response=None,
|
|
1681
|
+
tool_name=tool_name, content=content,
|
|
1682
|
+
path_prefix=tool_input.get("file_path", "") or tool_input.get("url", "") or working_dir or "",
|
|
1683
|
+
project_hash=hash_project(working_dir) if working_dir else None,
|
|
1684
|
+
)
|
|
1685
|
+
memory_update_workflow(
|
|
1686
|
+
project_hash=hash_project(working_dir) if working_dir else "global",
|
|
1687
|
+
tool_name=tool_name, was_denied=False,
|
|
1688
|
+
)
|
|
1689
|
+
except Exception:
|
|
1690
|
+
pass
|
|
1691
|
+
return {
|
|
1692
|
+
"hookSpecificOutput": {
|
|
1693
|
+
"hookEventName": "PreToolUse",
|
|
1694
|
+
"permissionDecision": "ask",
|
|
1695
|
+
"permissionDecisionReason": final_msg,
|
|
1696
|
+
}
|
|
752
1697
|
}
|
|
753
|
-
}
|
|
754
1698
|
|
|
755
1699
|
# No issues found - allow
|
|
756
1700
|
_log(
|
|
@@ -762,6 +1706,18 @@ def process_hook(input_data: dict, logger: SecurityLogger) -> dict:
|
|
|
762
1706
|
decision_reason="Passed all screening layers",
|
|
763
1707
|
)
|
|
764
1708
|
|
|
1709
|
+
# Memory: record clean pass and update workflow baseline
|
|
1710
|
+
try:
|
|
1711
|
+
from tweek.memory.queries import memory_update_workflow
|
|
1712
|
+
from tweek.memory.store import hash_project
|
|
1713
|
+
memory_update_workflow(
|
|
1714
|
+
project_hash=hash_project(working_dir) if working_dir else "global",
|
|
1715
|
+
tool_name=tool_name,
|
|
1716
|
+
was_denied=False,
|
|
1717
|
+
)
|
|
1718
|
+
except Exception:
|
|
1719
|
+
pass
|
|
1720
|
+
|
|
765
1721
|
return {}
|
|
766
1722
|
|
|
767
1723
|
|
|
@@ -831,14 +1787,20 @@ def main():
|
|
|
831
1787
|
print(json.dumps(result))
|
|
832
1788
|
|
|
833
1789
|
except json.JSONDecodeError as e:
|
|
834
|
-
# Invalid JSON - fail
|
|
1790
|
+
# Invalid JSON - fail closed (deny) for safety
|
|
835
1791
|
logger.log_quick(
|
|
836
1792
|
EventType.ERROR,
|
|
837
1793
|
"unknown",
|
|
838
|
-
decision="
|
|
1794
|
+
decision="deny",
|
|
839
1795
|
decision_reason=f"JSON decode error: {e}"
|
|
840
1796
|
)
|
|
841
|
-
print(
|
|
1797
|
+
print(json.dumps({
|
|
1798
|
+
"hookSpecificOutput": {
|
|
1799
|
+
"hookEventName": "PreToolUse",
|
|
1800
|
+
"permissionDecision": "deny",
|
|
1801
|
+
"permissionDecisionReason": f" TWEEK ERROR: Invalid hook input.\nBlocking for safety.",
|
|
1802
|
+
}
|
|
1803
|
+
}))
|
|
842
1804
|
|
|
843
1805
|
except Exception as e:
|
|
844
1806
|
# Any error - fail closed (block for safety)
|