rafter-cli 0.11.2__tar.gz → 0.11.4__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 (102) hide show
  1. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/PKG-INFO +2 -2
  2. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/README.md +1 -1
  3. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/pyproject.toml +1 -1
  4. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/agent.py +48 -5
  5. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/agent_components.py +3 -2
  6. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/hook.py +24 -3
  7. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issue_builder.py +40 -15
  8. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issues_app.py +13 -4
  9. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/notify.py +84 -30
  10. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/policy.py +1 -1
  11. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/risk_rules.py +226 -2
  12. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/rafter-security-skill.md +1 -1
  13. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/SKILL.md +1 -0
  14. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/betterleaks.py +135 -80
  15. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/regex_scanner.py +1 -0
  16. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/api.py +6 -1
  17. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/__init__.py +0 -0
  18. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/__main__.py +0 -0
  19. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/__init__.py +0 -0
  20. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/backend.py +0 -0
  21. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/brief.py +0 -0
  22. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/ci.py +0 -0
  23. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/docs.py +0 -0
  24. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/issues/__init__.py +0 -0
  25. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/issues/dedup.py +0 -0
  26. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/issues/github_client.py +0 -0
  27. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/mcp_server.py +0 -0
  28. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/report.py +0 -0
  29. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/scan.py +0 -0
  30. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/sites.py +0 -0
  31. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/skill.py +0 -0
  32. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/commands/skill_remote.py +0 -0
  33. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/__init__.py +0 -0
  34. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/audit_logger.py +0 -0
  35. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/command_interceptor.py +0 -0
  36. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/config_manager.py +0 -0
  37. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/config_schema.py +0 -0
  38. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/custom_patterns.py +0 -0
  39. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/docs_loader.py +0 -0
  40. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/hook_control.py +0 -0
  41. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/pattern_engine.py +0 -0
  42. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/policy_loader.py +0 -0
  43. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/core/suppression_writer.py +0 -0
  44. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/__init__.py +0 -0
  45. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/agents/__init__.py +0 -0
  46. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/agents/rafter.md +0 -0
  47. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  48. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  49. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  50. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  51. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  52. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  53. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  54. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  55. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  56. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/pre-push-hook.sh +0 -0
  57. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/__init__.py +0 -0
  58. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  59. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  60. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  61. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  62. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  63. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  64. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  65. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  66. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  67. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  68. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  69. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  70. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  71. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  72. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  73. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  74. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  75. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  76. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  77. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  78. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  79. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  80. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  81. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  82. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  83. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  84. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  85. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  86. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  87. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  88. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  89. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  90. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  91. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/__init__.py +0 -0
  92. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/git_diff_scan.py +0 -0
  93. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/git_history_scan.py +0 -0
  94. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/secret_patterns.py +0 -0
  95. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/skill_scanner.py +0 -0
  96. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/scanners/union.py +0 -0
  97. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/__init__.py +0 -0
  98. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/binary_manager.py +0 -0
  99. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/formatter.py +0 -0
  100. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/git.py +0 -0
  101. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/rafter_cli/utils/git_diff.py +0 -0
  102. {rafter_cli-0.11.2 → rafter_cli-0.11.4}/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.11.2
3
+ Version: 0.11.4
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
@@ -166,7 +166,7 @@ Export Rafter policy for agent platforms.
166
166
 
167
167
  ```bash
168
168
  # Filter high-severity vulnerabilities (SARIF levels: error, warning, note)
169
- rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.level=="error")'
169
+ rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.severity=="error")'
170
170
 
171
171
  # CI gate
172
172
  if rafter get SCAN_ID --format json | jq -e '.vulnerabilities | length > 0'; then
@@ -139,7 +139,7 @@ Export Rafter policy for agent platforms.
139
139
 
140
140
  ```bash
141
141
  # Filter high-severity vulnerabilities (SARIF levels: error, warning, note)
142
- rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.level=="error")'
142
+ rafter get SCAN_ID --format json | jq '.vulnerabilities[] | select(.severity=="error")'
143
143
 
144
144
  # CI gate
