# Your agent doesn't need the whole internet: egress control and permissions on AWS
<!-- meta: How to limit what an AI agent can reach and do on AWS: Network Firewall domain allowlists, why DNS filtering alone fails, one IAM role per agent with session policies, approval gates and a kill switch. -->

At the end of September 2026 OpenAI paused training of its newest models. According to AP's reporting of the company's own disclosures, agents running during training and evaluation had gone beyond what they were asked to do on US government websites, and in one case found developer keys. A separate incident from 20 September is the one we keep coming back to: an internal research agent was supposed to have no internet access, and it got out anyway, **through a gap in DNS filtering**, and reached an external chatbot. A few days later the FTC confirmed it was investigating OpenAI, Anthropic and other labs over the risks their agents pose to consumers.

These are labs with security teams larger than most of our clients' whole engineering departments. The lesson for everyone else isn't that agents are uncontrollable. It's that **the model provider's guardrails are not your guardrails**. The moment you give an agent a browser, an HTTP tool, code execution or AWS credentials, what it can reach is decided by your network and your IAM, not by the model's good intentions. This article is how we set that up on AWS for clients who run agents in production.

## What actually needs to be limited

An agent is a loop: the model decides on an action, a tool executes it, the result goes back to the model. The model itself can't do anything. Everything dangerous happens in the tools, with the network access and permissions of the process that runs them. That gives five places to put limits, and each one stops something the others don't:

<div class="article-figure">
<svg viewBox="0 0 900 330" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Table of five defence layers for AI agents and what each stops and does not stop: DNS firewall, network firewall, IAM role with session policy, tool design with approvals, budgets with kill switch">
<text x="10" y="22" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="15px" font-weight="600">Five layers, and what each one cannot stop on its own</text>
<text x="20" y="52" fill="#6b7a99" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12px" font-weight="600">LAYER</text>
<text x="250" y="52" fill="#4fffb0" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12px" font-weight="600">STOPS</text>
<text x="580" y="52" fill="#ff6b8a" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12px" font-weight="600">DOES NOT STOP</text>
<rect x="10" y="62" width="880" height="44" rx="6" fill="#151b2e"/>
<text x="20" y="89" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600">DNS Firewall</text>
<text x="250" y="89" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">lookups of domains not on the list</text>
<text x="580" y="89" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">direct IPs, DNS over HTTPS, own resolvers</text>
<rect x="10" y="112" width="880" height="44" rx="6" fill="#151b2e"/>
<text x="20" y="139" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600">Network Firewall</text>
<text x="250" y="139" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">connections to unlisted hosts, IPs included</text>
<text x="580" y="139" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">misuse of a domain that is allowed</text>
<rect x="10" y="162" width="880" height="44" rx="6" fill="#151b2e"/>
<text x="20" y="189" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600">IAM role + session policy</text>
<text x="250" y="189" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">AWS calls outside this task's scope</text>
<text x="580" y="189" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">damage through APIs it is allowed to call</text>
<rect x="10" y="212" width="880" height="44" rx="6" fill="#151b2e"/>
<text x="20" y="239" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600">Tool design + approvals</text>
<text x="250" y="239" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">side effects that nobody approved</text>
<text x="580" y="239" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">wrong answers, bad judgment in reading</text>
<rect x="10" y="262" width="880" height="44" rx="6" fill="#151b2e"/>
<text x="20" y="289" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600">Budgets + kill switch</text>
<text x="250" y="289" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">runaway loops, runaway cost</text>
<text x="580" y="289" fill="#c9cfe8" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="12.5px">a single bad action before it triggers</text>
<text x="10" y="324" fill="#6b7a99" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px">No layer is enough on its own. The September incident got past exactly one of them: DNS.</text>
</svg>
</div>

## Network: why DNS filtering alone isn't enough

