Created
February 24, 2026 01:22
-
-
Save casualjim/c6af3fc00c7ce7682126b13fce51519b to your computer and use it in GitHub Desktop.
opencode plugins
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Plugin } from "@opencode-ai/plugin" | |
| // Matches kubectl ... get ... secret[s] in order within a single shell segment. | |
| // | |
| // A segment ends at an unescaped terminator: ; | & or newline. | |
| // Line continuations (\<newline>) are treated as non-terminators. | |
| // | |
| // \b on kubectl/get/secrets ensures we don't match substrings like | |
| // "mykubectl", "getter", or "secretstore". | |
| const KUBECTL_GET_SECRET = | |
| /\bkubectl\b(?:[^;|&\n]|\\\n)*\bget\b(?:[^;|&\n]|\\\n)*\bsecrets?\b/ | |
| export const KubectlSecretGuard: Plugin = async () => { | |
| return { | |
| "tool.execute.before": async (input, output) => { | |
| if (input.tool === "bash") { | |
| const command: string = output.args?.command || "" | |
| if (KUBECTL_GET_SECRET.test(command)) { | |
| throw new Error( | |
| `Blocked: command would execute "kubectl get secret". ` + | |
| `This is protected by @plugin/kubectl-secret-guard.ts. ` + | |
| `Ask the user to run this command directly in their terminal if needed. ` + | |
| `Never attempt to bypass this protection or ask the user to disable it.` | |
| ) | |
| } | |
| } | |
| }, | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Plugin } from "@opencode-ai/plugin" | |
| import { join } from "path" | |
| export const SecretGuard: Plugin = async ({ directory, worktree }) => { | |
| const configDir = worktree || directory | |
| const envPath = join(configDir, ".env.json") | |
| let secretKeys: string[] = [] | |
| let secretValues: Record<string, string> = {} | |
| try { | |
| const file = Bun.file(envPath) | |
| if (await file.exists()) { | |
| const env = await file.json() | |
| secretKeys = Object.keys(env).filter(k => k !== 'sops') | |
| for (const key of secretKeys) { | |
| const val = process.env[key] | |
| if (val && typeof val === 'string') { | |
| secretValues[key] = val | |
| } | |
| } | |
| } | |
| } catch {} | |
| if (secretKeys.length === 0) return {} | |
| const escapedKeys = secretKeys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) | |
| const keyPattern = new RegExp(`\\b(?:${escapedKeys.join('|')})\\b`, 'i') | |
| // Extract decoded text from hexdump-style output | |
| function extractDecodedText(output: string): string | null { | |
| const lines = output.split('\n') | |
| const decoded: string[] = [] | |
| let hasHexFormat = false | |
| for (const line of lines) { | |
| // hexdump -C: |text| | |
| const pipeMatch = line.match(/\|([^|]+)\|/) | |
| if (pipeMatch) { | |
| hasHexFormat = true | |
| decoded.push(pipeMatch[1]) | |
| continue | |
| } | |
| // xxd: "address: hex hex text" or "hex hex text" | |
| const xxdMatch = line.match(/^(?:[0-9a-f]+:\s+)?([0-9a-f]{2,4}(\s+[0-9a-f]{2,4})*)\s{2,}(\S.*)$/i) | |
| if (xxdMatch) { | |
| hasHexFormat = true | |
| decoded.push(xxdMatch[3]) | |
| continue | |
| } | |
| // od -c: address + spaced chars | |
| if (/^\d+\s+/.test(line) && !line.includes('|')) { | |
| const parts = line.split(/^\d+\s+/) | |
| if (parts.length > 1 && parts[1].match(/\S\s+\S/)) { | |
| hasHexFormat = true | |
| decoded.push(parts[1].replace(/\s+/g, '')) | |
| } | |
| } | |
| } | |
| return hasHexFormat ? decoded.join('') : null | |
| } | |
| // Check if raw hex output contains secret (xxd -p, od -x, etc.) | |
| function containsSecretInHex(output: string): boolean { | |
| for (const [key, value] of Object.entries(secretValues)) { | |
| const fullValue = `${key}=${value}` | |
| const hex = Buffer.from(fullValue).toString('hex') | |
| // Check contiguous hex | |
| if (output.toLowerCase().includes(hex)) { | |
| return true | |
| } | |
| // Check hex with whitespace (strip non-hex chars and look for pattern) | |
| const stripped = output.toLowerCase().replace(/[^0-9a-f]/g, '') | |
| if (stripped.includes(hex)) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| function redactOutput(output: string): string { | |
| let result = output | |
| // Check decoded text from hexdump-style output | |
| const decoded = extractDecodedText(output) | |
| if (decoded) { | |
| for (const [key, value] of Object.entries(secretValues)) { | |
| if (decoded.includes(`${key}=`)) { | |
| if (decoded.includes(value) || decoded.includes(value.substring(0, Math.max(5, value.length - 2)))) { | |
| return '[REDACTED]' | |
| } | |
| } | |
| } | |
| } | |
| // Check raw hex output | |
| if (containsSecretInHex(output)) { | |
| return '[REDACTED]' | |
| } | |
| // Standard redaction | |
| for (const [key, value] of Object.entries(secretValues)) { | |
| const fullValue = `${key}=${value}` | |
| // Plaintext | |
| result = result.replaceAll(fullValue, '[REDACTED]') | |
| // Base64 | |
| result = result.replaceAll(Buffer.from(fullValue).toString('base64'), '[REDACTED]') | |
| result = result.replaceAll(Buffer.from(fullValue + "\n").toString('base64'), '[REDACTED]') | |
| // Rot13 | |
| const rot13 = fullValue.replace(/[a-zA-Z]/g, c => { | |
| const base = c <= 'Z' ? 65 : 97 | |
| return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base) | |
| }) | |
| result = result.replaceAll(rot13, '[REDACTED]') | |
| // Reversed | |
| result = result.replaceAll(fullValue.split('').reverse().join(''), '[REDACTED]') | |
| } | |
| return result | |
| } | |
| return { | |
| "tool.execute.before": async (input, output) => { | |
| if (input.tool === "bash") { | |
| const command = output.args?.command || "" | |
| const match = command.match(keyPattern) | |
| if (match) { | |
| throw new Error( | |
| `Blocked: command references secret "${match[0]}". ` + | |
| `Ask the user to run this command directly in their terminal if needed. ` + | |
| `Never attempt to bypass this protection or ask the user to disable it.` | |
| ) | |
| } | |
| } | |
| }, | |
| "tool.execute.after": async (input, output) => { | |
| if (input.tool === "bash" && typeof output.output === 'string') { | |
| output.output = redactOutput(output.output) | |
| } | |
| }, | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Plugin } from "@opencode-ai/plugin" | |
| // Matches any sops invocation that decrypts content within a single shell segment. | |
| // | |
| // A segment ends at an unescaped terminator: ; | & or newline. | |
| // Line continuations (\<newline>) are treated as non-terminators. | |
| // | |
| // Decryption modes covered: | |
| // sops decrypt <file> -- subcommand | |
| // sops --decrypt / -d <file> -- long/short flag | |
| // sops exec-env <file> <cmd> -- decrypts into environment | |
| // sops exec-file <file> <cmd> -- decrypts to a temp file | |
| // sops edit <file> -- decrypts for editing | |
| // sops <file> -- bare invocation (decrypts for editing) | |
| // | |
| // "sops as a command" means it appears at: | |
| // - start of string / after a shell terminator (; | & \n), with optional whitespace | |
| // - after -- (end-of-options marker), e.g. `mise exec -- sops ...` | |
| // - optionally preceded by KEY=val env assignments | |
| // | |
| // The bare invocation case uses a negative lookahead to pass through safe | |
| // subcommands: encrypt/--encrypt/-e, rotate/--rotate/-r, publish, keyservice, | |
| // filestatus, groups, updatekeys, set, unset, completion, help, h. | |
| const A = '(?:[^;|&\\n]|\\\\\\n)' // one segment atom (doesn't cross terminators) | |
| const START = '(?:(?:^|[;|&\\n])\\s*)' | |
| const ENV_PREFIX = '(?:[A-Z_][A-Z0-9_]*=[^\\s]*\\s+)*' | |
| const CMD_SOPS = `(?:${START}${ENV_PREFIX}sops\\b)` | |
| const CMD_SOPS_DD = `(?:--\\s+${ENV_PREFIX}sops\\b)` // after -- (mise exec --) | |
| const SOPS_CMD = `(?:${CMD_SOPS}|${CMD_SOPS_DD})` | |
| const NO_SAFE_AHEAD = | |
| `(?!${A}*\\b(?:encrypt|rotate|publish|keyservice|filestatus|groups|updatekeys|set|unset|completion|help|h)\\b)` + | |
| `(?!${A}*(?:--encrypt\\b|--rotate\\b|-e\\b|-r\\b))` + | |
| `(?!${A}*(?:--version\\b|-v\\b))` | |
| const SOPS_DECRYPT = new RegExp( | |
| // Alt 1: explicit decrypt subcommands | |
| `${SOPS_CMD}${A}*\\b(?:decrypt|exec-env|exec-file|edit)\\b` + | |
| // Alt 2: --decrypt or -d flag | |
| `|${SOPS_CMD}${A}*(?:--decrypt\\b|-d\\b)` + | |
| // Alt 3: bare invocation — no safe subcommand/flag in this segment | |
| `|${SOPS_CMD}${NO_SAFE_AHEAD}`, | |
| "m" | |
| ) | |
| export const SopsSecretGuard: Plugin = async () => { | |
| return { | |
| "tool.execute.before": async (input, output) => { | |
| if (input.tool === "bash") { | |
| const command: string = output.args?.command || "" | |
| if (SOPS_DECRYPT.test(command)) { | |
| throw new Error( | |
| `Blocked: command would decrypt secrets via sops. ` + | |
| `This is protected by @plugin/sops-secret-guard.ts. ` + | |
| `Ask the user to run this command directly in their terminal if needed. ` + | |
| `Never attempt to bypass this protection or ask the user to disable it.` | |
| ) | |
| } | |
| } | |
| }, | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment