feat(engine): add opencode CLI review engine #26

Merged
steve merged 4 commits from feat/opencode-engine into main 2026-07-18 05:51:51 +00:00
4 changed files with 106 additions and 79 deletions
Showing only changes of commit 2477e50230 - Show all commits
+10 -7
View File
@@ -176,13 +176,16 @@ secret in config). Tuning knobs (all optional):
| `GADFLY_OPENCODE_BIN` | `opencode` | CLI binary path |
> **Read-only is enforced through config, not a flag.** OpenCode has no `--append-system-prompt`, so
> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with
> `edit`/`bash` denied at both the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`.
> That env var is the highest-precedence config source in the container, so it **outranks any
> `opencode.json` a reviewed repo ships** — a repo can't re-enable edits on the reviewer. The
> subprocess runs with a **minimal environment** (`OLLAMA_API_KEY` + `PATH`/`HOME`/locale/`OPENCODE_*`/
> `GADFLY_OPENCODE_*`), not the runner's full env; the Gitea token, Anthropic/Claude keys, and findings
> token aren't handed to the CLI.
> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with the
> mutating and network tools (`edit`/`bash`/`webfetch`/`websearch`/`external_directory`) denied at both
> the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`. That env var is the
> highest-precedence config source in the container, so it **outranks any `opencode.json` a reviewed
> repo ships** — a repo can't re-enable edits on the reviewer. The subprocess runs with a **reduced
> environment**: the provider keys OpenCode needs to authenticate (`OLLAMA_API_KEY` for the primary
> path, plus `ANTHROPIC_*`/`OPENAI_*`/`GOOGLE_*`/`GEMINI_*` for the `opencode/<provider>/<model>`
> pass-through) alongside `PATH`/`HOME`/locale/`OPENCODE_*`/`GADFLY_OPENCODE_*` — but **not** gadfly's
> own secrets (the Gitea token, the findings token, or the claude-code subscription token), which the
> CLI has no use for.
> **Newly wired, lightly tested.** Like the claude-code engine, `auto` specialist selection and the
> `delegate_investigation` worker are majordomo-only and are skipped here (OpenCode does its own
+29 -19
View File
@@ -160,16 +160,7 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
// Force an extended-thinking budget for this run (a "...:max" spec).
cmd.Env = append(cmd.Env, "MAX_THINKING_TOKENS="+strconv.Itoa(e.thinkingTokens))
}
// Put the CLI and the Node children it spawns in their own process group and
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
// orphaned claude/node processes behind in the container.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
killGroupOnCancel(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -220,13 +211,39 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
return "", fmt.Errorf("claude -p produced no parseable output")
}
// killGroupOnCancel puts cmd in its own process group and, on context cancel,
// SIGKILLs the whole group — so a timed-out shell-out CLI (claude/opencode) can't
// leave orphaned Node children behind in the container. Call before cmd.Run.
func killGroupOnCancel(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
// filterEnv returns the current process environment reduced to the variables for
// which keep returns true. Shared by the shell-out engines' minimal-env builders
// (claudeEnv, openCodeEnv), which differ only in their keep predicate.
func filterEnv(keep func(string) bool) []string {
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
}
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
// the CLI needs (PATH/HOME, its auth tokens, locale, Node/XDG/GADFLY_CLAUDE_*
// knobs), deliberately dropping the rest of the runner's secrets — GITEA_TOKEN,
// GADFLY_FINDINGS_TOKEN, provider keys — so they never reach the third-party
// CLI. Defense in depth: the parent already holds them, but the CLI has no need.
func claudeEnv() []string {
keep := func(k string) bool {
return filterEnv(func(k string) bool {
switch k {
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
return true
@@ -237,14 +254,7 @@ func claudeEnv() []string {
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_")
}
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
})
}
// truncateForErr caps CLI error detail so a stderr dump can't bloat the comment,
+50 -42
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"strings"
"syscall"
)
// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in
@@ -35,16 +34,17 @@ type openCodeEngine struct {
extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS)
}
// openCodeProviderName / openCodeAgentName are the internal names of the provider
// and agent gadfly generates in the injected config. "gadfly" won't collide with
Outdated
Review

const block groups unrelated constants; doc comment visually misattaches to wrong const

maintainability · flagged by 1 model

  • cmd/gadfly/opencode.go:38-46 — const block groups unrelated constants; doc comment visually misattaches to the wrong const. The single const block lumps the paired name constants (openCodeProviderName/openCodeAgentName, both "gadfly") together with the unrelated defaultOpenCodeBaseURL. Because openCodeAgentName = "gadfly" is shorter than the others, gofmt aligns it under a different column, so the // defaultOpenCodeBaseURL is ollama-cloud's… comment on line 44 reads as if i…

🪰 Gadfly · advisory

⚪ **const block groups unrelated constants; doc comment visually misattaches to wrong const** _maintainability · flagged by 1 model_ - **`cmd/gadfly/opencode.go:38-46` — const block groups unrelated constants; doc comment visually misattaches to the wrong const.** The single `const` block lumps the paired name constants (`openCodeProviderName`/`openCodeAgentName`, both `"gadfly"`) together with the unrelated `defaultOpenCodeBaseURL`. Because `openCodeAgentName = "gadfly"` is shorter than the others, gofmt aligns it under a different column, so the `// defaultOpenCodeBaseURL is ollama-cloud's…` comment on line 44 reads as if i… <sub>🪰 Gadfly · advisory</sub>
// OpenCode's models.dev provider registry.
const (
// openCodeProviderName / openCodeAgentName are the internal names of the
// provider and agent gadfly generates in the injected config. "gadfly" won't
// collide with OpenCode's models.dev provider registry.
openCodeProviderName = "gadfly"
openCodeAgentName = "gadfly"
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
defaultOpenCodeBaseURL = "https://ollama.com/v1"
)
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
const defaultOpenCodeBaseURL = "https://ollama.com/v1"
// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine:
// the bare id "opencode"/"open-code" or an "opencode/<model>" form (both
// spellings accepted; "opencode" is canonical).
@@ -122,9 +122,24 @@ type openCodeConfig struct {
Agent map[string]openCodeAgent `json:"agent"`
}
type openCodePermission struct {
Edit string `json:"edit"`
Bash string `json:"bash"`
// openCodePermission is OpenCode's permission map: tool key → "allow"|"ask"|"deny".
type openCodePermission map[string]string
// denyMutations denies every OpenCode permission that could change the repo, run
// commands, or reach the network / the filesystem outside the checked-out tree —
// keeping the reviewer strictly read-only. The read/search tools OpenCode gates
// separately (read/glob/grep/list/lsp) stay at their default so the agent can
// still verify findings against the code. OpenCode's permission keys are
// enumerated at https://opencode.ai/docs/agents; a key it doesn't recognize is
// simply ignored, so listing extras is safe.
func denyMutations() openCodePermission {
return openCodePermission{
"edit": "deny",
"bash": "deny",
"webfetch": "deny",
"websearch": "deny",
"external_directory": "deny",
}
}
type openCodeProvider struct {
1
@@ -147,13 +162,13 @@ type openCodeAgent struct {
}
// config builds the OpenCode config JSON for one pass. The system prompt becomes
// the "gadfly" agent's prompt; edit/bash are denied at BOTH the global and agent
// level (defense in depth — OpenCode's read-only tools stay available). For a
// bare or pass-through spec the generated ollama-cloud provider block is omitted.
// The API key is always the "{env:OLLAMA_API_KEY}" reference, never a literal
// secret baked into the config.
// the "gadfly" agent's prompt; the mutating/network tools are denied at BOTH the
// global and agent level (defense in depth — OpenCode's read tools stay
// available). For a bare or pass-through spec the generated ollama-cloud provider
// block is omitted. The API key is always the "{env:OLLAMA_API_KEY}" reference,
// never a literal secret baked into the config.
func (e *openCodeEngine) config(system string) ([]byte, error) {
deny := openCodePermission{Edit: "deny", Bash: "deny"}
deny := denyMutations()
cfg := openCodeConfig{
Schema: "https://opencode.ai/config.json",
Permission: deny,
3
@@ -194,16 +209,7 @@ func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int
// outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the
// captured stdout free of ANSI decoration.
cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1")
// Put the CLI and the Node children it spawns in their own process group and
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
// orphaned opencode/node processes behind in the container.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
killGroupOnCancel(cmd) // don't orphan the CLI's Node children on a timed-out lens
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
1
@@ -233,15 +239,20 @@ func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int
return "", fmt.Errorf("opencode run returned no output")
}
// openCodeEnv builds a minimal environment for the `opencode` subprocess: only
// what the CLI needs (PATH/HOME, locale, Node/XDG/OPENCODE_*/GADFLY_OPENCODE_*
// knobs) plus OLLAMA_API_KEY — which the generated provider references and which
// claudeEnv deliberately DROPS. The runner's other secrets (GITEA_TOKEN,
// GADFLY_FINDINGS_TOKEN, Anthropic/Claude keys) are withheld — the CLI has no need
// for them. OPENCODE_CONFIG_CONTENT is never inherited: runPass sets it, and a
// duplicate key would be ambiguous (getenv returns the first occurrence).
// openCodeEnv builds a minimal environment for the `opencode` subprocess. It
// forwards what the CLI needs to reach a model provider: OLLAMA_API_KEY for the
Outdated
Review

🟠 openCodeEnv() strips provider API keys (e.g. ANTHROPIC_API_KEY), breaking the documented opencode// pass-through escape hatch

error-handling, maintainability · flagged by 3 models

  • Documented opencode/<provider>/<model> escape hatch can't actually authenticate for its own example. README.md:160 documents opencode/anthropic/claude-sonnet-4-6 as a working pass-through that uses "OpenCode's own provider registry/auth." But openCodeEnv() (cmd/gadfly/opencode.go:243-264) only keeps PATH/HOME/USER/LOGNAME/TMPDIR/LANG/TERM/SHELL/OLLAMA_API_KEY plus LC_/OPENCODE_/GADFLY_OPENCODE_/NODE_/XDG_ prefixes — ANTHROPIC_API_KEY (and every other provider key) is stripped f…

🪰 Gadfly · advisory

🟠 **openCodeEnv() strips provider API keys (e.g. ANTHROPIC_API_KEY), breaking the documented opencode/<provider>/<model> pass-through escape hatch** _error-handling, maintainability · flagged by 3 models_ - **Documented `opencode/<provider>/<model>` escape hatch can't actually authenticate for its own example.** README.md:160 documents `opencode/anthropic/claude-sonnet-4-6` as a working pass-through that uses "OpenCode's own provider registry/auth." But `openCodeEnv()` (cmd/gadfly/opencode.go:243-264) only keeps `PATH/HOME/USER/LOGNAME/TMPDIR/LANG/TERM/SHELL/OLLAMA_API_KEY` plus `LC_/OPENCODE_/GADFLY_OPENCODE_/NODE_/XDG_` prefixes — `ANTHROPIC_API_KEY` (and every other provider key) is stripped f… <sub>🪰 Gadfly · advisory</sub>
// generated ollama-cloud provider (the primary opencode/<model> path), PLUS the
// standard provider keys — ANTHROPIC_*, OPENAI_*, GOOGLE_*, GEMINI_* — so the
// opencode/<provider>/<model> pass-through form can authenticate against
// OpenCode's own built-in providers (those keys are otherwise stripped, which
// broke the documented escape hatch). It still withholds gadfly's OWN secrets —
// GITEA_TOKEN, GADFLY_API_KEY, GADFLY_FINDINGS_TOKEN, and the claude-code
Outdated
Review

🔴 openCodeEnv drops ANTHROPIC_API_KEY/OPENAI_API_KEY, breaking the documented opencode// pass-through escape hatch (e.g. opencode/anthropic/...) which relies on OpenCode's built-in providers for auth

correctness · flagged by 2 models

🪰 Gadfly · advisory

🔴 **openCodeEnv drops ANTHROPIC_API_KEY/OPENAI_API_KEY, breaking the documented opencode/<provider>/<model> pass-through escape hatch (e.g. opencode/anthropic/...) which relies on OpenCode's built-in providers for auth** _correctness · flagged by 2 models_ <sub>🪰 Gadfly · advisory</sub>
// subscription token (CLAUDE_CODE_OAUTH_TOKEN, which OpenCode can't use anyway) —
// so they never reach the third-party CLI. OPENCODE_CONFIG_CONTENT is never
// inherited: runPass sets it, and a duplicate key would be ambiguous (getenv
// returns the first occurrence).
func openCodeEnv() []string {
keep := func(k string) bool {
return filterEnv(func(k string) bool {
if k == "OPENCODE_CONFIG_CONTENT" {
return false // set explicitly by runPass; never inherit a competing value
}
@@ -253,13 +264,10 @@ func openCodeEnv() []string {
strings.HasPrefix(k, "OPENCODE_") ||
strings.HasPrefix(k, "GADFLY_OPENCODE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_")
}
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
strings.HasPrefix(k, "XDG_") ||
strings.HasPrefix(k, "ANTHROPIC_") ||
strings.HasPrefix(k, "OPENAI_") ||
strings.HasPrefix(k, "GOOGLE_") ||
strings.HasPrefix(k, "GEMINI_")
})
}
+17 -11
View File
@@ -138,12 +138,15 @@ func TestOpenCodeConfig(t *testing.T) {
if ag.Prompt != sys {
t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt)
}
if ag.Permission.Edit != "deny" || ag.Permission.Bash != "deny" {
t.Errorf("agent permission = %+v, want edit/bash deny", ag.Permission)
}
// Global permission also denies (defense in depth).
if cfg.Permission.Edit != "deny" || cfg.Permission.Bash != "deny" {
t.Errorf("global permission = %+v, want edit/bash deny", cfg.Permission)
// Mutating/network tools are denied at BOTH the agent and global level (defense
// in depth); the read/search tools stay at OpenCode's default.
for _, k := range []string{"edit", "bash", "webfetch", "websearch", "external_directory"} {
if ag.Permission[k] != "deny" {
t.Errorf("agent permission[%q] = %q, want deny", k, ag.Permission[k])
}
if cfg.Permission[k] != "deny" {
t.Errorf("global permission[%q] = %q, want deny", k, cfg.Permission[k])
}
}
// Provider block: correct npm, default baseURL, env-ref apiKey, model in map.
@@ -199,7 +202,8 @@ func TestOpenCodeEnvFilters(t *testing.T) {
t.Setenv("OLLAMA_API_KEY", "keep-ollama")
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
t.Setenv("ANTHROPIC_API_KEY", "secret-anthropic")
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic") // pass-through provider auth
t.Setenv("OPENAI_API_KEY", "keep-openai") // pass-through provider auth
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude")
t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob")
t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit")
@@ -213,14 +217,16 @@ func TestOpenCodeEnvFilters(t *testing.T) {
}
return false
}
// kept: the ollama key the provider references + opencode knobs + PATH
for _, k := range []string{"OLLAMA_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
// kept: the ollama key + the standard provider keys the opencode/<provider>/<model>
// pass-through form needs + opencode knobs + PATH
for _, k := range []string{"OLLAMA_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
if !has(k) {
t.Errorf("openCodeEnv dropped %s, but it should be kept", k)
}
}
// dropped: the runner's other secrets + the claude engine's auth
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"} {
// dropped: gadfly's own secrets + the claude engine's subscription token
// (OpenCode's anthropic provider uses ANTHROPIC_API_KEY, not this OAuth token).
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} {
if has(k) {
t.Errorf("openCodeEnv leaked %s into the subprocess env", k)
}