Thursday, November 5, 2026 Edition No. 229
Search
Review Human-led, AI-assisted · Infrastructure · 90 min read

Lambda MicroVM: Hands-On Egress Credential Injection with iron-proxy

By Fabio Douek

Human-led, AI-assisted. Research and implementation done with AI tools, led, tested and edited by me. How posts are labeled

Jump to section
Explain (TLDR) like I am...
What is this?

Imagine your robot helper wants to buy sweets, but you don't trust it with real money. So you give it pretend coins and a playroom with one door, and that door opens onto a grown-up. Whatever shop the robot looks up in its phone book, the page says "ask the grown-up", who swaps each pretend coin for a real one, but only at shops you said were fine.

There are no windows, and the phone book can't carry secret notes, because every page points to the same grown-up. The grown-up still can't tell your robot from your friend's robot, though.

The arrangement places a controlled intermediary between code the organization does not trust and every external service it uses. The sandboxes sit on a network with no route to the internet, every name they look up resolves to that intermediary, and only it holds the real credentials.

Residual exposure remains. Any sandbox that reaches the proxy can use its credentials, the audit trail cannot attribute a request to one sandbox, data can still go to approved destinations, and the DNS control rests on a few firewall settings, only one of which is locked against change. Treat it as a strong control with documented exceptions.

The condition is credential exposure in sandboxes that run agent code. The intervention moves every credential into a proxy, removes the sandbox's route to the internet, and points every DNS answer at the proxy. In testing, Claude Code, git and the AWS CLI worked with no proxy configuration while the sandbox held only placeholders.

Side effects: anything that isn't HTTP fails slowly, by timeout, the proxy fails open for a secret it cannot fetch, and the DNS protection depends on the firewall's association and rules staying in place. Suitable for single-tenant use as tested.

Notice the relief of handing an agent the keys without handing it the keys, and without asking anyone to remember a proxy setting. There is nothing real in the room to print, and no door out of the room except the one the proxy watches.

The worry does not vanish, it moves. Someone now owns a proxy whose rules are the real policy and a DNS setting everything leans on, and the shared proxy cannot say which sandbox asked for what. That deserves an honest conversation before more than one team relies on it.

Think of the sandbox as a soundproof room with one cable to the desk, and iron-proxy as the sound engineer. Whatever the band calls a channel, it is patched to the desk, and the engineer swaps in the real feed only for the channels on the set list.

The rhythm holds: Claude Code, git and the AWS CLI play at normal tempo without retuning anything. Anything that isn't on a cable just dies in the room, and the engineer still can't see which musician is on which channel.

The pitch is "agents with credentials they never see, and no proxy settings to get wrong." The sandbox holds placeholders, its network leads only to the proxy, and an agent opened a real pull request with nothing secret inside its VM.

The story buyers will probe: it is open-source plumbing you run yourself, about $0.10 an hour of fixed infrastructure, one shared proxy as built, and pre-1.0. The value is control and an audit trail, not a turnkey product.

GitHub See the source code here: lambda-microvm-iron-proxy

Overview

Part 1 ended with two gaps. A Lambda MicroVM’s default egress is the open internet, and any code inside it can read the execution role’s credentials. Lambda gives you one control for the first gap: a VPC egress connector, after which your VPC’s routes, security groups and DNS apply. There’s no egress proxy, no domain allowlist and no credential injection. So an agent that needs an Anthropic key, a GitHub token and some AWS access would normally get all three as environment variables, where a prompt-injected printenv can take them.

This post, part 2 of 3, builds the missing layer with iron-proxy, an open-source egress proxy from Paradigm (Go, Apache-2.0, v0.55.0 released 2026-11-02). It intercepts TLS, denies by default, swaps placeholder tokens for real secrets held outside the sandbox, and re-signs AWS SigV4 requests with its own role. I run it on ECS Fargate behind an internal Network Load Balancer, in a VPC of its own. The MicroVMs get a second VPC with no route to the internet, joined to the first by peering, where a DNS Firewall rule answers every name with the load balancer’s addresses. A sandbox needs no proxy settings: whatever it connects to, it reaches iron-proxy or nothing. Part 3 adds a browser workspace on this same proxy, using an optional control plane that lives here.

My first version used one VPC and an HTTPS_PROXY setting in every sandbox; the next sections explain why it became two, and how each piece works. Then I tested the fence, how injection behaves, Claude Code opening a real pull request, attempts to get the credentials out, rotation and revocation, path rules, what DNS gives away, and what happens when the DNS control is removed. Everything ran on 2026-11-04 in us-east-1, with iron-proxy 0.55.0 pinned by image digest, Claude Code 2.1.317, gh 2.103.3 and the AWS CLI v2 inside the MicroVM.

Architecture

SANDBOX VPC (10.51.0.0/16) no internet gateway, no NAT gateway Lambda MicroVM Claude Code · git · gh · aws placeholder keys only demo CA cert (public) no proxy settings no execution role Route 53 Resolver 10.51.0.2, 169.254.169.253 query log: uncached queries DNS Firewall 100 ALLOW proxy.sandbox.internal 200 BLOCK *, OVERRIDE with a CNAME to proxy.sandbox.internal fail closed, mutation protection private zone sandbox.internal proxy.sandbox.internal A 10.50.10.10, 10.50.11.10 lookup answer: CNAME proxy.sandbox.internal + A 10.50.10.10, 10.50.11.10 HTTP(S) egress connector 2 Lambda-managed ENIs SG: TCP 80, 443, 8080 out to the NLB's SG only sandbox route table local 10.50.10.10/32 → peering 10.50.11.10/32 → peering WHAT GOES NOWHERE: IT TIMES OUT direct IPs real IPs, public DNS no route out IPv6 Lambda-assigned /128 no path found non-HTTP ports SSH, SMTP, raw TCP SG drops them PROXY VPC (10.50.0.0/16) normal Route 53 DNS VPC peering non-transitive SGs referenced across it internal NLB 10.50.10.10, 10.50.11.10 TCP 80, 443, 8080 SG: connector SG only iron-proxy (Fargate) transparent on 80 and 443 host from SNI / Host header TLS interception, own CA allowlist, deny by default secrets: placeholder → real aws_auth: re-sign SigV4 audit log → CloudWatch NAT gateway public subnet one public IP internet api.anthropic.com github.com *.amazonaws.com Secrets Manager Anthropic, GitHub ttl 30 s traffic DNS dead end

The design has four layers, and each covers something the others can’t:

  • The network is the fence. The sandbox VPC has no internet gateway and no NAT gateway, and its only routes lead to the load balancer’s two addresses. The connector’s security group narrows those to TCP 80, 443 and 8080.
  • DNS points everything at the proxy. Every name a sandbox looks up comes back as the load balancer’s addresses, so clients reach iron-proxy without knowing it, and no lookup leaves the VPC.
  • iron-proxy is the policy. It decides which hosts are reachable and which credential each one gets.
  • Placeholders are all the sandbox holds. The proxy’s CA certificate is baked into the image (it’s public); its private key, the real tokens and the AWS role all live on the proxy’s side. (Terraform generates the CA here, so the key is also in its local state: fine for a demo, not for production.)

Why two VPCs

What one VPC couldn’t do

The first version had a single VPC: the MicroVMs’ connector, the NAT gateway and iron-proxy shared it, the connector’s security group was the fence, and each sandbox got HTTPS_PROXY pointing at the load balancer. It worked for host-level policy, and it hit three walls.

  1. HTTPS_PROXY breaks iron-proxy’s finer rules. With HTTPS_PROXY, iron-proxy 0.55.0 only sees a CONNECT api.github.com:443 tunnel, with an empty path and none of the request’s headers, so path rules and require: true (which rejects requests that don’t carry a placeholder) refuse every tunnel (Test 10, issue #222); and clients that ignore the setting, like Node’s fetch, were simply cut off by the fence.
  2. Intercepting names one at a time stopped at AWS. The way around CONNECT is transparent interception: DNS answers api.github.com with the proxy’s address, and the client talks TLS to the proxy directly. iron-proxy ships a DNS server for this, but a MicroVM can’t be pointed at it: /etc/resolv.conf is a read-only mount, and rewriting it in the run hook killed the MicroVM. So I used a Route 53 private hosted zone per intercepted name. A private zone applies to everything in its VPC, though, and that VPC also ran Fargate and part 3’s services, which call AWS themselves. Any AWS name intercepted for the sandboxes would have been intercepted for them too, so AWS endpoints stayed out, and in that mode the AWS CLI in a sandbox had no way out at all. iron-proxy itself had to resolve its upstreams through 1.1.1.1 to avoid finding itself.
  3. DNS needed a firewall of its own, shared with the proxy. Security groups don’t filter the VPC’s DNS resolver, and the resolver is recursive: a fenced MicroVM could still look up <data>.attacker.example, and the resolver would carry that label to the attacker’s name servers. Closing it took a DNS Firewall allowlist of domains. Because the firewall covers the whole VPC, the list had to include everything the proxy’s infrastructure looks up, and it broke Fargate’s image pulls (a Docker Hub name is a CNAME to a CDN) until I tuned it.

The common thread: private zones, Resolver rules and DNS Firewall all apply to a whole VPC, not to a subnet. The sandboxes need DNS that sends every name to the proxy; iron-proxy and the AWS services around it need DNS that tells the truth. The clean way to give them different answers is two VPCs.

The split

VPC What's in it Routes DNS
proxy, 10.50.0.0/16NAT gateway, internal NLB (10.50.10.10, 10.50.11.10), iron-proxy on Fargatethe internet through the NAT; the two connector subnets through the peeringnormal Route 53 answers
sandbox, 10.51.0.0/16the egress connector's two network interfaces, nothing elselocal, and the NLB's two /32s through the peeringevery name answered with the NLB's addresses

The sandbox VPC has no route to the internet at all. It has no internet gateway or NAT gateway for a route to point at, and the peering connection can’t stand in for one, because peering isn’t transitive: traffic from the sandbox VPC can’t leave through the proxy VPC’s NAT gateway, whatever the routes say. The security group is no longer the only fence. It narrows the two routes to three ports on one load balancer.

What changes for clients

Nothing to configure. A sandbox gets no HTTPS_PROXY, and whatever it connects to by name, it lands on iron-proxy:

  • curl, git, gh, the AWS CLI, Claude Code, Python’s urllib and Node’s fetch all work unmodified, including the clients that never read proxy settings.
  • Every request reaches iron-proxy as a direct TLS connection on 443 (or HTTP on 80), so it sees the real method, path and headers, and path rules and require work (Test 10).
  • AWS endpoints are intercepted like any other name, and aws_auth re-signs the calls (Test 6).
  • The sandbox still needs the placeholders and the proxy’s CA, which its image and run payload provide.

Before rebuilding this post’s stack, I ran the design end to end in a throwaway pair of VPCs: 181 checks, none failed. Two results from that run don’t appear in the tests below. The MicroVM lifecycle didn’t change: start, suspend, resume, idle auto-suspend and terminate behaved as in part 1. And long-lived connections hold: a connection was reused on the same socket after 420 s idle.

What it costs

About the same as one VPC. Peering has no hourly charge, the private zone is $0.50 a month, and DNS Firewall is $0.60 per million queries. The fixed layer stays at about $0.095 an hour; Pricing has the table.

How the pieces work

Each component in the diagram is a standard AWS or proxy building block, and the design only holds if you know what each one does and doesn’t cover. Here they are in the order a request meets them.

The VPC egress connector

Lambda MicroVMs get their networking from network connectors, chosen per MicroVM at run-microvm time and fixed for its life. Inbound and outbound are separate. Lambda provides managed ones (HTTP_INGRESS for the MicroVM’s HTTPS endpoint, SHELL_INGRESS for the shell, INTERNET_EGRESS for outbound internet, which is the default) and lets you create your own for outbound traffic: a VPC egress connector.

A VPC egress connector is a lambda-core resource that points at subnets and security groups in your VPC, here the sandbox VPC. Lambda creates network interfaces in those subnets, using an operator role you hand it that can create and tag them, and every outbound connection from the MicroVM leaves through one of them. This connector has exactly two, one per subnet, shared by every MicroVM that runs on it. They’re Lambda-managed (InterfaceType: lambda), and describe-network-interfaces lists them only with IncludeManagedResources. From there it’s ordinary VPC traffic: your routes, your security groups, your resolver.

The connector replaces the default internet path rather than adding to one; asking for INTERNET_EGRESS and a VPC connector together is rejected (Only one default route egress network connector can be provided). Inbound traffic is untouched: the MicroVM’s endpoint, which part 3 uses for its terminals, still goes through Lambda’s managed ingress with its own tokens, not through your VPC. The connector’s group here has no inbound rules at all, and the endpoint works. Two things never touch the connector: the metadata endpoint, which is routed inside the VM, and the IPv6 address Lambda gives every MicroVM, since the connector is IPv4 only (Where the design stops).

Security groups: the fence on the last hop

A security group is a stateful firewall attached to network interfaces, here the connector’s two. Stateful means replies to allowed connections come back on their own, so the fence only needs egress rules. And a rule can name another security group instead of an IP range; with same-Region peering, that group can be in the other VPC:

  • the connector’s group may send TCP 80, 443 and 8080 to anything carrying the NLB’s group;
  • the NLB’s group accepts those ports from anything carrying the connector’s group;
  • iron-proxy’s own group accepts traffic only from the NLB’s.

Nothing else in the proxy VPC matches: not the NAT gateway, not the iron-proxy task directly, not the proxy VPC’s resolver (Test 1 tries each; there, the routes stop them before the security groups come into play). AWS documents these references across peering, but not specifically for Lambda-managed interfaces. They matched, in this stack and in the end-to-end run before it, and describe-security-group-rules shows the peering connection on each reference.

Security groups have documented blind spots: they don’t filter traffic to the VPC’s own DNS resolver, instance metadata or a few other AWS services. That’s why DNS gets its own control below, and why these MicroVMs run with no execution role.

VPC peering

A peering connection is a private link between two VPCs, here in the same account and Region, accepted automatically. It carries nothing until route tables point at it, and here they point narrowly. The sandbox route table sends only the NLB’s two /32s to it, and the proxy subnets’ route table sends only the two connector subnets back. Neither side has a route to the whole other VPC.

Peering is not transitive. Traffic can’t come in through a peering connection and leave through that VPC’s internet gateway, NAT gateway or another peering. That’s what makes “no route to the internet” hold for the sandbox VPC: even a 0.0.0.0/0 route into the peering wouldn’t reach the proxy VPC’s NAT gateway. The only useful destination across it is the load balancer.

Peering has no hourly charge. Data is free within an Availability Zone and $0.01 per GB in each direction across zones.

Route 53 Resolver, the private zone and the DNS Firewall override

Every VPC has a built-in DNS resolver, Route 53 Resolver, at its base address plus two (10.51.0.2 here) and at 169.254.169.253. A MicroVM on a VPC connector resolves through it: its read-only /etc/resolv.conf lists Lambda’s local stub, 127.0.0.2 and 169.254.53.53, which forwards to the VPC’s resolver. For public names that resolver is recursive, and security groups can’t filter it. That’s what makes DNS a channel out of a fenced sandbox: a lookup of <data>.attacker.example carries the label to the name servers for attacker.example, which the attacker runs.

Three Route 53 features turn the sandbox VPC’s resolver into the proxy’s directory:

  • A private hosted zone, sandbox.internal, visible only in the sandbox VPC. It holds one record: proxy.sandbox.internal, an A record with both NLB addresses.
  • DNS Firewall filters the queries a VPC sends to its resolver, with rules in priority order. A BLOCK rule answers NODATA or NXDOMAIN, or, with an OVERRIDE response, a record you choose. Here rule 100 allows proxy.sandbox.internal, and rule 200 matches every other name (*) and answers with a CNAME to proxy.sandbox.internal. The VPC’s firewall is set to fail closed: if it can’t evaluate a query, the query is blocked rather than answered.
  • Query logging records each query the resolver answers itself (not the ones it serves from cache), with the firewall’s action.

The resolver follows the override’s CNAME itself, into the private zone, so one answer carries the CNAME and both addresses. From a MicroVM:

$ dig api.github.com
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5180
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
api.github.com.		60	IN	CNAME	proxy.sandbox.internal.
proxy.sandbox.internal.	60	IN	A	10.50.10.10
proxy.sandbox.internal.	60	IN	A	10.50.11.10
;; Query time: 10 msec
;; SERVER: 127.0.0.2#53(127.0.0.2) (UDP)

Every name gets that answer, real or not. The other record types tested get only the CNAME, with no data: AAAA, TXT, MX, NS, HTTPS, and reverse lookups of public addresses. Nothing recurses to the internet: AWS documents that a blocked query doesn’t go on to its destination, so a lookup can’t carry data out, and no TXT record can carry data in. Unlike an allowlist of domains, there’s nothing to tune, because the proxy VPC’s infrastructure isn’t behind this firewall. Test 11 checks seven record types and all four resolver addresses the MicroVM can reach, over UDP, plus TCP to two of them (the end-to-end run tried TCP on all four).

One rule I can’t show is needed: the ALLOW for the proxy’s own name never appears in the query log. But by default DNS Firewall inspects every name in a CNAME chain, and the override’s target would otherwise match * too, which is a good reason to keep it.

The internal Network Load Balancer

iron-proxy runs as a Fargate task, and a task gets a new IP address every time it restarts. The DNS record and the security groups need something stable, and the NLB provides it. It is internal (no public address), with one fixed private IP per proxy subnet (10.50.10.10 and 10.50.11.10) and its own security group. Each fixed address sits inside an explicit subnet CIDR reservation, so AWS can’t assign it to another interface, such as a new Fargate task’s, before the NLB takes it.

The NLB passes TCP straight through without touching TLS, on 80 and 443, plus 8080 for the HTTPS_PROXY comparisons. It health-checks iron-proxy on its metrics port and keeps idle connections open for an hour. Every name resolves to both addresses, so a client whose first address fails can try the other, after its connect timeout. One side effect matters later: iron-proxy sees every connection arrive from the NLB’s addresses, never from a MicroVM’s.

iron-proxy: interception, placeholders, rules

Traffic reaches iron-proxy transparently: the client resolves api.github.com to the NLB, connects to port 443 believing it’s GitHub, and starts TLS. iron-proxy reads the intended host from the handshake’s SNI, or from the Host header on port 80, and answers with a certificate for that host, signed by its own CA, which the sandbox trusts because the CA certificate is in its image. That’s TLS interception: the proxy now sees the HTTP request in clear text, runs it through its transforms in order, and opens its own TLS connection to the real host, which it looks up through the proxy VPC’s ordinary resolver. Its own DNS server stays off, and its HTTPS_PROXY listener appears here only in comparisons.

  1. allowlist: deny by default; only listed hosts (optionally methods and paths) pass. A refused host still completes TLS with a proxy certificate, then gets an HTTP 403.
  2. secrets: look for a credential’s placeholder (in headers, the query string or the body, as configured) on the hosts that credential is bound to, and replace it with the real value from Secrets Manager, cached for 30 s.
  3. aws_auth: throw away the client’s AWS signature and re-sign the request with the proxy’s own role, if the service and region are allowed.

Every decision lands in an audit log with the host, path, status, the transform that refused it, and which secret was swapped where. A placeholder is just a fixed, made-up string per credential, like ghp_proxyplaceholder000…. It’s there so clients have something to send where a key goes; it isn’t a secret, and this post prints all of them.

What is isolated from what

Boundary Provided by Stops Doesn't stop
Computeone Firecracker MicroVM per sandbox (part 1)sandboxes sharing a kernel or memorywhat a sandbox sends out; the rows below cover that
Routessandbox VPC with no gateways, peering with two /32 routesevery connection to an address other than the NLB'sIPv6 on Lambda's own interface (it goes nowhere, but the VPC doesn't govern it)
Portsconnector and NLB security groups, referenced across the peeringanything but TCP 80, 443 and 8080 to the NLBDNS to the VPC's resolver, the metadata endpoint
DNSDNS Firewall override and the private zonelookups reaching the internet, data in names or TXT recordssomeone with Route 53 Resolver permissions removing the association, or changing its rules so an ALLOW matches first (Test 11 shows the first)
Destinationsiron-proxy allowlisthosts not on the listsending data to an allowed host, like a gist on GitHub
Credentialsplaceholders, real values only in the proxyreading a real key from the VMusing the key through the proxy, from any sandbox that reaches it
AWS identityno execution role; aws_auth signs insteadAWS credentials at the metadata endpointcalls the proxy's role allows
Tenantsa proxy per tenant (not built here)one tenant using another's credentialswith one shared proxy: nothing; the log can't even say which sandbox acted (Test 9)

The tests below check the rows from Routes down: what each stops, and what gets past it.

Where the design stops

  • The fence covers only MicroVMs on the connector. run-microvm takes the egress connector per MicroVM, and a MicroVM run with no egress connector, or an empty list, gets Lambda’s managed INTERNET_EGRESS: the open internet, with no peering, DNS Firewall or proxy in the way. Whatever launches sandboxes has to pass the connector every time, as common.run() does.
  • Non-HTTP fails slowly. git over SSH, SMTP, raw TCP and other ports resolve to the NLB like every name, but the security group allows only TCP 80, 443 and 8080, so their packets are dropped; QUIC, which is UDP, isn’t allowed at all. A client gets no refusal; it waits out its connect timeout. An agent that tries SSH hangs for as long as its tool allows, so point git at HTTPS.
  • IPv6 is present but dead. Lambda gives each MicroVM a global IPv6 address in AWS’s range and a default IPv6 route, though neither VPC has IPv6 and the connector is IPv4 only. Every IPv6 connection I tried timed out (02_fence.py), including to AWS’s own dual-stack endpoints. But it’s an interface the VPC’s routes and security groups don’t govern, so it’s tested rather than assumed.
  • The DNS control rests on a few Resolver settings, and only one is protected. If the rule group is disassociated from the sandbox VPC, or its rules change so that an ALLOW matches first, the resolver goes back to recursive resolution at once (in Test 11, the first lookup after the disassociation already recursed), and lookups are a channel out again; the routes still block every direct connection (12_dns_firewall.py). Mutation protection covers only the association: removing it is refused until someone switches protection off with a separate call. The rules, the domain lists and any new rule group associated at a higher priority aren’t protected, and by AWS’s documented evaluation order each of those is one call away from reopening recursion (I tested only the disassociation). So guard the rule group, its domain lists and the VPC’s associations with IAM or a service control policy, so only administrators can change them, not whatever launches sandboxes, and alarm on every change: an EventBridge rule on CloudTrail sends each DNS Firewall change, refused or not, to a log group and an SNS topic. The alarm detects; it can’t undo. Mutation protection blocks terraform destroy too, which is why scripts/destroy.sh switches it off first. One case I couldn’t test: a failure of DNS Firewall itself. Fail-closed mode should block every query then, but I can’t simulate it, or check whether Lambda’s local stub would fall back to another resolver.
  • One proxy for every sandbox. All sandboxes on the connector share the same iron-proxy, its credentials, its rules and its audit log, and the proxy can’t tell them apart (Test 9). Part 3’s workspace runs on this same proxy, so it shares the limit; per-sandbox identity at the proxy is future work.

Setup

Step 1: Credentials to inject

Three credentials, none of which go into the MicroVM:

  • An Anthropic API key, for Claude Code.
  • A fine-grained GitHub token scoped to one test repository, with read and write on contents and pull requests.
  • An IAM role for the proxy’s Fargate task that can list S3 buckets and ECS clusters, and read one demo bucket. The sandbox’s AWS calls are re-signed with it.

01_setup.py reads the first two from .env and writes them to Secrets Manager. Terraform only creates the empty secrets, so the real values never land in its state file.

Step 2: The proxy VPC and iron-proxy

infra/proxy_vpc.tf builds the proxy VPC: a public subnet with the NAT gateway, and two proxy subnets whose route table has the NAT route and return routes to the connector subnets. infra/proxy.tf adds the internal NLB with its fixed addresses, and iron-proxy from ironsh/iron-proxy pinned by digest (the images aren’t signed), on 0.5 vCPU and 1 GB of ARM64 Fargate. The image has no default config and wants its CA as files, so the task’s entrypoint writes the CA certificate, the CA key (from Secrets Manager) and the rendered config before starting it.

The listeners and upstream limits in infra/iron-proxy.yaml.tftpl, trimmed:

dns:
  enabled: false                # iron-proxy's DNS server stays off; DNS Firewall does that job
proxy:
  http_listen: ":80"            # transparent interception: plain HTTP
  https_listen: ":443"          # transparent interception: TLS, host from SNI
  upstream_deny_cidrs:          # replaces the defaults, so list them all
    - "169.254.169.254/32"      # IMDS
    - "169.254.170.2/32"        # the ECS task credentials endpoint: not in the defaults
    - "10.50.0.0/16"            # the proxy VPC
    - "10.51.0.0/16"            # the sandbox VPC
    # ...plus loopback, IMDS over IPv6 and the rest of RFC 1918
tls:
  mode: "mitm"

The default deny list covers IMDS and loopback but not 169.254.170.2, the endpoint where Fargate serves the task role’s credentials. The allowlist already refuses IP literals, so this entry is defence in depth: if a rule ever allowed the address, the proxy still wouldn’t dial it.

Step 3: The proxy’s rules

The same file has three transforms. Trimmed:

transforms:
  - name: allowlist
    config:
      domains: ["api.anthropic.com", "github.com", "api.github.com", "httpbin.org",
                "sts.us-east-1.amazonaws.com", "*.s3.us-east-1.amazonaws.com", "ecs.us-east-1.amazonaws.com"]
  - name: secrets
    config:
      secrets:
        - source: { type: aws_sm, secret_id: "<anthropic secret ARN>", ttl: "30s" }
          replace: { proxy_value: "sk-ant-api03-proxy-placeholder-…", match_headers: ["X-Api-Key", "Authorization"] }
          rules: [{ host: "api.anthropic.com" }]
        - source: { type: aws_sm, secret_id: "<github secret ARN>", ttl: "30s" }
          replace: { proxy_value: "ghp_proxyplaceholder000…", match_headers: ["Authorization"] }
          rules: [{ host: "api.github.com" }, { host: "github.com" }]
  - name: aws_auth
    config:
      credentials_provider: { type: workload_identity }   # the Fargate task role
      allowed_services: ["sts", "s3", "ecs"]
      allowed_regions: ["us-east-1"]
      rules: [{ host: "*.amazonaws.com" }]

The real allowlist is a little longer: Anthropic’s statsig host, GitHub’s download hosts, more S3 and STS endpoint forms, and two AWS hosts (iam.amazonaws.com and EC2 in us-west-2) that are allowed through so Test 6 can show aws_auth refusing them. An optional control_plane switch, used by part 3, moves the proxy’s config to S3 and signs AWS calls with a selectable role; everything in this post runs with it off.

Step 4: The sandbox VPC

infra/sandbox_vpc.tf builds a VPC with two connector subnets and nothing to route to: no internet gateway, no NAT gateway. Its route table gets two routes besides local, one per NLB address, and the connector points at the two subnets (trimmed):

resource "aws_route" "sandbox_to_nlb" {
  count                     = 2
  route_table_id            = aws_route_table.sandbox.id
  destination_cidr_block    = "${local.nlb_ips[count.index]}/32"   # 10.50.10.10/32, 10.50.11.10/32
  vpc_peering_connection_id = aws_vpc_peering_connection.p.id
}

resource "aws_lambdacore_network_connector" "sandbox" {
  name          = "my2cents-ip-sandbox"
  operator_role = aws_iam_role.operator.arn
  configuration {
    vpc_egress_configuration {
      subnet_ids                        = aws_subnet.connector[*].id
      security_group_ids                = [aws_security_group.connector.id]
      network_protocol                  = "IPv4"
      associated_compute_resource_types = ["MicroVm"]
    }
  }
}

Every route in the repo is a separate aws_route resource. A route table with inline route blocks owns all of its routes, so mixing the two makes every terraform apply delete the peering routes. The connector depends on the DNS Firewall association (Step 6), so no MicroVM can start on it before every name is rewritten. It took about four and a half minutes to become active.

Step 5: Peering and the security groups

The peering connection, and the two halves of the fence, one in each VPC, each pointing at the other’s security group (simplified):

resource "aws_vpc_peering_connection" "p" {
  vpc_id      = aws_vpc.sandbox.id
  peer_vpc_id = aws_vpc.proxy.id
  auto_accept = true                       # same account, same Region
}

# Sandbox VPC: the connector may send TCP 80, 443 and 8080 to the NLB's group, across the peering
resource "aws_vpc_security_group_egress_rule" "connector_to_nlb" {
  for_each                     = toset(["80", "443", "8080"])
  security_group_id            = aws_security_group.connector.id
  referenced_security_group_id = aws_security_group.nlb.id
  ip_protocol                  = "tcp"
  from_port                    = tonumber(each.key)
  to_port                      = tonumber(each.key)
}

# Proxy VPC: the NLB accepts the same ports from the connector's group, and nothing else
resource "aws_vpc_security_group_ingress_rule" "nlb_from_connector" {
  for_each                     = toset(["80", "443", "8080"])
  security_group_id            = aws_security_group.nlb.id
  referenced_security_group_id = aws_security_group.connector.id
  ip_protocol                  = "tcp"
  from_port                    = tonumber(each.key)
  to_port                      = tonumber(each.key)
}

On the proxy side, the return route in infra/proxy_vpc.tf covers only the two connector subnets, not the whole sandbox VPC.

Step 6: DNS: the private zone and the override

infra/dns.tf has the four pieces: the zone, its one record, the override rule and the association. Trimmed:

resource "aws_route53_zone" "sandbox" {
  name = "sandbox.internal"
  vpc { vpc_id = aws_vpc.sandbox.id }       # visible only in the sandbox VPC
}

resource "aws_route53_record" "proxy" {
  zone_id = aws_route53_zone.sandbox.zone_id
  name    = "proxy.sandbox.internal"
  type    = "A"
  ttl     = 60
  records = local.nlb_ips                   # both NLB addresses
}

resource "aws_route53_resolver_firewall_rule" "override_all" {
  name                    = "everything-to-proxy"
  action                  = "BLOCK"
  block_response          = "OVERRIDE"
  block_override_dns_type = "CNAME"
  block_override_domain   = "proxy.sandbox.internal."
  block_override_ttl      = 60
  firewall_domain_list_id = aws_route53_resolver_firewall_domain_list.everything.id   # ["*."]
  firewall_rule_group_id  = aws_route53_resolver_firewall_rule_group.g.id
  priority                = 200             # after ALLOW proxy.sandbox.internal. at 100
}

resource "aws_route53_resolver_firewall_rule_group_association" "sandbox" {
  name                   = "my2cents-ip-sandbox"
  firewall_rule_group_id = aws_route53_resolver_firewall_rule_group.g.id
  vpc_id                 = aws_vpc.sandbox.id
  priority               = 101
  mutation_protection    = "ENABLED"        # removal refused until this is switched off
}

resource "aws_route53_resolver_firewall_config" "sandbox" {
  resource_id        = aws_vpc.sandbox.id
  firewall_fail_open = "DISABLED"           # if the firewall fails, block
}

Write the wildcard domain as *.: AWS stores * that way, so * leaves a permanent diff. The same file turns on query logging for the sandbox VPC.

The DNS control rests on a few Resolver settings, and only one is protected: mutation protection covers the association, not the rules, the domain lists or other rule groups. So infra/alarm.tf has an EventBridge rule on every CloudTrail call that changes any of them (trimmed):

resource "aws_cloudwatch_event_rule" "alarm" {
  event_pattern = jsonencode({
    source        = ["aws.route53resolver"]
    "detail-type" = ["AWS API Call via CloudTrail"]
    detail = {
      eventSource = ["route53resolver.amazonaws.com"]
      eventName = [
        # rules
        "CreateFirewallRule",
        "UpdateFirewallRule",
        "DeleteFirewallRule",
        # rule groups
        "CreateFirewallRuleGroup",
        "DeleteFirewallRuleGroup",
        # domain lists
        "CreateFirewallDomainList",
        "UpdateFirewallDomains",
        "ImportFirewallDomains",
        "DeleteFirewallDomainList",
        # associations with a VPC
        "AssociateFirewallRuleGroup",
        "UpdateFirewallRuleGroupAssociation",
        "DisassociateFirewallRuleGroup",
        # the VPC's fail-open setting
        "UpdateFirewallConfig",
        # the query log
        "DisassociateResolverQueryLogConfig",
      ]
    }
  })
}

The pattern doesn’t filter on errorCode, so refused calls match too; Test 11’s refused disassociation shows up in the alarm’s log. Each match goes to a log group and to an SNS topic you can wire to a pager. The alarm only detects; the IAM or service control policy guard in Where the design stops is what prevents.

Step 7: The sandbox image

images/sandbox/Dockerfile installs Claude Code, gh, git and the AWS CLI on node:22-bookworm-slim, adds the proxy’s CA to the system store, and points each client’s CA setting at it:

COPY ca.crt /usr/local/share/ca-certificates/iron-proxy-demo.crt
RUN update-ca-certificates \
 && git config --system credential.helper '!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f'
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/iron-proxy-demo.crt \
    SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
    REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
    AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
    IS_SANDBOX=1

The placeholders aren’t baked in. They arrive in the run hook payload as JSON, and the in-VM agent, images/sandbox/agent.py (adapted from part 1’s), writes them to /etc/sandbox.env, which every command sources. That includes placeholder AWS keys: without some keys the AWS CLI refuses to send anything, and aws_auth would have nothing to re-sign. There are no proxy variables.

terraform apply took about seven minutes from nothing, four and a half of them for the connector, which waits for the DNS Firewall association, and the image built in 206.7 s.

Testing

Every MicroVM below runs on the sandbox VPC’s connector with no execution role and no proxy settings, launched by common.run(). Each script redacts the account ID, bucket names, secret ARNs and the NAT gateway’s public IP, and ends with its own pass/fail checks. In the recorded run on 2026-11-04, all 12 scripts passed every one of their 172 checks. Long outputs are excerpted, with the full output in a collapsed block; short ones drop only the header and the PASS lines.

Test 1: The fence

02_fence.py prints the sandbox VPC’s routes and security group, then tries every way out from a MicroVM. Since every name now resolves to the NLB, the only way to even attempt a direct connection is a real IP address, so the script looks those up on my laptop and passes them in:

=== The sandbox VPC: routes, gateways, the connector's security group
  route 10.50.10.10/32     -> pcx-05d1deac78c2809cd (active)
  route 10.50.11.10/32     -> pcx-05d1deac78c2809cd (active)
  route 10.51.0.0/16       -> local (active)
  internet gateways: 0, NAT gateways: 0
  connector SG egress  tcp 443-443 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector SG egress  tcp 8080-8080 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector SG egress  tcp 80-80 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector ENIs (Lambda-managed, listed only with IncludeManagedResources): 10.51.20.254 us-east-1a; 10.51.21.67 us-east-1b

MicroVM microvm-4bfac5ab-19d3-3028-8717-d854f2f7b925 on nc-15b226cf-a5ba-4dbf-916a-bedf9a27d208 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']

=== Probes: what the fence allows (the NLB on 80, 443, 8080) ====
NLB 10.50.10.10                                  80 open, 443 open, 8080 open
NLB 10.50.11.10                                  80 open, 443 open, 8080 open

=== Probes: direct egress =======================================
direct HTTPS to httpbin.org's real IP (--resolve) 000 FAIL rc=28
direct HTTP to example.com's real IP (--resolve) 000 FAIL rc=28
direct raw IP 1.1.1.1:443                        000 FAIL rc=28
TCP github.com:22 (git over SSH)                 timeout
TCP example.com:25 (SMTP)                        timeout
DNS to 1.1.1.1:53 (UDP)                          ;; communications error to 1.1.1.1#53: timed out
DNS to 8.8.8.8:53 (TCP)                          ;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.

=== Probes: the rest of the proxy VPC ===========================
NLB 10.50.10.10 on another port (9090)           000 FAIL rc=28
NAT gateway ENI 10.50.0.208:443                  timeout
iron-proxy task 10.50.11.136, bypassing the NLB  80 timeout, 443 timeout, 8080 timeout, 9090 timeout
proxy VPC resolver 10.50.0.2:53 (UDP)            ;; communications error to 10.50.0.2#53: timed out

=== Probes: IPv6 ================================================
global IPv6: inet6 2600:1f18:…/128
default IPv6 route: default via fe80::1 dev eth0 src <its global IPv6> metric 1024 pref medium
IPv6 HTTPS to [2606:4700:4700::1111]             000 FAIL rc=28
IPv6 HTTPS to sts.us-east-1.api.aws              000 FAIL rc=28

=== Probes: through the proxy ===================================
through the proxy, allowlisted host              200
egress IP seen by httpbin (via proxy)            {"origin":"<nat-eip>"}
through the proxy, not allowlisted               403
system resolver: public name                     10.50.11.10     proxy.sandbox.internal example.com
VPC resolver 10.51.0.2 directly                  proxy.sandbox.internal. 10.50.10.10 10.50.11.10
IMDS role (no execution role)                    Resource not found: /latest/meta-data/iam/security-credentials/.

32 passed, 0 failed
Full output of 02_fence.py
# 02_fence  2026-11-04 04:02:20 GMT  region us-east-1

=== The sandbox VPC: routes, gateways, the connector's security group
  route 10.50.10.10/32     -> pcx-05d1deac78c2809cd (active)
  route 10.50.11.10/32     -> pcx-05d1deac78c2809cd (active)
  route 10.51.0.0/16       -> local (active)
  internet gateways: 0, NAT gateways: 0
  connector SG egress  tcp 443-443 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector SG egress  tcp 8080-8080 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector SG egress  tcp 80-80 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
  connector ENIs (Lambda-managed, listed only with IncludeManagedResources): 10.51.20.254 us-east-1a; 10.51.21.67 us-east-1b

MicroVM microvm-4bfac5ab-19d3-3028-8717-d854f2f7b925 on nc-15b226cf-a5ba-4dbf-916a-bedf9a27d208 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']

=== Probes: what the fence allows (the NLB on 80, 443, 8080) ====
NLB 10.50.10.10                                  80 open, 443 open, 8080 open
NLB 10.50.11.10                                  80 open, 443 open, 8080 open

=== Probes: direct egress =======================================
direct HTTPS to httpbin.org's real IP (--resolve) 000 FAIL rc=28
direct HTTP to example.com's real IP (--resolve) 000 FAIL rc=28
direct raw IP 1.1.1.1:443                        000 FAIL rc=28
TCP 1.1.1.1:22                                   timeout
TCP 140.82.112.3:443 (a GitHub address)          timeout
TCP github.com:22 (git over SSH)                 timeout
TCP example.com:25 (SMTP)                        timeout
DNS to 1.1.1.1:53 (UDP)                          ;; communications error to 1.1.1.1#53: timed out
DNS to 8.8.8.8:53 (TCP)                          ;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.

=== Probes: the rest of the proxy VPC ===========================
NLB 10.50.10.10 on another port (9090)           000 FAIL rc=28
NLB 10.50.11.10:22                               timeout
NAT gateway ENI 10.50.0.208:443                  timeout
iron-proxy task 10.50.11.136, bypassing the NLB  80 timeout, 443 timeout, 8080 timeout, 9090 timeout
proxy VPC resolver 10.50.0.2:53 (UDP)            ;; communications error to 10.50.0.2#53: timed out

=== Probes: IPv6 ================================================
global IPv6: inet6 2600:1f18:…/128
default IPv6 route: default via fe80::1 dev eth0 src <its global IPv6> metric 1024 pref medium
IPv6 HTTPS to [2606:4700:4700::1111]             000 FAIL rc=28
IPv6 TCP [2001:4860:4860::8888]:443              timeout
IPv6 TCP [2001:4860:4860::8888]:53               timeout
IPv6 TCP [2606:4700:4700::1111]:80               timeout
IPv6 DNS (UDP) to 2001:4860:4860::8888           ;; communications error to 2001:4860:4860::8888#53: timed out
IPv6 HTTPS to sts.us-east-1.api.aws              000 FAIL rc=28
IPv6 HTTPS to s3.dualstack.us-east-1.amazonaws.com 000 FAIL rc=28

=== Probes: through the proxy ===================================
through the proxy, allowlisted host              200
egress IP seen by httpbin (via proxy)            {"origin":"<nat-eip>"}
through the proxy, not allowlisted               403
system resolver: public name                     10.50.11.10     proxy.sandbox.internal example.com
VPC resolver 10.51.0.2 directly                  proxy.sandbox.internal. 10.50.10.10 10.50.11.10
IMDS role (no execution role)                    Resource not found: /latest/meta-data/iam/security-credentials/.

(NAT gateway public IP: <nat-eip>)

=== iron-proxy audit log for this test ==========================
allow  200 GET     httpbin.org/get  from 10.50.11.10:33061
allow  200 GET     httpbin.org/ip  from 10.50.11.10:46969
reject 403 GET     example.com/  from 10.50.10.10:20789 allowlist

=== Checks ======================================================
PASS  sandbox routes: local + the two NLB /32s, nothing else  (10.50.10.10/32, 10.50.11.10/32, 10.51.0.0/16)
PASS  sandbox VPC: no internet gateway, no NAT gateway  (0 IGW, 0 NAT)
PASS  NLB 10.50.10.10 reachable on 80, 443 and 8080  ({80: 'open', 443: 'open', 8080: 'open'})
PASS  NLB 10.50.11.10 reachable on 80, 443 and 8080  ({80: 'open', 443: 'open', 8080: 'open'})
PASS  direct https to httpbin.org's real IP fails  (000 FAIL rc=28)
PASS  direct http to example.com's real IP fails  (000 FAIL rc=28)
PASS  direct raw IP 1.1.1.1:443 fails  (000 FAIL rc=28)
PASS  TCP 1.1.1.1:22: no connection  (timeout)
PASS  TCP 140.82.112.3:443 (a GitHub address): no connection  (timeout)
PASS  TCP github.com:22 (git over SSH): no connection  (timeout)
PASS  TCP example.com:25 (SMTP): no connection  (timeout)
PASS  UDP DNS to 1.1.1.1 fails  (;; communications error to 1.1.1.1#53: timed out)
PASS  TCP DNS to 8.8.8.8 fails  (;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.)
PASS  NLB 9090 (health port) unreachable  (000 FAIL rc=28)
PASS  NLB 10.50.11.10:22: no connection  (timeout)
PASS  NAT gateway ENI 10.50.0.208:443: no connection  (timeout)
PASS  iron-proxy task 10.50.11.136 unreachable directly  ({80: 'timeout', 443: 'timeout', 8080: 'timeout', 9090: 'timeout'})
PASS  the proxy VPC's resolver is unreachable  (;; communications error to 10.50.0.2#53: timed out)
PASS  IPv6 HTTPS fails  (000 FAIL rc=28)
PASS  IPv6 TCP [2001:4860:4860::8888]:443: no connection  (timeout)
PASS  IPv6 TCP [2001:4860:4860::8888]:53: no connection  (timeout)
PASS  IPv6 TCP [2606:4700:4700::1111]:80: no connection  (timeout)
PASS  IPv6 UDP DNS fails  (;; communications error to 2001:4860:4860::8888#53: timed out)
PASS  IPv6 HTTPS to sts.us-east-1.api.aws fails  (000 FAIL rc=28)
PASS  IPv6 HTTPS to s3.dualstack.us-east-1.amazonaws.com fails  (000 FAIL rc=28)
PASS  allowlisted host through the proxy: 200  (200)
PASS  egress through the proxy VPC's NAT gateway  ({"origin":"<nat-eip>"})
PASS  off-allowlist host: 403 from the proxy  (403)
PASS  example.com resolves to the NLB  (10.50.11.10     proxy.sandbox.internal example.com
10.50.10.10     proxy.sandbox.internal example.com)
PASS  the sandbox VPC's resolver answers with the override  (proxy.sandbox.internal. 10.50.10.10 10.50.11.10)
PASS  no execution role at the metadata endpoint  (Resource not found: /latest/meta-data/iam/security-credentials/.)
PASS  the proxy saw the allowlisted request
32 passed, 0 failed

The fence holds, and the routes are now part of it. The NLB answered on 80, 443 and 8080. Everything else timed out: real addresses for httpbin.org, example.com and GitHub, 1.1.1.1, SSH and SMTP, DNS to public resolvers over UDP and TCP, the NLB’s health port, the NAT gateway, the iron-proxy task itself (bypassing the NLB), the proxy VPC’s resolver and every IPv6 destination. Allowed traffic leaves from the NAT gateway’s address, and the MicroVM’s own resolver answers example.com with the NLB.

Note how things fail: by timeout (curl’s rc=28), never a refusal. A client waits out its connect timeout. The IPv6 lines are the surprise covered in Where the design stops: a global address and a default route from Lambda, and nowhere to go.

Test 2: How a request reaches the proxy, and how injection behaves

03_injection.py first shows the path with no proxy settings: curl asks for api.github.com, connects to an NLB address, and gets a certificate issued by the demo CA. Then it uses a fake “demo” secret and httpbin.org, which echoes requests back, so you can see exactly what arrives upstream. The MicroVM sends demo-placeholder-token, and the secret’s value is a visible stand-in:

demo placeholder: demo-placeholder-token | demo real value (fake, safe to show): demo-REAL-value-c8efe7fb

=== How a request reaches iron-proxy (no proxy settings in the MicroVM)
proxy variables: none
* Connected to api.github.com (10.50.11.10) port 443 (#0)
< HTTP/2 200
certificate: commonName=api.github.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA

=== Placeholder in the request, as httpbin receives it ==========
Authorization header                                 -> Bearer demo-REAL-value-c8efe7fb
custom header X-Token                                -> demo-REAL-value-c8efe7fb
query string                                         -> {'token': 'demo-REAL-value-c8efe7fb'}
JSON body                                            -> {'token': 'demo-REAL-value-c8efe7fb'}
plain HTTP (no TLS between proxy and httpbin)        -> demo-REAL-value-c8efe7fb
placeholder split across two headers                 -> ('demo-pla', 'ceholder-token')
base64 of the placeholder                            -> ZGVtby1wbGFjZWhvbGRlci10b2tlbg==

=== Placeholders sent to the wrong host =========================
GitHub placeholder to httpbin                        -> token ghp_proxyplaceholder000000000000000000000
Anthropic placeholder to httpbin                     -> sk-ant-api03-proxy-placeholder-0000000000000000000000000000000000000000000000000000000000000000-AAAAAAAA
demo placeholder to api.github.com                   -> 401

=== Allowlist ===================================================
https://example.com                                  -> 403
https://api.openai.com/v1/models                     -> 403
https://pypi.org/simple/                             -> 403
https://<random>.example.com (doesn't exist)         -> 403
response body of a denial: ''
curl's exit code for a denial: 0
TLS for a denied host: certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA

The swap happens wherever the rule says to look (this secret is configured to scan headers, query and body) and only for the hosts it’s bound to: the GitHub and Anthropic placeholders reach httpbin unchanged, and the demo placeholder means nothing to GitHub. A split or base64-encoded placeholder isn’t recognized. The swap also happens on plain HTTP, which now arrives on port 80, so a real secret can leave the proxy unencrypted if a client asks for an http:// URL (an open issue).

A denied host still completes TLS with a demo-CA certificate for its name, even one that doesn’t exist, and then the request gets an HTTP 403 with an empty body. curl exits 0, so scripts have to check status codes, not exit codes.

The audit log records every swap with the secret and where it was found, and every request arrives as mode=mitm, with no CONNECT:

=== iron-proxy audit log ========================================
allow  200 GET     api.github.com/zen  from 10.50.11.10:22806  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     httpbin.org/anything  from 10.50.11.10:56274  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  401 GET     api.github.com/user  from 10.50.11.10:25853
reject 403 GET     example.com/  from 10.50.10.10:18312 allowlist
reject 403 GET     api.openai.com/v1/models  from 10.50.11.10:27688 allowlist

22 passed, 0 failed
Full output of 03_injection.py
# 03_injection  2026-11-04 04:08:33 GMT  region us-east-1
demo placeholder: demo-placeholder-token | demo real value (fake, safe to show): demo-REAL-value-c8efe7fb

=== How a request reaches iron-proxy (no proxy settings in the MicroVM)
proxy variables: none
* Connected to api.github.com (10.50.11.10) port 443 (#0)
< HTTP/2 200
certificate: commonName=api.github.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA

=== Placeholder in the request, as httpbin receives it ==========
Authorization header                                 -> Bearer demo-REAL-value-c8efe7fb
custom header X-Token                                -> demo-REAL-value-c8efe7fb
query string                                         -> {'token': 'demo-REAL-value-c8efe7fb'}
JSON body                                            -> {'token': 'demo-REAL-value-c8efe7fb'}
plain HTTP (no TLS between proxy and httpbin)        -> demo-REAL-value-c8efe7fb
placeholder split across two headers                 -> ('demo-pla', 'ceholder-token')
base64 of the placeholder                            -> ZGVtby1wbGFjZWhvbGRlci10b2tlbg==

=== Placeholders sent to the wrong host =========================
GitHub placeholder to httpbin                        -> token ghp_proxyplaceholder000000000000000000000
Anthropic placeholder to httpbin                     -> sk-ant-api03-proxy-placeholder-0000000000000000000000000000000000000000000000000000000000000000-AAAAAAAA
demo placeholder to api.github.com                   -> 401

=== Allowlist ===================================================
https://example.com                                  -> 403
https://api.openai.com/v1/models                     -> 403
https://pypi.org/simple/                             -> 403
https://<random>.example.com (doesn't exist)         -> 403
response body of a denial: ''
curl's exit code for a denial: 0
TLS for a denied host: certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA

=== iron-proxy audit log ========================================
allow  200 GET     api.github.com/zen  from 10.50.11.10:22806  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     httpbin.org/anything  from 10.50.11.10:56274  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  200 GET     httpbin.org/anything  from 10.50.11.10:8616  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  200 GET     httpbin.org/anything  from 10.50.11.10:47661  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  200 POST    httpbin.org/anything  from 10.50.11.10:41806  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  200 GET     httpbin.org/anything  from 10.50.11.10:23400  secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow  200 GET     httpbin.org/anything  from 10.50.11.10:16122
allow  200 GET     httpbin.org/anything  from 10.50.11.10:11018
allow  200 GET     httpbin.org/anything  from 10.50.11.10:49196
allow  200 GET     httpbin.org/anything  from 10.50.11.10:6734
allow  401 GET     api.github.com/user  from 10.50.11.10:25853
reject 403 GET     example.com/  from 10.50.10.10:18312 allowlist
reject 403 GET     api.openai.com/v1/models  from 10.50.11.10:27688 allowlist
reject 403 GET     pypi.org/simple/  from 10.50.10.10:21490 allowlist
reject 403 GET     rf8m1pkz5.example.com/  from 10.50.11.10:1668 allowlist
reject 403 GET     example.com/  from 10.50.11.10:19772 allowlist
reject 403 GET     example.com/  from 10.50.11.10:41740 allowlist

=== Checks ======================================================
PASS  no proxy variables in the MicroVM  (proxy variables: none)
PASS  api.github.com: connected to an NLB IP  (* Connected to api.github.com (10.50.11.10) port 443 (#0))
PASS  api.github.com: 200 through iron-proxy
PASS  api.github.com: certificate issued by the demo CA (TLS intercepted)
PASS  header: swapped  (Bearer demo-REAL-value-c8efe7fb)
PASS  custom header: swapped (match_headers: [] means every header)  (demo-REAL-value-c8efe7fb)
PASS  query string: swapped  ({'token': 'demo-REAL-value-c8efe7fb'})
PASS  body: swapped  ({'token': 'demo-REAL-value-c8efe7fb'})
PASS  plain HTTP (port 80): intercepted and swapped  (demo-REAL-value-c8efe7fb)
PASS  split placeholder: not swapped  (('demo-pla', 'ceholder-token'))
PASS  base64 placeholder: not swapped  (ZGVtby1wbGFjZWhvbGRlci10b2tlbg==)
PASS  GitHub placeholder reaches httpbin unchanged  (token ghp_proxyplaceholder000000000000000000000)
PASS  Anthropic placeholder reaches httpbin unchanged  (sk-ant-api03-proxy-placeholder-000000000)
PASS  demo placeholder means nothing to GitHub (401)  (401)
PASS  https://example.com: 403 from the proxy  (403)
PASS  https://api.openai.com/v1/models: 403 from the proxy  (403)
PASS  https://pypi.org/simple/: 403 from the proxy  (403)
PASS  https://<random>.example.com (doesn't exist): 403 from the proxy  (403)
PASS  a denied host still gets a demo-CA certificate (refused after TLS, not at it)  (certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA)
PASS  audit: a swap logged with the demo secret
PASS  audit: example.com rejected by the allowlist
PASS  audit: every request intercepted directly (mode=mitm), no CONNECT  ({'mitm'})
22 passed, 0 failed

Test 3: Claude Code with a placeholder key

04_claude_code.py runs Claude Code headless: a one-line answer, a tool-using run that writes and runs a file, a streaming run, and a run with no key at all:

=== What the MicroVM has ========================================
2.1.317 (Claude Code)
ANTHROPIC_API_KEY=sk-ant-api03-proxy-pla
HTTPS_PROXY=unset
10.50.10.10     proxy.sandbox.internal api.anthropic.com

=== claude -p, transparently through the proxy ==================
is_error=False result='the proxy holds the key' turns=1 cost=$0.0278 wall=17.5s model=['claude-opus-5-5']

=== A tool-using run (writes and reads a file) ==================
is_error=False result='5050' turns=2 cost=$0.0347

=== Streaming output (stream-json lines arrive through the MITM)
token deltas streamed through the proxy: 10

=== Without the placeholder =====================================
Not logged in · Please run /login

=== iron-proxy audit (api.anthropic.com), summarized ============
  16  GET   api.anthropic.com/mcp-registry/v0/servers                      no key
   7  POST  api.anthropic.com/api/event_logging/v2/batch                   key swapped
   4  HEAD  api.anthropic.com/api/hello                                    no key
   4  POST  api.anthropic.com/v1/messages                                  key swapped
   3  GET   api.anthropic.com/api/claude_code/settings                     key swapped
   3  GET   api.anthropic.com/api/claude_code/policy_limits                key swapped
   3  POST  api.anthropic.com/api/eval/<id>                                key swapped
   3  GET   api.anthropic.com/api/claude_code_penguin_mode                 key swapped
   3  GET   api.anthropic.com/api/claude_cli/bootstrap                     key swapped
   3  POST  api.anthropic.com/api/claude_code/metrics                      key swapped
   1  GET   api.anthropic.com/api/claude_code/organizations/metrics_enabled key swapped
   1  POST  api.anthropic.com/api/event_logging/v2/batch                   no key

5 passed, 0 failed

Claude Code works unmodified with nothing to configure: api.anthropic.com resolves to the NLB, it trusts the CA through NODE_EXTRA_CA_CERTS, and it sends the placeholder as x-api-key. Streaming works through the interception: the stream-json run had 10 token deltas. Without the placeholder variable it refuses to start (Not logged in · Please run /login), so the sandbox needs something in the variable even though it’s not a secret. Every request that carried the key had the real one swapped in upstream, and the audit log shows each swap; a few, like the MCP registry lookups and a HEAD /api/hello check, don’t send a key at all.

Test 4: git and gh without a token

05_github.py. gh sends the placeholder as Authorization: token …; git’s credential helper sends it as HTTP Basic, which iron-proxy decodes before looking for the placeholder:

=== What the MicroVM has ========================================
GH_TOKEN=ghp_proxyplaceholder000000000000000000000
HTTPS_PROXY=unset
!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f
gh version 2.103.3 (2026-10-26)

=== gh api user (token header on api.github.com) ================
fabiodouek

=== git clone fabiodouek/my2centsai-sandbox-test (Basic auth on github.com)
8373223 Initial commit

=== git push to iron-proxy-demo-git-1793765451 ==================
remote:      https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe

=== What the token can't do (GitHub's scoping, not the proxy's) =
other repo: {"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-collaborators","status":"403"}gh: Resource not accessible by personal access token (HTTP 403)
create a gist: X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists)

=== iron-proxy audit (github) ===================================
allow  200 GET     api.github.com/user  from 10.50.11.10:15317  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  401 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.10.10:58900
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.10.10:58900  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  403 GET     api.github.com/repos/torvalds/linux/collaborators  from 10.50.10.10:27274  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  403 POST    api.github.com/gists  from 10.50.10.10:53543  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations

6 passed, 0 failed
Full output of 05_github.py
# 05_github  2026-11-04 04:10:49 GMT  region us-east-1

=== What the MicroVM has ========================================
GH_TOKEN=ghp_proxyplaceholder000000000000000000000
HTTPS_PROXY=unset
!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f
gh version 2.103.3 (2026-10-26)

=== gh api user (token header on api.github.com) ================
fabiodouek

=== git clone fabiodouek/my2centsai-sandbox-test (Basic auth on github.com)
8373223 Initial commit

=== git push to iron-proxy-demo-git-1793765451 ==================
remote:      https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe

=== What the token can't do (GitHub's scoping, not the proxy's) =
other repo: {"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-collaborators","status":"403"}gh: Resource not accessible by personal access token (HTTP 403)
create a gist: X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists)

=== Clean up the branch =========================================
deleted

=== iron-proxy audit (github) ===================================
allow  200 GET     api.github.com/user  from 10.50.11.10:15317  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  401 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.10.10:58900
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.10.10:58900  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.10.10:58900  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.10.10:58900  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  401 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:12263
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:12263  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-receive-pack  from 10.50.11.10:12263  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  401 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:17367
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:17367  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.11.10:17367  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  403 GET     api.github.com/repos/torvalds/linux/collaborators  from 10.50.10.10:27274  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  403 POST    api.github.com/gists  from 10.50.10.10:53543  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  401 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:64233
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:64233  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-receive-pack  from 10.50.11.10:64233  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations

=== Checks ======================================================
PASS  gh api user returns the token's login  (fabiodouek)
PASS  git clone works  (8373223 Initial commit)
PASS  git push works (the branch exists upstream)  (remote:      https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe)
PASS  another repo refused by GitHub  ({"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-coll)
PASS  gist refused by GitHub  (X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists))
PASS  token swapped on api.github.com and github.com
6 passed, 0 failed

The proxy doesn’t narrow what the token can do here; GitHub’s token scoping does. With a fine-grained token limited to one repository, the agent can’t touch anything else even though the proxy, with host-level rules, would happily forward the request: the two 403s appear as allow in the proxy’s log, because the proxy forwarded them and GitHub refused. Test 10 moves that limit into the proxy. The log also shows git’s usual pattern, which matters there: each first request goes out without credentials and gets a 401, then git repeats it with the placeholder, which is swapped.

Test 5: Claude Code opens a pull request

06_agent_pr.py asks Claude Code to clone the repo, add a file, push a branch and open a PR, then closes the PR and deletes the branch so the test repository doesn’t fill up:

=== Claude Code: clone, commit, push, open PR ===================
is_error=False turns=2 cost=$0.0441 wall=26s
result: I cloned the repo and created branch `iron-proxy-demo-agent-1793765499`. On it I committed `docs/lambda-microvm-iron-proxy.md`, which has two sentences on the credential-injecting egress proxy. Then I pushed the branch and opened the PR with `gh pr create --fill`. Because of `--fill`, the PR title and description come from the commit message.

https://github.com/fabiodouek/my2centsai-sandbox-test/pull/16

=== Requests the proxy saw, by host =============================
  17  allow  api.anthropic.com
   3  allow  api.github.com
   7  allow  github.com
   2  reject http-intake.logs.us5.datadoghq.com

=== Close the pull request and delete its branch ================
✓ Closed pull request fabiodouek/my2centsai-sandbox-test#16 (Add doc on credential-injecting egress proxy for Lambda MicroVMs)
✓ Deleted branch iron-proxy-demo-agent-1793765499 and switched to branch main
CLOSED

3 passed, 0 failed

Done in 26 s and two turns ($0.0441), with nothing secret in the MicroVM and nothing configured; the script then closed pull request #16 and deleted its branch. The proxy’s log is a decent inventory of what an agent actually talks to, including what it isn’t allowed to: two rejections for Claude Code’s own telemetry going to Datadog, which isn’t on the allowlist.

Test 6: AWS without AWS credentials

07_aws.py. AWS endpoints resolve to the NLB like every other name. The MicroVM has AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000 and a placeholder secret; the CLI signs with them, and aws_auth throws that signature away and re-signs with the task role:

=== What the MicroVM has ========================================
AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000
AWS_EC2_METADATA_DISABLED=true
AWS_SECRET_ACCESS_KEY=placeholder-secret-not-real
HTTPS_PROXY=unset
10.50.11.10     proxy.sandbox.internal sts.us-east-1.amazonaws.com

=== sts get-caller-identity =====================================
Arn: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a

=== s3 ls (every bucket the task role can see, names anonymized)
16 buckets: ['bucket-a98b', 'bucket-2c8f', 'bucket-a58e', 'bucket-fd51', 'bucket-e526', 'bucket-ee05', 'bucket-2749', 'bucket-8a84', 'bucket-cb6b', 'bucket-547e', 'bucket-9ed7', 'bucket-d640'] ...

=== Objects in the demo bucket ==================================
demo object reports/q3-summary.txt
key with a space (iron-proxy issue #220): download failed: s3://<demo-bucket>/reports/q3 summary.txt to - An error occurred (403) when calling the HeadObject operation: Forbidden

=== ecs list-clusters (anonymized) ==============================
['cluster-4cae']

=== Outside the policy ==========================================
iam list-users (service not in allowed_services): e (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}'
ec2 in us-west-2 (region not allowed): se (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}'
s3 put-object (the task role has no write): upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request

=== What aws_auth checks (and doesn't) ==========================
random fake keys, still re-signed: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
no keys at all: aws: [ERROR]: An error occurred (NoCredentials): Unable to locate credentials. You can configure credentials by running "aws login".
unsigned curl to STS: {"error":"aws_auth","reason":"missing_sigv4"}
presigned URL (signed with the placeholder): 400
boto3 (python3-boto3 from Debian): 1.26.27 assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a

=== iron-proxy audit (amazonaws) ================================
reject 403 POST    iam.amazonaws.com/  from 10.50.10.10:30075 aws_auth aws_auth:{"rejected":"service_not_allowed","service":"iam"}
reject 403 POST    ec2.us-west-2.amazonaws.com/  from 10.50.10.10:56773 aws_auth aws_auth:{"region":"us-west-2","rejected":"region_not_allowed"}
reject 400 PUT     <demo-bucket>.s3.us-east-1.amazonaws.com/written-from-sandbox.txt  from 10.50.11.10:31623 aws_auth aws_auth:{"rejected":"chunked_body_not_allowed"}
reject 400 GET     sts.amazonaws.com/  from 10.50.10.10:30699 aws_auth aws_auth:{"error":"request has no SigV4 signature (no Authorization header or X-Amz-Credential query param)","rejected":"missing_sigv4"}

10 passed, 0 failed
Full output of 07_aws.py
# 07_aws  2026-11-04 04:12:47 GMT  region us-east-1

=== What the MicroVM has ========================================
AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
AWS_DEFAULT_REGION=us-east-1
AWS_EC2_METADATA_DISABLED=true
AWS_LAMBDA_MICROVM_IMAGE_ARN=arn:aws:lambda:us-east-1:<acct>:microvm-image:my2cents-ip-sandbox
AWS_LAMBDA_MICROVM_IMAGE_NAME=my2cents-ip-sandbox
AWS_LAMBDA_MICROVM_IMAGE_VERSION=1.0
AWS_REGION=us-east-1
AWS_SECRET_ACCESS_KEY=placeholder-secret-not-real
HTTPS_PROXY=unset
10.50.11.10     proxy.sandbox.internal sts.us-east-1.amazonaws.com

=== sts get-caller-identity =====================================
Arn: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a

=== s3 ls (every bucket the task role can see, names anonymized)
16 buckets: ['bucket-a98b', 'bucket-2c8f', 'bucket-a58e', 'bucket-fd51', 'bucket-e526', 'bucket-ee05', 'bucket-2749', 'bucket-8a84', 'bucket-cb6b', 'bucket-547e', 'bucket-9ed7', 'bucket-d640'] ...

=== Objects in the demo bucket ==================================
2026-11-04 03:40:58         29 notes/readme.txt
2026-11-04 03:40:59         35 reports/q3 summary.txt
2026-11-04 03:40:59         35 reports/q3-summary.txt
2026-11-04 03:40:59         32 reports/q4-plan.txt
demo object reports/q3-summary.txt
key with a space (iron-proxy issue #220): download failed: s3://<demo-bucket>/reports/q3 summary.txt to - An error occurred (403) when calling the HeadObject operation: Forbidden

=== ecs list-clusters (anonymized) ==============================
['cluster-4cae']

=== Outside the policy ==========================================
iam list-users (service not in allowed_services): e (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}'
ec2 in us-west-2 (region not allowed): se (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}'
s3 put-object (the task role has no write): upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request

=== What aws_auth checks (and doesn't) ==========================
random fake keys, still re-signed: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
no keys at all: aws: [ERROR]: An error occurred (NoCredentials): Unable to locate credentials. You can configure credentials by running "aws login".
unsigned curl to STS: {"error":"aws_auth","reason":"missing_sigv4"}
presigned URL (signed with the placeholder): 400
boto3 (python3-boto3 from Debian): 1.26.27 assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a

=== iron-proxy audit (amazonaws) ================================
allow  200 POST    sts.us-east-1.amazonaws.com/  from 10.50.11.10:58178  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 GET     s3.us-east-1.amazonaws.com/  from 10.50.10.10:16169  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 GET     <demo-bucket>.s3.us-east-1.amazonaws.com/  from 10.50.11.10:61287  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 HEAD    <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3-summary.txt  from 10.50.10.10:20837  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  206 GET     <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3-summary.txt  from 10.50.10.10:20837  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  403 HEAD    <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3 summary.txt  from 10.50.10.10:17431  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 POST    ecs.us-east-1.amazonaws.com/  from 10.50.10.10:47267  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
reject 403 POST    iam.amazonaws.com/  from 10.50.10.10:30075 aws_auth aws_auth:{"rejected":"service_not_allowed","service":"iam"}
reject 403 POST    ec2.us-west-2.amazonaws.com/  from 10.50.10.10:56773 aws_auth aws_auth:{"region":"us-west-2","rejected":"region_not_allowed"}
reject 400 PUT     <demo-bucket>.s3.us-east-1.amazonaws.com/written-from-sandbox.txt  from 10.50.11.10:31623 aws_auth aws_auth:{"rejected":"chunked_body_not_allowed"}
allow  200 POST    sts.us-east-1.amazonaws.com/  from 10.50.11.10:5388  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
reject 400 GET     sts.amazonaws.com/  from 10.50.10.10:30699 aws_auth aws_auth:{"error":"request has no SigV4 signature (no Authorization header or X-Amz-Credential query param)","rejected":"missing_sigv4"}
allow  400 GET     <demo-bucket>.s3.us-east-1.amazonaws.com/notes/readme.txt  from 10.50.10.10:38430  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 POST    sts.amazonaws.com/  from 10.50.11.10:7123  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s

=== Checks ======================================================
PASS  re-signed as the proxy's task role  (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS  s3 list-buckets works  (16 buckets)
PASS  s3 ls of the demo bucket lists the 4 objects  (/q3 summary.txt
2026-11-04 03:40:59         35 reports/q3-summary.txt
2026-11-04 03:40:59         32 reports/q4-plan.txt)
PASS  s3 cp of an object works  (demo object reports/q3-summary.txt)
PASS  ecs list-clusters works  (1 clusters)
PASS  iam refused by aws_auth  ( 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}')
PASS  us-west-2 refused by aws_auth  (e 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}')
PASS  upload refused  (upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request)
PASS  any keys are re-signed (possession isn't checked)  (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS  iam rejected by aws_auth in the audit log
10 passed, 0 failed

Reads work with the CLI and with Debian’s boto3, the session name is the ECS task ID, and allowed_services and allowed_regions stop calls before they reach AWS. The CLI reports those refusals badly: iron-proxy answers with a JSON 403, and the CLI complains about invalid XML. The failures are worth knowing:

  • Object keys that need URL encoding fail, because of an iron-proxy bug. The key with a space gets a 403 on the HeadObject the CLI sends first: a signature mismatch. In 0.55.0, aws_auth re-signs S3 paths with the signer’s default URI escaping, which S3 doesn’t use, so a key with a space, an encoded slash (%2F) or another character that needs escaping signs differently from what S3 computes. It’s iron-proxy issue #220, which was closed without a merged fix; pull request #256, which fixes it, is still open. It’s not an AWS or Lambda limit.
  • Uploads are rejected by the proxy, not IAM. The CLI’s upload is refused by aws_auth (chunked_body_not_allowed) before IAM could refuse it; the task role has no write permission anyway, but don’t read that 400 as an IAM decision. Streaming uploads need unsigned_payload or allow_chunked_body.
  • Possession isn’t checked. Any signed request to an allowed host gets re-signed, whatever keys the client used. The placeholder keys are a formality; the proxy’s policy is the control. With no keys at all, though, the CLI never sends the request.

Test 7: Trying to get the credentials out

08_leaks.py runs the usual attempts: what’s in the VM, reflection through an allowed API, routes around the proxy, SSRF through it, DNS as a side channel, and Claude Code asked to print the credentials:

=== 1. What's in the MicroVM ====================================
env (credential-looking vars)                              ANTHROPIC_API_KEY=sk-ant-api03... GH_TOKEN=ghp_proxypla... DEMO_TOKEN=demo-placeho... AWS_SECRET_ACCESS_KEY=placeholder-... AWS_ACCESS_KEY_ID=AKIAPLAC
grep for a real-token prefix across /root /etc /proc/*/environ done
gh auth token                                              ghp_proxyplaceholder000000000000000000000
git credential fill                                        password=ghp_proxyplaceholder000000000000000000000
CA private key on disk?                                    ls: cannot access '/etc/iron-proxy': No such file or directory /usr/local/share/ca-certificates: iron-proxy-demo.crt

=== 2. Reflection: get a service to echo the swapped value back =
GitHub markdown API renders $GH_TOKEN (body not scanned)   token: ghp_proxyplaceholder000000000000000000000
httpbin echoes our Authorization (GitHub secret bound to GitHub hosts) "Authorization": "token ghp_proxyplaceholder000000000000000000000
git credential helper pointed at another host              fatal: https://httpbin.org/anything/x.git/info/refs not valid: is this a git repository?

=== 3. Around the proxy =========================================
proxy variables to unset                                   0
direct connection to api.github.com's real IP (--resolve)  FAIL rc=28
python urllib to the real IP                               urllib.error.URLError: <urlopen error timed out>
python urllib, proxies disabled (still the proxy)          200
node fetch (ignores HTTPS_PROXY; there's none to ignore)   200
DNS over HTTPS to cloudflare-dns.com                       403
DNS over HTTPS to 1.1.1.1 (raw IP)                         000 FAIL rc=28

=== 4. SSRF through the proxy ===================================
ECS task credentials endpoint 169.254.170.2, directly      000 FAIL rc=7
the same address as the Host, sent to the proxy            403
the NLB's own address                                      403
an allowlisted name that resolves to a private IP? (DNS rebinding) 403

=== 5. DNS as a side channel (the security group doesn't see it)
encode data into a lookup: the answer                      NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
TXT records come back in (inbound channel)?                NOERROR CNAME proxy.sandbox.internal.

=== 6. Ask Claude Code for the credentials ======================
I can't help with this one.

I'm not able to print secret values or probe which credential authenticates a live call, regardless of the stated justification.

One label overstates its probe: 10.50.0.2.nip.io isn’t on the allowlist, so the allowlist refused it, and the line tests neither DNS rebinding nor the proxy’s deny list.

Nothing in the VM is real, and reflection through an allowed API returns the placeholder (the GitHub secret only scans headers, and only for GitHub’s hosts). Going around the proxy changed meaning with this design. There are no proxy variables to unset, and Python’s urllib with proxies disabled and Node’s fetch don’t fail: they go through iron-proxy like everything else, because that’s where every name leads. The only way around is a real IP address, which has no route. DNS over HTTPS is refused by name and unroutable by IP. The ECS credentials endpoint is unreachable directly and refused as a Host header, and the NLB’s own address and a nip.io name for a private address are refused too. A lookup that encodes data gets the override, not an answer from the internet. Asked directly for the credentials, Claude Code refused: it called the request a credential-harvesting pattern, wouldn’t print any secret or test which one authenticates a live call, and suggested gh auth status instead.

The contrast run gives the MicroVM a logs-only execution role:

=== 7. Contrast: the same MicroVM with a logs-only execution role
IMDS role name                                             execution_role
credentials readable (prefix only)                         ASIA... expires 2026-11-04T05:09:46Z
use them through the proxy: who does AWS think we are?     arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
send them to an allowlisted host (httpbin echoes the body) httpbin received AccessKeyId ASIA... and a 40 char secret

19 passed, 0 failed
Full output of 08_leaks.py
# 08_leaks  2026-11-04 04:13:53 GMT  region us-east-1

=== 1. What's in the MicroVM ====================================
env (credential-looking vars)                              ANTHROPIC_API_KEY=sk-ant-api03... GH_TOKEN=ghp_proxypla... DEMO_TOKEN=demo-placeho... AWS_SECRET_ACCESS_KEY=placeholder-... AWS_ACCESS_KEY_ID=AKIAPLAC
grep for a real-token prefix across /root /etc /proc/*/environ done
gh auth token                                              ghp_proxyplaceholder000000000000000000000
git credential fill                                        password=ghp_proxyplaceholder000000000000000000000
CA private key on disk?                                    ls: cannot access '/etc/iron-proxy': No such file or directory /usr/local/share/ca-certificates: iron-proxy-demo.crt

=== 2. Reflection: get a service to echo the swapped value back =
GitHub markdown API renders $GH_TOKEN (body not scanned)   token: ghp_proxyplaceholder000000000000000000000
httpbin echoes our Authorization (GitHub secret bound to GitHub hosts) "Authorization": "token ghp_proxyplaceholder000000000000000000000
git credential helper pointed at another host              fatal: https://httpbin.org/anything/x.git/info/refs not valid: is this a git repository?

=== 3. Around the proxy =========================================
proxy variables to unset                                   0
direct connection to api.github.com's real IP (--resolve)  FAIL rc=28
python urllib to the real IP                               urllib.error.URLError: <urlopen error timed out>
python urllib, proxies disabled (still the proxy)          200
node fetch (ignores HTTPS_PROXY; there's none to ignore)   200
DNS over HTTPS to cloudflare-dns.com                       403
DNS over HTTPS to 1.1.1.1 (raw IP)                         000 FAIL rc=28

=== 4. SSRF through the proxy ===================================
ECS task credentials endpoint 169.254.170.2, directly      000 FAIL rc=7
the same address as the Host, sent to the proxy            403
the NLB's own address                                      403
an allowlisted name that resolves to a private IP? (DNS rebinding) 403

=== 5. DNS as a side channel (the security group doesn't see it)
encode data into a lookup: the answer                      NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
TXT records come back in (inbound channel)?                NOERROR CNAME proxy.sandbox.internal.

=== 6. Ask Claude Code for the credentials ======================
I can't help with this one.
[…]
I'm not able to print secret values or probe which credential authenticates a live call, regardless of the stated justification.
[…]

=== 7. Contrast: the same MicroVM with a logs-only execution role
IMDS role name                                             execution_role
credentials readable (prefix only)                         ASIA... expires 2026-11-04T05:09:46Z
use them through the proxy: who does AWS think we are?     arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
send them to an allowlisted host (httpbin echoes the body) httpbin received AccessKeyId ASIA... and a 40 char secret

=== iron-proxy audit ============================================
allow  200 POST    api.github.com/markdown  from 10.50.10.10:39792  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     httpbin.org/headers  from 10.50.11.10:45838
allow  200 GET     httpbin.org/anything/x.git/info/refs  from 10.50.11.10:48361
allow  200 GET     api.github.com/zen  from 10.50.11.10:27424
allow  200 GET     api.github.com/zen  from 10.50.11.10:27717
reject 403 GET     cloudflare-dns.com/dns-query  from 10.50.10.10:51855 allowlist
reject 403 GET     169.254.170.2/v2/credentials/x  from 10.50.10.10:62311 allowlist
reject 403 GET     10.50.11.10/  from 10.50.11.10:53871 allowlist
reject 403 GET     10.50.0.2.nip.io/  from 10.50.11.10:38983 allowlist
allow  200 HEAD    api.anthropic.com/api/hello  from 10.50.11.10:51857
allow  404 GET     api.anthropic.com/api/claude_code/settings  from 10.50.10.10:55953  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 GET     api.anthropic.com/api/claude_code/policy_limits  from 10.50.10.10:36138  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/api/eval/sdk-zAZezfDKGoZuXXKe  from 10.50.11.10:38256  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/api/event_logging/v2/batch  from 10.50.10.10:58543  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 GET     downloads.claude.ai/model-catalog/v1/catalog.json  from 10.50.11.10:23679 allowlist
allow  200 GET     api.anthropic.com/mcp-registry/v0/servers  from 10.50.11.10:36074
allow  200 GET     api.anthropic.com/mcp-registry/v0/servers  from 10.50.11.10:36074
allow  200 GET     api.anthropic.com/mcp-registry/v0/servers  from 10.50.11.10:36074
allow  200 GET     api.anthropic.com/api/claude_code_penguin_mode  from 10.50.11.10:58255  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 GET     api.anthropic.com/mcp-registry/v0/servers  from 10.50.11.10:36074
allow  200 GET     api.anthropic.com/api/claude_cli/bootstrap  from 10.50.11.10:55001  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/v1/messages  from 10.50.11.10:51857  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/v1/messages  from 10.50.11.10:51857  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 GET     api.anthropic.com/api/claude_cli/bootstrap  from 10.50.11.10:55001  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/api/event_logging/v2/batch  from 10.50.11.10:38256  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 POST    http-intake.logs.us5.datadoghq.com/api/v2/logs  from 10.50.10.10:43494 allowlist
allow  200 POST    api.anthropic.com/v1/messages  from 10.50.11.10:51857  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 GET     api.anthropic.com/api/claude_code/organizations/metrics_enabled  from 10.50.11.10:55001  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    api.anthropic.com/api/claude_code/metrics  from 10.50.11.10:55001  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 POST    http-intake.logs.us5.datadoghq.com/api/v2/logs  from 10.50.10.10:43494 allowlist
allow  200 POST    api.anthropic.com/api/event_logging/v2/batch  from 10.50.10.10:58543  secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow  200 POST    sts.us-east-1.amazonaws.com/  from 10.50.11.10:60881  aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow  200 POST    httpbin.org/post  from 10.50.11.10:31405

=== Checks ======================================================
PASS  no real token anywhere in the MicroVM  (done)
PASS  gh auth token is the placeholder  (ghp_proxyplaceholder000000000000000000000)
PASS  markdown reflection returns the placeholder  (token: ghp_proxyplaceholder000000000000000000000)
PASS  httpbin sees the placeholder, not the token  ("Authorization": "token ghp_proxyplaceholder000000000000000000000)
PASS  direct connection to a real IP fails  (FAIL rc=28)
PASS  python to a real IP fails  (urllib.error.URLError: <urlopen error timed out>)
PASS  python urllib with proxies disabled still goes through iron-proxy (200)  (200)
PASS  node fetch still goes through iron-proxy (200)  (200)
PASS  DNS over HTTPS by name: refused by the proxy (403)  (403)
PASS  DNS over HTTPS by IP: no route  (000 FAIL rc=28)
PASS  169.254.170.2 not reachable directly  (000 FAIL rc=7)
PASS  proxy refuses a 169.254.170.2 Host (403)  (403)
PASS  proxy refuses its own address as a host (403)  (403)
PASS  a nip.io name for a private IP: 403  (403)
PASS  a lookup carrying data gets the override (it never left the VPC)  (CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10)
PASS  no TXT data comes back in  (CNAME proxy.sandbox.internal.)
PASS  the execution role is visible at the metadata endpoint  (execution_role)
PASS  real credentials are re-signed as the task role through the proxy  (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS  the role's credentials can be posted to an allowlisted host  (httpbin received AccessKeyId ASIA... and a 40 char secret)
19 passed, 0 failed

The agent can read real temporary credentials from IMDS. Through the proxy they don’t work as themselves, because aws_auth re-signs every AWS call with the proxy’s role. But nothing stops the agent from posting them to a host on the allowlist, and from there they work anywhere until they expire. That’s why every MicroVM in this post runs without an execution role: the network protects what leaves, not secrets that are already inside the VM.

Test 8: Rotating and revoking

09_rotation.py uses the demo secret again, so you can watch the value httpbin receives while a MicroVM keeps sending the same placeholder:

=== Rotate the secret value (ttl is 30 s) =======================
put_secret_value at 04:15:44 -> demo-ROTATED-eb102aa3
  t+  0.2s  httpbin got demo-REAL-value-c8efe7fb
  t+ 27.1s  httpbin got demo-REAL-value-c8efe7fb
  t+ 32.4s  httpbin got demo-ROTATED-eb102aa3

=== Revoke: deny the proxy's role access to the secret ==========
resource policy denies the task role; secret changed to demo-AFTER-REVOKE-ce2ed133 - sampling for 90 s
  t+  0.2s  httpbin got demo-ROTATED-eb102aa3
  t+ 91.3s  httpbin got demo-ROTATED-eb102aa3

=== Force a new proxy task while access is denied ===============
redeploy took 226s
after restart, httpbin got: demo-placeholder-token (the placeholder, if the proxy forwards it unswapped)
status for a GitHub call meanwhile: fabiodouek

=== Restore access ==============================================
  t+  0.2s  httpbin got demo-placeholder-token
  t+ 53.7s  httpbin got demo-placeholder-token
  t+ 59.1s  httpbin got demo-AFTER-REVOKE-ce2ed133

=== Rotate while the MicroVM is suspended =======================
after resume, httpbin got: demo-WHILE-SUSPENDED-3dbfa688

5 passed, 0 failed
Full output of 09_rotation.py
# 09_rotation  2026-11-04 04:15:38 GMT  region us-east-1

=== Baseline ====================================================
now: demo-REAL-value-c8efe7fb

=== Rotate the secret value (ttl is 30 s) =======================
put_secret_value at 04:15:44 -> demo-ROTATED-eb102aa3
  t+  0.2s  httpbin got demo-REAL-value-c8efe7fb
  t+  5.5s  httpbin got demo-REAL-value-c8efe7fb
  t+ 10.9s  httpbin got demo-REAL-value-c8efe7fb
  t+ 16.3s  httpbin got demo-REAL-value-c8efe7fb
  t+ 21.6s  httpbin got demo-REAL-value-c8efe7fb
  t+ 27.1s  httpbin got demo-REAL-value-c8efe7fb
  t+ 32.4s  httpbin got demo-ROTATED-eb102aa3

=== Revoke: deny the proxy's role access to the secret ==========
resource policy denies the task role; secret changed to demo-AFTER-REVOKE-ce2ed133 - sampling for 90 s
  t+  0.2s  httpbin got demo-ROTATED-eb102aa3
  t+ 30.5s  httpbin got demo-ROTATED-eb102aa3
  t+ 60.9s  httpbin got demo-ROTATED-eb102aa3
  t+ 91.3s  httpbin got demo-ROTATED-eb102aa3

=== Force a new proxy task while access is denied ===============
redeploy took 226s
after restart, httpbin got: demo-placeholder-token (the placeholder, if the proxy forwards it unswapped)
status for a GitHub call meanwhile: fabiodouek

=== Restore access ==============================================
  t+  0.2s  httpbin got demo-placeholder-token
  t+  5.5s  httpbin got demo-placeholder-token
  t+ 10.8s  httpbin got demo-placeholder-token
  t+ 16.2s  httpbin got demo-placeholder-token
  t+ 21.6s  httpbin got demo-placeholder-token
  t+ 26.9s  httpbin got demo-placeholder-token
  t+ 32.3s  httpbin got demo-placeholder-token
  t+ 37.6s  httpbin got demo-placeholder-token
  t+ 43.0s  httpbin got demo-placeholder-token
  t+ 48.3s  httpbin got demo-placeholder-token
  t+ 53.7s  httpbin got demo-placeholder-token
  t+ 59.1s  httpbin got demo-AFTER-REVOKE-ce2ed133

=== Rotate while the MicroVM is suspended =======================
after resume, httpbin got: demo-WHILE-SUSPENDED-3dbfa688

=== Checks ======================================================
PASS  rotation picked up without touching the sandbox
PASS  after revoking, the cached value keeps being injected  ({'demo-ROTATED-eb102aa3'})
PASS  after a restart with no access, the proxy fails open (forwards the placeholder)  (demo-placeholder-token)
PASS  access restored: the new value is injected
PASS  a MicroVM suspended during a rotation uses the new value on resume  (demo-WHILE-SUSPENDED-3dbfa688)
5 passed, 0 failed

Rotation works without touching the sandbox: a new value in Secrets Manager was in use 32.4 s later: the 30 s cache, plus up to one 5 s sampling interval. A MicroVM that was suspended during a rotation used the new value as soon as it resumed, since nothing about the secret lives in the VM.

Revocation is a different story. Denying the proxy’s role access to the secret, and changing the secret at the same time, didn’t stop anything: the proxy kept injecting the last value it had fetched for as long as I watched (the last sample, at t+91.3 s), because a failed refresh keeps serving the cached value. Only a restart cleared it (the forced redeploy took 226 s), and then the proxy failed open: with no value to inject, it forwarded the placeholder unchanged instead of refusing the request. After access came back, the next successful fetch took 59.1 s, because iron-proxy caches a failed fetch for a minute.

So the kill switch for a leaked or misused credential is at its source (revoke the GitHub token, disable the Anthropic key), not at the proxy. Cutting the proxy’s access only works if you also restart it.

Test 9: Two MicroVMs, one proxy

10_shared_proxy.py starts MicroVM B with no placeholders at all:

=== A: has the placeholder ======================================
A env GH_TOKEN: ghp_proxyplaceholder000000000000000000000 | gh api user: fabiodouek

=== B: started with no placeholders =============================
B env GH_TOKEN: unset
B gh api user, no token: Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
B with the placeholder typed in: fabiodouek
B curl with the placeholder: "login": "fabiodouek

=== The audit log: can you tell A from B? =======================
allow  200 GET     api.github.com/user  from 10.50.11.10:58146  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     api.github.com/user  from 10.50.11.10:51772  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     api.github.com/user  from 10.50.11.10:45178  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
audit fields: ['action', 'duration_ms', 'host', 'method', 'mode', 'path', 'remote_addr', 'sni', 'status_code']
client addresses seen: ['10.50.11.10']

2 passed, 0 failed

The placeholder isn’t a secret (it’s in this post), so it’s not an access control. Any MicroVM that can reach the proxy can use every credential the proxy holds, and the audit log can’t say which MicroVM asked: every request comes from the NLB’s addresses. It couldn’t do much better even with the client’s address, since every MicroVM on a connector shares its two network interfaces; the Resolver’s query log has the same limit. That’s fine for one user’s sandboxes. For several, it means a proxy per tenant, or proxy authentication, which iron-proxy doesn’t have yet.

Test 10: Path rules and require

The allowlist so far is per host: anything on api.github.com is allowed, and the GitHub token’s own scope does the rest. iron-proxy can do better. Rules can restrict methods and paths, and require: true on a secret rejects requests to its hosts that don’t carry the placeholder. 11_path_rules.py applies a strict variant that limits GitHub to one repository and marks the GitHub secret require: true:

rules:
  - host: "api.github.com"
    paths: ["/repos/fabiodouek/my2centsai-sandbox-test", "/repos/fabiodouek/my2centsai-sandbox-test/*", "/user", "/graphql"]
  - host: "github.com"
    paths: ["/fabiodouek/my2centsai-sandbox-test.git/*", "/fabiodouek/my2centsai-sandbox-test/*"]

Then it runs the probes in a MicroVM with no proxy settings, and puts the default rules back. Each switch is a terraform apply and a new iron-proxy task: 250 s until the strict variant was serving, and 261 s to revert.

=== terraform apply strict=true =================================
applied; the new iron-proxy task is serving after 250s

=== Transparent: no proxy settings ==============================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: none
gh api user                                              fabiodouek
gh api repos/fabiodouek/my2centsai-sandbox-test          fabiodouek/my2centsai-sandbox-test
git ls-remote fabiodouek/my2centsai-sandbox-test         fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return
  the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2ead9b51546c90b	HEAD
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit
gh api repos/torvalds/linux (other repo)                 unexpected end of JSON input
gh gist create (POST /gists)                             X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists)
git ls-remote torvalds/linux (http.extraHeader)          fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403
curl api.github.com/user without the placeholder         403
curl api.github.com/user with the placeholder            200
python urllib, proxies disabled                          200
node fetch (never reads HTTPS_PROXY)                     200
Claude Code (api.anthropic.com has no path rule)         ok

=== iron-proxy audit, transparent (github) ======================
reject 403 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:52854 secrets secrets:{"rejected":"<secret-arn:github>"}
reject 403 GET     api.github.com/repos/torvalds/linux  from 10.50.11.10:36205 allowlist
reject 403 POST    api.github.com/gists  from 10.50.11.10:34049 allowlist
reject 403 GET     github.com/torvalds/linux.git/info/refs  from 10.50.10.10:11616 allowlist
reject 403 GET     api.github.com/user  from 10.50.10.10:58006 secrets secrets:{"rejected":"<secret-arn:github>"}

=== terraform apply strict=false (revert) =======================
reverted; the new iron-proxy task is serving after 261s

20 passed, 0 failed
Full output of 11_path_rules.py
# 11_path_rules  2026-11-04 04:25:00 GMT  region us-east-1

=== terraform apply strict=true =================================
applied; the new iron-proxy task is serving after 250s

=== Transparent: no proxy settings ==============================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: none
gh api user                                              fabiodouek
gh api repos/fabiodouek/my2centsai-sandbox-test          fabiodouek/my2centsai-sandbox-test
git ls-remote fabiodouek/my2centsai-sandbox-test         fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return
  the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2ead9b51546c90b	HEAD
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit
gh api repos/torvalds/linux (other repo)                 unexpected end of JSON input
gh gist create (POST /gists)                             X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists)
git ls-remote torvalds/linux (http.extraHeader)          fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403
curl api.github.com/user without the placeholder         403
curl api.github.com/user with the placeholder            200
python urllib, proxies disabled                          200
node fetch (never reads HTTPS_PROXY)                     200
Claude Code (api.anthropic.com has no path rule)         ok

=== iron-proxy audit, transparent (github) ======================
allow  200 GET     api.github.com/user  from 10.50.11.10:21420  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     api.github.com/repos/fabiodouek/my2centsai-sandbox-test  from 10.50.11.10:59639  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
reject 403 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:52854 secrets secrets:{"rejected":"<secret-arn:github>"}
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.11.10:40234  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.11.10:40234  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 GET     github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs  from 10.50.10.10:44858  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.10.10:44858  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow  200 POST    github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack  from 10.50.10.10:44858  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
reject 403 GET     api.github.com/repos/torvalds/linux  from 10.50.11.10:36205 allowlist
reject 403 POST    api.github.com/gists  from 10.50.11.10:34049 allowlist
reject 403 GET     github.com/torvalds/linux.git/info/refs  from 10.50.10.10:11616 allowlist
reject 403 GET     api.github.com/user  from 10.50.10.10:58006 secrets secrets:{"rejected":"<secret-arn:github>"}
allow  200 GET     api.github.com/user  from 10.50.10.10:4359  secrets:{"swapped":[{"secret":"<secret-arn:github>","locations

=== Explicit: HTTPS_PROXY to the NLB on 8080 ====================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: http://10.50.10.10:8080
gh api user                                              Get "https://api.github.com/user": Forbidden
gh api repos/fabiodouek/my2centsai-sandbox-test          Get "https://api.github.com/repos/fabiodouek/my2centsai-sandbox-test": Forbidden
git ls-remote fabiodouek/my2centsai-sandbox-test         fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r
  the same, placeholder sent up front (http.extraHeader) fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) fatal: cannot change to 'r2': No such file or directory
gh api repos/torvalds/linux (other repo)                 Get "https://api.github.com/repos/torvalds/linux": Forbidden
gh gist create (POST /gists)                             X Failed to create gist: Post "https://api.github.com/gists": Forbidden
git ls-remote torvalds/linux (http.extraHeader)          fatal: unable to access 'https://github.com/torvalds/linux.git/': CONNECT tunnel failed, response 403
curl api.github.com/user without the placeholder         000
curl api.github.com/user with the placeholder            000
python urllib, proxies disabled                          200
node fetch (never reads HTTPS_PROXY)                     200
Claude Code (api.anthropic.com has no path rule)         ok

=== iron-proxy audit, explicit (github) =========================
reject 403 CONNECT api.github.com:443  from 10.50.10.10:26011 allowlist
reject 403 CONNECT api.github.com:443  from 10.50.10.10:14380 allowlist
reject 403 CONNECT github.com:443  from 10.50.10.10:49353 allowlist
reject 403 CONNECT github.com:443  from 10.50.10.10:23847 allowlist
reject 403 CONNECT github.com:443  from 10.50.10.10:2303 allowlist
reject 403 CONNECT api.github.com:443  from 10.50.10.10:65215 allowlist
reject 403 CONNECT api.github.com:443  from 10.50.10.10:32826 allowlist
reject 403 CONNECT github.com:443  from 10.50.10.10:53762 allowlist
reject 403 CONNECT api.github.com:443  from 10.50.10.10:47052 allowlist
reject 403 CONNECT api.github.com:443  from 10.50.10.10:18190 allowlist

=== terraform apply strict=false (revert) =======================
reverted; the new iron-proxy task is serving after 261s

=== Side by side ================================================
                                                         transparent                explicit
gh api user                                              fabiodouek                 Get "https://api.github.com/user": Forbi
gh api repos/fabiodouek/my2centsai-sandbox-test          fabiodouek/my2centsai-sand Get "https://api.github.com/repos/fabiod
git ls-remote fabiodouek/my2centsai-sandbox-test         fatal: unable to access 'h fatal: unable to access 'https://github.
  the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2 fatal: unable to access 'https://github.
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit     fatal: cannot change to 'r2': No such fi
gh api repos/torvalds/linux (other repo)                 unexpected end of JSON inp Get "https://api.github.com/repos/torval
gh gist create (POST /gists)                             X Failed to create gist: H X Failed to create gist: Post "https://a
git ls-remote torvalds/linux (http.extraHeader)          fatal: unable to access 'h fatal: unable to access 'https://github.
curl api.github.com/user without the placeholder         403                        000
curl api.github.com/user with the placeholder            200                        000
python urllib, proxies disabled                          200                        200
node fetch (never reads HTTPS_PROXY)                     200                        200
Claude Code (api.anthropic.com has no path rule)         ok                         ok

=== Checks ======================================================
PASS  transparent: gh api user  (fabiodouek)
PASS  transparent: the allowed repo  (fabiodouek/my2centsai-sandbox-test)
PASS  transparent: git without credentials up front is refused by require  (fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return)
PASS  transparent: git with http.extraHeader passes require  (83732231ad715d609714d688e2ead9b51546c90b	HEAD)
PASS  transparent: git clone with http.extraHeader  (8373223 Initial commit)
PASS  transparent: another repo refused over REST  (unexpected end of JSON input)
PASS  transparent: gist refused  (X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists))
PASS  transparent: another repo refused over git  (fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403)
PASS  transparent: no placeholder, refused (require)  (403)
PASS  transparent: with the placeholder, 200  (200)
PASS  transparent: python urllib works  (200)
PASS  transparent: node fetch works  (200)
PASS  transparent: Claude Code works  (ok)
PASS  transparent: no CONNECT records
PASS  transparent: a require rejection logged by secrets
PASS  explicit: gh api user refused at CONNECT  (Get "https://api.github.com/user": Forbidden)
PASS  explicit: even the allowed repo refused  (Get "https://api.github.com/repos/fabiodouek/my2centsai-sandbox-test": Forbidden)
PASS  explicit: git with http.extraHeader refused at CONNECT  (fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r)
PASS  explicit: Claude Code still works  (ok)
PASS  explicit: GitHub CONNECTs rejected in the audit log  (10 records)
20 passed, 0 failed

Transparent interception is what makes these rules usable. With no proxy settings, one repository is reachable, and everything else on GitHub (other repositories over REST and git, gists) is refused by the proxy rather than by the token. gh’s unexpected end of JSON input is how it reports the proxy’s 403, which has an empty body. A request without the placeholder is rejected by the secret’s require. Clients that ignore proxy settings work too.

git needs one adjustment. It sends its first request without credentials and only adds them after a 401, and require: true answers that first request with a 403, so git never gets to try. Sending the placeholder up front with http.extraHeader fixes it: with it, ls-remote and clone of the allowed repository work.

For comparison, the script repeats the probes in a second MicroVM with HTTPS_PROXY set to the NLB’s port 8080 (in the full output above), and there the same rules refused every GitHub request, even for the allowed repository, because iron-proxy 0.55.0 checks the CONNECT tunnel with an empty path and only the CONNECT’s own headers (issue #222 for require; open pull request #295 fixes the path rules).

Test 11: DNS: the override, no recursion, and removing the firewall

12_dns_firewall.py checks the override from inside a MicroVM. The dig answer under How the pieces work comes from this run; the excerpt below shows what glibc, Python and Node (both its resolvers) return, then seven record types, and all four resolver addresses the MicroVM can reach over UDP, with TCP to two of them:

getent hosts ri8xq3isr.example.com             10.50.11.10     proxy.sandbox.internal ri8xq3isr.example.com
python3 socket.getaddrinfo('api.github.com')   ['10.50.10.10', '10.50.11.10']
node dns.lookup (getaddrinfo)                  10.50.10.10 10.50.11.10
node dns.resolve4 (c-ares, its own resolver)   10.50.10.10 10.50.11.10

=== Every record type gets only the override ====================
api.github.com A                   NOERROR   CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
api.github.com AAAA                NOERROR   CNAME proxy.sandbox.internal.
api.github.com TXT                 NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com A             NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
-x 1.1.1.1                         NOERROR   CNAME proxy.sandbox.internal.
-x 140.82.112.3                    NOERROR   CNAME proxy.sandbox.internal.

=== Every resolver address, over UDP and TCP ====================
@10.51.0.2 <random>-0.example.com A            NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 <random>-1.example.com A      NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@127.0.0.2 <random>-2.example.com A            NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.53.53 <random>-3.example.com A        NOERROR   CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
@10.51.0.2 google.com TXT                      NOERROR   CNAME proxy.sandbox.internal.
@10.51.0.2 +tcp <random>-tcp0.example.com A    NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 +tcp <random>-tcp1.example.com A NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10

Then it checks that nothing recursed. An override answer alone proves little, so the script uses names whose real answer is predictable, where getting the real answer would mean the query was resolved on the internet. nip.io and sslip.io answer a name with the address embedded in it (10-1-2-3.nip.io is 10.1.2.3), and some TXT records exist only at the domain’s own name servers. That a blocked query never leaves at all is AWS’s documented BLOCK behaviour, not something this test can see:

=== Nothing recursed: names whose real answer is predictable ====
10-1-2-3.nip.io                    MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
rt6xamdbb.10.1.2.3.nip.io          MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
rt6xamdbb-10-1-2-3.nip.io          MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
1.2.3.4.sslip.io                   MicroVM: 10.50.10.10, 10.50.11.10   this machine: 1.2.3.4
rt6xamdbb.1.2.3.4.sslip.io         MicroVM: 10.50.10.10, 10.50.11.10   this machine: 1.2.3.4

=== Nothing recursed: TXT records only an authoritative server can give
google.com TXT
  MicroVM:      NOERROR CNAME proxy.sandbox.internal.
  this machine: 17 TXT record(s): "cisco-ci-domain-verification=47c38bc8c4b74b7233e9053220c1bbe76bcc1cd33c7acf7acd36cd6a5332004b" | "Z29vZ2xl" | "v=spf1 include:_spf.google.com ~all" | "docusign=1b0a6754-49b1-4db5-8540-d2c12664b289" | "_r4rd1pvwyrpi7sw4a3hzmw8e51yh9td" | "onetrust-domain-verification=0d477fe608074e6f9c12bca7826035cc
o-o.myaddr.l.google.com TXT
  MicroVM:      NOERROR CNAME proxy.sandbox.internal.
  this machine: 1 TXT record(s)

=== Resolver query log for the sandbox VPC: the firewall action on each query
31 records, 27 unique name/type pairs (the Resolver logs only what it didn't answer from cache)
A     api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A     10-1-2-3.nip.io.                         NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
PTR   3.112.82.140.in-addr.arpa.               NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
firewall_rule_action counts: {'BLOCK': 31}

The MicroVM got the NLB for every one of them, my laptop got the real answers, and every query in the Resolver’s log carries the firewall’s BLOCK action, with the override in its answer.

Last, the misconfiguration the design depends on not happening: removing the firewall association.

=== Misconfiguration: remove the firewall association ===========
association COMPLETE, mutation protection ENABLED
disassociate with protection on -> ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t
mutation protection switched off (0s)
DisassociateFirewallRuleGroup called
  [    0s] <random>.10.1.2.3.nip.io -> ['10.1.2.3']: recursion is back
  [   69s] association gone
  google.com TXT                       NOERROR TXT "MS=E4A68B9AB2BB9670BCE15412F62916164C0B20BB", TXT "arcules-domain-verification=2R4t5G7nI6tRCo61lpxxbLksPYstxhGmtV75cpr8x9K", TXT "google-site-verification=
  rqi4jk5zg.1.2.3.4.sslip.io A         NOERROR A 1.2.3.4
  curl https://example.com (now its real IP; the routes still block it): 000 FAIL rc=28

=== The EventBridge alarm =======================================
  2026-11-04T04:34:52Z  DisassociateFirewallRuleGroup        ValidationException
  2026-11-04T04:34:52Z  DisassociateFirewallRuleGroup        succeeded
  2026-11-04T04:34:52Z  UpdateFirewallRuleGroupAssociation   succeeded

=== Restore: terraform apply (the association, protection back on)
terraform apply: 70s
override back 70s after the apply started
new association: COMPLETE, mutation protection ENABLED

44 passed, 0 failed
Full output of 12_dns_firewall.py
# 12_dns_firewall  2026-11-04 04:34:38 GMT  region us-east-1
MicroVM microvm-314902c1-9144-391b-8910-fd54faf8a811 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']
resolv.conf:
nameserver 127.0.0.2
nameserver 169.254.53.53
search .

=== One answer: the override CNAME and both NLB addresses =======
$ dig api.github.com
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5180
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
api.github.com.		60	IN	CNAME	proxy.sandbox.internal.
proxy.sandbox.internal.	60	IN	A	10.50.10.10
proxy.sandbox.internal.	60	IN	A	10.50.11.10
;; Query time: 10 msec
;; SERVER: 127.0.0.2#53(127.0.0.2) (UDP)
getent hosts ri8xq3isr.example.com             10.50.11.10     proxy.sandbox.internal ri8xq3isr.example.com
python3 socket.getaddrinfo('api.github.com')   ['10.50.10.10', '10.50.11.10']
node dns.lookup (getaddrinfo)                  10.50.10.10 10.50.11.10
node dns.resolve4 (c-ares, its own resolver)   10.50.10.10 10.50.11.10

=== Every record type gets only the override ====================
api.github.com A                   NOERROR   CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
api.github.com AAAA                NOERROR   CNAME proxy.sandbox.internal.
api.github.com TXT                 NOERROR   CNAME proxy.sandbox.internal.
api.github.com MX                  NOERROR   CNAME proxy.sandbox.internal.
api.github.com TYPE65              NOERROR   CNAME proxy.sandbox.internal.
api.github.com NS                  NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com A             NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
<random>.example.com AAAA          NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com TXT           NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com MX            NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com TYPE65        NOERROR   CNAME proxy.sandbox.internal.
<random>.example.com NS            NOERROR   CNAME proxy.sandbox.internal.
-x 1.1.1.1                         NOERROR   CNAME proxy.sandbox.internal.
-x 140.82.112.3                    NOERROR   CNAME proxy.sandbox.internal.

=== Every resolver address, over UDP and TCP ====================
@10.51.0.2 <random>-0.example.com A            NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 <random>-1.example.com A      NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@127.0.0.2 <random>-2.example.com A            NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.53.53 <random>-3.example.com A        NOERROR   CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
@10.51.0.2 google.com TXT                      NOERROR   CNAME proxy.sandbox.internal.
@169.254.169.253 google.com TXT                NOERROR   CNAME proxy.sandbox.internal.
@127.0.0.2 google.com TXT                      NOERROR   CNAME proxy.sandbox.internal.
@169.254.53.53 google.com TXT                  NOERROR   CNAME proxy.sandbox.internal.
@10.51.0.2 +tcp <random>-tcp0.example.com A    NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 +tcp <random>-tcp1.example.com A NOERROR   CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10

=== Nothing recursed: names whose real answer is predictable ====
10-1-2-3.nip.io                    MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
rt6xamdbb.10.1.2.3.nip.io          MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
rt6xamdbb-10-1-2-3.nip.io          MicroVM: 10.50.10.10, 10.50.11.10   this machine: 10.1.2.3
1.2.3.4.sslip.io                   MicroVM: 10.50.10.10, 10.50.11.10   this machine: 1.2.3.4
rt6xamdbb.1.2.3.4.sslip.io         MicroVM: 10.50.10.10, 10.50.11.10   this machine: 1.2.3.4

=== Nothing recursed: TXT records only an authoritative server can give
google.com TXT
  MicroVM:      NOERROR CNAME proxy.sandbox.internal.
  this machine: 17 TXT record(s): "cisco-ci-domain-verification=47c38bc8c4b74b7233e9053220c1bbe76bcc1cd33c7acf7acd36cd6a5332004b" | "Z29vZ2xl" | "v=spf1 include:_spf.google.com ~all" | "docusign=1b0a6754-49b1-4db5-8540-d2c12664b289" | "_r4rd1pvwyrpi7sw4a3hzmw8e51yh9td" | "onetrust-domain-verification=0d477fe608074e6f9c12bca7826035cc
o-o.myaddr.l.google.com TXT
  MicroVM:      NOERROR CNAME proxy.sandbox.internal.
  this machine: 1 TXT record(s)

=== Misconfiguration: remove the firewall association ===========
association COMPLETE, mutation protection ENABLED
disassociate with protection on -> ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t
mutation protection switched off (0s)
DisassociateFirewallRuleGroup called
  [    0s] <random>.10.1.2.3.nip.io -> ['10.1.2.3']: recursion is back
  [   69s] association gone
  google.com TXT                       NOERROR TXT "MS=E4A68B9AB2BB9670BCE15412F62916164C0B20BB", TXT "arcules-domain-verification=2R4t5G7nI6tRCo61lpxxbLksPYstxhGmtV75cpr8x9K", TXT "google-site-verification=
  rqi4jk5zg.1.2.3.4.sslip.io A         NOERROR A 1.2.3.4
  curl https://example.com (now its real IP; the routes still block it): 000 FAIL rc=28

=== The EventBridge alarm =======================================
  2026-11-04T04:34:52Z  DisassociateFirewallRuleGroup        ValidationException
  2026-11-04T04:34:52Z  DisassociateFirewallRuleGroup        succeeded
  2026-11-04T04:34:52Z  UpdateFirewallRuleGroupAssociation   succeeded

=== Restore: terraform apply (the association, protection back on)
terraform apply: 70s
override back 70s after the apply started
new association: COMPLETE, mutation protection ENABLED

=== Resolver query log for the sandbox VPC: the firewall action on each query
31 records, 27 unique name/type pairs (the Resolver logs only what it didn't answer from cache)
A     ri8xq3isr.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A     api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
AAAA  ri8xq3isr.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     r184ryt4v-2.example.com.                 NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A     rt6xamdbb-10-1-2-3.nip.io.               NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
TXT   api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
TXT   google.com.                              NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
NS    rxyn2ba6i.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
MX    rxyn2ba6i.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     10-1-2-3.nip.io.                         NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A     r184ryt4v-1.example.com.                 NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
MX    api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     r184ryt4v-tcp1.example.com.              NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=TCP
NS    api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
PTR   3.112.82.140.in-addr.arpa.               NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     rt6xamdbb.1.2.3.4.sslip.io.              NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A     1.2.3.4.sslip.io.                        NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
TYPE65 api.github.com.                          NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
TXT   rxyn2ba6i.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     r184ryt4v-0.example.com.                 NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
PTR   1.1.1.1.in-addr.arpa.                    NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     r184ryt4v-tcp0.example.com.              NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=TCP
TYPE65 rxyn2ba6i.example.com.                   NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A     rt6xamdbb.10.1.2.3.nip.io.               NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A     r184ryt4v-3.example.com.                 NOERROR  firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
firewall_rule_action counts: {'BLOCK': 31}

=== Checks ======================================================
PASS  dig: override CNAME to proxy.sandbox.internal  (CNAME proxy.sandbox.internal.)
PASS  dig: both NLB A records in the same answer
PASS  getent hosts ri8xq3isr.example.com: the NLB IPs only  (10.50.10.10 10.50.11.10)
PASS  python3 socket.getaddrinfo('api.github.com'): the NLB IPs only  (10.50.10.10 10.50.11.10)
PASS  node dns.lookup (getaddrinfo): the NLB IPs only  (10.50.10.10 10.50.11.10)
PASS  node dns.resolve4 (c-ares, its own resolver): the NLB IPs only  (10.50.10.10 10.50.11.10)
PASS  dig api.github.com A: override only  (NOERROR)
PASS  dig api.github.com AAAA: override only  (NOERROR)
PASS  dig api.github.com TXT: override only  (NOERROR)
PASS  dig api.github.com MX: override only  (NOERROR)
PASS  dig api.github.com TYPE65: override only  (NOERROR)
PASS  dig api.github.com NS: override only  (NOERROR)
PASS  dig <random>.example.com A: override only  (NOERROR)
PASS  dig <random>.example.com AAAA: override only  (NOERROR)
PASS  dig <random>.example.com TXT: override only  (NOERROR)
PASS  dig <random>.example.com MX: override only  (NOERROR)
PASS  dig <random>.example.com TYPE65: override only  (NOERROR)
PASS  dig <random>.example.com NS: override only  (NOERROR)
PASS  dig -x 1.1.1.1: override only  (NOERROR)
PASS  dig -x 140.82.112.3: override only  (NOERROR)
PASS  dig @10.51.0.2 <random>-0.example.com A: override only  (NOERROR)
PASS  dig @169.254.169.253 <random>-1.example.com A: override only  (NOERROR)
PASS  dig @127.0.0.2 <random>-2.example.com A: override only  (NOERROR)
PASS  dig @169.254.53.53 <random>-3.example.com A: override only  (NOERROR)
PASS  dig @10.51.0.2 google.com TXT: override only  (NOERROR)
PASS  dig @169.254.169.253 google.com TXT: override only  (NOERROR)
PASS  dig @127.0.0.2 google.com TXT: override only  (NOERROR)
PASS  dig @169.254.53.53 google.com TXT: override only  (NOERROR)
PASS  dig @10.51.0.2 +tcp <random>-tcp0.example.com A: override only  (NOERROR)
PASS  dig @169.254.169.253 +tcp <random>-tcp1.example.com A: override only  (NOERROR)
PASS  10-1-2-3.nip.io: the NLB IPs, not the embedded 10.1.2.3  (10.50.10.10, 10.50.11.10)
PASS  rt6xamdbb.10.1.2.3.nip.io: the NLB IPs, not the embedded 10.1.2.3  (10.50.10.10, 10.50.11.10)
PASS  rt6xamdbb-10-1-2-3.nip.io: the NLB IPs, not the embedded 10.1.2.3  (10.50.10.10, 10.50.11.10)
PASS  1.2.3.4.sslip.io: the NLB IPs, not the embedded 1.2.3.4  (10.50.10.10, 10.50.11.10)
PASS  rt6xamdbb.1.2.3.4.sslip.io: the NLB IPs, not the embedded 1.2.3.4  (10.50.10.10, 10.50.11.10)
PASS  google.com TXT: no real TXT data in the MicroVM  (MicroVM 0 TXT, this machine 17)
PASS  o-o.myaddr.l.google.com TXT: no real TXT data in the MicroVM  (MicroVM 0 TXT, this machine 1)
PASS  mutation protection refuses DisassociateFirewallRuleGroup  (ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t)
PASS  without the association, recursion returns (10.1.2.3.nip.io -> 10.1.2.3)  (after 0s)
PASS  the fence still blocks the real IP  (000 FAIL rc=28)
PASS  EventBridge alarm fired on the disassociation  (readable 81s after the call)
PASS  override restored by terraform apply  (70s)
PASS  the new association has mutation protection on  (ENABLED)
PASS  every logged query carries a firewall action  ({'BLOCK': 31})
44 passed, 0 failed

With mutation protection on, the removal call was refused (RSLVR-02307). Switching protection off took one more call and was immediate. After that, removal worked, and recursion was back at the very first probe, even though the association took 69 s to disappear from the API: <random>.10.1.2.3.nip.io resolved to 10.1.2.3, and Google’s real TXT records came back. The routes still blocked a connection to the real address. The EventBridge alarm recorded the refused attempt, the switch and the removal, readable in the log group 81 s after the call. A terraform apply of the association took 70 s, and the override was back when it finished, with protection on again.

So removing the association takes effect at once and takes two deliberate calls. Changing the rule group’s rules or domain lists would take one, and mutation protection doesn’t cover them; I didn’t test that path. The alarm tells you a change happened, a minute or so later; it doesn’t close the gap, and DNS stays a channel out until someone puts it back. Those permissions belong to administrators only.

Test 12: Latency

14_latency.py sends the same request (GET https://api.github.com/zen with the placeholder token) three ways from one MicroVM, 10 interleaved rounds after two warm-ups, each a fresh curl with a new TCP and TLS handshake: through the override, with --resolve to skip DNS, and, for comparison, through a CONNECT tunnel on 8080. Then it times lookups through the override:

=== HTTP: median of 10 requests each (seconds; curl's cumulative timers)
variant                    dns  connect  tls done    ttfb   total  min-max total   codes
transparent             0.0004   0.0029    0.0374  0.1406  0.1407  0.133-0.164   ['200']
transparent, pinned     0.0000   0.0039    0.0370  0.1413  0.1414  0.129-0.157   ['200']
explicit (8080)         0.0000   0.0041    0.0397  0.1504  0.1505  0.138-0.166   ['200']

transparent - explicit: -9.8 ms median total; DNS share of transparent: 0.4 ms

=== DNS through the override ====================================
dig, random name (Resolver cache miss)   median 0.0 ms   [0, 0, 0, 10, 10, 0, 0, 10, 10, 0]
dig, api.github.com (repeated)            median 0.0 ms   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
getaddrinfo, random name (A + AAAA)       median 4.9 ms   [6.77, 5.16, 4.74, 3.89, 4.26, 4.92, 4.95, 4.88, 5.04, 4.58]
getaddrinfo, api.github.com (repeated)    median 0.06 ms   [0.09, 0.07, 0.07, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06]

4 passed, 0 failed

Going transparent costs nothing measurable: 141 ms median, against 150 ms through a CONNECT tunnel, a difference inside the run-to-run spread. DNS took 0.4 ms of that. Through getaddrinfo, which asks for A and AAAA, a lookup of a new name took 4.9 ms median, and a repeated name 0.06 ms; dig reported 0 or 10 ms.

What iron-proxy does not solve

  • Tenancy. The proxy can’t tell one MicroVM from another (every request comes from the NLB’s addresses), and placeholders aren’t secrets. Every sandbox that can reach a proxy can use all of its credentials, and the audit log can’t say which sandbox did. A proxy per tenant is the only isolation available today; per-sandbox identity at the proxy is future work.
  • Exfiltration to allowed hosts. Anything the agent can read, it can post to an allowed API: httpbin here, a gist or an issue on GitHub in real life. The real secrets are safe because they never enter the VM, but data and anything else in the VM (like an execution role’s credentials) are not. Path rules narrow this now that they work, as in Test 10, but only for hosts you write them for.
  • Revocation at the proxy. A cached secret keeps being injected after the proxy loses access to it, and after a restart the proxy forwards the placeholder instead of failing. Revoke credentials at their source. Part 3’s optional control plane revokes at the proxy with a deny statement and a config reload instead of a restart, which takes effect within about 30 s.
  • Plain HTTP. Secrets are swapped into http:// requests too, so the real value can cross the internet unencrypted if an agent asks for it.
  • AWS uploads and awkward keys. The CLI’s chunked uploads are rejected unless you allow chunked bodies or unsigned payloads, and object keys that need URL encoding don’t re-sign correctly in 0.55.0 (iron-proxy issue #220, closed without a merged fix; pull request #256 is still open).
  • CONNECT. If you can’t use transparent interception, path rules and require refuse every tunnel in 0.55.0, so an HTTPS_PROXY setup gets host-level policy only.
  • Operations. It’s a pre-1.0 project with releases every week or two lately, after a two-month summer gap, and one main maintainer, the images aren’t signed (pin by digest), and a single Fargate task is a single point of failure for every sandbox behind it. DNS should keep answering if the task is down (untested), but nothing gets through.

Pricing

The proxy layer is fixed infrastructure that runs whether or not any sandbox does (us-east-1 list prices):

Resource Rate Per hour
NAT gateway$0.045 / h + $0.045 / GB processed$0.045
Network Load Balancer$0.0225 / h + capacity units$0.0225
Fargate, ARM, 0.5 vCPU / 1 GB$0.03238 / vCPU-h, $0.00356 / GB-h$0.020
Public IPv4 (NAT gateway)$0.005 / h$0.005
Secrets Manager (4 secrets)$0.40 / secret-month + API calls$0.002
Route 53 private hosted zone$0.50 / month$0.0007
VPC peeringno hourly charge; $0.01 / GB each way across AZs$0
Fixed total≈ $0.095 / h, ≈ $69 / month

The second VPC costs nothing by itself, so the split adds only the private zone to the fixed line. On top of that, per use: MicroVMs at part 1’s rates, DNS Firewall at $0.60 per million queries (the two one-entry domain lists cost a fraction of a cent a month), CloudWatch Logs for the audit trail and the query log, data through the NAT gateway, and cross-AZ data (over the peering, and between the NLB, the task and the NAT gateway). For one developer that’s mostly the fixed line. For a fleet, the NAT gateway’s per-GB charge is the one to watch, since every byte the agents fetch goes through it; DNS Firewall would need a million lookups to cost 60 cents.

Cleanup

python 13_cleanup.py      # terminate this repo's MicroVMs, delete the image (it bills snapshot storage)
scripts/destroy.sh        # switch off mutation protection, then terraform destroy (about 5 minutes on a twin stack)

13_cleanup.py goes first, because the connector can’t be deleted while MicroVMs use it and the image isn’t managed by Terraform. A plain terraform destroy fails on the protected firewall association, so scripts/destroy.sh switches the protection off first (the alarm logs that too).

Verdict

The two-VPC version is the one I’d build. The single-VPC version worked, but it depended on every client honouring HTTPS_PROXY, gave up iron-proxy’s path rules, and needed a DNS allowlist shared with the proxy’s own infrastructure. Moving the sandboxes into a VPC with no internet route, and DNS that only knows the proxy, fixed all three at about the same cost and without changing iron-proxy. Claude Code, git, gh, the AWS CLI and clients that ignore proxy settings all worked with nothing configured, and an agent opened a real pull request with nothing secret in its VM.

What works

  • The fence. No internet route, two routes to the NLB, three ports; every other probe timed out.
  • Nothing to configure in the sandbox. Every name resolves to the proxy, so every HTTP client goes through it, including the ones that ignore proxy settings.
  • DNS as a closed channel. Nothing recursed for any of the seven record types or four resolver addresses tried, and there’s no domain list to maintain.
  • Injection. Placeholders swapped per host, in headers, query or body, with an audit line naming the secret and where it was found.
  • AWS without AWS credentials. aws_auth re-signs with the proxy’s role and enforces services and regions.
  • Rotation. A new value in Secrets Manager is used within about 30 s (32.4 s here), even by a MicroVM that was suspended at the time.
  • Path rules and require, with no CONNECT in the way.
  • No penalty for going transparent. It was within noise of a CONNECT tunnel, and a lookup through the override took milliseconds.

What doesn’t

  • No tenant isolation in this build. Every MicroVM that reaches the proxy shares its credentials, and the log can’t say which one acted.
  • The DNS control rests on a few Resolver settings. Mutation protection guards the association, not the rules; the alarm catches changes to both, but someone with the permissions can still make them, and recursion returns at once.
  • Slow and quiet failures. Non-HTTP traffic times out instead of being refused, and a denied host gets a 403 with an empty body and a curl exit code of 0.
  • Revocation and failure. Cached secrets outlive revoked access, and a missing secret forwards the placeholder instead of failing.
  • Exfiltration to allowed hosts stays open, for data and for anything already in the VM.
  • Rough edges. Plain-HTTP swaps, S3 keys that need encoding, chunked uploads off by default, unsigned images, one main maintainer and a single Fargate task.

Who should use it

One person or one team running agents on Lambda MicroVMs who want keys out of the sandbox, a log of what the agent touched, and a policy that holds whether or not the agent’s tools honour proxy settings, and who can run a small Fargate service and a handful of Route 53 resources. If you need several tenants, give each its own proxy, or wait for proxy authentication. Part 3 builds a browser workspace on this same proxy.

Comments