Skip to content

Instantly share code, notes, and snippets.

@AlexMikhalev
Created April 12, 2026 09:36
Show Gist options
  • Select an option

  • Save AlexMikhalev/bc7cc0f237bdb2a6fade347aba203acb to your computer and use it in GitHub Desktop.

Select an option

Save AlexMikhalev/bc7cc0f237bdb2a6fade347aba203acb to your computer and use it in GitHub Desktop.
OpenCode DCG Plugin - Destructive Command Guard integration via tool.execute.before hook

Guarding OpenCode with Destructive Command Guard

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.

The Problem

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.

The Architecture

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 Plugin

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.`
        );
      }
    }
  };
};

What Got Fixed: A Subtle API Mismatch

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.

Installation

# 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

What DCG Blocks

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.

Key Design Decisions

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.

Extending with Packs

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"

Conclusion

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.

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 {
const result = JSON.parse(stdout);
resolve(result);
} catch (e) {
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.`
);
}
}
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment