# Tool Call Permission Gate

> A second opinion on every tool call

- Skill: `mrmps/tool-call-permission-gate` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mrmps/tool-call-permission-gate`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mrmps/tool-call-permission-gate/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: mrmps (https://skillmd.com/u/mrmps)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/mrmps/tool-call-permission-gate

---


# 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.

```js
#!/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.

