Blocks dependency changes with the wrong package manager or outside a repo, and shows repo, package manager and branch: in the status line, and above the…

A small, language-neutral scaffold you copy into a repo so coding agents start with sensible guardrails. It is Claude Code–native, with AGENTS.md as the cross-tool contract that Cursor, Codex, Copilot, and others read too.
It is deliberately small. It leans on Claude Code's built-in controls where they exist, and adds one hook for the part that has to be project-specific: running your formatter and your tests.
scaffold/ ← copy this into your repo
├── AGENTS.md project facts, commands, conventions, security (fill in)
├── CLAUDE.md imports AGENTS.md, plus a few Claude-specific notes
├── .gitignore lines to add to yours
└── .claude/
├── settings.json permission rules and hook wiring
├── hooks/
│ ├── checks.mjs runs your formatter after edits, your tests before Claude finishes
│ └── checks.json the commands it runs (empty until you fill it in)
└── skills/zenn/ /zenn: optional spec-first workflow for larger work
providers.md notes for Cursor / Copilot / Codex / Gemini / Devin
mods/ user-level Claude Code mods (see mods/README.md)
tests/ node --test "tests/*.test.mjs": the hook, the scaffold rules, the mods
cp -r /path/to/agents/scaffold/. /path/to/your-repo/
The trailing /. copies the contents, including the dot-directories. If the repo already has a .gitignore, AGENTS.md, or CLAUDE.md, merge those by hand instead of overwriting them. Node on PATH is the only requirement.
Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.
Edit .claude/hooks/checks.json. Both lists are empty by default, which turns the hook off.
{
"format": {
"py": "ruff format {file}",
"ts,tsx,js": "npx --no-install prettier --write {file}"
},
"verify": ["pytest -q"]
}
format maps file extensions to a command that runs after Claude edits a file of that type. {file} is the edited file's path, already quoted. If the command fails, its output goes back to Claude to fix.verify commands run when Claude finishes a turn in which it edited files. If one fails, Claude keeps working until it passes.CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.On macOS, Linux, or WSL2, run /sandbox in Claude Code. It confines shell commands to the project directory and to network hosts you approve. The secret paths denied in settings.json apply inside the sandbox too.
| Layer | What it covers | | :- | :- | | deny rules | Secrets are never read or written: .env*, .dev.vars*, key files, ~/.ssh, ~/.gnupg, cloud credentials (AWS, GCP, Azure, Kubernetes), tool and registry credentials (GitHub CLI, Docker, npm, PyPI, RubyGems, ~/.git-credentials, ~/.netrc). .env.example stays usable. Lockfiles (package-lock.json, pnpm-lock.yaml, *.lock, *.lockb, go.sum) are not read either: they are large, and they only change through the package manager. | | File search | settings.json sets CLAUDE_CODE_GLOB_NO_IGNORE=false, so Claude's file search respects .gitignore. By default it also lists ignored files such as node_modules and build output. | | ask rules | A person approves git push, git reset --hard, git clean, gh pr merge, CI workflow edits, and any command retried outside the sandbox. These prompt in every permission mode, including auto. | | Built into Claude Code | Writes to .claude/, .git/, .mcp.json, and shell startup files are never auto-approved. rm -rf on the project, home, or root is always stopped. settings.json also disables bypass-permissions mode. | | Checks hook | Your formatter and tests run without anyone remembering to. | | AGENTS.md | Conventions and intent. It shapes what agents try; it enforces nothing. |
There are no allow rules: nothing is pre-approved. Claude Code already runs read-only commands without asking, and saves your own "don't ask again" choices to .claude/settings.local.json.
No permission mode is pinned either. Use Manual, auto, or plan as you prefer; the deny and ask rules hold in all of them.
Bash(git push *) catches git push origin main but not git -C . push. The rules stop the usual form, not a determined workaround. For anything that must never happen, protect the branch on the remote.cat. A script that opens a file itself is only stopped by the OS sandbox.*.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.mods/ holds Claude Code mods that load into every session on the machine, whatever repo it runs in, the Desktop Code tab included. Each one fixes friction that kept repeating across real sessions:
| Mod | In short | | :- | :- | | shell-sense | Denies shell commands that are certain to fail on Windows, and says what works instead. | | package-gate | Asks you before any package install, with a registry link per package. | | secret-shield | Keeps secret files and token values out of shell reads and the transcript. | | repo-lock | Denies dependency changes with the wrong package manager or from the wrong folder. | | context-meter | A band above the prompt: context fill against quality marks (30% fading, 40% "dumb zone"), rate limits, and model and effort switchers. | | session-context | Tells Claude the repo, branch and uncommitted work with each prompt. |
Setup, the full behaviour of each, and how to work on them: mods/README.md.
node --test "tests/*.test.mjs"
The tests exercise checks.mjs, enforce the scaffold's own rules (file size limits, valid hook paths, rule syntax), and check that the mods' shared files match. Each mod also has its own tests: claude plugin test mods/<name>. Using another agent? See providers.md.
Earlier versions (per-language standards skills, command-guard hooks, the multi-provider scaffolds) live in git history.
hooks/register.tsx 95 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { CHANGES_BRANCH, LOCKFILES, type Pm, ancestors, join, pmCalls, verdict, winPath } from './rules'
5
6// The line's text, for the band the Desktop Code tab draws in place of a status line.
7const line = atom({ plugin: 'repo-lock', key: 'line' } as const, null as string | null)
8
9// The nearest lockfile at or above `dir` names the package manager. A folder's
10// lockfiles are checked at once, nearest folder first.
11async function repoPm($: EngineInterface, dir: string): Promise<Pm | undefined> {
12 for (const folder of ancestors(dir)) {
13 const found = await Promise.all(LOCKFILES.map(([file]) => $.fs.exists(`${folder}/${file}`).catch(() => false)))
14 const i = found.indexOf(true)
15 if (i >= 0) return LOCKFILES[i][1]
16 }
17 return undefined
18}
19
20// The folder the status line shows. A reload clears it, so the next command redraws.
21let shownFor: string | undefined
22
23// Status line: repo · package manager · branch. One git call answers the first
24// and last; in a repo with no commits it exits 128 but still prints both.
25async function showStatus($: EngineInterface, dir: string) {
26 shownFor = dir
27 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD'], { cwd: dir, timeoutMs: 10000 }).catch(() => undefined)
28 const [top, branch] = (r?.stdout ?? '').trim().split(/\r?\n/)
29 const text = top
30 ? [top.split('/').pop(), (await repoPm($, dir)) ?? 'no lockfile', branch].filter(Boolean).join(' · ')
31 : `${winPath(dir).split('/').pop()} · not a repo`
32 $.ui.status(text)
33 await update($, line, () => text)
34}
35
36async function guard($: EngineInterface, command: string): Promise<string | undefined> {
37 const { cd, calls } = pmCalls(command)
38 if (!calls.length) return undefined
39 const cwd = await $.session.cwd()
40 const base = cd === undefined ? cwd : join(cwd, cd)
41 for (const call of calls) {
42 const dir = call.subdir ? join(base, call.subdir) : base
43 const reason = verdict(call, await repoPm($, dir), winPath(dir))
44 if (reason) return reason
45 }
46 return undefined
47}
48
49// Redraw only when what the line shows can have changed: the folder, the branch,
50// or the lockfile. Most commands start with `cd`, so matching cd redrew every time.
51async function afterCommand($: EngineInterface, command: string) {
52 const dir = await $.session.cwd()
53 if (dir !== shownFor || CHANGES_BRANCH.test(command) || pmCalls(command).calls.length) await showStatus($, dir)
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 const result = await next(e)
59 await showStatus($, e.cwd)
60 return result
61 })
62
63 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64 const deny = await guard($, e.command)
65 if (deny) return { deny }
66 const result = await next(e)
67 await afterCommand($, e.command)
68 return result
69 })
70
71 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
72 const deny = await guard($, e.command)
73 if (deny) return { deny }
74 const result = await next(e)
75 await afterCommand($, e.command)
76 return result
77 })
78
79 // The terminal has the status line. Every other surface gets one dim line
80 // above the prompt, under whatever the engine and the plugins beneath draw.
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 const below = await next(e)
83 if (e.surface === 'terminal') return below
84 const text = await read($, line)
85 if (text === null) return below
86 const { Box, Text } = $.ui.resolve(e)
87 return (
88 <Box flexDirection="column">
89 {below}
90 <Text dimColor>{text}</Text>
91 </Box>
92 )
93 })
94}
95hooks/rules.ts 92 lines1// Pure rules for repo-lock: which package manager a folder uses, and whether a
2// command would change dependencies with a different one, or from a folder that
3// is not a project at all (the parent Z:\github_projects, for example).
4
5import { segments } from './shell'
6
7export type Pm = 'npm' | 'pnpm' | 'yarn' | 'bun'
8
9export const LOCKFILES: [string, Pm][] = [
10 ['pnpm-lock.yaml', 'pnpm'],
11 ['package-lock.json', 'npm'],
12 ['yarn.lock', 'yarn'],
13 ['bun.lock', 'bun'],
14 ['bun.lockb', 'bun'],
15]
16
17// /z/github_projects/x → Z:/github_projects/x
18export const winPath = (p: string) =>
19 p.replace(/^\/([a-zA-Z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`).replace(/\\/g, '/')
20
21export const join = (dir: string, rel: string) =>
22 /^[A-Za-z]:\//.test(winPath(rel)) ? winPath(rel) : `${winPath(dir).replace(/\/$/, '')}/${winPath(rel)}`
23
24// Folders from `dir` up to the drive root.
25export function ancestors(dir: string): string[] {
26 const parts = winPath(dir).replace(/\/$/, '').split('/')
27 return parts.map((_, i) => parts.slice(0, parts.length - i).join('/')).filter(p => p && !/^[A-Za-z]:$/.test(p))
28}
29
30const MUTATING: Record<Pm, string[]> = {
31 npm: ['install', 'i', 'in', 'isntall', 'add', 'ci', 'uninstall', 'remove', 'rm', 'un', 'update', 'up', 'upgrade', 'dedupe', 'prune'],
32 pnpm: ['install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un', 'update', 'up', 'upgrade', 'dedupe', 'prune', 'import'],
33 yarn: ['install', 'add', 'remove', 'upgrade', 'up', 'dedupe'],
34 bun: ['install', 'i', 'add', 'remove', 'rm', 'update', 'upgrade'],
35}
36const DIR_FLAGS = new Set(['--prefix', '-C', '--dir', '--cwd'])
37
38// Commands that can change the branch the status line shows.
39export const CHANGES_BRANCH = /\bgit\s+(checkout|switch|branch|init)\b/i
40
41export type PmCall = { pm: Pm; verb: string; subdir?: string; isGlobal: boolean }
42
43// The dependency-changing package manager calls in a command, and the leading
44// `cd` that sets where they run.
45export function pmCalls(command: string): { cd?: string; calls: PmCall[] } {
46 const segs = segments(command)
47 let cd: string | undefined
48 const calls: PmCall[] = []
49 for (const words of segs) {
50 const tool = words[0].toLowerCase().replace(/\.(exe|cmd)$/, '')
51 if (/^(cd|set-location|pushd|sl)$/.test(tool) && words[1]) {
52 cd = cd === undefined ? words[1] : join(cd, words[1])
53 continue
54 }
55 if (!(tool in MUTATING)) continue
56 const pm = tool as Pm
57 let subdir: string | undefined
58 let verb: string | undefined
59 for (let i = 1; i < words.length; i++) {
60 const w = words[i]
61 if (DIR_FLAGS.has(w)) subdir = words[++i]
62 else if (w.startsWith('--prefix=') || w.startsWith('--dir=')) subdir = w.split('=')[1]
63 else if (w === '--filter' || w === '-F' || w === '-w' || w === '--workspace') i += w === '-w' && pm === 'pnpm' ? 0 : 1
64 else if (!w.startsWith('-') && verb === undefined) verb = w.toLowerCase()
65 }
66 // a bare `yarn` or `bun` is an install
67 if (verb === undefined && (pm === 'yarn' || pm === 'bun') && words.length === 1) verb = 'install'
68 if (verb && MUTATING[pm].includes(verb)) {
69 calls.push({ pm, verb, subdir, isGlobal: words.includes('-g') || words.includes('--global') })
70 }
71 }
72 return { cd, calls }
73}
74
75export function verdict(call: PmCall, repoPm: Pm | undefined, dir: string): string | undefined {
76 if (call.isGlobal) return undefined // package-gate asks about global installs
77 if (repoPm === undefined) {
78 return (
79 `repo-lock: ${dir} has no lockfile in it or above it, so "${call.pm} ${call.verb}" would start a new ` +
80 'dependency tree in the wrong place. Start the command with an absolute cd into the repo, ' +
81 'for example: cd /z/github_projects/<repo> && ...'
82 )
83 }
84 if (repoPm !== call.pm) {
85 return (
86 `repo-lock: ${dir} uses ${repoPm} (its lockfile says so). "${call.pm} ${call.verb}" would write a second ` +
87 `lockfile and a different node_modules layout. Use ${repoPm} instead.`
88 )
89 }
90 return undefined
91}
92hooks/shell.ts 115 lines1// The shell tokenizer package-gate, repo-lock and secret-shield share. Each mod is
2// its own plugin folder, so each holds a copy of this file: change one, then copy
3// it to the other two. tests/mods.test.mjs fails while the copies differ.
4//
5// Splits a shell command into segments (at && || ; | & ( ) $( ` and newlines
6// outside quotes) of whitespace-separated words with their quotes removed, and
7// `<` as a word of its own. A wrapper (time, env, sudo, corepack, ...) is
8// dropped, and the script of a `bash -c` or `powershell -Command` is split in
9// turn. Heredoc bodies are skipped unless a shell runs them. Good enough for the
10// commands an agent writes; it is a gate on intent, not a shell parser.
11const WRAPPERS = new Set(['time', 'nohup', 'exec', 'sudo', 'nice', 'env', 'xargs', 'corepack', 'timeout', 'stdbuf'])
12const SHELLS = /^(bash|sh|zsh|dash|cmd|powershell|pwsh)(\.exe)?$/i
13const RUN_FLAG = /^(-[a-z]*c|\/c|-command)$/i
14const HEREDOC = /<<-?[ \t]*(['"]?)([\w.-]+)\1/y
15
16function expand(words: string[]): string[][] {
17 let w = words.filter(x => x !== '{' && x !== '}')
18 while (w.length > 1) {
19 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w = w.slice(1) // FOO=1 cmd
20 else if (w[0].startsWith('$') && w[1] === '=') w = w.slice(2) // $x = cmd
21 else if (WRAPPERS.has(w[0].toLowerCase())) {
22 w = w.slice(1)
23 while (w.length > 1 && /^-|^\d+[smhd]?$/.test(w[0])) w = w.slice(1) // sudo -E, timeout 30
24 } else break
25 }
26 const run = w.findIndex(x => RUN_FLAG.test(x))
27 if (w.length && SHELLS.test(w[0]) && run > 0 && run < w.length - 1) return segments(w.slice(run + 1).join(' '))
28 return w.length ? [w] : []
29}
30
31export function segments(command: string): string[][] {
32 const out: string[][] = []
33 let words: string[] = []
34 let word = ''
35 let quote: string | null = null
36 let has = false
37 const subs: (string | null)[] = [] // the quote each open ( or $( returns to at its )
38 let bodies: string[] = [] // heredoc end tags whose bodies start at the next newline
39 // A heredoc body is data (cat > notes.md <<EOF): skip to the line after its end tag.
40 const skipBodies = (at: number) => {
41 for (const tag of bodies) {
42 while (at < command.length) {
43 const end = command.indexOf('\n', at + 1)
44 const line = command.slice(at + 1, end < 0 ? command.length : end)
45 at = end < 0 ? command.length : end
46 if (line.trim() === tag) break
47 }
48 }
49 bodies = []
50 return at
51 }
52 const endWord = () => {
53 if (has) words.push(word)
54 word = ''
55 has = false
56 }
57 const endSegment = () => {
58 endWord()
59 if (words.length) out.push(...expand(words))
60 words = []
61 }
62 for (let i = 0; i < command.length; i++) {
63 const c = command[i]
64 const next = command[i + 1]
65 if (quote === '"' && c === '$' && next === '(') {
66 endSegment() // "$(cat x)" still runs cat x
67 subs.push(quote)
68 quote = null
69 i++
70 } else if (quote) {
71 if (c === quote) quote = null
72 else word += c
73 } else if (c === '"' || c === "'") {
74 quote = c
75 has = true
76 } else if (c === '(' || (c === '$' && next === '(')) {
77 if (c === '$') i++
78 endSegment()
79 subs.push(null)
80 } else if (c === ')') {
81 endSegment()
82 quote = subs.pop() ?? null
83 } else if (c === '<' && next === '<') {
84 HEREDOC.lastIndex = i
85 const m = command[i + 2] === '<' ? null : HEREDOC.exec(command)
86 if (m) {
87 endWord()
88 if (!SHELLS.test(words[0] ?? '')) bodies.push(m[2]) // bash <<EOF runs its body: read it
89 i = HEREDOC.lastIndex - 1
90 } else {
91 while (command[i + 1] === '<') word += command[i++] // <<< here-string
92 word += c
93 has = true
94 }
95 } else if (c === '&' && (next === '>' || command[i - 1] === '>' || command[i - 1] === '<')) {
96 word += c // 2>&1 and &> are redirects, not separators
97 has = true
98 } else if (c === '\n' || c === ';' || c === '|' || c === '&' || c === '`') {
99 if ((c === '&' || c === '|') && next === c) i++
100 endSegment()
101 if (c === '\n' && bodies.length) i = skipBodies(i)
102 } else if (c === '<' && next !== '<') {
103 endWord()
104 words.push('<')
105 } else if (/\s/.test(c)) {
106 endWord()
107 } else {
108 word += c
109 has = true
110 }
111 }
112 endSegment()
113 return out
114}
115types/index.d.ts 11 lines1// The status line's text: `repo · package manager · branch`, or null before the first read.
2export type Line = string | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'repo-lock': {
7 line: Line
8 }
9 }
10}
11