AI coding assistants are fast, productive, and occasionally catastrophic. One misplaced rm -rf, one accidental git reset --hard, and hours of uncommitted work vanish. This post shows how to integrate Destructive Command Guard (dcg) with OpenCode using its plugin hook system, so destructive commands are intercepted before they run.
AI agents don't type commands into a terminal. They invoke tools programmatically -- and they don't always get it right:
- "Cleaning up build artifacts" becomes
rm -rf ./src(one-character typo) - "Resetting to last commit" becomes
git reset --hard(uncommitted work gone) - "Force pushing the fix" becomes
git push --force(team history destroyed)
You need a safety net that operates between the agent and your shell.
OpenCode v1.4+ exposes a plugin hook system. Hooks are top-level keys on the Hooks interface:
interface Hooks {
"tool.execute.before"?: (input, output) => Promise<void>;
"tool.execute.after"?: (input, output) => Promise<void>;
// ...
}The "tool.execute.before" hook fires before every tool call. It receives the tool name and arguments, and can throw an error to abort execution. This is exactly where a command guard belongs.
DCG is a Rust binary that reads a JSON payload from stdin and exits 0 (allow) or 2 (block). The plugin wires these together:
OpenCode agent
|
| calls bash tool: "rm -rf ./build"
v
"tool.execute.before" hook
|
| spawns: echo '{"tool":"bash","args":{"command":"rm -rf ./build"}}' | dcg
v
dcg (Rust, SIMD-accelerated pattern matching)
|
| exit code 2 + reason on stderr
v
hook throws Error --> command never executes
The complete plugin is ~60 lines:
import { spawn } from 'child_process';
const callDcgHook = (toolCall) => {
return new Promise((resolve, reject) => {
const dcg = spawn('dcg', [], {
env: { ...process.env, DCG_FORMAT: 'json' }
});
let stdout = '';
let stderr = '';
dcg.stdout.on('data', (data) => { stdout += data.toString(); });
dcg.stderr.on('data', (data) => { stderr += data.toString(); });
dcg.on('close', (code) => {
if (code === 0) {
try { resolve(JSON.parse(stdout)); }
catch { resolve({ allowed: true }); }
} else {
reject(new Error(stderr || 'dcg blocked command'));
}
});
dcg.stdin.write(JSON.stringify(toolCall));
dcg.stdin.end();
});
};
export const DcgGuard = async ({ client }) => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== 'bash') return;
const toolCall = {
tool: 'bash',
args: { command: output.args.command }
};
try {
await callDcgHook(toolCall);
} catch (error) {
throw new Error(
`dcg blocked destructive command: ${output.args.command}\n\n` +
`${error.message}\n\n` +
`This command was blocked to protect your system.`
);
}
}
};
};The original plugin used a nested structure:
return {
tool: {
execute: {
before: async (input, output) => { ... }
}
}
};This looks intuitive but is wrong. In OpenCode's plugin API, tool is reserved for registering new tools (each needing a description, args, and execute function). The hook "tool.execute.before" is a top-level dotted key on the Hooks object, not a nested path.
The fix:
return {
"tool.execute.before": async (input, output) => { ... }
};This distinction matters. OpenCode iterates over tool entries expecting ToolDefinition objects. When it found { before: ... } instead, it called .execute(args, ctx) on it -- which was undefined. Hence the error: def.execute is not a function.
# 1. Install dcg
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/master/install.sh" | bash
# 2. Install the plugin
mkdir -p ~/.config/opencode/plugin
curl -fsSL https://raw.githubusercontent.com/jms830/opencode-dcg-plugin/main/plugin/dcg-guard.js \
-o ~/.config/opencode/plugin/dcg-guard.js
# 3. Restart OpenCode| Category | Examples |
|---|---|
| Git history destruction | git reset --hard, git push --force, git branch -D |
| Uncommitted work loss | git checkout -- ., git restore file, git clean -f |
| Stash destruction | git stash drop, git stash clear |
| Filesystem damage | rm -rf outside /tmp |
| Database operations | DROP TABLE, FLUSHALL (via packs) |
| Container destruction | docker system prune, docker-compose down --volumes |
| Infrastructure | terraform destroy, kubectl delete namespace |
Safe operations pass through silently: git status, git add, git commit, git push (without --force), git stash, git checkout -b, and all non-destructive commands.
Default-allow. Unrecognized commands pass through. DCG blocks only known dangerous patterns. This prevents false positives from blocking legitimate work.
Whitelist-first. Safe patterns (like git checkout -b) are checked before destructive patterns. Explicitly safe commands are never accidentally blocked.
Sub-millisecond latency. DCG uses SIMD-accelerated substring search via Rust's memchr crate. Commands without "git" or "rm" bypass regex matching entirely. The guard adds no perceptible delay.
Fail-open. If dcg crashes or produces unexpected output, the plugin catches the error and defaults to allowing the command. A broken guard shouldn't break your workflow.
DCG ships with a modular pack system. Enable additional protection categories in ~/.config/dcg/config.toml:
[packs]
enabled = [
"database.postgresql",
"containers.docker",
"kubernetes",
"cloud.aws",
]Or via environment variable:
export DCG_PACKS="containers.docker,kubernetes"The OpenCode plugin API's "tool.execute.before" hook is a clean interception point for safety guards. Combined with dcg's fast pattern matching, you get protection against destructive commands with zero workflow friction. The plugin is small, the guard is fast, and the safety net catches the mistakes that matter.
The full plugin source and installation instructions are available as a GitHub gist and in the opencode-dcg-plugin repository.