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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5303 -2396
  5. tweek/cli_model.py +380 -0
  6. tweek/config/families.yaml +609 -0
  7. tweek/config/manager.py +42 -5
  8. tweek/config/patterns.yaml +1510 -8
  9. tweek/config/tiers.yaml +161 -11
  10. tweek/diagnostics.py +71 -2
  11. tweek/hooks/break_glass.py +163 -0
  12. tweek/hooks/feedback.py +223 -0
  13. tweek/hooks/overrides.py +531 -0
  14. tweek/hooks/post_tool_use.py +472 -0
  15. tweek/hooks/pre_tool_use.py +1024 -62
  16. tweek/integrations/openclaw.py +443 -0
  17. tweek/integrations/openclaw_server.py +385 -0
  18. tweek/licensing.py +14 -54
  19. tweek/logging/bundle.py +2 -2
  20. tweek/logging/security_log.py +56 -13
  21. tweek/mcp/approval.py +57 -16
  22. tweek/mcp/proxy.py +18 -0
  23. tweek/mcp/screening.py +5 -5
  24. tweek/mcp/server.py +4 -1
  25. tweek/memory/__init__.py +24 -0
  26. tweek/memory/queries.py +223 -0
  27. tweek/memory/safety.py +140 -0
  28. tweek/memory/schemas.py +80 -0
  29. tweek/memory/store.py +989 -0
  30. tweek/platform/__init__.py +4 -4
  31. tweek/plugins/__init__.py +40 -24
  32. tweek/plugins/base.py +1 -1
  33. tweek/plugins/detectors/__init__.py +3 -3
  34. tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
  35. tweek/plugins/git_discovery.py +16 -4
  36. tweek/plugins/git_registry.py +8 -2
  37. tweek/plugins/git_security.py +21 -9
  38. tweek/plugins/screening/__init__.py +10 -1
  39. tweek/plugins/screening/heuristic_scorer.py +477 -0
  40. tweek/plugins/screening/llm_reviewer.py +14 -6
  41. tweek/plugins/screening/local_model_reviewer.py +161 -0
  42. tweek/proxy/__init__.py +38 -37
  43. tweek/proxy/addon.py +22 -3
  44. tweek/proxy/interceptor.py +1 -0
  45. tweek/proxy/server.py +4 -2
  46. tweek/sandbox/__init__.py +11 -0
  47. tweek/sandbox/docker_bridge.py +143 -0
  48. tweek/sandbox/executor.py +9 -6
  49. tweek/sandbox/layers.py +97 -0
  50. tweek/sandbox/linux.py +1 -0
  51. tweek/sandbox/project.py +548 -0
  52. tweek/sandbox/registry.py +149 -0
  53. tweek/security/__init__.py +9 -0
  54. tweek/security/language.py +250 -0
  55. tweek/security/llm_reviewer.py +1146 -60
  56. tweek/security/local_model.py +331 -0
  57. tweek/security/local_reviewer.py +146 -0
  58. tweek/security/model_registry.py +371 -0
  59. tweek/security/rate_limiter.py +11 -6
  60. tweek/security/secret_scanner.py +70 -4
  61. tweek/security/session_analyzer.py +26 -2
  62. tweek/skill_template/SKILL.md +200 -0
  63. tweek/skill_template/__init__.py +0 -0
  64. tweek/skill_template/cli-reference.md +331 -0
  65. tweek/skill_template/overrides-reference.md +184 -0
  66. tweek/skill_template/scripts/__init__.py +0 -0
  67. tweek/skill_template/scripts/check_installed.py +170 -0
  68. tweek/skills/__init__.py +38 -0
  69. tweek/skills/config.py +150 -0
  70. tweek/skills/fingerprints.py +198 -0
  71. tweek/skills/guard.py +293 -0
  72. tweek/skills/isolation.py +469 -0
  73. tweek/skills/scanner.py +715 -0
  74. tweek/vault/__init__.py +0 -1
  75. tweek/vault/cross_platform.py +12 -1
  76. tweek/vault/keychain.py +87 -29
  77. tweek-0.2.0.dist-info/METADATA +281 -0
  78. tweek-0.2.0.dist-info/RECORD +121 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
  81. tweek/integrations/moltbot.py +0 -243
  82. tweek-0.1.0.dist-info/METADATA +0 -335
  83. tweek-0.1.0.dist-info/RECORD +0 -85
  84. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
  85. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
@@ -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
- escalation = self.check_escalations(content)
500
+ content_escalation = self.check_escalations(content)
501
+ path_escalation = self.check_path_escalation(
502
+ target_paths or [], working_dir
503
+ )
266
504
 
267
- if escalation is None:
268
- return base_tier, None
505
+ best_tier = base_tier
506
+ best_priority = tier_priority.get(base_tier, 1)
507
+ best_escalation = None
269
508
 
270
- tier_priority = {"safe": 0, "default": 1, "risky": 2, "dangerous": 3}
271
- base_priority = tier_priority.get(base_tier, 1)
272
- escalated_tier = escalation.get("escalate_to", "default")
273
- escalated_priority = tier_priority.get(escalated_tier, 1)
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
- # Only escalate, never de-escalate
276
- if escalated_priority > base_priority:
277
- return escalated_tier, escalation
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 base_tier, None
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
- # Try user patterns first (~/.tweek/patterns/), fall back to bundled
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
- self.patterns = self._load_patterns(patterns_path)
297
- elif user_patterns.exists():
298
- self.patterns = self._load_patterns(user_patterns)
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[:12]
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
- # Extract content to analyze (command for Bash, path for Read, etc.)
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 in ("Read", "Write", "Edit"):
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
- content = tool_input.get("url", "")
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
- # Initialize managers
474
- tier_mgr = TierManager()
475
- pattern_matcher = PatternMatcher()
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(tool_name, content)
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
- pattern_match = pattern_matcher.check(content)
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
- # LAYER 3: LLM Review (for risky/dangerous tiers)
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 and tool_name == "Bash":
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
- _log(
713
- EventType.USER_PROMPTED,
714
- tool_name,
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
- # Combine all messages
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
- return {
748
- "hookSpecificOutput": {
749
- "hookEventName": "PreToolUse",
750
- "permissionDecision": "ask",
751
- "permissionDecisionReason": final_msg,
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 open (allow) but log
1790
+ # Invalid JSON - fail closed (deny) for safety
835
1791
  logger.log_quick(
836
1792
  EventType.ERROR,
837
1793
  "unknown",
838
- decision="allow",
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)