Skip to content

feat: credential injection via --inject-header#1

Open
pjcdawkins wants to merge 17 commits into
mainfrom
credential-injection
Open

feat: credential injection via --inject-header#1
pjcdawkins wants to merge 17 commits into
mainfrom
credential-injection

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Turn curb's CONNECT proxy from a passthrough egress filter into a proxy that keeps real credentials out of the sandbox. For a selected destination the proxy terminates TLS with a per-run CA and substitutes a placeholder with the real credential in the request headers; the sandboxed process only ever holds the placeholder. A credential is bound to its destinations and is never sent to any other host.

How it works

--inject-header ENV_VAR:HOST[:PORT][,HOST...] binds the credential in ENV_VAR to one or more destinations:

  • curb generates a stable per-variable placeholder (curb-inject-<VAR>-placeholder), sets the sandbox's copy of ENV_VAR to it, and reads the real value from the host's ENV_VAR.
  • The proxy terminates TLS for each destination and replaces the placeholder with the real value wherever the client placed it among the request headers. This is header-agnostic: no auth-scheme knowledge is configured, so Authorization: Bearer … and x-api-key: … both work with one binding. It keeps curb decoupled from process-internal wire details.
  • A destination is HOST[:PORT], where HOST is a hostname or IP literal and PORT defaults to 443. Bindings are keyed by host:port, so the credential is injected only on that exact destination — a connection to the same host on a different port is relayed unchanged, with no credential.
  • The right side is a comma-separated list, so one credential can be bound to several destinations (GH_TOKEN:api.github.com,uploads.github.com).
  • Injection is opt-in: if ENV_VAR is unset or empty on the host, the binding is skipped silently (so the claude profile is a no-op for OAuth users).
  • The binding is var-first because a credential belongs to its variable and may be valid for more than one destination.

Each destination must be in the network allowlist — a hostname via --domains, an IP via --ips — and the proxy must be active (incompatible with --unrestricted-net); --inject-header does not add the destination to the allowlist itself.

Syntax

The separator is a colon: it reads as a binding (the variable is bound to a destination, not assigned a value) and parses unambiguously because an env var name can never contain one. In a config file or profile, write the list item without a space after the colon (- GH_TOKEN:api.github.com); a space makes YAML parse it as a mapping, which curb rejects.

What's added

  • proxy/inject.go — a per-run EC CA (mints a leaf per host or IP on demand) and a host:port→placeholder→value Injector, plus a host-level set for the plain-HTTP refusal. Handler gains an optional *Injector; when nil, behavior is unchanged (passthrough).
  • CLI / config--inject-header ENV_VAR:HOST[:PORT][,HOST...], also settable via CURB_INJECT_HEADER and the inject-header: config/profile key, merged additively.
  • policy.InjectTarget — parsed destinations (host, port, is-IP) are carried as map[policy.InjectTarget][]proxy.Injection through the plan, so the structured target is never serialized to a string and re-parsed.
  • CA delivery — a combined bundle (system roots + per-run CA) is written to the temp dir; the standard CA env vars (SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS) point at it. The CA key and the real credential stay in the parent — never serialized to the child.
  • claude profileinject-header: ANTHROPIC_API_KEY:api.anthropic.com keeps the API key out of the sandboxed process; a no-op for OAuth users.
  • platforms — supported on Linux and macOS (the macOS plan terminates TLS via the same proxy; inactive bindings are no-ops).
  • --dry-run — reports the bindings (destination, CA-trust env vars) without ever printing the credential value; the default :443 is dropped so the common case reads as a bare host.

Safety properties

  • A credential is injected only on the exact host:port it was provisioned for; any other destination the program connects to is relayed without it.
  • Placeholders are wrapped as curb-inject-<VAR>-placeholder. Since an env var name cannot contain -, no placeholder is a prefix of another (e.g. TOK vs TOK2), so the substring substitution cannot corrupt one credential's placeholder while replacing another bound to the same destination.
  • Host normalization is shared (policy.NormalizeHost, with IP-literal canonicalization for the proxy lookup) across the domain matcher, the injection-target parser, and the binding lookup, so they agree on what counts as the same destination.
  • A plain-HTTP request to a host that holds a credential is refused rather than forwarded, so the real value is never sent over cleartext.

