diff --git a/README.md b/README.md index e1331ac..e7fa3ca 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Docs

-Point Claude Code, Cursor, Antigravity, Gemini CLI, or Codex at a target. +Point Claude Code, Cursor, Antigravity, Gemini CLI, Codex, or OpenCode at a target. Pick architecture review, threat modeling, SAST, and the rest of the catalog. Browse every result in a local dashboard — `secfoo serve`, on your machine. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4dc7f6f..5ca53cf 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -501,7 +501,7 @@ full reference, including remote HTTP/SSE servers. that invocation without changing any CLI global settings. Use the registered agent ID (`agent` for Cursor, `agy` for Antigravity). Model IDs must be non-empty strings. Claude, Codex, Gemini, Copilot and Cursor receive `--model`; -Droid receives `-m`. Antigravity and Devin currently warn and ignore model +Droid and OpenCode receive `-m`. Antigravity and Devin currently warn and ignore model selection because no supported model flag is established for these adapters. The `api` agent uses the selected model ahead of `SECFOO_API_MODEL`, then its existing built-in default. With no selection, CLI commands remain unchanged. diff --git a/src/secfoo/agents/base.py b/src/secfoo/agents/base.py index 3a123d1..f85499a 100644 --- a/src/secfoo/agents/base.py +++ b/src/secfoo/agents/base.py @@ -95,6 +95,7 @@ class AgentAdapter(ABC): # line one. Only enable this for CLIs whose non-interactive mode # documents reading the prompt from stdin. prompt_via_stdin: ClassVar[bool] = False + extra_env: ClassVar[dict[str, str]] = {} model: str | None = None model_flag: ClassVar[str | None] = None @@ -195,6 +196,7 @@ def run(self, prompt: str, *, workdir: Path, timeout: int | None = None, prev_co encoding="utf-8", errors="replace", start_new_session=True, + **({"env": {**os.environ, **self.extra_env}} if self.extra_env else {}), ) except OSError as exc: return AgentResult( diff --git a/src/secfoo/agents/opencode.py b/src/secfoo/agents/opencode.py new file mode 100644 index 0000000..8adc1ba --- /dev/null +++ b/src/secfoo/agents/opencode.py @@ -0,0 +1,67 @@ +"""OpenCode CLI adapter, tested against opencode 1.18.34. + +The non-interactive `run` command reads the prompt from stdin and rejects +permission requests without --auto. Read-only permissions are configured via +OPENCODE_CONFIG_CONTENT and OPENCODE_PERMISSION; --pure disables external plugins. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import ClassVar + +from secfoo.agents.base import AgentAdapter, Usage + + +def _events(stdout: str): + for line in stdout.splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict) and isinstance(event.get("part"), dict): + yield event + + +class OpenCodeAdapter(AgentAdapter): + name = "opencode" + binary = "opencode" + prompt_via_stdin = True + model_flag = "-m" + extra_env: ClassVar[dict[str, str]] = { + "OPENCODE_CONFIG_CONTENT": json.dumps( + {"permission": {"bash": "deny", "edit": "deny", "webfetch": "deny"}} + ), + "OPENCODE_PERMISSION": json.dumps({"bash": "deny", "edit": "deny", "webfetch": "deny"}), + } + + def build_command(self, prompt: str, *, workdir: Path) -> list[str]: + return [ + self.binary, "run", "--pure", "--format", "json", + "--dir", str(workdir), *self.model_arguments(), + ] + + def extract_report(self, stdout: str) -> str: + parts = [ + event["part"]["text"] + for event in _events(stdout) + if event.get("type") == "text" and isinstance(event["part"].get("text"), str) + ] + return "\n".join(parts) if parts else stdout + + def extract_usage(self, stdout: str) -> Usage: + steps = [event["part"] for event in _events(stdout) if event.get("type") == "step_finish"] + if not steps: + return Usage() + + def total(values): + numbers = [value for value in values if isinstance(value, (int, float)) and not isinstance(value, bool)] + return sum(numbers) if numbers else None + + tokens = [part.get("tokens") if isinstance(part.get("tokens"), dict) else {} for part in steps] + return Usage( + input_tokens=total(token.get("input") for token in tokens), + output_tokens=total(token.get("output") for token in tokens), + cost_usd=total(part.get("cost") for part in steps), + ) diff --git a/src/secfoo/agents/registry.py b/src/secfoo/agents/registry.py index e7b6a29..9303107 100644 --- a/src/secfoo/agents/registry.py +++ b/src/secfoo/agents/registry.py @@ -10,6 +10,7 @@ from secfoo.agents.devin import DevinAdapter from secfoo.agents.droid import DroidAdapter from secfoo.agents.gemini import GeminiAdapter +from secfoo.agents.opencode import OpenCodeAdapter ADAPTERS: dict[str, type[AgentAdapter]] = { ClaudeAdapter.name: ClaudeAdapter, @@ -21,6 +22,7 @@ CodexAdapter.name: CodexAdapter, DroidAdapter.name: DroidAdapter, DevinAdapter.name: DevinAdapter, + OpenCodeAdapter.name: OpenCodeAdapter, } diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index d4fcf61..bd0b9b7 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -106,6 +106,7 @@ class AgentId(str, Enum): CODEX = "codex" DROID = "droid" DEVIN = "devin" + OPENCODE = "opencode" class DepthId(str, Enum): diff --git a/tests/test_agents/test_models.py b/tests/test_agents/test_models.py index e9f7e35..f173944 100644 --- a/tests/test_agents/test_models.py +++ b/tests/test_agents/test_models.py @@ -7,7 +7,7 @@ MODEL_FLAGS = { "claude": "--model", "codex": "--model", "gemini": "--model", - "copilot": "--model", "agent": "--model", "droid": "-m", + "copilot": "--model", "agent": "--model", "droid": "-m", "opencode": "-m", } diff --git a/tests/test_agents/test_opencode.py b/tests/test_agents/test_opencode.py new file mode 100644 index 0000000..4cabc24 --- /dev/null +++ b/tests/test_agents/test_opencode.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +import subprocess + +from secfoo.agents import ADAPTERS, get_adapter +from secfoo.agents.codex import CodexAdapter +from secfoo.agents.opencode import OpenCodeAdapter +from secfoo.cli import AgentId + + +def test_registered_for_cli(): + assert ADAPTERS["opencode"] is OpenCodeAdapter + assert isinstance(get_adapter("opencode"), OpenCodeAdapter) + assert AgentId("opencode") is AgentId.OPENCODE + + +def test_build_command_is_noninteractive_and_does_not_auto_approve(tmp_path): + command = OpenCodeAdapter().build_command("review", workdir=tmp_path) + assert command == ["opencode", "run", "--pure", "--format", "json", "--dir", str(tmp_path)] + assert "--auto" not in command + + +def test_run_sets_permissions_and_pipes_prompt(fake_popen, tmp_path, monkeypatch): + monkeypatch.setenv("SECFOO_TEST_INHERITED", "present") + monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", '{"permission":{"bash":"allow"}}') + monkeypatch.setenv("OPENCODE_PERMISSION", '{"bash":"allow"}') + fake = fake_popen(stdout='{"type":"text","part":{"text":"report"}}', returncode=0) + result = OpenCodeAdapter().run("review\nfiles", workdir=tmp_path) + assert result.raw_report == "report" + assert fake.call_kwargs["stdin"] == subprocess.PIPE + assert fake.stdin_input == "review\nfiles" + env = fake.call_kwargs["env"] + assert env["SECFOO_TEST_INHERITED"] == "present" + assert json.loads(env["OPENCODE_CONFIG_CONTENT"]) == { + "permission": {"bash": "deny", "edit": "deny", "webfetch": "deny"} + } + assert json.loads(env["OPENCODE_PERMISSION"]) == {"bash": "deny", "edit": "deny", "webfetch": "deny"} + + +def test_other_adapters_still_inherit_environment_by_default(fake_popen, tmp_path): + fake = fake_popen(stdout="report", returncode=0) + CodexAdapter().run("review", workdir=tmp_path) + assert "env" not in fake.call_kwargs + + +def test_json_events_extract_report_and_accumulate_usage(fake_popen, tmp_path): + stdout = "\n".join([ + json.dumps({"type": "text", "part": {"text": "First"}}), + json.dumps({"type": "step_finish", "part": {"tokens": {"input": 12, "output": 3}, "cost": 0.1}}), + json.dumps({"type": "text", "part": {"text": "Second"}}), + json.dumps({"type": "step_finish", "part": {"tokens": {"input": 8, "output": 2}, "cost": 0.2}}), + ]) + fake_popen(stdout=stdout, returncode=0) + result = OpenCodeAdapter().run("review", workdir=tmp_path) + assert result.raw_report == "First\nSecond" + assert (result.input_tokens, result.output_tokens) == (20, 5) + assert abs(result.cost_usd - 0.3) < 1e-9 + + +def test_unrecognized_output_is_preserved_and_usage_unknown(): + adapter = OpenCodeAdapter() + assert adapter.extract_report("not json") == "not json" + assert adapter.extract_usage("not json").input_tokens is None