Confirm production kubectl/helm/IAM mutations before they run (also inside aws-vault exec), require an explicit kube context, and hand interactive aws-vault…

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 68 lines1import type { Register } from 'claude-code'
2
3import { classify, type Policy } from './classify'
4
5const MAX_SHOWN = 600
6
7function policyOf(options: Readonly<Record<string, unknown>>): Policy {
8 const contexts = String(options.prodContexts ?? 'prod')
9 return {
10 prodContexts: contexts.split(',').map(c => c.trim()).filter(c => c !== ''),
11 requireContext: options.requireContext !== false,
12 }
13}
14
15export const register: Register = (on, options) => {
16 const policy = policyOf(options)
17 // Calls the user confirmed, keyed by tool_use_id (or the command when a
18 // call has none), so tool.check can let them past the auto-mode classifier.
19 const confirmed = new Set<string>()
20
21 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
22 const verdict = classify(e.command, policy)
23 const key = e.tool_use_id ?? e.command
24
25 switch (verdict.kind) {
26 case 'pass':
27 return next(e)
28 case 'deny':
29 return { deny: `prod-guard: ${verdict.why}.` }
30 case 'handoff': {
31 const filled = await $.prompt.fill({ text: `! ${e.command}`, mode: 'replace' })
32 $.ui.toast(filled.isFilled ? 'prod-guard: review the command in your prompt box, then press Enter to run it' : 'prod-guard: run the aws-vault command yourself with !')
33 return {
34 deny:
35 `prod-guard: ${verdict.why}, so it was handed to the user` +
36 (filled.isFilled ? ' (placed in their prompt box as a `!` command)' : '') +
37 '. Do not retry it; wait for the user to run it and share the output.',
38 }
39 }
40 case 'confirm': {
41 const shown = e.command.length > MAX_SHOWN ? `${e.command.slice(0, MAX_SHOWN)}…` : e.command
42 const answer = await $.ui.ask(`${verdict.why}. Run this command?\n\n${shown}`, {
43 header: 'prod-guard',
44 options: ['Run', 'Cancel'],
45 })
46 if (answer !== 'Run') {
47 return { deny: `prod-guard: the user declined (${verdict.why})${answer === 'Cancel' ? '' : `: ${answer}`}.` }
48 }
49 confirmed.add(key)
50 try {
51 return await next(e)
52 } finally {
53 confirmed.delete(key)
54 }
55 }
56 }
57 }).catch(($, e, next) =>
58 next.called ? next(e) : { deny: 'prod-guard: the guard failed or the question was dismissed; the command did not run.' },
59 )
60
61 on('tool.check', { tool: 'Bash' }, ($, e, next) => {
62 const command = (e.input as { command?: string }).command ?? ''
63 return confirmed.has(e.tool_use_id ?? command)
64 ? { decision: 'allow', reason: 'prod-guard: confirmed by the user' }
65 : next(e)
66 })
67}
68hooks/classify.ts 99 lines1import { option, positionals, program, segments } from './shell'
2
3export type Policy = {
4 /** Kube contexts whose mutations need confirmation (also helm --kube-context). */
5 prodContexts: readonly string[]
6 /** Refuse kubectl mutations that name no --context. */
7 requireContext: boolean
8}
9
10export const DEFAULT_POLICY: Policy = { prodContexts: ['prod'], requireContext: true }
11
12export type Verdict =
13 | { kind: 'pass' }
14 | { kind: 'confirm'; why: string }
15 | { kind: 'handoff'; why: string }
16 | { kind: 'deny'; why: string }
17
18const KUBECTL_VALUED = [
19 '--context', '-n', '--namespace', '--kubeconfig', '--cluster', '--user', '-s', '--server',
20 '--token', '--as', '-c', '--container', '-o', '--output', '-f', '--filename', '-l', '--selector',
21]
22const KUBECTL_MUTATING = new Set([
23 'apply', 'create', 'delete', 'edit', 'patch', 'replace', 'scale', 'autoscale', 'drain', 'cordon',
24 'uncordon', 'taint', 'label', 'annotate', 'set', 'expose', 'run', 'exec', 'cp', 'debug',
25])
26const ROLLOUT_READS = new Set(['status', 'history'])
27const HELM_MUTATING = new Set(['install', 'upgrade', 'uninstall', 'delete', 'rollback'])
28const IAM_WRITE = /^(create|put|attach|detach|delete|update|add|remove|tag|untag|set|upload|change|reset|enable|deactivate)-/
29
30function kubectl(ws: string[], policy: Policy): Verdict {
31 const [verb = '', sub = ''] = positionals(ws, 1, KUBECTL_VALUED)
32 const isMutating = KUBECTL_MUTATING.has(verb) || (verb === 'rollout' && !ROLLOUT_READS.has(sub))
33 if (!isMutating) return { kind: 'pass' }
34 const context = option(ws, '--context')
35 if (context === undefined) {
36 return policy.requireContext
37 ? { kind: 'deny', why: `kubectl ${verb} has no explicit --context; add --context=<context> as the first argument` }
38 : { kind: 'pass' }
39 }
40 return policy.prodContexts.includes(context)
41 ? { kind: 'confirm', why: `kubectl ${verb}${verb === 'rollout' ? ` ${sub}` : ''} on ${context}` }
42 : { kind: 'pass' }
43}
44
45function helm(ws: string[], policy: Policy): Verdict {
46 if (!HELM_MUTATING.has(ws[1] ?? '')) return { kind: 'pass' }
47 const context = option(ws, '--kube-context')
48 if (context !== undefined && policy.prodContexts.includes(context)) return { kind: 'confirm', why: `helm ${ws[1]} on ${context}` }
49 if (context === undefined) return { kind: 'confirm', why: `helm ${ws[1]} without --kube-context (target cluster unknown)` }
50 return { kind: 'pass' }
51}
52
53function aws(ws: string[]): Verdict {
54 const [service, op] = positionals(ws, 1, ['--profile', '--region', '--output', '--query', '--endpoint-url'])
55 return service === 'iam' && op !== undefined && IAM_WRITE.test(op) ? { kind: 'confirm', why: `aws iam ${op}` } : { kind: 'pass' }
56}
57
58const VAULT_VALUED = new Set([
59 '--backend', '--prompt', '--keychain', '--secret-service-collection', '--pass-dir', '--pass-cmd',
60 '--pass-prefix', '--file-dir', '-d', '--duration', '--region', '-t', '--mfa-token',
61])
62
63// `aws-vault exec <profile> -- <cmd>` runs non-interactively on a cached session, so the
64// wrapped command is judged like any other; only an interactive shell or login is handed off.
65function awsVault(ws: string[], policy: Policy): Verdict {
66 if (ws[1] === 'login') return { kind: 'handoff', why: 'aws-vault login opens the AWS console in your browser' }
67 if (ws[1] !== 'exec') return { kind: 'pass' }
68 let i = 2
69 for (let w = ws[i]; w !== undefined && w.startsWith('-') && w !== '--'; w = ws[i]) {
70 if (w === '--help') return { kind: 'pass' }
71 i += !w.includes('=') && VAULT_VALUED.has(w) ? 2 : 1
72 }
73 const rest = ws.slice(i + 1)
74 const inner = rest[0] === '--' ? rest.slice(1) : rest
75 return inner.length > 0
76 ? judge(program(inner), policy)
77 : { kind: 'handoff', why: 'aws-vault exec without a command opens an interactive shell' }
78}
79
80function judge(ws: string[], policy: Policy): Verdict {
81 if (ws[0] === 'kubectl') return kubectl(ws, policy)
82 if (ws[0] === 'helm') return helm(ws, policy)
83 if (ws[0] === 'aws') return aws(ws)
84 if (ws[0] === 'aws-vault') return awsVault(ws, policy)
85 return { kind: 'pass' }
86}
87
88const RANK = { pass: 0, confirm: 1, handoff: 2, deny: 3 } as const
89
90/** The strictest verdict over every simple command in a Bash command line. */
91export function classify(command: string, policy: Policy = DEFAULT_POLICY): Verdict {
92 let worst: Verdict = { kind: 'pass' }
93 for (const seg of segments(command)) {
94 const v = judge(program(seg.words), policy)
95 if (RANK[v.kind] > RANK[worst.kind]) worst = v
96 }
97 return worst
98}
99hooks/shell.ts 144 lines1// Minimal shell splitting for guards: good enough to find simple commands
2// in a Bash tool call, not a full shell parser. Quotes are respected;
3// substitutions ($(...), backticks) are not expanded.
4
5export type Segment = {
6 /** The segment's source text, trimmed. */
7 text: string
8 /** Offset of `text` in the full command. */
9 start: number
10 /** Words with quotes removed. */
11 words: string[]
12}
13
14/** Splits a command line at unquoted `&&`, `||`, `;`, `|`, `&` and newlines. */
15export function segments(command: string): Segment[] {
16 const out: Segment[] = []
17 let segStart = 0
18 let quote: '"' | "'" | null = null
19
20 const push = (end: number) => {
21 const raw = command.slice(segStart, end)
22 const lead = raw.length - raw.trimStart().length
23 const text = raw.trim()
24 if (text !== '') out.push({ text, start: segStart + lead, words: words(text) })
25 }
26
27 for (let i = 0; i < command.length; i++) {
28 const c = command.charAt(i)
29 if (quote !== null) {
30 if (c === '\\' && quote === '"') i++
31 else if (c === quote) quote = null
32 continue
33 }
34 if (c === '\\') {
35 i++
36 continue
37 }
38 if (c === '"' || c === "'") {
39 quote = c
40 continue
41 }
42 const two = command.slice(i, i + 2)
43 if (two === '&&' || two === '||') {
44 push(i)
45 segStart = i + 2
46 i++
47 } else if (c === '&' && /[<>]/.test(command.charAt(i - 1) + command.charAt(i + 1))) {
48 // fd duplication or `&>`, not a separator
49 continue
50 } else if (c === ';' || c === '|' || c === '&' || c === '\n') {
51 push(i)
52 segStart = i + 1
53 }
54 }
55 push(command.length)
56 return out
57}
58
59/** Splits one simple command into words, removing quotes. */
60export function words(text: string): string[] {
61 const out: string[] = []
62 let cur = ''
63 let has = false
64 let quote: '"' | "'" | null = null
65
66 for (let i = 0; i < text.length; i++) {
67 const c = text.charAt(i)
68 if (quote !== null) {
69 if (c === quote) quote = null
70 else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text.charAt(++i)
71 else cur += c
72 continue
73 }
74 if (c === '"' || c === "'") {
75 quote = c
76 has = true
77 } else if (c === '\\' && i + 1 < text.length) {
78 cur += text.charAt(++i)
79 has = true
80 } else if (/\s/.test(c)) {
81 if (has) out.push(cur)
82 cur = ''
83 has = false
84 } else {
85 cur += c
86 has = true
87 }
88 }
89 if (has) out.push(cur)
90 return out
91}
92
93const WRAPPERS = new Set(['sudo', 'env', 'time', 'command', 'exec'])
94
95/** Drops leading `VAR=value` assignments and `sudo`/`env`/`time` wrappers. */
96export function program(ws: string[]): string[] {
97 const i = ws.findIndex(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) && !WRAPPERS.has(w))
98 return i === -1 ? [] : ws.slice(i)
99}
100
101/**
102 * The value of a long or short option in `ws`, written `--name=value` or
103 * `--name value`; undefined when absent.
104 */
105export function option(ws: string[], ...names: string[]): string | undefined {
106 for (const [i, w] of ws.entries()) {
107 for (const name of names) {
108 if (w === name) return ws[i + 1]
109 if (name.startsWith('--') && w.startsWith(name + '=')) return w.slice(name.length + 1)
110 }
111 }
112 return undefined
113}
114
115/**
116 * Positional words after `from`, skipping flags and redirections; flags in
117 * `valued` consume the next word unless written `--flag=value`.
118 */
119export function positionals(ws: string[], from: number, valued: readonly string[]): string[] {
120 const out: string[] = []
121 let skip = false
122 for (const [i, w] of ws.entries()) {
123 if (i < from) continue
124 if (skip) {
125 skip = false
126 continue
127 }
128 if (w === '--') {
129 out.push(...ws.slice(i + 1))
130 break
131 }
132 if (/^\d*(>>?|<|&>)/.test(w)) {
133 skip = /^\d*(>>?|<|&>)$/.test(w)
134 continue
135 }
136 if (w.startsWith('-') && w !== '-') {
137 skip = !w.includes('=') && valued.includes(w)
138 continue
139 }
140 out.push(w)
141 }
142 return out
143}
144