SLOPSHOPPER

gates

Gates that refuse risky Bash commands (pkill, kill by pattern, git add -A, git commit -a) and tell Claude why and what to run instead.

newguard
v0.1.0MITupdated 2026-10-26astrosteveo/astro-plugins/plugins/gates
A shopper browsing a rack in a slop shop
README

gates

Gates that refuse risky Bash commands before they run, and tell Claude why and what to run instead. Every gate lives here, so enabling this one plugin turns them all on, in every project and for subagents too.

GateRefusesSays to
pkillany pkillfind the one process with pgrep -f '[g]odot' and kill its PID
kill by patternkill with pgrep -f or `ps \grep`, unless a letter of the pattern is bracketedbracket a letter, so the pattern can't match the Bash tool's own shell
stage everythinggit add -A, --all, -u, --update or .check git status and stage files by name
commit everythinggit commit -a or --allstage by name, then commit without -a

A gate reads each line of a command on its own, as a shell does, and catches a command after cd … && or ;, and git -C dir. It reads text too, so a commit message that names a refused command, such as one describing these gates, trips it. Every refusal says the way round: put the text in a file and pass the file, git commit -F FILE.

A gate that fails refuses the command: a broken gate never waves one through. Claude Code skips a failed hook and runs the tool, so the module's .catch answers with a refusal in its place.

Adding a gate

Add an entry to GATES in hooks/register.ts: a refuses test on the command and the reason Claude reads. Write the reason as what goes wrong and what to run instead, so Claude picks a safe form rather than guessing. Add its cases to tests/gates.test.ts, the commands it refuses and the near misses it lets run.

claude plugin validate plugins/gates
claude plugin test plugins/gates
Source 1 files
hooks/register.ts 62 lines
1import type { Register } from 'claude-code'
2
3// A gate refuses a Bash command it matches, with the reason Claude reads: what goes wrong and
4// what to run instead. Each pattern is tested line by line, as a shell reads a script.
5type Gate = { refuses: (command: string) => boolean; reason: string }
6
7const anyLine = (command: string, pattern: RegExp) => command.split('\n').some(line => pattern.test(line))
8
9// A word on its own: not part of a longer name such as pkill-style.
10const word = (name: string) => new RegExp(`(^|[^A-Za-z0-9_-])${name}([^A-Za-z0-9_-]|$)`)
11
12// git, perhaps with -C DIR or -c KEY=VALUE, then a subcommand; the rest of that one command, up
13// to the next ; & or |; and where an argument ends.
14const GIT = String.raw`(^|[^A-Za-z0-9_./-])git(\s+-[Cc]\s+\S+)*\s+`
15const REST = String.raw`(\s+[^;&|]*)?`
16const END = String.raw`(\s|[;&|]|$)`
17
18const GATES: Gate[] = [
19  {
20    refuses: command => anyLine(command, word('pkill')),
21    reason:
22      "pkill kills every process that matches, the user's own too (their editor, other sessions), and pkill -f also matches this Bash tool's own shell. Kill the one process you started by its PID: find it with pgrep -f '[g]odot' (a bracketed letter keeps the shell out), then kill PID.",
23  },
24  {
25    // The Bash tool runs each command as `<shell> -c '<command>'`, so a pattern found by
26    // command line matches that shell too, and kill takes it down. A bracketed letter
27    // ('[g]odot') matches the process but not the shell.
28    refuses: command =>
29      anyLine(command, word('kill')) &&
30      !anyLine(command, /\[[^\]]+\]/) &&
31      (anyLine(command, /(^|[^A-Za-z0-9_-])pgrep[^|;&]*\s(-[A-Za-z0-9]*f|--full)/) ||
32        anyLine(command, /(^|[^A-Za-z0-9_-])ps(\s[^|]*)?\|.*grep/)),
33    reason:
34      "This kills its own shell: the Bash tool runs the command as `<shell> -c <command>`, so the pgrep -f or grep pattern matches that shell too. Bracket one letter of the pattern so it still matches the process but not the shell, e.g. pgrep -f '[g]odot' or grep '[g]odot'.",
35  },
36  {
37    refuses: command =>
38      anyLine(command, new RegExp(`${GIT}add${REST}\\s(-[A-Za-z]*[Au][A-Za-z]*|--all|--update|\\.)${END}`)),
39    reason:
40      "git add -A, --all, -u, --update or . stages every change in the tree, including files this task didn't make (captures, scratch output, another session's edits). Check git status, then stage the files this change made by name: git add path/to/file other/file.",
41  },
42  {
43    refuses: command => anyLine(command, new RegExp(`${GIT}commit${REST}\\s(-[A-Za-z]*a[A-Za-z]*|--all)${END}`)),
44    reason:
45      "git commit -a or --all commits every tracked change, including ones this task didn't make. Stage the files this change made by name with git add, then git commit without -a.",
46  },
47]
48
49// A gate reads text too: a commit message that names a command it refuses trips it.
50const MENTION =
51  ' If the command only mentions this in text, such as a commit message, put that text in a file and pass the file (git commit -F FILE).'
52
53export const register: Register = on => {
54  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
55    const gate = GATES.find(gate => gate.refuses(e.command))
56    return gate ? { deny: gate.reason + MENTION } : next(e)
57  }).catch(($, e, next) =>
58    // A gate that fails refuses: a broken gate must not wave a command through.
59    next.called ? next(e) : { deny: `${$.plugin.name}: a gate failed, so the command was refused.` },
60  )
61}
62