rafter-cli 0.8.9__tar.gz → 0.9.0__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.9 → rafter_cli-0.9.0}/PKG-INFO +4 -4
  2. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/pyproject.toml +4 -4
  3. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/agent.py +122 -2
  4. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/agent_components.py +68 -1
  5. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/backend.py +17 -2
  6. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/hook.py +17 -3
  7. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/issues_app.py +2 -2
  8. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/scan.py +8 -4
  9. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/skill_remote.py +5 -0
  10. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/command_interceptor.py +16 -1
  11. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/risk_rules.py +13 -0
  12. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/rafter-security-skill.md +1 -1
  13. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/secret_patterns.py +7 -0
  14. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/git.py +60 -5
  15. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/README.md +0 -0
  16. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/__init__.py +0 -0
  17. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/__main__.py +0 -0
  18. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/__init__.py +0 -0
  19. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/brief.py +0 -0
  20. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/ci.py +0 -0
  21. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/docs.py +0 -0
  22. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/__init__.py +0 -0
  23. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/dedup.py +0 -0
  24. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/github_client.py +0 -0
  25. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/issue_builder.py +0 -0
  26. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/mcp_server.py +0 -0
  27. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/notify.py +0 -0
  28. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/policy.py +0 -0
  29. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/report.py +0 -0
  30. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/skill.py +0 -0
  31. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/__init__.py +0 -0
  32. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/audit_logger.py +0 -0
  33. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/config_manager.py +0 -0
  34. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/config_schema.py +0 -0
  35. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/custom_patterns.py +0 -0
  36. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/docs_loader.py +0 -0
  37. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/hook_control.py +0 -0
  38. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/pattern_engine.py +0 -0
  39. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/policy_loader.py +0 -0
  40. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/suppression_writer.py +0 -0
  41. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/__init__.py +0 -0
  42. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/agents/__init__.py +0 -0
  43. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/agents/rafter.md +0 -0
  44. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
  45. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
  46. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
  47. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter.md +0 -0
  48. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
  49. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
  50. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
  51. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
  52. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/pre-commit-hook.sh +0 -0
  53. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/pre-push-hook.sh +0 -0
  54. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/__init__.py +0 -0
  55. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
  56. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
  57. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
  58. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
  59. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
  60. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
  61. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
  62. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
  63. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
  64. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
  65. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
  66. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
  67. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
  68. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
  69. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
  70. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
  71. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
  72. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
  73. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
  74. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
  75. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
  76. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
  77. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
  78. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
  79. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
  80. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
  81. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
  82. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
  83. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
  84. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
  85. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
  86. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
  87. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
  88. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
  89. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/__init__.py +0 -0
  90. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/betterleaks.py +0 -0
  91. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/git_diff_scan.py +0 -0
  92. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/regex_scanner.py +0 -0
  93. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/skill_scanner.py +0 -0
  94. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/union.py +0 -0
  95. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/__init__.py +0 -0
  96. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/api.py +0 -0
  97. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/binary_manager.py +0 -0
  98. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/formatter.py +0 -0
  99. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/git_diff.py +0 -0
  100. {rafter_cli-0.8.9 → rafter_cli-0.9.0}/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.9
3
+ Version: 0.9.0
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
@@ -15,12 +15,12 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Dist: click (>=8.0.0,<9.0.0)
17
17
  Requires-Dist: mcp (>=1.9.0,<2.0.0)
18
- Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
18
+ Requires-Dist: python-dotenv (>=1.2.2,<2.0.0)
19
19
  Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
20
- Requires-Dist: requests (>=2.31.0,<3.0.0)
20
+ Requires-Dist: requests (>=2.33.0,<3.0.0)
21
21
  Requires-Dist: rich (>=13.7.1,<14.0.0)
22
22
  Requires-Dist: typer (>=0.15.0,<0.16.0)
23
- Requires-Dist: urllib3 (>=2.6.0,<3)
23
+ Requires-Dist: urllib3 (>=2.7.0,<3)
24
24
  Requires-Dist: watchdog (>=4.0.0,<5.0.0)
25
25
  Description-Content-Type: text/markdown
26
26
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rafter-cli"
3
- version = "0.8.9"
3
+ version = "0.9.0"
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"
@@ -11,9 +11,9 @@ python = ">=3.10,<4.0"
11
11
  typer = "^0.15.0"
12
12
  click = ">=8.0.0,<9.0.0"
13
13
  rich = "^13.7.1"
14
- python-dotenv = "^1.0.1"
15
- requests = "^2.31.0"
16
- urllib3 = ">=2.6.0,<3"
14
+ python-dotenv = "^1.2.2"
15
+ requests = "^2.33.0"
16
+ urllib3 = ">=2.7.0,<3"
17
17
  pyyaml = "^6.0.1"
18
18
  mcp = "^1.9.0"
19
19
  watchdog = "^4.0.0"
@@ -205,8 +205,11 @@ def _install_claude_code_hooks(root: Path) -> None:
205
205
  {"matcher": "Bash", "hooks": [pre_hook]},
206
206
  {"matcher": "Write|Edit", "hooks": [pre_hook]},
207
207
  ])
208
+ # Narrow to tools that produce scannable output (shell output + file
209
+ # writes). Firing posttool on every tool — including Read and MCP calls —
210
+ # added latency to operations that never produce secrets to redact.
208
211
  settings["hooks"]["PostToolUse"].extend([
209
- {"matcher": ".*", "hooks": [post_hook]},
212
+ {"matcher": "Bash|Write|Edit|MultiEdit", "hooks": [post_hook]},
210
213
  ])
211
214
 
212
215
  settings_path.write_text(json.dumps(settings, indent=2) + "\n")
@@ -264,6 +267,7 @@ def _print_dry_run_plan(
264
267
  want_continue: bool,
265
268
  want_aider: bool,
266
269
  want_hermes: bool,
270
+ want_opencode: bool,
267
271
  want_betterleaks: bool,
268
272
  want_skill_scanner: bool,
269
273
  risk_level: str,
@@ -382,6 +386,11 @@ def _print_dry_run_plan(
382
386
  print("Hermes (--with-hermes):")
383
387
  W(root / ".hermes" / "config.yaml", "mcp_servers.rafter entry merged into existing YAML")
384
388
 
389
+ if want_opencode:
390
+ print()
391
+ print("OpenCode (--with-opencode):")
392
+ W(root / ".config" / "opencode" / "opencode.json", "mcp.rafter local/stdio entry merged into existing JSON")
393
+
385
394
  if want_openclaw:
386
395
  print()
387
396
  print("OpenClaw (--with-openclaw):")
@@ -629,6 +638,15 @@ _RAFTER_MCP_ENTRY = {
629
638
  "args": ["mcp", "serve"],
630
639
  }
631
640
 
641
+ # OpenCode's schema differs: the block is `mcp` (not `mcpServers`), each local
642
+ # server carries type: "local", and command + args are a single `command`
643
+ # array. Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
644
+ _RAFTER_OPENCODE_MCP_ENTRY = {
645
+ "type": "local",
646
+ "command": [_RAFTER_MCP_ENTRY["command"], *_RAFTER_MCP_ENTRY["args"]],
647
+ "enabled": True,
648
+ }
649
+
632
650
 
633
651
  def _install_claude_code_mcp(root: Path) -> bool:
634
652
  """Install MCP server config for Claude Code (<root>/.mcp.json).
@@ -1052,6 +1070,45 @@ def _install_hermes_mcp(root: Path) -> bool:
1052
1070
  return True
1053
1071
 
1054
1072
 
1073
+ def _install_opencode_mcp(root: Path) -> bool:
1074
+ """Install MCP server config for OpenCode (<root>/.config/opencode/opencode.json).
1075
+
1076
+ OpenCode reads a global config at ~/.config/opencode/opencode.json and a
1077
+ project-level opencode.json (project takes precedence). We register the
1078
+ local stdio server ``rafter mcp serve`` under the ``mcp`` block. OpenCode's
1079
+ schema differs from Cursor/Windsurf: the block is ``mcp`` (not
1080
+ ``mcpServers``), each local server carries ``type: "local"``, and the
1081
+ command + arguments are a single ``command`` array. A ``$schema`` pointer is
1082
+ seeded on first write. Any existing keys / other MCP servers are preserved.
1083
+ Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
1084
+ """
1085
+ opencode_dir = root / ".config" / "opencode"
1086
+ config_path = opencode_dir / "opencode.json"
1087
+
1088
+ opencode_dir.mkdir(parents=True, exist_ok=True)
1089
+
1090
+ config: dict[str, Any] = {}
1091
+ if config_path.exists():
1092
+ try:
1093
+ loaded = json.loads(config_path.read_text())
1094
+ if isinstance(loaded, dict):
1095
+ config = loaded
1096
+ except (json.JSONDecodeError, ValueError):
1097
+ rprint(fmt.warning("Existing OpenCode opencode.json was unreadable, creating new one"))
1098
+
1099
+ if "$schema" not in config:
1100
+ config["$schema"] = "https://opencode.ai/config.json"
1101
+ mcp = config.get("mcp")
1102
+ if not isinstance(mcp, dict):
1103
+ mcp = {}
1104
+ config["mcp"] = mcp
1105
+ mcp["rafter"] = {**_RAFTER_OPENCODE_MCP_ENTRY}
1106
+
1107
+ config_path.write_text(json.dumps(config, indent=2) + "\n")
1108
+ rprint(fmt.success(f"Installed Rafter MCP server to {config_path}"))
1109
+ return True
1110
+
1111
+
1055
1112
  @agent_app.command()
1056
1113
  def init(
1057
1114
  risk_level: str = typer.Option("moderate", "--risk-level", help="minimal, moderate, or aggressive"),
@@ -1066,6 +1123,7 @@ def init(
1066
1123
  with_windsurf: bool = typer.Option(False, "--with-windsurf", help="Install Windsurf integration"),
1067
1124
  with_continue: bool = typer.Option(False, "--with-continue", help="Install Continue.dev integration"),
1068
1125
  with_hermes: bool = typer.Option(False, "--with-hermes", help="Install Hermes integration"),
1126
+ with_opencode: bool = typer.Option(False, "--with-opencode", help="Install OpenCode integration"),
1069
1127
  all_integrations: bool = typer.Option(False, "--all", help="Install all detected integrations and download Betterleaks"),
1070
1128
  update: bool = typer.Option(False, "--update", help="Re-download betterleaks and reinstall integrations without resetting config"),
1071
1129
  local: bool = typer.Option(
@@ -1105,6 +1163,7 @@ def init(
1105
1163
  has_continue_dev = scope == "user" and (home / ".continue").exists()
1106
1164
  has_aider = scope == "user" and (home / ".aider.conf.yml").exists()
1107
1165
  has_hermes = scope == "user" and (home / ".hermes").exists()
1166
+ has_opencode = scope == "user" and (home / ".config" / "opencode").exists()
1108
1167
 
1109
1168
  # Resolve opt-in flags. In --local scope, --all is restricted to platforms with
1110
1169
  # a project-local config story (claudeCode, codex, gemini, cursor).
@@ -1129,6 +1188,12 @@ def init(
1129
1188
  # reads ~/.hermes/config.yaml; project-local install story isn't
1130
1189
  # established. Excluded from --all in --local for the same reason (sable-gyw).
1131
1190
  want_hermes = with_hermes or (all_integrations and not local)
1191
+ # OpenCode: MCP-based, user scope only. OpenCode reads a global config at
1192
+ # ~/.config/opencode/opencode.json; a project-local install story via
1193
+ # --local isn't wired here, so (like Hermes) it's excluded from --all in
1194
+ # --local scope. It supports MCP local/stdio servers and AGENTS.md natively
1195
+ # (https://opencode.ai/docs/mcp-servers/, sable-l8e5).
1196
+ want_opencode = with_opencode or (all_integrations and not local)
1132
1197
  want_betterleaks = with_betterleaks or (all_integrations and not local)
1133
1198
  # skill-scanner is heavy and opt-in only — deliberately NOT folded into --all.
1134
1199
  want_skill_scanner = with_skill_scanner
@@ -1153,6 +1218,8 @@ def init(
1153
1218
  detected.append("Aider")
1154
1219
  if has_hermes:
1155
1220
  detected.append("Hermes")
1221
+ if has_opencode:
1222
+ detected.append("OpenCode")
1156
1223
 
1157
1224
  if detected:
1158
1225
  rprint(fmt.info(f"Detected environments: {', '.join(detected)}"))
@@ -1180,6 +1247,8 @@ def init(
1180
1247
  rprint(fmt.warning("Aider requested but not detected (~/.aider.conf.yml not found)"))
1181
1248
  if want_hermes and not has_hermes:
1182
1249
  rprint(fmt.warning("Hermes requested but not detected (~/.hermes not found)"))
1250
+ if want_opencode and not has_opencode:
1251
+ rprint(fmt.warning("OpenCode requested but not detected (~/.config/opencode not found)"))
1183
1252
 
1184
1253
  # --dry-run: print every file path the command would touch, then exit
1185
1254
  # before any filesystem write happens (rf-hrtd). Built from the same
@@ -1197,6 +1266,7 @@ def init(
1197
1266
  want_continue=want_continue and (has_continue_dev or local),
1198
1267
  want_aider=want_aider and (has_aider or local),
1199
1268
  want_hermes=want_hermes and has_hermes,
1269
+ want_opencode=want_opencode and has_opencode,
1200
1270
  want_betterleaks=want_betterleaks,
1201
1271
  want_skill_scanner=want_skill_scanner,
1202
1272
  risk_level=risk_level,
@@ -1427,6 +1497,19 @@ def init(
1427
1497
  except Exception as e:
1428
1498
  rprint(fmt.error(f"Failed to install Hermes integration: {e}"))
1429
1499
 
1500
+ # Install OpenCode integration if opted in (sable-l8e5).
1501
+ # User scope only — OpenCode reads a global config at
1502
+ # ~/.config/opencode/opencode.json. MCP-based: we register the local stdio
1503
+ # server `rafter mcp serve` under the `mcp` block.
1504
+ opencode_ok = False
1505
+ if want_opencode and has_opencode:
1506
+ try:
1507
+ opencode_ok = _install_opencode_mcp(root)
1508
+ if opencode_ok:
1509
+ manager.set("agent.environments.opencode.enabled", True)
1510
+ except Exception as e:
1511
+ rprint(fmt.error(f"Failed to install OpenCode integration: {e}"))
1512
+
1430
1513
  # Install global instruction files for platforms that support them
1431
1514
  _install_global_instructions(
1432
1515
  claude_code=claude_code_ok,
@@ -1442,7 +1525,7 @@ def init(
1442
1525
  rprint(fmt.success("Agent security initialized!"))
1443
1526
  rprint()
1444
1527
 
1445
- any_integration = openclaw_ok or claude_code_ok or codex_ok or gemini_ok or cursor_ok or windsurf_ok or continue_ok or aider_ok or hermes_ok
1528
+ any_integration = openclaw_ok or claude_code_ok or codex_ok or gemini_ok or cursor_ok or windsurf_ok or continue_ok or aider_ok or hermes_ok or opencode_ok
1446
1529
 
1447
1530
  if any_integration:
1448
1531
  rprint("Next steps:")
@@ -1462,6 +1545,10 @@ def init(
1462
1545
  rprint(" - Restart Continue.dev to load MCP server")
1463
1546
  if aider_ok:
1464
1547
  rprint(" - Restart Aider to load RAFTER.md from .aider.conf.yml read:")
1548
+ if hermes_ok:
1549
+ rprint(" - Restart Hermes to load MCP server")
1550
+ if opencode_ok:
1551
+ rprint(" - Restart OpenCode to load MCP server")
1465
1552
  elif scope == "project":
1466
1553
  rprint("No integrations were installed. In --local mode, pass one or more opt-in flags:")
1467
1554
  rprint(" rafter agent init --local --with-claude-code")
@@ -1489,6 +1576,8 @@ def init(
1489
1576
  rprint(" rafter agent init --with-aider # Aider only")
1490
1577
  if has_hermes:
1491
1578
  rprint(" rafter agent init --with-hermes # Hermes only")
1579
+ if has_opencode:
1580
+ rprint(" rafter agent init --with-opencode # OpenCode only")
1492
1581
  else:
1493
1582
  rprint("No agent environments detected. Install an agent tool and re-run with --with-<tool>.")
1494
1583
 
@@ -2816,6 +2905,34 @@ def _check_hermes() -> _CheckResult:
2816
2905
  return _CheckResult(name, True, "MCP server configured")
2817
2906
 
2818
2907
 
2908
+ def _check_opencode() -> _CheckResult:
2909
+ """Check if OpenCode integration is healthy (sable-l8e5).
2910
+
2911
+ OpenCode uses ~/.config/opencode/opencode.json with an ``mcp`` block
2912
+ (local/stdio servers carry type: "local").
2913
+ """
2914
+ name = "OpenCode"
2915
+ home = Path.home()
2916
+ opencode_dir = home / ".config" / "opencode"
2917
+
2918
+ if not opencode_dir.exists():
2919
+ return _CheckResult(name, False, "Not detected — run 'rafter agent init --with-opencode' to enable", optional=True)
2920
+
2921
+ config_path = opencode_dir / "opencode.json"
2922
+ if not config_path.exists():
2923
+ return _CheckResult(name, False, f"Config not found: {config_path} — run 'rafter agent init --with-opencode'", optional=True)
2924
+
2925
+ try:
2926
+ loaded = json.loads(config_path.read_text()) or {}
2927
+ except (OSError, json.JSONDecodeError, ValueError) as e:
2928
+ return _CheckResult(name, False, f"Cannot read config: {e}", optional=True)
2929
+
2930
+ servers = loaded.get("mcp") if isinstance(loaded, dict) else None
2931
+ if not (isinstance(servers, dict) and servers.get("rafter")):
2932
+ return _CheckResult(name, False, "Rafter MCP server not configured — run 'rafter agent init --with-opencode'", optional=True)
2933
+ return _CheckResult(name, True, "MCP server configured")
2934
+
2935
+
2819
2936
  def _probe_claude_code() -> _CheckResult:
2820
2937
  """Runtime probe of the Claude Code hook integration (rf-65zg).
2821
2938
 
@@ -2917,6 +3034,7 @@ def verify(
2917
3034
  _check_continue_dev(),
2918
3035
  _check_aider(),
2919
3036
  _check_hermes(),
3037
+ _check_opencode(),
2920
3038
  ]
2921
3039
 
2922
3040
  if probe:
@@ -3531,6 +3649,7 @@ def status(
3531
3649
  {"name": "Windsurf", "flag": "--with-windsurf", "config_dir": home / ".codeium" / "windsurf", "config_file": home / ".codeium" / "windsurf" / "mcp_config.json", "needle": "rafter"},
3532
3650
  {"name": "Continue.dev", "flag": "--with-continue", "config_dir": home / ".continue", "config_file": home / ".continue" / "config.json", "needle": "rafter"},
3533
3651
  {"name": "Hermes", "flag": "--with-hermes", "config_dir": home / ".hermes", "config_file": home / ".hermes" / "config.yaml", "needle": "rafter"},
3652
+ {"name": "OpenCode", "flag": "--with-opencode", "config_dir": home / ".config" / "opencode", "config_file": home / ".config" / "opencode" / "opencode.json", "needle": "rafter"},
3534
3653
  ]
3535
3654
 
3536
3655
  for agent in mcp_agents:
@@ -3627,6 +3746,7 @@ def _detect_agent_platforms() -> list[str]:
3627
3746
  ("continue", home / ".continue"),
3628
3747
  ("aider", home / ".aider.conf.yml"),
3629
3748
  ("hermes", home / ".hermes"),
3749
+ ("opencode", home / ".config" / "opencode"),
3630
3750
  ]
3631
3751
  return [name for name, path in candidates if path.exists()]
3632
3752
 
@@ -124,6 +124,15 @@ def _filter_hooks(arr: list[Any] | None, predicate: Callable[[Any], bool]) -> li
124
124
 
125
125
  RAFTER_MCP_ENTRY: dict[str, Any] = {"command": "rafter", "args": ["mcp", "serve"]}
126
126
 
127
+ # OpenCode's schema differs: the block is `mcp` (not `mcpServers`), each local
128
+ # server carries type: "local", and command + args are a single `command`
129
+ # array. Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
130
+ RAFTER_OPENCODE_MCP_ENTRY: dict[str, Any] = {
131
+ "type": "local",
132
+ "command": [RAFTER_MCP_ENTRY["command"], *RAFTER_MCP_ENTRY["args"]],
133
+ "enabled": True,
134
+ }
135
+
127
136
 
128
137
  # ── Skill template lookup ─────────────────────────────────────────────
129
138
 
@@ -186,7 +195,9 @@ def _claude_code_hooks() -> ComponentSpec:
186
195
  {"matcher": "Bash", "hooks": [pre]},
187
196
  {"matcher": "Write|Edit", "hooks": [pre]},
188
197
  ])
