Research & integration design

Iron-Proxy meets
Smithers & Plue

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.

default-deny egress boundary secret rewriting per-request audit sandbox-owned config

2026-06-09 · research deliverable

Primer

What iron-proxy is

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?

  • Default-deny allowlist. Every outbound request is blocked unless the host matches a domain glob or CIDR.
  • Boundary secret rewriting. The proxy holds the real secrets. The sandbox only ever sees placeholder tokens, swapped for real ones on the way out.
  • SSRF / metadata block. Even allowlisted hosts are denied if they resolve into 169.254.169.254 or loopback.
  • Per-request audit. Every request becomes structured JSON: host, action, status, which secret swapped, which rule matched.

Transform pipeline

An ordered chain of policy stages over each request and response:

  • allowlist · default-deny, with a warn-only mode
  • secrets · token → real value from env / file / AWS / 1Password
  • header-allowlist · strip headers before upstream
  • judge · ask an LLM to allow / defer a risky request
  • mcp-policy · default-deny tool allowlist for MCP servers
  • annotate / body-capture · enrich the audit log

DNS :53 · HTTP/HTTPS proxy :80/:443 (TLS via on-the-fly leaf certs) · CONNECT + SOCKS5 tunnel · OTEL export

The gap today

Untrusted agents, unrestricted egress

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.

Plue · Smithers Cloud

  • Agents run in Freestyle microVMs. agent_dispatch.buildServiceSpec sets no firewall: open egress to any IP and port.
  • Secrets are injected as plaintext env (SMITHERS_AGENT_TOKEN, repo secrets). Visible to agent code via env, ps, memory.
  • A deny-default FirewallPolicy already exists, but only workflow sandboxes use it. Agent VMs do not.

Smithers · framework

  • Sandboxed child workflows need egress controls attached to the sandbox request, not to the parent Smithers process.
  • Provider keys should enter the sandbox as placeholders; real secrets stay at the proxy or provider boundary.
  • No core harness mutation: Smithers should not set process-wide proxy env, global dispatchers, or built-in iron-proxy provider shortcuts.

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

One seam. Two postures.

Smithers framework

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.

Plue · Smithers Cloud

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.

<Sandbox egress> → normalizes → executeSandbox() → passes → SandboxProvider.run(request) ← supplied by ← Plue Cloud

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

The seam is already a standard

Every proxy worth using already speaks one contract:

HTTP_PROXYHTTPS_PROXYNO_PROXYNODE_EXTRA_CA_CERTS

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.

What the seam carries

FieldMeaning
envextra sandbox env such as placeholder API keys
httpProxy / httpsProxyproxy endpoints visible inside the sandbox
noProxyhost bypass list, normalized to a comma-separated value
caCertPem / caCertPathCA written into the request bundle, or a path already present in the sandbox
secretBindingsplaceholder → logical secret

No egress config means the current open-source behavior: no proxy overlay added by Smithers.

Smithers core · no vendor names

The SandboxEgressConfig contract

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

Threading it into the sandbox, not the harness

The parent Smithers process does not install proxy env or a global dispatcher. The config stays attached to the sandbox boundary.

<Sandbox>

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 }}
/>

executeSandbox()

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 / local transport

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

Iron-proxy lives inside the sandbox provider

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

Wiring the default into sandbox execution

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

Where the proxy sits

TopologyHowBypass resistanceFit
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

Secrets the sandbox can never read

Today

VM env: ANTHROPIC_API_KEY = sk-ant-REAL…

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.

With iron-proxy

env: …= sk-proxy-7f3
iron-proxy swaps for real keyapi.anthropic.com only

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

Every request, on the record

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:

  • Sandbox lifecycle context: attach audit records to run id, sandbox id, and provider id from SandboxProviderRequest.
  • Plue's OTEL export to Cloud Logging, the same path the proxy supports natively (Axiom, Logfire).
  • A future per-run egress timeline in the run UI: who tried to reach what, allowed or denied, and which secret was swapped.

Denials become alerts. A burst of blocked hosts on a run is a strong tamper signal.

One audit record

{
  "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

Two transforms that fit Smithers exactly

MCP policy

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.

Judge

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

Phased, each step shippable

P0

Spec

Document sandbox-owned egress in .smithers/specs/ and public docs.

P1

Config API

Add SandboxEgressConfig to <Sandbox> and executeSandbox().

P2

Provider path

Pass normalized request.egress into provider-backed sandboxes and redact persisted config.

P3

Local path

Materialize CA files and merge proxy env only inside built-in local sandbox transports.

P4

Plue default

Freestyle provider starts sidecar, injects placeholder env, and applies deny-default firewall.

P5

Harden

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

The seam earns its keep

  • Core stays clean and testable. Tests pass provider objects directly and assert request.egress. No module mocks or harness env mutation.
  • Open-source users keep control. Bring a sandbox provider, use local transports, or omit egress entirely. All paths share the same config type.
  • Cloud gets defense in depth by default. Default-deny egress, secret rewriting, and audit can live inside the hosted Freestyle provider.
  • It composes. Because the seam is the standard proxy contract, it stacks with enterprise proxies and existing firewall rules.
  • One choke point. Network policy, secret handling, and audit converge at the sandbox provider boundary instead of the parent harness.
  • Reuses what exists. Smithers already has provider-backed sandboxes, request bundles, local transports, sandbox events, and diff review.

Honest open questions

What still needs answers

Recap

One sandbox contract, two postures

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.

core: pluggable, no vendor lock-in cloud: secure by default mostly wiring, little new surface

Sources

github.com/ironsh/iron-proxy  ·  Apache-2.0, Go  ·  @mslipper (Optimism)
Smithers framework: packages/sandbox/src/execute.js, packages/sandbox/src/egress.js, packages/components/src/components/Sandbox.js
Plue: Freestyle sandbox provider implementation, sidecar policy, and firewall enforcement
01 / 17
← → / space · F fullscreen · click edges