A second opinion on every tool call
An allowlist matches strings: rm -rf build and rm -fr ./build are one act
in two spellings. Classify what the call would do instead, beside
the decision the person is already making. The gate does two things and no
more: it refuses a call it is sure is destructive, and it attaches a reason to
the question the person gets anyway. An automatic yes is opt-in, below.
What leaves the machine
One string per call: the tool name and its arguments, redacted, cut to 2,000 characters. No file contents, no environment, no repository name. Redaction replaces bearer and basic headers, fields and flags named for a key, token, secret or password, opaque runs of 40 characters or more, hex digests, mail addresses and query strings. A call still holding more than two placeholders is never sent; it goes to the person.
in Bash: curl -H "authorization: Bearer tok_9f2a" https://api.example.com/orders?key=9f2a
out Bash: curl -H "authorization: Bearer [redacted]" https://api.example.com/orders?[redacted]
tool gate: ask - read only, changes nothing 0.99
in Bash: deploy --token tok_9f2a --secret s3cr3t --user ops@example.com
out tool gate: ask - mostly redacted, nothing was sent
The service states that it stores no input text and forwards it to the model provider answering the request: https://classifier.dev/privacy. Read that against your policy. If the policy keeps command lines off the network, keep them off: ask the agent's own model the same labels and apply the same rule. Any classifier returning a calibrated confidence fits here; this one needs no account.
The hook
Four labels naming consequence, not category, spelled out because labels are
read as language: p0 classifies worse than destructive or irreversible.
The hook reads a PreToolUse JSON object or a bare command on stdin and exits
0 allow, 1 ask, 2 block.
#!/usr/bin/env node
const LABELS = ["read only, changes nothing", "writes only inside this repository",
"changes shared state outside this machine", "destructive or irreversible"];
const DENY_AT = 0.9;
const AUTO_ALLOW = process.argv.includes("--auto-allow"); // off by default
const REDACT = [
[/\b(bearer|basic)\s+[^\s"']+/gi, "$1 [redacted]"],
[/(--?[\w-]*(?:key|token|secret|password|pwd)[\w-]*)[= ]+"?[^\s",}]+/gi, "$1 [redacted]"],
[/([\w.-]*(?:key|token|secret|password|pwd)[\w.-]*)\s*[=:]\s*"?[^\s",}]+/gi, "$1=[redacted]"],
[/\b[A-Za-z0-9_-]{40,}\b/g, "[redacted]"],
[/\b[0-9a-f]{32,}\b/gi, "[redacted]"],
[/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[redacted]"],
[/([?&])[^\s"'`]+/g, "$1[redacted]"],
];
const redact = (s) => REDACT.reduce((t, [re, to]) => t.replace(re, to), s);
async function verdict(call) {
const text = redact(call).slice(0, 2000);
if ((text.match(/\[redacted\]/g) || []).length > 2)
return ["ask", "mostly redacted, nothing was sent"];
const r = await fetch("https://classifier.dev/v1/classify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ labels: LABELS, inputs: [text],
instructions: "A coding agent proposes this tool call. Judge only what running it would do." }),
signal: AbortSignal.timeout(4000),
});
if (!r.ok) throw new Error(`classifier ${r.status}`);
const [res] = (await r.json()).results;
const c = res.confidence ?? 0, why = `${res.label} ${c}`;
if (res.label === LABELS[3] && c >= DENY_AT) return ["deny", why];
if (AUTO_ALLOW && c >= DENY_AT && LABELS.indexOf(res.label) < 2) return ["allow", why];
return ["ask", why];
}
(async () => {
let raw = ""; for await (const c of process.stdin) raw += c;
let hook = null; try { hook = JSON.parse(raw); } catch {}
const call = hook?.tool_name
? `${hook.tool_name}: ${JSON.stringify(hook.tool_input)}` : raw.trim();
let d = "ask", why = "gate unavailable";
try { [d, why] = await verdict(call); }
catch (e) { why = `gate unavailable, ${e.message}`; }
const out = { hookEventName: "PreToolUse", permissionDecision: d, permissionDecisionReason: `tool gate: ${why}` };
if (hook) return console.log(JSON.stringify({ hookSpecificOutput: out }));
console.error(`tool gate: ${d} - ${why}`);
process.exit(d === "allow" ? 0 : d === "ask" ? 1 : 2);
})();
Register it in .claude/settings.json under hooks.PreToolUse, matcher
Bash|Edit|Write, command node .claude/hooks/gate.js. Codex, Cursor and
OpenCode pass text: the stdin path.
Thresholds
The classifier returns labels, scores and a calibrated confidence, and writes
no prose. Refuse at 0.9 and above on destructive or irreversible, where
answers were right 82 to 92% of the time: rm -rf build dist and untracked files was refused at 0.94, exit 2. Everything else stays a question
carrying the label and the number: from 0.5 to 0.9 that number is the warning,
below 0.5 the gate does not know. A network failure, a 429 or a held-back
command is a question too.
--auto-allow turns a read-only or in-repository answer at 0.9 and above into
a yes. Do not start there: run the gate for a week, log its lines, read what
it would have allowed, and set the flag only if that log is dull. npm test
measured 0.75 and psql prod -c "DROP TABLE orders" 0.66, so that week is not
a formality, and no threshold makes this a security boundary. Keep hard deny
rules for the acts you never want, and send one command per call: a chained
line gets a single label.
When not to use
Skip it when the policy keeps command lines off the network, when a static deny list already covers the repository, or when the agent runs in a throwaway container.