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