Skip to content

api: /api/model, /api/config and /api/provider/{id} return resolved provider credentials #53465

Open

Description

@ned9

Description

/api/model, /api/config and /api/provider/{providerID} return provider settings with environment-substitution credentials resolved to their literal secrets: a provider configured as "apiKey": "{env:ACME_API_KEY}" — kept out of every config file on disk — comes back from these endpoints as plaintext.

The server is what performs the inference requests, so catalog/config clients don't need the literal key; returning it verbatim breaks least exposure. The impact is amplified under agentic use: agents are now the primary client class for this API, and when one prints an endpoint response into a transcript, that transcript is persisted on disk and re-sent to the model provider as context on every subsequent turn — so a locally-bound secret ends up both in on-disk plaintext and at the model provider.

Expected: catalog/config endpoints redact credential-bearing fields or return what was configured (the {env:...} template), resolving secrets server-side only at request time.

Plugins

None

OpenCode version

2.0.23

Steps to reproduce

  1. Configure a custom provider with an env-substitution credential:

    {
      "$schema": "https://opencode.ai/config.json",
      "provider": {
        "acme": {
          "npm": "@ai-sdk/openai-compatible",
          "options": {
            "apiKey": "{env:ACME_API_KEY}",
            "baseURL": "https://example.com/v1"
          },
          "models": {
            "m1": { "limit": { "context": 100000, "output": 8192 } }
          }
        }
      }
    }
  2. Start opencode with ACME_API_KEY in the environment.

  3. From any shell:

    opencode api get /api/model | jq '.data[] | select(.providerID == "acme") | .settings.apiKey'
    opencode api get /api/config | jq '.. | .apiKey? // empty'
    opencode api get /api/provider/acme | jq '.settings.apiKey'
  4. All three print the literal secret, while the config file still holds only the {env:...} template. Verified on 2.0.23: template is 24 chars, the endpoints return a 43-char literal (lengths compared; the value was never printed or stored in this issue).

Operating System

macOS (Darwin 25.6.0, arm64)

Terminal

Ghostty / tmux

Activity

  1. github-actions commented on Oct 6, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #22157: reports /config returning resolved provider API keys and other environment secrets to browser clients.
    • #50915: reports resolved provider API keys exposed by opencode debug config, a related credential-exposure surface.
  2. ned9 commented on Oct 6, 2026

    @ned9
    Author

    This issue might be a duplicate of existing issues. Please check:

    • #22157: reports /config returning resolved provider API keys and other environment secrets to browser clients.
    • #50915: reports resolved provider API keys exposed by opencode debug config, a related credential-exposure surface.

    I don't believe this issue is a duplicate, but they're related and worth cross-linking:

    • #22157: same problem class, different endpoint — serve-mode/web-UI GET /config, and no fix landed. This issue covers the v2 HTTP API surface, including the v2 GET /api/config, GET /api/model, and GET /api/provider/{providerID}.
    • #50915: fixed, but scoped to the opencode debug config CLI command only — the merged fix states "Redact only the debug output; the resolved configuration must remain usable by providers." The HTTP API endpoints here are a different surface, still exposed; verified on 2.0.23, and (the identical model.list handler (catalog.model.available() passthrough) ships unchanged in current stable v1.18.34).

    Comparing them all:

    Surface What's exposed Status
    GET /api/model Per-model settings.apiKey — resolved literal Open — only reported here
    GET /api/config Entire resolved config document: provider options.apiKey, plus MCP server environment values and MCP OAuth secrets Open — configSvc.get() returned raw on dev; fix attempt #33723 closed unmerged
    GET /api/provider/{providerID} Resolved provider entry incl. settings.apiKey Open — only report is here
    GET /config (Serve-mode/web-UI ) Resolved provider keys and MCP environment secrets to authenticated web clients Stale-closed (#22157), never fixed; fix attempt #33723 closed unmerged
    opencode debug config (CLI) Credentials in plaintext Fixed — #50956 / #50957, shipped in v1.18.33
  3. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the detailed and careful report, @ned9.

    The request is that the catalog and config endpoints (/api/model, /api/config, /api/provider/{id}) stop returning provider credentials resolved from {env:...}. They would either redact them or return the configured template, and the server would resolve the secret only when it makes the inference request.

    Right now this is how the API is built rather than an accidental leak. Credential redaction exists only for opencode debug config output (#50956, #50957). The authenticated local API returns resolved settings and also exposes credential values through its credential endpoints. Your point about agents echoing responses into saved transcripts and model context is a good reason to reconsider that, so I've marked this as a feature request for the maintainers. I can't promise if or when it will change.

    Until then, one way to reduce exposure is to keep agents from running opencode api get against these endpoints. For example, deny those commands in your permission config.

  4. iceteaSA commented on Oct 28, 2026

    @iceteaSA

    Same on the released 2.2.0 (Linux). This also applies to {file:...} substitutions, not only {env:...}.

    I tested in an isolated sandbox with a dummy value. A remote MCP server in opencode.json has a header "Authorization": "Bearer {file:<path>}". The response from GET /api/config contains that header with the file contents, not the template. The config file on disk holds only the template. I compared the values as booleans and did not print them.

    The server does the file substitution when it loads the config text, before it stores the parsed document. If you change only the file, the /api/config response does not change. After an edit to the config text, the subsequent response contains the new file contents.

    The change in this issue also prevents this condition: send the configured template, or remove the values that came from substitution.

  5. iceteaSA commented on Oct 28, 2026

    @iceteaSA

    This also occurs on 2.2.0. It is not only for {env:...} provider keys: {file:...} values and MCP server settings come back as literal text too.

    Steps, with an isolated server and dummy values:

    1. Put a dummy value in two files, provider.txt and mcp.txt.

    2. Use this config:

      {
        "providers": {
          "acme": {
            "name": "Acme",
            "settings": { "apiKey": "{file:/path/to/provider.txt}", "baseURL": "https://example.invalid/v1" },
            "models": { "m1": {} }
          }
        },
        "mcp": {
          "servers": {
            "remote1": {
              "type": "remote",
              "url": "https://example.invalid/mcp",
              "enabled": false,
              "headers": { "Authorization": "Bearer {file:/path/to/mcp.txt}" }
            }
          }
        }
      }
    3. Start opencode serve and send GET /api/config with the server password.

    Result: the response has the text of the two files at info.providers.acme.settings.apiKey and info.mcp.servers.remote1.headers.Authorization. The response does not have the {file:...} text from the config. The config sets enabled: false on the MCP server, and its secret is in the response all the same.

    Cause: the config.get handler sends config.entries() (packages/server/src/handlers/config.ts:9). These entries keep the text after the {file:...} substitution in packages/core/src/config/variable.ts.

    Effect: a client with the server password can read each secret file that the config refers to. On a shared service, a local process that can read the service credentials, or that runs the opencode CLI, gets all provider and MCP secrets. Read rules on the secret files do not stop this.

    Necessary result: /api/config sends the {file:...} and {env:...} text as configured, or removes the credential values. The server reads the secrets itself when it sends a request.

  6. speed007 commented on Oct 29, 2026

    @speed007

    Confirming on 2.2.1 (Linux 7.2.11-1-cachyos x86_64, release binary), and adding one detail that makes /api/model worse than a single leaked field: the credential is repeated on every model entry, not once per provider.

    • GET /api/model returned 391 entries, ~324 KB, with settings.apiKey present verbatim on every one. For each configured provider the same secret is serialised over and over, so a single opencode api get /api/model > out.json writes N copies of the key to disk, and an agent that prints the response puts all N copies into the persisted transcript (and back to the model provider on every later turn).
    • Confirmed with {file:...} substitution (OpenRouter key file) as well as plain literal config values (llama.cpp → sk-local, opencode → public), so this is not specific to {env:...} resolution.
    • Repro on 2.2.1: providers.openrouter.settings.apiKey = "{file:~/.secrets.d/openrouter.key}" → opencode api get /api/model → read data[].settings.apiKey.
    • Practical consequence: I triggered this accidentally while inspecting model metadata (checking which models are type: "image"), and had to rotate the key afterwards because the plaintext landed in a terminal transcript.

    I have not exercised /api/config or /api/provider/{id}, so I'm only confirming /api/model on 2.2.1 plus the repetition/amplification factor above. Redacting the field or returning the unresolved template would both fix it.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions