SLOPSHOPPER

approve-blocked

When the auto-mode classifier blocks a Bash command, ask you Run or Don't run instead of having Claude tell you to run it with !

newguardtoast
v0.2.0MITupdated 2026-10-20blackironj/claude-mods/approve-blocked
A shopper browsing a rack in a slop shop
README

claude-mods

Six Claude Code mods that guard risky actions and cut down on babysitting: confirm production changes, keep secrets out of the model's context, start branches from the right base, hold Claude to "just look" when you ask for it, tell you when CI finishes, and let you approve a command auto mode blocked with one button.

They started as one person's setup and stay opinionated, but every personal value is a plugin option with a sensible default.

[!WARNING] Mods run inside Claude Code with your permissions and are not sandboxed. These guards parse shell commands heuristically: they catch the common forms Claude writes, not every way a command can be spelled. Treat them as a safety net for mistakes, not a security boundary. Read the source before installing.

Mods

ModWhat it does
prod-guardHolds mutating kubectl/helm commands against production contexts and aws iam writes until you confirm the exact command; a confirmed call skips the auto-mode classifier, and one the classifier blocks anyway goes into your prompt box as ! …. Refuses mutating kubectl without an explicit --context. Judges the command inside aws-vault exec <profile> -- … by the same rules, and puts aws-vault login or a bare aws-vault exec <profile> (an interactive shell) in your prompt box as ! … instead of running it (left out when the box holds a draft, so the draft is kept).
secret-guardBlocks Bash and Read access to .env files (not .env.example) and to ~/.claude.json. Masks JWTs, AWS/GitHub/Slack/Anthropic/OpenAI/Google keys and private key blocks in Bash and Read output before Claude sees them.
branch-baseRewrites git checkout -b, git switch -c and git worktree add with no start point to start from origin/<base> after a fetch, and adds --base <base> to gh pr create. When a repo has no such branch, asks you which base to use (remembered per repo until reload).
read-only-lockAfter a look-only prompt ("just investigate", "plan first, don't change anything", or Korean equivalents such as "확인만 해"), blocks file edits and mutating commands and shows a one-line band with an Unlock button. "go ahead", "apply it" or the button unlocks.
deploy-watchAfter git push or gh pr create, polls GitHub Actions for that commit every 30 s; after gh pr merge, for the merge commit GitHub made on the base branch, where deploy workflows run (an auto-merge is followed until it lands); after gh workflow run, for the dispatched workflow. Raises a toast and a notification when the runs finish and sends the result to Claude as a message that starts a turn (a push that started no runs is only noted, without starting one), so Claude reports it and picks up work that was waiting on CI without watching CI itself. /ci opens a pane with the runs.
approve-blockedWhen the auto-mode classifier blocks a Bash command, asks you Run or Don't run in a dialog instead of leaving Claude to tell you to run it with !. Run runs the call again past the classifier (a permissions.deny rule still stands); if the classifier blocks it again, the command goes into your prompt box as ! … so Enter runs it. Don't run tells Claude not to hand the command to you. A call prod-guard already had you confirm is not asked about twice.

Install

Requires Claude Code v2.1.287 or later (mods are on by default). At the prompt of a terminal session:

/plugin install prod-guard --marketplace blackironj/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: secret-guard, branch-base, read-only-lock, deploy-watch, approve-blocked.

From a shell:

claude plugin install prod-guard --marketplace blackironj/claude-mods

Disable or uninstall any mod from the Installed tab in /plugin.

Options

Set them on the install screen, or change them later in /config.

ModOptionDefaultMeaning
prod-guardprodContextsprodComma-separated kube contexts whose mutations need confirmation (also matched against helm --kube-context).
prod-guardrequireContexttrueRefuse mutating kubectl commands without --context.
branch-basebaseBranchdevRemote branch new branches start from and PRs target.

Requirements and limits

  • deploy-watch needs the GitHub CLI signed in; branch-base needs git.
  • Panes, bands and toasts draw in the terminal and the Desktop app's Code tab. Under claude -p and the VS Code chat panel the guards still run but nothing is drawn.
  • read-only-lock decides from keywords, so it can lock or stay unlocked when you didn't mean it to; the band shows when it is on.
  • secret-guard also blocks commands that create a .env (for example cp .env.example .env); run those yourself. Masking covers the token shapes listed above, not arbitrary passwords.
  • branch-base does not affect worktrees Claude Code creates on its own (EnterWorktree).
  • approve-blocked turns every auto-mode block of a Bash command into a question for you, so pressing Run overrides the classifier for that one call: read the command first. Other tools the classifier blocks are left as they are.
  • prod-guard lets a call you confirmed run even where a permission rule or the auto-mode classifier would stop it, and its aws-vault handoff puts a command Claude wrote into your prompt box: a ! command runs in your shell with no permission check, so read it before pressing Enter.
  • prod-guard lets aws-vault exec <profile> -- … run in Claude's shell, which has no terminal for prompts. When the session needs an MFA code or a key-store passphrase, the command fails; run one aws-vault command yourself with ! to refresh the session.

Development

Each mod is a plugin folder (.claude-plugin/plugin.json, hooks/hooks.json, a TypeScript hooks module, tests). There is no build step: Claude Code loads the .ts/.tsx sources directly. See CLAUDE.md for the layout, checks and conventions.

claude plugin validate prod-guard   # what the module hooks and calls, and anything the engine would refuse
claude plugin test prod-guard       # runs prod-guard/tests/*.test.ts against the engine
claude --plugin-dir ./prod-guard    # a session with your working copy loaded and hot-reloaded

License

MIT

Source 2 files
hooks/register.ts 70 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { classifierBlock } from './blocked'
4
5const MAX_SHOWN = 600
6
7/** Puts `! <command>` in an empty prompt box and tells the model to hand the command to the user. */
8async function handOff($: EngineInterface, command: string, why: string) {
9  // Fill only an empty prompt box: replacing would throw away what the user was typing.
10  const isFilled = (await $.prompt.read()).text.trim() === '' && (await $.prompt.fill({ text: `! ${command}`, mode: 'replace' })).isFilled
11  $.ui.toast(isFilled ? 'approve-blocked: press Enter in your prompt box to run the command' : 'approve-blocked: run the command yourself with !')
12  return {
13    deny:
14      `approve-blocked: ${why}, so it was handed to the user` +
15      (isFilled ? ' (placed in their prompt box as a `!` command)' : '') +
16      '. Do not retry or work around it. End your turn now with no further tool calls, and show the user ' +
17      'this exact line to run, in a code block, `!` included:\n\n' +
18      `! ${command}\n\n` +
19      'Its output lands in this conversation when they run it.',
20  }
21}
22
23export const register: Register = on => {
24  // Commands the user approved after a block, while their retry runs, so tool.check can let them past the classifier.
25  const approved = new Set<string>()
26
27  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28    const result = await next(e)
29    const reason = classifierBlock(result)
30    if (reason === undefined) return result
31
32    // Another guard (prod-guard) already had the user approve this call: don't ask twice.
33    if ((await $.tool.check({ tool: 'Bash', input: { command: e.command } })).decision === 'allow') {
34      return handOff($, e.command, `auto mode blocked it although the user had approved it (${reason})`)
35    }
36
37    const shown = e.command.length > MAX_SHOWN ? `${e.command.slice(0, MAX_SHOWN)}…` : e.command
38    const answer = await $.ui.ask(`Auto mode blocked this command (${reason}). Run it anyway?\n\n${shown}`, {
39      header: 'Blocked',
40      options: ['Run', "Don't run"],
41    })
42    if (answer !== 'Run') {
43      return {
44        deny:
45          `approve-blocked: auto mode blocked the command and the user chose not to run it${answer === "Don't run" ? '' : `: ${answer}`}. ` +
46          'Do not ask them to run it themselves with `!`; go on without it, or ask what to do instead.',
47      }
48    }
49    approved.add(e.command)
50    try {
51      const retry = await next(e)
52      const again = classifierBlock(retry)
53      return again === undefined ? retry : await handOff($, e.command, `auto mode blocked it again after the user chose Run (${again})`)
54    } finally {
55      approved.delete(e.command)
56    }
57  }).catch(($, e, next) =>
58    next.called ? next(e) : { deny: 'approve-blocked: the hook failed; the command did not run.' },
59  )
60
61  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
62    const verdict = await next(e)
63    const command = (e.input as { command?: string }).command ?? ''
64    // Only the classifier's question is answered for the user: a settings deny rule still stands.
65    return approved.has(command) && verdict.decision === 'ask'
66      ? { decision: 'allow', reason: 'approve-blocked: approved by the user after an auto-mode block' }
67      : verdict
68  })
69}
70
hooks/blocked.ts 11 lines
1import type { ToolCallResult } from 'claude-code'
2
3const CLASSIFIER_BLOCK = /denied by the Claude Code auto mode classifier/
4
5/** The classifier's reason when a call's result is an auto-mode block, else undefined. */
6export function classifierBlock(result: ToolCallResult): string | undefined {
7  const text = result.deny ?? (result.isError === true ? result.text : undefined)
8  if (text === undefined || !CLASSIFIER_BLOCK.test(text)) return undefined
9  return /Reason: (.+?)\. If you have/s.exec(text)?.[1] ?? 'no reason given'
10}
11