SLOPSHOPPER

secret-guard

Stops Claude from reading or editing .env files, private keys and credential files, including through symlinks and shell commands

newguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭───────────────────────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ secret-guard blocked Bash on .env │ ⎿ Read 6 lines ╰───────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-guard: secret-guard: .env looks like a secret (env file, key or credentials), so Bash was ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Studio Mods

Small, tested mods for Claude Code. Each one does one job, asks for the least access it can, and says exactly what it touches.

Mods need Claude Code 2.1.287 or later. They run in the terminal and in the Claude desktop app's Code tab.

Install

Type the line at the prompt of a terminal Claude Code session, answer y to add the marketplace, then pick a scope:

/plugin install secret-guard --marketplace Sajid-Rahman0126/claude-studio-mods

Swap secret-guard for touched-files or kanban-status to install the others.

The mods

secret-guard

Blocks Claude's Read, Edit and Write tools, and shell commands, from touching files that usually hold secrets: .env and .env.*, *.pem / *.key / *.p12, SSH private keys, ~/.aws/credentials, .npmrc, .netrc, .pypirc, Docker and GitHub CLI auth files, and *credentials*.json / *service-account*.json. Template files such as .env.example stay allowed. Symlinks are followed, so a harmless-looking link to a key is still blocked. When it blocks something, Claude is told why, and you see a short notice.

  • Access it uses: checks the path of each Read, Edit, Write and Bash call, and looks up where a path really points. It never reads file contents and never uses the network.
  • Limits: this is a deny-list, so it is best effort. Hard links, copies under a new name, or a script that builds the path at run time can get past it. Treat it as a seatbelt, not a vault.

touched-files

Keeps a list of every file Claude edited or wrote in this session. The status line shows a count, and /touched opens a pane with each file and how many times it changed.

  • Access it uses: watches Edit, Write and NotebookEdit calls after they finish, and reads the time. It reads no files and never uses the network.

kanban-status

Shows an Obsidian Kanban board in the status line, such as Board · Todo 3 · Doing 1. /board opens a pane with every open card. Works with any Markdown file that uses ## Column headings and - [ ] cards.

  • Access it uses: reads one file, BOARD.md in the session folder by default. Change it with the Board file option in the plugin's settings. It writes nothing and never uses the network.

Safety

Mods run with your permissions and are not sandboxed. Read the code before installing any mod, these included; each one is a few dozen lines in mods/<name>/hooks/. To start Claude Code with no mods loaded, run claude --safe-mode.

Development

Each mod has tests. From a mod's folder:

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.ts 40 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { isSecretPath, secretInCommand } from './guard'
4
5async function blocked($: EngineInterface, path: string): Promise<string | undefined> {
6  if (isSecretPath(path)) return path
7  // Follow symlinks: a harmless-looking link can point at a secret file.
8  const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
9  return stat?.realPath && isSecretPath(stat.realPath) ? stat.realPath : undefined
10}
11
12function denial($: EngineInterface, tool: string, what: string): { deny: string } {
13  $.ui.toast(`secret-guard blocked ${tool} on ${what}`)
14  return {
15    deny: `secret-guard: ${what} looks like a secret (env file, key or credentials), so ${tool} was blocked. Ask the user to share only the specific non-secret value you need, or to disable the secret-guard plugin.`,
16  }
17}
18
19export const register: Register = on => {
20  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
21    const hit = await blocked($, e.file_path)
22    return hit ? denial($, 'Read', hit) : next(e)
23  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: its check failed, so the call was blocked.' }))
24
25  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
26    const hit = await blocked($, e.file_path)
27    return hit ? denial($, 'Edit', hit) : next(e)
28  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: its check failed, so the call was blocked.' }))
29
30  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
31    const hit = await blocked($, e.file_path)
32    return hit ? denial($, 'Write', hit) : next(e)
33  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: its check failed, so the call was blocked.' }))
34
35  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
36    const hit = secretInCommand(e.command)
37    return hit ? denial($, 'Bash', hit) : next(e)
38  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: its check failed, so the call was blocked.' }))
39}
40
hooks/guard.ts 29 lines
1// Path rules for secret files. Best effort: a determined process can still find a way
2// around a deny-list (hard links, copies, encoded paths). This catches the common cases.
3
4const TEMPLATE = /\.env\.(example|sample|template|dist|defaults)$/i
5
6const SECRET_NAMES: RegExp[] = [
7  /(^|\/)\.env$/i,
8  /(^|\/)\.env\.[^/]+$/i,
9  /\.(pem|key|p12|pfx|keystore|jks)$/i,
10  /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/i,
11  /(^|\/)\.aws\/credentials$/i,
12  /(^|\/)\.(npmrc|netrc|pypirc|pgpass)$/i,
13  /(^|\/)\.docker\/config\.json$/i,
14  /(^|\/)\.config\/gh\/hosts\.yml$/i,
15  /(^|\/)[^/]*(service-account|credentials)[^/]*\.json$/i,
16]
17
18export function isSecretPath(path: string): boolean {
19  const clean = path.trim().replace(/^["']|["']$/g, '')
20  if (!clean || TEMPLATE.test(clean)) return false
21  return SECRET_NAMES.some(rule => rule.test(clean))
22}
23
24// Splits a shell command into path-like words and checks each one.
25export function secretInCommand(command: string): string | undefined {
26  const words = command.split(/[\s;|&<>()`=]+/).filter(Boolean)
27  return words.find(word => isSecretPath(word))
28}
29