Skip to content

bug(supervisor): request body credential scan denies placeholders bound to a different endpoint #3237

Description

@Tojaj

User Story

As a developer running a coding agent in an OpenShell sandbox with more than one
provider attached, I want tool output containing an OpenShell credential placeholder to
be forwarded to the model as inert text, so that a routine command like gh auth status
does not permanently break inference for the rest of the session.

Problem Statement

On a credentialed REST endpoint the sandbox proxy streams the request body and
fail-closes as soon as it sees OpenShell's reserved placeholder syntax
(openshell:resolve:env:… or OPENSHELL-RESOLVE-ENV-…). The scan is purely syntactic:
it does not consider which provider owns the placeholder or whether the placeholder is
usable at the destination.

Sandbox-visible environment variables hold placeholders, not secrets, so any command
that echoes a token prints the placeholder. An agent then replays that tool output in
its next model request, and the request is dropped:

[sandbox] [OCSF] NET:OPEN  [INFO] ALLOWED …/bin/codex -> api.openai.com:443 [policy:_provider_openai engine:opa]
[sandbox] [OCSF] HTTP:POST [INFO] ALLOWED POST http://api.openai.com:443/v1/responses [policy:_provider_openai engine:l7]
[sandbox] [OCSF] NET:TRAFFIC [HIGH] DENIED api.openai.com:443
         [reason:POST request body credential traffic denied for api.openai.com:443]
[sandbox] [OCSF] FINDING:CREATE [HIGH] "Credential-bearing traffic cannot be inspected"
         [type:openshell.credentials.traffic_uninspectable]

In the reproduction the offending bytes are a GITHUB_TOKEN placeholder from an
attached github provider. That credential is bound to github.com / api.github.com
and can never resolve for api.openai.com. Because
request_body_credential_rewrite is also disabled on that endpoint, the placeholder
would have been forwarded as opaque text — no secret was going to leave the sandbox
either way.

This is not specific to Codex. The built-in codex, claude-code, and copilot
profiles all declare their conversation hosts as protocol: rest / access: read-write
and leave both request_body_credential_rewrite and allow_uninspected_credentials at
their default of false, so all three deny on the same condition.

Impact / Why This Matters

Agents accumulate tool output in conversation history. Once a placeholder enters that
history, every subsequent model request carries it, so the sandbox does not fail one
request — it stops serving inference for the remainder of the session. The agent
receives a transport-level failure with no indication that its own earlier tool output
caused it, and typically retries into the same denial.

Attaching an agent provider alongside a credential provider (codex + github, so the
agent can drive gh and git) is the intended multi-provider workflow, so the failing
combination is a common one rather than an edge case.

The available workaround is to clone the built-in profile to a custom id, set
allow_uninspected_credentials: true on the conversation endpoints, import it, and
attach that instead of --type codex. It is insufficient for three reasons:

  1. Built-in profiles are immutable — the gateway rejects updates to a profile managed
    by source builtin — so every affected user must maintain and re-sync a private fork
    of a profile that upstream continues to change.
  2. The flag is far coarser than the problem. It disables the body guard for that
    endpoint entirely, including for placeholders that genuinely are bound to it, so
    working around a false positive costs real coverage.
  3. Nothing in the denial output points to the workaround. The emitted finding names the
    destination host, not the placeholder or the provider that owns it, so a user cannot
    tell from the logs that a second provider's credential is responsible.

Acceptance Criteria

  • With request_body_credential_rewrite disabled, a request body containing a
    placeholder that is not resolvable for the destination endpoint is forwarded
    instead of denied.
  • With request_body_credential_rewrite disabled, a request body containing a
    placeholder that is bound to the destination endpoint still fails closed
    (no change in behavior).
  • A Codex sandbox with the built-in codex and github providers attached
    completes POST /v1/responses after gh auth status output has entered the
    conversation history.
  • The same scenario succeeds for the built-in claude-code and copilot profiles.
  • When a request is still denied, the OCSF finding records that the placeholder was
    bound to the destination endpoint, so operators can distinguish a real
    uninspectable-credential denial from a foreign placeholder.
  • Documentation describes how multi-provider sandboxes interact with the request
    body credential scan.

Reproduction Steps

  1. Run a gateway with Providers v2 enabled and the Podman compute driver.
  2. Create both providers from the built-in profiles:
    openshell provider create --name codex --type codex
    openshell provider create --name github --type github
  3. Start a sandbox with both attached:
    openshell sandbox create --provider codex --provider github -- codex
  4. Inside the agent session, run a command that echoes a credential-backed environment
    variable, for example gh auth status. It prints the placeholder rather than the
    token:
    Token: openshell:resolve:env:v<N>_GITHUB_*****
    
  5. Send any follow-up prompt, so Codex includes that tool output in the next
    POST /v1/responses body.
  6. The request is denied. The sandbox log shows NET:TRAFFIC [HIGH] DENIED with
    reason:POST request body credential traffic denied, and every later request in the
    session fails the same way.

Environment

  • OpenShell: 0.0.116
  • OS: Fedora 44
  • Runtime: rootless Podman
  • Deployment: Providers v2 enabled; sandbox with built-in codex and github providers
  • Agent: Codex CLI

Related Issues

Related to #2904, which reports the same symptom from a different input: a literal,
unbound
placeholder string read out of documentation or source. Both denials come from
the same syntactic body scan. A binding-aware check would likely resolve both, so the
two should be evaluated together, but the cases are distinct — #2904 concerns a
placeholder that references no attached credential, while this issue concerns a real
placeholder attached to a different endpoint.

Agent Investigation

Skills loaded: create-github-issue

Traced against main at 118b250f.

  • crates/openshell-supervisor-network/src/l7/mod.rs:329
    deny_uninspected_body_credentials() returns
    !allow_uninspected_credentials && (provider_credentialed || has_resolver). The gate
    is therefore on by default for a credentialed REST endpoint, and also for any REST
    endpoint in a sandbox that has a secret resolver at all.
  • crates/openshell-supervisor-network/src/l7/rest.rs:1093 — when that gate is set the
    body is streamed through relay_request_body_with_marker_guard.
  • crates/openshell-supervisor-network/src/l7/rest.rs:1166
    ReservedMarkerStreamGuard::push errors on the first buffered window matching
    contains_reserved_credential_marker_bytes.
  • crates/openshell-core/src/secrets.rs:45-96 — that predicate is a raw and
    percent-decoded substring test against PLACEHOLDER_PREFIX and
    PROVIDER_ALIAS_MARKER. No resolver, provider, or endpoint is consulted.

The information needed to make the check binding-aware is already present at that call
site. scoped_context_for_request
(crates/openshell-supervisor-network/src/l7/relay.rs:88) replaces the request's
resolver with ProviderCredentials::resolver_for_endpoint_with_revision(host, port, target), and SecretResolver::scoped_to_env_keys
(crates/openshell-core/src/secrets.rs:385) records out-of-scope credentials in
denied_env_keys, already surfaced as UnresolvedPlaceholderReason::EndpointMismatch.
A foreign placeholder is thus distinguishable from a locally bound one at the moment the
body is scanned; the scanner just does not ask.

providers/codex.yaml declares api.openai.com, auth.openai.com, chatgpt.com, and
ab.chatgpt.com as protocol: rest, access: read-write, enforcement: enforce, with
both credential flags left at default. providers/claude-code.yaml and
providers/copilot.yaml do the same for their conversation hosts;
providers/copilot.yaml sets allow_uninspected_credentials: true only on two L4-only
telemetry hosts, which satisfies a different lint
(crates/openshell-providers/src/profiles.rs:2641) and does not cover this path.

Built-in profile immutability is enforced in the gateway — updates are rejected with
"managed by source 'builtin' and cannot be updated"
(crates/openshell-server/src/grpc/provider.rs:5416).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions