rafter-cli 0.11.3__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.3 → rafter_cli-0.11.4}/PKG-INFO +2 -2
  2. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/README.md +1 -1
  3. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/pyproject.toml +1 -1
  4. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/agent.py +10 -6
  5. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/agent_components.py +3 -2
  6. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/hook.py +24 -3
  7. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issue_builder.py +40 -15
  8. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/issues_app.py +13 -4
  9. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/notify.py +84 -30
  10. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/policy.py +1 -1
  11. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/risk_rules.py +226 -2
  12. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/rafter-security-skill.md +1 -1
  13. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/api.py +6 -1
  14. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/__init__.py +0 -0
  15. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/__main__.py +0 -0
  16. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/__init__.py +0 -0
  17. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/backend.py +0 -0
  18. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/brief.py +0 -0
  19. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/ci.py +0 -0
  20. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/docs.py +0 -0
  21. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/__init__.py +0 -0
  22. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/dedup.py +0 -0
  23. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/issues/github_client.py +0 -0
  24. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/mcp_server.py +0 -0
  25. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/report.py +0 -0
  26. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/scan.py +0 -0
  27. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/sites.py +0 -0
  28. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/skill.py +0 -0
  29. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/commands/skill_remote.py +0 -0
  30. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/__init__.py +0 -0
  31. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/audit_logger.py +0 -0
  32. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/command_interceptor.py +0 -0
  33. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/config_manager.py +0 -0
  34. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/config_schema.py +0 -0
  35. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/custom_patterns.py +0 -0
  36. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/docs_loader.py +0 -0
  37. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/hook_control.py +0 -0
  38. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/pattern_engine.py +0 -0
  39. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/policy_loader.py +0 -0
  40. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/core/suppression_writer.py +0 -0
  41. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/__init__.py +0 -0
  42. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/agents/__init__.py +0 -0
  43. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/agents/rafter.md +0 -0
  44. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  45. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  46. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  47. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  48. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  49. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  50. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  51. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  52. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  53. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/pre-push-hook.sh +0 -0
  54. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/__init__.py +0 -0
  55. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
  56. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  57. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  58. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  59. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  60. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  61. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  62. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  63. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  64. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  65. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  66. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  67. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  68. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  69. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  70. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  71. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  72. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  73. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  74. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  75. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  76. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  77. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  78. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  79. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  80. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  81. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  82. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  83. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  84. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  85. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  86. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  87. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  88. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  89. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/__init__.py +0 -0
  90. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/betterleaks.py +0 -0
  91. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/git_diff_scan.py +0 -0
  92. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/git_history_scan.py +0 -0
  93. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/regex_scanner.py +0 -0
  94. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/secret_patterns.py +0 -0
  95. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/skill_scanner.py +0 -0
  96. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/scanners/union.py +0 -0
  97. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/__init__.py +0 -0
  98. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/binary_manager.py +0 -0
  99. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/formatter.py +0 -0
  100. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/git.py +0 -0
  101. {rafter_cli-0.11.3 → rafter_cli-0.11.4}/rafter_cli/utils/git_diff.py +0 -0
  102. {rafter_cli-0.11.3 → 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.3
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.3"
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.
@@ -803,7 +803,7 @@ def _install_gemini_hooks(root: Path, quiet: bool = False) -> None:
803
803
  hooks = settings.setdefault("hooks", {})
804
804
  rafter_bin = _resolve_rafter_path()
805
805
  for event, sub, matcher in (
806
- ("BeforeTool", "pretool", "run_shell_command|write_file|replace|edit"),
806
+ ("BeforeTool", "pretool", "run_shell_command|write_file|replace"),
807
807
  ("AfterTool", "posttool", ".*"),
808
808
  ):
809
809
  entries = hooks.get(event) or []
@@ -842,7 +842,11 @@ def _install_cursor_hooks(root: Path, quiet: bool = False) -> None:
842
842
  config.setdefault("version", 1)
843
843
  config.setdefault("hooks", {})
844
844
 
845
- 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}"
846
850
  entries = config["hooks"].get(event)
847
851
  if not isinstance(entries, list):
848
852
  entries = []
@@ -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
  ],
