No description.

Blocks a shell command that runs a banned CLI tool and tells the model which tool to use instead. The line in rules/tools.md makes blocks rare, and the hook catches the cases where the model misses that instruction.
One matcher, core.ts, serves four agents through three adapters:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts, a mod: tool.call on Bash returns { deny } | CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi and omp | pi.ts: tool_call on bash returns { block, reason } | extensions in dot_pi/agent/modify_settings.json and dot_omp/private_agent/private_config.yml |
| Codex | command.ts: exit code 2 with the message on stderr | PreToolUse in dot_codex/modify_hooks.json |
The design behind this layout is in docs/hooks/. The internals of the hook are in docs/implementation.md.
The hook runs before each shell call: Bash in Claude Code and Codex, and bash in pi and omp. It blocks the call when a banned tool is in command position. The shipped rules ban npm and yarn, with pnpm as the replacement.
The rules are data, not code. To ban another tool, add one line to rules. See Rules.
pi.ts in their own runtime.PATH. This version runs .ts files without a flag.Codex runs a hook only after you trust its entry. After the change from hook.sh to command.ts, trust the hooks again in the Codex TUI, on the host and in the devcontainer. See docs/agents/codex.md.
The hook checks only the command position: the first word of each command that the line runs. A banned name in an argument or in text does not count. For each Bash call, the hook does these steps:
&, |, ;, and newlines. It also splits at the brackets of a subshell ( ), a group { ...; }, a substitution $( ), and backticks, so the command inside them counts wherever it sits in the line. A separator inside a quoted string does not split.VAR=value assignments and these wrappers: sudo, env, command, builtin, exec, nohup, time, timeout, nice, stdbuf, xargs. After a wrapper, it also steps over each word that starts with - or a digit, such as 30 in timeout 30.These commands block:
npm i # plain invocation
sudo npm ci # wrapper skipped
CI=1 npm run build # env assignment skipped
timeout 30 npm ci # wrapper argument skipped too
/usr/local/bin/npm i # path stripped
cd web && yarn add x # second segment
cd web
npm i # second line
(cd web && npm i) # subshell
{ npm i; } # group
echo $(npm bin) # substitution, even as an argument
These commands pass, because the banned name is never in command position:
rg npm
git commit -m "switch to npm"
git commit -m "fix; npm bump" # separator inside a quoted string
echo "a && npm i"
cat package-lock.json
npmrc-check
A heredoc with a quoted delimiter (<<'EOF', <<"EOF", <<\EOF) runs nothing, so the hook ignores its body. Three gaps remain:
$(...), so the hook still splits it. A separator in its text can start a false command and block the call. The overrides clear that case.sh -c 'npm i' passes.sudo -u root npm i and timeout -s KILL 30 npm ci pass, because the hook reads root and KILL as the command.A real shell-word parser is the upgrade path. The heredoc gap and the quoted-span gap are not worth one yet.
A clean command produces no output. A block gives the model this message, here for npm i in /path/to/repo:
Blocked: `npm` is not allowed here, use `pnpm` instead.
suggested: pnpm i
Allow it anyway with one of:
* add "npm" to /path/to/repo/.agent-allow-cli-tools (persistent, per-repo),
and keep it out of git: echo '.agent-allow-*' >> "$(git -C "/path/to/repo" rev-parse --path-format=absolute --git-path info/exclude)"
* set AGENT_ALLOW_CLI_TOOLS=npm (one-off, colon-separated)
The suggested line swaps the first whole-word match of the banned tool for the replacement, so the model can retry in one turn. A multi-line command gets no suggestion, because the model already has it.
Each adapter sends the message in its own way:
{ deny: <message> }.pi.ts returns { block: true, reason: <message> }.command.ts writes the message to stderr and exits 2.command.ts uses these exit codes:
0: the call runs. No output.1: the payload is not valid JSON. The call runs, because only exit code 2 blocks.2: the call is blocked. The message is on stderr.A clean command costs zero tokens, and a block costs about 120 tokens for the message. See docs/research.md for the cost per call and why the hook blocks and does not rewrite the command.
rules holds one <banned> <replacement> pair on each line. The hook ignores blank lines and lines that start with #:
npm pnpm
yarn pnpm
To enforce another tool, add a line. The replacement takes the rest of the line, so it can be several words: npx pnpm dlx blocks npx create-vite app and suggests pnpm dlx create-vite app.
Create .agent-allow-cli-tools in the repo root, with one tool on each line. The hook ignores blank lines and text after #:
# .agent-allow-cli-tools
npm # this client's repo is npm-only, package-lock.json is committed
The file stays across sessions. Use it for a repo that does not use pnpm. The exemption is for you, the user of this hook, so keep the file out of git with the exclude file of the repo, not .gitignore.
This command adds the exclude line. One line covers the allow files of both guards, enforce-cli-tools and block-secret-commits, and all worktrees share the exclude file:
echo '.agent-allow-*' >> "$(git rev-parse --path-format=absolute --git-path info/exclude)"
The hook reads the file from the project root of the session:
$.session.root().CLAUDE_PROJECT_DIR, else the payload cwd, else the working directory of command.ts.Set AGENT_ALLOW_CLI_TOOLS to the tools to exempt, separated by whitespace or colons:
AGENT_ALLOW_CLI_TOOLS=npm:yarn
Use it for a one-off, or for a session that must leave no trace in the repo.
The allow file and the variable add to each other, and each names the tools that stay allowed. An exemption for npm keeps every other rule, so neither override turns the whole hook off.
The hook does not read AGENT_HOOKS_DISABLE, so /toggle-hook cannot turn it off. To turn it off in one agent, remove its entry from the file in the "Loaded by" column of the table above.
The hook fails open in every agent, so a broken hook never blocks work:
pi.ts catches its own errors, because pi blocks a call when a handler throws.command.ts exits 1 on a bad payload, and the call runs. The Codex entry starts with command -v node || exit 0, so a machine without Node is not blocked.rules file is missing or empty, the hook allows everything, so a half-deployed config never blocks work.test/check.ts checks the matcher and the three adapters. See docs/testing.md.
hooks/register.ts 22 lines1/**
2 * Claude Code mod: denies a Bash call that runs a banned tool, with the block
3 * message as the reason. Fails open: a hook that throws is skipped and the
4 * call runs, as the command hook did.
5 */
6
7import type { Register } from "claude-code";
8import { allowList, blockMessage, parseRules, violation } from "../core.ts";
9
10export const register: Register = (on) => {
11 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
12 const root = await $.session.root();
13 // The engine only takes $ calls spelled at the call site, so each read is
14 // inline. A missing file reads as "", which allows.
15 const rules = await $.fs.read(`${$.plugin.root}/rules`).catch(() => "");
16 const allowFile = await $.fs.read(`${root}/.agent-allow-cli-tools`).catch(() => "");
17 const allowed = allowList(allowFile, await $.env.get("AGENT_ALLOW_CLI_TOOLS"));
18 const rule = violation(e.command, parseRules(rules), allowed);
19 return rule ? { deny: blockMessage(e.command, rule, root) } : next(e);
20 });
21};
22core.ts 160 lines1/**
2 * The enforce-cli-tools matcher: finds a banned CLI tool in command position
3 * and builds the block message. Pure: the adapters read the rules file, the
4 * allowlist file and the environment, and pass the text in.
5 *
6 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp),
7 * command.ts (Codex command hook).
8 */
9
10export interface Rule {
11 tool: string;
12 replacement: string;
13}
14
15/**
16 * The text of `rules` -> its rules. One "<banned> <replacement>" pair per
17 * line, blank and # lines skipped. The replacement takes the rest of the
18 * line, so "pnpm dlx" survives whole.
19 */
20export function parseRules(text: string): Rule[] {
21 const rules: Rule[] = [];
22 for (const line of text.split("\n")) {
23 const match = /^\s*(\S+)\s+(.*\S)\s*$/.exec(line);
24 if (match?.[1] && match[2] && !match[1].startsWith("#"))
25 rules.push({ tool: match[1], replacement: match[2] });
26 }
27 return rules;
28}
29
30/**
31 * The tools this repo or session may still use: the first word of each line
32 * of `.agent-allow-cli-tools`, and the AGENT_ALLOW_CLI_TOOLS value split on
33 * whitespace or colons.
34 */
35export function allowList(file: string, env = ""): Set<string> {
36 const fromFile = file
37 .split("\n")
38 .map((line) => line.trim().split(/\s+/)[0] ?? "")
39 .filter((tool) => tool && !tool.startsWith("#"));
40 const fromEnv = env.split(/[\s:]+/).filter(Boolean);
41 return new Set([...fromFile, ...fromEnv]);
42}
43
44/**
45 * The command line without the body of any heredoc whose delimiter is quoted
46 * (<<'EOF', <<"EOF", <<\EOF). The shell runs nothing in such a body, so a
47 * banned name there is text. shortcut: one heredoc per line; a second one on
48 * the same line keeps its body, which can only over-block.
49 */
50export function stripQuotedHeredocs(line: string): string {
51 const opener = /<<(-?)\s*['"\\]+([A-Za-z_][A-Za-z0-9_]*)/;
52 const kept: string[] = [];
53 let delimiter = "";
54 let dash = false;
55 for (const row of line.split("\n")) {
56 if (delimiter) {
57 // <<- also strips leading tabs from the closing line.
58 if ((dash ? row.replace(/^\t+/, "") : row) === delimiter) delimiter = "";
59 continue;
60 }
61 kept.push(row);
62 const match = opener.exec(row);
63 if (match?.[2]) {
64 dash = match[1] === "-";
65 delimiter = match[2];
66 }
67 }
68 return kept.join("\n");
69}
70
71const WRAPPERS = new Set([
72 "sudo",
73 "env",
74 "command",
75 "builtin",
76 "exec",
77 "nohup",
78 "time",
79 "timeout",
80 "nice",
81 "stdbuf",
82 "xargs",
83]);
84
85/**
86 * The command each segment of the line runs, in order. Only these positions
87 * are checked, so `rg npm` and `git commit -m "drop npm"` stay clean.
88 */
89export function commandsRun(line: string): string[] {
90 // Drop quoted-delimiter heredoc bodies before the quote pass: an apostrophe
91 // in the text would otherwise pair with a quote lines away. Then blank
92 // quoted spans, so a separator inside a string does not split. shortcut: an
93 // unquoted heredoc body is still split naively; it can run $(...), so
94 // dropping it would let a real call through.
95 const text = stripQuotedHeredocs(line).replace(/'[^']*'|"[^"]*"/g, "q");
96 const commands: string[] = [];
97 // Brackets split too: whatever runs inside $(...), backticks or { ...; } is
98 // its own command wherever it sits.
99 for (const segment of text.split(/[&|;(){}`\n]/)) {
100 let wrapped = false;
101 for (const word of segment.split(/[ \t]+/).filter(Boolean)) {
102 // Step over what precedes the real command: `sudo npm`, `CI=1 npm`.
103 if (word.includes("=") || WRAPPERS.has(word)) {
104 wrapped = true;
105 continue;
106 }
107 // Then the wrapper's own flags and values (`timeout 30 npm`). The first
108 // word of a real command is never a flag or a bare number.
109 if (wrapped && /^[-0-9]/.test(word)) continue;
110 const name = word.slice(word.lastIndexOf("/") + 1); // /usr/bin/npm -> npm
111 if (!name) continue; // a bare trailing slash is not a command
112 commands.push(name);
113 break;
114 }
115 }
116 return commands;
117}
118
119/** The first banned, not allowlisted tool the line runs, or undefined. */
120export function violation(line: string, rules: Rule[], allowed: Set<string>): Rule | undefined {
121 for (const command of commandsRun(line))
122 for (const rule of rules) if (command === rule.tool && !allowed.has(command)) return rule;
123 return undefined;
124}
125
126/**
127 * The line with the first whole-word `tool` swapped for `replacement`, so the
128 * model can retry in one turn. Empty for a multi-line command: the model
129 * already has it, and a script-long hint only costs context.
130 */
131export function suggest(line: string, tool: string, replacement: string) {
132 if (line.includes("\n")) return "";
133 const escaped = tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
134 const edge = "[^A-Za-z0-9_.-]";
135 const word = new RegExp(`(^|${edge})${escaped}(${edge}|$)`);
136 if (!word.test(line)) return "";
137 return line.replace(word, (_, before, after) => before + replacement + after);
138}
139
140/**
141 * The command that adds the allow files to the exclude file of the repo, which
142 * all worktrees share. Not .gitignore: the list is for the user of this hook.
143 */
144const excludeCommand = (root: string) =>
145 `echo '.agent-allow-*' >> "$(git -C "${root}" rev-parse --path-format=absolute --git-path info/exclude)"`;
146
147/** The reason the model reads when the call is blocked. */
148export function blockMessage(line: string, rule: Rule, root: string): string {
149 const suggested = suggest(line, rule.tool, rule.replacement);
150 return [
151 `Blocked: \`${rule.tool}\` is not allowed here, use \`${rule.replacement}\` instead.`,
152 ...(suggested ? [` suggested: ${suggested}`] : []),
153 "",
154 "Allow it anyway with one of:",
155 ` * add "${rule.tool}" to ${root}/.agent-allow-cli-tools (persistent, per-repo),`,
156 ` and keep it out of git: ${excludeCommand(root)}`,
157 ` * set AGENT_ALLOW_CLI_TOOLS=${rule.tool} (one-off, colon-separated)`,
158 ].join("\n");
159}
160