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

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
| Kind | Example (fake) | Becomes |
|---|---|---|
| Password in a URL | postgresql://app:REDACTED@db/app | postgresql://app:REDACTED@db/app |
*PASSWORD*, *SECRET*, *TOKEN*, *API_KEY*, *PRIVATE_KEY*, *ACCESS_KEY* assignments (= or :) | DB_PASSWORD=correct-horse | DB_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 tokens | Bearer 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
hooks/register.ts 35 lines1import 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}
35hooks/redact.ts 82 lines1// 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