# 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:254-323](../../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.

A defence-in-depth detail: the caller's system prompt is suffixed with
`TOOL_SUPPRESSION_SUFFIX` ([claude_cli.rs:84-86](../../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.
- `--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
([claude_cli.rs:357-374](../../src/claude/ai/claude_cli.rs)) describing
exactly what was relaxed and what the operator is now trusting. 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. Non-positive or
non-finite values are silently treated as "no cap" (the simplest
fail-open behaviour for an optional value).

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 `--beta-header` global 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.

**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.
