harness is a Go library for driving AI coding CLIs in headless mode. It
supports Claude Code, Codex, GitHub Copilot CLI, and OpenCode through one
interface while leaving process placement to the caller. A command can run on
the host, inside a container, or through a remote runner with the same arguments
and event parser.
| Name | Binary | Credential environment | Staged skill path | Project instructions | Model API hosts |
|---|---|---|---|---|---|
claude |
claude |
ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN |
.claude/skills/<name> |
CLAUDE.md |
*.anthropic.com |
codex |
codex |
CODEX_API_KEY |
skills/<name> |
AGENTS.md |
api.openai.com, auth0.openai.com, chatgpt.com |
copilot |
copilot |
COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN |
.github/skills/<name> |
.github/copilot-instructions.md |
github.com, api.github.com, api.mcp.github.com, *.githubcopilot.com |
opencode |
opencode |
OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENCODE_CONFIG_CONTENT, OPENCODE_AUTH_CONTENT |
.opencode/skill/<name> |
AGENTS.md |
models.dev, api.openai.com, *.anthropic.com |
The Copilot adapter targets CLI 1.0.80 and remains compatible with the prompt-mode JSONL stream introduced in 1.0.75.
For Copilot BYOK runs, Job.BaseURL sets COPILOT_PROVIDER_BASE_URL and
Harness.Env passes through the configured COPILOT_MODEL and
COPILOT_PROVIDER_* credentials and wire settings as bare keys, so container
and remote runners can inject their values without placing secrets in argv.
Codex runs with analytics, apps, and plugins turned off through -c overrides,
which take precedence over the same keys in the caller's config.toml.
The library owns the details that differ between CLIs: binary names, arguments, credential and state environment variables, project instruction files, skill directories, model API hosts, JSONL parsing, account-limit errors, default models, and token prices.
go get github.com/alpha-omega-security/harnessGo 1.26 or later is required.
A Job contains resolved values for one invocation. Callers apply their own
configuration defaults before constructing it.
type Job struct {
Workspace string
SrcDir string
SkillName string
Prompt string
SystemPrompt string
Model string
Effort string
MaxTurns int
OutputFile string
AllowedTools string
BaseURL string
ResumeSessionID string
ResumePrompt string
}Workspace is the command's working directory. SrcDir is the repository
directory relative to it and defaults to src; set it to . when Workspace
is already the repository root. SkillName selects a staged SKILL.md; when
Prompt is empty, the backend builds a short activation prompt. SystemPrompt
uses --system-prompt with Claude and the backend's project instruction file
for the other CLIs.
MaxTurns uses the backend default when set to zero; Copilot maps it to maximum
autopilot continuations. Effort applies to Claude, Codex, and Copilot.
AllowedTools applies only to Claude. ResumeSessionID and ResumePrompt
continue an existing conversation. RunSession runs a job through Run or
container.Scope.Run and returns the session the backend announced, so a
caller can resume it, for instance to have an invalid OutputFile rewritten.
A resume that cannot load its conversation is run again fresh with Prompt.
RunRepaired builds on it for a JSON OutputFile: it reads the file as a
regular file of at most Repair.MaxBytes, validates it against Repair.Schema
and Repair.Validate, and on a failure resumes the session once with the
validation errors and the invalid output, capped at Repair.MaxTurns on
Claude and Copilot. It returns the valid output, or a *ValidationError that
keeps the original. A file already at OutputFile before the run reads as its
output, so the workspace starts without one.
The Harness interface exposes the parts needed by local, container, and remote
runners:
type Harness interface {
Binary() string
Args(Job) []string
Prompt(Job) string
ParseStream(io.Reader, func(Event))
SkillDir(workspace, name string) string
GuideFilename() string
SystemPromptViaArgs() bool
EgressHosts() []string
Env(baseURL string) []string
StateEnv(dir string) []string
AccountErrorText(string) string
DefaultModels() []ModelDefault
}Use ByName to select a backend. An empty name selects Claude.
h, err := harness.ByName("codex")
name := harness.Name(h)
available := harness.Names() // "claude, codex, copilot, opencode"Each parser produces the same event type:
type Event struct {
Kind string
Tool string
Text string
CostUSD float64
SessionCostUSD float64
Turns int
Usage Usage
Model string
SessionID string
RateLimit *RateLimitInfo
}Kinds are thinking, text, tool, result, usage, error, session,
rate_limit and egress. FormatEvent renders an event for a plain-text log.
CostFromUsage calculates a list-price estimate when the CLI reports tokens
without a dollar amount. Copilot's CostUSD uses the latest cumulative
session.usage_checkpoint when one is present, so on a resumed Copilot session
the reported cost is session-cumulative rather than per-invocation.
Claude's total_cost_usd is cumulative too, both across the several results
one invocation can print and across --resume. Each Claude result's CostUSD
is therefore what that result added within the invocation. The raw total is
kept on SessionCostUSD. Copilot sets SessionCostUSD from its checkpoint.
When resuming a session, subtract the SessionCostUSD recorded for the earlier
invocation from the first result's CostUSD only if that result also sets
SessionCostUSD, since its cost then still includes the earlier invocation. A
resumed Copilot result with no checkpoint has SessionCostUSD 0 and a
per-invocation CostUSD, so use it unchanged.
A usage event reports the tokens one model call added, with its Model and a
list-price CostUSD estimate (zero for an unknown model). Summing them gives a
running estimate while a run is in progress, but the result event stays the
authoritative total, so do not add usage events to it. Claude needs no caller
change because Args adds --include-partial-messages. Subagent output tokens
are a lower bound until the result arrives. Codex reports usage only in its
result and emits no usage events.
Run starts the selected binary in the workspace, applies its environment,
writes a project instruction file when needed, and parses combined output as it
arrives.
package main
import (
"context"
"fmt"
"log"
"github.com/alpha-omega-security/harness"
"github.com/alpha-omega-security/harness/egress"
"github.com/git-pkgs/skills"
)
func main() {
ctx := context.Background()
workspace := "/work/project"
h, err := harness.ByName("claude")
if err != nil {
log.Fatal(err)
}
instructions, err := skills.Parse("/work/instructions/review.md")
if err != nil {
log.Fatal(err)
}
job := harness.Job{
Workspace: workspace,
SrcDir: ".",
Prompt: "Review this project for security defects.",
SystemPrompt: skills.Concat(instructions),
Model: "claude-sonnet-4-6",
MaxTurns: 20,
}
if err := egress.WriteSandboxSettings(workspace, h.EgressHosts()); err != nil {
log.Fatal(err)
}
err = harness.Run(ctx, h, job, func(event harness.Event) {
fmt.Println(harness.FormatEvent(event))
})
if err != nil {
log.Fatal(err)
}
}When a non-zero exit contains a provider account-limit message, Run returns
an *harness.AccountError. Its optional reset time can be used to schedule a
later retry.
Callers that own process creation can use the same API without Run. This is
useful for containers, job queues, and remote execution.
h, err := harness.ByName("codex")
if err != nil {
return err
}
skill, err := skills.Parse("/skills/security-review/SKILL.md")
if err != nil {
return err
}
job := harness.Job{
Workspace: "/work",
SrcDir: ".",
SkillName: skill.Name,
Model: "gpt-5.3-codex",
OutputFile: "report.json",
}
if _, err := harness.StageSkill(h, job, nil, skill); err != nil {
return err
}
if err := harness.WriteSystemPrompt(h, job); err != nil {
return err
}
argv := append([]string{h.Binary()}, h.Args(job)...)
env := append(h.Env(job.BaseURL), h.StateEnv("/state")...)
// Pass argv and env to the process runner. Entries such as "CODEX_API_KEY"
// use the `docker run -e KEY` passthrough form.
stdout, err := startContainer(ctx, argv, env)
if err != nil {
return err
}
h.ParseStream(stdout, func(event harness.Event) {
fmt.Println(harness.FormatEvent(event))
})WriteSystemPrompt is needed only when SystemPrompt is non-empty. Backends
whose SystemPromptViaArgs method returns true receive that value in their
arguments, so the helper does not write a guide file.
The generated arguments allow unattended tool use. Claude uses
bypassPermissions unless AllowedTools is set. Codex uses
danger-full-access, OpenCode uses --auto, and Copilot uses --allow-all.
Run these commands only in a workspace and execution environment where those
permissions are acceptable. Container and remote callers should apply their
own filesystem, process, secret, and network limits.
The egress package can restrict outbound HTTP and HTTPS by hostname. Its proxy
checks the resolved destination immediately before connecting and rejects
loopback, private, link-local, carrier-grade NAT, unspecified, and multicast
addresses. This closes the usual DNS rebinding path after a hostname has passed
the allowlist.
StageSkill writes a skill into the directory where the selected backend discovers it, checks the skill name and OutputFile stay inside the workspace, and copies schema.json and scripts/ to the workspace root, where skills refer to them. Pass an fs.FS such as an embed.FS for skills shipped in a binary, or nil for skills parsed from disk or built in memory. Frontmatter keys outside the Agent Skills specification, such as model, are left out of the staged SKILL.md. Parsing and the rest of the skill API come from github.com/git-pkgs/skills.
container runs a backend inside an ephemeral OCI container. Runner.Run
mirrors harness.Run's signature; the workspace is bind-mounted at /work
and an optional state directory at /harness-state so a later run can resume
the session. DetectRuntime resolves docker, podman (rootful or rootless), or
Apple's container CLI and applies each engine's flag differences
(--userns=keep-id, --progress none, missing --security-opt). SELinux
:z bind-mount relabeling is handled via ResolveSELinuxRelabel, and
VerifyKeepID / VerifySELinuxMount report host misconfiguration once during
startup. Runner.Hardened
creates an internal network. Runner.Run owns that network for one backend
invocation. Call Runner.Open when readiness checks or retries must share it;
the returned Scope runs backends with Run, auxiliary commands with
RunCommand, and removes its resources with Close. Runner.ProcessEnv
supplies scoped values for bare Runner.Env keys without exposing them in the
container-runtime argv, while Runner.OmitEnv removes inherited backend
credentials that a caller replaces. Rootless podman gets a harness-proxy
sidecar on that network, restricted to Harness.EgressHosts() and any extra
hosts in Runner.Sidecar; Docker Desktop uses the same sidecar because an
internal network cannot reach its host proxy. Other runtimes use
Runner.ProxyURL through the network gateway. Runner images used for this
path need curl and the cmd/harness-proxy binary. Call SweepHardened during
startup to remove networks and sidecars left by an interrupted process.
Sidecar images must be rebuilt when Harness changes its proxy policy; stale
images fail closed. Runner.IsolateHost makes a hardened run on Docker Engine
28 or newer create that network with isolated gateways and IPv6 disabled, run
the same sidecar, and alias host.docker.internal to it, so the backend cannot
reach host services directly. It fails on other runtimes and on older or
unknown engine versions. Runner.ReadOnlyWorkspace mounts the workspace
read-only except for Job.OutputFile, which Run empties on the host before
each invocation. Runner.Memory, CPUs, PIDsLimit, and FileSizeLimit add
--memory, --cpus, --pids-limit, and an fsize ulimit when set.
egress contains the authenticated allowlist proxy and
WriteSandboxSettings, which writes Claude's .claude/settings.json domain
allowlist.
APIPort permits inspected HTTP only. CONNECT to it is refused even when the
port is also listed in HostPorts; use HostPorts for separate TLS or raw
TCP services.
llm sends a single schema-constrained request to the Anthropic Messages API.
It accepts a caller-owned HTTP client and permits plain HTTP only for local
development endpoints.
modelproxy keeps a model credential on the host. New wraps one key for a
Provider (Anthropic, OpenAI, or ChatGPT for Codex signed in with an
access token) and one model, and Issue mints a token per sandboxed run that
the proxy, served under PathPrefix, swaps for the key on a fixed list of
routes. Request bodies must name that model and pass a field-by-field check of
the protocol positions reviewed for the provider, output token limits are
clamped to 16,000, and client headers are dropped apart from Accept and
reviewed Anthropic-Beta values. A token stops working when it is revoked,
when the context it was issued with is done, or after Options.MaxRequests
requests (150 by default). Revoking returns the Usage the run's responses
reported, a harness.Usage with the cache reads and writes kept apart so
CostFromUsage prices them, plus the request count. The OpenAI service_tier
is dropped so a run cannot pick a dearer tier. Options.Charge is called with
the issuing context and the size of each POST body before it is forwarded, so
a caller can charge a shared budget, and Options.Log receives refusals
without conversation text.
NewCertificate returns a throwaway CA and a server certificate for the HTTPS
listener the ChatGPT provider needs.
Serve serves a proxy on a free port for the containers of the host, over
HTTPS under such a certificate when asked, and returns the origin they reach it
at, the CA they trust, and the APIPort or HostPorts the egress sidecar
needs: the egress proxy refuses CONNECT to its API port, so an HTTPS proxy is a
separate host port. The origin names egress.HostGatewayAlias, which Docker and
Podman resolve and Apple's container runtime does not.
Provider.BaseURL gives the API base URL a CLI is pointed at for a proxy served
at an origin. CodexConfig returns the config.toml that sends Codex's model
calls to the proxy through an OpenAI or ChatGPT provider, with web search,
retries, WebSockets and the ChatGPT features the proxy refuses turned off, plus
the environment Codex needs to sign in through the proxy and trust its CA with
ChatGPT.
modelproxy/modelproxytest writes the replies a fake upstream needs to drive a
real CLI through the proxy in a test: AnthropicText and AnthropicToolUse
stream a Messages API turn, ResponsesFunctionCall streams a Responses API
function call, and CodexStartup answers the ChatGPT routes Codex calls before
its first model request when it signs in with an access token.
renovate/agent-clis.json updates the agent CLI releases a Dockerfile pins as
ARG <CLI>_<AMD64|ARM64>_LOCK=<tag>@sha256:<digest>, for CLAUDE, CODEX,
OPENCODE and COPILOT, and groups both architectures of a CLI into one
update. Extend it at a harness tag so a change to the preset is reviewed like
any other dependency update:
{
"extends": ["github>alpha-omega-security/harness//renovate/agent-clis#<tag>"]
}MIT