145
145
  if rafter get SCAN_ID --format json | jq -e '.vulnerabilities | length > 0'; then
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.11.2"
3
+ version = "0.11.4"
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"
@@ -223,7 +223,7 @@ def _install_claude_code_hooks(root: Path, quiet: bool = False) -> None:
223
223
  post_hook = {"type": "command", "command": post_command}
224
224
  settings["hooks"]["PreToolUse"].extend([
225
225
  {"matcher": "Bash", "hooks": [pre_hook]},
226
- {"matcher": "Write|Edit", "hooks": [pre_hook]},
226
+ {"matcher": "Write|Edit|MultiEdit", "hooks": [pre_hook]},
227
227
  ])
228
228
  # Narrow to tools that produce scannable output (shell output + file
229
229
  # writes). Firing posttool on every tool — including Read and MCP calls —
@@ -733,9 +733,9 @@ def _install_gemini_mcp(root: Path, quiet: bool = False) -> bool:
733
733
 
734
734
  # Cursor hook events covered by rafter (rf-svn3).
735
735
  _CURSOR_HOOK_EVENTS: tuple[tuple[str, str], ...] = (
736
- ("preToolUse", "rafter hook pretool --format cursor"),
737
- ("postToolUse", "rafter hook posttool --format cursor"),
738
- ("beforeShellExecution", "rafter hook pretool --format cursor"),
736
+ ("preToolUse", "hook pretool --format cursor"),
737
+ ("postToolUse", "hook posttool --format cursor"),
738
+ ("beforeShellExecution", "hook pretool --format cursor"),
739
739
  )
740
740
 
741
741
  # Skills shipped as both Cursor rules and Claude Code / Codex / Gemini skills.
@@ -783,6 +783,44 @@ def _install_codex_hooks(root: Path, quiet: bool = False) -> None:
783
783
  _init_print(quiet, fmt.success(f"Installed PostToolUse hooks to {hooks_path}"))
784
784
 
785
785
 
786
+ def _install_gemini_hooks(root: Path, quiet: bool = False) -> None:
787
+ """Install Rafter BeforeTool/AfterTool hooks into <root>/.gemini/settings.json.
788
+
789
+ Mirrors Node's installGeminiHooks. Gemini matchers are regexes against its
790
+ built-in tool names; the mutating ones are matched explicitly.
791
+ """
792
+ gemini_dir = root / ".gemini"
793
+ gemini_dir.mkdir(parents=True, exist_ok=True)
794
+ settings_path = gemini_dir / "settings.json"
795
+
796
+ settings: dict[str, Any] = {}
797
+ if settings_path.exists():
798
+ try:
799
+ settings = json.loads(settings_path.read_text())
800
+ except (json.JSONDecodeError, ValueError):
801
+ _init_alert(quiet, fmt.warning("Existing Gemini settings.json was unreadable, creating new one"))
802
+
803
+ hooks = settings.setdefault("hooks", {})
804
+ rafter_bin = _resolve_rafter_path()
805
+ for event, sub, matcher in (
806
+ ("BeforeTool", "pretool", "run_shell_command|write_file|replace"),
807
+ ("AfterTool", "posttool", ".*"),
808
+ ):
809
+ entries = hooks.get(event) or []
810
+ entries = [
811
+ e for e in entries
812
+ if not any(f"hook {sub}" in str(h.get("command", "")) for h in (e.get("hooks") or []))
813
+ ]
814
+ entries.append({
815
+ "matcher": matcher,
816
+ "hooks": [{"type": "command", "command": f"{rafter_bin} hook {sub} --format gemini", "timeout": 5000}],
817
+ })
818
+ hooks[event] = entries
819
+
820
+ settings_path.write_text(json.dumps(settings, indent=2))
821
+ _init_print(quiet, fmt.success(f"Installed hooks to {settings_path}"))
822
+
823
+
786
824
  def _install_cursor_hooks(root: Path, quiet: bool = False) -> None:
