Skip to content

Latest commit

 

History

History
148 lines (120 loc) · 5.52 KB

File metadata and controls

148 lines (120 loc) · 5.52 KB

Pi Integration

Last updated: 2026-06-01

This document shows how to connect dcg to the Pi coding agent (earendil-works/pi). Pi is not auto-configured by dcg's installer — Pi's guardrails are deliberately user-authored as small TypeScript extensions — so this is the official "directed implementation" recipe requested in issue #133. Drop in the extension below and Pi will route every shell command through dcg before it runs.

How Pi interception works

Pi extensions register handlers with pi.on("tool_call", …). The handler runs before the tool executes, receives the tool name and (mutable) input, and can veto execution by returning { block: true, reason }:

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event, ctx) => {
    // event.toolName === "bash", event.input.command === "<the command>"
    // return { block: true, reason } to deny
  });
}

Extensions auto-load from ~/.pi/agent/extensions/*.ts (global) or .pi/extensions/*.ts (project-local). See Pi's extensions docs.

Which dcg entrypoint to call

dcg exposes a stable, agent-friendly decision API via robot mode, which is the right fit for an extension because the decision is carried by the exit code and a machine-readable JSON payload — no hook-protocol envelope to construct:

dcg --robot test "<command>"
  • exit 0 → allowed
  • exit 1 → denied (JSON on stdout carries reason, rule_id, pack_id, explanation, remediation, …)
  • exit ≥ 3 → a dcg error (config/parse/IO). Treat these as fail-open (let the command proceed) or fail-closed (block) per your risk tolerance; the example below fails open so a broken dcg install never wedges Pi, matching the default posture of the other dcg integrations.

(See docs/adr-002-robot-mode-api.md for the full exit-code contract.)

Why not pipe a hook JSON payload to bare dcg? You can — printf '{"tool_name":"Bash","tool_input":{"command":"…"}}' | dcg works too — but in that mode dcg always exits 0 and puts the decision inside hookSpecificOutput.permissionDecision, so the extension would have to parse JSON just to learn allow vs. deny. --robot test makes the exit code authoritative, which is simpler and less error-prone.

The extension

Save this as ~/.pi/agent/extensions/dcg-guard.ts (global) or <repo>/.pi/extensions/dcg-guard.ts (per project):

// dcg-guard.ts — block destructive shell commands with dcg
// https://github.com/Dicklesworthstone/destructive_command_guard
import { spawn } from "node:child_process";

const DCG_BIN = process.env.DCG_BIN ?? "dcg";

function dcgDecision(command: string): Promise<{ deny: boolean; reason: string }> {
  return new Promise((resolve) => {
    const child = spawn(DCG_BIN, ["--robot", "test", command], {
      stdio: ["ignore", "pipe", "ignore"],
    });

    let stdout = "";
    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });

    // Fail open if dcg can't be found / spawned, so a broken install never
    // wedges Pi. Flip this to resolve({ deny: true, ... }) to fail closed.
    child.on("error", () => resolve({ deny: false, reason: "" }));

    child.on("close", (code) => {
      if (code === 1) {
        // Denied. The reason lives in the robot-mode JSON.
        let reason = "Blocked by dcg (destructive command).";
        try {
          const parsed = JSON.parse(stdout);
          if (parsed?.reason) reason = parsed.reason;
          if (parsed?.rule_id) reason += ` [${parsed.rule_id}]`;
        } catch {
          /* keep the default reason */
        }
        resolve({ deny: true, reason });
      } else {
        // 0 = allowed; >=3 = dcg error -> fail open.
        resolve({ deny: false, reason: "" });
      }
    });
  });
}

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event) => {
    if (event.toolName !== "bash") return;
    const command = String(event.input?.command ?? "");
    if (!command.trim()) return;

    const { deny, reason } = await dcgDecision(command);
    if (deny) {
      return { block: true, reason };
    }
  });
}

Adjust the tool-name check (event.toolName !== "bash") if your Pi build names its shell tool differently, and set DCG_BIN if dcg is not on Pi's PATH (e.g. ~/.local/bin/dcg).

Verifying it works

  1. Install dcg (curl … | bash) and confirm dcg --version works in the same shell environment Pi runs in.

  2. Drop dcg-guard.ts into one of the discovery paths above (or pass it explicitly: pi -e ./dcg-guard.ts).

  3. Ask Pi to run a known-destructive command, e.g. git reset --hard HEAD~1. Pi should refuse with the dcg reason instead of executing it.

  4. Sanity-check the underlying decision directly:

    dcg --robot test "git reset --hard HEAD~1"; echo "exit=$?"   # exit=1 (denied)
    dcg --robot test "ls -la"; echo "exit=$?"                    # exit=0 (allowed)

Limitations

Like every PreToolUse-style guardrail (Claude Code, Codex, Gemini, …), this gates the tool call. A sufficiently determined model can still write a script to disk and execute that, or use a tool other than bash. For a hard boundary, run Pi inside a container or sandbox. dcg's pre-commit scan mode (docs/scan-precommit-guide.md) is a useful second layer for the git path.