Skip to content

feat(credential): oauth-capture routes with phantom-token broker#1267

Draft
christine-at-datadog wants to merge 6 commits into
nolabs-ai:mainfrom
christine-at-datadog:christine.le/capture-json-secret-paths
Draft

feat(credential): oauth-capture routes with phantom-token broker#1267
christine-at-datadog wants to merge 6 commits into
nolabs-ai:mainfrom
christine-at-datadog:christine.le/capture-json-secret-paths

Conversation

@christine-at-datadog

@christine-at-datadog christine-at-datadog commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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 real access_token/refresh_token for nono_<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:

  • Added a capture path in the nonce mint/resolve engine
  • Hooked up HTTP/1.1 and HTTP/2 forwarding in the proxy's TLS intercept loop
  • Added dispatch mode to tool-sandbox Capture action
  • Set up a nono-only ACL entry to macOS keychain FFI

Net new:

  • Fake tokens in the agent's keychain — when the agent logs in, it gets placeholder values instead of real credentials. The proxy swaps them back to real tokens on the way out. The real tokens live in a separate, nono-owned keychain entry the agent can't read.
  • The broker remembers credentials across sessions — it saves the real tokens to its keychain entry so they survive restarts, detects when the agent has logged out and cleans up stale placeholders, and protects its entry so only nono can read it.
  • Token response interception — when the OAuth server sends back the login response, nono catches it before the agent sees it, swaps the real tokens for placeholders, then lets the (now-modified) response through.
  • credential_routes as a single config entry — instead of manually configuring proxy routes, you declare one entry with the API host and token endpoint, and nono figures out the rest.
  • Same host vs. different host — if the token endpoint and API are on the same host, one proxy route covers both. If they're on different hosts (claude's case: platform.claude.com for login, api.anthropic.com for inference), nono generates two routes automatically.

Additional Notes

  • feat(credential-broker): add macos keychain credential broker #1034 is a stateless subprocess keyring mediator; this broker is an OAuth phantom-token store with per-entry SecAccess ACL + holds nonce pairs.
  • The pattern should be agent agnostic. The one claude-specific layer from this PR is the orphan-GC hydration reader (current_claude_access_token) which knows claude's keychain service name, account "unknown", and claudeAiOauth JSON envelope shape. For a different OAuth agent, write an analogous reader and pass it to build_shared_broker; everything else is reused unchanged.

Adding OAuth capture to an existing profile

Add credential_routes alongside your existing network.tls_intercept block. Two cases:

Same host for token endpoint and API:

"network": {
  "listen_port": [0],
  "tls_intercept": { "ca_lifecycle": "session" }
},
"environment": { "deny_vars": ["ANTHROPIC_BASE_URL"] },
"credential_routes": [
  {
    "name": "anthropic_oauth",
    "upstream": "https://api.anthropic.com",
    "capture": {
      "type": "oauth_intercept",
      "token_url_match": "/v1/oauth/token"
    }
  }
]

Cross-host (token endpoint on a different host than the API — claude's case):

"credential_routes": [
  {
    "name": "anthropic_oauth",
    "upstream": "https://api.anthropic.com",
    "capture": {
      "type": "oauth_intercept",
      "token_host": "https://platform.claude.com",
      "token_url_match": "/v1/oauth/token"
    }
  }
]

token_host absent → defaults to upstream. Cross-host desugars to two routes: a capture route on token_host and an egress (nonce-resolve) route on upstream.

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 from api.anthropic.com where nonces resolve.

Checklist

  • An issue exists and is linked above
  • All commits are signed-off, using DCO
  • All new code follows the project's coding standards (CLAUDE.md) and is covered by tests
  • Public-facing changes are paired with documentation updates
  • Release note has been added to CHANGELOG.md if needed

Agent Compliance Check

  • I am not prohibited from contributing under this policy
  • An issue already exists (feat(credential): credential mediation for OAuth-capture routes #1265)
  • I disclosed that I am an agent in the issue discussion
  • I described my intent and approach in the issue discussion
  • I reviewed repository coding and security rules for the affected area
  • I provided required attribution for reused or adapted code
  • I did not use forbidden patterns such as unwrap/expect
  • I used NonoError where required
  • I validated and canonicalized all relevant paths
  • This PR matches the approved or disclosed issue scope

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +3253
Lines removed -12
Total changed 3265
Classification Large (> 300 lines)

Affected crates

  • crates/nono-proxydownstream consumers depend on this crate. API or behaviour changes will affect external callers; treat any breaking change with extra scrutiny.
  • crates/nono-cli — CLI changes. Verify argument parsing, flag documentation, and UX behaviour across supported platforms.

Blast radius — Moderate

This PR touches: source code,configuration / policy files


Updated automatically on each push to this PR.

christine-at-datadog and others added 3 commits June 26, 2026 00:07
…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>
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from adbdfe8 to 6f19565 Compare June 26, 2026 07:08
@christine-at-datadog christine-at-datadog changed the title feat(credential): OAuth-capture routes with phantom-token broker feat(credential): oauth-capture routes with phantom-token broker Jun 26, 2026
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>
christine-at-datadog and others added 2 commits June 26, 2026 12:59
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(credential): credential mediation for OAuth-capture routes

1 participant