Skip to content

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

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

feat(credential): oauth-capture routes with phantom-token broker#1267
christine-at-datadog wants to merge 7 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 retrieves pre-existing secrets via keyring with the default system ACL. This broker captures OAuth token pairs at the token endpoint, stores them under a nono-only SecAccess ACL, and hands the agent nonces it cannot resolve outside a running nono session.
  • The pattern should be agent agnostic.

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_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 +3981
Lines removed -27
Total changed 4008
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 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
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from 67571fc to 604827d Compare June 26, 2026 20:37
@christine-at-datadog christine-at-datadog marked this pull request as ready for review June 26, 2026 20:38
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from fb160a7 to 63badb1 Compare June 26, 2026 20:44
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from 63badb1 to 7d763b8 Compare June 26, 2026 20:47
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from 7d763b8 to 0264c73 Compare June 26, 2026 20:53
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from 0264c73 to a33f177 Compare June 26, 2026 20:58
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>
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from a33f177 to d54c109 Compare June 26, 2026 22:35
@lukehinds lukehinds self-requested a review June 29, 2026 08:46

@lukehinds lukehinds left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1021 to +1031
.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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukehinds Ah you're right, this is a gap. fa7174a should fix the refresh forwarding.

Comment on lines +1090 to +1108
// 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,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from 86197b9 to a6d7980 Compare June 30, 2026 07:13
christine-at-datadog and others added 3 commits June 30, 2026 00:15
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>
@christine-at-datadog christine-at-datadog force-pushed the christine.le/capture-json-secret-paths branch from a6d7980 to 7bf889b Compare June 30, 2026 07:16
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

2 participants