opencode

package
v0.18.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package opencode is the headless code-authoring adapter for mallcop's self-extension loop. It drives the opencode CLI (MIT, github.com/sst/opencode) HEADLESS to author a net-new security-monitoring DETECTOR inside a sandboxed git worktree, on metered inference routed through the configured OpenAI-compatible inference endpoint.

Provider wiring

opencode talks to the inference endpoint as an OpenAI-compatible provider. The provider config is delivered ONLY through the OPENCODE_CONFIG_CONTENT subprocess env var (never a file committed to the worktree), and carries the short-lived run key as the provider apiKey. The authoring model is referenced as "<provider>/<lane>": opencode sends model="<lane>" to the endpoint, which resolves the lane to a concrete backing model — a raw catalog id may 404, so the request sends a lane name. On the metered rail the run key's allowed_models is scoped to that one lane's catalog ids (see engine).

Code-authoring model override

LIVE evidence: sending the bare "heal" lane resolves to a flash-tier model. That model authored a correctly-STRUCTURED customer-tree sidecar detector but HALLUCINATED the pkg/event API (called Payload as a method instead of reading the json.RawMessage FIELD), failing go build/go vet at the sound gate. Adapter.Model (below) lets the CALLER override the model string opencode requests, independent of Lane, so the CODE-authoring path sends the OPAQUE alias CodeAuthoringModel ("coding") — which the inference endpoint resolves server-side to a stronger Go-capable coder through the account's "coding" alias (the raw model id never leaves the server, so it never ships in the public binary). Lane still scopes the run-key mint (the aliased request spends against a lane grant that already covers the stronger coder, so no extra grant is needed); the caller resolves the alias locally and verifies that grant BEFORE setting Model — never overrides silently if the pool does not actually carry it. The model the alias resolves to must NEVER be a Fable model (it refuses security-adjacent authoring like detector code); that choice lives in the endpoint's lane configuration, not here. The override must never be forced onto the BYOI rail: a user's own arbitrary inference endpoint may not recognize the alias, so BYOI leaves Model empty and keeps sending the bare lane string (Lane). This invariant does NOT touch the customer-facing lane routing customers actually get inference through.

Secret hygiene

The run key is embedded only inside the in-memory provider config (never a bare env var, never logged). Every transcript is run through redact.Redact BEFORE it is returned for persistence, scrubbing the exact run key and any mallcop-sk-* token to a fixed marker.

Trusted signals only

BuildTaskPrompt constructs the authoring instruction from STRUCTURAL signals only (detector id, event type, finding family/severity/actor/source) — never raw untrusted sample text (mallcop core/agent/untrusted.go tags untrusted zones; the gap builder excludes them). The prompt cannot carry an injected instruction from a scanned artifact.

Index

Constants

View Source
const CodeAuthoringModel = "coding"

