Skip to content

feat(proxy): proxy tools — credential-injecting egress proxy for general HTTP clients - #8

Draft
paveq wants to merge 11 commits into
mainfrom
feat/proxy-tools
Draft

paveq wants to merge 11 commits into
mainfrom
feat/proxy-tools

Conversation

@paveq

@paveq paveq commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Why

Not all GCP APIs are reachable through gcloud, so agents need a raw HTTP client — but curl with a secret in its env is exactly what SECURITY.md bans, and a host allowlist alone does not fix that: allowed API hosts are multi-tenant, and curl ≥ 8.3 can expand its own env vars (--variable %NAME --expand-url, verified on macOS curl 8.7.1).

Proxy tools invert the model: the tool never holds a secret. A daemon-side intercepting proxy attaches the credential after the request has left the tool, for operator-approved hosts only, and the sandbox pins the tool's network to that proxy.

[tools.curl]
proxy = true

[[tools.curl.routes]]
host   = "*.googleapis.com"
inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" }
allow  = ["GET /**", "POST /v2/projects/*/locations/*/services"]
deny   = ["DELETE /**"]

Design record, including the comparison with claw-wrap: docs/proxy-tools-design.md. Operator-facing threat model and residual risks: SECURITY.md#proxy-tools.

What is in here

  • Schema + matcher (src/proxy.rs, src/config.rs): deny-by-default routes — host + optional METHOD /path allow/deny. Proxy tools may not reference secrets in env nor set proxy/CA variables. Paths an upstream might normalize differently (.., //, %2F) are refused.
  • Runtime (src/proxy/server.rs): per-exec listener on an ephemeral loopback port; mandatory per-exec proxy token (a loopback port has no file mode, unlike the 0700 socket); CONNECT :443 only; client SNI ignored; Host must equal the CONNECT authority; TE+CL / duplicate CL refused; client copies of the injected header stripped; secret looked up per request (stale → 502), assembled in a zeroized buffer; upstream resolved once, non-routable answers refused, then that exact address dialed; upstream TLS verified against public roots; every request audited to the ring buffer without query string or header values.
  • CA (src/proxy/ca.rs): ECDSA P-256, key in memory only, pathlen:0, X.509 Name Constraints limited to routed names; only the certificate is written (airlock-ca.pem, removed on shutdown and by stale-state cleanup). Generated inside the async runtime, so the sync-startup invariant is untouched.
  • Sandbox: requires_network: boolNetworkAccess::{None, Full, ProxyOnly(port)}. macOS: (allow network-outbound (remote tcp "localhost:<port>")), no blanket outbound, no mDNSResponder. Linux: Landlock ABI v4 ConnectTcp on that port; fails closed on kernels < 6.7.
  • Docs: README, SKILL.md, ARCHITECTURE.md, SECURITY.md (curl ban narrowed to "never with secrets in env; only as a proxy tool").

Verification

  • macOS: cargo test 567 passed / 0 failed, clippy --all-targets -D warnings clean, fmt clean.
  • Linux: CI green. tests/proxy_e2e_integration.rs runs real curl through the real daemon there, which exercises both sides of the Landlock rule — the proxy port is reachable (curl receives the proxy's 403 for an unrouted host), and a direct TCP connect to an IP literal is refused immediately (exit 7) rather than timing out.
  • Seatbelt rule checked by hand with sandbox-exec against live listeners: allowed port OK; other loopback port, public IP, and DNS all fail.
  • Apple's system curl (SecureTransport/LibreSSL) honors CURL_CA_BUNDLE and accepts the name-constrained CA — no Homebrew curl needed.
  • Manual smoke test against httpbin.org: injected header reached the upstream, the echoed secret came back as [REDACTED:demo_token], unrouted host → 403, --noproxy '*' → fails at the sandbox, secret absent from airlock logs.

Known limits (documented in SECURITY.md)

  • Misuse within the credential's authority is not prevented — scope the service account narrowly; path rules see the path, not the body.
  • Data (not the credential) can still be sent to co-tenants of allowed multi-tenant hosts.
  • curl -o writes bypass the stdout redactor.
  • Linux: Landlock rule is port-scoped, not host-scoped, and does not cover UDP (DNS exfiltration of data remains possible). A network-namespace backend is the intended follow-up.
  • HTTP/1.1 only; certificate-pinned clients break.
  • Not run: the fail-closed path on a pre-6.7 kernel; no completed-request e2e in tests/ (the SSRF filter refuses loopback upstreams and no production switch was added to bypass it — that path is covered in-crate with a test-only connector, including the real /usr/bin/curl).
  • A resolution containing any non-routable answer is refused outright, which will break split-horizon corporate DNS. Deliberately strict; easy to relax.

🤖 Generated with Claude Code

…ng egress proxy

Not every API has a purpose-built CLI (most of the GCP REST surface is out
of gcloud's reach), but SECURITY.md rightly forbids handing curl a secret:
the agent controls its arguments, and curl can ship its own environment
anywhere. Restricting where curl may connect does not fix that, because
allowed API hosts are multi-tenant and curl >= 8.3 can interpolate env vars
by itself (--variable / --expand-url), macOS included.

Proxy tools invert the model: the tool never holds a secret, and a
daemon-side intercepting proxy attaches the credential after the request
has left the tool, for operator-approved hosts only. The full design,
including what is taken from and changed relative to claw-wrap, is in
[proxy-tools-design.md](docs/proxy-tools-design.md).

This commit lands the declarative half only:

- [proxy.rs](src/proxy.rs): route table, host and METHOD /path matching.
  Deny by default; paths an upstream might normalize differently (.., //,
  %2F) are refused rather than guessed at.
- [config.rs](src/config.rs): `proxy = true` + `[[tools.X.routes]]`. A proxy
  tool may not reference secrets in env nor set the proxy / CA variables
  the daemon will own.
- [daemon.rs](src/daemon.rs): refuses to exec a proxy tool. There is no
  runtime yet, and spawning one the ordinary way would give it open network
  with no enforcement, which is the exact tool SECURITY.md bans.

SECURITY.md also loses the claim that curl has no env interpolation.
…ccess

A proxy tool needs a third network state that a bool cannot express: reach
the daemon's per-exec proxy listener and nothing else. `NetworkAccess::{None,
Full, ProxyOnly(port)}` carries the bound port down to the backend, which is
the only thing that makes the egress rule per-exec rather than global.

On macOS `ProxyOnly` emits `(allow network-outbound (remote tcp
"localhost:P"))` alone — no blanket `network-outbound`, no mDNSResponder
socket, no unix-socket bind. Verified with `sandbox-exec` that a tool under
that profile reaches port P, cannot reach a public IP, cannot resolve a name,
and cannot reach a different loopback port even when something is listening
there. Seatbelt rejects an IP literal in `remote tcp`; `localhost` is the
only form that compiles and it covers the loopback interface the listener
binds to.

On Linux `ProxyOnly` adds the ABI v4 network rights under the existing
`HardRequirement` compat level and grants `ConnectTcp` on that one port, so a
kernel older than 6.7 fails the exec instead of running the tool unpinned.
`None` and `Full` leave network rights unhandled, keeping every existing tool
byte-identical to before.

AgentPolicy keeps its `requires_network: bool` — an agent has exactly two
states and always takes the second one.
The route model landed in phase 0 with nothing behind it. This is the
runtime: a per-exec listener on an ephemeral loopback port that terminates
TLS, vets each request against the tool's routes, attaches the credential,
and forwards upstream over a verified connection.

Why the credential is attached here and not in the tool's environment: a tool
whose arguments the agent controls can read its own environment
(`curl --variable %NAME`), write files the agent reads back, and upload
anything it can read to a multi-tenant host that is on the allowlist. None of
that matters if the tool never holds the secret, so the whole design rests on
attaching it after the request has left the tool. Egress pinning is the
second layer, not the first.

Shape of the request path, kept small enough to audit:

- `vet_request` is pure — request head, CONNECT authority, route in; a status
  and a reason out. Host must equal the authority (no domain fronting), the
  target must be origin-form, `Transfer-Encoding` with `Content-Length` and a
  duplicated `Content-Length` are refused, and the route decides on method and
  path with the query string excluded from matching.
- The CONNECT authority is the single source of truth: it selects the route,
  names the leaf the tool is shown, is the name resolved and dialled, and is
  the name the upstream certificate is verified against. The client's SNI is
  never read, so `--resolve`, `--connect-to` and a forged `Host` cannot make
  any two of those disagree.
- Every client-supplied copy of the injected header is removed before the
  credential goes on, so the upstream can never see two.
- The secret is looked up per request — a refresh applies to the next one, a
  `Stale` slot fails this one with 502 — and assembled into a buffer that is
  zeroized, never through `format!`.
- Addresses are vetted on the concrete `SocketAddr` that is then dialled, with
  no second lookup for a rebinding answer to slip into.

Proxy auth is mandatory rather than optional: the daemon's trust boundary is
a 0700 Unix socket, but a loopback TCP port has no file mode, so without a
per-exec token another local user could race an exec and have the daemon sign
their requests.

The CA is generated once per daemon inside the async runtime, so the
synchronous-startup invariant is untouched. The key never reaches disk, the
certificate is `CA:TRUE, pathlen:0` and carries Name Constraints limited to
the union of routed DNS names, and the leaf cache is capped because a
wildcard route makes the host key space agent-controlled.

Bounds on everything the peer drives: header read timeout and read-buffer
cap via hyper, handshake and connect timeouts, and a cap on concurrent
tunnels per exec. One task per connection, so a panic in the request path
cannot reach the daemon.

`Upstream::fixed` is `cfg(test)` only, which is what lets the tests point a
route at a local rustls server without a production code path that can be
told to skip the SSRF filter. Those tests include Apple's system curl (8.7.1,
SecureTransport/LibreSSL) driven only by the environment the daemon sets: it
honours `CURL_CA_BUNDLE` for an intercepted connection and accepts a leaf
issued by the name-constrained CA, which settles the open question in
docs/proxy-tools-design.md.
Replaces the fail-closed guard with the real flow. The exec handler now
binds a proxy listener before building the sandbox profile — the port has to
exist before the profile can name it — overlays the daemon-owned proxy and
CA-bundle variables onto the child environment after the tool's own `env`,
and sets `NetworkAccess::ProxyOnly` so the sandbox pins egress to that port.

The session is held in a local for the rest of the handler. Dropping it
aborts the serve task and closes the listener, so the proxy comes down with
the child on every path out — normal exit, timeout, kill, client disconnect,
and each early `return` in the validation sequence — without any of them
having to remember to tear it down.

Everything else about an exec is unchanged: stdout and stderr still go
through the redactor, the timeout and child registry still apply, and a tool
with no `proxy` key takes exactly the path it took before.

The CA is created once per daemon in `async_main` (and its embedded twin) and
shared as an `Arc`. It is built there rather than in `synchronous_startup`
because key generation must not precede the fork. `Config` grows a `ca_path`
derived beside `socket_path` and `pid_path`; the certificate is published
there when at least one proxy tool exists, removed at graceful shutdown, and
swept with the socket and PID file when a dead daemon's state is cleaned up.
Through a real daemon and the system curl: the CA certificate's lifecycle
(published when a proxy tool exists, absent when none does, swept as stale
state, removed at shutdown), the proxy's refusal of an unrouted host, and —
the one that matters — that `curl --noproxy '*'` fails at the sandbox rather
than at the environment. The environment is guidance; the profile is
enforcement, and this asserts the difference.

A completed request is deliberately not tested here. The proxy resolves the
upstream itself and refuses any address that is not globally routable, so a
local test server could only stand in as the upstream via a switch that turns
the SSRF filter off — the switch that must not exist in a shipped binary.
That path is covered in src/proxy/server.rs instead, where a cfg(test)
connector can point a route at a local rustls server.
Phase 0 left every document saying the runtime did not exist. Bringing them
back in line with the code, each for its own audience:

- SECURITY.md gains the real "Proxy tools" section: the invariant, what the
  daemon does per execution, the full request-handling table, the CA, and the
  residual risks the design doc listed as things to document once the runtime
  landed. The curl ban narrows from "never" to "never with secrets in its
  environment; only as a proxy tool", and the per-platform sandbox sections
  describe the three network states rather than a boolean.
- README.md documents `proxy` / `routes` without the "schema only" caveat and
  shows the GCP impersonated-token config end to end.
- SKILL.md tells the agent the four things it actually needs: use ordinary
  https URLs, do not pass auth headers, only the listed hosts exist, and a 403
  is policy — retrying with --noproxy or -k makes it worse, not better.
- ARCHITECTURE.md adds the two new modules, the proxy steps in the exec flow,
  a CONNECT-path diagram, and the new crates.
- The design doc becomes a design record rather than a proposal, and its Apple
  curl question is answered: system curl 8.7.1 (SecureTransport/LibreSSL)
  honours CURL_CA_BUNDLE for an intercepted connection and accepts a leaf from
  the name-constrained CA, so no Homebrew curl requirement and no softening of
  the constraint. Phase 2 is marked written but unverified on a Linux host.

CLAUDE.md's lib test count was also three releases stale, and now warns that
`cargo test` wants `< /dev/null` — some client tests read the real stdin and
hang on an inherited pipe that never closes.
…e listener

ProxySession's drop aborted the accept task only. Connections and CONNECT
tunnels run as tasks of their own, so an established tunnel went on
attaching credentials after the exec that owned it had ended. The child and
its process group are killed on every exit path, but a descendant that left
the group (setsid) would have kept a working, credential-signing tunnel past
the exec timeout. A cancellation token owned by the session now reaches
every task under it; [tests.rs](src/proxy/server/tests.rs) has the
regression test, which failed before this change.

Two smaller hardenings in the same path: the CONNECT authority is reduced to
one canonical spelling before it names a leaf certificate and keys the leaf
cache, and the deprecated IPv4-compatible range (::/96) joins the addresses
the upstream dialer refuses, so `::10.0.0.1` is treated like `10.0.0.1`.
The interview decision read 'this PR: design + config schema', which stopped being true once the runtime landed on the same branch. Record why the schema went first instead.
…volving DNS

The existing bypass test targets a DNS name. On Linux that cannot distinguish a working Landlock TCP rule from a failed name lookup, because Landlock does not cover UDP. An unroutable IP literal can: a sandbox denial fails connect() at once (curl exit 7), while an unsandboxed connect hangs until --max-time (exit 28).
…nux CI

Phase 2 was marked unverified because no Linux toolchain was available where it was written. CI has since run both sides of the rule on a real kernel; what remains unexercised is only the refusal on pre-6.7 kernels.
@paveq paveq changed the title feat(config): proxy tools — design + config schema for a credential-injecting egress proxy feat(proxy): proxy tools — credential-injecting egress proxy for general HTTP clients Sep 17, 2026
Two proxy tests were added after the count was last refreshed.
@paveq
paveq added this pull request to stack #10 September 17, 2026 12:02
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.

1 participant