●2.1.274 — This round is aimed at people running Claude Code unattended: a warning when memory runs critical, an environment variable that bounds how long the first turn waits for MCP servers, and an end to sessions retrying a 400 forever●10/07 — The old management-settings spelling is accepted until noon PT on October 7, nineteen days from now. Deprecation warnings have been showing since September 10●WINUPD — Reports keep coming in of a Windows update leaving Cowork unable to mount a single host folder. Removing the update is still the only workaround anyone has found●NEW — Stopping before you hit the limit: a record of rebuilding the day around the five-hour window●BING — Seven in ten of the people who actually read these pages arrive from Bing. Search has more than one front door●EXCEL — Before handing over a spreadsheet, decide which columns it may read and which it may not●2.1.274 — This round is aimed at people running Claude Code unattended: a warning when memory runs critical, an environment variable that bounds how long the first turn waits for MCP servers, and an end to sessions retrying a 400 forever●10/07 — The old management-settings spelling is accepted until noon PT on October 7, nineteen days from now. Deprecation warnings have been showing since September 10●WINUPD — Reports keep coming in of a Windows update leaving Cowork unable to mount a single host folder. Removing the update is still the only workaround anyone has found●NEW — Stopping before you hit the limit: a record of rebuilding the day around the five-hour window●BING — Seven in ten of the people who actually read these pages arrive from Bing. Search has more than one front door●EXCEL — Before handing over a spreadsheet, decide which columns it may read and which it may not
Passing the Request, Not the Secret — Where Sandbox Credential Masking Works and Where Substitution Breaks
Claude Code's sandbox credential masking lets processes read sentinel values while a proxy swaps in the real secret at send time. I rebuilt it as a minimal proxy and measured which auth schemes survive the swap, which break, what happens when the secret rides in the body, and where chunked framing makes the substitution miss entirely.
Denying reads is a strong control. But any job that legitimately calls an external API still needs the real token somewhere. Deny can hide the secrets a job does not need — it cannot protect the one secret the job actually uses.
Then the August 5, 2026 changelog landed with mode: "mask" for sandbox credentials on Linux and WSL, and one sentence stopped me: commands inside the sandbox read a sentinel copy of the value, and the sandbox proxy swaps in the real value at send time.
Not hiding the secret. Handing the process a decoy, then swapping in the original at the exit.
How far that design actually protects you is not something the docs alone can answer. So I rebuilt the core of the mechanism — the byte-level swap — as a minimal proxy and pushed on its boundaries myself.
From "Can't Read It" to "Reads a Decoy"
Deny and mask protect different things.
Deny controls exposure. It hides ~/.aws/credentials and unrelated environment variables from the sandbox — the same instinct as tightening filesystem isolation from the write surface: shrink what can be touched at all.
Mask protects the next layer. Even a secret the job legitimately uses is never visible to the process. What the process reads is a harmless string like SBX_SENTINEL_...; the real value only exists in the request for the instant it passes through the egress proxy.
The implication is significant. If prompt injection or malicious code runs inside the sandbox, all it can exfiltrate is the sentinel — a string with no authority anywhere in the outside world.
And yet: a mechanism defined as "replace bytes at send time" must have cases it structurally cannot cover. To find them, I wrote the smallest proxy that could.
Rebuilding the Mechanism in Miniature
My test environment is Python 3.10.12 in a Linux sandbox. Real implementations involve TLS handling and more, but here I isolate the one core operation — replacing sentinel bytes on the wire with the real value — over a plaintext path.
The naive version replaces within each received chunk and forwards it:
# Naive: substitute the sentinel per received chunkdef handle(client_sock): up = socket.create_connection((UPSTREAM_HOST, UPSTREAM_PORT)) client_sock.settimeout(0.5) try: while True: chunk = client_sock.recv(8192) if not chunk: break # Only sentinels fully contained in one chunk get replaced up.sendall(chunk.replace(SENTINEL, REAL_VALUE)) except socket.timeout: pass
It appears to work. But it has a classic stream-processing hole: the moment a sentinel straddles two recv chunks, neither chunk contains the complete pattern, and the substitution silently misses.
The fix is a rolling buffer that always retains a tail of length len(sentinel) - 1:
# Rolling buffer: catches sentinels split across chunk boundariesS = SENTINEL # byteskeep = len(S) - 1 # longest prefix that could straddle a boundarydef handle(client_sock): up = socket.create_connection((UPSTREAM_HOST, UPSTREAM_PORT)) client_sock.settimeout(0.5) buf = b"" try: while True: chunk = client_sock.recv(8192) if not chunk: break buf += chunk buf = buf.replace(S, REAL_VALUE) if len(buf) > keep: # Hold back the last `keep` bytes until the next chunk arrives up.sendall(buf[:-keep]) buf = buf[-keep:] except socket.timeout: pass if buf: # Final flush: replace anything left in the tail before sending up.sendall(buf.replace(S, REAL_VALUE))
Error handling is trimmed for clarity, but the two essentials are the carried-over tail and the final flush. Drop either one and you get a bug that only fires when a sentinel lands at the end of a request — the kind that is miserable to reproduce.
✦
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
✦Learn to classify auth schemes into ones sentinel substitution can protect and ones it structurally cannot, backed by base64 and HMAC measurements
✦See why per-chunk replacement silently leaks sentinels that straddle recv boundaries, with reusable naive vs. rolling-buffer proxy code
✦Learn why a secret in the request body throws off the declared length — a longer real value truncates into a JSONDecodeError, a shorter one blocks upstream for 3.003 seconds — and why chunked framing defeats even a rolling buffer, with four measured outcomes, the correction code, and a six-step rollout checklist
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.
Measurement 1: Straddled Sentinels Slip Past Naive Replacement
I forced recv into 8-byte chunks so the 19-byte sentinel always straddles a boundary, put the sentinel in a Bearer header, and ran it through both proxies.
Implementation
Real value reached upstream
Sentinel leaked verbatim
Naive (per-chunk replace)
No
Yes
Rolling buffer
Yes
No
With the naive version, the upstream server's receive log contained the sentinel byte-for-byte. If you are ever tempted to hand-roll this kind of proxy, this single result is a good reason to reconsider. Stream substitution is a notch harder than it looks, and production implementations carry rolling-buffer logic for a reason.
Measurement 2: Transformed Secrets Cannot Be Swapped
This is where the results diverged from my expectations.
Substitution presupposes that the sentinel appears verbatim in the bytes on the wire. So what happens with auth schemes that transform the secret before sending?
First, Basic auth — user:password base64-encoded into a header. I put the sentinel in the password slot and sent it through the rolling-buffer proxy:
Authorization header received upstream, after base64 decode: user:SBX_SENTINEL_a7f3e9Swap performed: none (the sentinel survived inside the base64 envelope)
The proxy scans wire bytes, but what travels is the base64-transformed string. The sentinel's original form never appears, so there is nothing to replace. Upstream receives credentials that sincerely believe the sentinel is a password — and rejects them with a 401.
Next, HMAC signing (think AWS SigV4). I compared a signature keyed on the sentinel against one keyed on the real value:
First 16 hex chars of HMAC-SHA256("payload"): sentinel key: c22efffa06d956f2 real key: 37c6b2d919ceadfd
Different, of course. The signature is computed inside the sandbox, so a sentinel-keyed signature is what leaves. The proxy only sees the finished artifact passing by — it can swap the secret's raw form, never the computation the secret fed into. Verification fails every time.
So the comfortable reading — "enable masking and all secrets are protected" — turned out to be imprecise. Masking protects exactly those schemes where the secret appears verbatim on the wire. Schemes with encoding or signing in between are structurally out of reach. I doubt I would have extracted that from one line of documentation without running it.
Measurement 3: A Sentinel in the Body Throws Off the Length
Everything so far assumed the secret rides in a header. Reviewing my own jobs, though, one API takes the key inside the JSON request body. Whether that deserves the same treatment was worth checking.
Substitution replaces a sentinel with the real value. Their lengths almost never match. And the body's length has already been declared, up in the Content-Length header.
The moment you substitute, the declared length and the bytes actually on the wire disagree.
I built a JSON body containing the 19-byte sentinel and varied the length of the real value, watching what upstream did with each.
Real value length
Declared Content-Length
Body actually sent
Outcome upstream
32 bytes (longer than the sentinel)
73
86 bytes
Truncated at 73 bytes, JSONDecodeError
10 bytes (shorter than the sentinel)
52
43 bytes
Waited for 9 missing bytes, blocked 3.003 seconds
The longer case fails loudly. The body gets cut at the declared offset and the upstream JSON parser dies immediately. The cause may be obscure, but the failure itself is unmistakable.
The shorter case is the troublesome one. Upstream waits for all 52 declared bytes, and the 9 that never arrive hold the connection until timeout. It surfaces as latency, not as an error. In a job with retries, that degrades into a vague "sometimes it's slow."
Correcting Content-Length by the delta in the proxy resolves both:
import redef swap_with_length_fix(head: bytes, body: bytes) -> tuple[bytes, bytes]: """Adjust the declared length by the per-occurrence size delta, then substitute.""" delta = body.count(SENTINEL) * (len(REAL_VALUE) - len(SENTINEL)) if delta: head = re.sub( rb"(?i)Content-Length: *(\d+)", lambda m: b"Content-Length: " + str(int(m.group(1)) + delta).encode(), head, count=1, ) return head, body.replace(SENTINEL, REAL_VALUE)
With the correction in place, upstream saw 86 declared against 86 received and parsed the JSON cleanly. Production implementations surely handle this as a matter of course — but the shape of the problem is worth remembering: an obvious fact, that the sentinel and the real value differ in length, only bites on the body path.
Measurement 4: Chunked Framing Needs More Than a Length Fix
In my first draft, this section was a single sentence: chunked transfer encoding presumably behaves the same way, since the length is simply declared somewhere else. The idea nagged at me enough to go and measure it, and the answer sat a layer deeper than I expected.
Under Transfer-Encoding: chunked, the body travels as frames of <hex length>\r\n<data>\r\n. First I left those frame lengths untouched and substituted anyway, with a minimal chunked reader standing in for upstream.
Real value length
Chunk-size line
Outcome upstream
Elapsed
36 bytes (longer)
left as-is
missing-crlf-after-chunk (framing desynchronizes)
0.0 s
10 bytes (shorter)
left as-is
blocked 3.003 seconds
3.003 s
36 bytes (longer)
corrected
parsed cleanly
0.0 s
10 bytes (shorter)
corrected
parsed cleanly
0.0 s
The shorter case blocks for the same 3.003 seconds as with Content-Length, but the longer case wears a different face. Where Content-Length kills the JSON parser, chunked framing finds no \r\n at the offset it was promised and loses frame synchronization entirely. My reader grabbed the two bytes 56 there — reading part of the payload as the next frame's length declaration.
The HTTP framing breaks before the JSON does, so the error surfaces at the transport layer. Chasing it through application logs, you would have a hard time ever reaching the culprit.
And there is one more step down. Suppose the application splits the body across two frames, and the split lands in the middle of the sentinel.
# The app sends two chunks, and the split falls inside the sentinelbody = json.dumps({"key": SENTINEL.decode()}).encode()cut = body.index(SENTINEL) + 9p1, p2 = body[:cut], body[cut:]wire = (head + b"%x\r\n" % len(p1) + p1 + b"\r\n" + b"%x\r\n" % len(p2) + p2 + b"\r\n0\r\n\r\n")
Here is what actually goes out on the wire:
12\r\n{"key": "SBX_SENTI\r\nc\r\nNEL_a7f3e9"}\r\n0\r\n\r\nSentinel appears contiguously on the wire: FalseNaive replacement changes anything: False
A frame declaration, \r\nc\r\n, sits in the middle of the sentinel. Unlike a recv boundary, those are real bytes the protocol inserted. So a rolling buffer can accumulate the entire wire and still never see a contiguous sentinel: the substitution simply misses. The result is the same as the base64 case in Measurement 2 — the sentinel arrives upstream intact.
Substitution only reaches paths where the secret appears contiguously in the body after the framing is undone. No amount of careful byte scanning gets you there; a proxy has to de-frame chunked bodies, substitute in the payload, and re-frame. That sits one step earlier than the length correction.
How production implementations handle this is not something I can determine by measuring from the outside. What I can say is that anyone writing their own has one more hole to fall into here.
Which Auth Schemes You Can Hand Over
Here is how the measurements sort things:
Auth scheme
Secret's form on the wire
Fit with mask mode
Bearer token in the Authorization header
Verbatim
Good
Custom headers such as X-API-Key
Verbatim
Good
Basic auth
Base64-encoded
Does not work
HMAC-signed requests (SigV4 and similar)
Transformed into a signature
Does not work
JWTs signed with a private key
Transformed into a signature
Does not work
Body payload sent with chunked encoding
Can be split across frames
Needs a de-framing proxy
In the scheduled pipelines I run as an indie developer, the secrets that matter are a GitHub PAT for pushes and an Anthropic API key — both verbatim header schemes, so both are a good fit for masking. If I ever wire up a signature-based API, the signing step will have to live outside the sandbox, likely combined with a design that leans on short-lived credentials.
Measured Overhead, and Six Steps Before Enabling
I also measured the scanning cost: 200 requests with 1KB bodies, direct versus through the proxy (loopback, plaintext — treat these as reference values).
Path
Median
p95
Direct
0.612 ms
0.848 ms
Via proxy (with substitution)
1.277 ms
1.547 ms
+0.665ms at the median — roughly 2.1x the direct path. Against real network round-trips of tens to hundreds of milliseconds, that disappears into noise for ordinary API calls. Only tight inner loops firing many small requests need to think about the accumulation.
Before enabling mask mode, I would work through this sequence:
List every external API your jobs call and its auth scheme. Split them into verbatim-header schemes versus encode/sign schemes
Put only the verbatim-scheme secrets under mask. Bearer-style tokens belong here
Keep transformed-scheme secrets on the deny/unset side. If signing is required, do it outside the sandbox
Set apart any API that carries the secret in the request body. Unlike verbatim headers, these add the question of keeping the declared length consistent (Measurement 3)
Check whether that API sends chunked. Frame boundaries can erase the sentinel's contiguous form altogether (Measurement 4)
Watch upstream 401 rates and p95 latency together after the switch. Misclassification shows up as auth errors; a length mismatch shows up as delay
Latency joined step 6 because Measurement 3 broke one of my assumptions. When I started writing, I believed a masking misclassification never fails silently — that it always announces itself with a 401. On the header path, that holds. But on the body path with a shorter real value, it arrives as waiting time instead. One class of failure slips past anyone watching only for 401s.
Step 5 arrived later still. Until I ran Measurement 4, I had it filed away as a variant of Measurement 3.
Measure the Boundary, Then Delegate
Deny narrows the exposure surface, filesystem isolation narrows the write surface, and now mask keeps even the in-use secret away from the process. The sandbox's defenses are visibly becoming layered.
But the work of measuring each new layer's boundary cannot be skipped. Had I applied mask mode to a signature-based API without knowing that substitution only touches verbatim bytes, I would have lost hours to auth errors with no obvious cause. The length mismatch would have been quieter still.
Start by making the table for your own jobs: each secret, each auth scheme, transformed or not. Add two more columns — header or body, chunked or not — and a single sheet will carry the concerns from Measurements 3 and 4 as well. Once the table is filled in, the range you can safely hand to masking becomes self-evident.
The verification code above is yours to reuse. If it shortens someone's debugging session even a little, writing this up was worth it.
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.