For repos where several AI agents (or you and an agent) work in the same folder: stops secret values from leaving your .env, pushes to public remotes, and git…

A Claude Code mod for repos where more than one AI agent works in the same folder.
I run Claude Code and a second coding agent on one repository, often at the same time and under the same git name. Over two years that setup produced these incidents:
Popular guard collections already block reading .env and force-pushing to main. They don't cover the incidents above, so this mod handles those.
| What happens | Why | |
|---|---|---|
| Secret values | Any shell command, file write, or tool call (browser, MCP and others) that contains the actual value of a secret from your .env is refused. If a value turns up in a command's output, it is masked before the model sees it. | Blocking the .env file isn't enough once the value has been copied somewhere else. |
| Other agents' work | git reset --hard, checkout ., restore, stash and clean -f ask you first when the tree holds uncommitted files that this session did not write. They are refused when nobody is there to answer. git add -A (and the same thing written as ., ./, :/ or *) is refused in the same situation. | Another agent's half-finished work looks like junk to the agent that didn't write it. |
| Public remotes | Pushes to remotes you mark as public are refused, and so are push --all and --mirror. | A repo with a private origin and a public mirror is one typo away from publishing everything. |
Printing .env | cat .env and similar commands are refused, and so is opening .env with the Read tool. .env.example is allowed. |
Commands
/guard shows how many secret values are watched, which remotes are marked public, what was stopped, and which uncommitted files are protected./preflight runs before you push. It checks unpushed commits for line-ending churn, secret-looking strings (Stripe, OpenAI/Anthropic, GitHub, Google, AWS, private keys, JWTs), committed .env files, and files this session did not write.While protected files exist, a quiet line above the prompt shows how many there are.
/plugin marketplace add naoanao/shared-repo-guard
/plugin install shared-repo-guard@shared-repo-guard
The install screen asks for two optional settings:
public, mirror..env files below the repository root, for example apps/web/.env.local. Every .env* file at the root is watched automatically..env files when the session starts. They are kept in memory only and never written, logged, or shown. A key counts as a secret when its name contains SECRET, KEY, TOKEN, PASSWORD, PRIVATE, CREDENTIAL, WEBHOOK, SIGNING, AUTH, DSN or DATABASE_URL and its value is at least 12 characters long. Public build variables (NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_, REACT_APP_) are skipped.-m "..." of git commit, and a here-document given to git commit or written to a file. A message holding $(...) or backticks is still checked, because the shell runs those..env* files in the repository root, plus any files you list under "Extra .env files". It reads them only to learn the secret values to watch for. The values stay in memory and are compared against what tools are about to run or return.git, through its own read-only queries:git rev-parse --show-toplevel to find the repository rootgit rev-parse --abbrev-ref --symbolic-full-name @{u} to find the upstream branchgit status --porcelain=v1 -z --untracked-files=all to list uncommitted filesgit log <upstream>..HEAD, plus git diff (with --shortstat, --ignore-cr-at-eol --shortstat and --name-only against the upstream) for /preflightNone of these change your repository.
[shared-repo-guard: secret hidden].tool.call on Bash, PowerShell, Read, Write, Edit and every other tool, for the checks abovesession.start and turn.complete to refresh the list of uncommitted filesui.render for the one-line notice above the promptcommand.run for /guard and /preflightrm -rf and force pushes. This mod targets a different loss: uncommitted work that belongs to someone else.;, &&, || and new lines are split, but quoting is not. That way it errs toward stopping.claude plugin test .
There are 13 tests. They cover the rules plus the whole mod running in Claude Code's test engine, with a fake git and a fake .env. Each guard was broken on purpose once to confirm that its test fails for the right reason.
Made by nao, an AI consultant who builds. I design and run AI automation for small businesses, from first conversation to production. Portfolio: https://growl-ai.com/portfolio/en
Also by me: agent-cross-check. When another coding agent commits to your repo, Claude notices and audits the commits by diff and tests, not by the agent's report.
MIT License.
同じフォルダで複数の AI に作業させている人のための、Claude Code の見張りです。
止めるものは次のとおりです。
.env にある鍵の値が、コマンド・ファイル・ほかの道具に入ろうとしたら止めます。コマンドの出力に鍵の値が出たときは、伏せ字にしてから AI に渡します。git reset --hard などの消すコマンドは、実行する前にあなたに確かめます。答える人がいなければ止めます。git add -A(.・./・:/・* と書いた場合も)も同じ状況では止めます。保存のメモの中の文字では止めません($(…) を含むメモは確かめます)。.env の表示:.env の中身を画面に出すことを止めます。入れ方は上の「Install」と同じです。/guard で今の見張りの状態を、/preflight で送る前の点検(改行の変化・鍵らしい文字・.env の混入・別の AI の作業)を見られます。
hooks/register.tsx 191 lines1import {
2 parseList, pushToPublic, addAll, destructiveGit, printsEnvFile, readsEnvFile,
3 secretValuesFrom, containsSecret, redact, statusPaths, relativeToRoot, foreignChanges,
4 secretPatternHits, lineEndingChurn,
5} from './rules.js'
6
7// Held only inside this module. Never written to the screen, a log or a file.
8let secrets = []
9let blocked = 0
10let root = ''
11let written = []
12let foreign = []
13let publicRemotes = []
14let extraEnvFiles = []
15
16async function git($, args) {
17 try {
18 const r = await $.process.run(['git', ...args], { cwd: root || undefined, timeoutMs: 20000 })
19 return r.exitCode === 0 ? String(r.stdout || '') : ''
20 } catch {
21 return ''
22 }
23}
24
25async function findRoot($) {
26 try {
27 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 10000 })
28 root = r.exitCode === 0 ? String(r.stdout || '').trim() : ''
29 } catch {
30 root = ''
31 }
32}
33
34async function loadSecrets($) {
35 const found = new Set()
36 const base = root || (await $.session.cwd())
37 const files = new Set(extraEnvFiles.map((f) => base + '/' + f))
38 try {
39 for (const entry of await $.fs.list(base)) {
40 const n = String(entry?.name || '')
41 if (/^\.env(\.|$)/.test(n) && !/\.(example|sample|template)$/.test(n)) files.add(base + '/' + n)
42 }
43 } catch {
44 // an unreadable folder adds nothing
45 }
46 for (const f of files) {
47 try {
48 if (!(await $.fs.exists(f))) continue
49 for (const v of secretValuesFrom(await $.fs.read(f))) found.add(v)
50 } catch {
51 // an unreadable file is skipped
52 }
53 }
54 secrets = [...found]
55}
56
57async function refreshForeign($) {
58 if (!root) {
59 foreign = []
60 return
61 }
62 foreign = foreignChanges(statusPaths(await git($, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])), written)
63 $.ui.invalidate('ui.render')
64}
65
66function listFew(paths) {
67 const head = paths.slice(0, 10).join('\n ')
68 return ' ' + head + (paths.length > 10 ? '\n ...and ' + (paths.length - 10) + ' more' : '')
69}
70
71function deny($, reason) {
72 blocked += 1
73 $.ui.toast('shared-repo-guard stopped: ' + reason.split('.')[0])
74 return { deny: 'shared-repo-guard: ' + reason }
75}
76
77async function preflight($) {
78 if (!root) return 'Not inside a git repository.'
79 const out = []
80 const upstream = (await git($, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim()
81 if (!upstream) {
82 out.push('No upstream branch, so there is nothing to compare against.')
83 } else {
84 const log = (await git($, ['log', upstream + '..HEAD', '--format=%h %ad %an %s', '--date=short'])).trim()
85 out.push('Unpushed commits on top of ' + upstream + ': ' + (log ? log.split('\n').length : 0))
86 if (log) {
87 out.push(log)
88 const churn = lineEndingChurn(
89 await git($, ['diff', '--shortstat', upstream, 'HEAD']),
90 await git($, ['diff', '--ignore-cr-at-eol', '--shortstat', upstream, 'HEAD']),
91 )
92 out.push('Line endings: ' + (churn ? 'CHANGED (diff shows ' + churn.shown + ' lines, real content ' + churn.real + ')' : 'unchanged'))
93 const hits = secretPatternHits(await git($, ['diff', upstream, 'HEAD']))
94 out.push('Secret-looking strings: ' + (hits.length ? hits.join(', ') : 'none'))
95 const files = (await git($, ['diff', '--name-only', upstream, 'HEAD'])).trim()
96 const envs = files.split('\n').filter((f) => /(^|\/)\.env(\.|$)/.test(f) && !/\.(example|sample|template)$/.test(f))
97 if (envs.length) out.push('.env files in the commits: ' + envs.join(', '))
98 }
99 }
100 await refreshForeign($)
101 out.push('Uncommitted files this session did not write: ' + foreign.length + (foreign.length ? '\n' + listFew(foreign) : ''))
102 return out.join('\n')
103}
104
105export function register(on, options) {
106 publicRemotes = parseList(options?.publicRemotes)
107 extraEnvFiles = parseList(options?.envFiles)
108
109 on('session.start', async ($, e, next) => {
110 await findRoot($)
111 await loadSecrets($)
112 await $.command.register({ name: 'guard', description: 'shared-repo-guard: what it watches and what it stopped' })
113 await $.command.register({ name: 'preflight', description: 'Check unpushed commits: line endings, secrets, .env files, other agents\' changes' })
114 await refreshForeign($)
115 return next(e)
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 await refreshForeign($)
120 return next(e)
121 })
122
123 on('command.run', { command: 'guard' }, async ($) => {
124 await loadSecrets($)
125 await refreshForeign($)
126 return {
127 text: [
128 'Secret values watched: ' + secrets.length,
129 'Public remotes: ' + (publicRemotes.length ? publicRemotes.join(', ') : 'none set'),
130 'Stopped this session: ' + blocked,
131 'Uncommitted files this session did not write (protected): ' + foreign.length + (foreign.length ? '\n' + listFew(foreign) : ''),
132 ].join('\n'),
133 }
134 })
135
136 on('command.run', { command: 'preflight' }, async ($) => ({ text: await preflight($) }))
137
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 if (e.props.hasSurvey || foreign.length === 0) return next(e)
140 const { Text } = $.ui.resolve(e)
141 return <Text dimColor>shared-repo-guard: {foreign.length} uncommitted file(s) from outside this session are protected (/guard)</Text>
142 })
143
144 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
145 const cmd = String(e.command || '')
146 const r = pushToPublic(cmd, publicRemotes) || printsEnvFile(cmd)
147 if (r) return deny($, r)
148 if (containsSecret(cmd, secrets)) return deny($, 'The command contains a secret value from your .env. Read it from the environment instead of writing it out.')
149 if (addAll(cmd)) {
150 await refreshForeign($)
151 if (foreign.length) return deny($, 'git add -A would stage ' + foreign.length + ' file(s) this session did not write. Add your own files by name.\n' + listFew(foreign))
152 }
153 const d = destructiveGit(cmd)
154 if (d) {
155 await refreshForeign($)
156 if (foreign.length) {
157 let answer = 'Stop'
158 try {
159 answer = await $.ui.ask(d + ' would throw away uncommitted work this session did not write (' + foreign.length + ' file(s)):\n' + listFew(foreign) + '\nRun it anyway?', ['Stop', 'Run anyway'])
160 } catch {
161 // nobody to answer: stop
162 }
163 if (answer !== 'Run anyway') return deny($, d + ' was stopped because it would discard work this session did not write. Leave those files alone.')
164 }
165 }
166 return redact(await next(e), secrets)
167 }).catch(async () => ({ deny: 'shared-repo-guard could not check this command, so it did not run.' }))
168
169 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
170 const r = readsEnvFile(e.file_path)
171 if (r) return deny($, r)
172 return redact(await next(e), secrets)
173 })
174
175 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
176 const text = String(e.content || '') + '\n' + String(e.new_string || '')
177 if (containsSecret(text, secrets)) return deny($, 'This writes a secret value into a file. Refer to the variable name instead.')
178 const result = await next(e)
179 const rel = relativeToRoot(String(e.file_path || ''), root)
180 if (rel && !written.includes(rel)) written.push(rel)
181 return result
182 })
183
184 on('tool.call', { tool: /^(?!(Bash|PowerShell|Read|Write|Edit)$)/ }, async ($, e, next) => {
185 let text = ''
186 try { text = JSON.stringify(e) } catch { text = '' }
187 if (containsSecret(text, secrets)) return deny($, 'This passes a secret value to ' + e.tool + '.')
188 return next(e)
189 })
190}
191hooks/rules.js 215 lines1// Pure decision functions. No engine calls here, so the tests can call them directly.
2
3// Split a shell line on ; && || and newlines. Quotes are not parsed: rough, but it errs toward stopping.
4function splitCommands(cmd) {
5 return String(cmd || '').split(/;|&&|\|\||\n/)
6}
7
8// "word" and 'word' become word; quoted text with spaces is left as it is.
9function unquoteWords(s) {
10 return String(s || '').replace(/(["'])([^"'\s]+)\1/g, '$2')
11}
12
13// Drop commit messages, which are text and never run: the -m "..." of git commit, and the body of a
14// here-document given to git commit or written to a file. A here-document that is run (bash <<EOF) stays,
15// and so does any message holding $(...) or backticks, because the shell runs those.
16function withoutMessages(cmd) {
17 let s = String(cmd || '')
18 s = s.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g, (all, q, tag, rest, body, offset) => {
19 if (/\$\(|`/.test(body)) return all
20 const opener = s.slice(s.lastIndexOf('\n', offset) + 1, offset) + rest
21 return /\bgit\s+commit\b/.test(opener) || />/.test(opener) ? '<<' + tag + rest + '\n' + tag : all
22 })
23 if (/\bgit\s+commit\b/.test(s)) {
24 s = s.replace(/(\s(?:-m|--message)(?:=|\s+))("(?:[^"\\]|\\.)*"|'[^']*')/g, (all, flag, msg) => (/\$\(|`/.test(msg) ? all : flag + '""'))
25 }
26 return s
27}
28
29// Comma or whitespace separated list from a userConfig string.
30export function parseList(value) {
31 return String(value || '')
32 .split(/[,\s]+/)
33 .map((s) => s.trim())
34 .filter(Boolean)
35}
36
37// git push to a remote the user marked public, or push --all / --mirror.
38export function pushToPublic(cmd, publicRemotes) {
39 for (const raw of splitCommands(withoutMessages(cmd))) {
40 // The shell drops quotes, so "public" and 'public' push to the same remote as public.
41 // Only single quoted words are unwrapped, so a message like -m "push to public" stays a message.
42 const part = unquoteWords(raw)
43 if (!/\bgit\b/.test(part) || !/\bpush\b/.test(part)) continue
44 for (const r of publicRemotes || []) {
45 const esc = r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46 if (new RegExp('\\bpush\\b.*(^|\\s)' + esc + '(\\s|$|:)').test(part)) {
47 return 'This pushes to "' + r + '", which is marked as a public remote. Push to your private remote instead.'
48 }
49 }
50 if (/\s--(all|mirror)\b/.test(part)) return 'git push --all / --mirror sends every branch at once. Push the current branch only.'
51 }
52 return null
53}
54
55// git add -A / --all / . / ./ / :/ / * (stages everything, including other agents' work)
56export function addAll(cmd) {
57 for (const part of splitCommands(withoutMessages(cmd))) {
58 const m = part.match(/\bgit\s+add\b(.*)$/)
59 if (!m) continue
60 const args = ' ' + unquoteWords(m[1]) + ' '
61 if (/\s(-A|--all|\.\/?|:\/|\*|-[a-zA-Z]*A[a-zA-Z]*)\s/.test(args)) return 'git add -A'
62 }
63 return null
64}
65
66// git commands that throw away uncommitted work with no way back.
67export function destructiveGit(cmd) {
68 for (const part of splitCommands(withoutMessages(cmd))) {
69 if (!/\bgit\b/.test(part)) continue
70 if (/\bgit\s+reset\b.*\s--hard\b/.test(part)) return 'git reset --hard'
71 if (/\bgit\s+checkout\b.*\s--(\s|$)/.test(part)) return 'git checkout -- <file>'
72 if (/\bgit\s+checkout\s+\.(\s|$)/.test(part)) return 'git checkout .'
73 if (/\bgit\s+restore\b/.test(part) && (!/\s--staged\b/.test(part) || /\s--worktree\b/.test(part))) return 'git restore'
74 if (/\bgit\s+stash\b/.test(part) && !/\bgit\s+stash\s+(list|show)\b/.test(part)) return 'git stash'
75 if (/\bgit\s+clean\b.*\s-[a-zA-Z]*f/.test(part)) return 'git clean -f'
76 }
77 return null
78}
79
80// A shell command that prints a .env file to the screen.
81export function printsEnvFile(cmd) {
82 const reader = /\b(cat|type|Get-Content|gc|more|less|head|tail|bat|nl|strings|xxd|od)\b[^|;&\n]*?[\\/\s'"]\.env(\.[\w.-]*)?\b/i
83 return reader.test(String(cmd || ''))
84 ? 'This prints a .env file. Read the value inside a script and never print it.'
85 : null
86}
87
88// The Read tool opening a .env file (.env.example is a template, so it passes).
89export function readsEnvFile(path) {
90 if (typeof path !== 'string') return null
91 const name = path.split(/[\\/]/).pop() || ''
92 if (!/^\.env(\..+)?$/.test(name)) return null
93 if (/\.(example|sample|template)$/.test(name)) return null
94 return 'Opening a .env file puts its secrets into the conversation. Use the variable names only.'
95}
96
97// Key names that hold secrets. Public build-time variables are skipped.
98const SECRET_NAME = /(SECRET|KEY|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|WEBHOOK|SIGNING|AUTH|DSN|DATABASE_URL)/i
99const PUBLIC_NAME = /^(NEXT_PUBLIC_|VITE_|PUBLIC_|EXPO_PUBLIC_|REACT_APP_)/
100
101// Secret values from a .env body. Short values are skipped to avoid matching ordinary words.
102export function secretValuesFrom(text) {
103 const out = []
104 for (const raw of String(text || '').split(/\r?\n/)) {
105 const line = raw.trim()
106 if (!line || line.startsWith('#')) continue
107 const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
108 if (!m) continue
109 const key = m[1]
110 let val = m[2].trim()
111 if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1)
112 if (PUBLIC_NAME.test(key) || !SECRET_NAME.test(key)) continue
113 if (val.length < 12) continue
114 out.push(val)
115 }
116 return out
117}
118
119export function containsSecret(text, secrets) {
120 if (!text || !secrets || secrets.length === 0) return false
121 for (const s of secrets) if (text.includes(s)) return true
122 return false
123}
124
125// Copy of a tool result with secret values masked (frozen input is never touched).
126export function redact(value, secrets) {
127 if (!secrets || secrets.length === 0) return value
128 if (typeof value === 'string') {
129 let v = value
130 for (const s of secrets) if (v.includes(s)) v = v.split(s).join('[shared-repo-guard: secret hidden]')
131 return v
132 }
133 if (Array.isArray(value)) return value.map((x) => redact(x, secrets))
134 if (value && typeof value === 'object') {
135 const o = {}
136 for (const k of Object.keys(value)) o[k] = redact(value[k], secrets)
137 return o
138 }
139 return value
140}
141
142// Paths from `git status --porcelain=v1 -z --untracked-files=all`. Renames give two entries; both count.
143export function statusPaths(z) {
144 const parts = String(z || '').split('\0').filter(Boolean)
145 const out = []
146 for (let i = 0; i < parts.length; i++) {
147 const entry = parts[i]
148 if (entry.length < 4) continue
149 out.push(normPath(entry.slice(3)))
150 if (entry[0] === 'R' || entry[0] === 'C') {
151 if (parts[i + 1]) out.push(normPath(parts[i + 1]))
152 i++
153 }
154 }
155 return out
156}
157
158// Forward slashes and lower case, so Windows and git spellings compare equal.
159export function normPath(p) {
160 return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
161}
162
163// A file path from a tool call, relative to the repository root, or null when it lies outside.
164export function relativeToRoot(filePath, root) {
165 const f = normPath(filePath)
166 const r = normPath(root).replace(/\/$/, '')
167 if (!r) return null
168 if (f.startsWith(r + '/')) return f.slice(r.length + 1)
169 return null
170}
171
172// Uncommitted paths this session did not write: another agent's or a person's work.
173export function foreignChanges(currentPaths, writtenBySession) {
174 const mine = new Set((writtenBySession || []).map(normPath))
175 return [...new Set((currentPaths || []).map(normPath))].filter((p) => !mine.has(p))
176}
177
178// Known secret formats in the added lines of a diff.
179export function secretPatternHits(diffText) {
180 const pats = {
181 'Stripe live key': /sk_live_[A-Za-z0-9]{8,}/g,
182 'Stripe test key': /sk_test_[A-Za-z0-9]{8,}/g,
183 'JWT': /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g,
184 'GitHub token': /gh[pousr]_[A-Za-z0-9]{20,}/g,
185 'OpenAI / Anthropic key': /sk-(ant-)?[A-Za-z0-9_-]{20,}/g,
186 'Google API key': /AIza[0-9A-Za-z_-]{30,}/g,
187 'AWS access key': /AKIA[0-9A-Z]{16}/g,
188 'Private key block': /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
189 }
190 const added = String(diffText || '')
191 .split(/\r?\n/)
192 .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
193 .join('\n')
194 const out = []
195 for (const [name, re] of Object.entries(pats)) {
196 const n = (added.match(re) || []).length
197 if (n > 0) out.push(name + ' x' + n)
198 }
199 return out
200}
201
202// Inserted plus deleted lines from `git diff --shortstat`.
203export function shortstatLines(s) {
204 const ins = Number((String(s).match(/(\d+) insertion/) || [])[1] || 0)
205 const del = Number((String(s).match(/(\d+) deletion/) || [])[1] || 0)
206 return ins + del
207}
208
209// A diff that is much bigger than its content once line endings are ignored.
210export function lineEndingChurn(plain, ignoringCr) {
211 const a = shortstatLines(plain)
212 const b = shortstatLines(ignoringCr)
213 return a !== b ? { shown: a, real: b } : null
214}
215