Een maand geleden schreef ik over een canary die naar buiten ging. Iemand zette de Grok Build CLI achter mitmproxy, plantte API_KEY=CANARY7F3A9-SECRET-should-not-leave in een repo, en zag hem woordelijk terugkomen in de capture.
Die post eindigde op een zin waar ik nog steeds achter sta: een belofte kun je niet auditen, een verbinding wel.
De voor de hand liggende vervolgvraag bleef onbeantwoord. Prima, ik heb naar de verbinding gekeken. En dan? Wat doe ik eraan dat de shell van mijn agent elk token op die machine kan lezen?
Claude Code bouwde daar in stappen een antwoord op: gemaskeerde omgevingsvariabelen sinds 2.1.199, gemaskeerde credential-bestanden sinds 2.1.221, en sinds 2.1.224 van 7 augustus de opties die het bruikbaar maken op echte waarden in plaats van op kale tokens. Alles hieronder is gecontroleerd op 2.1.231.
Het idee: je shell krijgt het geheim nooit te zien
Credential masking zit tussen twee botte opties in die je al had.
Je kon de agent GH_TOKEN laten zien en hopen. Of je zette er deny op, waarna gh niet meer werkt en de agent jou om de haverklap vraagt dingen met de hand te draaien.
Maskeren neemt van allebei de goede helft. Het commando in de sandbox ziet een sentinel: een nepwaarde die per sessie wisselt. Zodra een verzoek de sandbox verlaat richting een host die jij hebt aangewezen, wisselt de sandbox-proxy die sentinel onderweg om voor de echte credential.
Het commando authenticeert gewoon. Het commando heeft het geheim nooit vastgehouden. Wat het ook logt, in een transcript echoot of per ongeluk op een pastebin zet: daar staat de sentinel in.
Dit is het minimum dat werkt:
{
"sandbox": {
"enabled": true,
"network": {
"tlsTerminate": {},
"allowedDomains": ["*.github.com", "registry.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GH_TOKEN", "mode": "mask", "injectHosts": ["api.github.com"] },
{ "name": "NPM_TOKEN", "mode": "mask" }
]
}
}
}injectHosts is het onderdeel dat je goed moet zetten. GH_TOKEN wordt alleen omgewisseld op verzoeken naar api.github.com. NPM_TOKEN heeft geen injectHosts en gaat dus naar élke host in allowedDomains, en dat is makkelijker dan verstandig. Alles wat in injectHosts staat, moet ook onder allowedDomains vallen.
tlsTerminate is niet optioneel. De proxy moet in het verzoek kunnen kijken om iets te kunnen omwisselen, en dus moet hij TLS zelf termineren.
Laat je het weg, dan krijg je dit bij het opstarten. Ik heb het expres uitgelokt om de formulering te controleren:
⚠ sandbox.credentials mask entries (DEMO_TOKEN) are configured but TLS
termination is unavailable — sandboxed commands see only a sentinel value
and the proxy cannot substitute the real credential on egress, so tools
needing these will fail to authenticate.Lees die faalmodus goed, want het is de goede soort. Zet je dit verkeerd, dan komt de sentinel ongewijzigd bij de server aan. Je build breekt. Je geheim lekt niet. In die volgorde.
Waarden met structuur
De hele waarde vervangen past bij een kaal token. Lang niet elke credential is een kaal token.
extract neemt een reguliere expressie en vervangt alleen wat groep 1 vangt, zodat de rest van de waarde overeind blijft:
{ "name": "DATABASE_URL", "mode": "mask", "extract": "://[^:]+:([^@]+)@" }Het proces in de sandbox krijgt nu een connection string die het gewoon kan parsen, met een echte host, poort en databasenaam. Alleen het wachtwoord is een placeholder.
Combineer dat met onExtractNoMatch, dat bepaalt wat er gebeurt als het patroon niets vindt. De standaard is warn, en die standaard laat de variabele ongemaskeerd door. Dat is de verkeerde keuze voor alles wat ertoe doet. Als het geheim er zou kunnen staan en je patroon het zou kunnen missen, zet dan deny zodat de variabele in de sandbox helemaal niet bestaat, of error om het opzetten van de sandbox te blokkeren tot je het hebt rechtgezet.
Voor een JWT gebruik je decode. Dat laat zich niet combineren met extract:
{
"name": "SERVICE_JWT",
"mode": "mask",
"decode": "jwt",
"maskClaims": ["sub", "email"],
"injectHosts": ["api.internal.example.com"]
}Claude Code controleert eerst of de waarde echt een JWT is en geeft de sandbox een structureel geldige nepversie, zodat code die het token uitleest blijft werken in plaats van een error te geven. Met maskClaims maak je het fijnmaziger: maskeer de claims die een mens identificeren en laat iss en exp leesbaar, zodat je debugsessie nog ergens op slaat.
AWS, en de drie verzoeken die je niet opnieuw kunt ondertekenen
AWS is het lastige geval, want een SigV4-verzoek is ondertekend over zijn eigen inhoud. Een sleutel omwisselen in een ondertekend verzoek levert een verzoek met een kapotte handtekening op.
De proxy lost dat op door het verzoek te herkennen aan de sentinel van de access key, en ondertekent het na de omwisseling opnieuw. Dat betekent dat je AWS_ACCESS_KEY_ID en AWS_SECRET_ACCESS_KEY samen moet maskeren. Maskeer je alleen de secret, dan herkent de proxy het verzoek helemaal niet, gaat het met een placeholder ondertekend naar AWS en sneuvelt het daar.
De gebruikelijke variabelenamen worden automatisch aan elkaar gekoppeld. Eigen namen koppel je met awsPairs:
{
"sandbox": {
"credentials": {
"awsPairs": [
{
"accessKeyIdVar": "MY_KEY_ID",
"secretAccessKeyVar": "MY_SECRET_KEY",
"sessionTokenVar": "MY_SESSION_TOKEN"
}
]
}
}
}Drie soorten verzoeken dragen een handtekening die de proxy niet kan herberekenen: aws-chunked streaming uploads, presigned URL's en asymmetrische SigV4A-handtekeningen. Standaard stranden die bij de proxy in plaats van kapot naar buiten te gaan. Met de instelling sigv4 versoepel je dat per soort via passthrough: het verzoek gaat alsnog de deur uit, zodat de tool die het verzoek doet de afwijzing van AWS zelf terugkrijgt in plaats van een proxyfout. Gebruik het om de echte foutmelding te zien, en repareer daarna je config.
Je repo kan dit niet aanzetten
Dit is de ontwerpkeuze die ik het mooiste vind, en meteen degene die je beter kunt testen dan geloven.
Maskeren geeft de proxy toestemming om je echte credential naar hosts te sturen die in een configbestand staan. Een configbestand is nu juist het soort ding dat binnenkomt als je iets cloont. Daarom worden mask-regels, tlsTerminate en awsPairs alleen gehonoreerd vanuit user settings, managed settings en de --settings-vlag. De .claude/settings.json van een repository wordt genegeerd.
Ik heb het gecontroleerd in plaats van aangenomen. Ik zette identieke mask-regels in twee scopes en draaide ze in één sessie: DEMO_TOKEN via --settings, PROJ_TOKEN via de .claude/settings.json van het project. De opstartwaarschuwing noemde alleen DEMO_TOKEN. De regel uit de repo verdween zonder een woord.
Geruisloos, en dat is het weten waard. Een collega die dit in de projectconfig zet krijgt geen foutmelding, alleen een masker dat er nooit komt.
Het is dezelfde grens die ik natrok bij wat een repo wel en niet kan afdwingen aan permissies: een repository mag altijd inperken wat een agent mag, en nooit oprekken.
Wat dit je niet oplevert
Drie grenzen, en die schrijf ik op, want een maatregel die je verkeerd begrijpt is erger dan een die je overslaat.
Het wisselt credentials om, en verder niets. Wat er nog meer in dat verzoek meelift, blijft ongezien. Alles waar ik me druk om maakte bij de Grok-capture geldt onverkort: besluit een client jouw repository naar een toegestane host te uploaden, dan kijkt het masker toe. Dit dicht het gat van je .env-bestand. Het gat van de upload dicht het niet.
Je termineert nu je eigen TLS. tlsTerminate staat nog als experimenteel aangemerkt, en aanzetten betekent dat er een onderscheppende proxy in je lus zit met echte credentials in het geheugen. Dat is een verdedigbare ruil tegenover een shell vol levende tokens, en het blijft een ruil. De proxy is nu het onderdeel dat vertrouwen verdient.
De allowlist eronder is al een keer bezweken. Maskeren leunt op de sandbox-proxy, en de hostnaam-allowlist van die proxy had vijfeneenhalve maand lang een parserverschil in zich. Aonan Guan liet zien dat een hostnaam als attacker-host.com\x00.google.com langs de JavaScript-controle met endsWith() glipte en daarna ergens heel anders uitkwam, omdat getaddrinfo() uit libc stopt met lezen bij de nulbyte. Elke release van v2.0.24 tot en met v2.1.89 was kwetsbaar, stilletjes gerepareerd in v2.1.90 op 1 april, zonder CVE tegen Claude Code zelf. Zijn eigen conclusie is de juiste bril voor alles hierboven: behandel de sandbox van je leverancier als een extra laag, en leg je echte uitgaande filtering op netwerk- of hypervisorniveau, buiten het bereik van de agent.
Nog een praktisch puntje voor Linux en WSL2, dat mij tien minuten verwarring kostte: de sandbox heeft naast bubblewrap ook socat nodig, en zonder dat pakket draait Claude Code je commando's stilletjes buiten de sandbox, met een waarschuwing bovenaan de sessie. Draai /sandbox en lees het tabblad Dependencies voordat je aanneemt dat hier ook maar iets van actief is. De sandbox zelf moet staan voordat maskeren iets betekent.
Die capture in juli werkte omdat één iemand de client genoeg wantrouwde om de bytes te lezen. Dit is de eerste maatregel die ik zie die de bytes met opzet saai maakt: laat de agent werken, laat het verzoek authenticeren, en zorg dat wat de moeite van het stelen waard is, daar nooit ligt.
Stel het in, en ga daarna alsnog naar de verbinding kijken.