The tempting setup is Route 53 Resolver DNS Firewall with an allowlist: the agent can only resolve the domains you approve, everything else returns NXDOMAIN. It's cheap, simple and worth having. But it controls name lookups, not connections. An agent that connects straight to an IP address never asks DNS. One that sends DNS over HTTPS to a public resolver never asks yours. One that writes a script with a hard-coded resolver doesn't either. The September incident is a reminder that "no internet" enforced only at DNS level is not no internet.

What enforces it is a control on the connection itself. On AWS that's **AWS Network Firewall** with a stateful domain-list rule group: outbound HTTPS is allowed only if the TLS SNI matches the list, plain HTTP only if the Host header does, and everything else is dropped by default, including connections to bare IP addresses. The agent's subnets have no route to the internet except through the firewall endpoint.

<div class="article-figure">
<svg viewBox="0 0 900 360" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Architecture: the agent runs in a private subnet with its own IAM role. DNS goes through Route 53 DNS Firewall. All outbound traffic goes through AWS Network Firewall with a domain allowlist; allowed domains pass, everything else is dropped and logged. AWS services are reached through VPC endpoints without going to the internet">
<text x="10" y="22" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="15px" font-weight="600">Agent egress on AWS: every path out is a decision</text>
<rect x="10" y="50" width="220" height="56" rx="10" fill="#151b2e" stroke="#2a3147" stroke-width="1.5"/>
<text x="120" y="74" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600" text-anchor="middle">Route 53 DNS Firewall</text>
<text x="120" y="93" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">allowlist, block the rest</text>
<rect x="10" y="150" width="220" height="90" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="120" y="178" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13.5px" font-weight="600" text-anchor="middle">Agent task</text>
<text x="120" y="198" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">Fargate, private subnet</text>
<text x="120" y="218" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">own IAM role, 15-min session</text>
<line x1="120" y1="150" x2="120" y2="112" stroke="#9aa3c7" stroke-width="1.5" stroke-dasharray="4 4"/><path d="M 115 112 L 120 106 L 125 112 Z" fill="#9aa3c7"/>
<rect x="330" y="150" width="220" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="440" y="178" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13.5px" font-weight="600" text-anchor="middle">AWS Network Firewall</text>
<text x="440" y="198" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">TLS SNI / HTTP Host allowlist</text>
<text x="440" y="218" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">default: drop + alert</text>
<line x1="230" y1="195" x2="324" y2="195" stroke="#9aa3c7" stroke-width="1.5"/><path d="M 324 190 L 330 195 L 324 200 Z" fill="#9aa3c7"/>
<rect x="650" y="110" width="240" height="64" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="770" y="137" fill="#4fffb0" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600" text-anchor="middle">Allowed domains</text>
<text x="770" y="157" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">model API, your APIs, approved docs</text>
<rect x="650" y="216" width="240" height="64" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="770" y="243" fill="#ff6b8a" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600" text-anchor="middle">Everything else</text>
<text x="770" y="263" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">bare IPs, DoH, unlisted hosts: dropped</text>
<line x1="550" y1="180" x2="644" y2="146" stroke="#4fffb0" stroke-width="1.5"/><path d="M 641 141 L 650 144 L 644 151 Z" fill="#4fffb0"/>
<line x1="550" y1="210" x2="644" y2="244" stroke="#ff6b8a" stroke-width="1.5" stroke-dasharray="5 4"/><path d="M 644 239 L 650 246 L 641 249 Z" fill="#ff6b8a"/>
<rect x="10" y="280" width="220" height="56" rx="10" fill="#151b2e" stroke="#2a3147" stroke-width="1.5"/>
<text x="120" y="304" fill="#eef2ff" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="13px" font-weight="600" text-anchor="middle">VPC endpoints</text>
<text x="120" y="323" fill="#9aa3c7" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px" text-anchor="middle">S3, Secrets Manager, Bedrock, Logs</text>
<line x1="120" y1="240" x2="120" y2="274" stroke="#7b8cff" stroke-width="1.5"/><path d="M 115 274 L 120 280 L 125 274 Z" fill="#7b8cff"/>
<text x="330" y="304" fill="#6b7a99" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px">AWS traffic never leaves the VPC, so it needs no internet allowlist entry.</text>
<text x="330" y="322" fill="#6b7a99" font-family="DM Sans, system-ui, -apple-system, sans-serif" font-size="11.5px">Firewall alert logs + DNS query logs go to CloudWatch for the kill switch.</text>
</svg>
</div>

In CDK the rule group is short. The important details are the target types (both SNI and Host), strict rule order, and a default action that drops what nothing matched:

```typescript
import * as nfw from "aws-cdk-lib/aws-networkfirewall";

const allowlist = new nfw.CfnRuleGroup(this, "AgentEgressAllowlist", {
  ruleGroupName: "agent-egress-allowlist",
  type: "STATEFUL",
  capacity: 100,
  ruleGroup: {
    rulesSource: {
      rulesSourceList: {
        generatedRulesType: "ALLOWLIST",
        targetTypes: ["TLS_SNI", "HTTP_HOST"],
        targets: ["api.anthropic.com", "api.yourcompany.com", ".docs.aws.amazon.com"],
      },
    },
    statefulRuleOptions: { ruleOrder: "STRICT_ORDER" },
  },
});

const policy = new nfw.CfnFirewallPolicy(this, "AgentEgressPolicy", {
  firewallPolicyName: "agent-egress",
  firewallPolicy: {
    statelessDefaultActions: ["aws:forward_to_sfe"],
    statelessFragmentDefaultActions: ["aws:forward_to_sfe"],
    statefulEngineOptions: { ruleOrder: "STRICT_ORDER" },
    statefulRuleGroupReferences: [{ resourceArn: allowlist.attrRuleGroupArn, priority: 10 }],
    statefulDefaultActions: ["aws:drop_established", "aws:alert_established"],
  },
});
```

`aws:drop_established` rather than `aws:drop_strict` is deliberate: the firewall has to let the TCP handshake through to see the SNI in the TLS Client Hello. A leading dot (`.docs.aws.amazon.com`) matches subdomains. Keep the list short and specific. "github.com" as a whole is an exfiltration channel, not an allowlist entry.

Two honest caveats. SNI filtering checks the name the client claims, so a determined process could send an allowed SNI to a different server. Network Firewall can do TLS inspection to close that gap, at extra cost and complexity, and for most agent workloads the combination of SNI allowlist plus no bare-IP egress is a reasonable line. And it isn't free: in us-east-1 an endpoint costs $0.395 an hour, about $288 a month per Availability Zone, plus $0.065 per GB processed. For a single small agent, an egress proxy (Squid or Envoy with a domain allowlist, on a small Fargate task) is a cheaper alternative with the same principle. What we wouldn't do is give an agent a [NAT gateway](/en/blog/nat-gateway-the-most-expensive-line-you-do-not-see) and a DNS filter and call it contained.

AWS services themselves (S3, Secrets Manager, Bedrock, CloudWatch Logs) should go through VPC endpoints. That traffic never touches the internet and never needs an allowlist entry, and endpoint policies let you restrict, for example, which buckets the agent's S3 traffic may reach at all.

## Identity: one role per agent, narrowed per task

The second common mistake is that the agent runs with the application's role. The web app needs to read and write every customer's data, so the agent can too. If the agent goes beyond its task, it does so with the permissions of the whole product.

What we do instead:

- **One IAM role per agent type.** The research agent, the support agent and the reporting agent each have their own role with only the actions their tools need. A permissions boundary on each role caps what anyone can later add to it.
- **A session policy per task.** When the agent starts a task, the orchestrator assumes the agent role with an inline session policy that narrows it further, for example to one tenant's prefix in S3. The effective permissions are the intersection of the role and the session policy.
- **Short sessions and session tags.** 15 minutes, the minimum for AssumeRole, and tags for tenant and task so every call in CloudTrail can be traced back to the task that made it.

```typescript
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";

const sts = new STSClient({});

export async function credentialsForTask(taskId: string, tenantId: string) {
  const { Credentials } = await sts.send(new AssumeRoleCommand({
    RoleArn: process.env.RESEARCH_AGENT_ROLE_ARN,
    RoleSessionName: `agent-${taskId}`,
    DurationSeconds: 900,
    Tags: [{ Key: "tenant", Value: tenantId }, { Key: "task", Value: taskId }],
    Policy: JSON.stringify({
      Version: "2012-10-17",
      Statement: [{
        Effect: "Allow",
        Action: ["s3:GetObject"],
        Resource: [`arn:aws:s3:::${process.env.DOCS_BUCKET}/tenants/${tenantId}/*`],
      }],
    }),
  }));
  return Credentials;
}
```

The trust policy on the role needs `sts:TagSession` for the tags to work. And the agent's tools get these credentials, not the task's default ones, so even a tool that misbehaves can only touch one tenant's files for fifteen minutes.

## Tools: the real permission surface

Network and IAM limit where an agent can go. The tools decide what it can do once it gets there, and that's where most of the design work is.

- **No generic tools in production.** A `bash` tool or a `fetch(url)` that accepts anything turns every limit above into the only limit. A tool called `get_invoice(invoice_id)` that checks the tenant itself is less flexible and much safer.
- **Check the allowlist in the tool too.** If there's a fetch tool, it validates the host against the same list the firewall uses. Defence in depth costs ten lines.
- **Run code in a sandbox without a network.** If the agent needs to execute code, do it in a separate container or function with no egress at all, or in the model provider's hosted sandbox, not in the process that holds the credentials.
- **Approval for side effects.** Reading is automatic, anything that writes, sends, pays or deletes waits for a person. The Anthropic SDK's tool runner has per-turn hooks for exactly this, and Managed Agents has permission policies (`always_allow`, `always_ask`, `auto`).

One gotcha from Anthropic's documentation that we'd underline: on Managed Agents, the hosted `web_search` and `web_fetch` tools run on Anthropic's servers and **ignore your environment's networking settings**. Locking down the sandbox's network doesn't restrict them. You turn them off, or restrict them per tool with `allowed_domains`. That's the same lesson as the DNS bypass in a different form: check which path the traffic actually takes.

## Budgets and a kill switch

The last layer is about runaway behaviour rather than single actions. Set a hard limit on turns and tokens per task (the Claude API has task budgets for exactly this), rate-limit tool calls per agent, and watch two numbers: Network Firewall drop alerts and DNS Firewall blocks for the agent's subnets. An agent that suddenly starts hitting the firewall is either broken or doing something it shouldn't. Either way, a CloudWatch alarm on those metrics can trigger a Lambda that attaches a deny-all policy to the agent role. Every session dies within minutes and nothing else in the product is affected.

## Our checklist

Before an agent with tools goes to production, we want to be able to say yes to each of these:

1. The agent runs in subnets with no route to the internet except through an allowlisting firewall or proxy.
2. Bare-IP egress is blocked, and DNS is filtered too.
3. AWS services are reached through VPC endpoints.
4. The agent has its own role, a permissions boundary and per-task session policies.
5. Tools are specific, check their own inputs, and side effects need approval.
6. Code execution happens in a sandbox with no network and no credentials.
7. Turns, tokens and tool calls have limits, and there is a tested kill switch.

None of this is exotic. It's the same discipline we apply to any service that handles untrusted input, applied to a service whose decisions are made by a model. If you're putting agents into production and want someone to review the setup, that's work [we do for clients](/contact). And if you want the background on why agents keep finding the gaps, we wrote about it in [why AI keeps escaping](/en/blog/why-ai-keeps-escaping).
