# ADR-0028: Sandboxed `claude-cli` Subprocess AI Backend

## Status

✅ Accepted (2026-05-12)

## Context

omni-dev's AI integration was originally framed by [ADR-0002](adr-0002.md) as
a `Box<dyn AiClient>` trait with in-process implementations for the direct
Anthropic API, AWS Bedrock, OpenAI-compatible endpoints, and Ollama. All four
require the caller to provision credentials (API key, AWS role, local
endpoint) before omni-dev can drive a model.

A growing subset of users already runs an authenticated Claude Code session
on their machine — the `claude` CLI holds an OAuth token bound to their
Anthropic subscription. Forcing those users to provision a separate
`ANTHROPIC_API_KEY` (with separate billing, separate rate-limit pool, and a
second credential surface to leak) is friction without a corresponding gain.
A subprocess backend that shells out to `claude -p` would reuse that
authenticated session directly.

The trade-off is that a nested `claude -p` invocation is, by default, *not*
a constrained operation. The child inherits the parent's environment and
working directory, can read and write files, can run shell commands, can
load MCP servers from `~/.claude/settings.json` (with their OAuth tokens),
and can persist session state to disk. None of those capabilities are
needed for the structured-prompt-in / structured-response-out use case that
omni-dev cares about, and several of them are actively hostile to it —
omni-dev's call sites assume the AI is a pure function of `(system prompt,
user prompt) → response`, not an agent with side effects on the calling
environment.

Three further forces shaped the design:

- **Cost containment.** A misbehaving prompt loop with a tool-enabled
  session can consume unbounded API spend before any external watchdog
  reacts. The backend needs a per-invocation budget cap with hard kill
  semantics, not advisory logging.

- **Forward compatibility.** The set of "dangerous" `claude` CLI features
  grows with each release (new tools, new slash-command skills, new MCP
  surfaces). The sandbox needs to be opt-out-from-secure-default rather
  than opt-in-to-secure, so new dangerous features stay disabled until
  someone reviews them.

- **Observability.** Whatever the cap, operators running at scale need to
  see the cost of each invocation in their logs without grepping for it.

Issues #633 (orphan Node helper processes outliving timeouts) and #683
(the MCP-pickup leak path) informed several of the implementation details
called out below.

## Decision

### A subprocess-based backend, dispatched by env var

A new variant, `ClaudeCliAiClient` ([src/claude/ai/claude_cli.rs](../../src/claude/ai/claude_cli.rs)),
sits beside the four existing backends. It is selected by setting
`OMNI_DEV_AI_BACKEND=claude-cli` (or `--ai-backend claude-cli`), which
short-circuits the dispatch in `create_default_claude_client`
([src/claude/client.rs](../../src/claude/client.rs)) ahead of the
`USE_OPENAI` / `USE_OLLAMA` / `CLAUDE_CODE_USE_BEDROCK` checks. Preflight
([src/utils/preflight.rs](../../src/utils/preflight.rs)) mirrors the same
ordering: it probes the `claude` binary with `--version` before falling
through to API-key checks. Adding a future backend must update both sites
in lock-step.

The subprocess interface is `claude -p --output-format json`. The JSON
envelope (`is_error`, `api_error_status`, `result`, `total_cost_usd`) is
the stable contract; everything else (`session_id`, `usage`, model
metadata) is ignored. We deliberately did **not** wrap the in-process SDK
on top of the OAuth token store, because:

- Re-implementing the `claude` auth refresh, session handling, and quota
  routing in Rust would duplicate a moving target.
- A process boundary is a precondition for several of the sandbox flags
  below (`--no-session-persistence`, `--setting-sources ""`, cwd
  isolation). An in-process SDK cannot enforce any of them.

### Secure-by-default sandbox

Every `claude -p` invocation is configured by `build_command_with_schema`
([claude_cli.rs:348-414](../../src/claude/ai/claude_cli.rs)) with the
following flags, all of which can only be removed by an explicit escape
hatch (see below):

| Flag | Effect |
|------|--------|
| `--tools ""` | Disable every built-in tool (Read, Edit, Write, Bash, Glob, Grep, …). |
| `--strict-mcp-config` | Refuse to load MCP servers; nested session cannot reach `~/.claude/settings.json`'s MCP block. |
| `--setting-sources ""` | Skip user / project / local settings discovery. |
| `--disable-slash-commands` | Block skill / slash-command invocation. |
| `--no-session-persistence` | Do not write session state to disk. |
| `--permission-mode default` | No auto-accept permission prompts. |

Two non-flag mechanisms complete the sandbox:

- **Fresh cwd.** Each invocation creates a `tempfile::TempDir` and sets it
  as the subprocess `current_dir`. The nested session has no view of the
  caller's project files even if a tool is somehow re-enabled.
- **Env scrubbing.** The subprocess inherits the caller's environment
  (because the Node runtime inside `claude` needs `HOME`, `PATH`, and on
  macOS sometimes `DYLD_*` entries to function), then removes
  `CLAUDE_PROJECT_DIR` and any variable with the `CLAUDE_CODE_` or
  `CLAUDE_PROJECT_` prefix. These are the variables a parent Claude Code
  session uses to re-scope a child session into the same project; leaving
  them set would defeat the temp-cwd isolation. When the tool-access
  escape hatch is enabled, the scrub widens to well-known secret vars —
  suffixes `_API_KEY`, `_TOKEN`, `_SECRET`, `_PASSWORD`, `_CREDENTIALS`,
  plus `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — because the two
  mitigations that hatch removes (tools-off, prompt-is-just-data) are
  exactly what kept inherited credentials unreachable from untrusted
  prompt content (issue #1144). `ANTHROPIC_API_KEY` /
  `ANTHROPIC_AUTH_TOKEN` are always kept — the nested `claude` may
  authenticate through them and omni-dev passes it no credentials of its
  own — and `OMNI_DEV_CLAUDE_CLI_KEEP_ENV` (comma-separated exact names)
  exempts credentials a tool-enabled workflow legitimately needs.

A defence-in-depth detail: the caller's system prompt is suffixed with
`TOOL_SUPPRESSION_SUFFIX` ([claude_cli.rs:100-102](../../src/claude/ai/claude_cli.rs)),
which instructs the model not to emit `<function_calls>` XML even though
the runtime would discard it. This is belt-and-braces against models that
"know" tools exist and try to drive them anyway.

A process-group concern: on Unix, the child is made its own process-group
leader (`Command::process_group(0)`) so a timeout can `killpg` the whole
group. Without this, Node worker processes spawned by `claude` survive a
direct PID kill and accumulate as orphans (issue #633).

### Two independent escape hatches

The sandbox is not negotiable per-invocation by the model. It is, however,
negotiable per-deployment by the operator, via two **independent**
toggles, each defaulting to off:

- `--claude-cli-allow-tools` / `OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS=true` —
  drops `--tools ""`. The nested session regains the full built-in
  toolset. Justified only for deliberately tool-capable use cases. Because
  the prompt is built from untrusted content (diffs, commit messages, JIRA
  text), a tool-capable session is injectable by that content; as a
  compensating control this hatch also activates the secret env scrub
  described above (issue #1144).
- `--claude-cli-allow-mcp` / `OMNI_DEV_CLAUDE_CLI_ALLOW_MCP=true` —
  drops `--strict-mcp-config`. The nested session can load MCP servers
  from `~/.claude/settings.json`, exposing whatever OAuth tokens or
  network endpoints those servers hold.

Each toggle that is active logs a `warn!()` line on every invocation
(`run_with_options_inner` in
[claude_cli.rs](../../src/claude/ai/claude_cli.rs)) describing exactly
what was relaxed and what the operator is now trusting, and — since
issue #1143 — naming where the hatch was enabled (command-line flag,
process environment variable, or a `settings.json` `env` map). Relaxing
the threat model is loud, not silent. The two toggles are independent
because the threat models they relax are independent — tool access leaks
file-system / shell capability into the session; MCP access leaks
credentials and network endpoints. An operator may legitimately want one
without the other.

### Hard spending cap

`--claude-cli-max-budget-usd <amount>` /
`OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD=<amount>` is forwarded verbatim to
`claude -p --max-budget-usd`. The subprocess itself enforces the cap —
when it estimates the in-flight invocation will exceed the budget, it
aborts. This is preferred over an omni-dev-side soft warning because the
subprocess can stop spending *before* the next round trip, whereas
omni-dev only sees the cost after the response arrives — a single
invocation can therefore overshoot the cap by up to one model turn, and
omni-dev has no independent kill if `claude -p` mishandles the flag
(#1135). Non-positive, non-finite, or unparseable values still fail open
to "no cap" (the value is optional), but since #1135 the drop is loud: a
`warn!()` names the offending value and env var instead of silently
running uncapped.

Regardless of cap, `total_cost_usd` from every response is logged at
`info!()` ([claude_cli.rs:499-520](../../src/claude/ai/claude_cli.rs)) so
the operating cost is observable in standard tracing output. When a cap
is set and `claude -p`'s reported cost exceeds it, omni-dev additionally
logs a `warn!()` as a sanity check against the subprocess's own
enforcement.

### Out of scope

- We do not attempt to validate the schema of `~/.claude/settings.json`,
  fingerprint installed MCP servers, or otherwise audit what
  `--claude-cli-allow-mcp` would expose. The escape hatch is a knob, not
  a policy engine.
- The per-subcommand `--beta-header` flag is ignored for this backend; betas
  are negotiated by the `claude` CLI itself, not by omni-dev.

## Consequences

**Positive:**

- Users with an existing Claude Code subscription can drive omni-dev's AI
  features without provisioning a second credential.
- The threat-model boundary is explicit in code, not in documentation
  alone. Every escape hatch is a flag with a warning log, not an
  undocumented env var.
- Cost is observable per invocation, and `claude -p`'s own hard kill makes
  the budget cap authoritative.
- Forward-compatible: if `claude` ships a new dangerous flag, the default
  set above continues to apply. We opt into new capabilities, never the
  reverse.

**Negative:**

- Subprocess overhead per call (process spawn, Node startup, JSON
  envelope marshalling) is non-trivial compared to in-process SDKs. For
  short prompts the spawn cost can dominate the API round-trip.
- The backend depends on a `claude` binary at a discoverable path. The
  preflight check gates this, but a user whose binary moves between
  invocations will see runtime errors that the other backends do not
  surface.
- Escape hatches are footguns when set in shell rc files: a user who
  exports `OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS=true` in `.zshrc` weakens the
  sandbox for every future invocation, with only the per-call `warn!()`
  to flag it. We accept this — the alternative (per-invocation
  confirmation) would defeat unattended use cases like CI. Since issue
  #1143 the warning names the source of the enabling value (flag vs
  process environment vs `settings.json`), so the sticky cases point at
  the setting to remove.

**Neutral:**

- The JSON envelope contract is unilateral. If `claude -p` removes or
  renames `total_cost_usd` or `is_error`, omni-dev breaks. We accept the
  coupling because the alternative (parsing free-form text output) is
  strictly worse, and `claude -p`'s JSON output is documented as stable.
- `OMNI_DEV_AI_BACKEND=claude-cli` short-circuits the dispatch ahead of
  the legacy `USE_OPENAI` / `USE_OLLAMA` / `CLAUDE_CODE_USE_BEDROCK`
  switches. A user with multiple backend env vars set will get the
  claude-cli backend regardless of the others. The selection order is
  the contract; collisions are not a bug.
