feat(credential): oauth-capture routes with phantom-token broker#1267
feat(credential): oauth-capture routes with phantom-token broker#1267christine-at-datadog wants to merge 7 commits into
Conversation
PR Review SummarySize
Affected crates
Blast radius — ModerateThis PR touches: source code,configuration / policy files Updated automatically on each push to this PR. |
adbdfe8 to
6f19565
Compare
67571fc to
604827d
Compare
fb160a7 to
63badb1
Compare
63badb1 to
7d763b8
Compare
7d763b8 to
0264c73
Compare
0264c73 to
a33f177
Compare
Adds CaptureFormat and secret_paths support to InterceptActionConfig::Capture
so the proxy can intercept JSON request bodies and rewrite credential file paths
to OS temp paths before forwarding.
- command_policy: add CaptureFormat enum and secret_paths field to Capture variant
- macos: wire CaptureFormat and secret_paths into the proxy intercept path
- linux: update Capture match to struct-variant pattern (Capture { .. })
- token_broker: add rewrite_json_secrets / substitute_one_path helpers
Signed-off-by: Christine Le <christine.le@anthropic.com>
Adds a token broker that stores Claude OAuth tokens in the macOS Keychain with a per-entry SecAccess ACL so only nono can read them back. The broker issues ephemeral nonces to sandboxed agents; nonces cannot be resolved outside a live nono session. The proxy intercepts the Claude auth token endpoint and swaps real tokens for nonces on the way out, and swaps them back on the way in. Linux: all broker/keychain code is guarded with #![cfg(target_os = "macos")] (broker_store.rs) and per-item #[cfg] attributes (token_broker.rs) so the crate builds cleanly on Linux without dead-code warnings. Signed-off-by: Christine Le <christine.le@anthropic.com>
Adds examples/profiles/claude-code-oauth-capture.json as a reference starting point showing how to configure credential_routes for Claude Code OAuth capture. Marked "Example — not for production use without review" in the meta field. Includes deny_vars for all four Claude Code API key env vars so a leaked key can't bypass the proxy. Signed-off-by: Christine Le <christine.le@anthropic.com>
a33f177 to
d54c109
Compare
lukehinds
left a comment
There was a problem hiding this comment.
hi @christine-at-datadog , solid work, its looking good, just a couple of things I found that might need digging into, looking forward to shipping this though, as its really adding to zero trust model.
| .and_then(|r| r.oauth_capture_match.as_ref()) | ||
| .is_some_and(|oc| { | ||
| let p = req.path.split('?').next().unwrap_or(req.path.as_str()); | ||
| p == oc.token_url_match || p == oc.refresh_url_match | ||
| }) | ||
| && ctx | ||
| .nonce_resolver | ||
| .as_deref() | ||
| .and_then(|r| r.oauth_capture()) | ||
| .is_some(); | ||
|
|
There was a problem hiding this comment.
@christine-at-datadog I think this enables OAuth capture for both token_url_match and refresh_url_match, but the egress substitution path below only resolves broker nonces in headers.
OAuth refresh requests normally send refresh_token in the JSON request body, and that body is still forwarded unchanged, so refresh will send nono_<hex> upstream instead of the real refresh token
There was a problem hiding this comment.
@lukehinds Ah you're right, this is a gap. fa7174a should fix the refresh forwarding.
| // OAuth-capture response rewrite: buffer the token response and swap real | ||
| // access/refresh tokens for broker nonces. Pass-through-on-error keeps | ||
| // `/login` working if the body isn't the expected JSON. | ||
| let response_hook: Option<forward::ResponseBodyRewriter<'_>> = if oauth_capture_active { | ||
| ctx.nonce_resolver.as_ref().map(|resolver| { | ||
| let resolver = Arc::clone(resolver); | ||
| let host = ctx.host.to_string(); | ||
| let hook: forward::ResponseBodyRewriter<'_> = Box::new(move |body: &[u8]| { | ||
| let capture = resolver.oauth_capture()?; | ||
| match crate::oauth_rewrite::rewrite_oauth_json_body(body, capture) { | ||
| crate::oauth_rewrite::OauthRewriteOutcome::Rewritten { bytes, substituted } => { | ||
| debug!( | ||
| "oauth-capture (h1): substituted {substituted} token field(s) for {host}" | ||
| ); | ||
| Some(bytes.to_vec()) | ||
| } | ||
| crate::oauth_rewrite::OauthRewriteOutcome::NotJson | ||
| | crate::oauth_rewrite::OauthRewriteOutcome::NoTokenFields => None, | ||
| } |
There was a problem hiding this comment.
@christine-at-datadog This pass-through behavior might be unsafe for the credential-isolation contract. If the token response cannot be parsed/re-serialized, it returns None; the buffered forwarder then sends the original response unchanged, which may contain the real access/refresh tokens. I think OAuth capture should fail closed here or handle expected encodings before forwarding.
We always veer on hard failure in nono, I have foot gunned myself a few times with returning Nono.
There was a problem hiding this comment.
Good catch and no arguments on failing closed. The rewrite now forwards the original upstream body only when it's parsed as a JSON object and verified to carry no access/refresh token field.
86197b9 to
a6d7980
Compare
When a sandboxed agent sends a token refresh request, the JSON body contains `"refresh_token":"nono_<hex>"`. Previously only header values were rewritten; the body was forwarded verbatim, causing upstream to reject the nonce as an invalid token. Adds `resolve_nonces_in_oauth_request_body` to `oauth_rewrite.rs` which parses the request body as JSON, resolves any `nono_<hex>` nonce in the `refresh_token` and `access_token` fields via the nonce resolver, and returns the rewritten bytes. The function is called in `handle_inner_request` immediately after `oauth_capture_active` is set, before the Content-Length header is written. Fail-open on unresolved nonces: the unresolved nonce is forwarded as-is so the upstream returns an auth error rather than leaking any real credential. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Christine Le <christine.le@datadoghq.com>
… macOS
Five issues surfaced while exercising the OAuth /login capture + cross-session
refresh flow. Each is independent but all block the same feature.
- tool-sandbox (macOS): the shim's `getcwd()` is mediated by Seatbelt and fails
EPERM when the agent's workdir was granted metadata-only, breaking every
intercepted command (e.g. the keychain `security` capture). Fall back to an
absolute `$PWD` when `current_dir()` fails; the cwd only sets the brokered
child's start dir, whose real access is its command-policy sandbox.
- profile env policy: a profile with an `environment` block but empty
`allow_vars` resolved `allowed_env_vars` to `Some([])`, which the filter
treats as "allow nothing" and strips every non-injected var including HOME.
Collapse empty `allow_vars` to `None` ("pass all except denied"), mirroring
`deny_vars`.
- proxy forwarding: `forward_request` wrote the whole request body before
reading the response, so an upstream that rejects (401) and closes mid-write
surfaced as `Broken pipe` and the 401 was discarded — masking the signal
that triggers the client's token refresh. Add `write_then_stream`, which
still reads the upstream response after a write-side close and only surfaces
the write error when nothing parseable came back.
- OAuth broker cross-session resume: the logout GC read the *contents* of
Claude's credential entry (ACL-gated → keychain prompt every session) and
compared nonces, wrongly clearing a live record when Claude's account/nonce
differed from the persisted one. Replace it with an existence check
(attributes-only `SecItemCopyMatching`, no prompt) plus a retention TTL
stamped on the record (`saved_at_secs`, capped at `BROKER_RECORD_MAX_AGE_SECS`,
re-stamped on every refresh). Silent resume; logout and idle sessions still
bounded.
- proxy refresh-body nonce resolution: clippy cleanup on the recent fix
(let-else -> `?`, collapsed nested `if let`) so `-D warnings` CI stays green.
Verified: `oauth_rewrite::request_tests` (request-body nonce resolution),
broker GC/TTL unit tests, and a live `claude /login` -> forced refresh showing
`resolved nonce(s) in refresh request body` -> 200 with silent cross-session
resume.
Signed-off-by: Christine Le <christine.le@datadoghq.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OAuth-capture response rewrite forwarded the original upstream body
unchanged whenever it wasn't the expected JSON (the NotJson/NoTokenFields
-> None pass-through). For the token endpoint that body carries the real
access_token/refresh_token, so an unparseable or unexpected response
leaked real credentials to the sandboxed client, breaking the
credential-isolation contract (review r3492944282).
Replace the implicit pass-through-on-error with an explicit decision:
- oauth_rewrite: OauthRewriteOutcome now distinguishes PassThroughSafe
(parsed as a JSON object and verified to carry no string token field,
e.g. {"error":"invalid_grant"}) from FailClosed (not JSON, not an
object, or re-serialisation failed after substitution).
- forward: the rewriter returns ResponseRewrite { Replace | PassThrough
| FailClosed }. On FailClosed -- and on a missing header/body separator
-- the buffered path discards the upstream body and emits a 502 with a
credential-free {"error":"nono_capture_failed"} body.
- handle.rs (h1) and h2_forward.rs (h2): both capture paths map outcomes
to the new decision and fail closed when no capture resolver is wired.
The only body that can reach the client is now one verified
credential-free or one whose real tokens have been replaced by nonces.
Signed-off-by: Christine Le <christine.le@datadoghq.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a6d7980 to
7bf889b
Compare
Linked Issue
Closes #1265
Summary
When a sandboxed agent runs
claude /login(or other coding agent equivalent), the proxy TLS-intercepts the OAuth token endpoint, swaps the realaccess_token/refresh_tokenfornono_<hex>nonces before the response reaches the agent, and resolves nonces back to real tokens on egress. The agent's original keychain entry only ever holds nonces.Built on existing primitives:
Net new:
Additional Notes
Adding OAuth capture to an existing profile
I included an example profile in the PR, but it's intended to be removed after review.
Add
credential_routesalongside your existingnetwork.tls_interceptblock. Two cases:Same host for token endpoint and API:
Cross-host (token endpoint on a different host than the API — claude's case):
token_hostabsent → defaults toupstream. Cross-host desugars to two routes: a capture route ontoken_hostand an egress (nonce-resolve) route onupstream.listen_port: [0]is required so the agent's OAuth callback server can bind.deny_vars: ["ANTHROPIC_BASE_URL"]prevents an enterprise gateway from routing inference traffic away fromapi.anthropic.comwhere nonces resolve.Checklist
CHANGELOG.mdif neededAgent Compliance Check