189
- hooks["PostToolUse"].append({"matcher": ".*", "hooks": [post]})
198
+ # Narrow to tools that produce scannable output (shell output + file
199
+ # writes); avoids firing posttool on every Read/MCP call (latency).
200
+ hooks["PostToolUse"].append({"matcher": "Bash|Write|Edit|MultiEdit", "hooks": [post]})
190
201
  _write_json(settings_path, s)
191
202
 
192
203
  def uninstall() -> None:
@@ -895,6 +906,61 @@ def _hermes_mcp() -> ComponentSpec:
895
906
  )
896
907
 
897
908
 
909
+ def _opencode_mcp() -> ComponentSpec:
910
+ """OpenCode MCP server entry (~/.config/opencode/opencode.json).
911
+
912
+ OpenCode's schema differs from Cursor/Windsurf: the block is ``mcp`` (not
913
+ ``mcpServers``), each local server carries ``type: "local"``, and command +
914
+ args are a single ``command`` array. A ``$schema`` pointer is seeded on
915
+ first write. Verified against https://opencode.ai/docs/mcp-servers/
916
+ (sable-l8e5).
917
+ """
918
+ home = Path.home()
919
+ detect_dir = home / ".config" / "opencode"
920
+ config_path = detect_dir / "opencode.json"
921
+
922
+ def is_installed() -> bool:
923
+ cfg = _read_json(config_path)
924
+ mcp = cfg.get("mcp") if isinstance(cfg, dict) else None
925
+ return isinstance(mcp, dict) and bool(mcp.get("rafter"))
926
+
927
+ def install() -> None:
928
+ detect_dir.mkdir(parents=True, exist_ok=True)
929
+ cfg = _read_json(config_path)
930
+ # Guard against valid-but-non-object top-level JSON (list/str/number).
931
+ if not isinstance(cfg, dict):
932
+ cfg = {}
933
+ if "$schema" not in cfg:
934
+ cfg["$schema"] = "https://opencode.ai/config.json"
935
+ mcp = cfg.get("mcp")
936
+ if not isinstance(mcp, dict):
937
+ mcp = {}
938
+ cfg["mcp"] = mcp
939
+ mcp["rafter"] = dict(RAFTER_OPENCODE_MCP_ENTRY)
940
+ _write_json(config_path, cfg)
941
+
942
+ def uninstall() -> None:
943
+ if not config_path.exists():
944
+ return
945
+ cfg = _read_json(config_path)
946
+ mcp = cfg.get("mcp") if isinstance(cfg, dict) else None
947
+ if isinstance(mcp, dict) and "rafter" in mcp:
948
+ del mcp["rafter"]
949
+ _write_json(config_path, cfg)
950
+
951
+ return ComponentSpec(
952
+ id="opencode.mcp",
953
+ platform="opencode",
954
+ kind="mcp",
955
+ description="OpenCode MCP server entry (~/.config/opencode/opencode.json)",
956
+ detect_dir=detect_dir,
957
+ path=config_path,
958
+ is_installed=is_installed,
959
+ install=install,
960
+ uninstall=uninstall,
961
+ )
962
+
963
+
898
964
  _AIDER_LEGACY_MCP_BLOCK_RE = re.compile(
899
965
  r"\n?#\s*Rafter security MCP server\s*\nmcp-server-command:\s*rafter\s+mcp\s+serve\s*\n?",
900
966
  )
