SLOPSHOPPER

Mask That Pass

Masks credentials in tool output (URL passwords, *_TOKEN / *_SECRET / *_PASSWORD values, API keys, bearer tokens, private keys) before Claude or the transcript…

newguardtoast
v0.2.0MITupdated 2026-10-02myveroai/mask-that-pass
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mask-that-pass
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ mask-that-pass │ ⏺ Read(src/auth.ts) │ mask-that-pass: masked 2 secrets in a tool │ ⎿ Read 6 lines │ result │ ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mask That Pass

Mask That Pass is a Claude Code mod that masks credentials in tool output before Claude or your transcript sees them. When a command prints a database URL, a token or a private key, Claude reads [redacted] in its place and you get a short toast saying how many values were masked.

$ cat .env
DATABASE_URL=postgresql://app:REDACTED@db.example.com:5432/app
GITHUB_TOKEN=[redacted]
TOKEN_BUDGET=4000

What it masks

KindExample (fake)Becomes
Password in a URLpostgresql://app:REDACTED@db/apppostgresql://app:REDACTED@db/app
*PASSWORD*, *SECRET*, *TOKEN*, *API_KEY*, *PRIVATE_KEY*, *ACCESS_KEY* assignments (= or :)DB_PASSWORD=correct-horseDB_PASSWORD=[redacted]
JSON fields named password, secret, client_secret, token, access_token, refresh_token, api_key, private_key"client_secret": "abc123""client_secret": "[redacted]"
Bearer tokensBearer eyJhbGciOi...Bearer [redacted]
Well-known key shapes: Anthropic (sk-ant-), GitHub (ghp_, gho_, github_pat_), AWS (AKIA), Google (AIza), Slack (xox?-)ghp_...gh_[redacted]

| PEM private key blocks | `REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER

Source 2 files
hooks/register.ts 35 lines
1import type { Register } from 'claude-code'
2
3import { redact, redactToolResults } from './redact.ts'
4
5function plural(n: number): string {
6  return `${n} secret${n === 1 ? '' : 's'}`
7}
8
9export const register: Register = on => {
10  // Bash: mask the tool's own record, so the screen and the model both see the masked text.
11  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
12    const ran = await next(e)
13    if (ran.deny !== undefined || ran.isError === true || !ran.result) return ran
14    const stdout = redact(ran.result.stdout ?? '')
15    const stderr = redact(ran.result.stderr ?? '')
16    const count = stdout.count + stderr.count
17    if (count === 0) return ran
18    $.ui.toast(`mask-that-pass: masked ${plural(count)} in Bash output`)
19    return {
20      result: { ...ran.result, stdout: stdout.text, stderr: stderr.text },
21      ...(ran.context ? { context: ran.context } : {}),
22    }
23  })
24
25  // Every other tool's result (Read, Grep, MCP tools, a Bash error): mask what the model reads
26  // and what the transcript stores.
27  on('session.append', async ($, e, next) => {
28    if (e.door !== 'tool-result') return next(e)
29    const masked = redactToolResults(e.message.content)
30    if (masked.count === 0) return next(e)
31    $.ui.toast(`mask-that-pass: masked ${plural(masked.count)} in a tool result`)
32    return next({ ...e, message: { ...e.message, content: masked.content } })
33  })
34}
35
hooks/redact.ts 82 lines
1// Credentials masked in tool output. Each rule keeps enough context to read the line
2// (the variable name, the URL's user and host) and replaces only the secret itself.
3
4type Rule = { pattern: RegExp; replace: (match: string, ...groups: string[]) => string }
5
6const MASK = '[redacted]'
7
8const RULES: readonly Rule[] = [
9  // PEM private keys (service-account JSON embeds these too).
10  {
11    pattern: /-----BEGIN ([A-Z ]*)PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
12    replace: (_m, kind) => `-----BEGIN ${kind}PRIVATE KEY----- ${MASK} -----END ${kind}PRIVATE KEY-----`,
13  },
14  // Passwords in URLs: scheme://user:password@host keeps the scheme, user and host
15  {
16    pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi,
17    replace: (_m, head) => `${head}${MASK}@`,
18  },
19  // Environment-style assignments: DB_PASSWORD=..., GITHUB_TOKEN: ..., FOO_API_KEY="..."
20  // A purely numeric value (TOKEN_BUDGET=4000) is a setting, not a secret.
21  {
22    pattern: /\b([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*\s*[=:]\s*)(["']?)([^\s"']{4,})/g,
23    replace: (m, head, quote, value) => (/^\d+$/.test(value) ? m : `${head}${quote}${MASK}`),
24  },
25  // JSON fields: "password": "...", "client_secret": "...", "refresh_token": "..."
26  {
27    pattern: /("(?:password|passwd|secret|client_secret|token|access_token|refresh_token|api_key|apikey|private_key)"\s*:\s*")([^"]{4,})"/gi,
28    replace: (_m, head) => `${head}${MASK}"`,
29  },
30  { pattern: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, replace: (_m, head) => `${head}${MASK}` },
31  { pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, replace: () => `sk-ant-${MASK}` },
32  { pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/g, replace: () => `gh_${MASK}` },
33  { pattern: /\bgithub_pat_[A-Za-z0-9_]{30,}/g, replace: () => `github_pat_${MASK}` },
34  { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: () => `AKIA${MASK}` },
35  { pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: () => `AIza${MASK}` },
36  { pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: () => `xox-${MASK}` },
37]
38
39/** The text with every credential masked, and how many were. */
40export function redact(text: string): { text: string; count: number } {
41  let count = 0
42  let out = text
43  for (const { pattern, replace } of RULES) {
44    out = out.replace(pattern, (match: string, ...rest: unknown[]) => {
45      const groups = rest.slice(0, -2).map(g => (typeof g === 'string' ? g : ''))
46      const replaced = replace(match, ...groups)
47      if (replaced !== match) count += 1
48      return replaced
49    })
50  }
51  return { text: out, count }
52}
53
54type Block = { type: string; [field: string]: unknown }
55
56/** A conversation row's content blocks with every tool_result's text masked, and how many were. */
57export function redactToolResults<B extends { type: string }>(content: readonly B[]): { content: B[]; count: number } {
58  let count = 0
59  const out = content.map(block => {
60    if (block.type !== 'tool_result') return block
61    const b = block as unknown as Block
62    if (typeof b.content === 'string') {
63      const masked = redact(b.content)
64      count += masked.count
65      return (masked.count ? { ...b, content: masked.text } : block) as B
66    }
67    if (!Array.isArray(b.content)) return block
68    let changed = false
69    const parts = b.content.map((part: unknown) => {
70      const p = part as Block
71      if (p?.type !== 'text' || typeof p.text !== 'string') return part
72      const masked = redact(p.text)
73      if (!masked.count) return part
74      count += masked.count
75      changed = true
76      return { ...p, text: masked.text }
77    })
78    return (changed ? { ...b, content: parts } : block) as B
79  })
80  return { content: out, count }
81}
82