Production deploys and pushes to main always come to you for confirmation, even when your rules allow them. Optional: no deploys on Friday.

Anything that ships to production comes to you for a yes, even when your permission rules or mode would let it run on its own. The prompt names what the command does ("Vercel production deploy", "Push to main").
| Caught | Command |
|---|---|
| Vercel | vercel --prod, vercel deploy --prod |
| Netlify | netlify deploy --prod |
| Fly.io | fly deploy, flyctl deploy |
| Firebase | firebase deploy |
| Google Cloud | gcloud app/run/functions deploy |
| Cloudflare | wrangler deploy, wrangler publish |
| Railway, Elastic Beanstalk | railway up, eb deploy |
| Serverless, SST | deploy --stage prod or production |
| Infrastructure | terraform apply, pulumi up |
| Kubernetes, Helm | kubectl apply/delete/rollout/… on a prod context or namespace, helm upgrade/install/rollback with prod |
| Git | a push to main, master, production, prod or release, a push to Heroku, and a bare git push while on one of those branches |
Previews (vercel deploy without --prod, netlify deploy without --prod) and pushes to feature branches pass.
Turn it on in /config: on Fridays (local time), production deploys are refused outright. When something really has to ship, the refusal tells Claude to ask you first and then prefix the command with JR_FRIDAY_DEPLOY=yes, which lets it through to the usual confirmation.
/config)| Option | Default | |
|---|---|---|
| No deploys on Friday | off | Refuse production deploys on Fridays unless the command carries JR_FRIDAY_DEPLOY=yes |
/plugin install jr-deploy-guard --marketplace jrlprost/claude-code-mods
Reads the command of each Bash call when its permission is decided. Runs git branch --show-current for a bare git push. Writes nothing, sends nothing over the network.
hooks/register.ts 37 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { FRIDAY_OVERRIDE, isBarePush, isFriday, PROTECTED_BRANCHES, productionAction } from './deploy'
4import { safeGit } from './jr/git'
5
6async function currentBranch($: EngineInterface) {
7 const { exitCode, stdout } = await $.process.run(safeGit(['branch', '--show-current']))
8 return exitCode === 0 ? stdout.trim() : ''
9}
10
11async function actionOf($: EngineInterface, command: string) {
12 const named = productionAction(command)
13 if (named) return named
14 if (isBarePush(command)) {
15 const branch = await currentBranch($)
16 if (PROTECTED_BRANCHES.includes(branch)) return `Push to ${branch}`
17 }
18 return null
19}
20
21export const register: Register = (on, options) => {
22 const noFriday = options.noFridayDeploys === true
23
24 // tool.check decides whether a call may run: an `ask` puts the call in front of you,
25 // whatever rules already allowed it.
26 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
27 const verdict = await next(e)
28 const command = (e.input as { command?: string }).command ?? ''
29 const action = await actionOf($, command)
30 if (!action || verdict.decision === 'deny') return verdict
31 if (noFriday && isFriday(await $.clock.now()) && !command.includes(FRIDAY_OVERRIDE)) {
32 return { decision: 'deny', reason: `jr-deploy-guard: no production deploys on Friday (${action}). If it really has to ship today, ask the person, then prefix the command with ${FRIDAY_OVERRIDE}.` }
33 }
34 return { decision: 'ask', reason: `jr-deploy-guard: ${action}. This ships to production: confirm it is meant to.` }
35 }).catch(() => ({ decision: 'ask', reason: 'jr-deploy-guard: the production check failed; confirm this command yourself.' }))
36}
37hooks/deploy.ts 32 lines1// Which shell commands ship to production.
2
3const RULES: { name: string; test: RegExp }[] = [
4 { name: 'Vercel production deploy', test: /\bvercel\b[^;&|]*\s--prod(uction)?\b/ },
5 { name: 'Netlify production deploy', test: /\bnetlify\s+deploy\b[^;&|]*\s--prod\b/ },
6 { name: 'Fly.io deploy', test: /\b(fly|flyctl)\s+deploy\b/ },
7 { name: 'Firebase deploy', test: /\bfirebase\s+deploy\b/ },
8 { name: 'Google Cloud deploy', test: /\bgcloud\s+(app|run|functions)\s+deploy\b/ },
9 { name: 'Cloudflare deploy', test: /\bwrangler\s+(deploy|publish)\b/ },
10 { name: 'Railway deploy', test: /\brailway\s+up\b/ },
11 { name: 'Elastic Beanstalk deploy', test: /\beb\s+deploy\b/ },
12 { name: 'Serverless production deploy', test: /\b(serverless|sls|sst)\s+deploy\b[^;&|]*\s--stage[ =](prod|production)\b/ },
13 { name: 'Terraform apply', test: /\bterraform\s+apply\b/ },
14 { name: 'Pulumi update', test: /\bpulumi\s+up\b/ },
15 { name: 'Kubernetes change on a production context', test: /\bkubectl\s+(apply|delete|rollout|scale|set|patch|replace)\b[^;&|]*(--context[ =]\S*prod|-n[ =]?\S*prod|--namespace[ =]\S*prod)/ },
16 { name: 'Helm release on production', test: /\bhelm\s+(upgrade|install|rollback|uninstall)\b[^;&|]*prod/ },
17 { name: 'Push to Heroku', test: /\bgit\s+push\b[^;&|]*\bheroku\b/ },
18 { name: 'Push to a production branch', test: /\bgit\s+push\b[^;&|]*(\s|:|\/)(main|master|production|prod|release)(\s|$|;|&|\|)/ },
19]
20
21export const PROTECTED_BRANCHES = ['main', 'master', 'production', 'prod', 'release']
22
23/** The production action a command takes, or null. */
24export const productionAction = (command: string) => RULES.find(r => r.test.test(command))?.name ?? null
25
26/** A bare `git push` (no branch named): the current branch decides. */
27export const isBarePush = (command: string) => /\bgit\s+push\s*(?:-u\s+|--set-upstream\s+)?(?:origin\s*)?(?:$|[;&|])/.test(command.trim())
28
29export const FRIDAY_OVERRIDE = 'JR_FRIDAY_DEPLOY=yes'
30
31export const isFriday = (now: number) => new Date(now).getDay() === 5
32hooks/jr/git.ts 83 lines1// git in a repository the mod does not trust. A repository's config can make git run programs:
2// fsmonitor, external diff and textconv drivers, clean/smudge filters, ssh and credential helpers,
3// pagers, editors, aliases, or an include pulling any of these from another file.
4// Two layers: config overrides on every call, and repoIsSafe() before any command that reads the
5// working tree. repoIsSafe asks git itself which config it would use there (so there is no parser
6// of ours to fool) and refuses when an entry able to run a program comes from anywhere but the
7// person's own setup (what git reads outside any repository).
8// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
9import type { EngineInterface } from 'claude-code'
10
11const SAFE_CONFIG = [
12 '-c', 'core.fsmonitor=false',
13 '-c', 'core.hooksPath=/dev/null',
14 '-c', 'core.pager=cat',
15 '-c', 'core.editor=true',
16 '-c', 'core.sshCommand=false',
17 '-c', 'core.askPass=false',
18 '-c', 'credential.helper=',
19 '-c', 'diff.external=',
20 '-c', 'core.untrackedCache=false',
21 '-c', 'protocol.ext.allow=never',
22 // Never step into submodules: each has its own config, which repoIsSafe does not read.
23 '-c', 'submodule.recurse=false',
24 '-c', 'diff.ignoreSubmodules=all',
25 '-c', 'status.submoduleSummary=false',
26]
27
28/** `git <args>` with command-running config switched off. Diffs never run external drivers. */
29export const safeGit = (args: readonly string[]) => {
30 const rest = [...args]
31 if (rest[0] === '-C') rest.splice(0, 2)
32 const cwd = args[0] === '-C' ? ['-C', args[1] ?? '.'] : []
33 const extra = rest[0] === 'diff' ? ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'] : []
34 return ['git', ...SAFE_CONFIG, ...cwd, ...(rest.length ? [rest[0]!, ...extra, ...rest.slice(1)] : [])]
35}
36
37export type GitConfigEntry = { origin: string; key: string; value: string }
38
39/** Parses `git config --list --show-origin -z`: records of origin NUL key LF value NUL. */
40export const parseConfigListing = (listing: string): GitConfigEntry[] => {
41 const parts = listing.split('\0')
42 const entries: GitConfigEntry[] = []
43 for (let i = 0; i + 1 < parts.length; i += 2) {
44 const origin = parts[i]!
45 const record = parts[i + 1]!
46 const nl = record.indexOf('\n')
47 entries.push({ origin, key: (nl < 0 ? record : record.slice(0, nl)).toLowerCase(), value: nl < 0 ? '' : record.slice(nl + 1) })
48 }
49 return entries
50}
51
52// The last part of a key (git keys are section[.subsection].name, already lower-cased by git
53// for section and name) that makes git run a program, plus whole sections that do.
54const RISKY_NAME = /\.(fsmonitor|sshcommand|askpass|editor|pager|hookspath|textconv|command|clean|smudge|process|driver|helper|program|external|packobjectshook|gitproxy)$/
55const RISKY_SECTION = /^(alias|include|includeif)\./
56
57/** Entries able to run a program that come from somewhere other than the trusted origins. */
58export const riskyEntries = (entries: readonly GitConfigEntry[], trustedOrigins: ReadonlySet<string>) =>
59 entries.filter(e => !trustedOrigins.has(e.origin) && !e.origin.startsWith('command line:') && (RISKY_NAME.test(e.key) || RISKY_SECTION.test(e.key)))
60
61async function listing($: EngineInterface, args: readonly string[]) {
62 try {
63 const out = await $.process.run(args)
64 return out.exitCode === 0 ? out.stdout : null
65 } catch {
66 return null
67 }
68}
69
70/**
71 * True only when the config git would use at `root` holds nothing able to run a program beyond
72 * what the person's own global and system config set up. Any failure counts as unsafe.
73 */
74export async function repoIsSafe($: EngineInterface, root: string) {
75 const local = await listing($, safeGit(['-C', root, 'config', '--list', '--show-origin', '-z']))
76 if (local === null) return false
77 // What git reads outside any repository is the person's own setup: global, system, and the
78 // config the git build ships (Apple's git keeps it next to Xcode, not in /etc/gitconfig).
79 const own = await listing($, safeGit(['-C', '/', 'config', '--list', '--show-origin', '-z']))
80 const trusted = new Set(parseConfigListing(own ?? '').map(e => e.origin))
81 return riskyEntries(parseConfigListing(local), trusted).length === 0
82}
83