@@ -1054,6 +1120,7 @@ def get_registry() -> list[ComponentSpec]:
1054
1120
  _continue_mcp(),
1055
1121
  _aider_read(),
1056
1122
  _hermes_mcp(),
1123
+ _opencode_mcp(),
1057
1124
  _openclaw_skill(),
1058
1125
  ]
1059
1126
  return _REGISTRY
@@ -91,6 +91,8 @@ def _do_remote_scan(
91
91
  quiet: bool,
92
92
  mode: str = "fast",
93
93
  github_token: "str | None" = None,
94
+ provider: "str | None" = None,
95
+ repo_url: "str | None" = None,
94
96
  ) -> None:
95
97
  """Shared implementation for remote backend scan — used by both `rafter run` and `rafter scan`."""
96
98
  import os as _os
@@ -98,7 +100,7 @@ def _do_remote_scan(
98
100
  key = resolve_key(api_key)
99
101
  gh_token = github_token or _os.environ.get("RAFTER_GITHUB_TOKEN")
100
102
  try:
101
- repo_slug, branch_name = detect_repo(repo, branch)
103
+ repo_slug, branch_name, detected_provider, detected_repo_url = detect_repo(repo, branch)
102
104
  except RuntimeError as e:
103
105
  print(str(e), file=sys.stderr)
104
106
  raise typer.Exit(code=EXIT_GENERAL_ERROR)
@@ -106,12 +108,23 @@ def _do_remote_scan(
106
108
  if not (repo and branch) and not quiet:
107
109
  print(f"Repo auto-detected: {repo_slug} @ {branch_name} (note: scanning remote)", file=sys.stderr)
108
110
 
111
+ # Explicit flags override inferred values.
112
+ resolved_provider = provider or detected_provider
113
+ resolved_repo_url = repo_url or detected_repo_url
114
+
109
115
  headers = {"x-api-key": key, "Content-Type": "application/json"}
110
116
 
111
117
  body: dict = {"repository_name": repo_slug, "branch_name": branch_name, "scan_mode": mode}
112
118
  if gh_token:
113
119
  body["github_token"] = gh_token
114
120
 
121
+ # Additive, backward-compatible: only send provider + repo_url for non-github
122
+ # remotes. For github (or an unknown host that defaulted to github), omit both
123
+ # so the request body is byte-identical to the legacy github-only behavior.
124
+ if resolved_provider and resolved_provider != "github" and resolved_repo_url:
125
+ body["provider"] = resolved_provider
126
+ body["repo_url"] = resolved_repo_url
127
+
115
128
  resp = requests.post(
116
129
  f"{API_BASE}/static/scan",
117
130
  headers=headers,
@@ -150,11 +163,13 @@ def register_backend_commands(app: typer.Typer) -> None:
150
163
  fmt: str = typer.Option("md", "--format", "-f", help="json | md"),
151
164
  mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
152
165
  github_token: str = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
166
+ provider: str = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
167
+ repo_url: str = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
153
168
  skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
154
169
  quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
155
170
  ):
156
171
  """Trigger a security scan."""
157
- _do_remote_scan(repo, branch, api_key, fmt, skip_interactive, quiet, mode, github_token=github_token)
172
+ _do_remote_scan(repo, branch, api_key, fmt, skip_interactive, quiet, mode, github_token=github_token, provider=provider, repo_url=repo_url)
158
173
 
159
174
  @app.command()
160
175
  def get(
@@ -13,7 +13,15 @@ from ..core.audit_logger import AuditLogger
13
13
  from ..core.command_interceptor import CommandInterceptor
14
14
  from ..scanners.regex_scanner import RegexScanner
15
15
 
16
- hook_app = typer.Typer(name="hook", help="Hook handlers for agent platform integration", no_args_is_help=True)
16
+ # allow_extra_args / ignore_unknown_options: tolerate extra flags/args the host
17
+ # harness appends to the hook command (e.g. Claude Code adds `--hook-json <data>`).
18
+ # Hook input comes from stdin, so anything else is unused — discard, don't error.
19
+ hook_app = typer.Typer(
20
+ name="hook",
21
+ help="Hook handlers for agent platform integration",
22
+ no_args_is_help=True,
23
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
24
+ )
17
25
 
18
26
  _RISK_LABELS = {
19
27
  "critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW",
@@ -401,7 +409,10 @@ def _evaluate_write(tool_input: dict) -> dict:
401
409
  return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"}
402
410
 
403
411
 
404
- @hook_app.command("pretool")
412
+ @hook_app.command(
413
+ "pretool",
414
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
415
+ )
405
416
  def pretool(
406
417
  format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"),
407
418
  ):
@@ -449,7 +460,10 @@ def pretool(
449
460
  _write_pretool_decision({"decision": "allow"}, format)
450
461
 
451
462
 
452
- @hook_app.command("posttool")
463
+ @hook_app.command(
464
+ "posttool",
465
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
466
+ )
453
467
  def posttool(
454
468
  format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"),
455
469
  ):
@@ -64,7 +64,7 @@ def from_scan(
64
64
  target_repo = repo
65
65
  if not target_repo:
66
66
  try:
67
- target_repo, _ = detect_repo(repo)
67
+ target_repo, _, _, _ = detect_repo(repo)
68
68
  except RuntimeError as e:
69
69
  print_stderr(fmt.error(str(e)))
70
70
  raise typer.Exit(code=EXIT_GENERAL_ERROR)
@@ -165,7 +165,7 @@ def from_text(
165
165
  target_repo = repo
166
166
  if not target_repo:
167
167
  try:
168
- target_repo, _ = detect_repo(repo)
168
+ target_repo, _, _, _ = detect_repo(repo)
169
169
  except RuntimeError as e:
170
170
  print_stderr(fmt.error(str(e)))
171
171
  raise typer.Exit(code=EXIT_GENERAL_ERROR)
@@ -50,13 +50,15 @@ def scan_default(
50
50
  fmt_: str = typer.Option("md", "--format", "-f", help="json | md"),
51
51
  mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
52
52
  github_token: Optional[str] = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
53
+ provider: Optional[str] = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
54
+ repo_url: Optional[str] = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
53
55
  skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
54
56
  quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
55
57
  ):
56
58
  """Scan for security issues. Defaults to remote scan."""
57
59
  if ctx.invoked_subcommand is None:
58
60
  # No subcommand — run remote scan
59
- _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token)
61
+ _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token, provider, repo_url)
60
62
 
61
63
 
62
64
  # ── rafter scan remote ────────────────────────────────────────────────
@@ -69,17 +71,19 @@ def scan_remote(
69
71
  fmt_: str = typer.Option("md", "--format", "-f", help="json | md"),
70
72
  mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
71
73
  github_token: Optional[str] = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
74
+ provider: Optional[str] = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
75
+ repo_url: Optional[str] = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
72
76
  skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
73
77
  quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
74
78
  ):
75
79
  """Trigger a remote backend security scan (explicit alias for 'rafter run')."""
76
- _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token)
80
+ _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token, provider, repo_url)
77
81
 
78
82
 
79
- def _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode="fast", github_token=None):
83
+ def _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode="fast", github_token=None, provider=None, repo_url=None):
80
84
  """Shared handler: invoke remote scan (same logic as `rafter run`)."""
81
85
  from ..commands.backend import _do_remote_scan
82
- _do_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token=github_token)
86
+ _do_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token=github_token, provider=provider, repo_url=repo_url)
83
87
 