CodeAuthoringModel is the OPAQUE lane alias the CODE-authoring path sends as the model string when the caller overrides the bare lane-name routing via Adapter.Model (see the package doc's "Code-authoring model override" section). It is NOT a raw catalog id: the inference endpoint resolves this alias to the real, stronger Go-capable coder through the calling account's model alias, exactly the way the scan lanes resolve. Keeping the raw model id server-side is deliberate: this constant ships in the PUBLIC self-extension binary, so it must name only the opaque alias — never the model the product exists to obscure.

The alias name IS the lane name by construction; it is declared here as its own const to avoid layering this low-level adapter on a higher-level routing package.

The model it resolves to MUST NEVER be a Fable model (Fable models refuse security-adjacent authoring tasks like detector code) and MUST NOT be the "heal" default (a flash-tier model that plans a detector but fails to execute the file writes — observed live); that choice lives in the endpoint's coding-lane configuration, not here.

View Source
const NoOpAuthoringEscalation = "\n\n" +
	"NOTE: a previous attempt explored the repository but ended without writing " +
	"any file. All required context is in this prompt — author the REQUIRED FILES " +
	"now. Do not end your session before issuing the write tool calls."

NoOpAuthoringEscalation is the paragraph the ENGINE appends to the base authoring prompt on a RETRY attempt, after a previous attempt exited 0 having written NOTHING — the narrate-then-die shape (rd 4a1): GLM narrates an intention as a text-only turn ("Let me survey all pre-existing scenarios...") and opencode ends the session with exit 0 and zero write tool calls, mid- diligence. The engine keeps the base prompt BYTE-IDENTICAL on the first attempt (so the anti-thrash fingerprint and the BuildTaskPrompt tests stay stable) and only appends this on attempt >= 2. It lives here, beside the prompt builders, so the escalation wording is versioned with the authoring prompt it extends.

Variables

This section is empty.

Functions

func Parse

func Parse(stdout []byte) (files []string, text string)

Parse extracts authored file paths and concatenated assistant text from opencode's line-delimited --format json event stream. It is deliberately SCHEMA-TOLERANT: it decodes each line as a generic JSON object and walks it recursively, collecting string values under file/path-shaped keys and under "text" keys. If the schema drifts and nothing is recognized, files come back empty — git (the worktree diff) remains the authoritative record of what the run actually changed.

func WriteToolFiles

func WriteToolFiles(stdout []byte) []string

WriteToolFiles walks opencode's line-delimited --format json event stream and returns the unique file paths authored by a WRITE or EDIT tool call — NOT every path the transcript referenced. This is the fix for the bc1 false positive: Parse (above) collects EVERY file-shaped key including a read tool's filePath, so len(Parse(...)) counted reads as authored files (authored_files=27 on a zero-write run). WriteToolFiles restricts the extraction to path values that appear WITHIN a write/edit tool event line, so a run that only READ files reports zero authored.

It is SCHEMA-TOLERANT like Parse: each line is decoded as a generic object; a line whose object carries a write/edit tool marker (a "tool" or "name" string value in writeToolNames, at any depth) contributes the file path(s) found under it. If the schema drifts and no write tool is recognized, the result is empty — git remains authoritative.

Types

type Adapter

type Adapter struct {
	// Bin is the opencode executable path. Empty → "opencode" on PATH.
	Bin string
	// Lane is the authoring lane (the opencode model key AND, when Model is
	// empty, the model string the inference endpoint receives), e.g. "heal".
	// Required. Lane ALSO still scopes the run-key mint on the metered rail even
	// when Model overrides what literal model string is requested — see Model.
	Lane string
	// Model, when non-empty, is the model string opencode requests INSTEAD of the
	// bare Lane string (see the package doc's
	// "Code-authoring model override" section). It is normally an OPAQUE lane
	// alias (CodeAuthoringModel, "coding") that the inference endpoint resolves
	// server-side to the real coder; on a BYOI endpoint that recognizes a literal
	// catalog id, the caller may pass that id verbatim instead. Empty preserves
	// the base behavior: send Lane itself as the model and let the endpoint's
	// own lane resolution pick a model. The caller is responsible for only ever
	// setting this to an alias/model the run key's allowed_models for Lane
	// actually grants after server-side resolution — this package has no endpoint
	// catalog visibility to verify that itself.
	Model string
	// Provider is the opencode provider key. Empty → sandbox.ProviderName.
	Provider string
	// ForgeBaseURL is the inference endpoint base URL; the OpenAI-compatible "/v1"
	// suffix is appended when building the provider config. Required.
	ForgeBaseURL string
	// MaxAttempts caps opencode invocations for one authoring run: the initial
	// run plus retries of a TRANSIENT, no-op fast-fail (opencode exiting non-zero
	// having authored NOTHING and left a transcript that indicates an upstream
	// 5xx/rate-limit/timeout/empty response). ≤0 → defaultMaxAttempts (3). A run
	// that authored files or failed non-transiently is NEVER retried (avoids
	// double-spend on work that already happened).
	MaxAttempts int
	// RetryBackoff is the pause between transient retry attempts. ≤0 →
	// defaultRetryBackoff.
	RetryBackoff time.Duration
	// Timeout bounds ONE opencode subprocess invocation.
	// ≤0 → defaultInvokeTimeout (15m). Applied fresh to EACH attempt (a retry
	// gets its own full budget, not a shared remainder) via
	// context.WithTimeout layered on top of the caller's ctx, so a hung
	// opencode process is force-killed — whole process GROUP, mirroring
	// mallcop connect/exec's procgroup pattern (see procgroup_unix.go) — even
	// though the caller's own ctx (e.g. context.Background() at the CLI) never
	// times out on its own.
	Timeout time.Duration
	// Confine, when true, runs the opencode child under OS-enforced Landlock
	// confinement (the jail package): no filesystem writes outside the
	// worktree's authoring/scratch tree, and no TCP egress except the port of the
	// configured inference endpoint. It is FAIL-CLOSED — if the kernel cannot
	// establish the jail, Invoke returns an error and authors nothing. The
	// production runner sets it; adapter unit tests leave it off (the re-exec
	// launcher needs the real operator binary as /proc/self/exe).
	Confine bool
	// MaxOutputTokens caps the authoring model's output tokens in the opencode
	// provider config. opencode has no registry metadata for our custom
	// openai-compatible provider, so without an explicit limit it defaults its
	// max_tokens ABOVE the lane model's hard cap, and every authoring request
	// 400s ("max_tokens must not exceed 4096"). opencode then transient-fast-fails
	// (0 files authored) on every attempt. ≤0 → defaultMaxOutputTokens.
	MaxOutputTokens int
	// MaxContextTokens is the context-window size declared alongside MaxOutputTokens
	// in the opencode model config. opencode REQUIRES context when output is set (it
	// fails config validation otherwise). ≤0 → defaultMaxContextTokens.
	MaxContextTokens int
	// Logger receives non-secret adapter events. Nil → discard.
	Logger *slog.Logger
	// contains filtered or unexported fields
}

Adapter drives one headless opencode authoring invocation.

func (*Adapter) BuildTaskPrompt

func (a *Adapter) BuildTaskPrompt(gap TrustedGap, customerShaped bool) string

BuildTaskPrompt constructs the headless authoring instruction from the gap's TRUSTED STRUCTURAL fields only. It never interpolates raw untrusted sample text — the only variable data are the detector id, event type, family, and the structural severity/actor/source, all of which the gap-builder derived from trusted signals.

customerShaped selects WHICH delivery shape the prompt targets: the engine derives it from the SAME trusted signal the gate already uses (gate.go's hasCmdMallcop on the trusted worktree jail's own TargetRepo — never anything the untrusted proposal content could set).

  • false (in-tree lane — TargetRepo IS the mallcop OSS repo itself, or any tree with its own cmd/mallcop): the prompt targets the ORIGINAL own-package shape (core/detect/authored/<name>/, init()+detect.Register) that core/selfgate's authoredast.go (K7 L3) grades — unchanged from before this fix.
  • true (a customer-shaped THIN-EMBED target repo — `mallcop init --create-repo`'s scaffold: go.mod pins mallcop, NO cmd/mallcop, NO core/detect/authored/ tree at all): the prompt targets the SIDECAR shape (detectors/<name>/main.go, package main, detectorhost.Run) that core/selfgate's sidecarshape.go grades instead. Authoring the in-tree shape here is the exact live-leg bug: the engine's registry-linkage step then tries to restore a core/detect/authored/registry.go that never existed in this repo's history and fails loud. See engine.Run's customerShaped branch, which ALSO skips that registry step entirely for this case — the detectors/<name>/ directory IS the registration under the sidecar model.

func (*Adapter) Invoke

func (a *Adapter) Invoke(ctx context.Context, wt *sandbox.Worktree, apiKey, task string) (Result, error)

Invoke runs opencode HEADLESS against the worktree jail:

opencode run <task> -m <provider>/<lane> --format json \
  --dangerously-skip-permissions --dir <wt.Dir>

The subprocess env is the sandbox's credential-scrubbed allowlist with OPENCODE_CONFIG_CONTENT overridden by this adapter's lane-aware provider config (the sandbox default omits the models map). The captured stdout+stderr are REDACTED before being returned for persistence.

A non-zero opencode exit is NOT an error — it is recorded in Result.ExitCode so the engine can decide (git remains the source of truth for what, if anything, was authored). A spawn failure or a canceled context IS an error.

Bounded transient retry

Live runs sometimes fast-fail: opencode exits non-zero within a few seconds on a transient upstream error (a 5xx, a rate-limit, an empty response) having authored NOTHING. Invoke retries that case up to MaxAttempts-1 times with a backoff, so a flaky upstream does not burn the whole build. The retry is gated by TWO hard conditions to avoid double-spend:

  • the worktree is still CLEAN (git status --porcelain empty) — proof nothing was authored; a run that wrote any file is never retried, and
  • the redacted transcript matches a TRANSIENT signal (see looksTransient) — a deterministic failure (bad model, config error, compile-time refusal) is not retried because a retry cannot help it.

A canceled context or a spawn failure surfaces immediately (never retried).

Truncation is fail-loud, never retried

When Result.Truncated is set (see its doc), Invoke returns an error IMMEDIATELY — no attempts remain, regardless of MaxAttempts — instead of falling into the exit-code/TimedOut/transient checks below. Retrying would resend the SAME prompt against the SAME cap, which reproduces the identical truncation; the point of this guard is to convert opencode's own silent, unbounded invalid-tool retry loop (upstream sst/opencode#18108 — live-reproduced, 35+ silent re-sends in 45s with zero actionable stdout, see Result.Truncated) into ONE loud, diagnosed Go error naming the cap, the moment the signature is seen — never a generic timeout or "nothing to commit".

func (*Adapter) ProviderConfig

func (a *Adapter) ProviderConfig(apiKey, forgeBaseURL string) (string, error)

ProviderConfig marshals the opencode OpenAI-compatible provider config as a STRING for OPENCODE_CONFIG_CONTENT. It declares the authoring lane under the provider's models map so opencode will accept "-m <provider>/<lane>", and embeds the run key as the provider apiKey. It is never written to a file.

func (*Adapter) RequestedModel

func (a *Adapter) RequestedModel() (model string, overridden bool)

RequestedModel is model, exported for callers outside this package (the engine's provenance writer) that need to record the literal model actually requested/billed, not just the lane it was requested under. overridden is true when Adapter.Model (the code-authoring override) is in effect, so the caller can distinguish "the resolved literal catalog id" from "the bare lane, the inference endpoint resolves it".

type Result

type Result struct {
	AuthoredFiles      []string
	AssistantText      string
	TranscriptRedacted []byte
	ExitCode           int
	// TimedOut is true when THIS attempt was killed because it exceeded
	// Adapter.Timeout. Invoke never retries a timed-out
	// attempt (see Invoke) — a hang is not the "transient, no-op fast-fail"
	// the retry heuristic targets, and retrying a bounded hang just risks
	// stacking several bounded hangs back to back instead of failing fast.
	TimedOut bool
	// Truncated is true when the event stream shows the model's generation was
	// cut off by the configured output-token cap (Adapter.maxOutputTokens) mid
	// tool-call: opencode could not parse the truncated JSON arguments and
	// relabeled the call tool="invalid" (upstream sst/opencode#18108), and/or a
	// step-finish event reports reason="length". LIVE-REPRODUCED (2026-07-17,
	// real opencode 1.17.11 against a local fake server scripted to truncate):
	// opencode does NOT fail the session or stop — it silently re-sends the SAME
	// oversized turn and relabels the reply "invalid" again, 35+ times in 45s,
	// with ZERO stdout events any caller could act on, until something external
	// kills it. See TruncationDetail for what was observed. Invoke treats
	// Truncated as a hard, immediate, non-retried failure (see Invoke) — the
	// same cap will truncate an identical retry, and letting opencode's own
	// loop run is exactly the silent-hang hazard this field exists to surface
	// loudly instead of.
	Truncated bool
	// TruncationDetail is a human-readable diagnostic set only when Truncated is
	// true: the cap value, how many invalid-tool/length-finish events were
	// observed, and (when the event carried it) the output-token count opencode
	// reported at cutoff.
	TruncationDetail string
}

Result is the outcome of one opencode invocation. AuthoredFiles is a best-effort extraction from the event stream; git (the worktree diff) is the authoritative record of what changed and is what the gate diffs.

type TrustedGap

type TrustedGap struct {
	// DetectorID is the proposed authored detector's stable name (e.g.
	// "authored-deploy-burst"). It becomes the own-package name and the emitted
	// finding.Type.
	DetectorID string
	// EventType is the connector event type the detector keys on.
	EventType string
	// TargetFamily is an OPTIONAL metadata family tag, normalized into the
	// anti-thrash fingerprint. It is NOT the emitted finding.Type: the detector
	// ALWAYS emits finding.Type == DetectorID, and the merge gate matches a
	// scenario's must_fire/must_not_fire labels against that emitted Type (the
	// detector name). A TargetFamily that differs from DetectorID therefore only
	// flavors metadata — the authoring prompt must NEVER put it in the scenarios'
	// expected_detection labels. Empty falls back to DetectorID.
	TargetFamily string
	// Severity is the structural severity of the gap's exemplar finding
	// (low/medium/high/critical). Structural, not free text.
	Severity string
	// Actor is the structural actor field of the exemplar finding.
	Actor string
	// Source is the structural source field of the exemplar finding.
	Source string
}

TrustedGap is the ONLY input to an authoring run: the structural description of a detection gap. It deliberately carries NO raw untrusted sample text — only fields the gap-builder derived from trusted, structural signals — so a scanned artifact cannot inject an instruction into the authoring prompt.

func (TrustedGap) Fingerprint

func (g TrustedGap) Fingerprint() string

Fingerprint is the stable anti-thrash key: sha256 over the normalized (detector id, event type, target family) triple. Two gaps that would author the same detector against the same family collapse to one fingerprint, so a known-reject is skipped without re-spending inference.

func (TrustedGap) PackageName

func (g TrustedGap) PackageName() string

PackageName derives the own-package directory/name for the authored detector from the gap's DetectorID (e.g. "authored-Deploy Burst" -> "deployburst").

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL