You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEATURE]: Opt-in credential redaction before outbound LLM requests #50904
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.
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.
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).
+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.
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:
Session title generation bypasses plugin hooks #46115 was reported on v1 (1.18.20). v2 has a dedicated title session hook next to context (agent loop), compaction and generate. Together they cover every kind of model request a session makes.
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"typeMode="warn"|"mask"|"block"constPATTERNS: 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]*PRIVATEKEY-----[\s\S]*?-----END[A-Z]*PRIVATEKEY-----/g,]// Keep the key / scheme, redact only the secret part.constKEYED: 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"],]functionscrub(text: string): {text: string;hits: number}{lethits=0letout=textfor(constreofPATTERNS)out=out.replace(re,()=>{hits++return"[REDACTED]"})for(const[re,sub]ofKEYED)out=out.replace(re,(...args)=>{hits++returnsub.replace(/\$(\d)/g,(_,i)=>String(args[Number(i)]??""))})return{text: out, hits }}// Recursively scrub every string in an unknown value (tool inputs / results).functiondeep(value: unknown,count: {n: number}): unknown{if(typeofvalue==="string"){constr=scrub(value)count.n+=r.hitsreturnr.text}if(Array.isArray(value))returnvalue.map((v)=>deep(v,count))if(value&&typeofvalue==="object"&&Object.getPrototypeOf(value)===Object.prototype)returnObject.fromEntries(Object.entries(value).map(([k,v])=>[k,deep(v,count)]))returnvalue}interfaceRequest{system: Array<{text: string}>messages: Array<{content: ReadonlyArray<unknown>}>}functionshield(event: Request,mode: Mode,kind: string){constcount={n: 0}constsystem=event.system.map((part)=>deep(part,count)astypeofpart)constmessages=event.messages.map((message)=>{constcontent=message.content.map((part)=>deep(part,count))// Copy the message, keeping its class, so history objects are never mutated.returnObject.assign(Object.create(Object.getPrototypeOf(message)),message,{ content })astypeofmessage})if(count.n===0)returnif(mode==="block")thrownewError(`secret-shield: blocked ${kind} request containing ${count.n} credential(s)`)if(mode==="warn")returnconsole.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)}exportdefaultPlugin.define({id: "secret-shield",asyncsetup(ctx){constraw=String(ctx.options.mode??process.env.OPENCODE_SECRET_SHIELD??"mask")if(raw==="off")returnconstmode: Mode=raw==="warn"||raw==="block" ? raw : "mask"awaitctx.session.hook("context",(event)=>shield(event,mode,"primary"))awaitctx.session.hook("compaction",(event)=>shield(event,mode,"compaction"))awaitctx.session.hook("generate",(event)=>shield(event,mode,"generate"))awaitctx.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.
Activity
Siphion commentedon Oct 22, 2026
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 themaskmode.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, acurlheader, 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. Ifsecret_shieldgets a mask mode, a restore step next to it would make it usable in long sessions.On coverage: a gateway on the provider's
baseURLsees every request to that provider, whichever internal flow built it (title generation too, if it uses the same provider).projectescape commentedon Oct 22, 2026
+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
blockmode, 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/
opencodejr commentedon Oct 28, 2026
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:
titlesession hook next tocontext(agent loop),compactionandgenerate. Together they cover every kind of model request a session makes.http.requesthook. The request hooks above run on the assembledsystemandmessagesbefore the request is turned into provider HTTP, so they apply to native and AI-SDK models alike.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.tsin a project, or under~/.config/opencode/plugins/to use it everywhere:The mode comes from
OPENCODE_SECRET_SHIELD(off/warn/mask/block) and defaults tomask. 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:
prompthook can rewrite user input when it is submitted.blockmode 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.