Tests

  • Unit: target parsing (host list, custom port, IPv4/IPv6, bad port, URL rejection), binding correctness, no cross-host or cross-port substitution, IP authorization via --ips, and that a client-supplied placeholder is overwritten with the real value regardless of which header carries it.
  • End-to-end: the real binary sandboxes curl against a local Go HTTPS server on a custom port, asserting the credential is injected, the sandbox trusts the per-run CA, and the placeholder is overwritten.

Known gaps

  • Integration with a real Claude Code run (the one-time custom-key approval prompt for the placeholder) is not yet validated in CI; the injection mechanism it builds on is.
  • A custom ANTHROPIC_BASE_URL is not covered by the profile binding (it targets api.anthropic.com); bind the key to the gateway host as well, or pass it through with --env.

🤖 Generated with Claude Code

@pjcdawkins pjcdawkins force-pushed the credential-injection branch from dfa3432 to f6e43c5 Compare June 15, 2026 07:35
Turn curb's CONNECT proxy from a passthrough egress filter into an egress
proxy that keeps real credentials out of the sandbox. For selected hosts the
proxy terminates TLS with a per-run CA, injects a credential header, and
forwards the request; the sandbox holds only a placeholder. A credential is
bound to its destination and never stapled to another host.

- proxy/inject.go: per-run EC CA (leaf per host) and a host->{header,value}
  Injector. Handler gains an optional *Injector; nil keeps passthrough.
- CLI: --inject-bearer HOST=SOURCE and --inject-header HOST=HEADER=SOURCE
  (Linux). SOURCE is @ENV_VAR (kept out of argv), @?ENV_VAR (optional, inject
  only if set), or a literal. Also settable via CURB_INJECT_BEARER/
  CURB_INJECT_HEADER and the inject-bearer:/inject-header: config/profile keys,
  merged additively.
- CA delivery: a combined bundle (system roots + per-run CA) is written to the
  temp dir; the standard CA env vars point at it. The CA key and tokens stay in
  the parent, never serialized to the child.
- env markers: VAR=?value sets an env var only when set on the host. With @?,
  this seals a credential conditionally.
- claude profile: seals ANTHROPIC_API_KEY out of Claude Code as the x-api-key
  header when set on the host; a no-op for OAuth users.
- --dry-run reports the bindings (host + header names + CA-trust env vars)
  without printing the token.
- Tests: unit (binding correctness, no cross-stapling, placeholder override)
  and end-to-end (real binary, local HTTPS server, sandbox trusts the CA).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pjcdawkins pjcdawkins force-pushed the credential-injection branch from f6e43c5 to 7564dbf Compare June 15, 2026 07:46
curb advertised its SOCKS proxy via ALL_PROXY/all_proxy unconditionally,
overwriting any user value. Set it only when not already provided, mirroring
the existing NO_PROXY precedence, so an embedder can pass --env ALL_PROXY= to
suppress the SOCKS env for HTTP-only workloads. This matters for clients that
eagerly build a transport for ALL_PROXY at construction (httpx raises without
the socks extra); ssh routing is unaffected (it uses its own config, not
ALL_PROXY). HTTP/HTTPS continue to prefer HTTP_PROXY/HTTPS_PROXY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds host-bound credential injection to curb’s network proxy on Linux: for selected hosts, curb terminates TLS with a per-run CA, injects a configured header (e.g. Authorization: Bearer …), and keeps real credentials out of the sandboxed process. It also wires the feature through CLI/env/config/profile inputs, updates help/docs, and adds unit + integration coverage.

Changes:

  • Add a per-run CA + Injector to the HTTP CONNECT proxy for per-host TLS termination and header injection.
  • Add --inject-bearer / --inject-header with env/config/profile plumbing, plus conditional env-set marker support and improved proxy env precedence behavior.
  • Add unit and end-to-end tests and update documentation (README + configuration/comparison docs + Claude profile).

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
sandbox/skill.go Adds SKILL.md messaging when credential injection is active.
sandbox/proxy_handler.go Constructs proxy.Injector and binds per-host injections into the proxy handler.
sandbox/plan.go Adds plan fields for CA/injection bindings; parses inject specs; resolves secrets; writes CA bundle; adds conditional env-set marker + ALL_PROXY precedence changes; prints injection info in dry-run.
sandbox/plan_test.go Adds tests for conditional env-set and ALL_PROXY precedence; minor formatting change.
sandbox/plan_linux.go Parses injection bindings early and auto-allows injection hosts; resolves injection after env resolution.
sandbox/plan_darwin.go Rejects injection flags on macOS.
sandbox/inject_test.go Unit tests for inject spec parsing, secret source resolution, and CA bundle writing.
sandbox/inject_integration_test.go Linux end-to-end tests running the real curb binary to validate injection + placeholder overwrite.
sandbox/capabilities_linux_test.go Verifies --dry-run reports injection without leaking token values.
README.md Documents basic usage for --inject-bearer and adds CLI reference entries.
proxy/inject.go Implements per-run CA, injector bindings, CONNECT TLS termination, and decrypted HTTP forwarding with injection.
proxy/inject_test.go Unit tests verifying correct binding behavior and placeholder overwrite (including arbitrary headers).
proxy/handler.go Adds optional Injector hook for CONNECT handling.
docs/configuration.md Adds configuration documentation for injection flags, sources, CA env vars, and the Claude profile seal.
docs/comparison-zerobox.md Updates comparison to reflect opt-in per-host TLS termination/injection.
config/profiles/claude.yaml Seals ANTHROPIC_API_KEY via conditional placeholder + optional header injection.
config/profile_test.go Tests that the Claude profile merge adds injection + placeholder and removes passthrough of the real key.
config/configfile.go Adds inject-bearer / inject-header keys and validation; merges them into runtime config.
config/configfile_test.go Tests parsing/validation and list-prepend merge semantics for injection fields.
config/config.go Adds config fields, flag parsing, and env merging for injection lists.
config/config_test.go Adds tests for env additive merge behavior for injection lists.
cmd/root.go Adds injection example to root help and registers new flags.
cmd/help.go Includes injection flags in the “Network” help group ordering.
CLAUDE.md Updates operational notes for ALL_PROXY precedence and injection behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sandbox/plan.go Outdated
Comment thread sandbox/plan.go
Comment thread proxy/handler.go
Comment thread proxy/inject.go Outdated
Comment thread proxy/inject.go Outdated
Address Copilot review on PR #1:

- Reject wildcard hosts (including "*") in --inject-bearer/--inject-header.
  A wildcard would broaden the domain allowlist while never matching an
  injection binding, so the credential would silently not be injected.
- Normalize injection hosts to lowercase with no trailing dot, both when
  parsing the bindings and when looking them up against CONNECT targets,
  so a binding for api.example.com matches API.EXAMPLE.COM and
  api.example.com.
- Set tls.Certificate.Leaf to the parsed leaf certificate instead of the
  CA certificate.
- Strip hop-by-hop headers (Proxy-Authorization, Connection, etc.) on the
  decrypted injected request before forwarding upstream, via a shared
  stripHopByHop helper. Strip before setting injected headers so a client
  cannot drop an injected header by naming it in Connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.

Comment thread sandbox/plan.go Outdated
Comment thread sandbox/plan.go Outdated
Comment thread proxy/inject.go Outdated
Comment thread config/configfile.go Outdated
Comment thread config/configfile.go Outdated
Comment thread sandbox/skill.go Outdated
Comment thread docs/configuration.md Outdated
Address Copilot re-review on PR #1:

- Fail credential injection with a clear error when no system CA bundle
  can be located or read, instead of silently delivering a bundle with
  only the per-run CA (which would break TLS trust for every other host).
- Validate injection header names against the RFC 7230 token charset at
  parse time, so an invalid name fails clearly instead of as a runtime
  502 from net/http on the upstream round-trip.
- Give the injector's upstream transport the same dial and TLS-handshake
  timeouts as passthrough traffic, so an injected request cannot hang
  indefinitely.
- Reject wildcard injection hosts at config-file load time (previously
  only the plan builder rejected them, giving a CLI-attributed error for
  a config-file field). Factored the validation into a shared
  policy.ValidateInjectHost used by the CLI, config file, and plan.
- Reword the SKILL.md note from "auth headers" to "credential headers"
  since --inject-header injects arbitrary headers (e.g. x-api-key).
- Document that injected hosts are served over HTTP/1.1 only (no h2
  ALPN), so HTTP/2-only clients may not work for those hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Comment thread docs/configuration.md Outdated
