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:
- 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.
- 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.
- 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
Reproduction Steps
- Run a gateway with Providers v2 enabled and the Podman compute driver.
- Create both providers from the built-in profiles:
openshell provider create --name codex --type codex
openshell provider create --name github --type github
- Start a sandbox with both attached:
openshell sandbox create --provider codex --provider github -- codex
- 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_*****
- Send any follow-up prompt, so Codex includes that tool output in the next
POST /v1/responses body.
- 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).
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 statusdoes 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:…orOPENSHELL-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:
In the reproduction the offending bytes are a
GITHUB_TOKENplaceholder from anattached
githubprovider. That credential is bound togithub.com/api.github.comand can never resolve for
api.openai.com. Becauserequest_body_credential_rewriteis also disabled on that endpoint, the placeholderwould 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, andcopilotprofiles all declare their conversation hosts as
protocol: rest/access: read-writeand leave both
request_body_credential_rewriteandallow_uninspected_credentialsattheir 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 theagent can drive
ghandgit) is the intended multi-provider workflow, so the failingcombination 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: trueon the conversation endpoints, import it, andattach that instead of
--type codex. It is insufficient for three reasons:by source
builtin— so every affected user must maintain and re-sync a private forkof a profile that upstream continues to change.
endpoint entirely, including for placeholders that genuinely are bound to it, so
working around a false positive costs real coverage.
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
request_body_credential_rewritedisabled, a request body containing aplaceholder that is not resolvable for the destination endpoint is forwarded
instead of denied.
request_body_credential_rewritedisabled, a request body containing aplaceholder that is bound to the destination endpoint still fails closed
(no change in behavior).
codexandgithubproviders attachedcompletes
POST /v1/responsesaftergh auth statusoutput has entered theconversation history.
claude-codeandcopilotprofiles.bound to the destination endpoint, so operators can distinguish a real
uninspectable-credential denial from a foreign placeholder.
body credential scan.
Reproduction Steps
variable, for example
gh auth status. It prints the placeholder rather than thetoken:
POST /v1/responsesbody.NET:TRAFFIC [HIGH] DENIEDwithreason:POST request body credential traffic denied, and every later request in thesession fails the same way.
Environment
codexandgithubprovidersRelated 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-issueTraced against
mainat118b250f.crates/openshell-supervisor-network/src/l7/mod.rs:329—deny_uninspected_body_credentials()returns!allow_uninspected_credentials && (provider_credentialed || has_resolver). The gateis 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 thebody is streamed through
relay_request_body_with_marker_guard.crates/openshell-supervisor-network/src/l7/rest.rs:1166—ReservedMarkerStreamGuard::pusherrors on the first buffered window matchingcontains_reserved_credential_marker_bytes.crates/openshell-core/src/secrets.rs:45-96— that predicate is a raw andpercent-decoded substring test against
PLACEHOLDER_PREFIXandPROVIDER_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'sresolver with
ProviderCredentials::resolver_for_endpoint_with_revision(host, port, target), andSecretResolver::scoped_to_env_keys(
crates/openshell-core/src/secrets.rs:385) records out-of-scope credentials indenied_env_keys, already surfaced asUnresolvedPlaceholderReason::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.yamldeclaresapi.openai.com,auth.openai.com,chatgpt.com, andab.chatgpt.comasprotocol: rest,access: read-write,enforcement: enforce, withboth credential flags left at default.
providers/claude-code.yamlandproviders/copilot.yamldo the same for their conversation hosts;providers/copilot.yamlsetsallow_uninspected_credentials: trueonly on two L4-onlytelemetry 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).