Research & integration design
A sandbox-owned egress seam in Smithers: proxy config is serialized into the sandbox request, interpreted by the selected sandbox provider, and never installed into the parent harness by core.
2026-06-09 · research deliverable
Primer
A default-deny egress firewall for untrusted workloads: CI jobs, sandboxed containers, and AI coding agents. Open-sourced from the Optimism stack by @mslipper. Single Go binary, Apache-2.0.
It sits in front of everything the workload sends out and answers one question per request: is this allowed, and what should it actually carry?
An ordered chain of policy stages over each request and response:
DNS :53 · HTTP/HTTPS proxy :80/:443 (TLS via on-the-fly leaf certs) · CONNECT + SOCKS5 tunnel · OTEL export
The gap today
Both products run code an attacker can influence through prompt injection or a poisoned dependency. Right now that code can phone home freely and read secrets in the clear.
Same failure mode, two layers: the workload can talk to anywhere, and it can read the keys it talks with.
The shape you asked for
A serializable SandboxEgressConfig on <Sandbox> and executeSandbox().
Core validates, redacts, writes CA material into the request bundle, and passes the normalized contract to provider-backed sandboxes. It does not name iron-proxy.
Runs iron-proxy inside the Freestyle sandbox provider implementation.
The hosted product can choose a sidecar, firewall, and secret-rewrite policy, but the workflow still passes a provider object and an egress config.
Provider objects are dependency injection. String provider ids only work after registerSandboxProvider(); core has no built-in iron-proxy id.
Why this is not a big lift
Every proxy worth using already speaks one contract:
Set those four values plus a trusted CA, and a sandbox process routes egress through the proxy, including TLS. Smithers does not need an iron-proxy SDK; it needs to serialize proxy env + CA into the sandbox request.
Iron-proxy's secret rewriting is the bonus on top: because the proxy terminates TLS, it can swap a placeholder for a real key. Smithers gets that for free by honoring the same contract.
| Field | Meaning |
|---|---|
| env | extra sandbox env such as placeholder API keys |
| httpProxy / httpsProxy | proxy endpoints visible inside the sandbox |
| noProxy | host bypass list, normalized to a comma-separated value |
| caCertPem / caCertPath | CA written into the request bundle, or a path already present in the sandbox |
| secretBindings | placeholder → logical secret |
No egress config means the current open-source behavior: no proxy overlay added by Smithers.
Smithers core · no vendor names
packages/sandbox/src/SandboxEgressConfig.ts
export type SandboxEgressConfig = {
env?: Record<string, string>;
httpProxy?: string;
httpsProxy?: string;
noProxy?: string | string[];
caCertPem?: string;
caCertPath?: string;
secretBindings?: Record<string, string>;
};
This is the seam Smithers implements. It is serializable, redacted before persistence, and passed to the sandbox provider as request.egress.
Smithers core · sandbox plumbing
The parent Smithers process does not install proxy env or a global dispatcher. The config stays attached to the sandbox boundary.
The React component accepts egress and stores it in task config. A workflow passes a provider object directly, or a registered provider id.
<Sandbox
provider={remoteVmProvider}
egress={{ httpsProxy, caCertPem, secretBindings }}
/>
Execution normalizes config.egress, redacts secrets in persisted config, writes CA PEM into the request bundle, and builds the provider request.
const egress = normalizeSandboxEgressConfig(rawConfig.egress);
await writeSandboxEgressFiles(egress, requestBundlePath);
provider.run({ ...request, egress, config: rawConfig });
Provider-backed sandboxes receive request.egress. Built-in local transports derive sandbox env from the same config.
const env = {
...sandbox.env,
...sandboxEgressEnv(sandbox.egress),
};
Result: egress policy is sandbox-owned. Smithers core does not mutate parent env, does not hardcode iron-proxy, and does not route unrelated agent calls.
Outside core · provider-owned
A Freestyle provider can choose to start iron-proxy, install its CA, set firewall rules, and translate request.egress into VM-local config. Smithers only passes the request.
function createPlueFreestyleProvider(providerId) {
return {
id: providerId,
async run(request) {
const proxy = await startIronProxyInVm({
httpProxy: request.egress?.httpProxy,
httpsProxy: request.egress?.httpsProxy,
caCertPem: request.egress?.caCertPem,
secretBindings: request.egress?.secretBindings,
});
return runSmithersBundleInVm({ request, proxy });
},
};
}
The provider id is an injected object property or a registered id. Smithers core has no built-in iron-proxy provider shortcut.
Plue · iron-proxy ON by default
Plue sandbox provider · build VM request from SandboxProviderRequest
// 1) Workflow or cloud policy supplies a provider object and sandbox egress config.
<Sandbox
provider={plueFreestyleProvider}
egress={{
httpsProxy: "http://127.0.0.1:8080",
httpProxy: "http://127.0.0.1:8080",
caCertPem,
env: { ANTHROPIC_API_KEY: "sk-proxy-" + runId },
secretBindings: { ["sk-proxy-" + runId]: "anthropic" },
}}
/>
// 2) The provider, not the Smithers harness, creates sidecars/firewall inside the VM.
vm.units.push(ironProxySystemdUnit(request.egress));
vm.firewall = denyDefaultAllowProxyOnly();
vm.env = { ...vm.env, ...sandboxEgressEnv(request.egress) };
The parent process keeps its original env. The proxy config crosses the boundary through the sandbox request and is enforced by the selected sandbox provider.
Plue · deployment choices
| Topology | How | Bypass resistance | Fit |
|---|---|---|---|
| Sidecar in VM | iron-proxy as a systemd unit; HTTP_PROXY → localhost | Low on its own. Pair with Freestyle deny-default (allow only :8080). | Phase 1 default. Simplest. |
| DNS + nftables | VM DNS points at iron-proxy; kernel rules block direct egress | High. Hardcoded IPs cannot escape. | Phase 2 hardening. |
| TPROXY | Transparent kernel intercept (IP_TRANSPARENT) | Highest. Nothing routes around it. | Strongest, most setup. |
| Workspace pods | GKE NetworkPolicy → iron-proxy Service / sidecar | Medium. Workspaces need broad egress. | Audit + secret-rewrite, looser allowlist. |
Sandbox runs want a tight allowlist. Interactive workspaces may need broader reach with audit. Same sandbox egress contract, provider-specific policy profiles.
The security upgrade
Readable via env, ps, a memory dump, or any log line. A prompt-injected step curls it to an attacker. Game over, and the key works anywhere.
The sandbox holds a placeholder. The real key lives in the proxy. An exfiltrated sk-proxy-7f3 is worthless off-box, and the swap only happens for the allowlisted host.
This directly closes Plue's plaintext-secret gap. It needs no agent cooperation: the agent keeps sending Authorization: Bearer <placeholder> and never learns the difference.
Observability
iron-proxy emits one structured JSON entry per request. In the current Smithers seam that log is provider-owned; Plue can forward it onto its own event and OTEL surfaces:
Denials become alerts. A burst of blocked hosts on a run is a strong tamper signal.
{
"host": "api.anthropic.com",
"method": "POST",
"action": "allow",
"status": 200,
"rule": "allowlist:*.anthropic.com",
"secretSwapped": "anthropic",
"runId": "run_8f12",
"durationMs": 412
}
Beyond the network layer
iron-proxy can enforce a default-deny tool allowlist for MCP servers: it filters tools/list and rejects denied tools/call.
For sandboxed workflows, the sandbox provider can route MCP server traffic through the same VM-local proxy. Core does not add a separate MCP egress hook.
For requests that pass the allowlist but still look risky, the judge transform asks an LLM to allow or defer, with timeouts and a circuit breaker.
Smithers already orchestrates models. A judge call is a natural extension of the same machinery, gating the gray-area egress that a static allowlist cannot classify.
Both are provider or proxy policy, outside the Smithers core API. Core stays unaware; Plue decides the posture.
Delivery
Document sandbox-owned egress in .smithers/specs/ and public docs.
Add SandboxEgressConfig to <Sandbox> and executeSandbox().
Pass normalized request.egress into provider-backed sandboxes and redact persisted config.
Materialize CA files and merge proxy env only inside built-in local sandbox transports.
Freestyle provider starts sidecar, injects placeholder env, and applies deny-default firewall.
nftables enforcement, OTEL audit, egress timeline, denial alerts.
P1-P3 are the Smithers seam and can ship without iron-proxy. P4 flips the hosted provider default. Nothing forces open-source users to adopt iron-proxy.
Why this shape holds up
Honest open questions
Recap
Smithers core exposes SandboxEgressConfig, writes CA material into the request bundle, and passes normalized egress to the selected sandbox provider. Plue can make iron-proxy the default inside its Freestyle provider without core hardcoding a provider string or mutating the parent harness.