Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<a href="https://secfoo.com/docs/index.html">Docs</a>
</p>

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.

Expand Down
2 changes: 1 addition & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/secfoo/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
67 changes: 67 additions & 0 deletions src/secfoo/agents/opencode.py
Original file line number Diff line number Diff line change
@@ -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),
)
2 changes: 2 additions & 0 deletions src/secfoo/agents/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +22,7 @@
CodexAdapter.name: CodexAdapter,
DroidAdapter.name: DroidAdapter,
DevinAdapter.name: DevinAdapter,
OpenCodeAdapter.name: OpenCodeAdapter,
}


Expand Down
1 change: 1 addition & 0 deletions src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class AgentId(str, Enum):
CODEX = "codex"
DROID = "droid"
DEVIN = "devin"
OPENCODE = "opencode"


class DepthId(str, Enum):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agents/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
64 changes: 64 additions & 0 deletions tests/test_agents/test_opencode.py
Original file line number Diff line number Diff line change
@@ -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
Loading