SLOPSHOPPER

enforce-cli-tools

No description.

newguard
A shopper browsing a rack in a slop shop
README

enforce-cli-tools

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:

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Bash returns { deny }CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pi and omppi.ts: tool_call on bash returns { block, reason }extensions in dot_pi/agent/modify_settings.json and dot_omp/private_agent/private_config.yml
Codexcommand.ts: exit code 2 with the message on stderrPreToolUse 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.

What it checks

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.

Requirements

  • Claude Code: version 2.1.296 or later, the oldest version tested.
  • pi and omp: none. They load pi.ts in their own runtime.
  • Codex: Node 23.6 or later on 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.

How it works

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:

  1. It splits the line into commands at &, |, ;, 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.
  2. In each command, it steps over 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.
  3. It removes a leading path from the next word and compares the word with the rules.
  4. It reports only the first violation. One clear message is better than a list, and the fix runs the hook again.

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

Gaps

A heredoc with a quoted delimiter (<<'EOF', <<"EOF", <<\EOF) runs nothing, so the hook ignores its body. Three gaps remain:

  • An unquoted heredoc body can run $(...), 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.
  • A banned tool inside a quoted span is invisible, so sh -c 'npm i' passes.
  • A wrapper flag value that is a word counts as the command. 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.

What the model sees

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:

  • Claude Code: the mod returns { deny: <message> }.
  • pi and omp: pi.ts returns { block: true, reason: <message> }.
  • Codex: 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.

Settings

Rules

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.

Allow a tool in one repo

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:

  • Claude Code: $.session.root().
  • pi and omp: the working directory of the agent.
  • Codex: CLAUDE_PROJECT_DIR, else the payload cwd, else the working directory of command.ts.

Allow a tool for one session

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.

Turn the hook off

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.

Failure behavior

The hook fails open in every agent, so a broken hook never blocks work:

  • Claude Code: Claude Code skips a mod hook that throws, and the call runs.
  • pi and omp: pi.ts catches its own errors, because pi blocks a call when a handler throws.
  • Codex: 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.
  • Missing rules: if the rules file is missing or empty, the hook allows everything, so a half-deployed config never blocks work.

Testing

test/check.ts checks the matcher and the three adapters. See docs/testing.md.

Source 2 files
hooks/register.ts 22 lines
1/**
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};
22
core.ts 160 lines
1/**
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