84
88
 
85
89
  # ── rafter scan local ─────────────────────────────────────────────────
@@ -346,6 +346,11 @@ def extract_npm_tarball(tgz_file: Path, dest_dir: Path) -> None:
346
346
  # Reject path traversal.
347
347
  if member.name.startswith("/") or ".." in member.name.split("/"):
348
348
  continue
349
+ # Defense-in-depth: only extract regular files and directories.
350
+ # Skip symlinks/hardlinks/devices, which a malicious archive could
351
+ # use to redirect a later member's write outside dest_dir.
352
+ if not (member.isfile() or member.isdir()):
353
+ continue
349
354
  members.append(member)
350
355
  tf.extractall(dest_dir, members=members) # noqa: S202
351
356
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
 
7
7
  from .audit_logger import AuditLogger
8
8
  from .config_manager import ConfigManager
9
- from .risk_rules import assess_command_risk
9
+ from .risk_rules import assess_command_risk, match_critical_pattern
10
10
 
11
11
 
12
12
  @dataclass
@@ -25,6 +25,21 @@ class CommandInterceptor:
25
25
  self._audit = AuditLogger()
26
26
 
27
27
  def evaluate(self, command: str) -> CommandEvaluation:
28
+ # Unconditional hard-block: catastrophic destructive commands (rm -rf /,
29
+ # fork bombs, disk wipes, mkfs, …) are NEVER allowed, regardless of the
30
+ # configured policy — or its absence. Security must not depend on a
31
+ # policy being present or on the chosen mode (even allow-all / a custom
32
+ # deny-list cannot opt out of these).
33
+ if assess_command_risk(command) == "critical":
34
+ return CommandEvaluation(
35
+ command=command,
36
+ risk_level="critical",
37
+ allowed=False,
38
+ requires_approval=False,
39
+ reason="Matches built-in blocked pattern (critical destructive command)",
40
+ matched_pattern=match_critical_pattern(command) or "builtin:critical-destructive",
41
+ )
42
+
28
43
  cfg = self._config.load_with_policy()
29
44
  policy = cfg.agent.command_policy
30
45
 
@@ -85,3 +85,16 @@ def assess_command_risk(command: str) -> str:
85
85
  if re.search(p, command, re.IGNORECASE):
86
86
  return "medium"
87
87
  return "low"
88
+
89
+
90
+ def match_critical_pattern(command: str) -> str | None:
91
+ """Return the first CRITICAL pattern matching the command, or None.
92
+
93
+ Mirrors assess_command_risk's matching. Intended to be called only once a
94
+ command is already classified "critical" (safe-prefix exclusion is handled
95
+ by assess_command_risk), to surface *which* built-in rule matched.
96
+ """
97
+ for p in CRITICAL_PATTERNS:
98
+ if re.search(p, command, re.IGNORECASE):
99
+ return p
100
+ return None
@@ -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.8.9
4
+ version: 0.9.0
5
5
  homepage: https://rafter.so
6
6
  metadata:
7
7
  openclaw:
@@ -148,6 +148,13 @@ DEFAULT_SECRET_PATTERNS: list[Pattern] = [
148
148
  severity="critical",
149
149
  description="PyPI API token detected",
150
150
  ),
151
+ # DigitalOcean
152
+ Pattern(
153
+ name="DigitalOcean Personal Access Token",
154
+ regex=r"dop_v1_[a-f0-9]{64}",
155
+ severity="critical",
156
+ description="DigitalOcean Personal Access Token detected",
157
+ ),
151
158
  ]
152
159
 
153
160
 
@@ -47,13 +47,65 @@ def parse_remote(url: str) -> str:
47
47
  return "/".join(parts[-2:])
48
48
 
49
49
 
50
+ def provider_for_host(host: str) -> str:
51
+ """Map a git remote host to a provider.
52
+
53
+ 'github' is the backward-compatible default for any host we don't
54
+ recognize — a GitHub user's request is unaffected, and unknown
55
+ self-hosted hosts fall back to the legacy behavior.
56
+ """
57
+ host = host.lower()
58
+ if host == "github.com":
59
+ return "github"
60
+ if host == "gitlab.com" or host.endswith(".gitlab.com"):
61
+ return "gitlab"
62
+ if host == "bitbucket.org":
63
+ return "bitbucket"
64
+ if host == "codeberg.org" or host.endswith(".gitea.io"):
65
+ return "gitea"
66
+ return "github" # backward-compatible default
67
+
68
+
69
+ def _split_remote(url: str) -> tuple[str, str] | None:
70
+ """Split a git remote URL into (host, 'owner/repo').
71
+
72
+ Handles both 'git@host:owner/repo(.git)' (scp-like) and
73
+ 'https://host/owner/repo(.git)'. Returns None when it can't be parsed
74
+ into host + slug.
75
+ """
76
+ rest = re.sub(r"^(https?://|git@)", "", url)
77
+ rest = rest.replace(":", "/")
78
+ if rest.endswith(".git"):
79
+ rest = rest[:-4]
80
+ parts = [p for p in rest.split("/") if p]
81
+ if len(parts) < 3: # need host + owner + repo
82
+ return None
83
+ host = parts[0]
84
+ slug = "/".join(parts[-2:])
85
+ return host, slug
86
+
87
+
88
+ def infer_remote(url: str) -> tuple[str, str | None]:
89
+ """Infer (provider, repo_url) from a git remote URL.
90
+
91
+ repo_url is a canonical 'https://<host>/<owner>/<repo>' clone URL.
92
+ Falls back to ('github', None) when the URL can't be parsed.
93
+ """
94
+ parts = _split_remote(url)
95
+ if parts is None:
96
+ return "github", None
97
+ host, slug = parts
98
+ return provider_for_host(host), f"https://{host}/{slug}"
99
+
100
+
50
101
  def detect_repo(
51
102
  repo: str | None = None,
52
103
  branch: str | None = None,
53
- ) -> tuple[str, str]:
104
+ ) -> tuple[str, str, str | None, str | None]:
54
105
  """Auto-detect repo slug and branch from git or CI env vars.
55
106
 
56
- Returns (repo_slug, branch).
107
+ Returns (repo_slug, branch, provider, repo_url). provider/repo_url are
108
+ inferred from the git remote when the slug is auto-detected, else None.
57
109
  Raises RuntimeError if detection fails.
58
110
  """
59
111
  import os
@@ -66,9 +118,11 @@ def detect_repo(
66
118
  )
67
119
  repo_slug = repo or repo_env
68
120
  branch_name = branch or branch_env
121
+ provider: str | None = None
122
+ repo_url: str | None = None
69
123
 
70
124
  if repo_slug and branch_name:
71
- return repo_slug, branch_name
125
+ return repo_slug, branch_name, provider, repo_url
72
126
 
73
127
  if not is_inside_repo():
74
128
  raise RuntimeError(
@@ -79,14 +133,15 @@ def detect_repo(
79
133
  if not repo_slug:
80
134
  try:
81
135
  remote_url = _run(["git", "remote", "get-url", "origin"])
82
- repo_slug = parse_remote(remote_url)
83
136
  except subprocess.CalledProcessError:
84
137
  raise RuntimeError(
85
138
  "Could not auto-detect Git repository. "
86
139
  "Please pass --repo and --branch explicitly."
87
140
  )
141
+ repo_slug = parse_remote(remote_url)
142
+ provider, repo_url = infer_remote(remote_url)
88
143
 
89
144
  if not branch_name:
90
145
  branch_name = safe_branch()
91
146
 
92
- return repo_slug, branch_name
147
+ return repo_slug, branch_name, provider, repo_url
File without changes