feat(credential): oauth-capture routes with phantom-token broker#1267
Draft
christine-at-datadog wants to merge 6 commits into
Draft
feat(credential): oauth-capture routes with phantom-token broker#1267christine-at-datadog wants to merge 6 commits into
christine-at-datadog wants to merge 6 commits into
Conversation
Contributor
PR Review SummarySize
Affected crates
Blast radius — ModerateThis PR touches: source code,configuration / policy files Updated automatically on each push to this PR. |
…ecret_paths)
Extend the `Capture` intercept action so a credential-helper command that
returns a JSON envelope can have specific nested string fields swapped for
broker nonces, leaving every other field intact — rather than today's
all-or-nothing stdout scan via `scan_and_reissue`.
`InterceptActionConfig::Capture` becomes a struct variant carrying
`format: Option<CaptureFormat>` and `secret_paths: Vec<String>`. Because the
enum is internally tagged (`#[serde(tag = "type")]`) and both fields default,
existing `{"type":"capture"}` configs continue to deserialize as opaque
capture — verified by a round-trip test.
With `format: "json"`, `TokenBroker::rewrite_json_secrets` parses stdout, walks
each dotted `secret_paths` entry, mints a fresh nonce per leaf string via the
existing broker, and re-serializes. Fail-closed throughout: malformed JSON or a
missing path falls back to `scan_and_reissue`. The macOS tool-sandbox platform
dispatcher is updated; `linux.rs` is deferred (the shared types compile on Linux
but the dispatch has not been smoke-tested on a Linux host).
Part of nolabs-ai#1265.
Signed-off-by: Christine Le <christine.le@datadoghq.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mediate an upstream's OAuth /login so a sandboxed agent never holds the
real bearer credential, even with subprocess and keychain-read access. A
profile declares a `credential_routes` entry with an `oauth_intercept`
capture; the proxy rewrites the token-endpoint response so the agent only
ever sees `nono_<hex>` nonces, and translates them back on egress. Real
tokens persist only in a nono-owned, ACL-protected macOS keychain entry.
Proxy (nono-proxy):
- `token.rs`: `OauthCaptureResolver` trait + `NonceResolver::oauth_capture()`
accessor (default None). Resolve-only resolvers are untouched.
- `oauth_rewrite.rs`: `rewrite_oauth_json_body` swaps `access_token` /
`refresh_token` for nonces. Fail-closed: non-JSON / missing fields /
re-serialise failure all forward the original body unchanged.
- `config.rs` / `route.rs`: `OauthCaptureMatch` on `RouteConfig`; route
detection in `requires_intercept`.
- `h2_forward.rs`: H2 capture path — buffers DATA frames, rewrites, re-emits.
This is the correct path for direct h2-capable clients; Claude Code falls
back to H1 under a CONNECT proxy (see next commit).
CLI (nono-cli):
- `tool-sandbox/broker_store.rs`: macOS keychain broker — `SecItemAdd` with a
nono-only `SecAccess` ACL so the broker entry is unreadable by the sandboxed
agent. Cross-session hydration reads `Claude Code-credentials`/`"unknown"`
(claude's keychain account on current builds) and `~/.claude/.credentials.json`
as a fallback. `MemoryBrokerStore` for tests.
- `tool-sandbox/token_broker.rs`: `with_store_and_reader` constructor hydrates
the broker on startup, GC-ing nonces whose access token no longer matches
claude's own credential entry (detects /logout inside the sandbox).
`capture_oauth_pair` mints and persists a nonce pair.
- `profile/mod.rs`: `credential_routes` profile key with `ManagedCredentialRoute`
/ `CredentialRouteCapture::OauthIntercept { upstream, token_host?, token_url_match }`.
`merge_profiles` handles credential_routes (child overrides by name).
- `proxy_runtime.rs`: `synthesize_credential_routes` desugars same-host (1 route)
vs cross-host (capture route on `token_host` + allow-all egress route on
`upstream`). `build_shared_broker` wires a single broker Arc shared by the
proxy and mediation server.
Part of nolabs-ai#1265.
Signed-off-by: Christine Le <christine.le@datadoghq.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude Code falls back to HTTP/1.1 when connecting through nono's transparent CONNECT proxy — Node's HTTP client won't negotiate h2 over a CONNECT tunnel. Live testing confirmed every intercepted request took the H1 path (`handle.rs`, logged as `tls_intercept: inner request`); zero requests reached `h2_forward`. So H1 capture is the path that actually fires for claude-under-nono. `forward.rs`: - `ResponseBodyRewriter<'a>`: `Box<dyn FnOnce(&[u8]) -> Option<Vec<u8>>>` hook threaded through `forward_request`. - `stream_response_buffered`: reads full upstream response, decodes chunked transfer encoding (Anthropic's token endpoint returns chunked JSON), hands body to the rewriter, rebuilds framing with a fresh Content-Length. - `stream_response_passthrough`: the historical chunk-by-chunk mirror, preserved and used when no rewriter is set. `handle.rs`: - `oauth_capture_active`: true when the route has `oauth_capture_match`, the request path matches `token_url_match`, and `nonce_resolver.oauth_capture()` is `Some`. - Strips `accept-encoding` from forwarded request headers so Anthropic returns plaintext JSON (not gzip/br) that the rewriter can parse. - Builds a `ResponseBodyRewriter` closure that calls `rewrite_oauth_json_body`, logs the substitution count on success, and passes the hook to `forward_request`. Both H1 and H2 paths share `oauth_rewrite::rewrite_oauth_json_body` and the `OauthCaptureResolver` trait, so they cannot diverge in rewrite semantics. Part of nolabs-ai#1265. Signed-off-by: Christine Le <christine.le@datadoghq.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
adbdfe8 to
6f19565
Compare
delete_broker_entry_in_process was using find_generic_password (which reads the password data to return an item ref) followed by item.delete() (which calls SecKeychainItemDelete and returns () — errors silently swallowed). Two problems: 1. Reading the password data triggers the ACL data-access check; any failure there meant the item reference was never obtained and the delete was never attempted. 2. SecKeychainItem::delete() in security-framework discards the SecKeychainItemDelete return code, so delete failures were invisible. Fix: use security_framework::passwords::delete_generic_password, which calls SecItemDelete with a query dict that does not request the password data. No ACL data-access check, and errors now propagate with a warning log (errSecItemNotFound is expected and silently ignored). Fixes orphan-GC not clearing the broker keychain entry after claude /logout deletes the Claude Code-credentials entry. Part of nolabs-ai#1265. Signed-off-by: Christine Le <christine.le@datadoghq.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
delete_broker_entry_in_process previously called delete_generic_password which dispatches SecItemDelete (new data-protection keychain API). Items are stored via SecItemAdd + kSecAttrAccess (file-based login.keychain), so SecItemDelete returns errSecItemNotFound and the entry is never cleared. Fix: use find_generic_password (old file-based keychain API, same as load_in_process) to obtain the SecKeychainItemRef, then call SecKeychainItemDelete directly from security_framework_sys with explicit OSStatus checking. This matches the API family used for save and load. Signed-off-by: Christine Le <christine.le@datadoghq.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds examples/profiles/claude-code-oauth-capture.json as a reference profile for OAuth /login capture with claude-code. Denies all four API-key env vars (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR) so the agent is forced through OAuth and cannot fall back to key-based auth. Signed-off-by: Christine Le <christine.le@datadoghq.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SecAccessACL + holds nonce pairs.current_claude_access_token) which knows claude's keychain service name, account"unknown", andclaudeAiOauthJSON envelope shape. For a different OAuth agent, write an analogous reader and pass it tobuild_shared_broker; everything else is reused unchanged.Adding OAuth capture to an existing profile
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