@@ -978,10 +978,231 @@ def modifies_rafter_security_config(command: str) -> bool:
978
978
  """
979
979
  if _disarms_rafter(command):
980
980
  return True
981
+ if _writes_rafter_dir(command):
982
+ return True
981
983
  sanitized = sanitize_command_for_matching(command)
982
984
  return sanitized != command and _disarms_rafter(sanitized)
983
985
 
984
986
 
987
+ def _writes_rafter_dir(command: str) -> bool:
988
+ """sable-36oe: the SHELL route to rafter's own directory (``~/.rafter``).
989
+
990
+ The Write/Edit route already refuses it (rf-vnxs, ``_writes_rafter_security_config``
991
+ in commands/hook.py); ``rm ~/.rafter/config.json`` disarms the gate exactly
992
+ as ``rafter agent config set agent.hooks.enabled false`` does.
993
+
994
+ Deny-by-default scoped to the path, NOT a blocklist of write verbs, which
995
+ are unbounded. A command is protected when a statement that NAMES the
996
+ rafter dir is not a known read-only command without an output redirect.
997
+ Statements that do not name it are not constrained; a cd INTO the dir is
998
+ itself not read-only, which keeps ``cd ~/.rafter && echo x > config.json``
999
+ blocked. Checked on the raw text (a quoted reference
1000
+ counts) and on the normalized view (brace lists, ANSI-C quotes). Mirrors
1001
+ node/src/core/risk-rules.ts ``writesRafterDir`` decision for decision.
1002
+ """
1003
+ if _rafter_dir_scan(command):
1004
+ return True
1005
+ normalized = normalize_for_matching(command)
1006
+ return normalized != command and _rafter_dir_scan(normalized)
1007
+
1008
+
1009
+ _RAFTER_DIR_NAME = ".rafter"
1010
+ _NAME_CHAR = re.compile(r"[a-z0-9_.-]", re.IGNORECASE)
1011
+ _GLOB_CHAR = re.compile(r"[*?\[]")
1012
+
1013
+
1014
+ def _is_name_char(c: str) -> bool:
1015
+ """Characters that continue a file name: ``.rafter.yml`` is not ``.rafter``."""
1016
+ return bool(_NAME_CHAR.fullmatch(c))
1017
+
1018
+
1019
+ def _names_rafter_dir(text: str, globs: bool) -> bool:
1020
+ """True if ``text`` names the rafter dir as a whole path component.
1021
+
1022
+ Case-insensitive (macOS file systems are). With ``globs``, an unquoted
1023
+ component that could glob to it (``.raf*``, ``.r?fter``, ``.*``) counts:
1024
+ bash never lets a wildcard match a leading dot, so only a component that
1025
+ starts with a literal "." qualifies, and the match is over-approximated as
1026
+ "the literal characters outside brackets appear in order in 'rafter'".
1027
+ """
1028
+ t = text.lower()
1029
+ n = len(_RAFTER_DIR_NAME)
1030
+ i = t.find(_RAFTER_DIR_NAME)
1031
+ while i != -1:
1032
+ before = t[i - 1] if i > 0 else ""
1033
+ after = t[i + n] if i + n < len(t) else ""
1034
+ if (before == "" or not _is_name_char(before)) and (after == "" or not _is_name_char(after)):
1035
+ return True
1036
+ i = t.find(_RAFTER_DIR_NAME, i + 1)
1037
+ if not globs:
1038
+ return False
1039
+ for comp in t.split("/"):
1040
+ if not comp or comp[0] != "." or not _GLOB_CHAR.search(comp):
1041
+ continue
1042
+ want = "rafter"
1043
+ k = 0
1044
+ in_bracket = False
1045
+ ok = True
1046
+ for c in comp[1:]:
1047
+ if in_bracket:
1048
+ if c == "]":
1049
+ in_bracket = False
1050
+ continue
1051
+ if c == "[":
1052
+ in_bracket = True
1053
+ continue
1054
+ if c in ("*", "?"):
1055
+ continue
1056
+ while k < len(want) and want[k] != c:
1057
+ k += 1
1058
+ if k == len(want):
1059
+ ok = False
1060
+ break
1061
+ k += 1
1062
+ if ok:
1063
+ return True
1064
+ return False
1065
+
1066
+
1067
+ #: Read-only commands: with no output redirect they can neither write a file
1068
+ #: nor run another program. NOT here: sed (its ``w``/``e`` commands write and
1069
+ #: execute without -i), less (``-o`` writes a log), tee/cp/mv/rm/ln/chmod/
1070
+ #: touch/truncate/dd, and anything that runs a program.
1071
+ _READ_ONLY_EXECS = {
1072
+ "cat", "more", "head", "tail", "ls", "stat", "file", "wc",
1073
+ "grep", "egrep", "fgrep", "rg", "jq", "diff", "cmp",
1074
+ "md5sum", "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
1075
+ "shasum", "b2sum", "cksum",
1076
+ "find", "test", "[", "realpath", "readlink", "du",
1077
+ "cd", "pushd", "popd",
1078
+ }
1079
+ #: find actions that delete, run a program, or write a file.
1080
+ _FIND_WRITE_ACTIONS = {
1081
+ "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls",
1082
+ }
1083
+ #: Only a bare name or a system bin dir: ``./cat`` is whatever the agent wrote there.
1084
+ _SYSTEM_BIN = re.compile(r"^(?:/usr(?:/local)?|/opt/homebrew)?/s?bin/[^/]+$")
1085
+ _WRITE_REDIRECTS = {">", ">>"}
1086
+ _FILE_COMPILE_FLAG = re.compile(r"^-[A-Za-z]*C")
1087
+ _FD_DUP_TARGET = re.compile(r"^(?:\d+|-)$")
1088
+
1089
+
1090
+ class _RafterDirScan:
1091
+ __slots__ = ("protected",)
1092
+
1093
+ def __init__(self) -> None:
1094
+ self.protected = False
1095
+
1096
+
1097
+ def _rafter_dir_scan(command: str) -> bool:
1098
+ """True if some statement names the rafter dir and is not read-only."""
1099
+ acc = _RafterDirScan()
1100
+ _scan_rafter_dir_statements(command, 0, acc)
1101
+ return acc.protected
1102
+
1103
+
1104
+ def _scan_rafter_dir_statements(command: str, depth: int, acc: _RafterDirScan) -> None:
1105
+ # Past the recursion cap, or on a desynchronized parse, the structure is
1106
+ # unknown: FAIL CLOSED, a reference anywhere in the text protects it.
1107
+ if depth > _MAX_SANITIZE_DEPTH:
1108
+ if _names_rafter_dir(command, False):
1109
+ acc.protected = True
1110
+ return
1111
+ pieces, unterminated = _tokenize(command)
1112
+ if unterminated and _names_rafter_dir(command, False):
1113
+ acc.protected = True
1114
+
1115
+ stmt: list[_Piece] = []
1116
+ for i, p in enumerate(pieces):
1117
+ for sub in p.substs:
1118
+ _scan_rafter_dir_statements(sub, depth + 1, acc)
1119
+ if p.op is not None and p.op in _CHAIN_OPS:
1120
+ # ``>|``, ``>&``, ``&>``, ``&>>`` are redirects spelled with a chain
1121
+ # character; splitting on them would orphan the target from its ``>``.
1122
+ prev = pieces[i - 1] if i > 0 else None
1123
+ nxt = pieces[i + 1] if i + 1 < len(pieces) else None
1124
+ after_redirect = (
1125
+ p.op not in ("&&", "||") and prev is not None
1126
+ and prev.end == p.start and prev.op in _WRITE_REDIRECTS
1127
+ )
1128
+ before_redirect = (
1129
+ p.op == "&" and nxt is not None
1130
+ and nxt.start == p.end and nxt.op in _WRITE_REDIRECTS
1131
+ )
1132
+ if not after_redirect and not before_redirect:
1133
+ _scan_rafter_dir_statement(stmt, acc)
1134
+ stmt = []
1135
+ continue
1136
+ stmt.append(p)
1137
+ _scan_rafter_dir_statement(stmt, acc)
1138
+
1139
+
1140
+ def _scan_rafter_dir_statement(stmt: list[_Piece], acc: _RafterDirScan) -> None:
1141
+ argv0 = -1
1142
+ redirect = False
1143
+ names = False
1144
+ for i, p in enumerate(stmt):
1145
+ if p.op is not None:
1146
+ if p.op in _WRITE_REDIRECTS and not _discards_output(stmt, i):
1147
+ redirect = True
1148
+ continue
1149
+ prev_op = stmt[i - 1].op if i > 0 else None
1150
+ is_redirect_target = prev_op is not None and prev_op in _REDIRECT_OPS
1151
+ # Executing a binary that lives in the dir (``~/.rafter/bin/betterleaks
1152
+ # version``) is not a reference; its arguments still are. Only an
1153
+ # UNQUOTED argv0 is a path the shell runs: a quoted one is a heredoc
1154
+ # body line or other code, never a binary.
1155
+ if argv0 == -1 and not is_redirect_target:
1156
+ argv0 = i
1157
+ if not p.quoted and not _ENV_ASSIGNMENT.match(p.text):
1158
+ continue
1159
+ if _names_rafter_dir(p.text, not p.quoted):
1160
+ names = True
1161
+ if names and (redirect or not _statement_read_only(stmt, argv0)):
1162
+ acc.protected = True
1163
+
1164
+
1165
+ def _discards_output(stmt: list[_Piece], i: int) -> bool:
1166
+ """``2>/dev/null``, ``2>&1``, ``>&-``: an output redirect that writes no file."""
1167
+ j = i + 1
1168
+ dup = False
1169
+ if j < len(stmt) and stmt[j].op == "&" and stmt[j].start == stmt[i].end:
1170
+ dup = True
1171
+ j += 1
1172
+ if j >= len(stmt):
1173
+ return False
1174
+ target = stmt[j]
1175
+ if target.op is not None or target.substs:
1176
+ return False
1177
+ return bool(_FD_DUP_TARGET.match(target.text)) if dup else target.text == "/dev/null"
1178
+
1179
+
1180
+ def _statement_read_only(stmt: list[_Piece], argv0: int) -> bool:
1181
+ if argv0 == -1:
1182
+ return True # empty, or only an input redirect
1183
+ head = stmt[argv0]
1184
+ if not head.quoted and _ENV_ASSIGNMENT.match(head.text):
1185
+ return False
1186
+ if "/" in head.text and not _SYSTEM_BIN.match(head.text):
1187
+ return False
1188
+ exec_ = head.text[head.text.rfind("/") + 1:]
1189
+ if exec_ not in _READ_ONLY_EXECS:
1190
+ return False
1191
+ args = [p for p in stmt[argv0 + 1:] if p.op is None]
1192
+ if exec_ == "find":
1193
+ return not any(a.text in _FIND_WRITE_ACTIONS for a in args)
1194
+ if exec_ == "rg": # --pre runs a program on every file searched
1195
+ return not any(a.text.startswith("--pre") for a in args)
1196
+ if exec_ == "file": # -C compiles a magic file, writing it
1197
+ return not any(a.text == "--compile" or _FILE_COMPILE_FLAG.match(a.text) for a in args)
1198
+ if exec_ in ("cd", "pushd", "popd"):
1199
+ # Claude Code's Bash tool keeps its cwd between calls, so a cd INTO the
1200
+ # dir would make the next call's bare ``config.json`` a write there
1201
+ # with no reference left to catch. cd elsewhere stays read-only.
1202
+ return not any(a.substs or _names_rafter_dir(a.text, not a.quoted) for a in args)
1203
+ return True
1204
+
1205
+
985
1206
  def shell_program_from_stream(command: str) -> bool:
986
1207
  """True if a SHELL receives its program through a channel rather than as a path.
987
1208
 
@@ -1013,10 +1234,13 @@ def shell_program_from_stream(command: str) -> bool:
1013
1234
  cur: list[_Piece] = []
1014
1235
  for piece in pieces:
1015
1236
  if piece.op is not None and piece.op in _CHAIN_OPS:
1016
- segments.append(cur); enders.append(piece.op); cur = []
1237
+ segments.append(cur)
1238
+ enders.append(piece.op)
1239
+ cur = []
1017
1240
  continue
1018
1241
  cur.append(piece)
1019
- segments.append(cur); enders.append(None)
1242
+ segments.append(cur)
1243
+ enders.append(None)
1020
1244
 
1021
1245
  def _resolve(seg: list[_Piece]) -> str:
1022
1246
  """Exec of a segment, skipping wrappers, their flags and env assignments.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rafter-security
3
3
  description: Security toolkit for AI workflows. Use when scanning code or repos for vulnerabilities, auditing third-party skills/MCPs/agent configs before installing, evaluating shell commands before running them, or generating secure design questions for new features. Provides `rafter run` (remote SAST + SCA, needs RAFTER_API_KEY), `rafter secrets` (offline secrets-only), `rafter agent exec --dry-run` (command-risk classification), and `rafter skill review`.
4
- version: 0.11.3
4
+ version: 0.11.4
5
5
  homepage: https://rafter.so
6
6
  metadata:
7
7
  openclaw:
@@ -69,7 +69,12 @@ def handle_403(resp: "requests.Response") -> int:
69
69
  if isinstance(body, dict) and "scan_mode" in body:
70
70
  mode = body["scan_mode"]
71
71
  limit = body.get("limit", "?")
72
- used = body.get("used", limit)
72
+ # The API sends ``remaining``, not ``used``; ``used`` is kept for older servers.
73
+ used = body.get("used")
74
+ if used is None:
75
+ remaining = body.get("remaining")
76
+ numeric = all(isinstance(v, int) and not isinstance(v, bool) for v in (limit, remaining))
77
+ used = limit - remaining if numeric else limit
73
78
  print(
74
79
  f"Error: {mode.capitalize()} scan limit reached ({used}/{limit} used this billing period).\n"
75
80
  f"Upgrade your plan or wait for your quota to reset.",