Skip to content

Instantly share code, notes, and snippets.

@casualjim
Created February 24, 2026 01:22
Show Gist options
  • Select an option

  • Save casualjim/c6af3fc00c7ce7682126b13fce51519b to your computer and use it in GitHub Desktop.

Select an option

Save casualjim/c6af3fc00c7ce7682126b13fce51519b to your computer and use it in GitHub Desktop.
opencode plugins
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.`
)
}
}
},
}
}
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)
}
},
}
}
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