diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f042090..9349ce90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.9.0] - 2026-07-08
+
+### Added
+
+- **Native OpenCode support** (sable-l8e5). `rafter agent init --with-opencode` registers the Rafter MCP server in OpenCode's config (`~/.config/opencode/opencode.json`, `mcp` block with `type: "local"`), and OpenCode is auto-detected on init. First-class platform support now spans 10 agents. Node + Python parity, with a `recipes/opencode.md` guide.
+- **Multi-provider remote scan** (sable-w79q). `rafter run` now infers the git remote's provider — GitLab, Gitea (`codeberg.org` / `*.gitea.io`), and Bitbucket, in addition to GitHub — and sends an additive `provider` + `repo_url` in the scan request. New optional `--provider` / `--repo-url` overrides. **Fully backward-compatible:** GitHub scans send a byte-identical request (no new fields). Non-GitHub scanning additionally depends on backend rollout.
+- **DigitalOcean Personal Access Token secret pattern** (#26, #189). New `dop_v1_[a-f0-9]{64}` detection pattern (severity `critical`) added to the built-in regex scanner in both Node and Python, with tests in each suite. Thanks to @Minh-Nguyen-2k7 for the contribution.
+
## [0.8.10] - 2026-06-28
### Changed
diff --git a/README.md b/README.md
index b47073d0..05ef874a 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
+
Multi-language CLI for [Rafter](https://rafter.so) — the security toolkit built for AI coding agents and the developers who use them.
@@ -25,7 +26,7 @@ Rafter is a **security primitive** that any developer or agent can call and trus
**Two capabilities in one package:**
-1. **Local Security Toolkit** (free, no account) — Fast secret scanning (21+ built-in patterns, deterministic for a given version), policy enforcement with risk-tiered rules, pre-commit hooks, extension auditing, custom rule authoring, and full audit logging. Works offline. **No API key. No telemetry. No data leaves your machine.** Supports Claude Code, Codex CLI, OpenClaw, Gemini CLI, Cursor, Windsurf, Continue.dev, Aider, and Hermes.
+1. **Local Security Toolkit** (free, no account) — Fast secret scanning (21+ built-in patterns, deterministic for a given version), policy enforcement with risk-tiered rules, pre-commit hooks, extension auditing, custom rule authoring, and full audit logging. Works offline. **No API key. No telemetry. No data leaves your machine.** Supports Claude Code, Codex CLI, OpenClaw, Gemini CLI, Cursor, Windsurf, Continue.dev, Aider, Hermes, and OpenCode.
2. **Remote Code Analysis** — Deep security audits that combine agentic analysis with a full SAST/SCA toolchain. Rafter's engine examines your codebase the way a professional penetration tester would — tracing data flows, reasoning about business logic, and surfacing vulnerabilities that static rules alone miss — then cross-references findings with industry-standard SAST, SCA, and secret-detection tools. Structured reports in JSON or Markdown. Pipe to any tool, feed to any workflow.
@@ -183,7 +184,7 @@ rafter agent disable gemini # opt a single platform out
This command:
- Creates `~/.rafter/` config and audit log (or `./.rafter/` with `--local` for ephemeral / containerized / benchmark setups)
-- Auto-detects Claude Code, Codex CLI, OpenClaw, Gemini, Cursor, Windsurf, Continue.dev, Aider, and Hermes
+- Auto-detects Claude Code, Codex CLI, OpenClaw, Gemini, Cursor, Windsurf, Continue.dev, Aider, Hermes, and OpenCode
- With `--with-*` or `--all`: installs Rafter skills/extensions to opted-in agents
- With `--with-betterleaks` or `--all`: downloads [Betterleaks](https://github.com/betterleaks/betterleaks) (the gitleaks successor maintained by the original gitleaks authors) for enhanced secret scanning. Falls back to built-in 21-pattern regex scanner.
@@ -484,6 +485,7 @@ Add to any MCP client config:
| Continue.dev | MCP server | `~/.continue` | `~/.continue/config.json` |
| Aider | MCP server | `~/.aider.conf.yml` | `~/.aider.conf.yml` |
| Hermes | MCP server | `~/.hermes` | `~/.hermes/config.yaml` |
+| OpenCode | MCP server | `~/.config/opencode` | `~/.config/opencode/opencode.json` |
`rafter agent init` auto-detects which platforms are installed. Use `--with-*` flags or `--all` to install integrations.
@@ -496,7 +498,7 @@ Add to any MCP client config:
Install, remove, or audit them at any time with `rafter skill list/install/uninstall/review`.
-**MCP-based platforms** (Gemini, Cursor, Windsurf, Continue.dev, Aider, Hermes) connect to the Rafter MCP server (`rafter mcp serve`), which exposes `scan_secrets`, `evaluate_command`, `read_audit_log`, and `get_config` tools. See individual setup recipes in [`recipes/`](recipes/).
+**MCP-based platforms** (Gemini, Cursor, Windsurf, Continue.dev, Aider, Hermes, OpenCode) connect to the Rafter MCP server (`rafter mcp serve`), which exposes `scan_secrets`, `evaluate_command`, `read_audit_log`, and `get_config` tools. See individual setup recipes in [`recipes/`](recipes/).
---
diff --git a/node/package.json b/node/package.json
index 7df888d1..ad2f41e5 100644
--- a/node/package.json
+++ b/node/package.json
@@ -1,6 +1,6 @@
{
"name": "@rafter-security/cli",
- "version": "0.8.10",
+ "version": "0.9.0",
"type": "module",
"repository": {
"type": "git",
diff --git a/node/resources/rafter-security-skill.md b/node/resources/rafter-security-skill.md
index 79349448..16aaa43a 100644
--- a/node/resources/rafter-security-skill.md
+++ b/node/resources/rafter-security-skill.md
@@ -1,7 +1,7 @@
---
name: rafter-security
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`.
-version: 0.8.10
+version: 0.9.0
homepage: https://rafter.so
metadata:
openclaw:
diff --git a/node/src/commands/agent/components.ts b/node/src/commands/agent/components.ts
index aecf13af..969b8e94 100644
--- a/node/src/commands/agent/components.ts
+++ b/node/src/commands/agent/components.ts
@@ -938,6 +938,53 @@ function hermesMcp(): ComponentSpec {
};
}
+/**
+ * OpenCode MCP server entry (~/.config/opencode/opencode.json).
+ *
+ * OpenCode's schema differs from Cursor/Windsurf: the block is `mcp` (not
+ * `mcpServers`), each local server carries `type: "local"`, and command + args
+ * are a single `command` array. Verified against
+ * https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
+ */
+function openCodeMcp(): ComponentSpec {
+ const home = os.homedir();
+ const configPath = path.join(home, ".config", "opencode", "opencode.json");
+ const entry = {
+ type: "local" as const,
+ command: [RAFTER_MCP_ENTRY.command, ...RAFTER_MCP_ENTRY.args],
+ enabled: true,
+ };
+ return {
+ id: "opencode.mcp",
+ platform: "opencode",
+ kind: "mcp",
+ description: "OpenCode MCP server entry (~/.config/opencode/opencode.json)",
+ detectDir: path.join(home, ".config", "opencode"),
+ path: configPath,
+ isInstalled: () => {
+ if (!fs.existsSync(configPath)) return false;
+ const cfg = readJson(configPath);
+ return !!cfg.mcp?.rafter;
+ },
+ install: () => {
+ const dir = path.join(home, ".config", "opencode");
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ let cfg: Record = fs.existsSync(configPath) ? readJson(configPath) : {};
+ // Guard against valid-but-non-object top-level JSON (array/string/number).
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) cfg = {};
+ if (!cfg.$schema) cfg.$schema = "https://opencode.ai/config.json";
+ if (!cfg.mcp || typeof cfg.mcp !== "object" || Array.isArray(cfg.mcp)) cfg.mcp = {};
+ cfg.mcp.rafter = { ...entry };
+ writeJson(configPath, cfg);
+ },
+ uninstall: () => {
+ if (!fs.existsSync(configPath)) return;
+ const cfg = readJson(configPath);
+ if (removeKey(cfg.mcp, "rafter")) writeJson(configPath, cfg);
+ },
+ };
+}
+
/**
* Aider read-only context: writes RAFTER.md and adds it to .aider.conf.yml `read:`.
*
@@ -1079,6 +1126,7 @@ export function getComponentRegistry(): ComponentSpec[] {
continueMcp(),
aiderRead(),
hermesMcp(),
+ openCodeMcp(),
openclawSkill(),
];
}
diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts
index 24c61c24..0b85eb4b 100644
--- a/node/src/commands/agent/init.ts
+++ b/node/src/commands/agent/init.ts
@@ -50,6 +50,7 @@ function printDryRunPlan(plan: {
wantContinue: boolean;
wantAider: boolean;
wantHermes: boolean;
+ wantOpenCode: boolean;
wantBetterleaks: boolean;
wantSkillScanner: boolean;
riskLevel: string;
@@ -176,6 +177,12 @@ function printDryRunPlan(plan: {
W(path.join(plan.root, ".hermes", "config.yaml"), "mcp_servers.rafter entry merged into existing YAML");
}
+ if (plan.wantOpenCode) {
+ console.log();
+ console.log("OpenCode (--with-opencode):");
+ W(path.join(plan.root, ".config", "opencode", "opencode.json"), "mcp.rafter local/stdio entry merged into existing JSON");
+ }
+
if (plan.wantOpenClaw) {
console.log();
console.log("OpenClaw (--with-openclaw):");
@@ -894,6 +901,63 @@ function installHermesMcp(root: string): boolean {
return true;
}
+/**
+ * OpenCode MCP server entry. OpenCode's schema differs from Cursor/Windsurf:
+ * the block is `mcp` (not `mcpServers`), each local server has `type: "local"`,
+ * and the command + args are a single `command` array (not split). Verified
+ * against https://opencode.ai/docs/mcp-servers/ (rf-opencode).
+ */
+const RAFTER_OPENCODE_MCP_ENTRY = {
+ type: "local" as const,
+ command: [RAFTER_MCP_ENTRY.command, ...RAFTER_MCP_ENTRY.args],
+ enabled: true,
+};
+
+/**
+ * Install MCP server config for OpenCode (~/.config/opencode/opencode.json).
+ *
+ * OpenCode reads a global config at ~/.config/opencode/opencode.json and a
+ * project-level opencode.json (project takes precedence). We register the
+ * local stdio server `rafter mcp serve` under the `mcp` block. The `$schema`
+ * pointer is seeded on first write so editors get completion. Any existing
+ * keys / other MCP servers are preserved.
+ */
+function installOpenCodeMcp(root: string): boolean {
+ const openCodeDir = path.join(root, ".config", "opencode");
+ const configPath = path.join(openCodeDir, "opencode.json");
+
+ if (!fs.existsSync(openCodeDir)) {
+ fs.mkdirSync(openCodeDir, { recursive: true });
+ }
+
+ let config: Record = {};
+ if (fs.existsSync(configPath)) {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
+ // Guard against valid-but-non-object top-level JSON (array/string/number).
+ // Mirrors the Python `isinstance(loaded, dict)` check so a wrong-shaped
+ // file is replaced rather than silently mangled by property assignment.
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ config = parsed;
+ } else {
+ console.log(fmt.warning("Existing OpenCode opencode.json was not a JSON object, creating new one"));
+ }
+ } catch {
+ console.log(fmt.warning("Existing OpenCode opencode.json was unreadable, creating new one"));
+ }
+ }
+
+ if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
+ if (!config.mcp || typeof config.mcp !== "object" || Array.isArray(config.mcp)) {
+ config.mcp = {};
+ }
+ config.mcp.rafter = { ...RAFTER_OPENCODE_MCP_ENTRY };
+
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
+ console.log(fmt.success(`Installed Rafter MCP server to ${configPath}`));
+ return true;
+}
+
function installAiderRead(root: string): boolean {
const rafterMdPath = path.join(root, "RAFTER.md");
const configPath = path.join(root, ".aider.conf.yml");
@@ -1087,6 +1151,7 @@ export function createInitCommand(): Command {
.option("--with-windsurf", "Install Windsurf integration")
.option("--with-continue", "Install Continue.dev integration")
.option("--with-hermes", "Install Hermes integration")
+ .option("--with-opencode", "Install OpenCode integration")
.option("--with-betterleaks", "Download and install Betterleaks binary")
.option("--with-skill-scanner", "Install the optional skill-scanner deep engine (heavy; audit-skill --deep)")
.option("--all", "Install all detected integrations and download Betterleaks")
@@ -1128,6 +1193,7 @@ export function createInitCommand(): Command {
const hasContinueDev = scope === "user" && fs.existsSync(path.join(os.homedir(), ".continue"));
const hasAider = scope === "user" && fs.existsSync(path.join(os.homedir(), ".aider.conf.yml"));
const hasHermes = scope === "user" && fs.existsSync(path.join(os.homedir(), ".hermes"));
+ const hasOpenCode = scope === "user" && fs.existsSync(path.join(os.homedir(), ".config", "opencode"));
// Resolve opt-in flags (--all enables all detected, --interactive prompts).
// In --local scope, --all is restricted to platforms that have a project-local
@@ -1155,6 +1221,12 @@ export function createInitCommand(): Command {
// reads ~/.hermes/config.yaml; the project-local install story isn't
// established. Excluded from --all in --local for the same reason.
let wantHermes = opts.withHermes || (opts.all && !opts.local);
+ // OpenCode: MCP-based, user scope only. OpenCode reads a global config at
+ // ~/.config/opencode/opencode.json; a project-local install story via
+ // --local isn't wired here, so (like Hermes) it's excluded from --all in
+ // --local scope. It supports MCP local/stdio servers and AGENTS.md
+ // natively (https://opencode.ai/docs/mcp-servers/, sable-l8e5).
+ let wantOpenCode = opts.withOpencode || (opts.all && !opts.local);
let wantBetterleaks = opts.withBetterleaks || (opts.all && !opts.local);
// skill-scanner is heavy and opt-in only — deliberately NOT folded into --all.
const wantSkillScanner = !!opts.withSkillScanner;
@@ -1173,6 +1245,7 @@ export function createInitCommand(): Command {
if (hasContinueDev && !wantContinue) wantContinue = await askYesNo("Install Continue.dev MCP server?");
if (hasAider && !wantAider) wantAider = await askYesNo("Install Aider MCP server?");
if (hasHermes && !wantHermes) wantHermes = await askYesNo("Install Hermes MCP server?");
+ if (hasOpenCode && !wantOpenCode) wantOpenCode = await askYesNo("Install OpenCode MCP server?");
if (!wantBetterleaks) wantBetterleaks = await askYesNo("Download Betterleaks binary (enhanced scanning)?");
console.log();
}
@@ -1188,6 +1261,7 @@ export function createInitCommand(): Command {
if (hasContinueDev) detected.push("Continue.dev");
if (hasAider) detected.push("Aider");
if (hasHermes) detected.push("Hermes");
+ if (hasOpenCode) detected.push("OpenCode");
if (detected.length > 0) {
console.log(fmt.info(`Detected environments: ${detected.join(", ")}`));
@@ -1207,6 +1281,7 @@ export function createInitCommand(): Command {
if (wantContinue && !hasContinueDev) console.log(fmt.warning("Continue.dev requested but not detected (~/.continue not found)"));
if (wantAider && !hasAider) console.log(fmt.warning("Aider requested but not detected (~/.aider.conf.yml not found)"));
if (wantHermes && !hasHermes) console.log(fmt.warning("Hermes requested but not detected (~/.hermes not found)"));
+ if (wantOpenCode && !hasOpenCode) console.log(fmt.warning("OpenCode requested but not detected (~/.config/opencode not found)"));
}
// --dry-run: print every file path the command would touch, then
@@ -1226,6 +1301,7 @@ export function createInitCommand(): Command {
wantContinue: wantContinue && (hasContinueDev || opts.local),
wantAider: wantAider && (hasAider || opts.local),
wantHermes: wantHermes && hasHermes,
+ wantOpenCode: wantOpenCode && hasOpenCode,
wantBetterleaks,
wantSkillScanner,
riskLevel: opts.riskLevel,
@@ -1517,6 +1593,20 @@ export function createInitCommand(): Command {
}
}
+ // Install OpenCode integration if opted in (sable-l8e5).
+ // User scope only — OpenCode reads a global config at
+ // ~/.config/opencode/opencode.json. MCP-based: we register the local
+ // stdio server `rafter mcp serve` under the `mcp` block.
+ let openCodeOk = false;
+ if (wantOpenCode && hasOpenCode) {
+ try {
+ openCodeOk = installOpenCodeMcp(root);
+ if (openCodeOk) manager.set("agent.environments.opencode.enabled", true);
+ } catch (e) {
+ console.error(fmt.error(`Failed to install OpenCode integration: ${e}`));
+ }
+ }
+
// Install global instruction files for platforms that support them.
// Cursor is intentionally absent — Cursor uses per-skill rules + the
// rafter sub-agent installed in the Cursor branch above (rf-svn3).
@@ -1531,7 +1621,7 @@ export function createInitCommand(): Command {
console.log(fmt.success("Agent security initialized!"));
console.log();
- const anyIntegration = openclawOk || claudeCodeOk || codexOk || geminiOk || cursorOk || windsurfOk || continueOk || aiderOk;
+ const anyIntegration = openclawOk || claudeCodeOk || codexOk || geminiOk || cursorOk || windsurfOk || continueOk || aiderOk || hermesOk || openCodeOk;
if (anyIntegration) {
console.log("Next steps:");
@@ -1543,6 +1633,8 @@ export function createInitCommand(): Command {
if (windsurfOk) console.log(" - Restart Windsurf to load MCP server");
if (continueOk) console.log(" - Restart Continue.dev to load MCP server");
if (aiderOk) console.log(" - Restart Aider to load RAFTER.md from .aider.conf.yml read:");
+ if (hermesOk) console.log(" - Restart Hermes to load MCP server");
+ if (openCodeOk) console.log(" - Restart OpenCode to load MCP server");
} else if (scope === "project") {
console.log("No integrations were installed. In --local mode, pass one or more opt-in flags:");
console.log(" rafter agent init --local --with-claude-code");
@@ -1561,6 +1653,7 @@ export function createInitCommand(): Command {
if (hasContinueDev) console.log(" rafter agent init --with-continue # Continue.dev only");
if (hasAider) console.log(" rafter agent init --with-aider # Aider only");
if (hasHermes) console.log(" rafter agent init --with-hermes # Hermes only");
+ if (hasOpenCode) console.log(" rafter agent init --with-opencode # OpenCode only");
} else {
console.log("No agent environments detected. Install an agent tool and re-run with --with-.");
}
diff --git a/node/src/commands/agent/status.ts b/node/src/commands/agent/status.ts
index 954b7e72..57bf2638 100644
--- a/node/src/commands/agent/status.ts
+++ b/node/src/commands/agent/status.ts
@@ -161,6 +161,7 @@ export function createStatusCommand(): Command {
{ name: "Windsurf", flag: "--with-windsurf", configDir: path.join(home, ".codeium", "windsurf"), configFile: path.join(home, ".codeium", "windsurf", "mcp_config.json"), needle: "rafter" },
{ name: "Continue.dev", flag: "--with-continue", configDir: path.join(home, ".continue"), configFile: path.join(home, ".continue", "config.json"), needle: "rafter" },
{ name: "Hermes", flag: "--with-hermes", configDir: path.join(home, ".hermes"), configFile: path.join(home, ".hermes", "config.yaml"), needle: "rafter" },
+ { name: "OpenCode", flag: "--with-opencode", configDir: path.join(home, ".config", "opencode"), configFile: path.join(home, ".config", "opencode", "opencode.json"), needle: "rafter" },
];
for (const agent of mcpAgents) {
@@ -267,6 +268,7 @@ function detectAgents(home: string): string[] {
["continue", path.join(home, ".continue")],
["aider", path.join(home, ".aider.conf.yml")],
["hermes", path.join(home, ".hermes")],
+ ["opencode", path.join(home, ".config", "opencode")],
];
return candidates
.filter(([, p]) => fs.existsSync(p))
diff --git a/node/src/commands/agent/verify.ts b/node/src/commands/agent/verify.ts
index 8853c8e8..10fee20f 100644
--- a/node/src/commands/agent/verify.ts
+++ b/node/src/commands/agent/verify.ts
@@ -334,6 +334,37 @@ function checkHermes(): CheckResult {
}
}
+function checkOpenCode(): CheckResult {
+ // OpenCode uses ~/.config/opencode/opencode.json with an `mcp` block
+ // (local/stdio servers carry type: "local"; sable-l8e5).
+ const name = "OpenCode";
+ const homeDir = os.homedir();
+ const openCodeDir = path.join(homeDir, ".config", "opencode");
+
+ if (!fs.existsSync(openCodeDir)) {
+ return { name, passed: false, optional: true, detail: `Not detected — run 'rafter agent init --with-opencode' to enable` };
+ }
+
+ const configPath = path.join(openCodeDir, "opencode.json");
+ if (!fs.existsSync(configPath)) {
+ return { name, passed: false, optional: true, detail: `Config not found: ${configPath} — run 'rafter agent init --with-opencode'` };
+ }
+
+ try {
+ const loaded = JSON.parse(fs.readFileSync(configPath, "utf-8"));
+ const servers = (loaded && typeof loaded === "object" && !Array.isArray(loaded))
+ ? (loaded as Record).mcp
+ : undefined;
+ const hasRafterMcp = servers && typeof servers === "object" && servers.rafter != null;
+ if (!hasRafterMcp) {
+ return { name, passed: false, optional: true, detail: "Rafter MCP server not configured — run 'rafter agent init --with-opencode'" };
+ }
+ return { name, passed: true, detail: "MCP server configured" };
+ } catch (e) {
+ return { name, passed: false, optional: true, detail: `Cannot read config: ${e}` };
+ }
+}
+
/**
* Probe the Claude Code hook integration end-to-end (rf-65zg).
*
@@ -438,6 +469,7 @@ export function createVerifyCommand(): Command {
checkContinueDev(),
checkAider(),
checkHermes(),
+ checkOpenCode(),
];
if (opts.probe) {
diff --git a/node/src/commands/backend/run.ts b/node/src/commands/backend/run.ts
index e94e6c85..62e435fc 100644
--- a/node/src/commands/backend/run.ts
+++ b/node/src/commands/backend/run.ts
@@ -20,6 +20,8 @@ export interface RunOpts {
skipInteractive?: boolean;
quiet?: boolean;
githubToken?: string;
+ provider?: string;
+ repoUrl?: string;
}
/**
@@ -29,8 +31,13 @@ export async function runRemoteScan(opts: RunOpts): Promise {
const key = resolveKey(opts.apiKey);
const ghToken = opts.githubToken || process.env.RAFTER_GITHUB_TOKEN;
let repo: string | undefined, branch: string | undefined;
+ let detectedProvider: string | undefined, detectedRepoUrl: string | undefined;
try {
- ({ repo, branch } = detectRepo({ repo: opts.repo, branch: opts.branch, quiet: opts.quiet }));
+ ({ repo, branch, provider: detectedProvider, repo_url: detectedRepoUrl } = detectRepo({
+ repo: opts.repo,
+ branch: opts.branch,
+ quiet: opts.quiet,
+ }));
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
@@ -40,6 +47,10 @@ export async function runRemoteScan(opts: RunOpts): Promise {
process.exit(EXIT_GENERAL_ERROR);
}
+ // Explicit flags override inferred values.
+ const provider = opts.provider ?? detectedProvider;
+ const repoUrl = opts.repoUrl ?? detectedRepoUrl;
+
const body: Record = {
repository_name: repo!,
branch_name: branch!,
@@ -47,6 +58,14 @@ export async function runRemoteScan(opts: RunOpts): Promise {
};
if (ghToken) body.github_token = ghToken;
+ // Additive, backward-compatible: only send provider + repo_url for non-github
+ // remotes. For github (or an unknown host that defaulted to github), omit both
+ // so the request body is byte-identical to the legacy github-only behavior.
+ if (provider && provider !== "github" && repoUrl) {
+ body.provider = provider;
+ body.repo_url = repoUrl;
+ }
+
if (!opts.quiet) {
const spinner = ora("Submitting scan").start();
try {
@@ -112,6 +131,8 @@ function addRunOptions(cmd: Command): Command {
.option("-f, --format ", "json | md", "md")
.option("-m, --mode ", "scan mode: fast | plus", "fast")
.option("--github-token ", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
+ .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)")
+ .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)")
.option("--skip-interactive", "do not wait for scan to complete")
.option("--quiet", "suppress status messages");
}
diff --git a/node/src/commands/scan/index.ts b/node/src/commands/scan/index.ts
index 21995816..3dcf071e 100644
--- a/node/src/commands/scan/index.ts
+++ b/node/src/commands/scan/index.ts
@@ -25,6 +25,8 @@ export function createScanGroupCommand(): Command {
.option("-f, --format ", "json | md", "md")
.option("-m, --mode ", "scan mode: fast | plus", "fast")
.option("--github-token ", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
+ .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)")
+ .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)")
.option("--skip-interactive", "do not wait for scan to complete")
.option("--quiet", "suppress status messages")
.action(async (opts) => {
@@ -41,6 +43,8 @@ export function createScanGroupCommand(): Command {
.option("-f, --format ", "json | md", "md")
.option("-m, --mode ", "scan mode: fast | plus", "fast")
.option("--github-token ", "GitHub PAT for private repos (or RAFTER_GITHUB_TOKEN env var)")
+ .option("--provider ", "git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)")
+ .option("--repo-url ", "full https clone URL for non-github remotes (default: auto-detected)")
.option("--skip-interactive", "do not wait for scan to complete")
.option("--quiet", "suppress status messages");
diff --git a/node/src/scanners/secret-patterns.ts b/node/src/scanners/secret-patterns.ts
index b576c753..d61219ba 100644
--- a/node/src/scanners/secret-patterns.ts
+++ b/node/src/scanners/secret-patterns.ts
@@ -159,6 +159,14 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [
regex: "pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\\-_]{50,1024}",
severity: "critical",
description: "PyPI API token detected"
+ },
+
+ // DigitalOcean
+ {
+ name: "DigitalOcean Personal Access Token",
+ regex: "dop_v1_[a-f0-9]{64}",
+ severity: "critical",
+ description: "DigitalOcean Personal Access Token detected"
}
];
diff --git a/node/src/utils/git.ts b/node/src/utils/git.ts
index 6fbee438..99800b95 100644
--- a/node/src/utils/git.ts
+++ b/node/src/utils/git.ts
@@ -21,17 +21,82 @@ export function parseRemote(url: string): string {
return parts.slice(-2).join("/"); // owner/repo
}
-export function detectRepo(opts: { repo?: string; branch?: string; quiet?: boolean }) {
- if (opts.repo && opts.branch) return opts;
+export type Provider = "github" | "gitlab" | "gitea" | "bitbucket";
+
+/**
+ * Map a git remote host to a provider. `github` is the backward-compatible
+ * default for any host we don't recognize — a GitHub user's request is
+ * unaffected, and unknown self-hosted hosts fall back to the legacy behavior.
+ */
+export function providerForHost(host: string): Provider {
+ host = host.toLowerCase();
+ if (host === "github.com") return "github";
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
+ if (host === "bitbucket.org") return "bitbucket";
+ if (host === "codeberg.org" || host.endsWith(".gitea.io")) return "gitea";
+ return "github"; // backward-compatible default
+}
+
+/**
+ * Split a git remote URL into its host + owner/repo slug, handling both
+ * `git@host:owner/repo(.git)` (scp-like) and `https://host/owner/repo(.git)`.
+ * Returns null when the URL can't be parsed into host + slug.
+ */
+function splitRemote(url: string): { host: string; slug: string } | null {
+ let rest = url.replace(/^(https?:\/\/|git@)/, "").replace(":", "/");
+ if (rest.endsWith(".git")) rest = rest.slice(0, -4);
+ const parts = rest.split("/").filter((p) => p.length > 0);
+ if (parts.length < 3) return null; // need host + owner + repo
+ const host = parts[0];
+ const slug = parts.slice(-2).join("/");
+ return { host, slug };
+}
+
+/**
+ * Infer the provider and a canonical `https:////` clone URL
+ * from a git remote (either scp-like `git@` or `https://` form). Falls back to
+ * `{ provider: "github", repoUrl: undefined }` when the URL can't be parsed.
+ */
+export function inferRemote(url: string): { provider: Provider; repoUrl?: string } {
+ const parts = splitRemote(url);
+ if (!parts) return { provider: "github" };
+ return {
+ provider: providerForHost(parts.host),
+ repoUrl: `https://${parts.host}/${parts.slug}`,
+ };
+}
+
+export interface DetectedRepo {
+ repo?: string;
+ branch?: string;
+ provider?: Provider;
+ repo_url?: string;
+}
+
+export function detectRepo(opts: { repo?: string; branch?: string; quiet?: boolean }): DetectedRepo {
+ // Both explicit — return them as-is. No provider/repo_url is inferred here;
+ // the caller's --provider/--repo-url flags fill that in when needed.
+ if (opts.repo && opts.branch) return { repo: opts.repo, branch: opts.branch };
const repoEnv = process.env.GITHUB_REPOSITORY || process.env.CI_REPOSITORY;
const branchEnv = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH || process.env.CI_BRANCH;
let repoSlug = opts.repo || repoEnv;
let branch = opts.branch || branchEnv;
+ let provider: Provider | undefined;
+ let repoUrl: string | undefined;
try {
if (!repoSlug || !branch) {
if (git("rev-parse --is-inside-work-tree") !== "true")
throw new Error("not a repo");
- if (!repoSlug) repoSlug = parseRemote(git("remote get-url origin"));
+ // Read the remote once when we need to detect the slug, and reuse it to
+ // infer the provider + canonical clone URL. When repo/branch come from
+ // env (CI), we never touch git here — behavior is byte-identical.
+ if (!repoSlug) {
+ const remoteUrl = git("remote get-url origin");
+ repoSlug = parseRemote(remoteUrl);
+ const inferred = inferRemote(remoteUrl);
+ provider = inferred.provider;
+ repoUrl = inferred.repoUrl;
+ }
if (!branch) {
try {
branch = safeBranch(git);
@@ -43,7 +108,7 @@ export function detectRepo(opts: { repo?: string; branch?: string; quiet?: boole
if ((!opts.repo || !opts.branch) && !opts.quiet) {
console.error(`Repo auto-detected: ${repoSlug} @ ${branch} (note: scanning remote)`);
}
- return { repo: repoSlug, branch };
+ return { repo: repoSlug, branch, provider, repo_url: repoUrl };
} catch {
throw new Error(
"Could not auto-detect Git repository. Please pass --repo and --branch explicitly."
diff --git a/node/tests/agent-components.test.ts b/node/tests/agent-components.test.ts
index 5a354763..090b1bef 100644
--- a/node/tests/agent-components.test.ts
+++ b/node/tests/agent-components.test.ts
@@ -71,6 +71,8 @@ describe("rafter agent list", () => {
"continue.rules",
"continue.mcp",
"aider.read",
+ "hermes.mcp",
+ "opencode.mcp",
"codex.hooks",
"codex.skills",
"openclaw.skills",
diff --git a/node/tests/agent-init-opencode.test.ts b/node/tests/agent-init-opencode.test.ts
new file mode 100644
index 00000000..8ddd1d8f
--- /dev/null
+++ b/node/tests/agent-init-opencode.test.ts
@@ -0,0 +1,258 @@
+/**
+ * Tests for the OpenCode platform integration (sable-l8e5).
+ *
+ * Runs the built CLI as a subprocess against a tempdir HOME so the test
+ * exercises the real --with-opencode code path (option parsing →
+ * detection → installer → JSON write), not just the installer in
+ * isolation. Mirrors the pattern used by agent-init-hermes.test.ts.
+ *
+ * OpenCode's schema differs from Cursor/Windsurf: the block is `mcp`
+ * (not `mcpServers`), each local server carries type: "local", and the
+ * command + args are a single `command` array. Verified against
+ * https://opencode.ai/docs/mcp-servers/.
+ */
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
+import { execSync, spawnSync } from "child_process";
+import fs from "fs";
+import path from "path";
+import os from "os";
+import { randomBytes } from "crypto";
+
+const PROJECT_ROOT = path.resolve(__dirname, "..");
+const CLI_DIST = path.join(PROJECT_ROOT, "dist", "index.js");
+
+beforeAll(() => {
+ if (!fs.existsSync(CLI_DIST)) {
+ execSync("pnpm run build", { cwd: PROJECT_ROOT, stdio: "inherit" });
+ }
+});
+
+function createTempHome(): string {
+ const dir = path.join(
+ os.tmpdir(),
+ `rafter-opencode-test-${Date.now()}-${randomBytes(4).toString("hex")}`,
+ );
+ fs.mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function runCli(args: string[], homeDir: string): { stdout: string; stderr: string; exitCode: number } {
+ const r = spawnSync(process.execPath, [CLI_DIST, ...args], {
+ cwd: PROJECT_ROOT,
+ encoding: "utf-8",
+ timeout: 60_000,
+ env: {
+ ...process.env,
+ HOME: homeDir,
+ XDG_CONFIG_HOME: path.join(homeDir, ".config"),
+ CI: "1",
+ },
+ });
+ return {
+ stdout: r.stdout || "",
+ stderr: r.stderr || "",
+ exitCode: r.status ?? 1,
+ };
+}
+
+function openCodeConfigPath(home: string): string {
+ return path.join(home, ".config", "opencode", "opencode.json");
+}
+
+function readOpenCodeConfig(home: string): Record {
+ const raw = fs.readFileSync(openCodeConfigPath(home), "utf-8");
+ return JSON.parse(raw) as Record;
+}
+
+describe("agent init --with-opencode", () => {
+ let home: string;
+
+ beforeEach(() => {
+ home = createTempHome();
+ fs.mkdirSync(path.join(home, ".config", "opencode"), { recursive: true });
+ });
+
+ afterEach(() => {
+ fs.rmSync(home, { recursive: true, force: true });
+ });
+
+ it("creates opencode.json from scratch with mcp.rafter populated", () => {
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+ const cfg = readOpenCodeConfig(home);
+ expect(cfg.mcp?.rafter?.type).toBe("local");
+ expect(cfg.mcp?.rafter?.command).toEqual(["rafter", "mcp", "serve"]);
+ expect(cfg.mcp?.rafter?.enabled).toBe(true);
+ expect(cfg.$schema).toBe("https://opencode.ai/config.json");
+ });
+
+ it("preserves existing mcp entries and other top-level keys", () => {
+ fs.writeFileSync(
+ openCodeConfigPath(home),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ mcp: { other: { type: "local", command: ["other", "go"], enabled: true } },
+ theme: "system",
+ }, null, 2),
+ "utf-8",
+ );
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+
+ const cfg = readOpenCodeConfig(home);
+ expect(cfg.mcp.other.command).toEqual(["other", "go"]);
+ expect(cfg.mcp.rafter).toBeDefined();
+ expect(cfg.theme).toBe("system");
+ });
+
+ it("is idempotent on a second run", () => {
+ runCli(["agent", "init", "--with-opencode"], home);
+ const first = fs.readFileSync(openCodeConfigPath(home), "utf-8");
+ runCli(["agent", "init", "--with-opencode"], home);
+ const second = fs.readFileSync(openCodeConfigPath(home), "utf-8");
+ expect(second).toBe(first);
+ });
+
+ it("recovers from an unreadable / malformed JSON config", () => {
+ fs.writeFileSync(
+ openCodeConfigPath(home),
+ "{ this is not valid json",
+ "utf-8",
+ );
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+ const cfg = readOpenCodeConfig(home);
+ expect(cfg.mcp?.rafter?.type).toBe("local");
+ expect(cfg.mcp?.rafter?.command).toEqual(["rafter", "mcp", "serve"]);
+ });
+
+ it("coerces an array-shaped mcp block (wrong shape) into an object", () => {
+ fs.writeFileSync(
+ openCodeConfigPath(home),
+ JSON.stringify({ mcp: ["junk"] }, null, 2),
+ "utf-8",
+ );
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+ const cfg = readOpenCodeConfig(home);
+ expect(typeof cfg.mcp).toBe("object");
+ expect(Array.isArray(cfg.mcp)).toBe(false);
+ expect(cfg.mcp.rafter?.type).toBe("local");
+ });
+
+ it("recovers from a valid-but-non-object top-level config (array)", () => {
+ // Valid JSON, but the top level is an array — must be replaced with a
+ // fresh object, not silently mangled by property assignment (rafter review).
+ fs.writeFileSync(
+ openCodeConfigPath(home),
+ JSON.stringify([1, 2, 3], null, 2),
+ "utf-8",
+ );
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+ const cfg = readOpenCodeConfig(home);
+ expect(Array.isArray(cfg)).toBe(false);
+ expect(cfg.mcp?.rafter?.type).toBe("local");
+ expect(cfg.$schema).toBe("https://opencode.ai/config.json");
+ });
+
+ it("recovers from a valid-but-non-object top-level config (string)", () => {
+ fs.writeFileSync(openCodeConfigPath(home), JSON.stringify("hello"), "utf-8");
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0);
+ const cfg = readOpenCodeConfig(home);
+ expect(cfg.mcp?.rafter?.command).toEqual(["rafter", "mcp", "serve"]);
+ });
+
+ it("warns when --with-opencode is requested but ~/.config/opencode does not exist", () => {
+ // Tear down the dir we created in beforeEach
+ fs.rmSync(path.join(home, ".config", "opencode"), { recursive: true, force: true });
+
+ const r = runCli(["agent", "init", "--with-opencode"], home);
+ expect(r.exitCode).toBe(0); // not detected ≠ failure
+ expect(r.stdout + r.stderr).toMatch(/OpenCode requested but not detected/);
+ expect(fs.existsSync(openCodeConfigPath(home))).toBe(false);
+ });
+
+ it("lists OpenCode in the dry-run plan when detected", () => {
+ const r = runCli(["agent", "init", "--with-opencode", "--dry-run"], home);
+ expect(r.exitCode).toBe(0);
+ expect(r.stdout + r.stderr).toMatch(/OpenCode \(--with-opencode\):/);
+ expect(r.stdout + r.stderr).toMatch(/opencode\/opencode\.json/);
+ // No actual file written under --dry-run.
+ expect(fs.existsSync(openCodeConfigPath(home))).toBe(false);
+ });
+});
+
+describe("OpenCode detection in verify / status / list", () => {
+ let home: string;
+
+ beforeEach(() => {
+ home = createTempHome();
+ fs.mkdirSync(path.join(home, ".config", "opencode"), { recursive: true });
+ });
+
+ afterEach(() => {
+ fs.rmSync(home, { recursive: true, force: true });
+ });
+
+ it("agent status reports OpenCode installed after init", () => {
+ runCli(["agent", "init", "--with-opencode"], home);
+ const r = runCli(["agent", "status"], home);
+ expect(r.stdout + r.stderr).toMatch(/OpenCode:\s+MCP installed/);
+ });
+
+ it("agent status reports detected-but-missing when dir exists without rafter", () => {
+ const r = runCli(["agent", "status"], home);
+ expect(r.stdout + r.stderr).toMatch(/OpenCode:\s+detected but MCP missing/);
+ });
+
+ it("agent status --json includes opencode in agents_detected", () => {
+ const r = runCli(["agent", "status", "--json"], home);
+ const parsed = JSON.parse(r.stdout);
+ expect(parsed.agents_detected).toContain("opencode");
+ });
+
+ it("agent verify --json marks OpenCode pass after init", () => {
+ runCli(["agent", "init", "--with-opencode"], home);
+ const r = runCli(["agent", "verify", "--json"], home);
+ const parsed = JSON.parse(r.stdout);
+ const opencode = parsed.checks.find((c: any) => c.name === "OpenCode");
+ expect(opencode).toBeDefined();
+ expect(opencode.status).toBe("pass");
+ });
+
+ it("agent verify --json warns (not fails) when OpenCode config lacks rafter", () => {
+ fs.writeFileSync(
+ openCodeConfigPath(home),
+ JSON.stringify({ mcp: { other: { type: "local", command: ["other"] } } }, null, 2),
+ "utf-8",
+ );
+ const r = runCli(["agent", "verify", "--json"], home);
+ const parsed = JSON.parse(r.stdout);
+ const opencode = parsed.checks.find((c: any) => c.name === "OpenCode");
+ expect(opencode.status).toBe("warn");
+ });
+
+ it("agent list includes the opencode.mcp component", () => {
+ const r = runCli(["agent", "list", "--json"], home);
+ const parsed = JSON.parse(r.stdout);
+ const ids = parsed.components.map((c: any) => c.id);
+ expect(ids).toContain("opencode.mcp");
+ });
+
+ it("opencode.mcp reports installed=true in list after init", () => {
+ runCli(["agent", "init", "--with-opencode"], home);
+ const r = runCli(["agent", "list", "--json"], home);
+ const parsed = JSON.parse(r.stdout);
+ const opencode = parsed.components.find((c: any) => c.id === "opencode.mcp");
+ expect(opencode).toBeDefined();
+ expect(opencode.installed).toBe(true);
+ expect(opencode.detected).toBe(true);
+ });
+});
diff --git a/node/tests/git-utils.test.ts b/node/tests/git-utils.test.ts
index f630d64b..5bf1ef69 100644
--- a/node/tests/git-utils.test.ts
+++ b/node/tests/git-utils.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { parseRemote, safeBranch, detectRepo } from "../src/utils/git.js";
+import { parseRemote, safeBranch, detectRepo, providerForHost, inferRemote } from "../src/utils/git.js";
// ── parseRemote (pure function) ────────────────────────────────────
@@ -29,6 +29,113 @@ describe("parseRemote", () => {
});
});
+// ── providerForHost (host → provider inference) ────────────────────
+
+describe("providerForHost", () => {
+ it("maps github.com → github", () => {
+ expect(providerForHost("github.com")).toBe("github");
+ });
+
+ it("maps gitlab.com → gitlab", () => {
+ expect(providerForHost("gitlab.com")).toBe("gitlab");
+ });
+
+ it("maps a self-hosted *.gitlab.com subdomain → gitlab", () => {
+ expect(providerForHost("git.gitlab.com")).toBe("gitlab");
+ });
+
+ it("maps bitbucket.org → bitbucket", () => {
+ expect(providerForHost("bitbucket.org")).toBe("bitbucket");
+ });
+
+ it("maps codeberg.org → gitea", () => {
+ expect(providerForHost("codeberg.org")).toBe("gitea");
+ });
+
+ it("maps a *.gitea.io host → gitea", () => {
+ expect(providerForHost("try.gitea.io")).toBe("gitea");
+ });
+
+ it("defaults an unknown host → github (backward-compatible)", () => {
+ expect(providerForHost("git.example.com")).toBe("github");
+ });
+
+ it("is case-insensitive", () => {
+ expect(providerForHost("GitLab.com")).toBe("gitlab");
+ });
+});
+
+// ── inferRemote (provider + canonical repo_url) ────────────────────
+
+describe("inferRemote", () => {
+ it("infers github from an https remote and omits nothing special", () => {
+ expect(inferRemote("https://github.com/owner/repo.git")).toEqual({
+ provider: "github",
+ repoUrl: "https://github.com/owner/repo",
+ });
+ });
+
+ it("infers github from an ssh remote", () => {
+ expect(inferRemote("git@github.com:owner/repo.git")).toEqual({
+ provider: "github",
+ repoUrl: "https://github.com/owner/repo",
+ });
+ });
+
+ it("normalizes a gitlab ssh remote to a canonical https url", () => {
+ expect(inferRemote("git@gitlab.com:group/project.git")).toEqual({
+ provider: "gitlab",
+ repoUrl: "https://gitlab.com/group/project",
+ });
+ });
+
+ it("normalizes a gitlab https remote (no .git suffix)", () => {
+ expect(inferRemote("https://gitlab.com/group/project")).toEqual({
+ provider: "gitlab",
+ repoUrl: "https://gitlab.com/group/project",
+ });
+ });
+
+ it("normalizes a bitbucket ssh remote", () => {
+ expect(inferRemote("git@bitbucket.org:team/repo.git")).toEqual({
+ provider: "bitbucket",
+ repoUrl: "https://bitbucket.org/team/repo",
+ });
+ });
+
+ it("normalizes a bitbucket https remote", () => {
+ expect(inferRemote("https://bitbucket.org/team/repo.git")).toEqual({
+ provider: "bitbucket",
+ repoUrl: "https://bitbucket.org/team/repo",
+ });
+ });
+
+ it("infers gitea for codeberg.org", () => {
+ expect(inferRemote("https://codeberg.org/owner/repo.git")).toEqual({
+ provider: "gitea",
+ repoUrl: "https://codeberg.org/owner/repo",
+ });
+ });
+
+ it("infers gitea for a *.gitea.io ssh remote", () => {
+ expect(inferRemote("git@try.gitea.io:owner/repo.git")).toEqual({
+ provider: "gitea",
+ repoUrl: "https://try.gitea.io/owner/repo",
+ });
+ });
+
+ it("defaults an unknown host to github while still normalizing the url", () => {
+ expect(inferRemote("https://git.example.com/owner/repo.git")).toEqual({
+ provider: "github",
+ repoUrl: "https://git.example.com/owner/repo",
+ });
+ });
+
+ it("returns provider github with no repoUrl when the url is unparseable", () => {
+ expect(inferRemote("not-a-url")).toEqual({ provider: "github" });
+ });
+});
+
// ── safeBranch ─────────────────────────────────────────────────────
describe("safeBranch", () => {
diff --git a/node/tests/scan-remote.test.ts b/node/tests/scan-remote.test.ts
index 4ee0cbb9..709ff2b4 100644
--- a/node/tests/scan-remote.test.ts
+++ b/node/tests/scan-remote.test.ts
@@ -416,6 +416,226 @@ describe("runRemoteScan", () => {
expect(exitSpy).toHaveBeenCalledWith(1);
});
+
+ // ── provider / repo_url (multi-provider, additive + backward-compatible) ──
+
+ async function postedBody(): Promise> {
+ // The 2nd positional arg of the last axios.post call is the request body.
+ const call = mockedAxios.post.mock.calls[mockedAxios.post.mock.calls.length - 1];
+ return call[1] as Record;
+ }
+
+ it("a github remote produces a body with NO provider/repo_url (byte-identical to today)", async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ repo: "owner/repo",
+ branch: "main",
+ mode: "fast",
+ skipInteractive: true,
+ quiet: true,
+ });
+
+ const body = await postedBody();
+ // Byte-identical shape: exactly these keys, nothing more.
+ expect(body).toEqual({
+ repository_name: "owner/repo",
+ branch_name: "main",
+ scan_mode: "fast",
+ });
+ expect(body).not.toHaveProperty("provider");
+ expect(body).not.toHaveProperty("repo_url");
+ });
+
+ it("an explicit --provider github still omits provider/repo_url", async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ repo: "owner/repo",
+ branch: "main",
+ mode: "fast",
+ skipInteractive: true,
+ quiet: true,
+ provider: "github",
+ repoUrl: "https://github.com/owner/repo",
+ });
+
+ const body = await postedBody();
+ expect(body).not.toHaveProperty("provider");
+ expect(body).not.toHaveProperty("repo_url");
+ });
+
+ it("--provider gitlab + --repo-url are sent for a non-github remote", async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ repo: "group/project",
+ branch: "main",
+ mode: "fast",
+ skipInteractive: true,
+ quiet: true,
+ provider: "gitlab",
+ repoUrl: "https://gitlab.com/group/project",
+ });
+
+ const body = await postedBody();
+ expect(body).toMatchObject({
+ repository_name: "group/project",
+ branch_name: "main",
+ scan_mode: "fast",
+ provider: "gitlab",
+ repo_url: "https://gitlab.com/group/project",
+ });
+ });
+
+ it("sends bitbucket provider + repo_url when flags are supplied", async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ repo: "team/repo",
+ branch: "main",
+ skipInteractive: true,
+ quiet: true,
+ provider: "bitbucket",
+ repoUrl: "https://bitbucket.org/team/repo",
+ });
+
+ const body = await postedBody();
+ expect(body.provider).toBe("bitbucket");
+ expect(body.repo_url).toBe("https://bitbucket.org/team/repo");
+ });
+
+ it("omits provider/repo_url when a provider is given without a repo_url", async () => {
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ repo: "group/project",
+ branch: "main",
+ skipInteractive: true,
+ quiet: true,
+ provider: "gitlab",
+ // no repoUrl → cannot send the pair; stay backward-compatible
+ });
+
+ const body = await postedBody();
+ expect(body).not.toHaveProperty("provider");
+ expect(body).not.toHaveProperty("repo_url");
+ });
+});
+
+// ── detectRepo-inferred provider flows into the body ────────────────────
+
+describe("runRemoteScan — inferred provider from detectRepo", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ vi.spyOn(process, "exit").mockImplementation((() => {
+ throw new Error("process.exit");
+ }) as any);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.doUnmock("../src/utils/git.js");
+ vi.resetModules();
+ });
+
+ it("includes an inferred gitlab provider + repo_url with no explicit flags", async () => {
+ vi.resetModules();
+ vi.doMock("../src/utils/git.js", () => ({
+ detectRepo: () => ({
+ repo: "group/project",
+ branch: "main",
+ provider: "gitlab",
+ repo_url: "https://gitlab.com/group/project",
+ }),
+ }));
+
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ mode: "fast",
+ skipInteractive: true,
+ quiet: true,
+ });
+
+ const call = mockedAxios.post.mock.calls[mockedAxios.post.mock.calls.length - 1];
+ const body = call[1] as Record;
+ expect(body).toMatchObject({
+ repository_name: "group/project",
+ branch_name: "main",
+ provider: "gitlab",
+ repo_url: "https://gitlab.com/group/project",
+ });
+ });
+
+ it("omits provider/repo_url when detectRepo infers github", async () => {
+ vi.resetModules();
+ vi.doMock("../src/utils/git.js", () => ({
+ detectRepo: () => ({
+ repo: "owner/repo",
+ branch: "main",
+ provider: "github",
+ repo_url: "https://github.com/owner/repo",
+ }),
+ }));
+
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ mode: "fast",
+ skipInteractive: true,
+ quiet: true,
+ });
+
+ const call = mockedAxios.post.mock.calls[mockedAxios.post.mock.calls.length - 1];
+ const body = call[1] as Record;
+ expect(body).not.toHaveProperty("provider");
+ expect(body).not.toHaveProperty("repo_url");
+ });
+
+ it("an explicit --provider flag overrides the inferred provider", async () => {
+ vi.resetModules();
+ vi.doMock("../src/utils/git.js", () => ({
+ detectRepo: () => ({
+ repo: "group/project",
+ branch: "main",
+ provider: "gitlab",
+ repo_url: "https://gitlab.com/group/project",
+ }),
+ }));
+
+ mockedAxios.post.mockResolvedValueOnce({ data: { scan_id: "scan-abc" } });
+
+ const { runRemoteScan } = await import("../src/commands/backend/run.js");
+ await runRemoteScan({
+ apiKey: "test-key",
+ skipInteractive: true,
+ quiet: true,
+ provider: "bitbucket",
+ repoUrl: "https://bitbucket.org/group/project",
+ });
+
+ const call = mockedAxios.post.mock.calls[mockedAxios.post.mock.calls.length - 1];
+ const body = call[1] as Record;
+ expect(body.provider).toBe("bitbucket");
+ expect(body.repo_url).toBe("https://bitbucket.org/group/project");
+ });
});
// ── Live API integration tests ──────────────────────────────────────────
diff --git a/node/tests/secret-patterns.test.ts b/node/tests/secret-patterns.test.ts
index aedbf943..d6c17cbf 100644
--- a/node/tests/secret-patterns.test.ts
+++ b/node/tests/secret-patterns.test.ts
@@ -310,6 +310,14 @@ describe("Secret patterns — coverage matrix", () => {
});
});
+ describe("DigitalOcean Personal Access Token", () => {
+ it("detects dop_v1_ token", () => {
+ const token = "dop_v1_" + "a".repeat(64);
+ const r = scanString(token + "\n");
+ expect(r.matches.some((m) => m.pattern.name.includes("DigitalOcean"))).toBe(true);
+ });
+ });
+
// ── Helper function coverage ──────────────────────────────────────
describe("getPatternsBySeverity", () => {
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 99654fdb..6756bc2e 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "rafter-cli"
-version = "0.8.10"
+version = "0.9.0"
description = "Rafter CLI — the default security agent for AI workflows. Free for individuals and open source."
authors = ["Rafter Team "]
license = "MIT"
diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py
index cd65f55c..44ad216a 100644
--- a/python/rafter_cli/commands/agent.py
+++ b/python/rafter_cli/commands/agent.py
@@ -267,6 +267,7 @@ def _print_dry_run_plan(
want_continue: bool,
want_aider: bool,
want_hermes: bool,
+ want_opencode: bool,
want_betterleaks: bool,
want_skill_scanner: bool,
risk_level: str,
@@ -385,6 +386,11 @@ def R(p: Path, note: str = "") -> None:
print("Hermes (--with-hermes):")
W(root / ".hermes" / "config.yaml", "mcp_servers.rafter entry merged into existing YAML")
+ if want_opencode:
+ print()
+ print("OpenCode (--with-opencode):")
+ W(root / ".config" / "opencode" / "opencode.json", "mcp.rafter local/stdio entry merged into existing JSON")
+
if want_openclaw:
print()
print("OpenClaw (--with-openclaw):")
@@ -632,6 +638,15 @@ def _register_gemini_skills(skills_dir: Path) -> None:
"args": ["mcp", "serve"],
}
+# OpenCode's schema differs: the block is `mcp` (not `mcpServers`), each local
+# server carries type: "local", and command + args are a single `command`
+# array. Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
+_RAFTER_OPENCODE_MCP_ENTRY = {
+ "type": "local",
+ "command": [_RAFTER_MCP_ENTRY["command"], *_RAFTER_MCP_ENTRY["args"]],
+ "enabled": True,
+}
+
def _install_claude_code_mcp(root: Path) -> bool:
"""Install MCP server config for Claude Code (/.mcp.json).
@@ -1055,6 +1070,45 @@ def _install_hermes_mcp(root: Path) -> bool:
return True
+def _install_opencode_mcp(root: Path) -> bool:
+ """Install MCP server config for OpenCode (/.config/opencode/opencode.json).
+
+ OpenCode reads a global config at ~/.config/opencode/opencode.json and a
+ project-level opencode.json (project takes precedence). We register the
+ local stdio server ``rafter mcp serve`` under the ``mcp`` block. OpenCode's
+ schema differs from Cursor/Windsurf: the block is ``mcp`` (not
+ ``mcpServers``), each local server carries ``type: "local"``, and the
+ command + arguments are a single ``command`` array. A ``$schema`` pointer is
+ seeded on first write. Any existing keys / other MCP servers are preserved.
+ Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
+ """
+ opencode_dir = root / ".config" / "opencode"
+ config_path = opencode_dir / "opencode.json"
+
+ opencode_dir.mkdir(parents=True, exist_ok=True)
+
+ config: dict[str, Any] = {}
+ if config_path.exists():
+ try:
+ loaded = json.loads(config_path.read_text())
+ if isinstance(loaded, dict):
+ config = loaded
+ except (json.JSONDecodeError, ValueError):
+ rprint(fmt.warning("Existing OpenCode opencode.json was unreadable, creating new one"))
+
+ if "$schema" not in config:
+ config["$schema"] = "https://opencode.ai/config.json"
+ mcp = config.get("mcp")
+ if not isinstance(mcp, dict):
+ mcp = {}
+ config["mcp"] = mcp
+ mcp["rafter"] = {**_RAFTER_OPENCODE_MCP_ENTRY}
+
+ config_path.write_text(json.dumps(config, indent=2) + "\n")
+ rprint(fmt.success(f"Installed Rafter MCP server to {config_path}"))
+ return True
+
+
@agent_app.command()
def init(
risk_level: str = typer.Option("moderate", "--risk-level", help="minimal, moderate, or aggressive"),
@@ -1069,6 +1123,7 @@ def init(
with_windsurf: bool = typer.Option(False, "--with-windsurf", help="Install Windsurf integration"),
with_continue: bool = typer.Option(False, "--with-continue", help="Install Continue.dev integration"),
with_hermes: bool = typer.Option(False, "--with-hermes", help="Install Hermes integration"),
+ with_opencode: bool = typer.Option(False, "--with-opencode", help="Install OpenCode integration"),
all_integrations: bool = typer.Option(False, "--all", help="Install all detected integrations and download Betterleaks"),
update: bool = typer.Option(False, "--update", help="Re-download betterleaks and reinstall integrations without resetting config"),
local: bool = typer.Option(
@@ -1108,6 +1163,7 @@ def init(
has_continue_dev = scope == "user" and (home / ".continue").exists()
has_aider = scope == "user" and (home / ".aider.conf.yml").exists()
has_hermes = scope == "user" and (home / ".hermes").exists()
+ has_opencode = scope == "user" and (home / ".config" / "opencode").exists()
# Resolve opt-in flags. In --local scope, --all is restricted to platforms with
# a project-local config story (claudeCode, codex, gemini, cursor).
@@ -1132,6 +1188,12 @@ def init(
# reads ~/.hermes/config.yaml; project-local install story isn't
# established. Excluded from --all in --local for the same reason (sable-gyw).
want_hermes = with_hermes or (all_integrations and not local)
+ # OpenCode: MCP-based, user scope only. OpenCode reads a global config at
+ # ~/.config/opencode/opencode.json; a project-local install story via
+ # --local isn't wired here, so (like Hermes) it's excluded from --all in
+ # --local scope. It supports MCP local/stdio servers and AGENTS.md natively
+ # (https://opencode.ai/docs/mcp-servers/, sable-l8e5).
+ want_opencode = with_opencode or (all_integrations and not local)
want_betterleaks = with_betterleaks or (all_integrations and not local)
# skill-scanner is heavy and opt-in only — deliberately NOT folded into --all.
want_skill_scanner = with_skill_scanner
@@ -1156,6 +1218,8 @@ def init(
detected.append("Aider")
if has_hermes:
detected.append("Hermes")
+ if has_opencode:
+ detected.append("OpenCode")
if detected:
rprint(fmt.info(f"Detected environments: {', '.join(detected)}"))
@@ -1183,6 +1247,8 @@ def init(
rprint(fmt.warning("Aider requested but not detected (~/.aider.conf.yml not found)"))
if want_hermes and not has_hermes:
rprint(fmt.warning("Hermes requested but not detected (~/.hermes not found)"))
+ if want_opencode and not has_opencode:
+ rprint(fmt.warning("OpenCode requested but not detected (~/.config/opencode not found)"))
# --dry-run: print every file path the command would touch, then exit
# before any filesystem write happens (rf-hrtd). Built from the same
@@ -1200,6 +1266,7 @@ def init(
want_continue=want_continue and (has_continue_dev or local),
want_aider=want_aider and (has_aider or local),
want_hermes=want_hermes and has_hermes,
+ want_opencode=want_opencode and has_opencode,
want_betterleaks=want_betterleaks,
want_skill_scanner=want_skill_scanner,
risk_level=risk_level,
@@ -1430,6 +1497,19 @@ def _local_unsupported(label: str) -> None:
except Exception as e:
rprint(fmt.error(f"Failed to install Hermes integration: {e}"))
+ # Install OpenCode integration if opted in (sable-l8e5).
+ # User scope only — OpenCode reads a global config at
+ # ~/.config/opencode/opencode.json. MCP-based: we register the local stdio
+ # server `rafter mcp serve` under the `mcp` block.
+ opencode_ok = False
+ if want_opencode and has_opencode:
+ try:
+ opencode_ok = _install_opencode_mcp(root)
+ if opencode_ok:
+ manager.set("agent.environments.opencode.enabled", True)
+ except Exception as e:
+ rprint(fmt.error(f"Failed to install OpenCode integration: {e}"))
+
# Install global instruction files for platforms that support them
_install_global_instructions(
claude_code=claude_code_ok,
@@ -1445,7 +1525,7 @@ def _local_unsupported(label: str) -> None:
rprint(fmt.success("Agent security initialized!"))
rprint()
- 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
+ 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
if any_integration:
rprint("Next steps:")
@@ -1465,6 +1545,10 @@ def _local_unsupported(label: str) -> None:
rprint(" - Restart Continue.dev to load MCP server")
if aider_ok:
rprint(" - Restart Aider to load RAFTER.md from .aider.conf.yml read:")
+ if hermes_ok:
+ rprint(" - Restart Hermes to load MCP server")
+ if opencode_ok:
+ rprint(" - Restart OpenCode to load MCP server")
elif scope == "project":
rprint("No integrations were installed. In --local mode, pass one or more opt-in flags:")
rprint(" rafter agent init --local --with-claude-code")
@@ -1492,6 +1576,8 @@ def _local_unsupported(label: str) -> None:
rprint(" rafter agent init --with-aider # Aider only")
if has_hermes:
rprint(" rafter agent init --with-hermes # Hermes only")
+ if has_opencode:
+ rprint(" rafter agent init --with-opencode # OpenCode only")
else:
rprint("No agent environments detected. Install an agent tool and re-run with --with-.")
@@ -2819,6 +2905,34 @@ def _check_hermes() -> _CheckResult:
return _CheckResult(name, True, "MCP server configured")
+def _check_opencode() -> _CheckResult:
+ """Check if OpenCode integration is healthy (sable-l8e5).
+
+ OpenCode uses ~/.config/opencode/opencode.json with an ``mcp`` block
+ (local/stdio servers carry type: "local").
+ """
+ name = "OpenCode"
+ home = Path.home()
+ opencode_dir = home / ".config" / "opencode"
+
+ if not opencode_dir.exists():
+ return _CheckResult(name, False, "Not detected — run 'rafter agent init --with-opencode' to enable", optional=True)
+
+ config_path = opencode_dir / "opencode.json"
+ if not config_path.exists():
+ return _CheckResult(name, False, f"Config not found: {config_path} — run 'rafter agent init --with-opencode'", optional=True)
+
+ try:
+ loaded = json.loads(config_path.read_text()) or {}
+ except (OSError, json.JSONDecodeError, ValueError) as e:
+ return _CheckResult(name, False, f"Cannot read config: {e}", optional=True)
+
+ servers = loaded.get("mcp") if isinstance(loaded, dict) else None
+ if not (isinstance(servers, dict) and servers.get("rafter")):
+ return _CheckResult(name, False, "Rafter MCP server not configured — run 'rafter agent init --with-opencode'", optional=True)
+ return _CheckResult(name, True, "MCP server configured")
+
+
def _probe_claude_code() -> _CheckResult:
"""Runtime probe of the Claude Code hook integration (rf-65zg).
@@ -2920,6 +3034,7 @@ def verify(
_check_continue_dev(),
_check_aider(),
_check_hermes(),
+ _check_opencode(),
]
if probe:
@@ -3534,6 +3649,7 @@ def _describe(on: bool, src: str) -> str:
{"name": "Windsurf", "flag": "--with-windsurf", "config_dir": home / ".codeium" / "windsurf", "config_file": home / ".codeium" / "windsurf" / "mcp_config.json", "needle": "rafter"},
{"name": "Continue.dev", "flag": "--with-continue", "config_dir": home / ".continue", "config_file": home / ".continue" / "config.json", "needle": "rafter"},
{"name": "Hermes", "flag": "--with-hermes", "config_dir": home / ".hermes", "config_file": home / ".hermes" / "config.yaml", "needle": "rafter"},
+ {"name": "OpenCode", "flag": "--with-opencode", "config_dir": home / ".config" / "opencode", "config_file": home / ".config" / "opencode" / "opencode.json", "needle": "rafter"},
]
for agent in mcp_agents:
@@ -3630,6 +3746,7 @@ def _detect_agent_platforms() -> list[str]:
("continue", home / ".continue"),
("aider", home / ".aider.conf.yml"),
("hermes", home / ".hermes"),
+ ("opencode", home / ".config" / "opencode"),
]
return [name for name, path in candidates if path.exists()]
diff --git a/python/rafter_cli/commands/agent_components.py b/python/rafter_cli/commands/agent_components.py
index a781aa6e..acb2382c 100644
--- a/python/rafter_cli/commands/agent_components.py
+++ b/python/rafter_cli/commands/agent_components.py
@@ -124,6 +124,15 @@ def _filter_hooks(arr: list[Any] | None, predicate: Callable[[Any], bool]) -> li
RAFTER_MCP_ENTRY: dict[str, Any] = {"command": "rafter", "args": ["mcp", "serve"]}
+# OpenCode's schema differs: the block is `mcp` (not `mcpServers`), each local
+# server carries type: "local", and command + args are a single `command`
+# array. Verified against https://opencode.ai/docs/mcp-servers/ (sable-l8e5).
+RAFTER_OPENCODE_MCP_ENTRY: dict[str, Any] = {
+ "type": "local",
+ "command": [RAFTER_MCP_ENTRY["command"], *RAFTER_MCP_ENTRY["args"]],
+ "enabled": True,
+}
+
# ── Skill template lookup ─────────────────────────────────────────────
@@ -897,6 +906,61 @@ def uninstall() -> None:
)
+def _opencode_mcp() -> ComponentSpec:
+ """OpenCode MCP server entry (~/.config/opencode/opencode.json).
+
+ OpenCode's schema differs from Cursor/Windsurf: the block is ``mcp`` (not
+ ``mcpServers``), each local server carries ``type: "local"``, and command +
+ args are a single ``command`` array. A ``$schema`` pointer is seeded on
+ first write. Verified against https://opencode.ai/docs/mcp-servers/
+ (sable-l8e5).
+ """
+ home = Path.home()
+ detect_dir = home / ".config" / "opencode"
+ config_path = detect_dir / "opencode.json"
+
+ def is_installed() -> bool:
+ cfg = _read_json(config_path)
+ mcp = cfg.get("mcp") if isinstance(cfg, dict) else None
+ return isinstance(mcp, dict) and bool(mcp.get("rafter"))
+
+ def install() -> None:
+ detect_dir.mkdir(parents=True, exist_ok=True)
+ cfg = _read_json(config_path)
+ # Guard against valid-but-non-object top-level JSON (list/str/number).
+ if not isinstance(cfg, dict):
+ cfg = {}
+ if "$schema" not in cfg:
+ cfg["$schema"] = "https://opencode.ai/config.json"
+ mcp = cfg.get("mcp")
+ if not isinstance(mcp, dict):
+ mcp = {}
+ cfg["mcp"] = mcp
+ mcp["rafter"] = dict(RAFTER_OPENCODE_MCP_ENTRY)
+ _write_json(config_path, cfg)
+
+ def uninstall() -> None:
+ if not config_path.exists():
+ return
+ cfg = _read_json(config_path)
+ mcp = cfg.get("mcp") if isinstance(cfg, dict) else None
+ if isinstance(mcp, dict) and "rafter" in mcp:
+ del mcp["rafter"]
+ _write_json(config_path, cfg)
+
+ return ComponentSpec(
+ id="opencode.mcp",
+ platform="opencode",
+ kind="mcp",
+ description="OpenCode MCP server entry (~/.config/opencode/opencode.json)",
+ detect_dir=detect_dir,
+ path=config_path,
+ is_installed=is_installed,
+ install=install,
+ uninstall=uninstall,
+ )
+
+
_AIDER_LEGACY_MCP_BLOCK_RE = re.compile(
r"\n?#\s*Rafter security MCP server\s*\nmcp-server-command:\s*rafter\s+mcp\s+serve\s*\n?",
)
@@ -1056,6 +1120,7 @@ def get_registry() -> list[ComponentSpec]:
_continue_mcp(),
_aider_read(),
_hermes_mcp(),
+ _opencode_mcp(),
_openclaw_skill(),
]
return _REGISTRY
diff --git a/python/rafter_cli/commands/backend.py b/python/rafter_cli/commands/backend.py
index ae34d84f..b82f1ab7 100644
--- a/python/rafter_cli/commands/backend.py
+++ b/python/rafter_cli/commands/backend.py
@@ -91,6 +91,8 @@ def _do_remote_scan(
quiet: bool,
mode: str = "fast",
github_token: "str | None" = None,
+ provider: "str | None" = None,
+ repo_url: "str | None" = None,
) -> None:
"""Shared implementation for remote backend scan — used by both `rafter run` and `rafter scan`."""
import os as _os
@@ -98,7 +100,7 @@ def _do_remote_scan(
key = resolve_key(api_key)
gh_token = github_token or _os.environ.get("RAFTER_GITHUB_TOKEN")
try:
- repo_slug, branch_name = detect_repo(repo, branch)
+ repo_slug, branch_name, detected_provider, detected_repo_url = detect_repo(repo, branch)
except RuntimeError as e:
print(str(e), file=sys.stderr)
raise typer.Exit(code=EXIT_GENERAL_ERROR)
@@ -106,12 +108,23 @@ def _do_remote_scan(
if not (repo and branch) and not quiet:
print(f"Repo auto-detected: {repo_slug} @ {branch_name} (note: scanning remote)", file=sys.stderr)
+ # Explicit flags override inferred values.
+ resolved_provider = provider or detected_provider
+ resolved_repo_url = repo_url or detected_repo_url
+
headers = {"x-api-key": key, "Content-Type": "application/json"}
body: dict = {"repository_name": repo_slug, "branch_name": branch_name, "scan_mode": mode}
if gh_token:
body["github_token"] = gh_token
+ # Additive, backward-compatible: only send provider + repo_url for non-github
+ # remotes. For github (or an unknown host that defaulted to github), omit both
+ # so the request body is byte-identical to the legacy github-only behavior.
+ if resolved_provider and resolved_provider != "github" and resolved_repo_url:
+ body["provider"] = resolved_provider
+ body["repo_url"] = resolved_repo_url
+
resp = requests.post(
f"{API_BASE}/static/scan",
headers=headers,
@@ -150,11 +163,13 @@ def run(
fmt: str = typer.Option("md", "--format", "-f", help="json | md"),
mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
github_token: str = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
+ provider: str = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
+ repo_url: str = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
):
"""Trigger a security scan."""
- _do_remote_scan(repo, branch, api_key, fmt, skip_interactive, quiet, mode, github_token=github_token)
+ _do_remote_scan(repo, branch, api_key, fmt, skip_interactive, quiet, mode, github_token=github_token, provider=provider, repo_url=repo_url)
@app.command()
def get(
diff --git a/python/rafter_cli/commands/issues/issues_app.py b/python/rafter_cli/commands/issues/issues_app.py
index 05269c76..638a9177 100644
--- a/python/rafter_cli/commands/issues/issues_app.py
+++ b/python/rafter_cli/commands/issues/issues_app.py
@@ -64,7 +64,7 @@ def from_scan(
target_repo = repo
if not target_repo:
try:
- target_repo, _ = detect_repo(repo)
+ target_repo, _, _, _ = detect_repo(repo)
except RuntimeError as e:
print_stderr(fmt.error(str(e)))
raise typer.Exit(code=EXIT_GENERAL_ERROR)
@@ -165,7 +165,7 @@ def from_text(
target_repo = repo
if not target_repo:
try:
- target_repo, _ = detect_repo(repo)
+ target_repo, _, _, _ = detect_repo(repo)
except RuntimeError as e:
print_stderr(fmt.error(str(e)))
raise typer.Exit(code=EXIT_GENERAL_ERROR)
diff --git a/python/rafter_cli/commands/scan.py b/python/rafter_cli/commands/scan.py
index ba83c12a..5979e464 100644
--- a/python/rafter_cli/commands/scan.py
+++ b/python/rafter_cli/commands/scan.py
@@ -50,13 +50,15 @@ def scan_default(
fmt_: str = typer.Option("md", "--format", "-f", help="json | md"),
mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
github_token: Optional[str] = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
+ provider: Optional[str] = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
+ repo_url: Optional[str] = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
):
"""Scan for security issues. Defaults to remote scan."""
if ctx.invoked_subcommand is None:
# No subcommand — run remote scan
- _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token)
+ _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token, provider, repo_url)
# ── rafter scan remote ────────────────────────────────────────────────
@@ -69,17 +71,19 @@ def scan_remote(
fmt_: str = typer.Option("md", "--format", "-f", help="json | md"),
mode: str = typer.Option("fast", "--mode", "-m", help="scan mode: fast | plus"),
github_token: Optional[str] = typer.Option(None, "--github-token", envvar="RAFTER_GITHUB_TOKEN", help="GitHub PAT for private repos"),
+ provider: Optional[str] = typer.Option(None, "--provider", help="git provider: gitlab | gitea | bitbucket (default: auto-detected; github requires nothing)"),
+ repo_url: Optional[str] = typer.Option(None, "--repo-url", help="full https clone URL for non-github remotes (default: auto-detected)"),
skip_interactive: bool = typer.Option(False, "--skip-interactive", help="do not wait for scan to complete"),
quiet: bool = typer.Option(False, "--quiet", help="suppress status messages"),
):
"""Trigger a remote backend security scan (explicit alias for 'rafter run')."""
- _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token)
+ _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token, provider, repo_url)
-def _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode="fast", github_token=None):
+def _run_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode="fast", github_token=None, provider=None, repo_url=None):
"""Shared handler: invoke remote scan (same logic as `rafter run`)."""
from ..commands.backend import _do_remote_scan
- _do_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token=github_token)
+ _do_remote_scan(repo, branch, api_key, fmt_, skip_interactive, quiet, mode, github_token=github_token, provider=provider, repo_url=repo_url)
# ── rafter scan local ─────────────────────────────────────────────────
diff --git a/python/rafter_cli/resources/rafter-security-skill.md b/python/rafter_cli/resources/rafter-security-skill.md
index 79349448..16aaa43a 100644
--- a/python/rafter_cli/resources/rafter-security-skill.md
+++ b/python/rafter_cli/resources/rafter-security-skill.md
@@ -1,7 +1,7 @@
---
name: rafter-security
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`.
-version: 0.8.10
+version: 0.9.0
homepage: https://rafter.so
metadata:
openclaw:
diff --git a/python/rafter_cli/scanners/secret_patterns.py b/python/rafter_cli/scanners/secret_patterns.py
index d7f66228..06b3e104 100644
--- a/python/rafter_cli/scanners/secret_patterns.py
+++ b/python/rafter_cli/scanners/secret_patterns.py
@@ -148,6 +148,13 @@
severity="critical",
description="PyPI API token detected",
),
+ # DigitalOcean
+ Pattern(
+ name="DigitalOcean Personal Access Token",
+ regex=r"dop_v1_[a-f0-9]{64}",
+ severity="critical",
+ description="DigitalOcean Personal Access Token detected",
+ ),
]
diff --git a/python/rafter_cli/utils/git.py b/python/rafter_cli/utils/git.py
index b0f692c3..4d7998d3 100644
--- a/python/rafter_cli/utils/git.py
+++ b/python/rafter_cli/utils/git.py
@@ -47,13 +47,65 @@ def parse_remote(url: str) -> str:
return "/".join(parts[-2:])
+def provider_for_host(host: str) -> str:
+ """Map a git remote host to a provider.
+
+ 'github' is the backward-compatible default for any host we don't
+ recognize — a GitHub user's request is unaffected, and unknown
+ self-hosted hosts fall back to the legacy behavior.
+ """
+ host = host.lower()
+ if host == "github.com":
+ return "github"
+ if host == "gitlab.com" or host.endswith(".gitlab.com"):
+ return "gitlab"
+ if host == "bitbucket.org":
+ return "bitbucket"
+ if host == "codeberg.org" or host.endswith(".gitea.io"):
+ return "gitea"
+ return "github" # backward-compatible default
+
+
+def _split_remote(url: str) -> tuple[str, str] | None:
+ """Split a git remote URL into (host, 'owner/repo').
+
+ Handles both 'git@host:owner/repo(.git)' (scp-like) and
+ 'https://host/owner/repo(.git)'. Returns None when it can't be parsed
+ into host + slug.
+ """
+ rest = re.sub(r"^(https?://|git@)", "", url)
+ rest = rest.replace(":", "/")
+ if rest.endswith(".git"):
+ rest = rest[:-4]
+ parts = [p for p in rest.split("/") if p]
+ if len(parts) < 3: # need host + owner + repo
+ return None
+ host = parts[0]
+ slug = "/".join(parts[-2:])
+ return host, slug
+
+
+def infer_remote(url: str) -> tuple[str, str | None]:
+ """Infer (provider, repo_url) from a git remote URL.
+
+ repo_url is a canonical 'https:////' clone URL.
+ Falls back to ('github', None) when the URL can't be parsed.
+ """
+ parts = _split_remote(url)
+ if parts is None:
+ return "github", None
+ host, slug = parts
+ return provider_for_host(host), f"https://{host}/{slug}"
+
+
def detect_repo(
repo: str | None = None,
branch: str | None = None,
-) -> tuple[str, str]:
+) -> tuple[str, str, str | None, str | None]:
"""Auto-detect repo slug and branch from git or CI env vars.
- Returns (repo_slug, branch).
+ Returns (repo_slug, branch, provider, repo_url). provider/repo_url are
+ inferred from the git remote when the slug is auto-detected, else None.
Raises RuntimeError if detection fails.
"""
import os
@@ -66,9 +118,11 @@ def detect_repo(
)
repo_slug = repo or repo_env
branch_name = branch or branch_env
+ provider: str | None = None
+ repo_url: str | None = None
if repo_slug and branch_name:
- return repo_slug, branch_name
+ return repo_slug, branch_name, provider, repo_url
if not is_inside_repo():
raise RuntimeError(
@@ -79,14 +133,15 @@ def detect_repo(
if not repo_slug:
try:
remote_url = _run(["git", "remote", "get-url", "origin"])
- repo_slug = parse_remote(remote_url)
except subprocess.CalledProcessError:
raise RuntimeError(
"Could not auto-detect Git repository. "
"Please pass --repo and --branch explicitly."
)
+ repo_slug = parse_remote(remote_url)
+ provider, repo_url = infer_remote(remote_url)
if not branch_name:
branch_name = safe_branch()
- return repo_slug, branch_name
+ return repo_slug, branch_name, provider, repo_url
diff --git a/python/tests/test_agent_components.py b/python/tests/test_agent_components.py
index b06d1f6c..9c6ec02c 100644
--- a/python/tests/test_agent_components.py
+++ b/python/tests/test_agent_components.py
@@ -64,6 +64,8 @@ def test_json_has_expected_shape(self, home: Path):
"continue.rules",
"continue.mcp",
"aider.read",
+ "hermes.mcp",
+ "opencode.mcp",
"codex.hooks",
"codex.skills",
"openclaw.skills",
diff --git a/python/tests/test_agent_init.py b/python/tests/test_agent_init.py
index ac9b0e58..1f1e53be 100644
--- a/python/tests/test_agent_init.py
+++ b/python/tests/test_agent_init.py
@@ -17,6 +17,7 @@
_install_continue_dev_rules,
_install_aider_read,
_install_hermes_mcp,
+ _install_opencode_mcp,
)
@@ -149,6 +150,173 @@ def test_hermes_mcp_component_install_uninstall(self, tmp_path, monkeypatch):
agent_components.reset_registry_cache()
+class TestInstallOpenCodeMcp:
+ def test_creates_config_from_scratch(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ assert _install_opencode_mcp(tmp_path)
+
+ config_path = tmp_path / ".config" / "opencode" / "opencode.json"
+ assert config_path.exists()
+ config = json.loads(config_path.read_text())
+ assert config["mcp"]["rafter"]["type"] == "local"
+ assert config["mcp"]["rafter"]["command"] == ["rafter", "mcp", "serve"]
+ assert config["mcp"]["rafter"]["enabled"] is True
+ assert config["$schema"] == "https://opencode.ai/config.json"
+
+ def test_preserves_existing_servers(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ (opencode_dir / "opencode.json").write_text(json.dumps({
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "other": {"type": "local", "command": ["other", "go"], "enabled": True},
+ },
+ "theme": "system",
+ }, indent=2))
+
+ _install_opencode_mcp(tmp_path)
+
+ config = json.loads((opencode_dir / "opencode.json").read_text())
+ assert "other" in config["mcp"]
+ assert "rafter" in config["mcp"]
+ # Non-mcp top-level keys must survive the merge.
+ assert config["theme"] == "system"
+
+ def test_idempotent_on_reinstall(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ _install_opencode_mcp(tmp_path)
+ first = (tmp_path / ".config" / "opencode" / "opencode.json").read_text()
+
+ _install_opencode_mcp(tmp_path)
+ second = (tmp_path / ".config" / "opencode" / "opencode.json").read_text()
+
+ assert first == second, "second install should be a no-op"
+
+ def test_recovers_from_unreadable_json(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ # Deliberately broken JSON — installer must not raise; should warn and
+ # write a fresh config containing the rafter entry.
+ (opencode_dir / "opencode.json").write_text("{ this is not valid json")
+
+ assert _install_opencode_mcp(tmp_path)
+ config = json.loads((opencode_dir / "opencode.json").read_text())
+ assert config["mcp"]["rafter"]["type"] == "local"
+
+ def test_replaces_array_shape_mcp(self, tmp_path, monkeypatch):
+ """If a user wrote `mcp:` as a list (wrong shape), the installer must
+ coerce it to a dict rather than blow up."""
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ (opencode_dir / "opencode.json").write_text(json.dumps({"mcp": ["junk"]}))
+
+ assert _install_opencode_mcp(tmp_path)
+ config = json.loads((opencode_dir / "opencode.json").read_text())
+ assert isinstance(config["mcp"], dict)
+ assert config["mcp"]["rafter"]["command"] == ["rafter", "mcp", "serve"]
+
+ def test_recovers_from_non_object_toplevel(self, tmp_path, monkeypatch):
+ """Valid JSON whose top level is a list/primitive must be replaced with a
+ fresh object, not crash on .get() (rafter review parity fix)."""
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ (opencode_dir / "opencode.json").write_text(json.dumps([1, 2, 3]))
+
+ assert _install_opencode_mcp(tmp_path)
+ config = json.loads((opencode_dir / "opencode.json").read_text())
+ assert isinstance(config, dict)
+ assert config["mcp"]["rafter"]["type"] == "local"
+ assert config["$schema"] == "https://opencode.ai/config.json"
+
+
+class TestOpenCodeDetection:
+ """sable-l8e5 — OpenCode must surface in verify / status / list."""
+
+ def test_check_opencode_not_detected(self, tmp_path, monkeypatch):
+ from rafter_cli.commands.agent import _check_opencode
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ result = _check_opencode()
+ assert not result.passed
+ assert result.optional
+ assert "not detected" in result.detail.lower()
+
+ def test_check_opencode_pass_after_install(self, tmp_path, monkeypatch):
+ from rafter_cli.commands.agent import _check_opencode, _install_opencode_mcp
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ _install_opencode_mcp(tmp_path)
+ result = _check_opencode()
+ assert result.passed
+ assert "configured" in result.detail.lower()
+
+ def test_check_opencode_warns_when_config_lacks_rafter(self, tmp_path, monkeypatch):
+ from rafter_cli.commands.agent import _check_opencode
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ (opencode_dir / "opencode.json").write_text(json.dumps({"mcp": {"other": {"type": "local", "command": ["other"]}}}))
+ result = _check_opencode()
+ assert not result.passed
+ assert result.optional # warn, not hard fail
+
+ def test_detect_agent_platforms_includes_opencode(self, tmp_path, monkeypatch):
+ from rafter_cli.commands.agent import _detect_agent_platforms
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ (tmp_path / ".config" / "opencode").mkdir(parents=True)
+ assert "opencode" in _detect_agent_platforms()
+
+ def test_opencode_mcp_component_registered(self, tmp_path, monkeypatch):
+ from rafter_cli.commands import agent_components
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ agent_components.reset_registry_cache()
+ try:
+ ids = [c.id for c in agent_components.get_registry()]
+ assert "opencode.mcp" in ids
+ finally:
+ agent_components.reset_registry_cache()
+
+ def test_opencode_mcp_component_install_uninstall(self, tmp_path, monkeypatch):
+ from rafter_cli.commands import agent_components
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ agent_components.reset_registry_cache()
+ try:
+ spec = agent_components.resolve_component("opencode.mcp")
+ assert spec is not None
+ assert not spec.is_installed()
+ spec.install()
+ assert spec.is_installed()
+ config = json.loads((tmp_path / ".config" / "opencode" / "opencode.json").read_text())
+ assert config["mcp"]["rafter"]["command"] == ["rafter", "mcp", "serve"]
+ spec.uninstall()
+ assert not spec.is_installed()
+ finally:
+ agent_components.reset_registry_cache()
+
+ def test_opencode_mcp_component_handles_non_object_config(self, tmp_path, monkeypatch):
+ """is_installed()/install() must not crash on a valid-but-non-object
+ top-level config (rafter review parity fix)."""
+ from rafter_cli.commands import agent_components
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ opencode_dir = tmp_path / ".config" / "opencode"
+ opencode_dir.mkdir(parents=True)
+ (opencode_dir / "opencode.json").write_text(json.dumps([1, 2, 3]))
+ agent_components.reset_registry_cache()
+ try:
+ spec = agent_components.resolve_component("opencode.mcp")
+ assert spec is not None
+ assert not spec.is_installed() # must not raise on a list top-level
+ spec.install()
+ assert spec.is_installed()
+ config = json.loads((opencode_dir / "opencode.json").read_text())
+ assert isinstance(config, dict)
+ assert config["mcp"]["rafter"]["type"] == "local"
+ finally:
+ agent_components.reset_registry_cache()
+
+
class TestInstallClaudeCodeHooks:
def test_creates_settings_from_scratch(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
diff --git a/python/tests/test_api_scope.py b/python/tests/test_api_scope.py
index ea9a3a31..6089745d 100644
--- a/python/tests/test_api_scope.py
+++ b/python/tests/test_api_scope.py
@@ -102,7 +102,7 @@ class TestRemoteScan403:
"""Verify _do_remote_scan properly handles 403 scope errors."""
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_scope_403_raises_exit_with_code_4(self, _mock_repo, mock_post, capsys):
mock_post.return_value = _mock_response(
403,
@@ -125,7 +125,7 @@ def test_scope_403_raises_exit_with_code_4(self, _mock_repo, mock_post, capsys):
assert "https://rfrr.co/account" in err
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_generic_403_raises_exit_with_code_4(self, _mock_repo, mock_post, capsys):
mock_post.return_value = _mock_response(403, "forbidden")
from rafter_cli.commands.backend import _do_remote_scan
@@ -142,7 +142,7 @@ def test_generic_403_raises_exit_with_code_4(self, _mock_repo, mock_post, capsys
assert exc_info.value.exit_code == EXIT_INSUFFICIENT_SCOPE
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_429_still_raises_quota_exhausted(self, _mock_repo, mock_post, capsys):
mock_post.return_value = _mock_response(429, "quota exhausted")
from rafter_cli.commands.backend import _do_remote_scan
@@ -159,7 +159,7 @@ def test_429_still_raises_quota_exhausted(self, _mock_repo, mock_post, capsys):
assert exc_info.value.exit_code == EXIT_QUOTA_EXHAUSTED
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_200_succeeds(self, _mock_repo, mock_post):
mock_post.return_value = _mock_response(200, "")
mock_post.return_value.json.return_value = {"scan_id": "abc123"}
@@ -218,7 +218,7 @@ class TestBackwardCompatibility:
"""Ensure existing 401 and other error paths are unaffected."""
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_401_raises_general_error(self, _mock_repo, mock_post, capsys):
mock_post.return_value = _mock_response(401, "invalid api key")
from rafter_cli.commands.backend import _do_remote_scan
@@ -236,7 +236,7 @@ def test_401_raises_general_error(self, _mock_repo, mock_post, capsys):
assert exc_info.value.exit_code == EXIT_GENERAL_ERROR
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("org/repo", "main", "github", "https://github.com/org/repo"))
def test_500_raises_general_error(self, _mock_repo, mock_post, capsys):
mock_post.return_value = _mock_response(500, "internal server error")
from rafter_cli.commands.backend import _do_remote_scan
diff --git a/python/tests/test_git_utils.py b/python/tests/test_git_utils.py
index 589f00b7..099b1488 100644
--- a/python/tests/test_git_utils.py
+++ b/python/tests/test_git_utils.py
@@ -12,6 +12,8 @@
detect_repo,
is_inside_repo,
get_git_root,
+ provider_for_host,
+ infer_remote,
)
@@ -38,6 +40,97 @@ def test_http_no_tls(self):
assert parse_remote("http://github.com/owner/repo.git") == "owner/repo"
+# ── provider_for_host (host → provider inference) ───────────────────
+
+
+class TestProviderForHost:
+ def test_github(self):
+ assert provider_for_host("github.com") == "github"
+
+ def test_gitlab(self):
+ assert provider_for_host("gitlab.com") == "gitlab"
+
+ def test_gitlab_subdomain(self):
+ assert provider_for_host("git.gitlab.com") == "gitlab"
+
+ def test_bitbucket(self):
+ assert provider_for_host("bitbucket.org") == "bitbucket"
+
+ def test_codeberg_is_gitea(self):
+ assert provider_for_host("codeberg.org") == "gitea"
+
+ def test_gitea_io_subdomain(self):
+ assert provider_for_host("try.gitea.io") == "gitea"
+
+ def test_unknown_defaults_to_github(self):
+ assert provider_for_host("git.example.com") == "github"
+
+ def test_case_insensitive(self):
+ assert provider_for_host("GitLab.com") == "gitlab"
+
+
+# ── infer_remote (provider + canonical repo_url) ────────────────────
+
+
+class TestInferRemote:
+ def test_github_https(self):
+ assert infer_remote("https://github.com/owner/repo.git") == (
+ "github",
+ "https://github.com/owner/repo",
+ )
+
+ def test_github_ssh(self):
+ assert infer_remote("git@github.com:owner/repo.git") == (
+ "github",
+ "https://github.com/owner/repo",
+ )
+
+ def test_gitlab_ssh_normalized(self):
+ assert infer_remote("git@gitlab.com:group/project.git") == (
+ "gitlab",
+ "https://gitlab.com/group/project",
+ )
+
+ def test_gitlab_https_no_suffix(self):
+ assert infer_remote("https://gitlab.com/group/project") == (
+ "gitlab",
+ "https://gitlab.com/group/project",
+ )
+
+ def test_bitbucket_ssh(self):
+ assert infer_remote("git@bitbucket.org:team/repo.git") == (
+ "bitbucket",
+ "https://bitbucket.org/team/repo",
+ )
+
+ def test_bitbucket_https(self):
+ assert infer_remote("https://bitbucket.org/team/repo.git") == (
+ "bitbucket",
+ "https://bitbucket.org/team/repo",
+ )
+
+ def test_codeberg_is_gitea(self):
+ assert infer_remote("https://codeberg.org/owner/repo.git") == (
+ "gitea",
+ "https://codeberg.org/owner/repo",
+ )
+
+ def test_gitea_io_ssh(self):
+ assert infer_remote("git@try.gitea.io:owner/repo.git") == (
+ "gitea",
+ "https://try.gitea.io/owner/repo",
+ )
+
+ def test_unknown_host_defaults_github_but_normalizes(self):
+ assert infer_remote("https://git.example.com/owner/repo.git") == (
+ "github",
+ "https://git.example.com/owner/repo",
+ )
+
+ def test_unparseable_returns_github_none(self):
+ assert infer_remote("not-a-url") == ("github", None)
+
+
# ── safe_branch ─────────────────────────────────────────────────────
@@ -100,8 +193,9 @@ def test_returns_none_outside_repo(self):
class TestDetectRepo:
def test_explicit_repo_and_branch(self):
+ # Both explicit → no provider/repo_url inferred (flags fill those in).
result = detect_repo(repo="org/repo", branch="main")
- assert result == ("org/repo", "main")
+ assert result == ("org/repo", "main", None, None)
def test_github_env_vars(self, monkeypatch):
monkeypatch.setenv("GITHUB_REPOSITORY", "gh-org/gh-repo")
@@ -110,7 +204,7 @@ def test_github_env_vars(self, monkeypatch):
monkeypatch.delenv("CI_COMMIT_BRANCH", raising=False)
monkeypatch.delenv("CI_BRANCH", raising=False)
result = detect_repo()
- assert result == ("gh-org/gh-repo", "develop")
+ assert result == ("gh-org/gh-repo", "develop", None, None)
def test_ci_repository_fallback(self, monkeypatch):
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
@@ -119,7 +213,7 @@ def test_ci_repository_fallback(self, monkeypatch):
monkeypatch.setenv("CI_COMMIT_BRANCH", "staging")
monkeypatch.delenv("CI_BRANCH", raising=False)
result = detect_repo()
- assert result == ("ci-org/ci-repo", "staging")
+ assert result == ("ci-org/ci-repo", "staging", None, None)
def test_ci_branch_env(self, monkeypatch):
monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo")
@@ -127,20 +221,20 @@ def test_ci_branch_env(self, monkeypatch):
monkeypatch.delenv("CI_COMMIT_BRANCH", raising=False)
monkeypatch.setenv("CI_BRANCH", "circle-branch")
result = detect_repo()
- assert result == ("org/repo", "circle-branch")
+ assert result == ("org/repo", "circle-branch", None, None)
def test_explicit_opts_override_env(self, monkeypatch):
monkeypatch.setenv("GITHUB_REPOSITORY", "env-org/env-repo")
monkeypatch.setenv("GITHUB_REF_NAME", "env-branch")
result = detect_repo(repo="my/repo", branch="my-branch")
- assert result == ("my/repo", "my-branch")
+ assert result == ("my/repo", "my-branch", None, None)
def test_github_precedence_over_ci(self, monkeypatch):
monkeypatch.setenv("GITHUB_REPOSITORY", "gh/repo")
monkeypatch.setenv("CI_REPOSITORY", "ci/repo")
monkeypatch.setenv("GITHUB_REF_NAME", "main")
result = detect_repo()
- assert result == ("gh/repo", "main")
+ assert result == ("gh/repo", "main", None, None)
def test_github_ref_precedence_over_ci_branch(self, monkeypatch):
monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo")
@@ -148,7 +242,7 @@ def test_github_ref_precedence_over_ci_branch(self, monkeypatch):
monkeypatch.setenv("CI_COMMIT_BRANCH", "gl-branch")
monkeypatch.setenv("CI_BRANCH", "ci-branch")
result = detect_repo()
- assert result == ("org/repo", "gh-branch")
+ assert result == ("org/repo", "gh-branch", None, None)
def test_falls_back_to_git_when_no_env(self, monkeypatch):
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
@@ -161,7 +255,32 @@ def test_falls_back_to_git_when_no_env(self, monkeypatch):
patch("rafter_cli.utils.git._run", return_value="https://github.com/fallback/repo.git"), \
patch("rafter_cli.utils.git.safe_branch", return_value="feat"):
result = detect_repo()
- assert result == ("fallback/repo", "feat")
+ # github remote → provider inferred, but backward-compat is enforced
+ # at the request-body layer (backend), not here.
+ assert result == (
+ "fallback/repo",
+ "feat",
+ "github",
+ "https://github.com/fallback/repo",
+ )
+
+ def test_infers_gitlab_provider_and_repo_url_from_remote(self, monkeypatch):
+ monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
+ monkeypatch.delenv("CI_REPOSITORY", raising=False)
+ monkeypatch.delenv("GITHUB_REF_NAME", raising=False)
+ monkeypatch.delenv("CI_COMMIT_BRANCH", raising=False)
+ monkeypatch.delenv("CI_BRANCH", raising=False)
+
+ with patch("rafter_cli.utils.git.is_inside_repo", return_value=True), \
+ patch("rafter_cli.utils.git._run", return_value="git@gitlab.com:group/project.git"), \
+ patch("rafter_cli.utils.git.safe_branch", return_value="main"):
+ result = detect_repo()
+ assert result == (
+ "group/project",
+ "main",
+ "gitlab",
+ "https://gitlab.com/group/project",
+ )
def test_raises_when_not_in_repo_and_no_env(self, monkeypatch):
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
diff --git a/python/tests/test_regex_scanner.py b/python/tests/test_regex_scanner.py
index 2910d66c..cdffbd43 100644
--- a/python/tests/test_regex_scanner.py
+++ b/python/tests/test_regex_scanner.py
@@ -75,6 +75,10 @@ def test_scan_text_ignores_short_hashicorp_vault_like_string(scanner):
matches = scanner.scan_text(token)
assert not any(m.pattern.name == "HashiCorp Vault Token" for m in matches)
+def test_scan_text_detects_digitalocean_token(scanner):
+ token = "dop_v1_" + ("a" * 64)
+ matches = scanner.scan_text(token)
+ assert any(m.pattern.name == "DigitalOcean Personal Access Token" for m in matches)
def test_has_secrets(scanner):
assert scanner.has_secrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij")
diff --git a/python/tests/test_scan_remote.py b/python/tests/test_scan_remote.py
index 3a1effe2..1c5091fb 100644
--- a/python/tests/test_scan_remote.py
+++ b/python/tests/test_scan_remote.py
@@ -45,7 +45,7 @@ class TestDoRemoteScan:
"""Unit tests for the core remote scan trigger function."""
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_success_skip_interactive(self, _mock_repo, mock_post):
"""200 with skip_interactive returns without polling."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
@@ -61,7 +61,7 @@ def test_success_skip_interactive(self, _mock_repo, mock_post):
)
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_posts_correct_body(self, _mock_repo, mock_post):
"""Verify POST body contains repository_name, branch_name, scan_mode."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
@@ -84,7 +84,173 @@ def test_posts_correct_body(self, _mock_repo, mock_post):
assert "github_token" not in body
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
+ def test_github_body_omits_provider_and_repo_url(self, _mock_repo, mock_post):
+ """A github remote produces a body with NO provider/repo_url (byte-identical to today)."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo="owner/repo",
+ branch="main",
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ mode="fast",
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ # Byte-identical shape: exactly these keys, nothing more.
+ assert body == {
+ "repository_name": "owner/repo",
+ "branch_name": "main",
+ "scan_mode": "fast",
+ }
+ assert "provider" not in body
+ assert "repo_url" not in body
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
+ def test_explicit_provider_github_still_omits(self, _mock_repo, mock_post):
+ """An explicit --provider github still omits provider/repo_url."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo="owner/repo",
+ branch="main",
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ provider="github",
+ repo_url="https://github.com/owner/repo",
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert "provider" not in body
+ assert "repo_url" not in body
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
+ def test_flag_provider_gitlab_sends_provider_and_repo_url(self, _mock_repo, mock_post):
+ """--provider gitlab + --repo-url are sent for a non-github remote."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo="group/project",
+ branch="main",
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ provider="gitlab",
+ repo_url="https://gitlab.com/group/project",
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert body["provider"] == "gitlab"
+ assert body["repo_url"] == "https://gitlab.com/group/project"
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
+ def test_flag_provider_bitbucket_sends_pair(self, _mock_repo, mock_post):
+ """--provider bitbucket + --repo-url are sent together."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo="team/repo",
+ branch="main",
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ provider="bitbucket",
+ repo_url="https://bitbucket.org/team/repo",
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert body["provider"] == "bitbucket"
+ assert body["repo_url"] == "https://bitbucket.org/team/repo"
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("group/project", "main", None, None))
+ def test_provider_without_any_repo_url_is_omitted(self, _mock_repo, mock_post):
+ """A resolved provider with no repo_url anywhere can't send the pair; stays backward-compatible."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ # Explicit repo/branch → detect_repo infers nothing; provider given but
+ # no repo_url from flag or inference → the pair must be omitted.
+ _do_remote_scan(
+ repo="group/project",
+ branch="main",
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ provider="gitlab",
+ repo_url=None,
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert "provider" not in body
+ assert "repo_url" not in body
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch(
+ "rafter_cli.commands.backend.detect_repo",
+ return_value=("group/project", "main", "gitlab", "https://gitlab.com/group/project"),
+ )
+ def test_inferred_gitlab_provider_flows_into_body(self, _mock_repo, mock_post):
+ """An auto-detected gitlab remote sends provider + repo_url with no explicit flags."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo=None,
+ branch=None,
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert body["repository_name"] == "group/project"
+ assert body["provider"] == "gitlab"
+ assert body["repo_url"] == "https://gitlab.com/group/project"
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch(
+ "rafter_cli.commands.backend.detect_repo",
+ return_value=("group/project", "main", "gitlab", "https://gitlab.com/group/project"),
+ )
+ def test_explicit_flag_overrides_inferred_provider(self, _mock_repo, mock_post):
+ """An explicit --provider/--repo-url wins over the inferred values."""
+ mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
+
+ _do_remote_scan(
+ repo=None,
+ branch=None,
+ api_key="test-key",
+ fmt="json",
+ skip_interactive=True,
+ quiet=True,
+ provider="bitbucket",
+ repo_url="https://bitbucket.org/group/project",
+ )
+
+ _, kwargs = mock_post.call_args
+ body = kwargs["json"]
+ assert body["provider"] == "bitbucket"
+ assert body["repo_url"] == "https://bitbucket.org/group/project"
+
+ @patch("rafter_cli.commands.backend.requests.post")
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_includes_github_token(self, _mock_repo, mock_post):
"""GitHub token is included in POST body when provided."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
@@ -103,7 +269,7 @@ def test_includes_github_token(self, _mock_repo, mock_post):
assert kwargs["json"]["github_token"] == "ghp_test123"
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_plus_mode(self, _mock_repo, mock_post):
"""scan_mode=plus is sent when mode='plus'."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
@@ -122,7 +288,7 @@ def test_plus_mode(self, _mock_repo, mock_post):
assert kwargs["json"]["scan_mode"] == "plus"
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_prints_scan_id_when_not_quiet(self, _mock_repo, mock_post, capsys):
"""Scan ID is printed to stderr when not quiet."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-xyz"})
@@ -140,7 +306,7 @@ def test_prints_scan_id_when_not_quiet(self, _mock_repo, mock_post, capsys):
assert "s-xyz" in err
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_auto_detect_message_when_not_explicit(self, _mock_repo, mock_post, capsys):
"""Auto-detection message prints when repo/branch not explicitly provided."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
@@ -158,7 +324,7 @@ def test_auto_detect_message_when_not_explicit(self, _mock_repo, mock_post, caps
assert "auto-detected" in err.lower()
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_429_raises_quota_exhausted(self, _mock_repo, mock_post):
"""HTTP 429 → exit code 3 (quota exhausted)."""
mock_post.return_value = _mock_response(429, "quota exhausted")
@@ -175,7 +341,7 @@ def test_429_raises_quota_exhausted(self, _mock_repo, mock_post):
assert exc_info.value.exit_code == EXIT_QUOTA_EXHAUSTED
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_403_scope_raises_insufficient_scope(self, _mock_repo, mock_post, capsys):
"""HTTP 403 with scope keyword → exit code 4."""
mock_post.return_value = _mock_response(
@@ -196,7 +362,7 @@ def test_403_scope_raises_insufficient_scope(self, _mock_repo, mock_post, capsys
assert exc_info.value.exit_code == EXIT_INSUFFICIENT_SCOPE
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_403_quota_raises_quota_exhausted(self, _mock_repo, mock_post, capsys):
"""HTTP 403 with scan_mode body → exit code 3 (quota)."""
mock_post.return_value = _mock_response(
@@ -215,7 +381,7 @@ def test_403_quota_raises_quota_exhausted(self, _mock_repo, mock_post, capsys):
assert exc_info.value.exit_code == EXIT_QUOTA_EXHAUSTED
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_401_raises_general_error(self, _mock_repo, mock_post):
"""HTTP 401 → exit code 1 (general error)."""
mock_post.return_value = _mock_response(401, "invalid api key")
@@ -232,7 +398,7 @@ def test_401_raises_general_error(self, _mock_repo, mock_post):
assert exc_info.value.exit_code == EXIT_GENERAL_ERROR
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_500_raises_general_error(self, _mock_repo, mock_post):
"""HTTP 500 → exit code 1 (general error)."""
mock_post.return_value = _mock_response(500, "internal server error")
@@ -266,7 +432,7 @@ def test_detect_repo_failure_raises_general_error(self, mock_detect):
@patch("rafter_cli.commands.backend._handle_scan_status_interactive")
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_calls_status_handler_when_not_skip_interactive(
self, _mock_repo, mock_post, mock_status
):
@@ -292,7 +458,7 @@ def test_calls_status_handler_when_not_skip_interactive(
@patch("rafter_cli.commands.backend._handle_scan_status_interactive")
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_skip_interactive_does_not_call_status_handler(
self, _mock_repo, mock_post, mock_status
):
@@ -311,7 +477,7 @@ def test_skip_interactive_does_not_call_status_handler(
mock_status.assert_not_called()
@patch("rafter_cli.commands.backend.requests.post")
- @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main"))
+ @patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_sends_api_key_header(self, _mock_repo, mock_post):
"""x-api-key header is set correctly."""
mock_post.return_value = _mock_response(200, json_body={"scan_id": "s-abc"})
diff --git a/recipes/opencode.md b/recipes/opencode.md
new file mode 100644
index 00000000..44411e39
--- /dev/null
+++ b/recipes/opencode.md
@@ -0,0 +1,64 @@
+# OpenCode Setup
+
+Rafter integrates with [OpenCode](https://opencode.ai) — the terminal AI coding
+agent — through its native MCP support:
+
+**MCP server** registered in `~/.config/opencode/opencode.json` — exposes the
+`scan_secrets`, `evaluate_command`, `read_audit_log`, and `get_config` tools
+over stdio via `rafter mcp serve`.
+
+OpenCode also reads `AGENTS.md` natively as persistent project context, so a
+project-root `AGENTS.md` (shared with Codex and Windsurf) is picked up
+automatically when present.
+
+> OpenCode's MCP schema differs from Cursor/Windsurf. The config block is
+> `mcp` (not `mcpServers`), each local server carries `type: "local"`, and the
+> command + arguments are a single `command` array (not split into
+> `command`/`args`). See the
+> [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers/).
+
+## Automatic setup
+
+```sh
+# User scope — registers the MCP server in ~/.config/opencode/opencode.json:
+rafter agent init --with-opencode
+```
+
+The install auto-detects `~/.config/opencode`. Restart OpenCode afterward so it
+loads the new MCP server.
+
+## What gets installed
+
+| Path | Scope | Purpose |
+|---|---|---|
+| `~/.config/opencode/opencode.json` | user | `mcp.rafter` local/stdio server entry |
+
+Existing keys and any other MCP servers in the file are preserved. A `$schema`
+pointer is seeded on first write so editors get completion.
+
+## Manual setup
+
+Add to `~/.config/opencode/opencode.json` (global) or a project-root
+`opencode.json` (project config takes precedence):
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "rafter": {
+ "type": "local",
+ "command": ["rafter", "mcp", "serve"],
+ "enabled": true
+ }
+ }
+}
+```
+
+## Verify
+
+```sh
+rafter agent verify
+```
+
+Confirms the MCP server entry is in place. Restart OpenCode after install so it
+picks up the new server.
diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md
index 0ee0ef18..6b9d2aaa 100644
--- a/shared-docs/CLI_SPEC.md
+++ b/shared-docs/CLI_SPEC.md
@@ -70,10 +70,33 @@ Trigger a new security scan for a repository.
- `-f, --format [json|md]` — output format (default: md)
- `-m, --mode [fast|plus]` — scan mode (default: fast). Fast runs SAST, secret detection, and dependency checks. Plus adds agentic deep-dive analysis that examines your codebase the way a professional cybersecurity auditor would — tracing data flows and reasoning about business logic on top of the full SAST/SCA toolchain.
- `--github-token TEXT` — GitHub PAT for private repos (or `RAFTER_GITHUB_TOKEN` env var)
+- `--provider [gitlab|gitea|bitbucket]` — git provider for non-GitHub remotes. Default: auto-detected from the git remote host. GitHub requires nothing — leave unset. Overrides the inferred provider when set.
+- `--repo-url TEXT` — full HTTPS clone URL for non-GitHub remotes (e.g. `https://gitlab.com/owner/repo`). Default: auto-detected and normalized from the git remote. Overrides the inferred URL when set.
- `--skip-interactive` — fire-and-forget mode (don't poll for completion)
- `--quiet` — suppress status messages on stderr
- `-h, --help`
+#### Scan request body (provider fields)
+
+The scan request `POST` body always contains `repository_name`, `branch_name`, and `scan_mode` (plus `github_token` when a PAT is supplied). Two **optional, additive** fields extend it for non-GitHub remotes:
+
+| Field | Type | When sent |
+|-------|------|-----------|
+| `provider` | string | Only for non-GitHub remotes. One of `"gitlab"`, `"gitea"`, `"bitbucket"`. |
+| `repo_url` | string | Only for non-GitHub remotes. Canonical `https:////` clone URL. |
+
+**Provider inference** maps the git remote host to a provider (both `git@host:owner/repo(.git)` and `https://host/owner/repo(.git)` forms are normalized):
+
+| Remote host | Provider |
+|-------------|----------|
+| `github.com` | `github` |
+| `gitlab.com`, `*.gitlab.com` | `gitlab` |
+| `bitbucket.org` | `bitbucket` |
+| `codeberg.org`, `*.gitea.io` | `gitea` |
+| anything else | `github` (backward-compatible default) |
+
+**Backward compatibility (hard rule):** when the resolved provider is `github` — including any unrecognized host that defaults to `github` — **neither `provider` nor `repo_url` is sent.** A GitHub user's request body is byte-identical to the legacy behavior. `--provider` / `--repo-url` override the inferred values; the two fields are sent together only when the resolved provider is non-GitHub.
+
### rafter get SCAN_ID [OPTIONS]
Retrieve results from a scan.
@@ -153,6 +176,8 @@ Initialize local security system. Creates config and detects available developme
- `--with-cursor` — install Cursor integration
- `--with-windsurf` — install Windsurf integration
- `--with-continue` — install Continue.dev integration
+- `--with-hermes` — install Hermes integration
+- `--with-opencode` — install OpenCode integration
- `--with-betterleaks` — download and install Betterleaks binary (the gitleaks successor)
- `--all` — install all detected integrations and download Betterleaks
- `-i, --interactive` — guided setup — prompts for each detected integration (Node only)
@@ -809,7 +834,7 @@ Check agent security integration status. Reports whether config files, hooks, an
- `--json` — emit results as a single JSON object (one entry per check + a summary). Stable schema; intended for CI consumption.
- `--probe` — runtime probe: synthesize a known-dangerous tool-call payload, pipe it to `rafter hook pretool`, and assert the resulting `command_intercepted` entry landed in `~/.rafter/audit.jsonl`. Catches the failure mode where rafter wrote the right files but the hook command itself doesn't actually fire (rf-65zg). Currently covers Claude Code; Codex/Cursor/Gemini probes are planned follow-ups.
-**Checks (10 total, in order):**
+**Checks (12 total, in order):**
| Name | Severity | Detection | Pass criterion |
|---|---|---|---|
@@ -823,6 +848,8 @@ Check agent security integration status. Reports whether config files, hooks, an
| `Windsurf` | optional | `~/.codeium/windsurf/` exists | `mcp_config.json` `mcpServers.rafter` set |
| `Continue.dev` | optional | `~/.continue/` exists | `config.json` `mcpServers` contains rafter (array or object format) |
| `Aider` | optional | `/.aider.conf.yml` or `~/.aider.conf.yml` exists | `read:` list includes `RAFTER.md` AND `RAFTER.md` exists on disk |
+| `Hermes` | optional | `~/.hermes/` exists | `config.yaml` `mcp_servers.rafter` set |
+| `OpenCode` | optional | `~/.config/opencode/` exists | `opencode.json` `mcp.rafter` set |
With `--probe`, an additional `Claude Code (probe)` check appears as the last entry.