Block Bash access to .env and Claude config files; mask tokens and keys in Bash and Read output

Five 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, and tell you when CI finishes.
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.
| Mod | What it does |
|---|---|
prod-guard | Holds 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. 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. |
secret-guard | Blocks 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-base | Rewrites 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-lock | After 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-watch | After 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 adds the result to the conversation, so Claude knows without watching CI itself. /ci opens a pane with the runs. |
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.
From a shell:
claude plugin install prod-guard --marketplace blackironj/claude-mods
Disable or uninstall any mod from the Installed tab in /plugin.
Set them on the install screen, or change them later in /config.
| Mod | Option | Default | Meaning |
|---|---|---|---|
prod-guard | prodContexts | prod | Comma-separated kube contexts whose mutations need confirmation (also matched against helm --kube-context). |
prod-guard | requireContext | true | Refuse mutating kubectl commands without --context. |
branch-base | baseBranch | dev | Remote branch new branches start from and PRs target. |
deploy-watch needs the GitHub CLI signed in; branch-base needs git.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).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.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
hooks/register.ts 40 lines1import type { Register } from 'claude-code'
2
3import { blockedReason, isEnvFile, mask } from './secrets'
4
5const WITHHELD = 'secret-guard: masking failed, so the output was withheld.'
6
7export const register: Register = on => {
8 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
9 const why = blockedReason(e.command)
10 if (why !== undefined) {
11 return { deny: `secret-guard: blocked because ${why}. Refer to variables by name, or ask the user to check the value.` }
12 }
13
14 const ran = await next(e)
15 if (ran.deny !== undefined) return ran
16 if (ran.isError === true) {
17 const masked = mask(ran.text ?? '')
18 return masked.count === 0 ? ran : { deny: `the command failed; output with ${masked.count} secret(s) masked:\n${masked.text}` }
19 }
20
21 const out = mask(ran.result.stdout)
22 const err = mask(ran.result.stderr)
23 if (out.count + err.count === 0) return ran
24 $.ui.toast(`secret-guard: masked ${out.count + err.count} secret(s) in Bash output`)
25 return { result: { ...ran.result, stdout: out.text, stderr: err.text }, context: ran.context }
26 }).catch(($, e, next) => (next.called ? { deny: WITHHELD } : { deny: 'secret-guard: the guard failed; the command did not run.' }))
27
28 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
29 if (isEnvFile(e.file_path)) {
30 return { deny: 'secret-guard: reading .env files is blocked. Refer to variables by name, or ask the user to check the value.' }
31 }
32 const ran = await next(e)
33 if (ran.deny !== undefined || ran.isError === true || ran.result.type !== 'text') return ran
34 const masked = mask(ran.result.file.content)
35 if (masked.count === 0) return ran
36 $.ui.toast(`secret-guard: masked ${masked.count} secret(s) in ${ran.result.file.filePath}`)
37 return { result: { ...ran.result, file: { ...ran.result.file, content: masked.text } }, context: ran.context }
38 }).catch(($, e, next) => (next.called ? { deny: WITHHELD } : { deny: 'secret-guard: the guard failed; the file was not read.' }))
39}
40hooks/secrets.ts 49 lines1// Token shapes worth masking: each has a fixed prefix or structure, so dev
2// placeholders such as DEV_ADMIN_PASSWORD=changeme are left alone.
3const PATTERNS: readonly [string, RegExp][] = [
4 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
5 ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
6 ['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
7 ['aws-secret', /(aws_secret_access_key\s*[=:]\s*["']?)[A-Za-z0-9/+=]{40}/gi],
8 ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/g],
9 ['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
10 ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
11 ['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g],
12 ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
13]
14
15/** Masks known token shapes; returns the text and how many were masked. */
16export function mask(text: string): { text: string; count: number } {
17 let count = 0
18 let out = text
19 for (const [name, re] of PATTERNS) {
20 out = out.replace(re, (match: string, prefix?: unknown) => {
21 count++
22 return `${typeof prefix === 'string' ? prefix : ''}[REDACTED:${name}]`
23 })
24 }
25 return { text: out, count }
26}
27
28// `.env`, `.env.local`, `.env.prod`, but not `.env.example` / `.sample` /
29// `.template` / `.dist`, as a word or path component.
30const ENV_FILE = /(?:^|[\s'"=<>:/(])\.env(?:\.(?!example\b|sample\b|template\b|dist\b)[\w-]+)?(?=$|[\s'";|&)<>])/
31const CLAUDE_CONFIG = /(?:~|\$HOME|\/home\/[^/\s]+)\/\.claude(?:\.json|\/config\.json)\b/
32
33// A heredoc with a quoted delimiter (<<'EOF') is literal text, not commands;
34// an unquoted one still expands $(...), so its body stays in the check.
35const LITERAL_HEREDOC = /<<-?\s*(['"])(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g
36
37/** Whether a file path names a .env file (not an example/template). */
38export function isEnvFile(path: string): boolean {
39 return ENV_FILE.test(path)
40}
41
42/** Why a Bash command must not run, or undefined when it may. */
43export function blockedReason(command: string): string | undefined {
44 const code = command.replace(LITERAL_HEREDOC, '')
45 if (ENV_FILE.test(code)) return 'it touches a .env file'
46 if (CLAUDE_CONFIG.test(code)) return 'it touches ~/.claude.json or ~/.claude/config.json'
47 return undefined
48}
49