Skip to content

[FEATURE]: Opt-in credential redaction before outbound LLM requests #50904

Open

Description

@GiacomoSaccaggi

OpenCode can send user credentials to model providers without sanitization. When a prompt, tool output, or system context contains API keys, connection strings, or JWTs, they go straight into the outbound request body.

Plugin-based workarounds can't fully cover this — title generation bypasses plugin hooks (#46115), and the AI-SDK path is excluded from http.request hooks (#42846). A built-in opt-in scan at the LLM request layer would catch credentials regardless of which internal flow triggers the request.

Proposal: a secret_shield config field with four modes:

Mode Behavior
off Default. No scanning, zero overhead.
warn Scan and log detection count, send original.
mask Replace detected credentials with [REDACTED], send masked.
block Block delivery if credentials found.
{ "secret_shield": "mask" }

Also via OPENCODE_SECRET_SHIELD env var.

Detection: 16 API key prefix families (OpenAI, Anthropic, Google, GitHub, AWS, Stripe, Slack, GitLab, HuggingFace, npm, PyPI, Vault, etc.), JSON fields (password, api_key, token, client_secret), HTTP auth headers, connection string URIs (postgres, mysql, mongodb, redis, amqp), and JWT tokens.

Integration point: transforms LLMRequest in the session runner after compaction check, before llm.stream(). Returns a discriminated ShieldResult (ok | blocked) so block mode surfaces as a failed step in the UI rather than crashing the runner.

I have a working implementation with 85 tests if the direction looks right.

Happy to adjust the design based on feedback.

Activity

  1. Siphion commented on Oct 22, 2026

    @Siphion

    Disclosure: I'm the author of Latet (https://github.com/Latet-AI/latet), a local privacy firewall for AI agents that works with OpenCode as a gateway on each provider's baseURL. It isn't a substitute for a built-in shield, but one thing from running it may matter for the mask mode.

    A one-way [REDACTED] tends to break the agent's next step. The model sees the placeholder and writes it back: into a config file, a curl header, a connection string it passes to a tool. Unless the value is restored before the tool runs, the agent writes [REDACTED] to disk or retries in a loop, and people turn the feature off. Latet replaces each secret with a fake of the same shape (the same fake for the same value, every time) and puts the real value back in the answer and in tool-call arguments, so the code the model writes still works and the files on disk get the real key. If secret_shield gets a mask mode, a restore step next to it would make it usable in long sessions.

    On coverage: a gateway on the provider's baseURL sees every request to that provider, whichever internal flow built it (title generation too, if it uses the same provider).

  2. projectescape commented on Oct 22, 2026

    @projectescape

    +1 on doing it at the LLM request layer, it's the one place every flow passes through.

    One thing from running something similar for a while: for block mode, it helps a lot if the model can find out what was blocked without seeing it. If the tool result just disappears, the agent usually re-runs the same command or stops. If it's told "lines 3 and 7 looked like an API key", it can re-read just the safe parts and keep going. In my tests that worked in one of two sessions, so not reliable, but much better than nothing.

    On detection: a deterministic pattern guard caught 24/60 on a mixed secrets + personal-data set I ran, a small local model (Qwen2.5 3B) caught 60/60 but flagged 18/60 harmless outputs. Patterns are the right default for mask; a model is maybe an opt-in extra.

    Disclosure: I build Torana (https://github.com/torana-edge/torana-edge), an open-source local proxy that does this as a plugin for OpenCode, Claude Code and Codex. Write-up: https://torana.sh/blog/local-models-tool-output/

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed proposal! You're asking for an opt-in check, built in at the LLM request layer, that warns about, masks or blocks credentials (API keys, connection strings, JWTs, auth headers) before any request reaches a model provider. Maintainers will consider it.

    The two plugin gaps you mention don't block this in v2:

    So you can do this as a plugin today. Here is one I put together from your proposal. It type-checks against @opencode/plugin, and I tested its redaction logic outside a running OpenCode. Save it as .opencode/plugins/secret-shield.ts in a project, or under ~/.config/opencode/plugins/ to use it everywhere:

    import { Plugin } from "@opencode/plugin"
    
    type Mode = "warn" | "mask" | "block"
    
    const PATTERNS: RegExp[] = [
      /\bsk-(?:proj-|ant-(?:api|admin)\d{2}-)?[A-Za-z0-9_-]{20,}/g, // OpenAI / Anthropic
      /\bAIza[0-9A-Za-z_-]{35}\b/g, // Google
      /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, // GitHub
      /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g,
      /\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
      /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key id
      /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}\b/g, // Stripe
      /\bxox[abposr]-[0-9A-Za-z-]{10,}\b/g, // Slack
      /\bhf_[A-Za-z0-9]{30,}\b/g, // Hugging Face
      /\bnpm_[A-Za-z0-9]{36}\b/g, // npm
      /\bpypi-[A-Za-z0-9_-]{50,}\b/g, // PyPI
      /\bhv[sb]\.[A-Za-z0-9_-]{20,}\b/g, // Vault
      /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
      /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
    ]
    // Keep the key / scheme, redact only the secret part.
    const KEYED: Array<[RegExp, string]> = [
      [/((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqps?):\/\/[^:\s/@]+:)[^@\s]+(@)/gi, "$1[REDACTED]$2"],
      [/(\bauthorization\s*:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1[REDACTED]"],
      [/("(?:password|passwd|api_?key|apikey|secret|client_secret|access_token|refresh_token|token)"\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
    ]
    
    function scrub(text: string): { text: string; hits: number } {
      let hits = 0
      let out = text
      for (const re of PATTERNS)
        out = out.replace(re, () => {
          hits++
          return "[REDACTED]"
        })
      for (const [re, sub] of KEYED)
        out = out.replace(re, (...args) => {
          hits++
          return sub.replace(/\$(\d)/g, (_, i) => String(args[Number(i)] ?? ""))
        })
      return { text: out, hits }
    }
    
    // Recursively scrub every string in an unknown value (tool inputs / results).
    function deep(value: unknown, count: { n: number }): unknown {
      if (typeof value === "string") {
        const r = scrub(value)
        count.n += r.hits
        return r.text
      }
      if (Array.isArray(value)) return value.map((v) => deep(v, count))
      if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype)
        return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deep(v, count)]))
      return value
    }
    
    interface Request {
      system: Array<{ text: string }>
      messages: Array<{ content: ReadonlyArray<unknown> }>
    }
    
    function shield(event: Request, mode: Mode, kind: string) {
      const count = { n: 0 }
      const system = event.system.map((part) => deep(part, count) as typeof part)
      const messages = event.messages.map((message) => {
        const content = message.content.map((part) => deep(part, count))
        // Copy the message, keeping its class, so history objects are never mutated.
        return Object.assign(Object.create(Object.getPrototypeOf(message)), message, { content }) as typeof message
      })
      if (count.n === 0) return
      if (mode === "block") throw new Error(`secret-shield: blocked ${kind} request containing ${count.n} credential(s)`)
      if (mode === "warn") return console.warn(`secret-shield: ${count.n} credential(s) in ${kind} request`)
      event.system.splice(0, event.system.length, ...system)
      event.messages.splice(0, event.messages.length, ...messages)
    }
    
    export default Plugin.define({
      id: "secret-shield",
      async setup(ctx) {
        const raw = String(ctx.options.mode ?? process.env.OPENCODE_SECRET_SHIELD ?? "mask")
        if (raw === "off") return
        const mode: Mode = raw === "warn" || raw === "block" ? raw : "mask"
        await ctx.session.hook("context", (event) => shield(event, mode, "primary"))
        await ctx.session.hook("compaction", (event) => shield(event, mode, "compaction"))
        await ctx.session.hook("generate", (event) => shield(event, mode, "generate"))
        await ctx.session.hook("title", (event) => shield(event, mode, "title"))
      },
    })

    The mode comes from OPENCODE_SECRET_SHIELD (off / warn / mask / block) and defaults to mask. If you package the plugin as a directory, you can also set it through plugin options: "plugins": [{ "package": "./plugins/secret-shield", "options": { "mode": "block" } }].

    Some notes:

    • These hooks change only the outgoing request. Your stored history is left as it is.
    • If you also want secrets removed from what gets saved, the prompt hook can rewrite user input when it is submitted.
    • In block mode the plugin throws from the hook, which should fail that request. Session hooks have no typed way to reject a request, so how that error shows up in the UI may be less polished than a built-in version.

    Related: #46115, #42846.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions