Stop secrets leaving the machine. Holds an edit or command that writes an API key, a private key, or a credential literal, a command that reads a secret file…

Stop secrets leaving the machine. Claude pastes a key into a config file that's about to be committed, puts a token on a curl command line, or runs curl -d @.env to "check the endpoint." This mod holds those calls with a question whose default answer is refuse, and never prints the secret itself.
The redaction mods in the ecosystem (secret-redactor, honmoon-redact) hide secrets from what Claude reads. This covers the other direction: what Claude writes, runs, and commits.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install tripwire@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/tripwire
Nothing to do. Three things are held:
| Held | Examples | |
|---|---|---|
| An Edit, Write, MultiEdit, or Bash command whose text contains a credential | AWS, GitHub, Slack, Stripe, OpenAI, Anthropic, and Google keys; a private key block; a JWT; password = "<12+ chars>" and similar assignments, unless the value looks like a placeholder | |
| A Bash command that reads a secret file and talks to the network or encodes output | curl -d @.env, `cat id_rsa \ | base64, scp credentials.json host:` |
A git commit whose staged changes add a credential, or stage a secret file | .env, id_rsa, *.pem, credentials.json. .env.example is fine. |
The question in Claude Code's dialog names the pattern and the line, never the value:
tripwire: this edit to src/config.ts writes an AWS access key on line 12 of the new text. Continue?
1. Refuse
2. Allow once
On refuse, Claude reads the same description plus what to do instead: read the value from an environment variable or a secret manager, write a placeholder, or unstage the file.
| Command | What it does |
|---|---|
/tripwire | Show whether it's on and what it caught this session. |
/tripwire off | Hold nothing. Remembered across sessions. |
/tripwire on | Turn it back on. |
From claude plugin validate ./mods/tripwire:
hooks: session.start, command.run{command=tripwire}, tool.call{tool=Bash|Edit|Write|MultiEdit}
calls: $.command.register, $.process.run (via stagedHit), $.store.get, $.store.set, $.ui.ask (via hold), $.ui.log
$.process.run runs git diff --cached --name-only and git diff --cached --unified=0 only when Claude is about to run git commit. Nothing else is run, and nothing leaves the machine.$.store holds the on/off flag.Failure policy. This is a guard, so if a check throws or times out the call is refused rather than let through.
claude plugin validate --strict and claude plugin test pass. /tripwire answered from a live claude -p session. The hold dialog hasn't been seen on screen by the author yet.claude -p nobody can answer, so every hold is a refusal.git push isn't scanned; the commit before it is.MIT, see the repository root.
hooks/register.ts 149 lines1// tripwire: stop secrets leaving the machine.
2//
3// /tripwire show whether it's on and what it caught this session
4// /tripwire off let everything through
5// /tripwire on turn it back on
6//
7// Three things are held, with a question whose default answer is refuse:
8// 1. An Edit, Write, or Bash command whose text contains a credential:
9// an AWS, GitHub, Slack, Stripe, OpenAI, Anthropic, or Google key, a
10// private key block, a JWT, or an assignment of a long literal to a
11// name like password, secret, token, or api_key.
12// 2. A Bash command that reads a secret file (.env, a private key, a
13// credentials file) and also talks to the network or encodes output.
14// 3. A git commit whose staged changes add a credential or stage a secret file.
15// The secret itself is never printed: Claude and the transcript see the
16// pattern's name and the line, with the value masked.
17
18type Hit = { what: string; line: number }
19
20const SECRETS: [string, RegExp][] = [
21 ['an AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
22 ['an AWS secret key', /aws_secret_access_key\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}\b/i],
23 ['a GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/],
24 ['a Slack token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
25 ['a Stripe live key', /\b[sr]k_live_[A-Za-z0-9]{20,}\b/],
26 ['an OpenAI key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/],
27 ['an Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}\b/],
28 ['a Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
29 ['a private key block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP |ENCRYPTED )?PRIVATE KEY-----/],
30 ['a JWT', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/],
31 ['a credential assigned as a literal', /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\b\s*[=:]\s*['"]([^'"\s]{12,})['"]/i],
32]
33// Values that look like placeholders, not secrets.
34const PLACEHOLDER = /example|placeholder|your[-_ ]|xxx|<[^>]+>|\$\{|\$[A-Z_]|changeme|dummy|redacted|\.\.\./i
35
36const SECRET_FILE = /(?:^|[\s"'=/@:])(?:\.env(?:\.[\w.-]+)?|id_rsa|id_ed25519|id_ecdsa|[\w.-]+\.pem|[\w.-]+\.p12|[\w.-]+\.pfx|credentials(?:\.json)?|\.netrc|\.npmrc|\.pypirc|service-account[\w.-]*\.json)\b/
37const NETWORK = /\b(?:curl|wget|nc|ncat|netcat|ssh|scp|rsync|sftp|ftp|telnet|base64|openssl\s+enc|python[23]?\s+-c|node\s+-e)\b/
38
39let enabled = true
40const caught: string[] = []
41
42function findSecret(text: string): Hit | null {
43 const lines = text.split('\n')
44 for (let i = 0; i < lines.length; i++) {
45 for (const [what, re] of SECRETS) {
46 const m = re.exec(lines[i])
47 if (!m) continue
48 if (what === 'a credential assigned as a literal' && PLACEHOLDER.test(m[1] ?? '')) continue
49 return { what, line: i + 1 }
50 }
51 }
52 return null
53}
54
55function isExfil(command: string): boolean {
56 return SECRET_FILE.test(command) && NETWORK.test(command)
57}
58
59function isCommit(command: string): boolean {
60 return /\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(command)
61}
62
63async function stagedHit($): Promise<string | null> {
64 try {
65 const names = await $.process.run(['git', 'diff', '--cached', '--name-only'])
66 for (const name of names.stdout.split('\n')) {
67 if (name && SECRET_FILE.test(name) && !/\.example$|\.sample$|\.template$/.test(name)) return 'stages the secret file ' + name
68 }
69 const diff = await $.process.run(['git', 'diff', '--cached', '--unified=0'])
70 const added = diff.stdout.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1)).join('\n')
71 const hit = findSecret(added)
72 return hit ? 'adds ' + hit.what + ' in the staged changes' : null
73 } catch (error) {
74 throw new Error('could not read the staged changes: ' + error)
75 }
76}
77
78async function hold($, what: string, advice: string) {
79 caught.push(what)
80 let answer = 'Refuse'
81 try {
82 answer = await $.ui.ask('tripwire: this ' + what + '. Continue?', ['Refuse', 'Allow once'])
83 } catch {
84 // Nobody to ask: refuse.
85 }
86 if (answer === 'Allow once') return null
87 return { deny: 'tripwire: refused because this ' + what + '. ' + advice }
88}
89
90async function guard($, e, next) {
91 if (!enabled) return next(e)
92 if (e.tool === 'Bash') {
93 const command = String(e.command ?? '')
94 const secret = findSecret(command)
95 if (secret) {
96 const d = await hold($, 'command contains ' + secret.what, 'Never put a credential on a command line. Read it from an environment variable or a secret manager, and ask the user if you do not know where it lives.')
97 if (d) return d
98 }
99 if (isExfil(command)) {
100 const d = await hold($, 'command reads a secret file and talks to the network', 'Do not send secret files anywhere. If the user needs this, they can do it themselves.')
101 if (d) return d
102 }
103 if (isCommit(command)) {
104 const why = await stagedHit($)
105 if (why) {
106 const d = await hold($, 'commit ' + why, 'Unstage the secret, move it to an environment variable or a .gitignored file, and commit again.')
107 if (d) return d
108 }
109 }
110 return next(e)
111 }
112 const text = e.tool === 'Write' ? String(e.content ?? '') : e.tool === 'MultiEdit' ? (Array.isArray(e.edits) ? e.edits.map((x) => x.new_string ?? '').join('\n') : '') : String(e.new_string ?? '')
113 const hit = findSecret(text)
114 if (!hit) return next(e)
115 const d = await hold($, 'edit to ' + String(e.file_path ?? 'a file') + ' writes ' + hit.what + ' on line ' + hit.line + ' of the new text', 'Do not write credentials into files. Reference an environment variable instead, or write a placeholder and tell the user where to put the real value.')
116 return d ?? next(e)
117}
118
119export function register(on) {
120 on('session.start', async ($, e, next) => {
121 try {
122 enabled = (await $.store.get('enabled')) !== false
123 } catch {
124 enabled = true
125 }
126 try {
127 await $.command.register({ name: 'tripwire', description: 'Hold edits, commands, and commits that would leak a secret', argumentHint: '[on | off]', immediate: true })
128 } catch (error) {
129 $.ui.log('could not register /tripwire: ' + error)
130 }
131 return next(e)
132 })
133
134 on('command.run', { command: 'tripwire' }, async ($, e) => {
135 const args = e.args.trim()
136 if (args === 'on' || args === 'off') {
137 enabled = args === 'on'
138 await $.store.set('enabled', enabled)
139 return { text: enabled ? 'tripwire on.' : 'tripwire off. Nothing is held.' }
140 }
141 return { text: (enabled ? 'tripwire on' : 'tripwire off') + '. Caught this session:\n' + (caught.length ? caught.map((c) => ' ' + c).join('\n') : ' nothing') }
142 }).catch(async () => ({ text: 'tripwire: the command failed, so nothing changed.' }))
143
144 on('tool.call', { tool: ['Bash', 'Edit', 'Write', 'MultiEdit'] }, guard).catch(async ($, e, next) => {
145 if (next.called) return next(e)
146 return { deny: 'tripwire: could not check this call (' + next.error.kind + '), so it was not run. Try again.' }
147 })
148}
149