Comment thread docs/configuration.md Outdated
Comment thread README.md Outdated
Comment thread config/configfile.go Outdated
Comment thread proxy/inject.go
pjcdawkins and others added 9 commits June 15, 2026 21:08
Address Copilot re-review on PR #1:

- Validate the injection header name at config-file load time, not just in
  CLI parsing, so an invalid token (spaces, ':') fails early with an
  inject-header[i] message instead of later during plan building. Moved the
  RFC 7230 token check into policy.ValidHeaderName, shared by both paths.
- Set req.Host (not just req.URL.Host) on the decrypted upstream request so
  the Host header matches the bound host rather than whatever the client
  sent. Drop the default :443 so the header looks like a direct request.
- docs: list inject-header among the additively-merged config list fields.
- docs/README: stop describing --inject-bearer as literal sugar for
  --inject-header HOST=Authorization=Bearer … (--inject-header has no Bearer
  prefix segment); describe the behavior instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--inject-bearer is not a literal sugar form of --inject-header
HOST=Authorization=Bearer … (--inject-header has no Bearer prefix
segment); describe the mechanism instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework --inject-header to placeholder substitution bound to a host, and
remove --inject-bearer.

curb generates a stable per-variable placeholder, seals the sandbox's copy
of the variable to it, reads the real value from the host's variable, and
the proxy (terminating TLS for the named host only) replaces the placeholder
wherever the client placed it among the request headers. This is
header-agnostic: no auth-scheme knowledge is configured, so Authorization:
Bearer and x-api-key both work with one binding. It keeps curb decoupled
from process-internal wire details, which matters for a fast-changing API
surface and for end users writing config.

Syntax changes:
- --inject-header is now ENV_VAR=HOST (was HOST=HEADER=SOURCE).
- --inject-bearer is removed; scan-replace handles the bearer case.
- The @/@? source markers are gone. The left side is always an env var name;
  injection is opt-in and skipped silently when the variable is unset. No
  literal sources (they could not carry a placeholder into the sandbox).
- Var-first ordering leaves room for a future comma-separated host list.

The claude profile collapses two coordinated lines (inject + env seal) into
one: ANTHROPIC_API_KEY=api.anthropic.com. curb seals the variable itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Prevent placeholder prefix collisions: wrap the env var as
  curb-sealed-<VAR>-placeholder. Because a valid env var name cannot contain
  "-", no placeholder can be a prefix of another (e.g. TOK vs TOK2), so the
  substring substitution in replaceInHeaders cannot corrupt one credential's
  placeholder while replacing another bound to the same host.
- Reject "=" in domains/injection hosts. Previously --inject-header A=b.com=x
  parsed into a host "b.com=x" that no CONNECT target matches, silently binding
  nothing instead of reporting the typo.
- Remove the "VAR=?value" conditional env-set feature (added earlier in this
  branch for the old sealing approach). Credential injection now seals its
  variable automatically, so it had no remaining user; drop the code, its test,
  and its docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim the verbose comments added for credential injection and remove the
"sealing" terminology in favor of "credential injection" throughout.

- claude.yaml: drop the long credential-sealing block; keep one short
  comment on the inject-header block.
- plan.go: condense the injectSpec/parseInjectHeader/injectPlaceholder
  comments; rename the `seals` variable to `placeholders`.
- Rename the placeholder prefix curb-sealed- to curb-inject- (updating the
  integration test and the configuration.md example, which also had the
  env-var/-placeholder ordering wrong).
- CLAUDE.md, README.md, profile_test.go, comparison-zerobox.md,
  configuration.md: reword the remaining "seal" prose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate three copies of host normalization into policy.NormalizeHost,
shared by the domain matcher, the injection-host validator, and the proxy's
binding lookup so they agree on what counts as the same host.

Consolidate the ENV_VAR=HOST parse-and-validate (previously duplicated in
config/configfile.go and sandbox/plan.go) into policy.ParseInjectHeader; each
caller wraps the error with its own flag/field prefix.

Drop a redundant req.WithContext(context.Background()) on the proxy forward
path: ReadRequest-produced requests already carry a Background context, so the
wrap allocated a shallow request copy per request for no effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ensure --inject-header does not add domains to the allowlist. Enable active injection planning on macOS while keeping inactive bindings as no-ops. Document the explicit allowlist requirement.

Written by Codex.
Inject credentials on all egress paths and harden trust delivery, from the
high-effort branch review.