787
825
  """Install Cursor hooks at <root>/.cursor/hooks.json.
788
826
 
@@ -804,7 +842,11 @@ def _install_cursor_hooks(root: Path, quiet: bool = False) -> None:
804
842
  config.setdefault("version", 1)
805
843
  config.setdefault("hooks", {})
806
844
 
807
- for event, command in _CURSOR_HOOK_EVENTS:
845
+ # The resolved path, as for the Claude Code and Codex hooks: Cursor runs
846
+ # hooks without the user's shell PATH, where a bare `rafter` may not resolve.
847
+ rafter_bin = _resolve_rafter_path()
848
+ for event, args in _CURSOR_HOOK_EVENTS:
849
+ command = f"{rafter_bin} {args}"
808
850
  entries = config["hooks"].get(event)
809
851
  if not isinstance(entries, list):
810
852
  entries = []
@@ -1505,6 +1547,7 @@ def init(
1505
1547
  _init_alert(quiet, fmt.error(f"Failed to install Gemini CLI skills: {skills_error}"))
1506
1548
  else:
1507
1549
  _register_gemini_skills(root / ".agents" / "skills", quiet=quiet)
1550
+ _install_gemini_hooks(root, quiet=quiet)
1508
1551
  if gemini_ok and scope == "user":
1509
1552
  manager.set("agent.environments.gemini.enabled", True)
1510
1553
  except Exception as e:
@@ -205,7 +205,7 @@ def _claude_code_hooks() -> ComponentSpec:
205
205
  del hooks["SessionStart"]
206
206
  hooks["PreToolUse"].extend([
207
207
  {"matcher": "Bash", "hooks": [pre]},
208
- {"matcher": "Write|Edit", "hooks": [pre]},
208
+ {"matcher": "Write|Edit|MultiEdit", "hooks": [pre]},
209
209
  ])
210
210
  # Narrow to tools that produce scannable output (shell output + file
211
211
  # writes); avoids firing posttool on every Read/MCP call (latency).
@@ -594,8 +594,9 @@ def _gemini_hooks() -> ComponentSpec:
594
594
  h["AfterTool"] = _filter_hooks(h["AfterTool"], lambda e: _hook_entry_has_rafter(e, "rafter hook posttool"))
595
595
  # Explicit Gemini built-in tool names per geminicli.com/docs/hooks/reference
596
596
  # (rf-044o verification).
597
+ # No tool is named `edit`: Gemini's edit tool is `replace`.
597
598
  h["BeforeTool"].append({
598
- "matcher": "run_shell_command|write_file|replace|edit",
599
+ "matcher": "run_shell_command|write_file|replace",
599
600
  "hooks": [{"type": "command", "command": "rafter hook pretool --format gemini", "timeout": 5000}],
600
601
  })
601
602
  h["AfterTool"].append({
@@ -455,8 +455,9 @@ def _writes_rafter_security_config(file_path: str) -> bool:
455
455
  def _evaluate_write(tool_input: dict) -> dict:
456
456
  # rf-vnxs: checked FIRST, and before the no-content early return, because a
457
457
  # truncating write with empty content disables the hook just as well as one
458
- # carrying JSON.
459
- if _writes_rafter_security_config(tool_input.get("file_path", "")):
458
+ # carrying JSON. apply_patch names its targets only in the patch headers.
459
+ targets = [tool_input.get("file_path", ""), *tool_input.get("patch_paths", [])]
460
+ if any(_writes_rafter_security_config(t) for t in targets):
460
461
  return {
461
462
  "decision": "deny",
462
463
  "reason": (
@@ -572,7 +573,8 @@ def _normalize_write_input(tool_name: str, tool_input: dict) -> dict:
572
573
 
573
574
  Continue's Write names its path ``filepath``; MultiEdit and Windsurf's
574
575
  pre_write_code carry ``edits[].new_string``; Codex's apply_patch carries
575
- the whole patch in ``command``.
576
+ the whole patch in ``command``, with its target paths in the patch
577
+ headers (``patch_paths``).
576
578
  """
577
579
  out = dict(tool_input)
578
580
  if "file_path" not in out and isinstance(tool_input.get("filepath"), str):
@@ -585,9 +587,28 @@ def _normalize_write_input(tool_name: str, tool_input: dict) -> dict:
585
587
  )
586
588
  elif tool_name == "apply_patch" and isinstance(tool_input.get("command"), str):
587
589
  out["content"] = tool_input["command"]
590
+ if tool_name == "apply_patch" and isinstance(tool_input.get("command"), str):
591
+ out["patch_paths"] = _apply_patch_paths(tool_input["command"])
588
592
  return out
