Asks before a production deploy: vercel --prod, netlify deploy --prod, firebase deploy, fly deploy, gcloud app deploy, eb deploy, heroku rollback, serverless…

Small mods for Claude Code. Pick the ones you want and combine them.
Baselane mods are Claude Code plugins made of function hooks. Each mod does one thing: a guard that asks before rm -rf, a band with the session cost, a side pane with your git status, a sound when tests pass, a pirate voice for Claude's prose. There are 100+ mods in families such as guards, panes, meters, sounds and stats. Install one, or stack ten. They do not need each other.
You need Claude Code 2.1.288 or later, with mods.
claude plugin marketplace add baselane-sh/mods-catalog
claude plugin install guard-essentials@baselane-mods
claude plugin install receipt@baselane-mods
Start Claude Code. Now it asks before harsh commands, and /receipt prints a receipt of the session.
Browse every mod at https://baselane-sh.github.io/mods-catalog/. The gallery (baselane-mods) pins each mod to a reviewed commit, so a new mod reaches the gallery a short time after it reaches this repo. For update, uninstall, options and starter sets, read docs/INSTALL.md.
This repo also has a development marketplace, baselane-mods-dev, that tracks main. Use it only to try changes that are not released yet:
claude plugin marketplace add baselane-sh/mods
claude plugin install <name>@baselane-mods-dev
Each mod is released with a git tag <name>--v<version>.
Most mods have one rule. A pack is one mod with several rules from the same family. For example, guard-essentials holds several guards and band-pack puts several meters in one band.
guard-pack and infra-guard installed, the terraform destroy check runs two times, and you can see two questions.guard-essentials, cost-meter and git-pane work well together.The catalog below marks each pack with "pack".
Beads (bd) is an issue tracker that lives in your repo. 19 mods show your beads in Claude Code: bars above the prompt, panes, slash commands, a guard and reminders. They need bd 1.2 or later and a repo with a .beads folder. Without these, they do nothing.
The core set in one go (the bars, the panes, /ready and the guard):
claude plugin install beads-bars@baselane-mods
claude plugin install beads-pane@baselane-mods
claude plugin install epics-pane@baselane-mods
claude plugin install ready@baselane-mods
claude plugin install beads-guard@baselane-mods
Each beads mod is also in the catalog below, in its family.
ntfy-notify or long-run-notify, or a webhook URL for slack-notify or discord-notify. With a destination set, those mods send a short message only: a generic "needs input" text with the project folder name, or the project name and the seconds. One exception needs no setup: ci-band and ops-band ask GitHub, through your own gh login, for the latest Actions run of your branch every 2 minutes, sending the repository and branch name. Without gh, or with gh not logged in, they do nothing.sports-narrator makes one small model call at the end of a turn that used tools. It sends tool names, file names and the first two words of each shell command, never file contents. It spends tokens, so you must turn it on.bd on your machine, such as bd status --json and bd ready --json. No beads mod creates, changes or closes a bead. beads-guard asks before a bd command that deletes or rewrites beads.secret-output-guard tells Claude not to repeat a credential, auto-format and auto-lint tell Claude to read a file again, beads-prime adds the workflow text that bd prime prints from your repo's .beads files, and bead-claim-nudge and bead-commit-nudge add a one-line reminder at your next prompt./wrapped read records that the mods keep on your machine./handoff writes .claude/handoff.md and never overwrites it. session-journal appends to ~/.claude/journal.log. auto-format and auto-lint run your project's own formatter or linter fix, which can rewrite files. The descriptions say what each mod writes.lsof for port-watch, gh for ci-band, bd 1.2 or later for the beads mods, ps for process-pane, and on macOS pmset for battery-band and pgrep with osascript for now-playing.plugins/ and .claude-plugin/marketplace.json are generated. Do not edit them by hand.
node scripts/build.mjs # build one plugin folder per catalog entry
bash scripts/check.sh # rebuild, then validate, test and type-check every mod
bash scripts/check.sh git-pane # check only the mods you name
node scripts/gen-docs.mjs # rewrite the catalog in this README
node --test 'scripts/*.test.mjs' # test the docs generator
A change is done when check.sh prints check: 0 failure(s). Read AGENTS.md for the layout and the API facts.
To add a mod:
engines/<engine>/hooks/rules/<id>.ts, with a test in engines/<engine>/tests/<id>.test.ts.catalog/<name>.json: { "name", "description", "rules": ["<id>"] }. Give the description the slash command, if the mod has one (for example "Open it with /git"). The catalog table reads it from there.node scripts/build.mjs, then bash scripts/check.sh <mod>, then node scripts/gen-docs.mjs.A new engine needs a family in scripts/gen-docs.mjs. Keep files small, do not change inputs in place, and do not use the em-dash character.
MIT. Copyright (c) 2026 Baselane, LLC. Read LICENSE.
<!-- catalog:start -->
199 mods in 10 families.
"needs setup" means the mod has options that you set with claude plugin configure <mod>. Its description tells you if it works before you set them. "pack" means one mod with several rules.
Ask you before a harsh or risky command runs.
| Mod | What it does | Command | Notes | ||
|---|---|---|---|---|---|
beads-guard | Asks before bd commands that delete data or rewrite history: delete, purge, prune, gc, sql, admin, import, rename, forget, restore, migrate and more. Reads only the command text, runs no bd. | ||||
secret-filename-guard | Asks before a Bash command touches a secret-looking file (.env, private keys, credentials). | ||||
secret-guard | Asks before a live API key, token or private key is written, edited or run. | ||||
env-exfil-guard | Asks before a command prints your environment, echoes a secret variable or sends local data to a remote host. | ||||
infra-guard | Asks before terraform destroy, kubectl delete, force-push, DROP TABLE or rm -rf. | ||||
secret-commit-guard | Asks before a git commit that would record a credential or a secret-named file. Reads the staged diff with git. | ||||
protect-main | Asks before a commit, push or merge while you are on main or master. Reads the branch with git. | ||||
gitignore-check | Asks before git add or commit when secret-looking files are not ignored or are already tracked. Checks with git ls-files. | ||||
secret-output-guard | Tells Claude not to repeat a credential that showed up in command or file output, and names the source to rotate. | ||||
curl-pipe-guard | Asks before a download is piped into a shell or interpreter (curl \ | sh, wget -O- \ | bash, bash <(curl ...)). | ||
sudo-guard | Asks before sudo, doas or su -c runs a command with elevated rights. | ||||
no-verify-guard | Asks before git hooks are skipped (--no-verify, commit -n, HUSKY=0, core.hooksPath=/dev/null). | ||||
lockfile-guard | Asks before a lockfile is written or edited by hand; the package manager should change it. | ||||
package-guard | Asks before a new dependency is installed (npm, pnpm, yarn, bun, pip, uv, poetry, cargo, go, gem) and names the packages. | ||||
prod-db-guard | Asks before destructive SQL runs in a command: TRUNCATE, DELETE or UPDATE with no WHERE. | ||||
path-jail | Asks before Write, Edit or NotebookEdit changes a file outside the working directory, by real path. | ||||
docker-guard | Asks before Docker commands that delete data: system, volume or image prune, rm -f, volume rm and compose down -v. | ||||
k8s-guard | Asks before kubectl apply or replace with --force, kubectl drain, helm uninstall, and kubectl delete behind global flags (plain kubectl delete is infra-guard's). | ||||
migration-guard | Asks before Write or Edit changes a migration that already exists; new migration files pass. | ||||
ci-config-guard | Asks before Write or Edit changes CI config: .github/workflows, .gitlab-ci.yml or .circleci/config.yml. | ||||
chmod-guard | Asks before chmod makes files world-writable (777, a+w, o+w) or chmod or chown runs recursively on a broad path (/, a system folder, a home folder). | ||||
git-history-guard | Asks before git commands that throw away work or rewrite history: reset --hard, clean, rebase, filter-branch, filter-repo, push --delete, branch -D and stash clear. | ||||
big-file-guard | Asks before a Write creates content over 1 MB, or git add names a file over 5 MB. Measures the files with find. | ||||
publish-guard | Asks before a package is published: npm, pnpm, yarn or bun publish, cargo publish, twine upload, gem push, poetry or uv publish. Dry runs pass. | ||||
tag-guard | Asks before release tags go to a remote: git push --tags, --follow-tags or --mirror, a push of a tag ref, and a push that deletes a remote tag. Dry runs pass. Reads tags with git. | ||||
deploy-guard | Asks before a production deploy: vercel --prod, netlify deploy --prod, firebase deploy, fly deploy, gcloud app deploy, eb deploy, heroku rollback, serverless deploy to prod. | ||||
db-reset-guard | Asks before a framework wipes a database: prisma migrate reset, rails or rake db:drop and db:reset, alembic downgrade, django flush, supabase db reset, knex rollback --all. | ||||
ssh-guard | Asks before a private key in .ssh is read, authorized_keys or the SSH config is changed, or ssh-keygen would overwrite a key. Public keys and ssh -i pass. Reads HOME with printenv. | ||||
cron-guard | Asks before scheduled jobs or services are wiped or stopped: crontab -r, crontab replaced from stdin or a file, launchctl unload or bootout, systemctl stop, disable or mask. | ||||
upload-guard | Asks before local files go to a remote host: scp or rsync to host:path, piped input to nc, curl -T, sftp put. Local copies, downloads and localhost pass. | ||||
registry-push-guard | Asks before an image or chart is pushed to a registry: docker push, docker buildx --push, podman push, helm push, gcloud artifacts docker push. Local registries pass. | ||||
guard-essentials | Asks only before harsh or disaster commands: destructive infra, git and SQL, piping downloads into a shell, sudo, leaking or committing secrets. Reads git. The quiet choice for daily work. | pack | |||
guard-pack | The 15 core Baselane guards in one mod (secrets, git, infra, packages, path jail). Some read git, measure files with find, read HOME with printenv, or check where a path really lands. | pack | |||
guard-devops | Asks before harsh DevOps commands: destructive Docker, Kubernetes and Helm calls, CI config edits, broad chmod and chown, and git commands that lose work. Add infra-guard for plain kubectl delete. | pack | |||
release-pack | Asks before a release leaves your machine: package publish, tag push, production deploy and registry push guards in one mod. The tag guard reads tags with git. | pack | |||
ship-safe-pack | The release-pack guards plus db-reset-guard and upload-guard: asks before publish, tag push, production deploy, registry push, database wipes and file uploads. Reads tags with git. | pack |
One quiet toast at turn end when something needs your attention.
| Mod | What it does | Command | Notes |
|---|---|---|---|
bead-claim-nudge | At turn end, if Claude edited a file and no bead is in progress, shows a toast and reminds Claude at your next prompt, once per session. Reads with bd count; quiet without bd. | ||
bead-commit-nudge | At turn end, if a git commit named no bead id while a bead is in progress, shows a toast and reminds Claude at your next prompt. Reads with bd count and bd where; quiet without bd. | ||
test-reminder | Reminds you at turn end when source files changed but no test command ran since. | ||
ctx-nudge | Reminds you to /clear or /compact when the context window passes 75 percent. | ||
clippy | One helpful toast per session per trigger, in the classic paperclip voice: when you edit a migrations folder, a Dockerfile or a GitHub workflow, or run rm -rf. At most one toast per turn. | ||
sports-narrator | One line of sports play-by-play at turn end. OFF by default: one haiku call per turn with tools. Turn on the enabled option. Sends tool names, file names and the first two words of each command. | needs setup | |
commit-nudge | Suggests a commit at turn end after 8 or more file edits since the last git commit. One toast per batch of edits; a successful git commit resets the count. | ||
todo-nudge | At turn end, one toast with the count of TODO, FIXME and HACK lines your edits added this turn. Quiet when none were added. | ||
break-nudge | Suggests a short break once the session has run 90 minutes, and again every 90 minutes after that. | ||
docs-nudge | At turn end, one toast when edits added exported code (an export statement in .ts or .js, or a public function signature) and no README or docs file was edited this session. | ||
debug-print-nudge | At turn end, one toast naming the count of console.log, print(, debugger and dbg! lines your edits added this turn. Only code files count. | ||
typecheck-nudge | At turn end, one toast when .ts or .tsx files were edited and no type check (tsc, vue-tsc, a typecheck script or a build) ran since the last edit. | ||
lockfile-nudge | At turn end, one toast when dependencies in package.json, pyproject.toml, Cargo.toml or go.mod changed and neither the matching lockfile was edited nor an install command ran. | ||
big-diff-nudge | Suggests splitting the change once your edits added or removed more than 500 lines since the last git commit. A successful commit resets the count. | ||
migration-nudge | At turn end, one toast when a schema file changed (schema.prisma, models.py, SQL under schema/, a drizzle schema) and no migration file was created this session. | ||
env-example-nudge | At turn end, one toast when edits add a reference to an environment variable (process.env, os.environ, os.getenv, Deno.env) that .env.example does not list. Reads only .env.example names, never .env. | ||
nudge-pack | Two turn-end reminders in one mod: test-reminder and ctx-nudge. | pack | |
focus-pack | Three quiet nudges in one mod: commit after 8 edits, TODO/FIXME/HACK lines added, and debug prints added. | pack | |
quality-pack | Three quiet nudges in one mod: type check after TypeScript edits, lockfile after dependency edits, and a split suggestion past 500 changed lines. | pack |
Slash commands that print a result and, where it helps, copy it.
| Mod | What it does | Command | Notes |
|---|---|---|---|
ready | Adds /ready: the 20 top ready beads, one line each, and the total. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /ready | |
bead | Adds /bead <id>: one bead with status, priority, labels, description, blockers and children. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /bead | |
beads-standup | Adds /beads-standup: beads closed since yesterday, in progress, and the next 5 ready. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /beads-standup | |
receipt | Adds /receipt: a shareable receipt of the session (tools, files, commands, blocks, context, cost), printed and copied. | /receipt | |
standup | Adds /standup: Yesterday, Today and Blockers from your git log and this session's record, printed and copied. | /standup | |
changelog | Adds /changelog: commits since the last tag (or the last 30), read with git log and grouped by conventional-commit type, as Markdown. Copies the result to your clipboard. | /changelog | |
pr-description | Adds /pr-description: title, summary, diff totals and a test plan stub for the current branch against main or master, read with git. Copies the result to your clipboard. | /pr-description | |
handoff | Adds /handoff: writes a session summary with the git status to .claude/handoff.md (never overwriting) and answers with the path. Copies the result to your clipboard. | /handoff | |
todos | Adds /todos: TODO, FIXME and HACK lines in tracked files, found with git grep, grouped by file (50 lines at most). Read-only. Copies the result to your clipboard. | /todos | |
loc | Adds /loc: lines of tracked text files by language, read with git, sorted, with a total. Skips lockfiles and binaries. Read-only. Copies the result to your clipboard. | /loc | |
hotspots | Adds /hotspots: the 10 files changed most often in the last 90 days, from git log, with change counts. Read-only. Copies the result to your clipboard. | /hotspots | |
commit-msg | Adds /commit-msg: a Conventional Commits message proposed from the staged diff, read with git. Heuristic, no model call, writes nothing. Copies the result to your clipboard. | /commit-msg | |
branches | Adds /branches: local branches merged into the default branch or idle for 30 days, as a cleanup list, read with git. Never deletes. Copies the result to your clipboard. | /branches | |
tree | Adds /tree: tracked files (git ls-files) as a tree, 2 levels deep, folders with file counts (80 lines at most). Read-only. Copies the result to your clipboard. | /tree | |
deps | Adds /deps: direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml at the repo root. No network. Read-only. Copies the result to your clipboard. | /deps | |
authors | Adds /authors: the top 15 contributors by commit count with their last commit date, from git log. Names only, never email addresses. Read-only. Copies the result to your clipboard. | /authors | |
scripts | Adds /scripts: runnable tasks from package.json scripts, Makefile targets, justfile recipes and pyproject scripts at the git repo root. Read-only. Copies the result to your clipboard. | /scripts | |
env-check | Adds /env-check: variable names in .env.example against .env at the git repo root, listing missing and extra names. Reads names only, never a value. Read-only. Copies the result to your clipboard. | /env-check | |
size | Adds /size: the 15 largest tracked files and the total tracked size at HEAD, read with git. Read-only. Copies the result to your clipboard. | /size | |
licenses | Adds /licenses: the licence of each direct dependency, from node_modules and Python dist-info at the git repo root. Shows unknown when it cannot tell. Read-only. Copies the result to your clipboard. | /licenses | |
conflicts | Adds /conflicts: tracked files that still hold merge conflict markers, with line numbers, found with git grep. Read-only. Copies the result to your clipboard. | /conflicts | |
secret-scan | Adds /secret-scan: tracked files and line numbers that hold secret-shaped text, found with git grep. Never prints a matched value. Read-only. Copies the result to your clipboard. | /secret-scan | |
envinfo | Adds /envinfo: versions of git, node, npm, python3, go, rustc and docker if installed, by running each (2 s limit), and the OS. Read-only. Copies the result to your clipboard. | /envinfo | |
stashes | Adds /stashes: git stashes with their age and the branch each was made on. Read-only, never applies or drops. Copies the result to your clipboard. | /stashes | |
recent | Adds /recent: your last 15 commits across all local branches (author is git user.name), with branch and date. Read-only. Copies the result to your clipboard. | /recent | |
file-owners | Adds /owners <path>: the top 5 authors of a file or folder by lines, from git blame. Names only, never addresses. Read-only. Copies the result to your clipboard. | /owners | |
readme-check | Adds /readme-check: missing README sections (install, usage, licence, contributing) and broken relative links, at the git repo root. Read-only. Copies the result to your clipboard. | /readme-check | |
command-pack | The five original slash commands in one mod: /receipt, /standup, /changelog, /pr-description and /handoff. They read git; /handoff also writes .claude/handoff.md. They copy results to the clipboard. | /receipt, /standup, /changelog, /pr-description, /handoff | pack |
repo-pack | Five read-only repo commands in one mod: /todos, /loc, /hotspots, /commit-msg and /branches. They read git. They copy results to the clipboard. | /todos, /loc, /hotspots, /commit-msg, /branches | pack |
explore-pack | Five read-only repo exploration commands in one mod: /tree, /deps, /authors, /scripts and /env-check. They read git and project files. They copy results to the clipboard. | /tree, /deps, /authors, /scripts, /env-check | pack |
audit-pack | Three read-only audit commands in one mod: /secret-scan, /conflicts and /licenses. They read git, project files and dependency folders. They copy results to the clipboard. | /secret-scan, /conflicts, /licenses | pack |
A one-line band above the prompt.
| Mod | What it does | Command | Notes |
|---|---|---|---|
cost-meter | A band above the prompt with the session cost so far and what the last turn cost. | ||
latte-meter | A band above the prompt that counts the session cost in lattes. Set the latte price in the plugin options. | needs setup | |
context-meter | A band above the prompt with a 10 cell bar of the context window used, yellow from 60 percent, red from 80. | ||
daily-spend | A band above the prompt with today's total cost across all your sessions. |
| pomodoro | A band above the prompt with a 25/5 focus timer (focus 18:42, break 03:10). Start and stop it with /pomodoro; a to
hooks/register.ts 10 lines1import type { Register } from 'claude-code'
2
3import { checkCalls } from './hosts/check'
4import { rule as deploy } from './rules/deploy'
5
6export const register: Register = on => {
7 const rules = [deploy]
8 checkCalls(on, rules)
9}
10hooks/hosts/check.ts 10 lines1import type { On } from 'claude-code'
2
3import { evaluate, NO_TOOLS } from '../engine'
4import type { GuardRule } from '../engine'
5
6// The check for rules that read only the call itself: no `$` call at all.
7export const checkCalls = (on: On, rules: readonly GuardRule[]): void => {
8 on('classic.PreToolUse', async (_$, e, next) => (await evaluate(rules, e, NO_TOOLS)) ?? next(e))
9}
10hooks/rules/deploy.ts 88 lines1import type { GuardRule } from '../engine'
2import { base, commandsOf, subcommandOf } from '../shell'
3
4// Deploys that reach production users at once. Preview and staging deploys,
5// dry runs and build-only runs pass.
6const VALUE_FLAGS = new Set(['--project', '-P', '--account', '--configuration', '--config', '-c', '--token', '-t', '--cwd', '--scope', '-S', '--dir', '-d', '--app', '-a', '-r', '--region', '--verbosity', '--format', '--profile'])
7const RUNNERS = new Set(['npx', 'bunx', 'pnpx'])
8const RUNS_AFTER: Readonly<Record<string, string>> = { pnpm: 'exec', yarn: 'exec', npm: 'exec', bun: 'x' }
9const PROD_STAGE = /^prod(uction)?$/i
10// A firebase deploy whose every --only target is a hosting target named for
11// previews, such as hosting:preview.
12const PREVIEW_ONLY = /^hosting:[^,]*preview[^,]*$/i
13
14// `vercel@latest` and `@scope/cli@2` name the package without the version.
15const withoutVersion = (spec: string): string => spec.replace(/^(@[^/@]+\/[^@]+|[^@]+)@.*$/, '$1')
16
17// `npx vercel` and `pnpm exec vercel` read as `vercel`.
18const withoutRunner = (argv: readonly string[]): readonly string[] => {
19 const name = argv[0] === undefined ? '' : base(argv[0])
20 const from = RUNNERS.has(name) ? 1 : RUNS_AFTER[name] !== undefined && argv[1] === RUNS_AFTER[name] ? 2 : 0
21 if (from === 0) return argv
22 const rest = argv.slice(from)
23 const start = rest.findIndex(word => !word.startsWith('-'))
24 return start < 0 ? [] : [withoutVersion(rest[start]!), ...rest.slice(start + 1)]
25}
26
27const valueOf = (args: readonly string[], names: readonly string[]): string | undefined => {
28 for (const [i, arg] of args.entries()) {
29 if (names.includes(arg)) return args[i + 1]
30 const name = names.find(n => n.startsWith('--') && arg.startsWith(`${n}=`))
31 if (name !== undefined) return arg.slice(name.length + 1)
32 }
33 return undefined
34}
35
36const firebaseDanger = (args: readonly string[]): string | undefined => {
37 if (args.includes('--dry-run')) return undefined
38 const only = valueOf(args, ['--only'])
39 const isPreview = only !== undefined && only.split(',').every(target => PREVIEW_ONLY.test(target))
40 return isPreview ? undefined : 'firebase deploy'
41}
42
43// Vercel subcommands that build or fetch locally and deploy nothing.
44const VERCEL_LOCAL = new Set(['build', 'pull', 'dev', 'env'])
45
46const vercelDanger = (args: readonly string[]): string | undefined => {
47 if (VERCEL_LOCAL.has(args.find(arg => !arg.startsWith('-')) ?? '')) return undefined
48 const isProd = args.some(arg => arg === '--prod' || arg === '--prod=true') || valueOf(args, ['--target']) === 'production'
49 return isProd ? 'vercel --prod' : undefined
50}
51
52const dangerOf = (argv: readonly string[]): string | undefined => {
53 const name = argv[0] === undefined ? '' : base(argv[0])
54 const { sub, args } = subcommandOf(argv, VALUE_FLAGS)
55 if (name === 'vercel') return vercelDanger(argv.slice(1))
56 if ((name === 'netlify' || name === 'netlify-cli') && sub === 'deploy') {
57 return args.some(arg => arg === '--prod' || arg === '-p' || arg === '--prod-if-unlocked') ? 'netlify deploy --prod' : undefined
58 }
59 if ((name === 'firebase' || name === 'firebase-tools') && sub === 'deploy') return firebaseDanger(args)
60 if ((name === 'fly' || name === 'flyctl') && sub === 'deploy') return args.includes('--build-only') ? undefined : 'fly deploy'
61 if (name === 'gcloud' && sub === 'app' && args[0] === 'deploy') return 'gcloud app deploy'
62 if (name === 'eb' && sub === 'deploy') return 'eb deploy'
63 if (name === 'heroku' && (sub === 'releases:rollback' || sub === 'rollback')) return 'heroku releases:rollback'
64 if ((name === 'serverless' || name === 'sls') && sub === 'deploy') {
65 const stage = valueOf(args, ['--stage', '-s'])
66 return stage !== undefined && PROD_STAGE.test(stage) ? 'serverless deploy --stage prod' : undefined
67 }
68 return undefined
69}
70
71export const deploysIn = (command: string): readonly string[] => {
72 const found = commandsOf(command).flatMap(argv => {
73 const danger = dangerOf(withoutRunner(argv))
74 return danger === undefined ? [] : [danger]
75 })
76 return [...new Set(found)]
77}
78
79export const rule: GuardRule = {
80 id: 'deploy-guard',
81 decision: 'ask',
82 check: e => {
83 if (e.tool !== 'Bash') return undefined
84 const found = deploysIn(e.command)
85 return found.length === 0 ? undefined : `this deploys to production (${found.join(', ')}). Users see the change at once.`
86 },
87}
88hooks/engine.ts 90 lines1import type { PreToolUseResult, ProcessRunResult, ToolCallEnvelope, ToolCallResult } from 'claude-code'
2
3// What a rule may use beyond the call itself: the session's directory, a
4// host command (git, for the rules that inspect the repo) and where a path
5// really lands.
6export type GuardTools = {
7 cwd: () => Promise<string>
8 // The absolute path with every symbolic link followed, or undefined when
9 // the path does not exist.
10 realPath: (path: string) => Promise<string | undefined>
11 run: (argv: readonly string[], cwd: string) => Promise<ProcessRunResult>
12}
13
14// One guard rule. `check` runs before the call and names a reason when the
15// call is risky; the engine asks the person (or refuses, for a deny rule)
16// with every reason that fired. `after` runs once the tool answered and
17// names a note the model reads with the result. `prompt` reads the person's
18// prompt text and names a note the model reads beside it.
19export type GuardRule = {
20 id: string
21 decision: 'ask' | 'deny'
22 check: (e: ToolCallEnvelope, tools: GuardTools) => string | undefined | Promise<string | undefined>
23 after?: (e: ToolCallEnvelope, ran: ToolCallResult) => string | undefined
24 prompt?: (text: string) => string | undefined
25}
26
27type Hit = { id: string; decision: GuardRule['decision']; reason: string }
28
29export const RUN_TIMEOUT_MS = 10_000
30
31// A rule that throws fails closed: the person is asked rather than the call
32// passing unchecked.
33const run = async (rule: GuardRule, e: ToolCallEnvelope, tools: GuardTools): Promise<Hit | undefined> => {
34 try {
35 const reason = await rule.check(e, tools)
36 return reason === undefined ? undefined : { id: rule.id, decision: rule.decision, reason }
37 } catch {
38 return { id: rule.id, decision: 'ask', reason: 'the check failed, so this call was not inspected.' }
39 }
40}
41
42export const evaluate = async (
43 rules: readonly GuardRule[],
44 e: ToolCallEnvelope,
45 tools: GuardTools,
46): Promise<PreToolUseResult | undefined> => {
47 const hits = (await Promise.all(rules.map(rule => run(rule, e, tools)))).filter(
48 (hit): hit is Hit => hit !== undefined,
49 )
50
51 if (hits.length === 0) {
52 return undefined
53 }
54
55 const text = `${hits.map(hit => `${hit.id}: ${hit.reason}`).join(' ')} Confirm this is intended.`
56
57 return hits.some(hit => hit.decision === 'deny') ? { deny: text } : { ask: text }
58}
59
60// A note that fails to compute is dropped: the tool already ran, so there is
61// nothing left to gate.
62export const notesFor = (rules: readonly GuardRule[], e: ToolCallEnvelope, ran: ToolCallResult): string[] =>
63 rules.flatMap(rule => {
64 try {
65 const note = rule.after?.(e, ran)
66 return note === undefined ? [] : [`SECURITY (${rule.id}): ${note}`]
67 } catch {
68 return []
69 }
70 })
71
72// Same drop-on-failure rule as notesFor: a prompt is never held back by a
73// check that threw.
74export const promptNotesFor = (rules: readonly GuardRule[], text: string): string[] =>
75 rules.flatMap(rule => {
76 try {
77 const note = rule.prompt?.(text)
78 return note === undefined ? [] : [`SECURITY (${rule.id}): ${note}`]
79 } catch {
80 return []
81 }
82 })
83
84// The tools a host does not give. A rule that calls one fails closed with an
85// ask (see `run`), and its tests fail: name what it uses in engine.json `needs`.
86const absent = (name: string) => (): Promise<never> =>
87 Promise.reject(new Error(`${name} is not given to this mod; name it in engine.json needs`))
88
89export const NO_TOOLS: GuardTools = { cwd: absent('cwd'), realPath: absent('realPath'), run: absent('run') }
90hooks/shell.ts 316 lines1// A small shell reader for the rules that must tell a command from text that
2// only mentions it (`grep sudo README.md`, `echo "no-verify"`). It splits a
3// command line into simple commands, honours quotes and escapes, skips
4// heredoc bodies and follows `sh -c '...'`, `eval ...` and command
5// substitution (`$(...)` and backticks) inside double quotes. It is not a full
6// parser: single-quoted text is never entered, and aliases are not tracked.
7
8export type Segment = {
9 // Commands joined by `|` share a pipeline id.
10 pipeline: string
11 // NAME=value words in front of the command.
12 env: readonly string[]
13 // The command and its arguments, with env, command, exec, nohup, time and
14 // builtin removed from the front.
15 argv: readonly string[]
16}
17
18type Raw = { pipeline: string; words: string[] }
19
20const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
21const WRAPPERS = new Set(['command', 'exec', 'nohup', 'time', 'builtin', 'env'])
22const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
23const DASH_C = /^-[a-z]*c[a-z]*$/
24const MAX_DEPTH = 3
25
26export const base = (word: string): string => word.slice(word.lastIndexOf('/') + 1)
27
28// sudo and doas options whose value is the next word. `-h` stays out: alone
29// it means help.
30const SUDO_VALUE_FLAGS = new Set(['-u', '-g', '-C', '-D', '-p', '-r', '-t', '-T', '-U', '--user', '--group', '--close-from', '--chdir', '--prompt', '--role', '--type', '--command-timeout', '--other-user'])
31
32// `sudo -E npm i x` and `sudo -u deploy npm i x` read as `npm i x` for a rule
33// about the command that runs.
34export const withoutSudo = (argv: readonly string[]): readonly string[] => {
35 if (argv[0] === undefined || !['sudo', 'doas'].includes(base(argv[0]))) return argv
36 const { sub, args } = subcommandOf(argv, SUDO_VALUE_FLAGS)
37 return sub === undefined ? [] : [sub, ...args]
38}
39
40// The index after a `"..."` span that starts at `open`. A `$(...)` inside it
41// is skipped whole, so its quotes and parens do not end the span early.
42const skipDoubleQuoted = (text: string, open: number): number => {
43 let j = open + 1
44 while (j < text.length && text[j] !== '"') {
45 if (text[j] === '\\') j += 2
46 else if (text[j] === '$' && text[j + 1] === '(') j = readParenSub(text, j).end
47 else if (text[j] === '`') j = readBacktickSub(text, j).end
48 else j += 1
49 }
50 return j + 1
51}
52
53// The index after a heredoc whose `<<` is at `open`: the rest of that line and
54// the body up to the line that is exactly the delimiter. A body is text, so
55// its quotes and parens never count.
56const skipHeredoc = (text: string, open: number): number => {
57 let j = open + 2
58 const strip = text[j] === '-'
59 if (strip) j += 1
60 while (text[j] === ' ' || text[j] === '\t') j += 1
61 let delimiter = ''
62 while (j < text.length && !/[\s;&|<>()]/.test(text[j]!)) {
63 if (text[j] !== "'" && text[j] !== '"' && text[j] !== '\\') delimiter += text[j]
64 j += 1
65 }
66 const eol = text.indexOf('\n', j)
67 if (eol < 0) return open + 2
68 j = eol + 1
69 while (j < text.length) {
70 const end = text.indexOf('\n', j)
71 const line = text.slice(j, end < 0 ? text.length : end)
72 j = end < 0 ? text.length : end + 1
73 if ((strip ? line.replace(/^\t+/, '') : line) === delimiter) break
74 }
75 return j
76}
77
78// The text of a `$(...)` that starts at `open` (the index of `$`), and the
79// index after its `)`. Nested parens, quotes and heredoc bodies are skipped.
80// A heredoc start is taken as a heredoc only when it is followed by a word.
81function readParenSub(text: string, open: number): { inner: string; end: number } {
82 // One flag per open paren: true inside `$((...))` or `((...))`, where `<<`
83 // is a shift and never a heredoc.
84 const arith = [text[open + 2] === '(']
85 let j = open + 2
86 while (j < text.length && arith.length > 0) {
87 const c = text[j]!
88 if (c === "'") {
89 const close = text.indexOf("'", j + 1)
90 j = close < 0 ? text.length : close + 1
91 } else if (c === '"') {
92 j = skipDoubleQuoted(text, j)
93 } else if (c === '`') {
94 j = readBacktickSub(text, j).end
95 } else if (c === '<' && text[j + 1] === '<' && text[j + 2] !== '<' && text[j + 2] !== '(' && !arith[arith.length - 1]) {
96 j = skipHeredoc(text, j)
97 } else {
98 if (c === '\\') j += 1
99 else if (c === '(') {
100 const prev = text[j - 1]
101 arith.push(prev === '(' || (prev === '$' && text[j + 1] === '(') || (prev !== '$' && arith[arith.length - 1]!))
102 } else if (c === ')') arith.pop()
103 j += 1
104 }
105 }
106 return { inner: text.slice(open + 2, arith.length === 0 ? j - 1 : j), end: j }
107}
108
109// The text of a backtick span that starts at `open`, and the index after it.
110function readBacktickSub(text: string, open: number): { inner: string; end: number } {
111 let j = open + 1
112 while (j < text.length && text[j] !== '`') j += text[j] === '\\' ? 2 : 1
113 return { inner: text.slice(open + 1, j).replace(/\\([`$\\])/g, '$1'), end: j + 1 }
114}
115
116// Raw simple commands, plus the text of every command substitution found
117// inside double quotes (the shell runs those).
118const tokenize = (text: string, prefix: string): { raws: Raw[]; subs: string[] } => {
119 const out: Raw[] = []
120 const subs: string[] = []
121 const heredocs: Array<{ delimiter: string; strip: boolean }> = []
122 let words: string[] = []
123 let cur = ''
124 let inWord = false
125 let pipe = 0
126 let i = 0
127
128 const endWord = () => {
129 if (inWord) words.push(cur)
130 cur = ''
131 inWord = false
132 }
133 const endSegment = (startsPipeline: boolean) => {
134 endWord()
135 if (words.length > 0) out.push({ pipeline: `${prefix}${pipe}`, words })
136 words = []
137 if (startsPipeline) pipe += 1
138 }
139 const skipHeredocBodies = () => {
140 for (const { delimiter, strip } of heredocs) {
141 while (i < text.length) {
142 const eol = text.indexOf('\n', i)
143 const line = text.slice(i, eol < 0 ? text.length : eol)
144 i = eol < 0 ? text.length : eol + 1
145 if ((strip ? line.replace(/^\t+/, '') : line) === delimiter) break
146 }
147 }
148 heredocs.length = 0
149 }
150 const readHeredocStart = () => {
151 i += 2
152 const strip = text[i] === '-'
153 if (strip) i += 1
154 while (text[i] === ' ' || text[i] === '\t') i += 1
155 let delimiter = ''
156 while (i < text.length && !/[\s;&|<>()]/.test(text[i]!)) {
157 if (text[i] !== "'" && text[i] !== '"' && text[i] !== '\\') delimiter += text[i]
158 i += 1
159 }
160 heredocs.push({ delimiter, strip })
161 }
162
163 while (i < text.length) {
164 const c = text[i]!
165 const next = text[i + 1]
166 if (c === '\\') {
167 if (next !== '\n' && next !== undefined) {
168 cur += next
169 inWord = true
170 }
171 i += 2
172 } else if (c === "'") {
173 const end = text.indexOf("'", i + 1)
174 const stop = end < 0 ? text.length : end
175 cur += text.slice(i + 1, stop)
176 inWord = true
177 i = stop + 1
178 } else if (c === '"') {
179 inWord = true
180 i += 1
181 while (i < text.length && text[i] !== '"') {
182 if (text[i] === '\\' && i + 1 < text.length && '"\\$`'.includes(text[i + 1]!)) {
183 cur += text[i + 1]
184 i += 2
185 } else if ((text[i] === '$' && text[i + 1] === '(') || text[i] === '`') {
186 const sub = text[i] === '`' ? readBacktickSub(text, i) : readParenSub(text, i)
187 subs.push(sub.inner)
188 cur += text.slice(i, sub.end)
189 i = sub.end
190 } else {
191 cur += text[i]
192 i += 1
193 }
194 }
195 i += 1
196 } else if (c === ' ' || c === '\t') {
197 endWord()
198 i += 1
199 } else if (c === '\n') {
200 endSegment(true)
201 i += 1
202 skipHeredocBodies()
203 } else if (c === '#' && !inWord) {
204 while (i < text.length && text[i] !== '\n') i += 1
205 } else if (c === ';' || c === '(' || c === ')' || c === '`') {
206 endSegment(true)
207 i += 1
208 } else if (c === '&') {
209 if (next === '&') {
210 endSegment(true)
211 i += 2
212 } else if (next === '>' || cur.endsWith('>') || cur.endsWith('<')) {
213 cur += c // 2>&1, &>file
214 inWord = true
215 i += 1
216 } else {
217 endSegment(true)
218 i += 1
219 }
220 } else if (c === '|') {
221 const isOr = next === '|'
222 endSegment(isOr)
223 i += isOr || next === '&' ? 2 : 1
224 } else if (c === '<' && next === '<' && text[i + 2] !== '<') {
225 readHeredocStart()
226 } else if ((c === '>' || c === '<') && inWord && !/^(\d*|&)[<>]*$/.test(cur)) {
227 // `key.pub>>file` is the word `key.pub` and the redirect `>>file`;
228 // `2>&1` and `&>file` stay one word.
229 endWord()
230 cur = c
231 inWord = true
232 i += 1
233 } else {
234 cur += c
235 inWord = true
236 i += 1
237 }
238 }
239 endSegment(false)
240 return { raws: out, subs }
241}
242
243const unwrap = (words: readonly string[]): { env: string[]; argv: string[] } => {
244 const env: string[] = []
245 let rest = [...words]
246 while (rest[0] !== undefined) {
247 const word = rest[0]
248 if (ASSIGNMENT.test(word)) {
249 env.push(word)
250 rest = rest.slice(1)
251 } else if (WRAPPERS.has(base(word))) {
252 const isEnv = base(word) === 'env'
253 rest = rest.slice(1)
254 while (isEnv && rest[0]?.startsWith('-')) rest = rest.slice(1)
255 } else {
256 break
257 }
258 }
259 return { env, argv: rest }
260}
261
262const segmentsAt = (command: string, prefix: string, depth: number): Segment[] => {
263 const { raws, subs } = tokenize(command, prefix)
264 const inSubs = depth < MAX_DEPTH ? subs.flatMap((sub, index) => segmentsAt(sub, `${prefix}s${index}.`, depth + 1)) : []
265 return [...segmentsFrom(raws, prefix, depth), ...inSubs]
266}
267
268const segmentsFrom = (raws: readonly Raw[], prefix: string, depth: number): Segment[] =>
269 raws.flatMap((raw, index) => {
270 const { env, argv } = unwrap(raw.words)
271 const own: Segment = { pipeline: raw.pipeline, env, argv }
272 const flag = argv.findIndex(word => DASH_C.test(word))
273 const script = argv[flag + 1]
274 const isShellC = argv[0] !== undefined && SHELLS.has(base(argv[0])) && flag > 0 && script !== undefined
275 if (isShellC && depth < MAX_DEPTH) return [own, ...segmentsAt(script, `${prefix}${index}.`, depth + 1)]
276 // `eval a b` runs the words joined by spaces as a command line.
277 const isEval = argv[0] === 'eval' && argv.length > 1
278 return isEval && depth < MAX_DEPTH ? [own, ...segmentsAt(argv.slice(1).join(' '), `${prefix}${index}.`, depth + 1)] : [own]
279 })
280
281export const segmentsOf = (command: string): readonly Segment[] => segmentsAt(command, '', 0)
282
283// The command with every quoted span blanked, for patterns that must not
284// match text inside quotes.
285export const blankQuotes = (command: string): string =>
286 command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, span => `${span[0]}${'_'.repeat(span.length - 2)}${span[0]}`)
287
288// A CLI call split at its subcommand: the global options in front, the
289// subcommand and the words after it. `valueFlags` names the options whose
290// value is the next word (`-C dir`); `--opt=value` is one word already.
291export type Subcommand = { globals: readonly string[]; sub: string | undefined; args: readonly string[] }
292
293export const subcommandOf = (argv: readonly string[], valueFlags: ReadonlySet<string>): Subcommand => {
294 let i = 1
295 while (i < argv.length && argv[i]!.startsWith('-')) i += valueFlags.has(argv[i]!) ? 2 : 1
296 return { globals: argv.slice(1, i), sub: argv[i], args: argv.slice(i + 1) }
297}
298
299const XARGS_VALUE_FLAGS = new Set(['-I', '-J', '-L', '-n', '-P', '-s', '-d', '-E', '-R', '-S', '-a', '--max-args', '--max-procs', '--max-lines', '--delimiter', '--arg-file', '--eof'])
300
301// `ls | xargs chmod 777` reads as `chmod 777` for a rule about the command
302// that runs.
303export const withoutXargs = (argv: readonly string[]): readonly string[] => {
304 if (argv[0] === undefined || base(argv[0]) !== 'xargs') return argv
305 const { sub, args } = subcommandOf(argv, XARGS_VALUE_FLAGS)
306 return sub === undefined ? [] : [sub, ...args]
307}
308
309// Every simple command in a command line as the argv that really runs, past
310// sudo and xargs.
311export const commandsOf = (command: string): ReadonlyArray<readonly string[]> =>
312 segmentsOf(command).map(({ argv }) => withoutSudo(withoutXargs(withoutSudo(argv))))
313
314// True when a short option cluster (`-fdx`) holds the letter.
315export const hasShortFlag = (word: string, letter: string): boolean => /^-[a-zA-Z]+$/.test(word) && word.includes(letter)
316