Skip to content

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

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

feat: credential injection via --inject-header#1
pjcdawkins wants to merge 27 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 Outdated
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 Outdated
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 Outdated
Comment thread policy/validate.go
pjcdawkins and others added 6 commits July 1, 2026 23:35
- Serve injected connections via http.Server + httputil.ReverseProxy.
  Fixes a race on the client reader when the upstream responds before
  consuming a keep-alive request body, supports HTTP/1.1 Upgrade
  (WebSocket) relays on bound hosts, and answers Expect: 100-continue
  instead of stalling clients. Streamed responses (SSE) flush
  immediately, matching the old direct relay.
- Treat an explicit --env VAR=value as an injection opt-out like exact
  passthrough. Previously the placeholder silently clobbered the value
  and the proxy injected the host environment's credential instead.
- Canonicalize injection target ports ("0443" -> "443") so the binding
  key matches a real connection's port.
- Make the plain-HTTP refusal port-exact, matching the documented
  binding contract; other ports of a bound host are relayed unchanged.
- Fail with an actionable error naming the variable and the --env
  passthrough alternative when a binding is active without the proxy
  (--unrestricted-net, or a platform without network filtering).
- Fall back to system roots with a warning when a CA bundle env var
  points at a directory, instead of aborting the run.
- Share one Injector (one upstream connection pool) between the HTTP
  and SOCKS5 proxies; drop the dead writeCABundle wrapper and the
  unreachable empty-bindings branch.
- Document that responses from bound hosts are relayed unscrubbed: an
  endpoint that reflects request headers returns the real credential to
  the sandbox, so a credential should be bound only to destinations it
  was issued for. Note in the claude profile that a custom
  ANTHROPIC_BASE_URL gateway is not covered by the built-in binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Keep the injection opt-out predicate at config level only: resolveInject
  now takes the config and parses bindings itself, removing the duplicated
  parse prelude from both platform builders and the plan-level predicate
  (explicitEnvVars field, hasExactEnvPassthrough), which also counted
  default passthrough vars as opt-outs contrary to the documented
  semantics. activeInjectSpecs resolves each token once.
- Validate --inject-header in FromFlags, matching --domains and --ips.
- Write one CA bundle per distinct base instead of one per env var: the
  common case read and wrote the same ~220 KB system bundle five times.
- Add policy.CanonicalHost as the single host-canonicalization primitive,
  used by the target parser and the proxy binding lookup; drop the
  proxy-local wrapper and the derivable InjectTarget.IsIP field.
- Extract shared helpers for logic duplicated in the diff:
  envPassesThrough (ResolveEnv, dry-run, CA-base lookup),
  injectDestinations (dry-run, skill file), proxyFilteringEnabled
  (capabilities, darwin builder), the --env error-hint suffix, and the
  caBundleEnvKeys list in the dry-run output.
- Reuse ConnListener for the injected-connection server instead of a
  second single-connection listener type; share one discard logger.
