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.
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/PKG-INFO +4 -4
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/pyproject.toml +4 -4
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/agent.py +122 -2
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/agent_components.py +68 -1
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/backend.py +17 -2
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/hook.py +17 -3
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/issues_app.py +2 -2
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/scan.py +8 -4
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/skill_remote.py +5 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/command_interceptor.py +16 -1
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/risk_rules.py +13 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/rafter-security-skill.md +1 -1
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/secret_patterns.py +7 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/git.py +60 -5
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/README.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/__main__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/brief.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/ci.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/docs.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/dedup.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/github_client.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/issues/issue_builder.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/mcp_server.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/notify.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/policy.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/report.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/commands/skill.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/audit_logger.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/config_manager.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/config_schema.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/custom_patterns.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/docs_loader.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/hook_control.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/pattern_engine.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/policy_loader.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/core/suppression_writer.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/agents/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/agents/rafter.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter.mdc +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/pre-commit-hook.sh +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/pre-push-hook.sh +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/SKILL.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/backend.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/cli-reference.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/finding-triage.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/guardrails.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/shift-left.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/SKILL.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/api.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/cwe-top25.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/investigation-playbook.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/api-design.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/data-storage.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/dependencies.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/deployment.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/ingestion.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/standards-pointers.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/threat-modeling.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/authorship-provenance.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/changelog-review.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/data-practices.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/malware-indicators.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/prompt-injection.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/docs/telemetry.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-code-review.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter.md +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/betterleaks.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/git_diff_scan.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/regex_scanner.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/skill_scanner.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/scanners/union.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/__init__.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/api.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/binary_manager.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/formatter.py +0 -0
- {rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/utils/git_diff.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
-
requests = "^2.
|
|
16
|
-
urllib3 = ">=2.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-code-review.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-secure-design.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/continue-rules/rafter-skill-review.md
RENAMED
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-code-review.mdc
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-secure-design.mdc
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/cursor-rules/rafter-skill-review.mdc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/cli-reference.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter/docs/finding-triage.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/SKILL.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/api.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/asvs.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/llm.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-code-review/docs/web-app.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-secure-design/docs/auth.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/skills/rafter-skill-review/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-code-review.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-secure-design.md
RENAMED
|
File without changes
|
{rafter_cli-0.8.9 → rafter_cli-0.9.0}/rafter_cli/resources/windsurf-rules/rafter-skill-review.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|