SLOPSHOPPER

env-protect

Asks the user before Claude reads secret files (.env, private keys, .netrc, .npmrc, .aws/credentials, keychains…) with Read, Grep, Glob or Bash

newguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · env-protect
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /env-protect ⎿ env-protect: env-protect is ON. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

env-protect

Asks for confirmation before Claude reads a secret file, judged by file name.

What it does

  • Checks Read, Grep, Glob and Bash calls; a match turns the verdict into an ask (permission prompt) with the reason. A call already denied stays denied.
  • Secret names: .env and .env.* (except .example, .sample, .template), *.pem, *.key, *.p12, keychains (*.keychain*), SSH private keys (id_rsa, id_ed25519, id_ecdsa, id_dsa, not .pub), .netrc, .npmrc, .pypirc, .aws/credentials.
  • Read checks file_path; Grep/Glob check path and the glob when it names a secret file directly (**/.env, *.pem), not a broad one (**/*).
  • Bash: readers such as cat, head, tail, less, source, ., grep, rg, awk, sed, cp, scp, rsync, openssl, base64, xargs, open, including < file and --flag=file; wrappers like sudo, env, rtk are looked through. Also security find-generic-password|find-internet-password with -w or -g.

Commands

CommandEffect
/env-protect statusShow whether checks are on (default when no argument)
/env-protect offDisable for this session (status line shows env-protect: OFF)
/env-protect onRe-enable

Install

claude --plugin-dir /path/to/ModsTools/mods/env-protect

Limits

  • Matches file names only: grep -r KEY ., paths built from variables or $(...), scripts that read the file themselves, symlinks and renamed copies are not seen.
  • Word-level parsing: a seatbelt against accidental reads, not a security boundary.
  • Asks through the permission system: in auto / bypassPermissions mode the mode settles the question.

Develop

claude plugin validate mods/env-protect
claude plugin test mods/env-protect   # 76 tests
Source 3 files
hooks/register.ts 57 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { checkBash, checkFileTool } from './rules'
5
6const isOff = atom({ plugin: 'env-protect', key: 'isOff' } as const, false)
7
8async function escalate<T extends { decision: string }>(
9  $: EngineInterface,
10  verdict: T,
11  why: string | null,
12): Promise<T | { decision: 'ask'; reason: string }> {
13  if (why === null || verdict.decision === 'deny' || (await read($, isOff))) return verdict
14
15  return {
16    decision: 'ask',
17    reason: `env-protect: this reads a secret file (${why}). Confirm only if the user expects it. If refused, ask the user: they can paste what is needed or stop the checks with /env-protect off.`,
18  }
19}
20
21export const register: Register = on => {
22  on('session.start', async ($, e, next) => {
23    await $.command.register({
24      name: 'env-protect',
25      description: 'env-protect on|off|status: ask before reading secret files (.env, private keys, .netrc…)',
26    })
27
28    return next(e)
29  })
30
31  on('command.run', { command: 'env-protect' }, async ($, e) => {
32    const arg = e.args.trim()
33    if (arg === 'off' || arg === 'on') {
34      await update($, isOff, () => arg === 'off')
35      $.ui.status(arg === 'off' ? 'env-protect: OFF' : undefined)
36    }
37    const off = await read($, isOff)
38
39    return { text: `env-protect is ${off ? 'OFF for this session' : 'ON'}.` }
40  })
41
42  for (const tool of ['Read', 'Grep', 'Glob'] as const) {
43    on('tool.check', { tool }, async ($, e, next) => {
44      const verdict = await next(e)
45
46      return escalate($, verdict, checkFileTool(tool, (e.input ?? {}) as Record<string, unknown>))
47    })
48  }
49
50  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
51    const verdict = await next(e)
52    const command = (e.input as { command?: unknown }).command
53
54    return escalate($, verdict, typeof command === 'string' ? checkBash(command) : null)
55  })
56}
57
hooks/rules.ts 104 lines
1// Word-level parsing, like rm-guard: no shell grammar. Not covered: `grep -r KEY .` (no file named),
2// variables or command substitutions that build a path, scripts that read the file themselves,
3// `dotenv`-style loaders, and a file reached through a symlink or a renamed copy.
4
5const SAFE_ENV_SUFFIXES = new Set(['example', 'sample', 'template'])
6
7const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1)
8
9/** True when the path names a secret file (private key, credentials, .env…), judged by its name alone. */
10export function isSecretPath(path: string): boolean {
11  const name = baseName(path.replace(/\/+$/, '')).toLowerCase()
12  if (name === '') return false
13  if (name === '.env' || name.startsWith('.env.')) {
14    return !SAFE_ENV_SUFFIXES.has(name.slice(name.lastIndexOf('.') + 1))
15  }
16  if (name.startsWith('.env') && /[*?[]/.test(name)) return true
17  if (/\.(pem|key|p12)$/.test(name) || name.includes('.keychain')) return true
18  if (/^id_(rsa|ed25519|ecdsa|dsa)(_.*)?$/.test(name) && !name.endsWith('.pub')) return true
19  if (name === '.netrc' || name === '.npmrc' || name === '.pypirc') return true
20
21  return name === 'credentials' && path.split('/').includes('.aws')
22}
23
24// Commands that read or copy the files named in their arguments.
25const READERS = new Set([
26  'cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'tac', 'xxd', 'hexdump', 'od', 'strings', 'base64',
27  'source', '.', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'sed', 'cut', 'sort', 'uniq', 'diff',
28  'cp', 'scp', 'rsync', 'openssl', 'ssh-keygen', 'gpg', 'xargs', 'open',
29])
30
31const WRAPPERS: Record<string, Set<string>> = {
32  sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
33  env: new Set(['-u', '-C', '-S']),
34  command: new Set(),
35  nohup: new Set(),
36  time: new Set(),
37  nice: new Set(['-n']),
38  rtk: new Set(),
39}
40
41const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
42
43const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
44
45function strip(words: string[]): string[] {
46  let i = 0
47  for (;;) {
48    while (words[i] !== undefined && isAssignment(words[i]!)) i++
49    const takesArg = WRAPPERS[words[i] ?? '']
50    if (takesArg === undefined) break
51    i++
52    while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
53  }
54
55  return words.slice(i)
56}
57
58/** Each simple command of a shell line as unquoted words, wrappers dropped; `$(…)`, backticks and parentheses split too. */
59export function segments(command: string): string[][] {
60  return command
61    .split(/&&|\|\||\$\(|[;|&\n`()]/)
62    .map(part => strip(part.trim().split(/\s+/).filter(Boolean).map(unquote)))
63    .filter(words => words.length > 0)
64}
65
66// `--flag=path` and `<path` count as the path.
67const operand = (word: string) => word.replace(/^<+/, '').replace(/^--[A-Za-z-]+=/, '')
68
69function checkWords(words: string[]): string | null {
70  const name = baseName(words[0] ?? '')
71  const args = words.slice(1)
72  if (name === 'security' && /^find-(generic|internet)-password$/.test(args[0] ?? '')) {
73    if (args.some(a => /^-[A-Za-z]*[wg][A-Za-z]*$/.test(a))) return 'keychain password'
74  }
75  if (!READERS.has(name)) return null
76  const secret = args.map(unquote).map(operand).find(isSecretPath)
77
78  return secret === undefined ? null : `${name} ${secret}`
79}
80
81/** Why the Bash command reads a secret file, or null when it does not. */
82export function checkBash(command: string): string | null {
83  for (const words of segments(command)) {
84    const reason = checkWords(words)
85    if (reason !== null) return reason
86  }
87
88  return null
89}
90
91// A glob that names a secret file directly (`**/.env`, `*.pem`), not a broad one (`**/*`).
92const isSecretGlob = (glob: string) => isSecretPath(glob) || /(^|\/)\.env\*?$|\*\.(pem|key|p12)$/.test(glob)
93
94/** Why a Read/Grep/Glob call targets a secret file, or null. */
95export function checkFileTool(tool: string, input: Record<string, unknown>): string | null {
96  const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
97  if (tool === 'Read') return isSecretPath(str('file_path')) ? str('file_path') : null
98  const path = str('path')
99  if (path !== '' && isSecretPath(path)) return path
100  const glob = tool === 'Glob' ? str('pattern') : str('glob')
101
102  return glob !== '' && isSecretGlob(glob) ? glob : null
103}
104
types/index.d.ts 8 lines
1export type EnvProtectSwitch = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'env-protect': { isOff: EnvProtectSwitch }
6  }
7}
8