From 5a03922934acc30715b4cbc528cfec512d1bb90a Mon Sep 17 00:00:00 2001 From: seto Date: Sun, 12 Apr 2026 11:41:33 +0900 Subject: [PATCH] feat(hooks,skills): add gateguard fact-forcing pre-action gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PreToolUse hook that forces Claude to investigate before editing. Instead of self-evaluation ("are you sure?"), it demands concrete facts: importers, public API, data schemas, user instruction. A/B tested: +2.25 quality points (9.0 vs 6.75) across two independent tasks. - scripts/hooks/gateguard-fact-force.js — standalone Node.js hook - skills/gateguard/SKILL.md — skill documentation - hooks/hooks.json — PreToolUse entries for Edit|Write and Bash Full package with config: pip install gateguard-ai Repo: https://github.com/zunoworks/gateguard Co-Authored-By: Claude Opus 4.6 --- hooks/hooks.json | 24 +++ scripts/hooks/gateguard-fact-force.js | 216 ++++++++++++++++++++++++++ skills/gateguard/SKILL.md | 117 ++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 scripts/hooks/gateguard-fact-force.js create mode 100644 skills/gateguard/SKILL.md diff --git a/hooks/hooks.json b/hooks/hooks.json index 528b03f8..0dc92970 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -126,6 +126,30 @@ ], "description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls", "id": "pre:mcp-health-check" + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/gateguard-fact-force.js\"", + "timeout": 5 + } + ], + "description": "Fact-forcing gate: block first Edit/Write per file and demand investigation (importers, data schemas, user instruction) before allowing", + "id": "pre:edit-write:gateguard-fact-force" + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/gateguard-fact-force.js\"", + "timeout": 5 + } + ], + "description": "Fact-forcing gate: block destructive Bash commands and demand rollback plan; quote user instruction on first Bash per session", + "id": "pre:bash:gateguard-fact-force" } ], "PreCompact": [ diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js new file mode 100644 index 00000000..d1e0106b --- /dev/null +++ b/scripts/hooks/gateguard-fact-force.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * PreToolUse Hook: GateGuard Fact-Forcing Gate + * + * Forces Claude to investigate before editing files or running commands. + * Instead of asking "are you sure?" (which LLMs always answer "yes"), + * this hook demands concrete facts: importers, public API, data schemas. + * + * The act of investigation creates awareness that self-evaluation never did. + * + * Gates: + * - Edit/Write: list importers, affected API, verify data schemas, quote instruction + * - Bash (destructive): list targets, rollback plan, quote instruction + * - Bash (routine): quote current instruction (once per session) + * + * Exit codes: + * 0 - Allow (gate already passed for this target) + * 2 - Block (force investigation first) + * + * Cross-platform (Windows, macOS, Linux). + * + * Full package with config support: pip install gateguard-ai + * Repo: https://github.com/zunoworks/gateguard + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MAX_STDIN = 1024 * 1024; + +// Session state file for tracking which files have been gated +const STATE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); +const STATE_FILE = path.join(STATE_DIR, '.session_state.json'); + +const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force|dd\s+if=)\b/i; + +// --- State management --- + +function loadState() { + try { + if (fs.existsSync(STATE_FILE)) { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); + } + } catch (_) { /* ignore */ } + return { checked: [], read_files: [] }; +} + +function saveState(state) { + try { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8'); + } catch (_) { /* ignore */ } +} + +function markChecked(key) { + const state = loadState(); + if (!state.checked.includes(key)) { + state.checked.push(key); + saveState(state); + } +} + +function isChecked(key) { + const state = loadState(); + return state.checked.includes(key); +} + +// --- Sanitize file path against injection --- + +function sanitizePath(filePath) { + return filePath.replace(/[\n\r]/g, ' ').trim().slice(0, 500); +} + +// --- Gate messages --- + +function editGateMsg(filePath) { + const safe = sanitizePath(filePath); + return [ + '[Fact-Forcing Gate]', + '', + `Before editing ${safe}, present these facts:`, + '', + '1. List ALL files that import/require this file (use Grep)', + '2. List the public functions/classes affected by this change', + '3. If this file reads/writes data files, cat one real record and show actual field names, structure, and date format', + '4. Quote the user\'s current instruction verbatim', + '', + 'Present the facts, then retry the same operation.' + ].join('\n'); +} + +function writeGateMsg(filePath) { + const safe = sanitizePath(filePath); + return [ + '[Fact-Forcing Gate]', + '', + `Before creating ${safe}, present these facts:`, + '', + '1. Name the file(s) and line(s) that will call this new file', + '2. Confirm no existing file serves the same purpose (use Glob)', + '3. If this file reads/writes data files, cat one real record and show actual field names, structure, and date format', + '4. Quote the user\'s current instruction verbatim', + '', + 'Present the facts, then retry the same operation.' + ].join('\n'); +} + +function destructiveBashMsg() { + return [ + '[Fact-Forcing Gate]', + '', + 'Destructive command detected. Before running, present:', + '', + '1. List all files/data this command will modify or delete', + '2. Write a one-line rollback procedure', + '3. Quote the user\'s current instruction verbatim', + '', + 'Present the facts, then retry the same operation.' + ].join('\n'); +} + +function routineBashMsg() { + return [ + '[Fact-Forcing Gate]', + '', + 'Quote the user\'s current instruction verbatim.', + 'Then retry the same operation.' + ].join('\n'); +} + +// --- Output helpers --- + +function deny(reason) { + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason + } + }; + process.stdout.write(JSON.stringify(output)); + process.exit(0); +} + +function allow() { + // Output nothing = allow + process.exit(0); +} + +// --- Main --- + +function main() { + let raw = ''; + try { + raw = fs.readFileSync(0, 'utf8').slice(0, MAX_STDIN); + } catch (_) { + allow(); + return; + } + + let data; + try { + data = JSON.parse(raw); + } catch (_) { + allow(); + return; + } + + const toolName = data.tool_name || ''; + const toolInput = data.tool_input || {}; + + if (toolName === 'Edit' || toolName === 'Write') { + const filePath = toolInput.file_path || ''; + if (!filePath) { + allow(); + return; + } + + // Gate: first action per file + if (!isChecked(filePath)) { + markChecked(filePath); + const msg = toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath); + deny(msg); + return; + } + + allow(); + return; + } + + if (toolName === 'Bash') { + const command = toolInput.command || ''; + + // Destructive commands: always gate + if (DESTRUCTIVE_BASH.test(command)) { + deny(destructiveBashMsg()); + return; + } + + // Routine bash: once per session + if (!isChecked('__bash_session__')) { + markChecked('__bash_session__'); + deny(routineBashMsg()); + return; + } + + allow(); + return; + } + + allow(); +} + +main(); diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md new file mode 100644 index 00000000..4802b64b --- /dev/null +++ b/skills/gateguard/SKILL.md @@ -0,0 +1,117 @@ +--- +name: gateguard +description: Fact-forcing gate that blocks Edit/Write/Bash and demands concrete investigation (importers, data schemas, user instruction) before allowing the action. Measurably improves output quality by +2.25 points vs ungated agents. +origin: community +--- + +# GateGuard — Fact-Forcing Pre-Action Gate + +A PreToolUse hook that forces Claude to investigate before editing. Instead of self-evaluation ("are you sure?"), it demands concrete facts. The act of investigation creates awareness that self-evaluation never did. + +## When to Activate + +- Working on any codebase where file edits affect multiple modules +- Projects with data files that have specific schemas or date formats +- Teams where AI-generated code must match existing patterns +- Any workflow where Claude tends to guess instead of investigating + +## Core Concept + +LLM self-evaluation doesn't work. Ask "did you violate any policies?" and the answer is always "no." This is verified experimentally. + +But asking "list every file that imports this module" forces the LLM to run Grep and Read. The investigation itself creates context that changes the output. + +**Three-stage gate:** + +``` +1. DENY — block the first Edit/Write/Bash attempt +2. FORCE — tell the model exactly which facts to gather +3. ALLOW — permit retry after facts are presented +``` + +No competitor does all three. Most stop at deny. + +## Evidence + +Two independent A/B tests, identical agents, same task: + +| Task | Gated | Ungated | Gap | +| --- | --- | --- | --- | +| Analytics module | 8.0/10 | 6.5/10 | +1.5 | +| Webhook validator | 10.0/10 | 7.0/10 | +3.0 | +| **Average** | **9.0** | **6.75** | **+2.25** | + +Both agents produce code that runs and passes tests. The difference is design depth. + +## Gate Types + +### Edit Gate (first edit per file) + +``` +Before editing {file_path}, present these facts: + +1. List ALL files that import/require this file (use Grep) +2. List the public functions/classes affected by this change +3. If this file reads/writes data files, cat one real record + and show actual field names, structure, and date format +4. Quote the user's current instruction verbatim +``` + +### Write Gate (first new file creation) + +``` +Before creating {file_path}, present these facts: + +1. Name the file(s) and line(s) that will call this new file +2. Confirm no existing file serves the same purpose (use Glob) +3. If this file reads/writes data files, cat one real record +4. Quote the user's current instruction verbatim +``` + +### Destructive Bash Gate (every destructive command) + +Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc. + +``` +1. List all files/data this command will modify or delete +2. Write a one-line rollback procedure +3. Quote the user's current instruction verbatim +``` + +### Routine Bash Gate (once per session) + +``` +Quote the user's current instruction verbatim. +``` + +## Quick Start + +### Option A: Use the ECC hook (zero install) + +The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json. + +### Option B: Full package with config + +```bash +pip install gateguard-ai +gateguard init +``` + +This adds `.gateguard.yml` for per-project configuration (custom messages, ignore paths, gate toggles). + +## Anti-Patterns + +- **Don't use self-evaluation instead.** "Are you sure?" always gets "yes." This is experimentally verified. +- **Don't skip the data schema check.** Both A/B test agents assumed ISO-8601 dates when real data used `%Y/%m/%d %H:%M`. Checking one real record prevents this entire class of bugs. +- **Don't gate every single Bash command.** Routine bash gates once per session. Destructive bash gates every time. This balance avoids slowdown while catching real risks. + +## Best Practices + +- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality. +- Customize gate messages for your domain. If your project has specific conventions, add them to the gate prompts. +- Use `.gateguard.yml` to ignore paths like `.venv/`, `node_modules/`, `.git/`. + +## Related Skills + +- `safety-guard` — Runtime safety checks (complementary, not overlapping) +- `code-reviewer` — Post-edit review (GateGuard is pre-edit investigation)