rafter-cli 0.8.4__tar.gz → 0.8.6__tar.gz

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 (96) hide show
  1. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/PKG-INFO +4 -4
  2. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/README.md +3 -3
  3. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/pyproject.toml +1 -1
  4. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/agent.py +159 -24
  5. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/hook.py +185 -38
  6. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/mcp_server.py +38 -40
  7. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/scan.py +11 -4
  8. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/config_manager.py +22 -0
  9. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/config_schema.py +21 -0
  10. rafter_cli-0.8.6/rafter_cli/core/hook_control.py +101 -0
  11. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/pattern_engine.py +4 -0
  12. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/policy_loader.py +8 -0
  13. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/rafter-security-skill.md +1 -1
  14. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +9 -2
  15. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/betterleaks.py +7 -1
  16. rafter_cli-0.8.6/rafter_cli/scanners/union.py +69 -0
  17. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/binary_manager.py +20 -0
  18. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/__init__.py +0 -0
  19. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/__main__.py +0 -0
  20. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/__init__.py +0 -0
  21. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/agent_components.py +0 -0
  22. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/backend.py +0 -0
  23. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/brief.py +0 -0
  24. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/ci.py +0 -0
  25. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/docs.py +0 -0
  26. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/__init__.py +0 -0
  27. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/dedup.py +0 -0
  28. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/github_client.py +0 -0
  29. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/issue_builder.py +0 -0
  30. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/issues/issues_app.py +0 -0
  31. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/notify.py +0 -0
  32. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/policy.py +0 -0
  33. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/report.py +0 -0
  34. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/skill.py +0 -0
  35. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/commands/skill_remote.py +0 -0
  36. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/__init__.py +0 -0
  37. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/audit_logger.py +0 -0
  38. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/command_interceptor.py +0 -0
  39. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/custom_patterns.py +0 -0
  40. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/docs_loader.py +0 -0
  41. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/core/risk_rules.py +0 -0
  42. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/__init__.py +0 -0
  43. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/agents/__init__.py +0 -0
  44. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/agents/rafter.md +0 -0
  45. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  46. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  47. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  48. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  49. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  50. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  51. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  52. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  53. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  54. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/pre-push-hook.sh +0 -0
  55. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/__init__.py +0 -0
  56. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
  57. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  58. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  59. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  60. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  61. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  62. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  63. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  64. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  65. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  66. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  67. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  68. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  69. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  70. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  71. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  72. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  73. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  74. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  75. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  76. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  77. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  78. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  79. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  80. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  81. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  82. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  83. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  84. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  85. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  86. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  87. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  88. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  89. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/__init__.py +0 -0
  90. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/regex_scanner.py +0 -0
  91. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/scanners/secret_patterns.py +0 -0
  92. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/__init__.py +0 -0
  93. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/api.py +0 -0
  94. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/formatter.py +0 -0
  95. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/git.py +0 -0
  96. {rafter_cli-0.8.4 → rafter_cli-0.8.6}/rafter_cli/utils/skill_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafter-cli
3
- Version: 0.8.4
3
+ Version: 0.8.6
4
4
  Summary: Rafter CLI — the default security agent for AI workflows. Free for individuals and open source.
5
5
  License: MIT
6
6
  Author: Rafter Team
@@ -66,9 +66,9 @@ rafter agent init --all # install all detected integrations
66
66
  rafter agent init --local # write config to ./.rafter (ephemeral/benchmark)
67
67
  rafter agent list # show detected integrations + status
68
68
  rafter agent enable claude-code # toggle a single platform on/off
69
- rafter agent scan . # scan for secrets
70
- rafter agent scan --diff HEAD~1 # scan changed files
71
- rafter agent scan --history # scan full git history (betterleaks engine)
69
+ rafter secrets . # scan for secrets
70
+ rafter secrets --diff HEAD~1 # scan changed files
71
+ rafter secrets --history # scan full git history (betterleaks engine)
72
72
  rafter agent exec "git commit" # execute with risk assessment
73
73
  rafter agent audit # view security logs
74
74
  rafter agent audit --verify # verify tamper-evident hash chain
@@ -40,9 +40,9 @@ rafter agent init --all # install all detected integrations
40
40
  rafter agent init --local # write config to ./.rafter (ephemeral/benchmark)
41
41
  rafter agent list # show detected integrations + status
42
42
  rafter agent enable claude-code # toggle a single platform on/off
43
- rafter agent scan . # scan for secrets
44
- rafter agent scan --diff HEAD~1 # scan changed files
45
- rafter agent scan --history # scan full git history (betterleaks engine)
43
+ rafter secrets . # scan for secrets
44
+ rafter secrets --diff HEAD~1 # scan changed files
45
+ rafter secrets --history # scan full git history (betterleaks engine)
46
46
  rafter agent exec "git commit" # execute with risk assessment
47
47
  rafter agent audit # view security logs
48
48
  rafter agent audit --verify # verify tamper-evident hash chain
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.8.4"
3
+ version = "0.8.6"
4
4
  description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source."
5
5
  authors = ["Rafter Team <hello@rafter.so>"]
6
6
  license = "MIT"
@@ -30,9 +30,10 @@ from ..core.pattern_engine import PatternEngine
30
30
  from ..scanners.betterleaks import BetterleaksScanner
31
31
  from ..scanners.regex_scanner import RegexScanner, ScanResult
32
32
  from ..scanners.secret_patterns import DEFAULT_SECRET_PATTERNS
33
+ from ..scanners.union import union_scan_results
33
34
  from ..utils.formatter import fmt, is_agent_mode, print_stderr
34
35
  from ..utils.skill_manager import SkillManager
35
- from ..utils.binary_manager import BinaryManager
36
+ from ..utils.binary_manager import BinaryManager, BETTERLEAKS_VERSION
36
37
 
37
38
  agent_app = typer.Typer(name="agent", help="Agent security features", no_args_is_help=True)
38
39
 