589
593
 
590
594
 
595
+ # The apply_patch format carries its targets only in hunk headers, which Codex
596
+ # matches on the trimmed line (codex-rs/apply-patch, apply_patch.lark).
597
+ _APPLY_PATCH_HEADERS = ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: ")
598
+
599
+
600
+ def _apply_patch_paths(patch: str) -> list[str]:
601
+ """Every path an apply_patch names."""
602
+ paths = []
603
+ for raw in patch.split("\n"):
604
+ line = raw.strip()
605
+ for header in _APPLY_PATCH_HEADERS:
606
+ if line.startswith(header):
607
+ paths.append(line[len(header):].strip())
608
+ break
609
+ return paths
610
+
611
+
591
612
  def _deny_on_error(exc: Exception) -> dict:
592
613
  reason = f"rafter hook error: {type(exc).__name__}: {exc}; denying to be safe"
593
614
  print(reason, file=sys.stderr)
@@ -17,10 +17,28 @@ class IssueDraft:
17
17
 
18
18
  @dataclass
19
19
  class BackendVulnerability:
20
- rule_id: str
21
- level: str
22
- message: str
23
- file: str
20
+ """Backend vulnerability from `GET /api/static/scan` (`rafter get <id> --format json`).
21
+
22
+ The real API shape (`toRafterFindings()` on the backend) is
23
+ `rule_id`/`severity`/`title`/`description`/`file_path`/`line_start`/
24
+ `line_end` plus optional `cwe`/`confidence`. `level`/`message`/`file`/
25
+ `line` are the pre-rename names, kept as a fallback for old cached
26
+ payloads and existing tests, never the primary source.
27
+ """
28
+
29
+ rule_id: str = ""
30
+ severity: str = ""
31
+ title: str = ""
32
+ description: str = ""
33
+ file_path: str | None = None
34
+ line_start: int | None = None
35
+ line_end: int | None = None
36
+ cwe: str | None = None
37
+ confidence: str | None = None
38
+ # Pre-rename names.
39
+ level: str = ""
40
+ message: str = ""
41
+ file: str = ""
24
42
  line: int | None = None
25
43
 
26
44
 
@@ -67,24 +85,31 @@ def _truncate(s: str, max_len: int) -> str:
67
85
 
68
86
 
69
87
  def build_from_backend_vulnerability(vuln: BackendVulnerability) -> IssueDraft:
70
- sev = _severity_label(vuln.level)
71
- emoji = _severity_emoji(vuln.level)
72
- fp = fingerprint(vuln.file, vuln.rule_id)
88
+ rule_id = vuln.rule_id or "unknown-rule"
89
+ level = vuln.severity or vuln.level or "note"
90
+ summary = vuln.title or vuln.message or "Security finding"
91
+ details = vuln.description or vuln.message or summary
92
+ file = vuln.file_path or vuln.file or "unknown file"
93
+ line = vuln.line_start if vuln.line_start is not None else vuln.line
94
+
95
+ sev = _severity_label(level)
96
+ emoji = _severity_emoji(level)
97
+ fp = fingerprint(file, rule_id)
73
98
 
74
- title = f"{emoji} [{sev.upper()}] {vuln.rule_id}: {_truncate(vuln.message, 80)}"
99
+ title = f"{emoji} [{sev.upper()}] {rule_id}: {_truncate(summary, 80)}"
75
100
 
76
101
  body = f"## Security Finding\n\n"
77
- body += f"**Rule:** `{vuln.rule_id}`\n"
102
+ body += f"**Rule:** `{rule_id}`\n"
78
103
  body += f"**Severity:** {sev}\n"
79
- body += f"**File:** `{vuln.file}`"
80
- if vuln.line:
81
- body += f" (line {vuln.line})"
104
+ body += f"**File:** `{file}`"
105
+ if line:
106
+ body += f" (line {line})"
82
107
  body += "\n\n"
83
- body += f"### Description\n\n{vuln.message}\n\n"
84
- body += f"### Remediation\n\nReview and fix the finding in `{vuln.file}`.\n"
108
+ body += f"### Description\n\n{details}\n\n"
109
+ body += f"### Remediation\n\nReview and fix the finding in `{file}`.\n"
85
110
  body += "\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n"
86
111
 
87
- labels = ["security", f"severity:{sev}", f"rule:{vuln.rule_id}"]
112
+ labels = ["security", f"severity:{sev}", f"rule:{rule_id}"]
88
113
 
89
114
  return IssueDraft(
90
115
  title=title,
@@ -268,10 +268,19 @@ def _drafts_from_backend(scan_id: str, api_key: str | None) -> list[IssueDraft]:
268
268
  return [
269
269
  build_from_backend_vulnerability(
270
270
  BackendVulnerability(
271
- rule_id=v["ruleId"],
272
- level=v.get("level", "warning"),
273
- message=v.get("message", ""),
274
- file=v.get("file", ""),
271
+ rule_id=v.get("rule_id") or v.get("ruleId") or "",
272
+ severity=v.get("severity") or "",
273
+ title=v.get("title") or "",
274
+ description=v.get("description") or "",
275
+ file_path=v.get("file_path"),
276
+ line_start=v.get("line_start"),
277
+ line_end=v.get("line_end"),
278
+ cwe=v.get("cwe"),
279
+ confidence=v.get("confidence"),
280
+ # Pre-rename names — fallback for old cached payloads.
281
+ level=v.get("level") or "",
282
+ message=v.get("message") or "",
283
+ file=v.get("file") or "",
275
284
  line=v.get("line"),
276
285
  )
277
286
  )
@@ -57,19 +57,81 @@ def _detect_platform(url: str) -> str:
57
57
  return "generic"
58
58
 
59
59
 
60
+ def _severity_tier(raw: str | None) -> str:
61
+ """Map a raw severity/level to a display tier.
62
+
63
+ Uses the same rules as the GitHub Action (github-action/action.yml) and
64
+ ``rafter get``, so notify agrees with both. A value outside the known set is
65
+ kept verbatim so a finding is never silently dropped.
66
+ """
67
+ s = (raw or "").lower()
68
+ if s == "critical":
69
+ return "critical"
70
+ if s in ("error", "high"):
71
+ return "high"
72
+ if s in ("warning", "medium"):
73
+ return "medium"
74
+ if s in ("note", "low"):
75
+ return "low"
76
+ return s or "unknown"
77
+
78
+
79
+ def _normalize_scan(scan_data: dict) -> dict:
80
+ """Read findings from the real scan shape (``vulnerabilities[]``).
81
+
82
+ Falls back to the legacy ``findings[]`` for old cached payloads. Counts are
83
+ derived from each finding's own severity — never from ``summary`` — so a
84
+ finding cannot be lost to a summary field this code does not read. "Clean"
85
+ is therefore reported only when there are genuinely zero findings.
86
+ """
87
+ raw = scan_data.get("vulnerabilities")
88
+ if not isinstance(raw, list):
89
+ raw = scan_data.get("findings")
90
+ if not isinstance(raw, list):
91
+ raw = []
92
+
93
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
94
+ findings = []
95
+ for v in raw:
96
+ tier = _severity_tier(v.get("severity") or v.get("level"))
97
+ if tier in counts:
98
+ counts[tier] += 1
99
+ title = (
100
+ v.get("title")
101
+ or v.get("rule_id")
102
+ or v.get("ruleId")
103
+ or v.get("message")
104
+ or "Unknown"
105
+ )
106
+ file = v.get("file_path") or v.get("file")
107
+ line = v.get("line_start") if v.get("line_start") is not None else v.get("line")
108
+ location = v.get("location") or file or ""
109
+ if not v.get("location") and file and line is not None:
110
+ location = f"{file}:{line}"
111
+ findings.append({"severity": tier, "title": title, "location": location})
112
+
113
+ return {
114
+ "findings": findings,
115
+ "critical": counts["critical"],
116
+ "high": counts["high"],
117
+ "medium": counts["medium"],
118
+ "low": counts["low"],
119
+ "total": len(findings),
120
+ }
121
+
122
+
60
123
  def _format_slack_payload(scan_data: dict) -> dict:
61
124
  """Format scan results as a Slack message with Block Kit."""
62
125
  status = scan_data.get("status", "unknown")
63
126
  repo = scan_data.get("repository_name", "unknown")
64
127
  scan_id = scan_data.get("scan_id", "")
65
- findings = scan_data.get("findings", [])
66
- summary = scan_data.get("summary", {})
67
-
68
- critical = summary.get("critical", 0)
69
- high = summary.get("high", 0)
70
- medium = summary.get("medium", 0)
71
- low = summary.get("low", 0)
72
- total = critical + high + medium + low
128
+ norm = _normalize_scan(scan_data)
129
+ findings = norm["findings"]
130
+ critical = norm["critical"]
131
+ high = norm["high"]
132
+ medium = norm["medium"]
133
+ low = norm["low"]
134
+ total = norm["total"]
73
135
 
74
136
  # Status emoji
75
137
  if status == "completed" and total == 0:
@@ -140,12 +202,9 @@ def _format_slack_payload(scan_data: dict) -> dict:
140
202
  if findings:
141
203
  finding_lines = []
142
204
  for f in findings[:5]:
143
- sev = f.get("severity", "unknown").upper()
144
- title = f.get("title", f.get("rule_id", "Unknown"))
145
- location = f.get("location", f.get("file", ""))
146
- line = f"• `[{sev}]` {title}"
147
- if location:
148
- line += f" — {location}"
205
+ line = f"• `[{f['severity'].upper()}]` {f['title']}"
206
+ if f["location"]:
207
+ line += f" — {f['location']}"
149
208
  finding_lines.append(line)
150
209
 
151
210
  if len(findings) > 5:
@@ -179,14 +238,13 @@ def _format_discord_payload(scan_data: dict) -> dict:
179
238
  status = scan_data.get("status", "unknown")
180
239
  repo = scan_data.get("repository_name", "unknown")
181
240
  scan_id = scan_data.get("scan_id", "")
182
- findings = scan_data.get("findings", [])
183
- summary = scan_data.get("summary", {})
184
-
185
- critical = summary.get("critical", 0)
186
- high = summary.get("high", 0)
187
- medium = summary.get("medium", 0)
188
- low = summary.get("low", 0)
189
- total = critical + high + medium + low
241
+ norm = _normalize_scan(scan_data)
242
+ findings = norm["findings"]
243
+ critical = norm["critical"]
244
+ high = norm["high"]
245
+ medium = norm["medium"]
246
+ low = norm["low"]
247
+ total = norm["total"]
190
248
 
191
249
  # Embed color
192
250
  if status == "completed" and total == 0:
@@ -237,12 +295,9 @@ def _format_discord_payload(scan_data: dict) -> dict:
237
295
  if findings:
238
296
  finding_lines = []
239
297
  for f in findings[:5]:
240
- sev = f.get("severity", "unknown").upper()
241
- title = f.get("title", f.get("rule_id", "Unknown"))
242
- location = f.get("location", f.get("file", ""))
243
- line = f"• `[{sev}]` {title}"
244
- if location:
245
- line += f" — {location}"
298
+ line = f"• `[{f['severity'].upper()}]` {f['title']}"
299
+ if f["location"]:
300
+ line += f" — {f['location']}"
246
301
  finding_lines.append(line)
247
302
 
248
303
  if len(findings) > 5:
@@ -271,8 +326,7 @@ def _format_generic_payload(scan_data: dict) -> dict:
271
326
  """Format scan results as a plain JSON payload with text field."""
272
327
  status = scan_data.get("status", "unknown")
273
328
  repo = scan_data.get("repository_name", "unknown")
274
- summary = scan_data.get("summary", {})
275
- total = sum(summary.get(k, 0) for k in ("critical", "high", "medium", "low"))
329
+ total = _normalize_scan(scan_data)["total"]
276
330
 
277
331
  if status == "completed" and total == 0:
278
332
  status_text = "Clean — no issues found"
@@ -23,7 +23,7 @@ def _generate_claude_config() -> str:
23
23
  "hooks": [{"type": "command", "command": "rafter hook pretool"}],
24
24
  },
25
25
  {
26
- "matcher": "Write|Edit",
26
+ "matcher": "Write|Edit|MultiEdit",
27
27
  "hooks": [{"type": "command", "command": "rafter hook pretool"}],
28
28
  },
29
29
  ],