rafter-cli 0.8.7__tar.gz → 0.8.9__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 (100) hide show
  1. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/PKG-INFO +1 -1
  2. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/pyproject.toml +1 -1
  3. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/agent.py +333 -71
  4. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/hook.py +31 -15
  5. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/mcp_server.py +20 -8
  6. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/scan.py +28 -72
  7. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/skill.py +154 -7
  8. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/config_manager.py +41 -0
  9. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/rafter-security-skill.md +1 -1
  10. rafter_cli-0.8.9/rafter_cli/scanners/git_diff_scan.py +25 -0
  11. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/scanners/regex_scanner.py +14 -0
  12. rafter_cli-0.8.9/rafter_cli/scanners/skill_scanner.py +429 -0
  13. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/utils/api.py +20 -2
  14. rafter_cli-0.8.9/rafter_cli/utils/git_diff.py +95 -0
  15. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/README.md +0 -0
  16. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/__init__.py +0 -0
  17. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/__main__.py +0 -0
  18. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/__init__.py +0 -0
  19. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/agent_components.py +0 -0
  20. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/backend.py +0 -0
  21. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/brief.py +0 -0
  22. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/ci.py +0 -0
  23. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/docs.py +0 -0
  24. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/issues/__init__.py +0 -0
  25. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/issues/dedup.py +0 -0
  26. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/issues/github_client.py +0 -0
  27. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/issues/issue_builder.py +0 -0
  28. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/issues/issues_app.py +0 -0
  29. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/notify.py +0 -0
  30. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/policy.py +0 -0
  31. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/report.py +0 -0
  32. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/commands/skill_remote.py +0 -0
  33. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/__init__.py +0 -0
  34. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/audit_logger.py +0 -0
  35. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/command_interceptor.py +0 -0
  36. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/config_schema.py +0 -0
  37. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/custom_patterns.py +0 -0
  38. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/docs_loader.py +0 -0
  39. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/hook_control.py +0 -0
  40. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/pattern_engine.py +0 -0
  41. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/policy_loader.py +0 -0
  42. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/risk_rules.py +0 -0
  43. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/core/suppression_writer.py +0 -0
  44. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/__init__.py +0 -0
  45. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/agents/__init__.py +0 -0
  46. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/agents/rafter.md +0 -0
  47. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  48. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  49. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  50. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  51. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  52. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  53. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  54. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  55. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  56. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/pre-push-hook.sh +0 -0
  57. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/__init__.py +0 -0
  58. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
  59. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  60. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  61. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  62. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  63. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  64. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  65. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  66. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  67. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  68. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  69. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  70. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  71. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  72. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  73. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  74. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  75. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  76. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  77. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  78. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  79. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  80. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  81. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  82. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  83. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  84. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  85. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  86. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  87. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  88. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  89. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  90. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  91. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  92. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/scanners/__init__.py +0 -0
  93. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/scanners/betterleaks.py +0 -0
  94. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/scanners/secret_patterns.py +0 -0
  95. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/scanners/union.py +0 -0
  96. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/utils/__init__.py +0 -0
  97. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/utils/binary_manager.py +0 -0
  98. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/utils/formatter.py +0 -0
  99. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/rafter_cli/utils/git.py +0 -0
  100. {rafter_cli-0.8.7 → rafter_cli-0.8.9}/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.7
3
+ Version: 0.8.9
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.8.7"
3
+ version = "0.8.9"
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"
@@ -25,7 +25,12 @@ from rich import print as rprint
25
25
  from .. import __version__
26
26
  from ..core.audit_logger import AuditLogger
27
27
  from ..core.command_interceptor import CommandInterceptor
28
- from ..core.config_manager import ConfigManager
28
+ from ..core.config_manager import (
29
+ ConfigManager,
30
+ is_secret_config_key,
31
+ mask_secret_value,
32
+ redact_config_secrets,
33
+ )
29
34
  from ..core.pattern_engine import PatternEngine
30
35
  from ..scanners.betterleaks import BetterleaksScanner
31
36
  from ..scanners.regex_scanner import RegexScanner, ScanResult
@@ -47,7 +52,7 @@ def config_show():
47
52
  from dataclasses import asdict
48
53
 
49
54
  manager = ConfigManager()