@@ -1453,8 +1454,22 @@ def init(
1453
1454
  # ── scan ─────────────────────────────────────────────────────────────
1454
1455
 
1455
1456
 
1456
- def _select_engine(preference: str, quiet: bool) -> str:
1457
- """Return 'betterleaks' or 'patterns'."""
1457
+ def _select_engine(preference: str, quiet: bool, auto_update: bool = True) -> str:
1458
+ """Return 'betterleaks', 'patterns', or 'both'.
1459
+
1460
+ ``auto`` (the default) resolves to ``both`` whenever betterleaks is
1461
+ available and usable — rafter then runs both engines and unions their
1462
+ findings (sable-j85), so a miss in one (betterleaks 1.1.x does not detect
1463
+ AWS access keys — sable-h2y) is still caught by the other. It degrades to
1464
+ ``patterns``-only when betterleaks is absent or a stale binary can't be
1465
+ refreshed. Explicit ``--engine betterleaks`` / ``--engine patterns`` stay
1466
+ single-engine.
1467
+
1468
+ ``auto_update`` (default True) controls sable-o4k behavior: when the
1469
+ resolved engine is betterleaks but the managed binary is stale, auto-update
1470
+ it before scanning. The caller resolves this from ``--no-auto-update`` and
1471
+ the ``scan.auto_update_betterleaks`` config key.
1472
+ """
1458
1473
  valid_engines = ("auto", "betterleaks", "patterns")
1459
1474
  if preference not in valid_engines:
1460
1475
  print(f"Invalid engine: {preference}. Valid values: {', '.join(valid_engines)}", file=sys.stderr)
@@ -1471,26 +1486,93 @@ def _select_engine(preference: str, quiet: bool) -> str:
1471
1486
  if not quiet:
1472
1487
  print_stderr(fmt.warning("Betterleaks requested but not available, using patterns"))
1473
1488
  return "patterns"
1489
+ return _ensure_betterleaks_usable(quiet, auto_update)
1490
+
1491
+ # auto — run BOTH engines when betterleaks is usable; otherwise patterns.
1492
+ if not available:
1493
+ return "patterns"
1494
+ usable = _ensure_betterleaks_usable(quiet, auto_update)
1495
+ return "both" if usable == "betterleaks" else "patterns"
1496
+
1497
+
1498
+ def _ensure_betterleaks_usable(quiet: bool, auto_update: bool) -> str:
1499
+ """sable-o4k — guard against a stale rafter-managed betterleaks binary
1500
+ silently returning zero findings (a leftover binary from an older rafter
1501
+ parses fine with ``version`` but emits a JSON shape the current parser
1502
+ rejects).
1503
+
1504
+ When the managed binary is stale we auto-update it to the pinned version
1505
+ (default on; opt out with ``--no-auto-update`` or
1506
+ ``scan.auto_update_betterleaks: false``). In an interactive TTY we confirm
1507
+ first. If the update is disabled, declined, or fails, we degrade to the
1508
+ patterns engine and print a CTA rather than scanning with a binary that
1509
+ yields nothing.
1510
+ """
1511
+ bm = BinaryManager()
1512
+ if not bm.is_managed_betterleaks_stale():
1474
1513
  return "betterleaks"
1475
1514
 
1476
- # auto
1477
- return "betterleaks" if available else "patterns"
1515
+ current = bm.get_betterleaks_version()
1516
+ cta = "Fix manually with: rafter agent update-betterleaks"
1517
+ stale = f"Stale betterleaks binary ({current}; expected v{BETTERLEAKS_VERSION})"
1518
+
1519
+ if not auto_update:
1520
+ if not quiet:
1521
+ print_stderr(fmt.warning(f"{stale}. Auto-update is off — using the patterns engine for this scan. {cta}"))
1522
+ return "patterns"
1523
+
1524
+ # Only prompt when there's a human at a TTY; otherwise auto-update proceeds
1525
+ # (default on) so non-interactive runs aren't left with a broken engine.
1526
+ if sys.stdin.isatty() and not quiet:
1527
+ answer = input(f" {stale}. Update it now? [Y/n] ").strip().lower()
1528
+ if answer not in ("", "y", "yes"):
1529
+ print_stderr(fmt.warning(f"Skipped — using the patterns engine for this scan. {cta}"))
1530
+ return "patterns"
1531
+
1532
+ if not quiet:
1533
+ print_stderr(fmt.info(f"Updating betterleaks to v{BETTERLEAKS_VERSION}..."))
1534
+ try:
1535
+ bm.download_betterleaks(
1536
+ (lambda m: print_stderr(f" {m}")) if not quiet else None,
1537
+ BETTERLEAKS_VERSION,
1538
+ )
1539
+ if not quiet:
1540
+ print_stderr(fmt.success(f"Betterleaks updated to v{BETTERLEAKS_VERSION}."))
1541
+ return "betterleaks"
1542
+ except Exception as exc: # noqa: BLE001 — degrade to patterns on any failure
1543
+ if not quiet:
1544
+ print_stderr(fmt.warning(f"Betterleaks update failed ({exc}) — using the patterns engine for this scan. {cta}"))
1545
+ return "patterns"
1478
1546
 
1479
1547
 
1480
1548
  def _scan_file(file_path: str, engine: str, custom_patterns=None) -> list[ScanResult]:
1549
+ def run_patterns() -> list[ScanResult]:
1550
+ scanner = RegexScanner(custom_patterns)
1551
+ r = scanner.scan_file(file_path)
1552
+ return [r] if r.matches else []
1553
+
1554
+ if engine == "both":
1555
+ # sable-j85 — run both engines and union. A betterleaks failure
1556
+ # degrades to patterns-only rather than losing the scan.
1557
+ bl_results: list[ScanResult] = []
1558
+ try:
1559
+ bl = BetterleaksScanner()
1560
+ result = bl.scan_file(file_path)
1561
+ if result.matches:
1562
+ bl_results = [ScanResult(file=result.file, matches=result.matches)]
1563
+ except Exception:
1564
+ print_stderr(fmt.warning("Betterleaks scan failed, using patterns only for this run"))
1565
+ return union_scan_results(bl_results, run_patterns())
1566
+
1481
1567
  if engine == "betterleaks":
1482
1568
  try:
1483
1569
  bl = BetterleaksScanner()
1484
1570
  result = bl.scan_file(file_path)
1485
1571
  return [ScanResult(file=result.file, matches=result.matches)] if result.matches else []
1486
1572
  except Exception:
1487
- scanner = RegexScanner(custom_patterns)
1488
- r = scanner.scan_file(file_path)
1489
- return [r] if r.matches else []
1490
- else:
1491
- scanner = RegexScanner(custom_patterns)
1492
- r = scanner.scan_file(file_path)
1493
- return [r] if r.matches else []
1573
+ return run_patterns()
1574
+
1575
+ return run_patterns()
1494
1576
 
1495
1577
 
1496
1578
  def _path_matches_exclude_pattern(rel_path: str, pattern: str) -> bool:
@@ -1569,17 +1651,29 @@ def _scan_directory(
1569
1651
  custom = [{"name": p.name, "regex": p.regex, "severity": p.severity} for p in scan_cfg.custom_patterns] if scan_cfg.custom_patterns else None
1570
1652
  exclude = scan_cfg.exclude_paths or None
1571
1653
 
1572
- if engine == "betterleaks":
1654
+ def run_patterns() -> list[ScanResult]:
1655
+ scanner = RegexScanner(custom)
1656
+ return scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1657
+
1658
+ if engine == "both":
1659
+ # sable-j85 — run both engines and union; the exclude-paths post-filter
1660
+ # below applies to the merged set.
1661
+ bl_results: list[ScanResult] = []
1662
+ try:
1663
+ bl = BetterleaksScanner()
1664
+ bl_results = [ScanResult(file=r.file, matches=r.matches) for r in bl.scan_directory(dir_path, use_git=history)]
1665
+ except Exception:
1666
+ print_stderr(fmt.warning("Betterleaks scan failed, using patterns only for this run"))
1667
+ results = union_scan_results(bl_results, run_patterns())
1668
+ elif engine == "betterleaks":
1573
1669
  try:
1574
1670
  bl = BetterleaksScanner()
1575
1671
  results = bl.scan_directory(dir_path, use_git=history)
1576
1672
  results = [ScanResult(file=r.file, matches=r.matches) for r in results]
1577
1673
  except Exception:
1578
- scanner = RegexScanner(custom)
1579
- results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1674
+ results = run_patterns()
1580
1675
  else:
1581
- scanner = RegexScanner(custom)
1582
- results = scanner.scan_directory(dir_path, exclude_paths=exclude, respect_gitignore=respect_gitignore)
1676
+ results = run_patterns()
1583
1677
  # sable-yz0 — post-filter chokepoint. See _apply_exclude_paths docstring.
1584
1678
  return _apply_exclude_paths(results, exclude, dir_path)
1585
1679
 
@@ -1605,7 +1699,9 @@ def _output_scan_results(
1605
1699
  files_out = [
1606
1700
  {"file": r.file, "matches": [
1607
1701
  {"pattern": {"name": m.pattern.name, "severity": m.pattern.severity, "description": m.pattern.description or ""},
1608
- "line": m.line, "column": m.column, "redacted": m.redacted}
1702
+ "line": m.line, "column": m.column, "redacted": m.redacted,
1703
+ # sable-j85 — present only for auto-mode (both-engine) scans.
1704
+ **({"engines": m.engines} if m.engines else {})}
1609
1705
  for m in r.matches
1610
1706
  ]}
1611
1707
  for r in kept_results
@@ -1674,6 +1770,7 @@ def _watch_and_scan(
1674
1770
  custom_patterns,
1675
1771
  scan_cfg,
1676
1772
  suppressions: list | None = None,
1773
+ auto_update: bool = True,
1677
1774
  ) -> None:
1678
1775
  """Watch a path for changes and re-scan on each change. Ctrl+C exits."""
1679
1776
  try:
@@ -1684,7 +1781,7 @@ def _watch_and_scan(
1684
1781
  raise typer.Exit(code=2)
1685
1782
 
1686
1783
  logger = AuditLogger()
1687
- eng = _select_engine(engine, quiet)
1784
+ eng = _select_engine(engine, quiet, auto_update)
1688
1785
 
1689
1786
  if not quiet:
1690
1787
  print_stderr(fmt.info(f"Watching {watch_path} for changes ({eng}). Press Ctrl+C to exit."))
@@ -1815,6 +1912,7 @@ def scan(
1815
1912
  watch: bool = typer.Option(False, "--watch", help="Watch for file changes and re-scan on change"),
1816
1913
  history: bool = typer.Option(False, "--history", help="Scan git history for secrets (requires betterleaks engine)"),
1817
1914
  gitignore: bool = typer.Option(True, "--gitignore/--no-gitignore", help="Respect .gitignore when walking the scan target (default: on)"),
1915
+ auto_update: bool = typer.Option(True, "--auto-update/--no-auto-update", help="Auto-update a stale managed betterleaks binary; --no-auto-update falls back to the patterns engine instead (default: on)"),
1818
1916
  ):
1819
1917
  """Scan files or directories for secrets. [deprecated: use 'rafter secrets' instead]"""
1820
1918
  print(
@@ -1831,6 +1929,10 @@ def scan(
1831
1929
  if scan_cfg.custom_patterns else None
1832
1930
  )
1833
1931
 
1932
+ # sable-o4k — stale-binary auto-update is on unless the CLI flag or the
1933
+ # scan.auto_update_betterleaks config key opts out. Either disables it.
1934
+ auto_update_enabled = auto_update and scan_cfg.auto_update_betterleaks
1935
+
1834
1936
  from ..core.custom_patterns import load_suppressions, policy_ignore_to_suppressions
1835
1937
  suppressions = policy_ignore_to_suppressions(scan_cfg.ignore) + load_suppressions()
1836
1938
 
@@ -1856,7 +1958,7 @@ def scan(
1856
1958
  if not quiet:
1857
1959
  print(f"Scanning {len(changed)} file(s) changed since {diff}...", file=sys.stderr)
1858
1960
 
1859
- eng = _select_engine(engine, quiet)
1961
+ eng = _select_engine(engine, quiet, auto_update_enabled)
1860
1962
  all_results: list[ScanResult] = []
1861
1963
  for f in changed:
1862
1964
  resolved = os.path.abspath(f)
@@ -1886,7 +1988,7 @@ def scan(
1886
1988
  if not quiet:
1887
1989
  print(f"Scanning {len(staged_files)} staged file(s)...", file=sys.stderr)
1888
1990
 
1889
- eng = _select_engine(engine, quiet)
1991
+ eng = _select_engine(engine, quiet, auto_update_enabled)
1890
1992
  all_results = []
1891
1993
  for f in staged_files:
1892
1994
  resolved = os.path.abspath(f)
@@ -1904,10 +2006,10 @@ def scan(
1904
2006
 
1905
2007
  # --watch
1906
2008
  if watch:
1907
- _watch_and_scan(resolved_path, engine, quiet, json_output, format, custom_patterns, scan_cfg, suppressions)
2009
+ _watch_and_scan(resolved_path, engine, quiet, json_output, format, custom_patterns, scan_cfg, suppressions, auto_update_enabled)
1908
2010
  return
1909
2011
 
1910
- eng = _select_engine(engine, quiet)
2012
+ eng = _select_engine(engine, quiet, auto_update_enabled)
1911
2013
 
1912
2014
  if os.path.isdir(resolved_path):
1913
2015
  if not quiet:
@@ -3072,6 +3174,26 @@ def status(
3072
3174
  else:
3073
3175
  print(f"\nConfig: not found — run: rafter agent init")
3074
3176
 
3177
+ # --- Hook off-switch (trusted-source only: env / global config) ---
3178
+ from ..core.hook_control import resolve_hook_control
3179
+
3180
+ c = resolve_hook_control()
3181
+
3182
+ def _describe(on: bool, src: str) -> str:
3183
+ if on:
3184
+ return "active"
3185
+ via = "RAFTER_DISABLE_* env" if src == "env" else "global config"
3186
+ return f"DISABLED (via {via})"
3187
+
3188
+ if not c.hook_enabled:
3189
+ print(f"Hooks: {_describe(False, c.source_hook)}")
3190
+ elif not c.secret_scan_enabled or not c.command_policy_enabled:
3191
+ print("Hooks: active (partial)")
3192
+ print(f" secret scan: {_describe(c.secret_scan_enabled, c.source_secret_scan)}")
3193
+ print(f" command policy: {_describe(c.command_policy_enabled, c.source_command_policy)}")
3194
+ else:
3195
+ print("Hooks: active")
3196
+
3075
3197
  # --- Betterleaks ---
3076
3198
  bm = BinaryManager()
3077
3199
  bl_local = bm.get_betterleaks_path()
@@ -3207,6 +3329,9 @@ def status(
3207
3329
 
3208
3330
 
3209
3331
  def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]:
3332
+ from ..core.hook_control import resolve_hook_control
3333
+
3334
+ c = resolve_hook_control()
3210
3335
  return {
3211
3336
  "installed": config_path.exists(),
3212
3337
  "version": __version__,
@@ -3215,6 +3340,16 @@ def _agent_status_json(config_path: Path, audit_path: Path) -> dict[str, Any]:
3215
3340
  "betterleaks_available": _betterleaks_available(),
3216
3341
  "config_path": _format_home_path(config_path),
3217
3342
  "audit_log_path": _format_home_path(audit_path),
3343
+ "hook_control": {
3344
+ "hook_enabled": c.hook_enabled,
3345
+ "secret_scan_enabled": c.secret_scan_enabled,
3346
+ "command_policy_enabled": c.command_policy_enabled,
3347
+ "source": {
3348
+ "hook": c.source_hook,
3349
+ "secret_scan": c.source_secret_scan,
3350
+ "command_policy": c.source_command_policy,
3351
+ },
3352
+ },
3218
3353
  }
3219
3354
 
3220
3355
 
@@ -3359,7 +3494,7 @@ def baseline_create(
3359
3494
 
3360
3495
  print(f"Scanning {resolved} to build baseline...", file=sys.stderr)
3361
3496
 
3362
- eng = _select_engine(engine, quiet=False)
3497
+ eng = _select_engine(engine, quiet=False, auto_update=scan_cfg.auto_update_betterleaks)
3363
3498
  if os.path.isdir(resolved):
3364
3499
  results = _scan_directory(resolved, eng, scan_cfg)
3365
3500
  else:
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import os
5
6
  import subprocess
6
7
  import sys
7
8
 
@@ -180,49 +181,171 @@ def _normalize_posttool_input(raw: dict, fmt: str) -> tuple[str, dict | None]:
180
181
  return raw.get("tool_name", "unknown"), raw.get("tool_response")
181
182
 
182
183
 
184
+ def _load_scan_config():
185
+ """Load the policy-merged scan config + suppression list the same way
186
+ scan.py does, so hook decisions match ``rafter secrets`` (sable-55u).
187
+
188
+ Returns ``(scan_cfg, suppressions, custom_patterns)``. The hook is
189
+ patterns-only by design (no betterleaks subprocess — it runs on every
190
+ tool call and must stay fast), so betterleaks version skew can never
191
+ affect it; it still honors custom patterns, ``exclude_paths``, and
192
+ ``ignore`` from ``.rafter.yml``.
193
+ """
194
+ from ..core.config_manager import ConfigManager
195
+ from ..core.custom_patterns import load_suppressions, policy_ignore_to_suppressions
196
+
197
+ cfg = ConfigManager().load_with_policy()
198
+ scan_cfg = cfg.agent.scan
199
+ custom_patterns = (
200
+ [{"name": p.name, "regex": p.regex, "severity": p.severity} for p in scan_cfg.custom_patterns]
201
+ if scan_cfg and scan_cfg.custom_patterns else None
202
+ )
203
+ # Policy ignore rules first so an explicit reason wins over a bare
204
+ # .rafterignore line covering the same finding.
205
+ suppressions = policy_ignore_to_suppressions(scan_cfg.ignore if scan_cfg else None) + load_suppressions()
206
+ return scan_cfg, suppressions, custom_patterns
207
+
208
+
209
+ # Cap on individual findings listed in a deny reason before truncating.
210
+ _MAX_REASON_FINDINGS = 10
211
+
212
+
213
+ def _format_staged_secret_reason(result: dict) -> str:
214
+ """Render a deny reason that names each offending file + pattern (+ line
215
+ when known) instead of a bare count, so the agent knows what to fix
216
+ without a second ``rafter secrets`` run. Truncates long lists.
217
+ """
218
+ repo_root = result.get("repo_root") or os.getcwd()
219
+ lines: list[str] = []
220
+ shown = 0
221
+ for r in result["findings"]:
222
+ try:
223
+ rel = os.path.relpath(r.file, repo_root)
224
+ except ValueError:
225
+ rel = os.path.basename(r.file)
226
+ for m in r.matches:
227
+ if shown >= _MAX_REASON_FINDINGS:
228
+ lines.append(f" …and {result['count'] - shown} more")
229
+ break
230
+ loc = f":{m.line}" if m.line else ""
231
+ lines.append(f" {rel}{loc} — {m.pattern.name}")
232
+ shown += 1
233
+ else:
234
+ continue
235
+ break
236
+ return "\n".join([
237
+ f"{result['count']} secret(s) detected in {result['files']} staged file(s):",
238
+ *lines,
239
+ "Hook scan is pattern-only (betterleaks version is irrelevant). "
240
+ "Run 'rafter secrets --staged' for full detail, or add an exclude_paths/ignore "
241
+ "rule to .rafter.yml if this is a false positive.",
242
+ ])
243
+
244
+
183
245
  def _scan_staged_files() -> dict:
246
+ """Scan git staged files, routed through the SAME config-aware pipeline
247
+ as ``rafter secrets --staged`` (sable-55u). Previously the hook ran
248
+ RegexScanner directly on the raw staged list with no config, so it
249
+ phantom-blocked commits on findings the CLI would suppress.
250
+ """
251
+ empty = {"secrets_found": False, "count": 0, "files": 0, "findings": [], "repo_root": os.getcwd()}
184
252
  try:
253
+ repo_root = subprocess.run(
254
+ ["git", "rev-parse", "--show-toplevel"],
255
+ capture_output=True, text=True,
256
+ ).stdout.strip() or os.getcwd()
257
+
185
258
  output = subprocess.run(
186
259
  ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
187
260
  capture_output=True, text=True,
188
261
  ).stdout.strip()
189
262
  if not output:
190
- return {"secrets_found": False, "count": 0, "files": 0}
263
+ return {**empty, "repo_root": repo_root}
191
264
  staged = [f for f in output.split("\n") if f.strip()]
192
- scanner = RegexScanner()
193
- results = scanner.scan_files(staged)
194
- total = sum(len(r.matches) for r in results)
195
- return {"secrets_found": len(results) > 0, "count": total, "files": len(results)}
265
+
266
+ from ..core.custom_patterns import apply_suppressions
267
+ from .agent import _apply_exclude_paths
268
+
269
+ scan_cfg, suppressions, custom_patterns = _load_scan_config()
270
+ scanner = RegexScanner(custom_patterns)
271
+
272
+ raw = []
273
+ for f in staged:
274
+ resolved = os.path.join(repo_root, f)
275
+ if not os.path.isfile(resolved):
276
+ continue
277
+ r = scanner.scan_file(resolved)
278
+ if r.matches:
279
+ raw.append(r)
280
+
281
+ exclude = scan_cfg.exclude_paths if scan_cfg else None
282
+ after_exclude = _apply_exclude_paths(raw, exclude, repo_root)
283
+ kept, _suppressed = apply_suppressions(after_exclude, suppressions)
284
+ total = sum(len(r.matches) for r in kept)
285
+ return {
286
+ "secrets_found": len(kept) > 0,
287
+ "count": total,
288
+ "files": len(kept),
289
+ "findings": kept,
290
+ "repo_root": repo_root,
291
+ }
196
292
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
197
293
  print(f"rafter: staged file scan failed: {exc}", file=sys.stderr)
198
- return {"secrets_found": False, "count": 0, "files": 0}
199
-
200
-
201
- def _evaluate_bash(command: str) -> dict:
202
- interceptor = CommandInterceptor()
294
+ return empty
295
+
296
+
297
+ def _evaluate_bash(command: str, control=None) -> dict:
298
+ # The production caller (the pretool dispatch) always passes a resolved
299
+ # control. `control=None` defaults to fully-enabled — fail-safe, and keeps
300
+ # the function callable from focused tests that exercise interception/scan
301
+ # without constructing a control object.
302
+ if control is None:
303
+ from ..core.hook_control import HookControl
304
+
305
+ control = HookControl(
306
+ hook_enabled=True,
307
+ secret_scan_enabled=True,
308
+ command_policy_enabled=True,
309
+ source_hook="default",
310
+ source_secret_scan="default",
311
+ source_command_policy="default",
312
+ )
203
313
  audit = AuditLogger()
204
- evaluation = interceptor.evaluate(command)
205
314
 
206
- # Blockedhard deny
207
- if not evaluation.allowed and not evaluation.requires_approval:
208
- audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
209
- return {"decision": "deny", "reason": _format_blocked_message(command, evaluation)}
315
+ # Command-risk interception gated by command_policy. When disabled, skip the
316
+ # block/approval logic but still fall through to the staged-secret scan below.
317
+ if control.command_policy_enabled:
318
+ interceptor = CommandInterceptor()
319
+ evaluation = interceptor.evaluate(command)
210
320
 
211
- # Requires approval — deny (hook can't prompt interactively)
212
- if evaluation.requires_approval:
213
- audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
214
- return {"decision": "deny", "reason": _format_approval_message(command, evaluation)}
321
+ # Blockedhard deny
322
+ if not evaluation.allowed and not evaluation.requires_approval:
323
+ audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
324
+ return {"decision": "deny", "reason": _format_blocked_message(command, evaluation)}
215
325
 
216
- # Git commit/pushscan staged files
326
+ # Requires approvaldeny (hook can't prompt interactively)
327
+ if evaluation.requires_approval:
328
+ audit.log_command_intercepted(command, False, "blocked", evaluation.reason)
329
+ return {"decision": "deny", "reason": _format_approval_message(command, evaluation)}
330
+
331
+ # Git commit/push — scan staged files. Gated by secret_scan so the git-commit
332
+ # secret check survives command_policy being disabled on its own.
217
333
  trimmed = command.strip()
218
- if trimmed.startswith(("git commit", "git push")):
334
+ if control.secret_scan_enabled and trimmed.startswith(("git commit", "git push")):
219
335
  result = _scan_staged_files()
220
336
  if result["secrets_found"]:
221
- audit.log_secret_detected("staged files", f"{result['count']} secret(s)", "blocked")
337
+ # Audit per file so the log records WHICH file + pattern, not a bare count.
338
+ repo_root = result.get("repo_root") or os.getcwd()
339
+ for r in result["findings"]:
340
+ try:
341
+ rel = os.path.relpath(r.file, repo_root)
342
+ except ValueError:
343
+ rel = os.path.basename(r.file)
344
+ names = list({m.pattern.name for m in r.matches})
345
+ audit.log_secret_detected(rel, ", ".join(names), "blocked")
222
346
  return {
223
347
  "decision": "deny",
224
- "reason": f"{result['count']} secret(s) detected in {result['files']} staged file(s). "
225
- "Run 'rafter secrets --staged' for details.",
348
+ "reason": _format_staged_secret_reason(result),
226
349
  }
227
350
 
228
351
  audit.log_command_intercepted(command, True, "allowed")
@@ -234,19 +357,32 @@ def _evaluate_write(tool_input: dict) -> dict:
234
357
  if not content:
235
358
  return {"decision": "allow"}
236
359
 
237
- scanner = RegexScanner()
238
- if scanner.has_secrets(content):
239
- matches = scanner.scan_text(content)
240
- names = list({m.pattern.name for m in matches})
241
- audit = AuditLogger()
242
- audit.log_secret_detected(
243
- tool_input.get("file_path", "file content"),
244
- ", ".join(names),
245
- "blocked",
246
- )
247
- return {"decision": "deny", "reason": f"Secret detected in file content: {', '.join(names)}"}
360
+ # Route through the same config pipeline as scan.py so the hook honors
361
+ # custom patterns, exclude_paths, and ignore rules from .rafter.yml.
362
+ from ..core.custom_patterns import apply_suppressions
363
+ from ..scanners.regex_scanner import ScanResult
364
+ from .agent import _apply_exclude_paths
248
365
 
249
- return {"decision": "allow"}
366
+ scan_cfg, suppressions, custom_patterns = _load_scan_config()
367
+ scanner = RegexScanner(custom_patterns)
368
+ matches = scanner.scan_text(content)
369
+ if not matches:
370
+ return {"decision": "allow"}
371
+
372
+ file_path = tool_input.get("file_path", "file content")
373
+ # Apply exclude_paths + suppressions keyed on the target file path, so a
374
+ # write to a policy-excluded path is allowed (matching `rafter secrets`).
375
+ exclude = scan_cfg.exclude_paths if scan_cfg else None
376
+ after_exclude = _apply_exclude_paths([ScanResult(file=file_path, matches=matches)], exclude, os.getcwd())
377
+ kept, _suppressed = apply_suppressions(after_exclude, suppressions)
378
+ kept_matches = kept[0].matches if kept else []
379
+ if not kept_matches:
380
+ return {"decision": "allow"}
381
+
382
+ names = list({m.pattern.name for m in kept_matches})
383
+ audit = AuditLogger()
384
+ audit.log_secret_detected(file_path, ", ".join(names), "blocked")
385
+ return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"}
250
386
 
251
387
 
252
388
  @hook_app.command("pretool")
@@ -273,10 +409,21 @@ def pretool(
273
409
  if not isinstance(tool_input, dict):
274
410
  tool_input = {}
275
411
 
412
+ # Honor the (trusted-source-only) hook off-switch before doing any work.
413
+ from ..core.hook_control import resolve_hook_control
414
+
415
+ control = resolve_hook_control()
416
+ if not control.hook_enabled:
417
+ _write_pretool_decision({"decision": "allow"}, format)
418
+ return
419
+
276
420
  if tool_name == "Bash":
277
- decision = _evaluate_bash(tool_input.get("command", ""))
421
+ decision = _evaluate_bash(tool_input.get("command", ""), control)
278
422
  elif tool_name in ("Write", "Edit"):
279
- decision = _evaluate_write(tool_input)
423
+ if not control.secret_scan_enabled:
424
+ decision = {"decision": "allow"}
425
+ else:
426
+ decision = _evaluate_write(tool_input)
280
427
  else:
281
428
  decision = {"decision": "allow"}
282
429