- Unexport policy.ValidEnvName (no external callers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- caBundleBase: an explicitly set CA var wins over passthrough, matching
  ResolveEnv precedence. Previously --env SSL_CERT_FILE= (explicit empty)
  fell through to the host value under wildcard passthrough; it now means
  system roots.
- parseInjectTarget: accept a bracketed IPv6 literal without a port
  ("[2001:db8::1]"). Reject brackets in hostnames so a non-IP bracketed
  value fails with a character error instead of passing as a hostname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The proxy-requirement check was reordered ahead of target authorization,
so a binding with no allowed domains reported "requires the network
proxy" instead of the specific "host is not allowed; add --domains"
error (caught by TestDarwinBuildPlan_InjectHeaderActiveRequiresAllowedDomain
on macOS CI). Restore authorization first; the proxy error now fires only
when the destinations are allowed but unfiltered (e.g. --unrestricted-net
with profile domains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- TestCurb_Inject_BoundDestinationOnly: runs the real binary against two
  loopback HTTPS servers, one bound and one not. Confirms the bound
  destination receives the real credential while the unbound port on the
  same host receives only the placeholder over the passthrough relay, and
  greps /proc from inside the sandbox to confirm the parent-held secret is
  not reachable. This is the central "credential bound to its destination"
  property, exercised through the proxy rather than in a unit test.
- TestCurb_Inject_EnvValueOptOut: confirms --env VAR=value disables
  injection at the binary level — the sandbox sees the user's value, not
  the placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onns

TestInjector_ExpectContinue asserted an interim 100 while the upstream
replied without reading the body, so whether the transport read the
request body (triggering the 100) before the upstream's response was a
race — it passed locally and failed on CI. The upstream now reads the
full body before replying, so the body must cross the wire, which cannot
happen until the proxy sends the client its 100. The client also reads
like a real one, tolerating interim 1xx responses.

clientThroughProxy now closes idle connections on cleanup so hijacked
CONNECT tunnels and their per-connection injection servers do not linger
across tests (relevant on macOS, default soft limit 256 fds).

Co-Authored-By: Claude Fable 5 <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 33 out of 33 changed files in this pull request and generated 2 comments.

Comment thread sandbox/plan.go Outdated
Comment thread sandbox/plan.go Outdated
pjcdawkins and others added 3 commits July 2, 2026 08:58
- Keep explicit --env names when '*' is also given (flags or CURB_ENV),
  so an exact passthrough still opts a variable out of credential
  injection under passthrough-all. CURB_ENV values combining '*' with
  NAME=value pairs also no longer drop the pairs.
- The proxy-requirement and platform-support errors now suggest --env
  for each active injection variable instead of only the first.

Written by Claude Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the star classification into a single applyEnvArgs method so the
passthrough-all and explicit-name semantics have one source of truth,
replacing the duplicated containsStar/removeStar sequence.

Written by Claude Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cleanups from a /simplify review of the branch:

- Move the injection planning cluster out of sandbox/plan.go into
  sandbox/inject.go, matching the file-per-concern layout.
- Add config.Config.EnvExplicitlyProvided so the --env grammar and its
  wildcard invariant stay in the config package; delete injectOptOut.
- Add policy.InjectTarget.String and use it for both the dry-run display
  (displayInjectTarget) and the upstream authority (injectedAuthority),
  which duplicated the drop-default-:443 rule in two packages.
- Remove the leaf-renewal machinery in proxy.CA: leaves now share the
  CA's expiry (a per-run in-memory CA never outlives its leaves), so
  leafFor is a plain mutex-guarded cache.
- Extract Handler.hijack for the three copies of the Hijacker prologue;
  share a connEstablished const.
- Set placeholders directly in plan.EnvSet instead of via an
  intermediate map; inline the single-use passthroughEnvValue.
- Drop injectVarNames: the --env flag list in the same message already
  names each variable.
- Trim sandbox TestParseInjectHeader to the wrapper's own behavior
  (policy/validate_test.go owns the validation matrix); merge the two
  GET helpers and the recording-upstream setup in proxy/inject_test.go;
  add direct tests for the moved/new helpers.

Written by Claude Code.

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

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

Comment thread proxy/inject.go
Comment thread proxy/inject.go
Comment thread proxy/handler.go
- Use a random 128-bit leaf serial instead of UnixNano, which could
  collide across leaves signed in the same instant.
- Bound the client TLS handshake in Injector.Serve with dialTimeout so
  a client stalling mid-handshake cannot hang the parent goroutine.
  The deadline is cleared after the handshake; served requests are
  unbounded, matching the passthrough relay.
- Include the port in the plain-HTTP refusal message: bindings are
  host:port exact, and net.JoinHostPort formats IPv6 correctly.

Written by Claude Code.

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

Copilot reviewed 34 out of 34 changed files in this pull request and generated 1 comment.

Comment thread proxy/inject.go
Comment on lines +191 to +206
func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs []Injection) {
clientConn, ok := h.hijack(w, "proxy_inject", host)
if !ok {
return
}
defer func() { _ = clientConn.Close() }()

if _, err := clientConn.Write([]byte(connEstablished)); err != nil {
return
}

h.logEvent("proxy_inject", host, "allowed", "")
if err := h.Injector.Serve(clientConn, host, port, injs); err != nil {
h.logEvent("proxy_inject", host, "error", err.Error())
}
}
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