- Wire injection into the SOCKS5 path and refuse plain HTTP. Extract the shared
  TLS-terminate-and-serve logic onto Injector.Serve, consulted by both the HTTP
  CONNECT and SOCKS5 proxies via a shared buildInjector. A bound host reached
  over plain HTTP is refused (502) rather than forwarded, since injecting over
  cleartext would expose the credential.
- Extend a user-provided SSL_CERT_FILE as the CA-bundle base instead of
  discarding it, so a custom trust store still applies to non-injected hosts.
- Sign leaf certs without holding the CA lock (RWMutex double-check), so a cache
  hit for one host no longer blocks behind a sign for another.
- Make Injector.binding nil-safe so the three call sites need no separate guard.
- Document the custom ANTHROPIC_BASE_URL gateway case and the HTTPS-only /
  SOCKS5 injection behavior.
- Remove a dead empty-placeholder guard, drop the repetitive env-var-name error
  wording, and rename a leftover "Seals" test.

Per-port termination is left as-is by design (a bound host is expected to be
HTTPS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ENV_VAR=HOST separator with ENV_VAR:HOST[:PORT][,HOST...].
The "=" read as assignment and collided with --env KEY=VALUE, where the
right side genuinely is a value; in inject-header it is a destination. A
colon reads as a binding and parses unambiguously because an env var name
cannot contain one.

The right side is now a comma-separated list of HOST[:PORT] targets, where
HOST is a hostname or IP literal and PORT defaults to 443:

- Host list: one credential valid for several destinations.
- Custom port: bindings are keyed by host:port, so the credential is
  injected only on that exact destination; a connection to the same host
  on another port is relayed unchanged.
- IP targets: authorized against --ips (hostnames against --domains).

Bindings are carried as map[policy.InjectTarget][]proxy.Injection through
the plan, so the structured target is never serialized to a string and
re-parsed.

This is a pre-1.0 interface change with no backwards compatibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pjcdawkins pjcdawkins changed the title feat: host-bound credential injection (--inject-bearer/--inject-header) feat: credential injection via --inject-header Jun 16, 2026
@pjcdawkins pjcdawkins requested a review from Copilot June 16, 2026 08:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Comment thread proxy/inject.go
Comment thread policy/validate.go Outdated
Comment thread sandbox/skill.go Outdated
pjcdawkins and others added 2 commits June 16, 2026 13:38
- Normalize the host once at the start of Injector.Serve so the minted
  leaf cert, SNI, and upstream Host header use the same canonical form
  the binding matched on (a CONNECT like API.EXAMPLE.COM. no longer
  mints a cert for the unnormalized name).
- Attribute inject-host validation errors to "injection host" instead
  of "--domains" so a bad --inject-header target reports the right
  source. Reorder the wildcard check ahead of pattern validation for a
  clearer message.
- Reword the SKILL.md credential-injection note: the credential is
  substituted into requests the agent already sends (placeholder in an
  env var), not added to requests automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve pass-through SSL_CERT_FILE as the base for injected CA bundles.

Treat ALL_PROXY and all_proxy as one override so either casing suppresses SOCKS env injection.

Bracket IPv6 authorities when injected HTTPS uses the default port.

Written by Codex.
Preserve comma-separated inject target lists by avoiding pflag StringSlice splitting.

Let exact env passthrough disable injection for that variable while keeping wildcard passthrough protected.

Extend existing CA bundle env values before installing injection trust, and allow degraded plans when inject bindings are inactive.

Written by Codex.
Extend the per-run injection CA lifetime and renew cached leaf certificates before they expire.

Add tests covering long-lived generated certs and cache renewal.

Generated by Codex.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.

Comment thread sandbox/plan.go
Comment on lines +739 to +747
func (p *SandboxPlan) caBundleBase(name string) string {
if base := p.EnvSet[name]; base != "" {
return base
}
if base, _ := p.passthroughEnvValue(name); base != "" {
return base
}
return ""
}
Comment thread policy/validate.go
Comment on lines +115 to +120
// A bare IP literal (no port): ParseAddr accepts an unbracketed IPv6
// address, which SplitHostPort below would reject.
if addr, err := netip.ParseAddr(item); err == nil {
return InjectTarget{Host: addr.String(), Port: "443", IsIP: true}, nil
}
host, port := item, "443"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants