Skip to content

Static credential placeholders are destination-agnostic — consider binding them to the endpoint (token-grant already does) #2353

Description

@CraigAllsopp

Summary

The static credential flow issues a deterministic placeholder (openshell:resolve:env:<KEY>) into the sandbox environment and substitutes the real value at the L7 proxy on egress. This is a genuinely good design — the secret never lands in the sandbox filesystem or environment, so a full container compromise yields nothing to exfiltrate.

The gap: the placeholder carries no binding to the destination it's intended for. Any process in the sandbox that can reach any policy-allowed L7 endpoint can include the placeholder in a request and receive the substituted secret. Egress policy then becomes the single control preventing a credential being redirected to an attacker-chosen — but allowed — endpoint.

This risk class is already acknowledged in the docs (exfiltration through allowed endpoints). The suggestion below is about not depending on the allowlist alone.

Why it looks worth closing

The codebase already contains the pattern. l7/token_grant_injection.rs binds dynamic tokens to their endpoint — credentials are keyed on a tab-separated host \t port \t path \t provider_key tuple, matched by host pattern + exact port + path glob, with the most-specific key winning. The static path (openshell-core/src/secrets.rs) has no equivalent: SecretResolver is a flat HashMap<String, SecretValue> keyed on the placeholder alone, and resolve_placeholder(&self, value: &str) takes no host, port or path.

The contrast is visible in a single struct — L7EvalContext carries both mechanisms side by side, and only one of them is documented as endpoint-bound:

/// Supervisor-only placeholder resolver for outbound headers.
pub(crate) secret_resolver: Option<Arc<SecretResolver>>,
...
/// Dynamic credentials (token grants) keyed by endpoint-bound provider metadata.
pub(crate) dynamic_credentials: Option<...>,

Concretely: on the balanced / open tiers — which permit POST to general-purpose third-party endpoints — an agent that is prompt-injected, or simply goal-seeking, can place openshell:resolve:env:GITHUB_TOKEN in a header or body aimed at an allowed endpoint, and the proxy will faithfully substitute the real token on the way out.

Two properties compound it:

  • the placeholder is deterministic — derivable from the credential name alone, with no read access required
  • substitution has no per-destination binding — the resolver matches on the placeholder only, so a credential resolves for any endpoint the connection is already permitted to reach

To be precise about the scope: this isn't unrestricted egress — L4/L7 policy still governs whether the connection happens at all, and placeholder scanning fails closed if a marker can't be resolved. The gap is credential cross-binding within the allow-list: a token intended for one allowed endpoint can be substituted into a request to a different allowed one.

Suggested change

Key the resolver on (env_key, host, port, path_glob) rather than env_key alone:

  • resolve a credential only when the request is bound for its declared destination
  • treat a placeholder appearing in a request bound elsewhere as a policy violation — reject with an audit event, rather than substituting

Optionally, a per-sandbox random salt in the placeholder would stop it being guessable from the credential name.

The effect is to move the mechanism from "obfuscation backed by a firewall" to "capability bound to an endpoint" — which is what the dynamic arm already achieves.

Notes

Filed as a design/hardening suggestion rather than a security report, since the underlying risk is already documented. Happy to move this to a private channel if you'd prefer.

Based on a read-only review of the public repo and docs — not run in anger, so I may have missed a binding that happens elsewhere in the pipeline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions