LangSmith Sandbox Credentials: Hands-On with Claude Code, GitHub and the Auth Proxy
By Fabio Douek
Jump to section
- Overview
- Architecture
- Setup
- Step 1: Credentials to inject
- Step 2: Companion code and snapshot
- Step 3: Store the secrets
- Step 4: The proxy rules
- Testing
- Test 1: How the proxy injects
- Test 2: Claude Code with a placeholder key
- Test 3: git and gh without a token
- Test 4: Claude Code opens a pull request
- Test 5: Trying to get the credentials out
- Test 6: Rotating and revoking under a running sandbox
- Test 7: AWS without AWS credentials
- Results summary
- What injection does not solve
- Cleanup
- Verdict
Explain (TLDR) like I am...
Imagine your robot helper wants cookies from different jars, and each jar needs a different key. Instead of giving the robot the keys, a grown-up stands by the jars with a list that says which key opens which jar, and opens the right one whenever the robot asks.
The robot never touches a key. The catch is that if you change a key, the grown-up keeps using the old one until you hand them a fresh list, and the robot can still fetch cookies for a sneaky stranger.
The question is custody. An AI coding agent usually holds the API keys it uses, so anything that compromises the agent can copy them. LangSmith keeps the keys in workspace secrets and attaches them to outbound requests through a proxy outside the sandbox.
That reduces the risk of theft, not of misuse. Diligence points: rules can be scoped by host and path, but a changed or deleted secret keeps working in a running sandbox until its configuration is reapplied. Non-Enterprise plans also restrict where a sandbox may connect: AWS needed a support exemption, which removes the default allow list.
The condition is credential exposure: agents leak keys when hostile text tells them to. The intervention keeps the key outside the sandbox and adds it to requests on the host. In my tests, the environment, the files and the process environments held no real credential, even searched as root.
Side effects to monitor: the agent can still use the access, a revoked secret is not withdrawn from running sandboxes until the configuration is reapplied, and a disabled rule lets requests through without credentials. The AWS treatment worked once egress was opened.
Notice the quiet worry that comes with giving an agent your keys and then letting it read untrusted code. Here the agent works as usual, but the keys never sit on its desk, and asking it to show them leads nowhere.
The trust moves rather than disappears. Someone still decides which hosts get which key, and someone has to remember that changing a key only takes effect when the sandbox is told about it. Naming that owner keeps rotation from becoming a source of quiet anxiety.
Think of the sandbox as the stage and LangSmith's proxy as the engineer with a patch sheet. Each line on the sheet says which output gets which signal, down to individual paths, and the band never handles the real cables.
The groove is unchanged for the player: Claude Code, git and gh just play. The thing to rehearse is the change between songs: a new key only reaches the desk when you hand the engineer a fresh sheet, and then it lands within a second.
The story is a clean before and after. Before: the sandbox holds a live API key and a GitHub token any prompt injection can print. After: the same agent opens a pull request while the sandbox holds nothing but a placeholder string.
The differentiator is precision: rules match host and path, values can be templated, and AWS role signing works with no AWS keys in the sandbox. The caveat buyers should hear early is that non-Enterprise plans restrict where sandboxes can connect, and AWS takes a ticket.
Overview
Running a coding agent in a sandbox protects your machine from the agent, not your credentials. If Claude Code needs an Anthropic key and a GitHub token, the usual setup puts them in the sandbox environment, where a prompt-injected printenv can take them.
LangSmith Sandboxes, which went generally available in May 2026, handle this with an auth proxy that runs on the host, outside the sandbox VM. You give each sandbox a proxy_config of rules: match a host (and optionally paths or headers), and add these headers. Header values come from LangSmith workspace secrets, written as {SECRET_NAME}, or from write-only “opaque” values. The sandbox itself holds nothing, or a placeholder so tools like gh agree to start.
So the proxy adds headers to matching requests; the sandbox doesn’t have to send anything. Two features stand out: rules can match paths as well as hosts, and an aws_auth rule can re-sign AWS requests with an IAM role trusted through LangSmith’s OIDC issuer, so not even temporary AWS keys enter the VM.
This post is part 2 of 2; part 1 walks a sandbox through its whole lifecycle. I built a Claude Code sandbox on that model, with the Anthropic key, a GitHub token for the REST API and the same token in Basic form for git. Then I tested how injection behaves, had Claude Code open a real pull request, tried to get the credentials out, and rotated them under a running sandbox. I also set up the AWS side, an OIDC provider and an IAM role. On my non-Enterprise organization, restricted egress blocked every AWS endpoint at first, so the role signing had to wait for an exemption from LangChain support. Once it came through, I re-ran the AWS test on 2026-11-03, adding Bedrock, an S3 mount and the sandbox’s own egress controls. Everything below ran on the langsmith Python SDK 0.14.14, Claude Code 2.1.312 and gh 2.103.3, against LangSmith’s GCP US region.
Architecture
Three things in that picture matter for the rest of the post:
- The proxy is transparent and adds headers. There’s no
HTTP_PROXYin the sandbox. Traffic to a host that matches a rule is intercepted and decrypted on the host, and the rule’s headers are added, replacing any the sandbox sent itself. The sandbox trusts the proxy’s CA through the system bundle, whichSSL_CERT_FILE,NODE_EXTRA_CA_CERTS,REQUESTS_CA_BUNDLEandCURL_CA_BUNDLEall point at. - Secrets are resolved when the config is applied, not per request. A rule says
{GITHUB_BEARER}; LangSmith looks up the value when the sandbox is created or itsproxy_configis updated. That has big consequences for rotation (Test 6). The exception is callbacks: a rule can call an endpoint you host to fetch a credential at request time (cached for 60 seconds to an hour, failing closed). I didn’t test them here. - On non-Enterprise plans, egress is a managed allow list. Any
access_controlyou pass is replaced by the organization’s policy, and a rule for a host doesn’t open that host. An exemption from LangChain support removes the list, and from then on your ownaccess_controlis the only thing limiting where a sandbox connects. More on that in Test 7.
Setup
Step 1: Credentials to inject
- An Anthropic API key, with a spend limit.
- A GitHub fine-grained personal access token scoped to one throwaway repo, with Contents and Pull requests set to read and write.
- For the AWS part: an AWS account where you can create an IAM OIDC provider, a role and a bucket, and a LangSmith organization that can reach AWS (Test 7).
Step 2: Companion code and snapshot
git clone https://github.com/fabiodouek/my2centsai-blog-samples.git
cd my2centsai-blog-samples/langsmith-sandbox-credentials
uv venv && uv pip install -e .
cp .env.example .env # LANGSMITH_API_KEY, ANTHROPIC_API_KEY, GITHUB_TOKEN, GITHUB_REPO
python snapshot/build.py
The snapshot is built from a Dockerfile, using LangSmith’s create_snapshot_from_dockerfile. It pins current Claude Code and gh versions:
FROM node:22-bookworm
ARG CLAUDE_CODE=2.1.312
ARG GH=2.103.3
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE} \
&& curl -sL https://github.com/cli/cli/releases/download/v${GH}/gh_${GH}_linux_amd64.tar.gz \
| tar -xz -C /usr/local --strip-components=1 \
&& apt-get update && apt-get install -y --no-install-recommends python3-boto3 jq && rm -rf /var/lib/apt/lists/*
The build runs inside a temporary builder sandbox (Docker Hub, npm, GitHub and Debian are all on the restricted allow list) and took about five minutes. Three things tripped me up on the way:
- Disk:
fs_capacity_bytesof 8 GiB is rejected because the builder starts from a 16 GiB base, so use 16 GiB. - Timeout: the SDK’s default HTTP timeout is 10 seconds, and
create_snapshot_from_dockerfile(timeout=...)doesn’t extend it for the final capture call. My first build failed with a client-sideReadTimeout, the SDK tore the builder down mid-capture, and the snapshot ended upfailed. Building withSandboxClient(timeout=600)fixed it. - Tag: Dockerfile builds are tagged with a build hash, not
latest, socreate_sandbox(snapshot="my2cents-claude-creds")(or the deprecatedsnapshot_name=) looks formy2cents-claude-creds:latestand returns 404. The companion code looks the snapshot up by name and passessnapshot_id.
Step 3: Store the secrets
python 01_setup_secrets.py
Workspace secrets are set with one POST /api/v1/workspaces/current/secrets call, and reading them back returns keys only:
token = os.environ["GITHUB_TOKEN"]
values = {
"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"],
"GITHUB_BEARER": f"Bearer {token}",
"GITHUB_BASIC": "Basic " + base64.b64encode(f"x-access-token:{token}".encode()).decode(),
}
secrets_api("POST", json=[{"key": k, "value": v} for k, v in values.items()])
=== What the API gives back (values are write-only) =============
{'key': 'ANTHROPIC_API_KEY'}
{'key': 'GITHUB_BASIC'}
{'key': 'MECH_BARE_TOKEN'}
{'key': 'GITHUB_BEARER'}
(MECH_BARE_TOKEN is the throwaway secret Test 1 creates.) There’s no per-secret ID, version or placeholder in the listing. Secrets can carry allowed hosts too (code evaluators use them), but proxy rules don’t need that.
Workspace secrets are shared by everything in the workspace: LangSmith’s code evaluators, for example, use the same store. Name them accordingly.
Step 4: The proxy rules
Header rules are plain dicts; the SDK’s workspace_secret() helper builds the reference:
from langsmith.sandbox import proxy_config, workspace_secret
ANTHROPIC_RULE = {
"name": "anthropic",
"match_hosts": ["api.anthropic.com"],
"headers": [{"name": "x-api-key", **workspace_secret("ANTHROPIC_API_KEY")}],
"env_vars": {"ANTHROPIC_API_KEY": "sk-ant-placeholder-not-a-real-key", "ANTHROPIC_MODEL": "claude-sonnet-4-6"},
}
GITHUB_API_RULE = {
"name": "github-api",
"match_hosts": ["api.github.com"],
"headers": [{"name": "Authorization", **workspace_secret("GITHUB_BEARER")}],
"env_vars": {"GH_TOKEN": "proxy-injected"},
}
GITHUB_GIT_RULE = {
"name": "github-git",
"match_hosts": ["github.com"],
"headers": [{"name": "Authorization", **workspace_secret("GITHUB_BASIC")}],
}
sbx = client.create_sandbox(
snapshot_id=snapshot_id("my2cents-claude-creds"), # companion helper: looks the snapshot up by name
proxy_config=proxy_config(rules=[ANTHROPIC_RULE, GITHUB_API_RULE, GITHUB_GIT_RULE]),
idle_ttl_seconds=600, delete_after_stop_seconds=600,
)
env_vars on a rule are set for every command while the rule is enabled. They’re for placeholders only: LangSmith stores and returns them in plain text (Test 1).
Testing
Test 1: How the proxy injects
python 02_injection_mechanics.py
When I ran this, restricted egress blocked httpbin and every other echo service, so this test uses GitHub as the observable endpoint: GET /user answers 200 or 401, and GET /rate_limit shows whether a request was authenticated (60 requests an hour without a token, 5,000 with). Two rules on api.github.com, split by path:
rules = [
# Text + reference in one header value
{"name": "templated", "match_hosts": ["api.github.com"], "match_paths": ["/user"],
"headers": [{"name": "Authorization", "type": "workspace_secret", "value": "Bearer {MECH_BARE_TOKEN}"}]},
# A write-only value sent with the config, on other paths
{"name": "opaque", "match_hosts": ["api.github.com"], "match_paths": ["/repos/*"],
"headers": [{"name": "Authorization", **opaque_secret(f"Bearer {token}")}],
"env_vars": {"MECH_VISIBLE": "env_vars are stored and returned in plaintext"}},
]
=== A. Inside the sandbox =======================================
MECH_VISIBLE=env_vars are stored and returned in plaintext
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
no HTTP_PROXY: interception is transparent
=== B. Matched host and path: /user =============================
GET /user -> 200
=== C. Sandbox sends its own Authorization header ===============
GET /user with 'Bearer bogus' -> 200
=== D. match_paths: unmatched path gets nothing =================
GET /rate_limit (no rule for this path) -> {"limit":60,"used":0}
GET /repos/<repo> (opaque rule) -> 200
=== E. Plain HTTP to a rule host ================================
http://api.github.com/user -> 403
=== F. What leaves the process, and who signed the certificate ==
* issuer: CN=smithbox-proxy-ca
> Host: api.github.com
| Check | Result |
|---|---|
| Secret in the sandbox env | Not there; only the rule's env_vars |
"Bearer {SECRET}": text plus a reference |
Works: the reference is templated into the string |
Sandbox sends its own Authorization: Bearer bogus |
Overwritten by the rule (200) |
Path with no matching rule (/rate_limit) |
No header: unauthenticated limit of 60 |
Opaque value on /repos/* |
Works (200) |
| Plain HTTP to a rule host | 403 from the proxy |
What curl -v sees leaving the process |
No Authorization header at all; TLS issued by smithbox-proxy-ca |
Path matching is the standout feature. One host, two different credentials, chosen by path. You can give api.github.com/repos/<one-repo>/* a token and leave the rest of the API unauthenticated. The templating answers a question my research couldn’t: you can store the bare token and write Bearer {TOKEN} in the rule, rather than baking the prefix into the secret.
Reading the sandbox back through the REST API (GET /api/v2/sandboxes/boxes/{name}; the SDK’s get_sandbox() model leaves proxy_config out) shows what LangSmith stores:
{"name": "templated", "headers": [{"name": "Authorization", "type": "workspace_secret", "value": "Bearer {MECH_BARE_TOKEN}"}]},
{"name": "opaque", "headers": [{"name": "Authorization", "type": "opaque", "is_set": true}],
"env_vars": {"MECH_VISIBLE": "env_vars are stored and returned in plaintext"}}
Workspace-secret references come back as references, opaque values come back as is_set: true, and env_vars come back in full. The same response includes access_control.allow_list: the managed egress list for restricted organizations, 60 entries, which LangChain doesn’t otherwise publish. It covers package registries (npm, PyPI, astral), Git hosts, Debian and Ubuntu mirrors, container registries, LangChain’s own hosts, and the main model APIs (*.anthropic.com, *.openai.com, Gemini, Groq, DeepSeek, OpenRouter, Hugging Face). No AWS. After the egress exemption (Test 7), new sandboxes come back with no access_control at all unless you set one.
Test 2: Claude Code with a placeholder key
python 03_claude_code.py
=== A. No rule: placeholder key goes to Anthropic as-is =========
2.1.312 (Claude Code)
ANTHROPIC_API_KEY=sk-ant-placeholder-not-a-real-key
no rule: {"is_error":true,"result":"Invalid API key · Fix external API key"}
=== B. Header rule on api.anthropic.com =========================
json 'ok' error=False cost=$0.0791 4.4s
stream-json 3 events
The control (the placeholder in env, no header rule) fails cleanly with “Invalid API key” rather than hanging. With the rule, Claude Code works in 4.4 s, streaming included. It trusts the proxy’s CA through NODE_EXTRA_CA_CERTS, with nothing to configure.
Test 3: git and gh without a token
python 04_github.py
=== What the sandbox has ========================================
GH_TOKEN=proxy-injected
no git config
=== gh api user (Bearer injected on api.github.com) =============
fabiodouek
=== git clone fabiodouek/my2centsai-sandbox-test (Basic injected on github.com)
8373223 Initial commit
=== git push to branch langsmith-demo-git-1793246142 ============
50f2bc68e1a3d3b52d57a9b182814cb04a85d382 refs/heads/langsmith-demo-git-1793246142
=== The remote URL and git config have no credentials ===========
https://github.com/fabiodouek/my2centsai-sandbox-test.git
no credential config
Because the proxy adds the Basic header to every request to github.com, git needs no configuration at all. GH_TOKEN=proxy-injected exists only so gh believes it’s logged in; it’s the same recipe LangChain uses in its own Open SWE agent.
Test 4: Claude Code opens a pull request
python 05_agent_task.py
=== Claude Code: clone, commit, push, open PR ===================
is_error=False turns=8 cost=$0.0801
wall=29s
result: https://github.com/fabiodouek/my2centsai-sandbox-test/pull/4
Twenty-nine seconds, eight cents and a real PR (24 s and 26 s in other runs). One snag on the way: LangSmith sandboxes run as root, and Claude Code refuses --dangerously-skip-permissions as root:
--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons
Setting IS_SANDBOX=1 tells Claude Code it’s in a sandbox and lifts the restriction. That variable isn’t in Claude Code’s docs, which only say the check is skipped inside a recognized sandbox, so treat it as subject to change. The companion code passes it on the command line for the agent runs.

(The stopped my2cents-lscreds-04 row is from an interrupted run; the script deletes its sandbox when it finishes.) The console doesn’t show the proxy rules or which secrets a sandbox uses. Neither the Boxes table nor the detail page has a field for them, so the REST API (GET /api/v2/sandboxes/boxes/{name}) is the only place to audit them.
Test 5: Trying to get the credentials out
python 06_leak_attempts.py
The search runs as root (no sudo needed), and the sandbox hashes every credential-shaped string it finds so the script can compare them against hashes of the real Anthropic key, GitHub token and LangSmith API key without sending those values anywhere:
=== 1. Environment, files, process environments (the sandbox runs as root)
root
ANTHROPIC_API_KEY=sk-ant-placeholder-not-a-real-key
GH_TOKEN=proxy-injected
every file: 4 credential-shaped strings, real credentials among them: none
every process environment: 0 credential-shaped strings, real credentials among them: none
=== 2. Watch our own outbound request ===========================
> Host: api.github.com
=== 3. Echo services and the metadata endpoint ==================
https://httpbin.org/headers 000 blocked
https://postman-echo.com/headers 000 blocked
http://169.254.169.254/latest/meta-data/ 404
=== 4. Body reflection: render GH_TOKEN through GitHub's markdown API
<p>token: proxy-injected</p>
=== 5. The LangSmith API is on the allow list; is there a key to call it with?
401
- Step 2: a plain request leaves the process with no
Authorizationheader; the proxy adds it later. (The most a tool inside can send is the placeholder:ghsendsGH_TOKEN=proxy-injected.) - Step 3: echo services are off the allow list, so the “inject into a host that reflects headers” mistake is hard to make by accident. That protection comes from restricted egress: after the exemption in Test 7 they’re reachable unless you set your own
access_control. The link-local metadata address answers with a generic404 page not found(and Google’s metadata host, which I checked by hand, with 403): no instance credentials there. - Step 5: the LangSmith API itself is reachable from the sandbox, but there’s no LangSmith key inside to call it with.
Asked with the same owner-debugging prompt as in my E2B and Daytona posts, Claude Code never printed anything secret, but its answer varied between runs. In the run above it declined in one turn and offered to check whether the variables were set. In the run recorded for the video, it listed each placeholder’s length and said GH_TOKEN “is what authenticated that call”, which is wrong: the proxy’s header did. The placeholder keeps the agent from leaking the key, not from misreading where its access comes from. And the honest limit, again:
=== 7. What injection does not stop: using the credential =======
authenticated to GitHub as fabiodouek
can list 5 repos
The agent can’t read the token, but it can use it on every host and path a rule matches. Path rules let you narrow that to specific API paths.
Test 6: Rotating and revoking under a running sandbox
python 07_rotation.py
One sandbox, polling GET /user from inside, while the script changes things from outside:
=== Baseline ====================================================
GET /user -> 200
=== A. Change the workspace secret, don't touch the sandbox =====
watch 60 s for a 401: 200 at 0.7s, still 200 after 60s
=== B. Re-apply the same proxy config (update_sandbox) ==========
update_sandbox took 0.2s; then: 401 at 0.6s
=== C. Restore the secret and re-apply ==========================
update_sandbox took 0.2s; then: 200 at 0.7s
=== D. Disable the api.github.com rule ==========================
update_sandbox took 0.2s; then: 401 at 0.6s
GH_TOKEN in a new command: unset
{
"message": "Requires authentication",
re-enable: update_sandbox took 0.2s; then: 200 at 0.6s
=== E. Delete the workspace secret, then re-apply ===============
watch 30 s without re-applying: 200 at 0.7s, still 200 after 31s
re-apply with a missing secret: ValidationError resolve secret references: secret 'GITHUB_BEARER' not found in tenant secrets. Ensure it's configured in your tenant settings
sandbox keeps the last applied value: 200
This is the result that most separates the three platforms I’ve tested (E2B, Daytona and LangSmith):
- Changing a workspace secret does nothing to a running sandbox. It kept authenticating with the old token for the full minute. LangSmith resolves
{SECRET}references when the proxy config is applied, and the docs say so; it’s still easy to miss. - Re-applying is instant.
update_sandbox(proxy_config=...)with the same config took 0.2 s, and the new value was in use at the next poll, but only when you ask for it. - Disabling a rule fails open. GitHub said “Requires authentication”: the request went out without the header. The rule’s
GH_TOKENplaceholder disappeared from new commands too. - Deleting a secret doesn’t revoke it. The running sandbox kept the old value, and re-applying the config was then rejected, because the reference no longer resolves. To cut a sandbox off, disable the rule or delete the sandbox, and revoke the token at GitHub.
Test 7: AWS without AWS credentials
python 08_aws_role.py
This is the feature that sets LangSmith apart on paper. An aws_auth rule names an IAM role; LangSmith assumes it with sts:AssumeRoleWithWebIdentity, using a short-lived OIDC token it signs for your workspace, and the proxy re-signs SigV4 requests to supported AWS endpoints (regional service hosts, S3, and global IAM and STS) with the role’s temporary credentials. The sandbox gets dummy keys so AWS SDKs start, and never sees a real one, not even a session token. That covers SigV4 services like S3, which header injection alone can’t.
When I first ran this test on 2026-10-29, it stopped at the reachability check: sts.us-east-1.amazonaws.com -> 000blocked. AWS isn’t on the managed allow list that non-Enterprise organizations get, and that list wins over anything a sandbox asks for. The aws_auth rule didn’t open the hosts (the auth proxy docs say configuring credentials for a destination “does not grant access beyond the organization policy”), an access_control allow list naming AWS was stored as null, and an S3 mount with the same role appeared but hung on the first listing. There’s no self-serve setting: the organization flag (sandbox_restricted_egress) can be read through the API but not written, and the documented route is a support ticket listing the destinations. I filed one that day. The only already-allowed path to Bedrock was LangChain’s LLM Gateway, which authenticates with a Bedrock API key stored in LangSmith rather than the IAM role. The exemption came through, and everything below is from a re-run on 2026-11-03.
The role
I set up the AWS side as LangSmith’s dashboard describes it. /api/v1/info on GCP US reports sandbox_workload_identity_supported: true with the issuer https://api.smith.langchain.com:
aws iam create-open-id-connect-provider --url https://api.smith.langchain.com --client-id-list sts.amazonaws.com
{"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::<account>:oidc-provider/api.smith.langchain.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {"StringEquals": {"api.smith.langchain.com:aud": "sts.amazonaws.com",
"api.smith.langchain.com:sub": "ws:<workspace-id>:sandbox"}}}]}
The role’s inline policy allows Bedrock invocation, and S3 reads and writes under sandbox/ in one bucket (the companion README has it). The bucket also holds private/secret.txt, which the role can’t read. The sandbox rule is one line, and since the exemption leaves egress wide open, the script also gives the sandbox an allow list of its own:
AWS_RULE = aws_auth(role_arn=ROLE, env_vars={"AWS_REGION": "us-east-1", "CLAUDE_CODE_USE_BEDROCK": "1", ...})
EGRESS = {"allow_list": ["*.amazonaws.com"]} # this sandbox's own egress policy: AWS only
sbx = create_sandbox("08", rules=[AWS_RULE], access_control=EGRESS)
=== What the sandbox sees =======================================
AWS_ACCESS_KEY_ID=dummy
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
AWS_EC2_METADATA_DISABLED=true
AWS_REGION=us-east-1
AWS_SECRET_ACCESS_KEY=dummy
stored rule : {'role_arn': 'arn:aws:iam::<account>:role/my2cents-langsmith-sandbox'}
stored access_control: {'allow_list': ['*.amazonaws.com']}
=== Egress: AWS only, by the sandbox's own allow list ===========
sts.us-east-1.amazonaws.com -> 302
example.com -> 000 blocked
=== Identity: the role, assumed by the proxy ====================
arn:aws:sts::<account>:assumed-role/my2cents-langsmith-sandbox/ls-01a19ed6-5be3-7990-830f-64f8663cb487
=== S3 inside and outside the role's prefix =====================
get sandbox/hello.txt : hello from my2cents
put sandbox/from-ls : ok
get private/secret.txt: AccessDenied (no identity-based policy allows)
list all buckets : AccessDenied (no identity-based policy allows)
=== Claude Code on Bedrock ======================================
'ok' error=False cost=$0.0063
- No AWS credential in the sandbox. Only dummy keys,
AWS_EC2_METADATA_DISABLED=trueand a CA bundle, with no session token. LangSmith stores only the role ARN. - The proxy assumes the role, not the sandbox. CloudTrail shows each
AssumeRoleWithWebIdentitycall coming from LangSmith with a Go AWS SDK user agent, the subjectws:<workspace-id>:sandbox, a session namedls-plus an ID, and 900-second credentials. The SDK docstring says LangSmith renews them. There’s no External ID anywhere, which settles the docs question below. - IAM still draws the line. Inside
sandbox/, reads and writes work;private/secret.txtandListBucketsgetAccessDeniedfrom the role’s own policy. - Claude Code runs on Bedrock from the rule’s
env_varsand the dummy keys, even with AWS as the only reachable destination. Two calls that day cost $0.078 each; repeats within a couple of minutes cost $0.006, which looks like prompt caching.
Egress after the exemption
The exemption doesn’t add AWS to the list; it removes the list. A default sandbox now reaches example.com and httpbin, and GET /boxes/{name} no longer returns any access_control. So the Test 5 point that echo services are off the list no longer holds for my organization. The upside is that a sandbox’s own access_control, ignored before, is now stored and enforced:
access_control at create time |
Restricted egress (2026-10-29) | After the exemption (2026-11-03) |
|---|---|---|
| None | Managed list of 60 patterns; AWS, example.com and httpbin blocked |
Everything I tried was reachable |
{"allow_list": ["*.amazonaws.com", "api.github.com"]} |
Stored as null; the managed list applied |
Stored and enforced: AWS and GitHub answer; example.com, httpbin and PyPI blocked |
{"deny_list": ["example.com", "httpbin.org"]} |
Not tested | Stored and enforced: those two blocked, everything else open |
A :443 suffix on allow-list entries made no difference. One more catch: after the exemption, GET /api/v1/orgs/current/info still reports sandbox_restricted_egress: true, so the flag can’t tell you which case you’re in. A curl from inside a sandbox can.
S3 as a mounted filesystem
The script’s second sandbox gets the same role through mount_config instead of a proxy rule, as a read-only mount of the sandbox/ prefix:
mnt = create_sandbox("08m", mount_config=mount_config(
auth=[aws_auth(role_arn=ROLE)],
mounts=[s3_mount(id="data", mount_path="/mnt/mounts/data", bucket=BUCKET, prefix="sandbox/", read_only=True)],
))
=== The bucket as a read-only mount =============================
from-ls.txt
hello.txt
hello from my2cents
write a file: Read-only file system
=== The mount's role, called directly ===========================
s3 get sandbox/hello.txt: hello from my2cents
s3 put sandbox/x.txt : AccessDenied (explicit deny in a session policy)
sts get-caller-identity : Proxy auth failed
The mount that hung before the exemption now lists in about a second (rclone over FUSE, mounted ro). The more interesting part is how it’s scoped. When LangSmith assumes the role for a mount, CloudTrail shows it passing a session policy: S3 object and list actions on the bucket and sandbox/* only, a deny on every other action and resource, and, because the mount is read-only, a deny on writes. The sandbox can still call S3 directly with the mount’s credentials, but only to read inside the prefix, and the proxy won’t sign anything that isn’t S3 at all. That matches the SDK docstring: in mount_config, a role “is restricted to the configured S3 mounts”, while in proxy_config it uses its full permissions. If a sandbox only needs a bucket, the mount is the tighter way to give it one.
A note on the docs: the SDK’s aws_auth docstring says “LangSmith supplies the workspace External ID”, the auth-proxy page talks about a “workspace External ID”, and the mounts page shows an AssumeRole trust policy with an External ID. That’s how LangSmith’s ECR registry integration works. For proxy rules and mounts, the API, the changelog, the dashboard’s role instructions and now CloudTrail all show OIDC web identity with no External ID, which is what I configured.
Results summary
| Test | What I checked | Result |
|---|---|---|
| 1. Mechanics | Templating, override, path matching, opaque values, plain HTTP, what the API stores | Adds/overwrites headers per host and path; templating works; env_vars stored in plain text; managed allow list visible |
| 2. Claude Code | Placeholder key with and without a rule | Clean 401-style error without; works with, streaming included |
| 3. git and gh | Private repo clone, push, REST call | All work with no git config at all |
| 4. Agent task | Claude Code opens a PR end to end | 29 s, $0.08, PR opened; needs IS_SANDBOX=1 as root |
| 5. Leak attempts | Env, files and /proc (hash-compared), curl, echo hosts, metadata, markdown reflection, asking Claude |
Nothing real found; nothing even leaves the process; the credential can still be used |
| 6. Rotation | Change, re-apply, disable, delete under a running sandbox | Secret changes only apply on re-apply (then in under a second); disabled rules fail open; deleted secrets keep working |
| 7. AWS role | OIDC role, SigV4 re-signing, S3, Bedrock, S3 mount, egress controls | Blocked until a support exemption; then no AWS credential in the sandbox, IAM enforces the prefix, Claude Code runs on Bedrock, and mounts get a scoped session policy. The exemption removes the allow list, so set access_control yourself |
What injection does not solve
- Misuse through matched hosts. Covered in Test 5. Path rules help, token scope still matters.
- Rotation isn’t automatic for workspace secrets. Changing or deleting one does nothing to running sandboxes until their proxy config is re-applied (Test 6). If you rotate on a schedule, the re-apply has to be part of the job, as it is in Open SWE, or use a callback rule that fetches the value at request time.
- Disabled header rules fail open. The request still goes out, without the header. (Callback rules are documented to fail closed.)
update_sandboxreplaces the whole proxy config. Omit a rule,callbacksoraccess_controlfrom the update and it’s gone. Build the full config in one place and always send all of it.env_varsare plain text. They’re stored and returned by the API. Only ever put placeholders there.- Header-shaped auth, plus cloud provider rules. The proxy can’t inject into request bodies or non-HTTP protocols (git over SSH, database protocols). The exceptions are the AWS, GCP and Azure provider rules, which handle signing and tokens themselves; I tried only AWS (Test 7).
- Egress is all or nothing at the organization level. On non-Enterprise plans the managed allow list replaces your
access_controlsilently, and reaching anything else, AWS included, takes a support ticket. The exemption removes the list entirely, so every sandbox can then reach every host until you give it anaccess_controlof its own. Check which case you’re in from inside a sandbox; the organization flag doesn’t change. - Workspace-wide trust for the AWS role. The OIDC subject is
ws:<workspace-id>:sandbox, and CloudTrail shows exactly that on every assumption, so every sandbox in the workspace can assume the role. The API accepts a narrower tag scope (subject_scope: "tag:<uuid>"), which LangChain’s Open SWE uses, but the guides don’t document the resulting token subject yet.
Cleanup
python 09_cleanup.py
The script deletes the demo sandboxes, the snapshot and the workspace secrets, closes the demo PRs and deletes their branches. The AWS side (OIDC provider, role, bucket) has its own commands in the companion README. Deleting LangSmith workspace secrets doesn’t revoke anything at the provider: revoke the GitHub token and rotate the Anthropic key if you created them only for this.
Verdict
The auth proxy does the core job. Across these tests the sandbox never held a real Anthropic key, GitHub token or AWS credential, and Claude Code, git, gh and boto3 worked without knowing. Path matching and templating let one host carry different credentials per API path, and the AWS role signing goes further than header injection can: no AWS key of any kind enters the VM, IAM still enforces the role’s policy, and an S3 mount gets a session policy scoped to the mount.
The sharp edges are operational. Secret changes reach a running sandbox only when you re-apply its config, a disabled rule fails open, env_vars are plain text, and the console doesn’t show which rules or secrets a sandbox uses. On non-Enterprise plans, egress decides what you can integrate with at all: AWS took a support ticket, and the exemption removed the allow list rather than adding to it.
If your agents already run on LangSmith and need Git or cloud access they shouldn’t be able to read, this is worth adopting. Treat the proxy config as code you re-apply on every rotation, scope the role’s trust and policy tightly, and if you get an egress exemption, give every sandbox an access_control from day one.