SLOPSHOPPER

jr-deploy-guard

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

newguardprocess
A shopper browsing a rack in a slop shop
README

jr-deploy-guard

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").

CaughtCommand
Vercelvercel --prod, vercel deploy --prod
Netlifynetlify deploy --prod
Fly.iofly deploy, flyctl deploy
Firebasefirebase deploy
Google Cloudgcloud app/run/functions deploy
Cloudflarewrangler deploy, wrangler publish
Railway, Elastic Beanstalkrailway up, eb deploy
Serverless, SSTdeploy --stage prod or production
Infrastructureterraform apply, pulumi up
Kubernetes, Helmkubectl apply/delete/rollout/… on a prod context or namespace, helm upgrade/install/rollback with prod
Gita 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.

No deploys on Friday

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.

Settings (/config)

OptionDefault
No deploys on FridayoffRefuse production deploys on Fridays unless the command carries JR_FRIDAY_DEPLOY=yes

Install

/plugin install jr-deploy-guard --marketplace jrlprost/claude-code-mods

Access

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.

Source 3 files
hooks/register.ts 37 lines
1import 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}
37
hooks/deploy.ts 32 lines
1// 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
32
hooks/jr/git.ts 83 lines
1// 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