Skip to content

Feat: placeholder-resolve AuthBridge plugin (OpenShell credential injection)#626

Closed
huang195 wants to merge 9 commits into
kagenti:mainfrom
huang195:feat/placeholder-resolve-plugin
Closed

Feat: placeholder-resolve AuthBridge plugin (OpenShell credential injection)#626
huang195 wants to merge 9 commits into
kagenti:mainfrom
huang195:feat/placeholder-resolve-plugin

Conversation

@huang195

Copy link
Copy Markdown
Member

Summary

Adds an outbound placeholder-resolve AuthBridge plugin that resolves OpenShell-style
credential placeholders (openshell:resolve:env:<KEY>) to their real secret values on the
outbound Authorization header — so a sandboxed agent egressing through AuthBridge never holds
the real credential.

This is the AuthBridge half of routing an OpenShell sandbox's egress through an AuthBridge
sidecar (OpenShell's NetworkMode::External): the agent receives a placeholder from OpenShell's
native provider mechanism, and AuthBridge swaps it for the live credential in flight, fail-closed.

What's included

B1 — the plugin (012603a)

  • placeholder-resolve outbound plugin with a pluggable Resolver interface; default sources are
    inline mappings (testing), a mounted secret_dir, or the process env.
  • Fails closed on an unresolved/unknown placeholder; rejects CR/LF/NUL in resolved values (CWE-113).
  • Purely additive — implements the existing pipeline.Plugin/Configurable contract with no
    shared-interface change; registered via a build-tagged
    cmd/authbridge-proxy/plugins_placeholderresolve.go (droppable with
    -tags exclude_plugin_placeholderresolve).

B2 — the gateway resolver (ca8cbd8)

  • authlib/openshell: generated gRPC stubs (buf) + a Client that replicates the supervisor's
    auth chain — projected SA token → IssueSandboxToken → gateway JWT →
    GetSandboxProviderEnvironment(sandbox_id) → resolved env; re-mints the JWT on Unauthenticated.
  • placeholderresolve/gateway: a caching Resolver that primes + refreshes the env map in the
    background, serves Resolve lock-free, honors per-key credential expiry, and fails closed until
    primed.
  • A gateway config block selects this source (precedence over mappings/secret_dir/env); the
    plugin gains Init/Ready/Shutdown to drive warm-up (mirrors token-exchange's lifecycle).

Testing

  • Unit tests for the plugin and the gateway client/resolver against an in-process mock gateway —
    metadata flow + JWT re-mint and a placeholder→real-value end-to-end, under -race.
  • Forward-proxy isolation run: resolve → real value on the wire; unknown key → 401 fail-closed;
    non-placeholder → passthrough.
  • Validated live end-to-end with the OpenShell feat/authbridge-egress branch: claude-code in a
    sandbox reached its LLM through the AuthBridge sidecar with the credential injected, while the
    sandbox env held only the placeholder. The OpenShell-side enabler (NetworkMode::External) lives
    on a separate, unmerged branch and is not required to build or test this PR.

Notes

  • Opt-in / inert by default: the plugin does nothing unless added to a pipeline's
    outbound.plugins.
  • The bulk of the diff is generated gRPC stubs under authlib/openshell/genproto/.
  • Live in-cluster gateway-resolver validation is deferred to the sidecar-deployment phase (the
    gateway restricts these RPCs to the sandbox's own identity).

Assisted-By: Claude Code

Resolve OpenShell-style credential placeholders
(openshell:resolve:env:<KEY>) in request headers to their real
secret values on the wire, so the agent never holds the real
credential. The placeholder rides in Authorization (Bearer) for the
OpenShell anthropic provider, which is the header the forward proxy
propagates to the upstream.

Resolution is via a pluggable Resolver interface (the swap seam for a
future gateway-gRPC source backed by OpenShell's
GetSandboxProviderEnvironment). Default resolvers: inline mappings
(testing), a mounted secret dir, or the process env. Fails closed on
an unresolved/unknown placeholder; rejects CR/LF/NUL in resolved
values (CWE-113).

Purely additive: implements the existing pipeline.Plugin/Configurable
contract with no shared-interface change, registered via a
build-tagged cmd/authbridge-proxy/plugins_placeholderresolve.go
(matching the per-plugin registration scheme so the plugin is
droppable via -tags exclude_plugin_placeholderresolve).

Verified by unit tests and a forward-proxy isolation run
(resolve -> real value on the wire, unknown key -> 401 fail-closed,
non-placeholder -> passthrough).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e4798878-93bf-4970-9390-11f204f7724c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Add a gateway-backed Resolver source to the placeholder-resolve plugin
(B2). It fetches the sandbox's resolved provider environment from the
OpenShell gateway and serves placeholder lookups from an in-memory
cache, so AuthBridge — running as a sidecar in the sandbox pod —
injects the live credential using OpenShell's native provider
mechanism (no mounted secrets).

- authlib/openshell: generated gRPC stubs (buf + vendored protos) and a
  Client that replicates the supervisor's auth chain: read the projected
  SA token, IssueSandboxToken (Bearer SA token) -> gateway JWT,
  GetSandboxProviderEnvironment(sandbox_id) (Bearer JWT) -> resolved env.
  Re-mints the JWT on Unauthenticated.
- authlib/plugins/placeholderresolve/gateway: a caching Resolver that
  primes and refreshes the env map in the background and serves Resolve
  from cache (lock-free); honors per-key credential expiry; fails closed
  until primed.
- placeholderresolve: a `gateway` config block selects the gateway source
  (precedence over mappings/secret_dir/env); the plugin gains
  Init/Ready/Shutdown to drive the resolver's warm-up (mirrors
  token-exchange's lifecycle). Structural interfaces avoid an import cycle.

Verified offline against an in-process mock gateway: client metadata flow
+ re-mint, and a plugin end-to-end (placeholder -> real value), all under
-race. Live in-cluster auth is deferred to the sidecar-deployment phase
(the gateway restricts these RPCs to the sandbox's own identity).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195 huang195 force-pushed the feat/placeholder-resolve-plugin branch from ca8cbd8 to d49da3a Compare June 26, 2026 02:23
@huang195 huang195 changed the title feat(authbridge): placeholder-resolve plugin (OpenShell credential injection) Feat(authbridge): placeholder-resolve plugin (OpenShell credential injection) Jun 26, 2026
@huang195 huang195 changed the title Feat(authbridge): placeholder-resolve plugin (OpenShell credential injection) Feat: placeholder-resolve AuthBridge plugin (OpenShell credential injection) Jun 26, 2026
huang195 added 2 commits June 25, 2026 23:11
Address PR kagenti#626 review feedback on the OpenShell gateway resolver.

- Fail closed on plaintext gRPC: http:// (or a bare host with no
  mtls_cert_dir) to a non-loopback gateway is refused, since the SA token
  and minted JWT would otherwise travel as cleartext metadata. An explicit
  `insecure: true` opt-in (logged with a warning) overrides; loopback is
  always allowed. (openshell.Config.Insecure, plumbed through the plugin's
  gateway config block.)
- Bound every gateway RPC with a 10s timeout so a connected-but-
  unresponsive gateway cannot block the resolver's background refresh
  goroutine indefinitely (stale cache -> fail-closed denials).
- Keep authbridge-lite minimal: exclude placeholder-resolve from the lite
  build (it pulls in gRPC + the openshell client) via the lite build-args
  in build.yaml / ci.yaml / CLAUDE.md.
- Document the plugin's config in docs/plugin-reference.md.
- Tests: plaintext fail-closed / insecure / loopback dial paths, mtlsConfig
  + hostOnly + isLoopbackHost, and the resolver's expiry-derived nextDelay.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…er-resolve

Address review feedback on the placeholder-resolve plugin (PR kagenti#626).

Secured:
- Remove the `env` resolver (the source AND the silent default). It let the
  agent name any KEY and exfiltrate the proxy's own environment onto the
  upstream Authorization header. An unconfigured source now fails closed.
- credinject.FileResolver path-contains the key (rejects separators / "..")
  instead of trusting the env-key grammar, so it is safe for arbitrary keys.

Simplified:
- Require an explicit `source` enum (gateway | secret_dir), replacing the
  implicit by-field-presence selection and its undocumented precedence.
- Drop the `headers` field; hardcode Authorization (the only header any
  listener reconciles to the upstream wire — others were silently dropped).
- Drop the inline `mappings` source from the production config (tests inject a
  resolver directly).

Reusable:
- New authlib/credinject package: Resolver / LifecycleResolver interfaces,
  FileResolver / MapResolver / DenyResolver, and SafeHeaderValue / SafeSetHeader
  (the CWE-113 header guard, previously unique to this plugin). placeholder-
  resolve consumes it; the OpenShell gateway resolver satisfies the interfaces
  structurally. A future host-keyed (e.g. DAM) credential injector can import
  credinject rather than reimplement these.

Optimized:
- Replace the per-request regexp with a prefix scan + a strings.Contains
  early-out; removes the only regexp dependency in the plugin.

Tests and plugin-reference.md updated. Build, lite build (placeholder-resolve
excluded), go test -race, go vet, and golangci-lint all green.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@rhuss

rhuss commented Jun 26, 2026

Copy link
Copy Markdown

This is the AuthBridge half of routing an OpenShell sandbox's egress through an AuthBridge
sidecar (OpenShell's NetworkMode::External): the agent receives a placeholder from OpenShell's
native provider mechanism, and AuthBridge swaps it for the live credential in flight, fail-closed.

Out of curiosity, what is NetworkMode::External ? I can't see it in https://github.com/NVIDIA/OpenShell/blob/b855d8d8721871da0cfe1fed329f96d5f258c27a/crates/openshell-core/src/policy.rs#L61

huang195 added 2 commits June 26, 2026 16:21
OpenShell injects provider-env placeholders as openshell:resolve:env:v<rev>_<NAME>
(secrets.rs: format!("{PREFIX}v{revision}_{key}")), but GetSandboxProviderEnvironment
returns the environment keyed by the bare <NAME>. The gateway resolver did a
byte-for-byte map lookup with no revision handling, so every revision-keyed
placeholder missed and the plugin failed closed (401) — making source: gateway
unusable in practice (only source: secret_dir worked, by naming the mounted file
with the revision prefix).

Resolve now tries the literal key first (covers revision==0 / bare placeholders and
any real var literally shaped like a prefix), then strips a single leading
"v<digits>_" and retries the bare name. Expiry is checked against the resolved
(bare) key. Fail-closed semantics are preserved.

Add TestResolveStripsRevisionPrefix: revision-keyed → bare value, bare still works,
absent/non-numeric/empty-revision fail closed, literal precedence, expiry on the
stripped key, cold cache.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…ey/mtls_ca paths

OpenShell splits the gateway TLS material — client cert/key in one secret
(OPENSHELL_TLS_CERT/KEY) and the CA in another (OPENSHELL_TLS_CA) — but the gateway
client loaded ca.crt/tls.crt/tls.key from a single mtls_cert_dir, forcing consumers
to recombine the split material into one dir. Replace mtls_cert_dir with three
independent file paths (mtls_cert/mtls_key/mtls_ca) mirroring OpenShell's three env
vars, so a sidecar/webhook can point straight at the driver-injected /tls/client +
/tls/ca mounts (PR unmerged — no back-compat shim).

- openshell.Config: MTLSCert/MTLSKey/MTLSCA; mtlsConfig(cert,key,ca,serverName)
- gateway.Config + plugin gatewayConfig: mtls_cert/mtls_key/mtls_ca (dropped the
  mtls_cert_dir default)
- tests + plugin-reference.md updated

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195

Copy link
Copy Markdown
Member Author

It's a new mode not yet implemented in OpenShell and lives only in our fork: https://github.com/kagenti/OpenShell/tree/feat/authbridge-egress. It's not yet working end to end, but I'm close.

huang195 added 3 commits June 26, 2026 18:40
The s (hideInactive) toggle evaluated each event against its own phase's
invocations — request-phase for a request, response-phase for a response. A
passthrough response carries no invocations of its own, so a skip-only request
could be hidden while its invocation-less response stayed, orphaning the response
(and symmetrically an active request could keep a hidden response). Gate
suppression on the whole exchange: a request/response pair is hidden only when
BOTH halves are inactive, using the partner pairing already computed in
rebuildEventsTable. Unpaired rows fall back to per-event.

Add TestRebuildEventsTable_HideInactivePerExchange.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…dentity)

The auto-follow re-set the cursor on every streamed event with index-based
heuristics: it INFERRED follow-mode from 'cursor on the last row' (wasAtEnd) and
restored the cursor by ROW INDEX. A rebuild that shifts indices — a CONNECT
folding into its inner request, hide-inactive filtering, or a buffer trim —
landing between key presses could move the cursor opposite to the arrow key
(the 'down sometimes scrolls up' report).

Track follow mode explicitly (set on session entry / End / a Down reaching the
last row; cleared on Up / Home / scrolling away) and, when not following, restore
the cursor by EVENT IDENTITY (timestamp+direction+phase) instead of row index, so
folding/filtering/trimming can't scroll the view out from under the user.

Tests: FollowPinsToNewest, PreservesCursorByIdentity.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…art, enforce doc note

- abctl: goBottom (G/End) now sets m.follow=true, so jumping to the tail after a scroll-up
  re-pins to new events (app.go documented End/G as follow-entering but goBottom didn't set it).
- gateway resolver: Start uses CompareAndSwap instead of load-then-store, so the double-start
  guard is correct by construction (not just via single-threaded Init); the loser cancels its
  unused context.
- plugin-reference.md: note that 'the placeholder is never forwarded' holds under the default
  enforce policy; on_error: observe shadows the deny and forwards the literal placeholder (no
  real credential leaks, but fail-closed no longer holds).

Also reviewed, no change: gateway server-cert trust = system roots + configured CA (matches
the tlsbridge/upstream.go convention); and the build.yaml 'full plugin set, includes parsers'
comment is correct (the default authbridge image builds with empty GO_BUILD_TAGS — parsers +
placeholder-resolve included; the excludes are on the separate authbridge-lite image).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants