Pluggable HTTP Transport
July 31, 2026 · View on GitHub
Host-injectable transport for all outbound HTTP made by the
curl/wget/httpbuiltins, so embedders can direct sandbox traffic through their own boundary (egress gateway, proxy, audit layer) while bashkit keeps enforcing HTTP policy.
Status
Implemented
Problem
Embedding hosts (reference consumer: everruns, specs/egress.md there) must route all outbound traffic through a host-owned egress boundary — for network policy, audit, signing, and airgapped deployments. bashkit's built-in reqwest connectivity dials the network directly and deliberately ignores host proxy env vars (TM-NET-015), so without an injection point an embedder cannot centralize sandbox HTTP. The legacy HttpHandler hook was too weak for this: loose (method, url, body, headers) args, no timeouts, no SSRF precheck result, stringly-typed errors that could not distinguish a host policy denial from a connect failure.
Design Decisions
- bashkit owns policy, the transport owns connectivity. Every policy step runs before
HttpTransport::execute: URL allowlist check, DNS/private-IP SSRF precheck,before_httphooks (credential injection), bot-auth signing, and the response size cap (re-checked after the transport returns). A transport moves bytes; it cannot be used to bypass the sandbox boundary. - Follows fetchkit. Same shape as fetchkit's
HttpTransport/TransportRequest(reference:everruns/fetchkit), so a host can back both libraries with one egress implementation. Differences: bashkit's response is buffered (curl/wget buffer bodies up tomax_response_bytesanyway) and the request carriesconnect_timeout(curl--connect-timeout). - Request struct, not loose args.
HttpTransportRequestis#[non_exhaustive]: method, url, merged headers, body, effective timeout, connect timeout,pinned_addrs,max_response_bytes. New context can be added without breaking transports. NoDebugderive — headers can carry credentials (TM-LOG-001). - Typed errors map to curl exit codes.
HttpTransportError::{Denied, Timeout, TooLarge, Transport}render with the message prefixes curl/wget already map to exit codes 7/28/63/1. A host policy denial at the egress boundary surfaces to the script exactly like a bashkit allowlist denial. - Pinned addresses close the rebind window at the host boundary. The SSRF precheck's resolve-then-check result is forwarded as
pinned_addrs(the validated IP literal, or the resolved-and-filtered addresses; empty on the documented DNS fail-open path or when private-IP blocking is disabled). Host transports forward them (e.g.EgressRequest.pinned_addrs); self-dialing transports connect to them or re-resolve + re-filter (is_private_ip). Same TM-NET-023 responsibility split asHttpHandlerhad, now with the data to act on it. - Signing is preserved. Bot-auth signing headers are computed in
HttpClientand merged intoHttpTransportRequest.headersbefore dispatch — identical to the built-in reqwest path. Redirects are followed manually by curl/wget, so every hop is re-validated, re-signed, and re-dispatched through the transport (fetchkit parity: one transport call per hop). - Limits are communicated, then enforced.
max_response_bytesand the effective timeout ride on the request so a well-behaved transport stops early withTooLarge/Timeout; bashkit still enforces both after the fact (tokio::time::timeoutaround the call, size re-check on the returned body), so a misbehaving transport cannot exceed them. - Disabled by default, unchanged. The transport does not widen network access: without the
http_clientfeature and aBashBuilder::network(allowlist)call, HTTP builtins cannot make requests and the transport is never invoked. HttpHandlerremoved, not deprecated. Per repo policy (no compatibility shims), the legacyHttpHandler/set_handler/http_handlersurface is deleted in the same release;HttpTransportis the single extension point. Migration is mechanical: wrap the old(method, url, body, headers)logic inexecute(HttpTransportRequest)and return typed errors.Arc, notBox. Hosts that build oneBashper execution share a single transport across instances.
Request Pipeline
1. Allowlist check ← security gate
2. Private IP / SSRF check ← SSRF protection, produces pinned_addrs
3. before_http hooks ← credential injection lives here
4. Bot-auth signing ← Ed25519 headers
5. Custom HttpTransport OR reqwest ← connectivity only
6. Response size cap re-check ← misbehaving-transport backstop
7. after_http hooks ← observational
API
HttpTransport (trait, execute(HttpTransportRequest) -> Result<Response, HttpTransportError>), HttpTransportRequest, HttpTransportError, re-exported HttpMethod/HttpResponse; injected via BashBuilder::http_transport(Arc<dyn HttpTransport>) or HttpClient::set_transport. See rustdoc on bashkit::HttpTransport for the full contract and an egress-shaped example.
Testing
- Unit (
network/client.rs,network/transport.rs): merged signing headers reach the transport, pinned addrs for IP literals, timeout/cap forwarding, deadline + size enforcement around misbehaving transports; error Display ↔ exit-code contract. - Integration (
tests/integration/network_security_tests.rs,custom_transportmodule): curl/wget end-to-end through a mock transport, allowlist still enforced ahead of the transport,Denied→7 /Timeout→28 /TooLarge→63 /Transport→1 exit codes.
See also
- Request Signing — signing pipeline the transport inherits
- Credential Injection — header injection ahead of dispatch
- Threat Model — TM-NET-023 (SSRF responsibility of custom transports), TM-NET-015 (host proxy isolation on the built-in path)
- Tool Contract — LLM tool surface this feeds