From 582ee46497d4adf4a2261afbff4548895e109c25 Mon Sep 17 00:00:00 2001 From: Minh_Nguyen Date: Wed, 1 Jul 2026 01:02:24 +0700 Subject: [PATCH 1/5] feat(scan): add DigitalOcean Personal Access Token secret pattern (#189) Adds `dop_v1_[a-f0-9]{64}` (severity critical) to the built-in regex scanner in both Node and Python, with tests in each suite. Closes #26. Co-authored-by: Minh_Nguyen --- CHANGELOG.md | 4 ++++ node/src/scanners/secret-patterns.ts | 8 ++++++++ node/tests/secret-patterns.test.ts | 8 ++++++++ python/rafter_cli/scanners/secret_patterns.py | 7 +++++++ python/tests/test_regex_scanner.py | 4 ++++ 5 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f04209..2067331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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/node/src/scanners/secret-patterns.ts b/node/src/scanners/secret-patterns.ts index b576c75..d61219b 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/tests/secret-patterns.test.ts b/node/tests/secret-patterns.test.ts index aedbf94..d6c17cb 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/rafter_cli/scanners/secret_patterns.py b/python/rafter_cli/scanners/secret_patterns.py index d7f6622..06b3e10 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/tests/test_regex_scanner.py b/python/tests/test_regex_scanner.py index 2910d66..cdffbd4 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") From 00015d2f07f018ad5f398b2639f85e8b7840bd18 Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Fri, 3 Jul 2026 10:13:00 +0000 Subject: [PATCH 2/5] feat(agent): add native OpenCode MCP integration (sable-l8e5) Add OpenCode as an MCP-based platform adapter in both the Node and Python implementations, mirroring the existing Cursor/Windsurf/Hermes pattern. OpenCode supports local/stdio MCP servers and reads AGENTS.md natively. Its config 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. Registered at ~/.config/opencode/opencode.json with a seeded `$schema` pointer. Verified against https://opencode.ai/docs/mcp-servers/. - Node: installOpenCodeMcp() + --with-opencode flag + auto-detection (~/.config/opencode), dry-run plan, interactive prompt, warnings, next-steps, help hints, and the openCodeMcp() component-registry spec. Wired into status.ts (mcpAgents + detectAgents) and verify.ts (checkOpenCode). - Python: full parity in agent.py and agent_components.py. - recipes/opencode.md, README (platform table, badge, prose lists), and CLI_SPEC.md (--with flag, verify checks table). - Tests: node/tests/agent-init-opencode.test.ts plus mirrored TestInstallOpenCodeMcp / TestOpenCodeDetection in Python; registry ID lists updated (also backfilled the missing hermes.mcp entry). OpenClaw (a separate product) is untouched. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- node/src/commands/agent/components.ts | 46 ++++ node/src/commands/agent/init.ts | 87 ++++++- node/src/commands/agent/status.ts | 2 + node/src/commands/agent/verify.ts | 32 +++ node/tests/agent-components.test.ts | 2 + node/tests/agent-init-opencode.test.ts | 232 ++++++++++++++++++ python/rafter_cli/commands/agent.py | 119 ++++++++- .../rafter_cli/commands/agent_components.py | 61 +++++ python/tests/test_agent_components.py | 2 + python/tests/test_agent_init.py | 133 ++++++++++ recipes/opencode.md | 64 +++++ shared-docs/CLI_SPEC.md | 6 +- 13 files changed, 788 insertions(+), 6 deletions(-) create mode 100644 node/tests/agent-init-opencode.test.ts create mode 100644 recipes/opencode.md diff --git a/README.md b/README.md index b47073d..05ef874 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Continue.dev supported Aider supported Hermes supported + OpenCode supported

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/src/commands/agent/components.ts b/node/src/commands/agent/components.ts index aecf13a..5d2fd3c 100644 --- a/node/src/commands/agent/components.ts +++ b/node/src/commands/agent/components.ts @@ -938,6 +938,51 @@ 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 }); + const cfg: Record = fs.existsSync(configPath) ? readJson(configPath) : {}; + 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 +1124,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 24c61c2..09b93fb 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,55 @@ 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 { + config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + } 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 +1143,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 +1185,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 +1213,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 +1237,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 +1253,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 +1273,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 +1293,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 +1585,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 +1613,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 +1625,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 +1645,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 954b7e7..57bf263 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 8853c8e..10fee20 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/tests/agent-components.test.ts b/node/tests/agent-components.test.ts index 5a35476..090b1be 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 0000000..1ad7883 --- /dev/null +++ b/node/tests/agent-init-opencode.test.ts @@ -0,0 +1,232 @@ +/** + * 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("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/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index cd65f55..44ad216 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 a781aa6..4a0d3c6 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,57 @@ 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: + mcp = _read_json(config_path).get("mcp") + 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) + 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(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 +1116,7 @@ def get_registry() -> list[ComponentSpec]: _continue_mcp(), _aider_read(), _hermes_mcp(), + _opencode_mcp(), _openclaw_skill(), ] return _REGISTRY diff --git a/python/tests/test_agent_components.py b/python/tests/test_agent_components.py index b06d1f6..9c6ec02 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 ac9b0e5..bcae5de 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,138 @@ 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"] + + +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() + + class TestInstallClaudeCodeHooks: def test_creates_settings_from_scratch(self, tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) diff --git a/recipes/opencode.md b/recipes/opencode.md new file mode 100644 index 0000000..44411e3 --- /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 0ee0ef1..9b2d9dc 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -153,6 +153,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 +811,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 +825,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. From e52e827aed6128473cd99149162e85ba81f928ce Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Fri, 3 Jul 2026 10:24:07 +0000 Subject: [PATCH 3/5] fix(agent): guard OpenCode config merge against non-object top-level JSON Rafter security review flagged a low-severity parity/robustness gap: when an existing ~/.config/opencode/opencode.json is valid JSON but the top level is not an object (an array, string, or number), the Node init path would assign properties onto it and silently mangle the user's file. Python's init already guarded this with isinstance(loaded, dict). - Node installOpenCodeMcp: only accept a parsed object (else warn + replace), mirroring the Python guard. - Node/Python component openCodeMcp/_opencode_mcp: coerce a non-object read result to {} in install(), and guard is_installed()/uninstall() so a non-dict top-level can't raise. - Regression tests for array/string top-level configs in both suites. Co-Authored-By: Claude Opus 4.8 --- node/src/commands/agent/components.ts | 4 ++- node/src/commands/agent/init.ts | 10 +++++- node/tests/agent-init-opencode.test.ts | 26 ++++++++++++++ .../rafter_cli/commands/agent_components.py | 8 +++-- python/tests/test_agent_init.py | 35 +++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/node/src/commands/agent/components.ts b/node/src/commands/agent/components.ts index 5d2fd3c..969b8e9 100644 --- a/node/src/commands/agent/components.ts +++ b/node/src/commands/agent/components.ts @@ -969,7 +969,9 @@ function openCodeMcp(): ComponentSpec { install: () => { const dir = path.join(home, ".config", "opencode"); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const cfg: Record = fs.existsSync(configPath) ? readJson(configPath) : {}; + 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 }; diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index 09b93fb..0b85eb4 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -933,7 +933,15 @@ function installOpenCodeMcp(root: string): boolean { let config: Record = {}; if (fs.existsSync(configPath)) { try { - config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + 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")); } diff --git a/node/tests/agent-init-opencode.test.ts b/node/tests/agent-init-opencode.test.ts index 1ad7883..8ddd1d8 100644 --- a/node/tests/agent-init-opencode.test.ts +++ b/node/tests/agent-init-opencode.test.ts @@ -143,6 +143,32 @@ describe("agent init --with-opencode", () => { 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 }); diff --git a/python/rafter_cli/commands/agent_components.py b/python/rafter_cli/commands/agent_components.py index 4a0d3c6..acb2382 100644 --- a/python/rafter_cli/commands/agent_components.py +++ b/python/rafter_cli/commands/agent_components.py @@ -920,12 +920,16 @@ def _opencode_mcp() -> ComponentSpec: config_path = detect_dir / "opencode.json" def is_installed() -> bool: - mcp = _read_json(config_path).get("mcp") + 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") @@ -939,7 +943,7 @@ def uninstall() -> None: if not config_path.exists(): return cfg = _read_json(config_path) - mcp = cfg.get("mcp") + 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) diff --git a/python/tests/test_agent_init.py b/python/tests/test_agent_init.py index bcae5de..1f1e53b 100644 --- a/python/tests/test_agent_init.py +++ b/python/tests/test_agent_init.py @@ -218,6 +218,20 @@ def test_replaces_array_shape_mcp(self, tmp_path, monkeypatch): 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.""" @@ -281,6 +295,27 @@ def test_opencode_mcp_component_install_uninstall(self, tmp_path, monkeypatch): 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): From 605f2cd6c794e01fa663be132d39948a18637a7a Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Tue, 7 Jul 2026 05:45:34 +0000 Subject: [PATCH 4/5] feat(scan): additive non-GitHub provider support for remote scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional multi-provider support to `rafter run` / `rafter scan` in both the Node and Python implementations. The scan POST body additively gains two OPTIONAL fields — `provider` (gitlab|gitea|bitbucket) and `repo_url` (canonical https clone URL) — sent ONLY for non-github remotes. GitHub requests are byte-identical to today (no provider, no repo_url). - git utils: infer provider from the remote host and normalize both `git@host:owner/repo(.git)` and `https://host/owner/repo(.git)` into a canonical `https:////` repo_url. Host map: github.com→github; gitlab.com / *.gitlab.com→gitlab; bitbucket.org→bitbucket; codeberg.org / *.gitea.io→gitea; anything else→github (backward-compat default). parseRemote/parse_remote return value is left intact for existing callers; detectRepo/detect_repo additively surface provider + repo_url. - backend run + scan command: add optional `--provider` / `--repo-url` flags that override inferred values. Body builder omits both fields whenever the resolved provider is github (or an unknown host that defaulted to github). github_token handling unchanged. - CLI_SPEC.md: document the two flags, the two additive body fields, the host→provider inference table, and the backward-compat rule. - Tests (Node + Python): provider inference per host and both URL forms; github remote produces a body with NO provider/repo_url (byte-identical shape); gitlab/bitbucket/gitea remotes produce the correct provider + normalized repo_url; --provider/--repo-url overrides win. No version bump. No lockfile or .rafter.yml changes. Co-Authored-By: Claude Opus 4.8 --- node/src/commands/backend/run.ts | 23 +- node/src/commands/scan/index.ts | 4 + node/src/utils/git.ts | 73 +++++- node/tests/git-utils.test.ts | 109 ++++++++- node/tests/scan-remote.test.ts | 220 ++++++++++++++++++ python/rafter_cli/commands/backend.py | 19 +- .../rafter_cli/commands/issues/issues_app.py | 4 +- python/rafter_cli/commands/scan.py | 12 +- python/rafter_cli/utils/git.py | 65 +++++- python/tests/test_api_scope.py | 12 +- python/tests/test_git_utils.py | 135 ++++++++++- python/tests/test_scan_remote.py | 194 +++++++++++++-- shared-docs/CLI_SPEC.md | 23 ++ 13 files changed, 846 insertions(+), 47 deletions(-) diff --git a/node/src/commands/backend/run.ts b/node/src/commands/backend/run.ts index e94e6c8..62e435f 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 2199581..3dcf071 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/utils/git.ts b/node/src/utils/git.ts index 6fbee43..99800b9 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/git-utils.test.ts b/node/tests/git-utils.test.ts index f630d64..5bf1ef6 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 4ee0cbb..709ff2b 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/python/rafter_cli/commands/backend.py b/python/rafter_cli/commands/backend.py index ae34d84..b82f1ab 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 05269c7..638a917 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 ba83c12..5979e46 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/utils/git.py b/python/rafter_cli/utils/git.py index b0f692c..4d7998d 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_api_scope.py b/python/tests/test_api_scope.py index ea9a3a3..6089745 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 589f00b..099b148 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_scan_remote.py b/python/tests/test_scan_remote.py index 3a1effe..1c5091f 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/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 0ee0ef1..a60f8e0 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. From 6f466dd1991779325a3a942ce7cdac8f082f586d Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Wed, 8 Jul 2026 00:33:15 +0000 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v0.9.0=20=E2=80=94=20native=20Op?= =?UTF-8?q?enCode=20support=20+=20multi-provider=20remote=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ node/package.json | 2 +- node/resources/rafter-security-skill.md | 2 +- python/pyproject.toml | 2 +- python/rafter_cli/resources/rafter-security-skill.md | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2067331..9349ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ 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 diff --git a/node/package.json b/node/package.json index 7df888d..ad2f41e 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 7934944..16aaa43 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/python/pyproject.toml b/python/pyproject.toml index 99654fd..6756bc2 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/resources/rafter-security-skill.md b/python/rafter_cli/resources/rafter-security-skill.md index 7934944..16aaa43 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: