●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Locking down Claude Code sandbox egress with strictAllowlist
Tightening automation egress with strictAllowlist in Claude Code v2.1.219, plus measured failure timings that tell a policy deny from DNS and real outages.
Late one night, my automated publishing pipeline stopped partway through without saying why.
The logs showed something that looked like a connection timeout. I blamed the network, re-ran it, and sometimes it went through. Classic "flaky network" behavior. Except the network was fine. A single host I had forgotten to add to the allowlist was being quietly refused.
This happened right after I turned on sandbox.network.strictAllowlist, added in Claude Code v2.1.219 (2026-07-24). As an indie developer running this pipeline solo, I want to share the field notes from folding that setting into my automation. I will spend less time on how to enable it and more on what broke afterward and how I tracked it down.
A "deny" wears the mask of an outage
The idea behind strictAllowlist is simple. For commands run inside the sandbox, refuse any outbound connection to a host that is not on the allowlist. It is a sensible way to run automation with a safe default.
But running it taught me that the deny itself is less troublesome than how the deny appears.
A connection to a host missing from the allowlist looks, from the application's point of view, like an ordinary connection failure. Most tools interpret that as "the network is unhealthy" and surface a retry or timeout message. In other words, an intentional policy block shows up wearing the face of a random network outage. That was the first pitfall. To work around this trap, observe the facts before you guess at the cause.
So the first thing I did was not to write an allowlist. It was to observe, once and completely, which hosts the pipeline actually reaches out to.
Discover the hosts you really touch
The "it probably connects to this host" in your head is not reliable. Trace connect(2) and collect destinations as facts. The script below wraps any command and prints the hostnames it connects to.
The key flag is -f. Both git push and npm install hand the real work to child processes. Watch only the parent and you miss the connections that matter. For any IP that has no reverse record, keep the raw address. A later section covers a forward-resolution dictionary that maps it back to a name.
Running this once in my environment, the destinations came to eleven hosts. I had expected four. Roughly 2.7x what I anticipated.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A runnable script that traces connect(2) to reveal every host your pipeline actually contacts
✦Why allowing a single hostname per service is not enough, with the exact failure I hit
✦Measured failure timings (0.02ms, 1.22ms, exactly 3s) that separate a policy block from DNS and from a real outage
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the part I could not grasp by reading docs alone.
I had naively assumed that "to push to GitHub, allow github.com; to call the Anthropic API, allow api.anthropic.com" would be enough. The recon results said otherwise.
Logical operation
Hosts actually contacted
git push / fetch (HTTPS)
github.com, plus codeload.github.com when fetching assets
npm install
registry.npmjs.org, plus a separate CDN host for tarballs
Claude API call
api.anthropic.com
Name resolution
if DNS cannot go out, everything stalls
A single "operation" fanned out to several hosts. That npm serves tarballs from a different host, and that git sometimes reaches codeload.github.com, were things I only noticed by watching the connections directly.
In short, the very premise of "one host per service" was wrong. Had I written the allowlist under that assumption, that midnight stall would have reproduced indefinitely.
Resisting the wildcard temptation
Adding a host every time something leaks is tedious. It is tempting to settle it with a broad wildcard like *.amazonaws.com.
I started to write exactly that. Then my hand stopped. Allowing an entire cloud that happens to host a CDN's tarballs also opens a path to countless unrelated buckets and endpoints. The one line dilutes the reason you added strictAllowlist in the first place.
In the end I chose to accept the occasional miss over a broad wildcard. Fill the allowlist with concrete hostnames, and when it grows, re-run recon and add the diff. I strongly recommend this over convenience wildcards: a decision not to trade away the "safe default" intent for operational ease.
Three steps before you enable it
Rather than switching the setting on in a hurry, this order cut down on accidents.
Run the pipeline once through egress-recon.sh without strictAllowlist, recording every destination
Drop the recorded hostnames into the allowlist and enable strictAllowlist
Watch runs with guard-run.sh and fail loudly the moment an unexpected connection appears (pick the cadence from the measurements below)
Just inserting these three steps replaces "stalls silently in production" with "observed once up front and eliminated."
Config, and making the deny observable
Put the discovered hosts into config. Confirm the exact key names against your version's settings schema (strictAllowlist is new, so the details may still change). I keep this in .claude/settings.json.
On top of that, add observability so a deny is not mistaken for a network outage. Wrap the pipeline once with a thin harness that records, on every run, whether it reached a host outside expectations.
#!/usr/bin/env bash# guard-run.sh — reconcile hosts touched during a run against the allowlistset -euo pipefailALLOW_FILE="${1:?pass an allowlist file}"; shiftSEEN="$(mktemp)"; trap 'rm -f "$SEEN"' EXIT# use egress-recon.sh inside, collecting destinations./egress-recon.sh "$@" | awk '{print $1}' | grep -vE '^\(' | sort -u > "$SEEN"# report only destinations not on the allowlistUNKNOWN="$(comm -23 "$SEEN" <(sort -u "$ALLOW_FILE") || true)"if [ -n "$UNKNOWN" ]; then echo "⚠️ detected connections outside the allowlist:" >&2 echo "$UNKNOWN" >&2 # in automation, prefer a loud failure over a silent one exit 1fiecho "✅ all destinations were within the allowlist"
Once this was in place, I could state in a single log line whether a stall was "the network's fault" or "a gap in the allowlist." The disappearance of pointless re-runs was the change I felt most in my body.
The speed of a failure is a fingerprint
If a deny arrives wearing the face of an outage, then stop reading the face and start reading the speed. That reframing sent me back to measure how long each kind of failure actually takes.
#!/usr/bin/env python3# probe-failure-shape.py — measure the "shape" of a failure to isolate its cause# usage: ./probe-failure-shape.py github.com api.anthropic.com 10.255.255.1import socket, time, errno, statistics, sysdef probe(host, port=443, timeout=3.0): t0 = time.perf_counter() try: # resolve and connect on the same path the pipeline uses addr = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM)[0][4] s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect(addr) s.close() return "connected", (time.perf_counter() - t0) * 1000 except socket.gaierror as e: return f"DNS failure (gaierror {e.errno})", (time.perf_counter() - t0) * 1000 except socket.timeout: # return elapsed time too, so you can see if it pinned to the ceiling return "timeout", (time.perf_counter() - t0) * 1000 except OSError as e: return f"errno={e.errno} ({errno.errorcode.get(e.errno, '?')})", (time.perf_counter() - t0) * 1000for host in sys.argv[1:]: runs = [probe(host) for _ in range(3)] ms = [r[1] for r in runs] print(f"{host}\t{runs[0][0]}\tmedian {statistics.median(ms):.2f}ms")
Here are the results with a three-second timeout, three runs per host. These are measurements from my own environment, so the absolute numbers will move elsewhere. What matters is the difference in order of magnitude.
What happened
What came back
Median time to failure
Normal connection to an allowed host
connected
10.2 – 14.1 ms
Name does not resolve
gaierror -2 (EAI_NONAME)
1.22 ms
Reached the peer and got refused
errno=111 (ECONNREFUSED)
0.02 ms
Packets silently dropped
timeout
3,003 ms (exactly the 3s ceiling)
Seeing them side by side is what made it click. The time to failure is itself a fingerprint of the cause.
Well under a millisecond means you reached the peer and were turned away immediately: a closed port, or a RST somewhere on the path. One to two milliseconds means the name never resolved at all, and you should check the DNS path before suspecting the allowlist.
The interesting row is the last one. A failure that pins to the timeout ceiling. Three seconds on a three-second setting, ten on a ten-second setting. A failure that does not vary is not a machine misbehaving. It is a rule someone wrote. When packets are dropped without any reply, the wait is decided entirely by your own ceiling.
That midnight stall wore exactly this face. A genuine network problem varies, failing somewhere short of the ceiling. Landing on the same clean number every single time was the unnatural part. Once you have seen it, a deny and an outage no longer look alike.
Observation has a price tag too
Tracing continuously is not free. Measuring the same HTTPS fetch five times, the bare run had a median of 88.0 ms, while going through strace -f came to 240.9 ms. Roughly 2.7x.
That number changed my mind about running guard-run on every single execution. Right after adding a new tool, plus once a week on a schedule: that cadence absorbs the slowdown while still catching the diff. A safety mechanism that becomes the reason things feel slow eventually gets removed. Mechanisms last longer when their weight is bearable.
Reverse lookups let me down
Earlier in this article I shared a script that "reverse-resolves" destinations. Being honest about it, that was the part where my assumptions were thin.
When I actually ran reverse lookups on the IPs recon collected, neither GitHub's 20.27.177.113 nor Anthropic's 160.79.104.10 returned a PTR record. All three addresses I tried had no reverse entry at all. Cloud provider ranges routinely ship without PTR, and that turns out to be the norm rather than the exception.
There was a more awkward discovery too. Tracing a single git ls-remote surfaced 172.16.10.1 among the destinations. An RFC1918 private address. Not the service at all, but a gateway or proxy sitting on the path.
In other words, recon output contains relay points that were never candidates for the allowlist in the first place. Copying observed IPs straight into an allowlist falls apart right here.
One more reason you cannot write IPs
To be sure, I resolved the main hosts twenty times each and counted unique addresses.
Host
Unique IPs across 20 resolutions
github.com
1
codeload.github.com
1
api.anthropic.com
1
registry.npmjs.org
12
Only registry.npmjs.org, sitting behind a CDN, handed back twelve different addresses in twenty lookups. An allowlist pinned to those IPs would break by the next day. Write allowlists with hostnames. The measurement made the reason concrete.
So I dropped reverse lookups and went the other way: resolve the allowlist candidates forward to build a dictionary, then map observed IPs back to names through it.
#!/usr/bin/env python3# resolve-map.py — resolve allowlist candidates forward, map observed IPs back to names# usage: ./resolve-map.py allowlist.txt observed-ips.txtimport socket, sysallow_file, observed_file = sys.argv[1], sys.argv[2]names = [l.strip() for l in open(allow_file) if l.strip() and not l.startswith("#")]ip2name = {}for name in names: # CDN addresses rotate per lookup, so query repeatedly and grow the set for _ in range(8): try: for info in socket.getaddrinfo(name, 443, socket.AF_INET, socket.SOCK_STREAM): ip2name.setdefault(info[4][0], set()).add(name) except socket.gaierror: print(f"# cannot resolve: {name}", file=sys.stderr) breakunknown = []for line in open(observed_file): ip = line.strip() if not ip: continue if ip in ip2name: print(f"{ip}\t{','.join(sorted(ip2name[ip]))}") else: unknown.append(ip)for ip in unknown: # a relay point, or a genuine gap in the allowlist? a human decides here print(f"{ip}\t(matches no name on the allowlist)")sys.exit(1 if unknown else 0)
One IP can map to several names, which is unremarkable on a CDN, so the dictionary values are sets. Whatever fails to match is the remainder that needs human judgment, and in my environment that remainder was a single address: 172.16.10.1. Once you know it is a relay point, it becomes a familiar face you can safely ignore.
Do not let the observation end as a one-off investigation. Keep the path back to names, and updating the allowlist shifts from guessing at additions to applying a diff.
Judgment calls that paid off
A few situational lessons that stuck after a few weeks of running it.
Situation
What I did
Adding a new tool
Run recon first, fold the new destinations into the allowlist, then ship
Afraid of missing a host
Do not widen with wildcards; let guard-run fail loudly and add the diff
An IP with no PTR appeared
Map it back through the forward dictionary (resolve-map.py); if no name matches, suspect a relay point and leave it off the allowlist
DNS looks suspect
Confirm the resolution path is allowed first (block it and everything dies)
The tighter the lock, the quieter the failure. That is exactly why two things became the crux: observing the hosts you really touch before you lock, and making the deny clearly visible after you lock. The effort before and after the switch is worth more than the single line that enables it.
I am still learning as I run this. If it spares someone taking automation toward a safer default one detour, I would be glad. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.