50
- print(json.dumps(asdict(manager.load()), indent=2))
55
+ print(json.dumps(redact_config_secrets(asdict(manager.load())), indent=2))
51
56
 
52
57
 
53
58
  @config_app.command("get")
@@ -58,8 +63,11 @@ def config_get(key: str = typer.Argument(..., help="Config key (e.g. agent.risk_
58
63
  if value is None:
59
64
  print(f"Key not found: {key}", file=sys.stderr)
60
65
  raise typer.Exit(code=1)
66
+ leaf = key.split(".")[-1]
61
67
  if isinstance(value, dict):
62
- print(json.dumps(value, indent=2))
68
+ print(json.dumps(redact_config_secrets(value), indent=2))
69
+ elif is_secret_config_key(leaf) and isinstance(value, str):
70
+ print(mask_secret_value(value))
63
71
  else:
64
72
  print(value)
65
73
 
@@ -76,7 +84,13 @@ def config_set(
76
84
  except (json.JSONDecodeError, ValueError):
77
85
  parsed = value
78
86
  manager.set(key, parsed)
79
- rprint(fmt.success(f"Set {key} = {json.dumps(parsed)}"))
87
+ leaf = key.split(".")[-1]
88
+ echo = (
89
+ json.dumps(mask_secret_value(parsed))
90
+ if is_secret_config_key(leaf) and isinstance(parsed, str)
91
+ else json.dumps(parsed)
92
+ )
93
+ rprint(fmt.success(f"Set {key} = {echo}"))
80
94
 
81
95
 
82
96
  agent_app.add_typer(config_app)
@@ -251,6 +265,7 @@ def _print_dry_run_plan(
251
265
  want_aider: bool,
252
266
  want_hermes: bool,
253
267
  want_betterleaks: bool,
268
+ want_skill_scanner: bool,
254
269
  risk_level: str,
255
270
  ) -> None:
256
271
  """Print every file path the install would touch — without writing anything (rf-hrtd).
@@ -295,6 +310,11 @@ def _print_dry_run_plan(
295
310
  print("Betterleaks (--with-betterleaks / --all):")
296
311
  D(home / ".rafter" / "bin" / "betterleaks", "binary, ~12MB from GitHub releases")
297
312
 
313
+ if want_skill_scanner:
314
+ print()
315
+ print("skill-scanner deep engine (--with-skill-scanner):")
316
+ D(Path("skill-scanner"), "heavy PyPI package, isolated install via uv tool / pip --user")
317
+
298
318
  if want_claude_code:
299
319
  print()
300
320
  print("Claude Code (--with-claude-code):")
@@ -1036,6 +1056,7 @@ def _install_hermes_mcp(root: Path) -> bool:
1036
1056
  def init(
1037
1057
  risk_level: str = typer.Option("moderate", "--risk-level", help="minimal, moderate, or aggressive"),
1038
1058
  with_betterleaks: bool = typer.Option(False, "--with-betterleaks", help="Download and install Betterleaks binary"),
1059
+ with_skill_scanner: bool = typer.Option(False, "--with-skill-scanner", help="Install the optional skill-scanner deep engine (heavy; audit-skill --deep)"),
1039
1060
  with_openclaw: bool = typer.Option(False, "--with-openclaw", help="Install OpenClaw integration"),
1040
1061
  with_claude_code: bool = typer.Option(False, "--with-claude-code", help="Install Claude Code integration"),
1041
1062
  with_codex: bool = typer.Option(False, "--with-codex", help="Install Codex CLI integration"),
@@ -1109,6 +1130,8 @@ def init(
1109
1130
  # established. Excluded from --all in --local for the same reason (sable-gyw).
1110
1131
  want_hermes = with_hermes or (all_integrations and not local)
1111
1132
  want_betterleaks = with_betterleaks or (all_integrations and not local)
1133
+ # skill-scanner is heavy and opt-in only — deliberately NOT folded into --all.
1134
+ want_skill_scanner = with_skill_scanner
1112
1135
 
1113
1136
  # Show detected environments
1114
1137
  detected = []
@@ -1175,6 +1198,7 @@ def init(
1175
1198
  want_aider=want_aider and (has_aider or local),
1176
1199
  want_hermes=want_hermes and has_hermes,
1177
1200
  want_betterleaks=want_betterleaks,
1201
+ want_skill_scanner=want_skill_scanner,
1178
1202
  risk_level=risk_level,
1179
1203
  )
1180
1204
  return
@@ -1229,6 +1253,29 @@ def init(
1229
1253
  "and ensure it is on PATH, then re-run 'rafter agent init'."
1230
1254
  ))
1231
1255
 
1256
+ if want_skill_scanner:
1257
+ _ss_on_path = None if update else shutil.which("skill-scanner")
1258
+ if _ss_on_path:
1259
+ rprint(fmt.success(f"skill-scanner available on PATH ({_ss_on_path})"))
1260
+ else:
1261
+ from ..scanners.skill_scanner import SkillScannerInstaller
1262
+
1263
+ rprint(fmt.info(
1264
+ "Installing optional skill-scanner deep engine (heavy "
1265
+ "third-party package; isolated install)..."
1266
+ ))
1267
+ _result = SkillScannerInstaller().install(on_progress=typer.echo)
1268
+ if _result.ok:
1269
+ rprint(fmt.success(
1270
+ f"skill-scanner installed (via {_result.via}): {_result.message}"
1271
+ ))
1272
+ else:
1273
+ rprint(fmt.warning(f"skill-scanner install failed: {_result.message}"))
1274
+ rprint(fmt.info(
1275
+ "To fix: run 'rafter agent update-skill-scanner' or install "
1276
+ "manually with 'uv tool install cisco-ai-skill-scanner'."
1277
+ ))
1278
+
1232
1279
  # Install OpenClaw skill if opted in
1233
1280
  openclaw_ok = False
1234
1281
  if has_openclaw and want_openclaw:
@@ -1575,6 +1622,76 @@ def _scan_file(file_path: str, engine: str, custom_patterns=None) -> list[ScanRe
1575
1622
  return run_patterns()
1576
1623
 
1577
1624
 
1625
+ def _run_git_added_line_scan(
1626
+ git_args: list[str],
1627
+ git_cwd: str | None,
1628
+ custom_patterns,
1629
+ scan_cfg,
1630
+ baseline_entries: list,
1631
+ suppressions,
1632
+ context_label: str,
1633
+ empty_message: str,
1634
+ *,
1635
+ json_output: bool,
1636
+ quiet: bool,
1637
+ format: str,
1638
+ not_repo_message: str = "Error: Not in a git repository or invalid ref",
1639
+ ) -> None:
1640
+ """Parse a unified diff for + lines and scan with the patterns engine."""
1641
+ from ..utils.git_diff import parse_unified_diff_added_lines
1642
+ from ..scanners.git_diff_scan import scan_added_diff_lines
1643
+
1644
+ try:
1645
+ patch = subprocess.run(
1646
+ ["git", *git_args],
1647
+ capture_output=True,
1648
+ text=True,
1649
+ check=True,
1650
+ cwd=git_cwd,
1651
+ ).stdout
1652
+ except subprocess.CalledProcessError:
1653
+ print(not_repo_message, file=sys.stderr)
1654
+ raise typer.Exit(code=2)
1655
+
1656
+ if not patch.strip():
1657
+ if not quiet:
1658
+ rprint(fmt.success(empty_message))
1659
+ raise typer.Exit(code=0)
1660
+
1661
+ added = parse_unified_diff_added_lines(patch)
1662
+ if not added:
1663
+ if not quiet:
1664
+ rprint(fmt.success(empty_message))
1665
+ raise typer.Exit(code=0)
1666
+
1667
+ try:
1668
+ repo_root = subprocess.run(
1669
+ ["git", "rev-parse", "--show-toplevel"],
1670
+ capture_output=True,
1671
+ text=True,
1672
+ check=True,
1673
+ cwd=git_cwd,
1674
+ ).stdout.strip()
1675
+ except subprocess.CalledProcessError:
1676
+ print(not_repo_message, file=sys.stderr)
1677
+ raise typer.Exit(code=2)
1678
+
1679
+ file_count = len({line.file for line in added})
1680
+ if not quiet:
1681
+ print(
1682
+ f"Scanning {len(added)} added line(s) in {file_count} file(s) ({context_label})...",
1683
+ file=sys.stderr,
1684
+ )
1685
+
1686
+ all_results = scan_added_diff_lines(added, repo_root, custom_patterns)
1687
+ exclude = scan_cfg.exclude_paths if scan_cfg else None
1688
+ all_results = _apply_exclude_paths(all_results, exclude, repo_root)
1689
+ filtered = _apply_baseline(all_results, baseline_entries)
1690
+ _output_scan_results(
1691
+ filtered, json_output, quiet, context_label, format=format, suppressions=suppressions
1692
+ )
1693
+
1694
+
1578
1695
  def _path_matches_exclude_pattern(rel_path: str, pattern: str) -> bool:
1579
1696
  """Mirror of Node ``pathMatchesExcludePattern`` (sable-yz0).
1580
1697
 
@@ -1938,64 +2055,42 @@ def scan(
1938
2055
 
1939
2056
  baseline_entries = _load_baseline_entries() if baseline else []
1940
2057
 
2058
+ resolved_scan_path = os.path.abspath(path)
2059
+ git_cwd = resolved_scan_path if os.path.isdir(resolved_scan_path) else None
2060
+
1941
2061
  # --diff
1942
2062
  if diff:
1943
- try:
1944
- diff_output = subprocess.run(
1945
- ["git", "diff", "--name-only", "--diff-filter=ACM", diff],
1946
- capture_output=True, text=True, check=True,
1947
- ).stdout.strip()
1948
- except subprocess.CalledProcessError:
1949
- print("Error: Not in a git repository or invalid ref", file=sys.stderr)
1950
- raise typer.Exit(code=2)
1951
-
1952
- if not diff_output:
1953
- if not quiet:
1954
- rprint(fmt.success(f"No files changed since {diff}"))
1955
- raise typer.Exit(code=0)
1956
-
1957
- changed = [f.strip() for f in diff_output.split("\n") if f.strip()]
1958
- if not quiet:
1959
- print(f"Scanning {len(changed)} file(s) changed since {diff}...", file=sys.stderr)
1960
-
1961
- eng = _select_engine(engine, quiet, auto_update_enabled)
1962
- all_results: list[ScanResult] = []
1963
- for f in changed:
1964
- resolved = os.path.abspath(f)
1965
- if os.path.isfile(resolved):
1966
- all_results.extend(_scan_file(resolved, eng, custom_patterns))
1967
- filtered = _apply_baseline(all_results, baseline_entries)
1968
- _output_scan_results(filtered, json_output, quiet, f"files changed since {diff}", format=format, suppressions=suppressions)
2063
+ _run_git_added_line_scan(
2064
+ ["diff", "-U0", "--no-color", "--diff-filter=ACM", diff],
2065
+ git_cwd,
2066
+ custom_patterns,
2067
+ scan_cfg,
2068
+ baseline_entries,
2069
+ suppressions,
2070
+ f"files changed since {diff}",
2071
+ f"No files changed since {diff}",
2072
+ json_output=json_output,
2073
+ quiet=quiet,
2074
+ format=format,
2075
+ )
1969
2076
  return
1970
2077
 
1971
2078
  # --staged
1972
2079
  if staged:
1973
- try:
1974
- staged_output = subprocess.run(
1975
- ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
1976
- capture_output=True, text=True, check=True,
1977
- ).stdout.strip()
1978
- except subprocess.CalledProcessError:
1979
- print("Error: Not in a git repository", file=sys.stderr)
1980
- raise typer.Exit(code=2)
1981
-
1982
- if not staged_output:
1983
- if not quiet:
1984
- rprint(fmt.success("No files staged for commit"))
1985
- raise typer.Exit(code=0)
1986
-
1987
- staged_files = [f.strip() for f in staged_output.split("\n") if f.strip()]
1988
- if not quiet:
1989
- print(f"Scanning {len(staged_files)} staged file(s)...", file=sys.stderr)
1990
-
1991
- eng = _select_engine(engine, quiet, auto_update_enabled)
1992
- all_results = []
1993
- for f in staged_files:
1994
- resolved = os.path.abspath(f)
1995
- if os.path.isfile(resolved):
1996
- all_results.extend(_scan_file(resolved, eng, custom_patterns))
1997
- filtered = _apply_baseline(all_results, baseline_entries)
1998
- _output_scan_results(filtered, json_output, quiet, "staged files", format=format, suppressions=suppressions)
2080
+ _run_git_added_line_scan(
2081
+ ["diff", "-U0", "--no-color", "--cached", "--diff-filter=ACM"],
2082
+ git_cwd,
2083
+ custom_patterns,
2084
+ scan_cfg,
2085
+ baseline_entries,
2086
+ suppressions,
2087
+ "staged files",
2088
+ "No files staged for commit",
2089
+ json_output=json_output,
2090
+ quiet=quiet,
2091
+ format=format,
2092
+ not_repo_message="Error: Not in a git repository",
2093
+ )
1999
2094
  return
2000
2095
 
2001
2096
  # Default: scan path
@@ -2232,17 +2327,26 @@ def exec_cmd(
2232
2327
  interceptor.log_evaluation(evaluation, "blocked")
2233
2328
  raise typer.Exit(code=1)
2234
2329
 
2235
- # Pre-exec scan for git commands
2330
+ # Pre-exec scan for git commands (+ lines in staged diff only)
2236
2331
  if not skip_scan and command.strip().startswith(("git commit", "git push")):
2237
2332
  try:
2238
- staged = subprocess.run(
2239
- ["git", "diff", "--cached", "--name-only"],
2240
- capture_output=True, text=True,
2241
- ).stdout.strip().split("\n")
2242
- staged = [f for f in staged if f]
2243
- if staged:
2244
- scanner = RegexScanner()
2245
- results = scanner.scan_files(staged)
2333
+ from ..utils.git_diff import parse_unified_diff_added_lines
2334
+ from ..scanners.git_diff_scan import scan_added_diff_lines
2335
+
2336
+ patch = subprocess.run(
2337
+ ["git", "diff", "-U0", "--no-color", "--cached", "--diff-filter=ACM"],
2338
+ capture_output=True,
2339
+ text=True,
2340
+ ).stdout
2341
+ if patch.strip():
2342
+ repo_root = subprocess.run(
2343
+ ["git", "rev-parse", "--show-toplevel"],
2344
+ capture_output=True,
2345
+ text=True,
2346
+ check=True,
2347
+ ).stdout.strip()
2348
+ added = parse_unified_diff_added_lines(patch)
2349
+ results = scan_added_diff_lines(added, repo_root)
2246
2350
  total = sum(len(r.matches) for r in results)
2247
2351
  if results:
2248
2352
  rprint(f"\n{fmt.warning('Secrets detected in staged files!')}\n")
@@ -2997,11 +3101,50 @@ def _generate_manual_review_prompt(
2997
3101
  Provide a clear risk rating (LOW/MEDIUM/HIGH/CRITICAL) and actionable recommendations."""
2998
3102
 
2999
3103
 
3104
+ def _display_deep_scan(deep, skill_name: str) -> None:
3105
+ """Render human-readable skill-scanner (deep engine) results."""
3106
+ print("\n\U0001f50e Deep Scan Results (skill-scanner)")
3107
+ print("═" * 60)
3108
+ if not deep.available:
3109
+ print("⚠️ skill-scanner not available")
3110
+ return
3111
+ if deep.error:
3112
+ print(f"⚠️ Deep scan error: {deep.error}")
3113
+ return
3114
+ actionable = [f for f in deep.findings if f.severity in ("critical", "high", "medium")]
3115
+ if not actionable:
3116
+ print("✓ No critical/high/medium findings")
3117
+ else:
3118
+ print(f"⚠️ {len(actionable)} finding(s) (max severity: {deep.max_severity})")
3119
+ for f in actionable[:10]:
3120
+ loc = f" (line {f.line})" if f.line else ""
3121
+ print(f" • [{f.severity.upper()}] {f.category}: {f.title}{loc}")
3122
+ if len(actionable) > 10:
3123
+ print(f" ... and {len(actionable) - 10} more")
3124
+ if deep.analyzers_used:
3125
+ print(f" analyzers: {', '.join(deep.analyzers_used)} (offline only)")
3126
+ print()
3127
+
3128
+
3000
3129
  @agent_app.command("audit-skill")
3001
3130
  def audit_skill(
3002
3131
  skill_path: str = typer.Argument(..., help="Path to skill file to audit"),
3003
3132
  skip_openclaw: bool = typer.Option(False, "--skip-openclaw", help="Skip OpenClaw integration, show manual review prompt"),
3004
3133
  json_output: bool = typer.Option(False, "--json", help="Output results as JSON"),
3134
+ deep: bool = typer.Option(
3135
+ False,
3136
+ "--deep",
3137
+ help=(
3138
+ "Run the optional DEEP engine (Cisco AI Defense skill-scanner) in "
3139
+ "addition to the quick scan. Offline analyzers only — no LLM/cloud/"
3140
+ "network. Requires `cisco-ai-skill-scanner` to be installed."
3141
+ ),
3142
+ ),
3143
+ engine: str = typer.Option(
3144
+ None,
3145
+ "--engine",
3146
+ help="Deep engine selector. 'skill-scanner' is equivalent to --deep.",
3147
+ ),
3005
3148
  ) -> None:
3006
3149
  """[deprecated] Security audit of a Claude Code skill file — use `rafter skill review` instead."""
3007
3150
  if not json_output:
@@ -3009,14 +3152,27 @@ def audit_skill(
3009
3152
  "[deprecated] `rafter agent audit-skill` is deprecated; use `rafter skill review <path-or-url>` instead.",
3010
3153
  file=sys.stderr,
3011
3154
  )
3012
- # Validate skill file exists
3155
+ # Validate target exists. Accept either a skill *file* (.md) or a skill
3156
+ # *directory*. The deep engine (--deep) is most thorough on a directory,
3157
+ # where it can also see bundled scripts / .pyc; the quick scan reads the
3158
+ # directory's SKILL.md (or the file itself).
3013
3159
  resolved = Path(skill_path).resolve()
3014
3160
  if not resolved.exists():
3015
- print(f"Error: Skill file not found: {skill_path}", file=sys.stderr)
3161
+ print(f"Error: Skill path not found: {skill_path}", file=sys.stderr)
3016
3162
  raise typer.Exit(code=2)
3017
3163
 
3018
- skill_content = resolved.read_text(encoding="utf-8")
3019
- skill_name = resolved.name
3164
+ if resolved.is_dir():
3165
+ skill_md = resolved / "SKILL.md"
3166
+ if skill_md.is_file():
3167
+ skill_content = skill_md.read_text(encoding="utf-8")
3168
+ else:
3169
+ # No SKILL.md to quick-scan; the deep engine can still scan the
3170
+ # directory's contents. Quick scan simply finds nothing.
3171
+ skill_content = ""
3172
+ skill_name = resolved.name
3173
+ else:
3174
+ skill_content = resolved.read_text(encoding="utf-8")
3175
+ skill_name = resolved.name
3020
3176
 
3021
3177
  # Run deterministic analysis
3022
3178
  if not json_output:
@@ -3030,11 +3186,44 @@ def audit_skill(
3030
3186
  if not json_output:
3031
3187
  _display_quick_scan(quick_scan, skill_name)
3032
3188
 
3189
+ # Optional DEEP engine (skill-scanner) — opt-in via --deep or
3190
+ # --engine skill-scanner. Offline analyzers only; preserves our
3191
+ # no-telemetry default (sable-7g7).
3192
+ want_deep = deep or (engine == "skill-scanner")
3193
+ if engine is not None and engine != "skill-scanner":
3194
+ print(
3195
+ f"Error: unknown --engine '{engine}' (supported: skill-scanner)",
3196
+ file=sys.stderr,
3197
+ )
3198
+ raise typer.Exit(code=2)
3199
+
3200
+ deep_result = None
3201
+ if want_deep:
3202
+ from ..scanners.skill_scanner import SkillScanner
3203
+
3204
+ scanner = SkillScanner()
3205
+ if not scanner.is_available():
3206
+ # --deep requested but tool missing: clear hint, non-zero exit,
3207
+ # don't crash.
3208
+ from ..scanners.skill_scanner import INSTALL_HINT
3209
+
3210
+ print(INSTALL_HINT, file=sys.stderr)
3211
+ raise typer.Exit(code=2)
3212
+ deep_result = scanner.scan_path(str(resolved))
3213
+ if deep_result.error:
3214
+ print(f"Error: deep scan failed: {deep_result.error}", file=sys.stderr)
3215
+ raise typer.Exit(code=2)
3216
+ if not json_output:
3217
+ _display_deep_scan(deep_result, skill_name)
3218
+
3033
3219
  # Check OpenClaw availability
3034
3220
  skill_manager = SkillManager()
3035
3221
  openclaw_available = skill_manager.is_openclaw_installed()
3036
3222
  rafter_skill_installed = skill_manager.is_rafter_skill_installed()
3037
3223
 
3224
+ quick_has_findings = quick_scan.secrets > 0 or len(quick_scan.high_risk_commands) > 0
3225
+ deep_has_findings = deep_result.has_findings if deep_result else False
3226
+
3038
3227
  if json_output:
3039
3228
  result = {
3040
3229
  "skill": skill_name,
@@ -3047,7 +3236,14 @@ def audit_skill(
3047
3236
  "openClawAvailable": openclaw_available,
3048
3237
  "rafterSkillInstalled": rafter_skill_installed,
3049
3238
  }
3050
- has_findings = quick_scan.secrets > 0 or len(quick_scan.high_risk_commands) > 0
3239
+ if deep_result is not None:
3240
+ result["deepScan"] = {
3241
+ "engine": "skill-scanner",
3242
+ "maxSeverity": deep_result.max_severity,
3243
+ "analyzersUsed": deep_result.analyzers_used,
3244
+ "findings": [f.to_dict() for f in deep_result.findings],
3245
+ }
3246
+ has_findings = quick_has_findings or deep_has_findings
3051
3247
  print(json.dumps(result, indent=2))
3052
3248
  raise typer.Exit(code=1 if has_findings else 0)
3053
3249
 
@@ -3084,7 +3280,7 @@ def audit_skill(
3084
3280
 
3085
3281
  print()
3086
3282
 
3087
- if quick_scan.secrets > 0 or len(quick_scan.high_risk_commands) > 0:
3283
+ if quick_has_findings or deep_has_findings:
3088
3284
  raise typer.Exit(code=1)
3089
3285
 
3090
3286
 
@@ -3141,6 +3337,72 @@ def update_betterleaks(
3141
3337
  raise typer.Exit(code=1)
3142
3338
 
3143
3339
 
3340
+ @agent_app.command("update-skill-scanner")
3341
+ def update_skill_scanner(
3342
+ version: str = typer.Option(
3343
+ None,
3344
+ "--version",
3345
+ help="skill-scanner version to install (default: pinned version)",
3346
+ ),
3347
+ ):
3348
+ """Install or update the optional `skill-scanner` deep engine (audit-skill --deep).
3349
+
3350
+ Installs Cisco AI Defense's skill-scanner in an isolated environment (uv tool,
3351
+ or pip --user fallback). This is a heavy third-party package and its
3352
+ transitive dependencies; it is NOT bundled with Rafter and is only used when
3353
+ you pass --deep. The deep engine still runs OFFLINE analyzers only.
3354
+ """
3355
+ from ..scanners.skill_scanner import (
3356
+ SkillScannerInstaller,
3357
+ SKILL_SCANNER_VERSION,
3358
+ )
3359
+
3360
+ target_version = version or SKILL_SCANNER_VERSION
3361
+ installer = SkillScannerInstaller()
3362
+
3363
+ existing = shutil.which("skill-scanner")
3364
+ if existing:
3365
+ rprint(fmt.info(f"Current skill-scanner: {existing}"))
3366
+ else:
3367
+ rprint(fmt.info("skill-scanner not currently on PATH"))
3368
+
3369
+ rprint(fmt.warning(
3370
+ "skill-scanner is a heavy third-party package (pulls litellm, fastapi, "
3371
+ "yara-x, …). Installing it in an isolated environment."
3372
+ ))
3373
+ rprint(fmt.info(f"Installing skill-scanner v{target_version}..."))
3374
+ rprint()
3375
+
3376
+ result = installer.install(version=target_version, on_progress=typer.echo)
3377
+ rprint()
3378
+ if not result.ok:
3379
+ rprint(fmt.error(f"Install failed: {result.message}"))
3380
+ rprint(fmt.info(
3381
+ "To fix: install manually with `uv tool install "
3382
+ "cisco-ai-skill-scanner` (or `pip install --user "
3383
+ "cisco-ai-skill-scanner`) and ensure `skill-scanner` is on PATH."
3384
+ ))
3385
+ raise typer.Exit(code=1)
3386
+
3387
+ rprint(fmt.success(f"skill-scanner installed (via {result.via}): {result.message}"))
3388
+ rprint(fmt.info("Run `rafter skill review <path> --deep` to use it."))
3389
+
3390
+
3391
+ @agent_app.command("remove-skill-scanner")
3392
+ def remove_skill_scanner():
3393
+ """Uninstall the optional `skill-scanner` deep engine (inverse of update-skill-scanner).
3394
+
3395
+ Removes the managed install (uv tool, or pip fallback). Safe to run when it
3396
+ isn't installed. Your skills and Rafter's own dependencies are untouched.
3397
+ """
3398
+ from ..scanners.skill_scanner import SkillScannerInstaller
3399
+
3400
+ result = SkillScannerInstaller().uninstall(on_progress=typer.echo)
3401
+ rprint()
3402
+ if not result.ok:
3403
+ rprint(fmt.error(f"Uninstall failed: {result.message}"))
3404
+ raise typer.Exit(code=1)
3405
+ rprint(fmt.success(f"skill-scanner removed: {result.message}"))
3144
3406
 
3145
3407
 
3146
3408
  # ── agent status ─────────────────────────────────────────────────────────
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import math
5
6
  import os
6
7
  import subprocess
7
8
  import sys
@@ -52,6 +53,23 @@ def _format_approval_message(command: str, evaluation) -> str:
52
53
 
53
54
  _STDIN_TIMEOUT_S = 5
54
55
 
56
+ def _stdin_timeout_s() -> float:
57
+ # Bound the stdin read so a hung/never-closing stdin can't wedge the hook.
58
+ # Overridable via env (milliseconds, parity with the Node hook) as an
59
+ # operator safety valve / for tests.
60
+ raw = os.environ.get("RAFTER_HOOK_STDIN_TIMEOUT_MS")
61
+ if raw:
62
+ try:
63
+ ms = float(raw)
64
+ # Require finite and positive (parity with the Node `Number.isFinite`
65
+ # check). `inf`/`nan` must NOT pass — `join(timeout=inf)` would
66
+ # reintroduce the exact unbounded hang this bound exists to prevent.
67
+ if math.isfinite(ms) and ms > 0:
68
+ return ms / 1000.0
69
+ except ValueError:
70
+ pass
71
+ return _STDIN_TIMEOUT_S
72
+
55
73
  def _read_stdin() -> str:
56
74
  import threading
57
75
  result: list[str] = [""]
@@ -60,9 +78,11 @@ def _read_stdin() -> str:
60
78
  result[0] = sys.stdin.read()
61
79
  except Exception:
62
80
  pass
81
+ # daemon=True: if stdin never closes, the abandoned reader thread does not
82
+ # block interpreter exit, so the process exits after the join timeout.
63
83
  t = threading.Thread(target=_reader, daemon=True)
64
84
  t.start()
65
- t.join(timeout=_STDIN_TIMEOUT_S)
85
+ t.join(timeout=_stdin_timeout_s())
66
86
  return result[0]
67
87
 
68
88
 
@@ -256,27 +276,23 @@ def _scan_staged_files() -> dict:
256
276
  ).stdout.strip() or os.getcwd()
257
277
 
258
278
  output = subprocess.run(
259
- ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
279
+ ["git", "diff", "-U0", "--no-color", "--cached", "--diff-filter=ACM"],
260
280
  capture_output=True, text=True,
261
- ).stdout.strip()
262
- if not output:
281
+ ).stdout
282
+ if not output.strip():
263
283
  return {**empty, "repo_root": repo_root}
264
- staged = [f for f in output.split("\n") if f.strip()]
265
284
 
285
+ from ..utils.git_diff import parse_unified_diff_added_lines
286
+ from ..scanners.git_diff_scan import scan_added_diff_lines
266
287
  from ..core.custom_patterns import apply_suppressions
267
288
  from .agent import _apply_exclude_paths
268
289
 
290
+ added = parse_unified_diff_added_lines(output)
291
+ if not added:
292
+ return {**empty, "repo_root": repo_root}
293
+
269
294
  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)
295
+ raw = scan_added_diff_lines(added, repo_root, custom_patterns)
280
296
 
281
297
  exclude = scan_cfg.exclude_paths if scan_cfg else None
282
298
  after_exclude = _apply_exclude_paths(raw, exclude, repo_root)