●BUDGET — You can now cap what a Claude Managed Agents session spends. When it hits the cap, the session stops issuing new model requests and returns a budget_reached stop reason●RESUME — Change or clear the budget and the session picks up again. Deployments take the same setting, but it applies per session they start, not to the deployment as a whole●GEO — A new inference_geo field controls where inference runs. Set it inside the model object when creating an agent, or override it for a single session. It takes us or global●SKILLS — When a Managed Agents session mounts a GitHub repository, any skills sitting in its root .claude/skills directory are discovered automatically at session start●TRADEOFF — Convenience and context cost sit on the same scale. Every extra skill you load also shows up in what /skill-doctor charges you each turn●CLI — Claude Code has not shipped a confirmed release since v2.1.263 on September 6. Version numbers skip, so check the official changelog against CHANGELOG.md before quoting one●BUDGET — You can now cap what a Claude Managed Agents session spends. When it hits the cap, the session stops issuing new model requests and returns a budget_reached stop reason●RESUME — Change or clear the budget and the session picks up again. Deployments take the same setting, but it applies per session they start, not to the deployment as a whole●GEO — A new inference_geo field controls where inference runs. Set it inside the model object when creating an agent, or override it for a single session. It takes us or global●SKILLS — When a Managed Agents session mounts a GitHub repository, any skills sitting in its root .claude/skills directory are discovered automatically at session start●TRADEOFF — Convenience and context cost sit on the same scale. Every extra skill you load also shows up in what /skill-doctor charges you each turn●CLI — Claude Code has not shipped a confirmed release since v2.1.263 on September 6. Version numbers skip, so check the official changelog against CHANGELOG.md before quoting one
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, and three defects that made my own recon scripts return nothing.
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.
The later half of this article covers something I only found after publishing the first version: the observation scripts I wrote to watch the lockdown were themselves broken. I ran them again with the kind of input they were built for, and they returned nothing at all.
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.
#!/usr/bin/env bash# egress-recon.sh — discover which hosts a command actually connects to# usage: ./egress-recon.sh <command to run...># e.g. ./egress-recon.sh bash deploy-pipeline.sh# NOTE: this version has three defects described later. The corrected script is# in "The observation harness was the first thing that broke"set -euo pipefailTRACE="$(mktemp)"trap 'rm -f "$TRACE"' EXIT# follow only connect(2), recording destination addresses# -f: follow child processes (git and npm spawn many)strace -f -qq -e trace=connect -o "$TRACE" "$@" || true# extract IPv4 destinations: sin_addr=inet_addr("x.x.x.x")grep -oE 'inet_addr\("[0-9.]+"\)' "$TRACE" \ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \ | sort -u > "$TRACE.ip"echo "# destination hosts (reverse-resolved, deduped)"while read -r ip; do # skip local / link-local case "$ip" in 127.*|0.0.0.0|169.254.*) continue;; esac host="$(getent hosts "$ip" | awk '{print $2}')" printf '%-24s %s\n' "${host:-"(no PTR)"}" "$ip"done < "$TRACE.ip" | sort -u
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.
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
✦Measured failure timings (0.02ms, 1.22ms, exactly 3s) that separate a policy block from DNS and from a real outage
✦Three defects that made the recon script return an empty list on exactly the addresses it was written for, with the corrected scripts and their real output
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 allowlist# NOTE: this first version is also defective. The corrected one follows below.set -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.
The observation harness was the first thing that broke
This is the part that sent me back to rewrite the article.
Once I knew reverse lookups were unreliable, I corrected the numbers above and considered the matter closed. Then I re-ran the two scripts against IPs that have no PTR record — the exact case they were written for — and neither behaved the way I intended. Three defects were stacking on top of each other.
Defect 1: the output went empty the moment a reverse lookup failed
getent hosts returns exit code 2 when it cannot resolve a name. But egress-recon.sh opens with set -euo pipefail. Under pipefail, the assignment host="$(getent hosts "$ip" | awk '{print $2}')" inherits getent's failure, and set -e tears down the whole loop.
Here is the published version, run against three IPs with no PTR record.
$ ./egress-recon.sh # the version published above# destination hosts (reverse-resolved, deduped)exit=2
Not one host line. It printed the header and stopped. As the earlier section established, cloud IPs generally cannot be reverse-resolved. So the script returned an empty list, silently, on precisely the addresses I most wanted to see.
A missing || true in one place had closed the entrance to the whole observation. There is no error message either, which is why I spent a long time believing I simply had a quiet environment.
Defect 2: the header # was reported as an unknown host on every run
guard-run.sh pipes recon output through awk '{print $1}'. The first field of the header line # destination hosts (reverse-resolved, deduped) is #. The grep -vE '^\(' that follows only drops lines starting with a parenthesis, so # sails through.
With defect 1 patched, here is what the published reconciliation logic produced.
--- SEEN (published version) ---#github.comregistry.npmjs.org--- comm result (published UNKNOWN) ---#--- exit decision ---equivalent to exit 1 (warning raised)
Even when every destination is on the allowlist, # survives as an unknown host, so guard-run.sh exits 1 on every single run. A monitor that always warns is a monitor nobody reads. I had been skimming past that output myself for weeks.
Defect 3: it filtered out the very lines worth reading
I wrote grep -vE '^\(' to drop the (no PTR) lines. But as the measurements showed, having no PTR record is the default for cloud hosts, not the exception.
Look again at that run: SEEN contains neither 160.79.104.10 (api.anthropic.com) nor 172.16.10.1 (the relay point on the path). The lines that most needed human judgment were being discarded before reconciliation.
Putting all three together, the shape finally came into focus. The eyes that watch the lockdown broke before the lockdown did. I think the reassurance of having added a security setting is what let me skip auditing the thing that audits it.
The corrected scripts
Each fix is small. Send the header to standard error, fall back to the IP itself when no name resolves, and reconcile on the first tab-separated field only.
#!/usr/bin/env bash# egress-recon.sh (fixed) — fall back to the IP when no name resolvesset -euo pipefail# header goes to stderr; stdout stays machine-readable as "name TAB ip"echo "# destinations (IP shown when no name resolves)" >&2while read -r ip; do case "$ip" in 127.*|0.0.0.0|169.254.*) continue;; esac # getent exits 2 when it cannot resolve; absorb it so pipefail does not kill us host="$(getent hosts "$ip" 2>/dev/null | awk '{print $2}')" || host="" printf '%s\t%s\n' "${host:-$ip}" "$ip"done < "$TRACE.ip" | sort -u
#!/usr/bin/env bash# guard-run.sh (fixed) — move known relay points into a separate fileset -euo pipefailALLOW_FILE="${1:?pass an allowlist file}"IGNORE_FILE="${2:-/dev/null}" # known, but deliberately not on the allowlistSEEN="$(mktemp)"; KNOWN="$(mktemp)"; trap 'rm -f "$SEEN" "$KNOWN"' EXIT# the header is on stderr now; still guard against lines starting with #./egress-recon.sh "${@:3}" 2>/dev/null \ | awk -F'\t' 'NF && $1 !~ /^#/ {print $1}' | sort -u > "$SEEN"cat "$ALLOW_FILE" "$IGNORE_FILE" 2>/dev/null \ | sed 's/#.*//' | awk 'NF{print $1}' | sort -u > "$KNOWN"UNKNOWN="$(comm -23 "$SEEN" "$KNOWN" || true)"if [ -n "$UNKNOWN" ]; then echo "⚠️ detected connections outside the allowlist:" >&2 echo "$UNKNOWN" >&2 exit 1fiecho "✅ all destinations were within the allowlist"
Running the corrected pair against the same input, two ways:
=== fixed recon, stdout ===104.16.0.35 104.16.0.35160.79.104.10 160.79.104.10172.16.10.1 172.16.10.120.27.177.113 20.27.177.113exit=0=== fixed guard (allowlist + relay list cover everything) ===✅ all destinations were within the allowlistexit=0=== fixed guard (an unknown 93.184.216.34 mixed in) ===⚠️ detected connections outside the allowlist:93.184.216.34exit=1
IPs without a PTR record stay in the name column, the known relay lives in known-transit.txt so it raises no warning, and a genuinely unfamiliar destination is the only thing that fails the run. Only with all three behaviors in place does the monitor mean anything.
The relay file holds a single line. Make the human judgment once, then write the conclusion down.
# relay point on the path (not the service itself)172.16.10.1
The name dictionary is still in the loop
Having dropped reverse lookups, I kept the other direction: resolve the allowlist candidates forward to build a dictionary, then map observed IPs back to names through it. It translates whatever the fixed guard reports as unknown, once, before a human looks at 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)
You wrote an observation script
Run it once against failing input — an IP with no PTR, a header line — not just the happy path
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.
What hit hardest this time was that the observing side had been broken all along, without a word. A monitoring tool deserves as much suspicion as the thing it monitors.
Start by handing your own recon script a single IP with no reverse record. If nothing comes back, you are standing where I stood. 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.