Asks before tool calls whose input carries a credential and redacts credentials from tool output.

L0 (no declared file writes, subprocess spawns or network egress). The manifest only declares an environment read of VANGUARD_SECRET_REDACTOR_PATTERNS. The file_writes, spawns and egress_hosts arrays are empty. This is capability disclosure, not enforced isolation.
patterns.json. These cover GitHub tokens, AWS access key IDs and secret keys, HTTP Bearer tokens and passwords embedded in URLs.[REDACTED:<pattern id>] before the model reads it. This works only where the harness lets a hook replace output (see the table).A secret is still found when it is split across a key and its value, for example aws_secret_access_key paired with its value. The same holds for a secret split across argument-list elements ("Bearer", "<token>").
Redaction repeats until the text stops changing. Text that never settles is replaced by [REDACTED:overflow].
VANGUARD_SECRET_REDACTOR_PATTERNS is an optional path to a JSON file of extra patterns.
patterns.json:id: kebab-case, and not a built-in id.namedescriptionregexflags: "" or "i".context: "tool-input", "tool-output" or both.severity[REDACTED:...] marker. Its redaction never settles, so the output comes back as [REDACTED:overflow].| Harness | Surface | Before a tool runs | After a tool runs |
|---|---|---|---|
| Claude Code | native | tool.check: asks on a hit, never loosens the engine verdict | tool.call: returns the redacted result |
| Claude Code | classic | PreToolUse: asks on a hit | PostToolUse: updatedToolOutput with the redacted output |
| Kiro | hook | Degraded: the whole payload is scanned and a hit is denied | Unsupported: Kiro documents no way to replace output |
| Codex | hook | Degraded: Codex rejects ask, so a hit is denied with a reason | Unsupported: Codex cannot replace output |
On the native surface, some results cannot be rebuilt from a redacted value:
Instead of the result, the model reads a notice followed by the redacted text.
PostToolUse fires only after a tool succeeds. Output from a failed tool is not redacted there.ask is settled by the permission mode, so a bypass mode may auto-approve it.See mods/README.md.
hooks/register.ts 20 lines1import type { EngineInterface, Register } from 'claude-code'
2import { WITHHELD, checkInput, loadPatterns, redactResult } from '../lib/core.mjs'
3
4// Top level, not inside register: `claude plugin validate` refuses a $-taking helper there.
5async function patterns($: EngineInterface) {
6 const file = await $.env.get('VANGUARD_SECRET_REDACTOR_PATTERNS')
7 return loadPatterns(file ? await $.fs.read(file) : undefined, file)
8}
9
10export const register: Register = (on) => {
11 on('tool.check', async ($, e, next) => {
12 const v = await next(e)
13 if (v.decision === 'deny') return v
14 return checkInput(e.input, await patterns($)) ?? v
15 }).catch(() => ({ decision: 'deny', reason: 'secret-redactor: check failed; denied' }))
16
17 // The tool has already run when redaction fails, so the catch withholds rather than replays.
18 on('tool.call', async ($, e, next) => redactResult(await next(e), await patterns($))).catch(() => ({ deny: WITHHELD }))
19}
20lib/core.mjs 62 lines1// Secret Redactor logic, shared by the native module and the Node hook so every harness
2// reaches the same verdict. Pure: no I/O, no environment.
3import { compilePatterns, parseJson, redactValue } from './common.mjs'
4import builtin from './patterns.mjs'
5
6const BUILTIN = compilePatterns(builtin, 'patterns.json')
7const CONTEXTS = new Set(['tool-input', 'tool-output'])
8const NOTICE = 'secret-redactor: credentials redacted from this result: '
9export const WITHHELD = '[secret-redactor: output withheld; redaction failed]'
10
11const inContext = (patterns, context) => patterns.filter((p) => p.context.includes(context))
12const names = (hits) => hits.map((h) => h.name).join('; ')
13
14// Built-in patterns plus the operator's extension, appended (never replacing). Throws on bad
15// JSON or shape, a reused built-in id or an unknown context; callers fail closed.
16export function loadPatterns(extraText, source = 'extension patterns') {
17 if (extraText === undefined) return BUILTIN
18 const extra = compilePatterns(parseJson(extraText, source), source)
19 for (const p of extra) {
20 if (BUILTIN.some((b) => b.id === p.id)) throw new Error(`${source}: id ${p.id} is a built-in pattern`)
21 if (!p.context.every((c) => CONTEXTS.has(c))) throw new Error(`${source}: ${p.id} context must be tool-input or tool-output`)
22 }
23 return [...BUILTIN, ...extra]
24}
25
26// Asks before a call whose input carries a credential. The reason names the patterns, never
27// the matched text.
28export function checkInput(input, patterns) {
29 const { hits } = redactValue(input, inContext(patterns, 'tool-input'))
30 return hits.length ? { decision: 'ask', reason: `secret-redactor: tool input contains ${names(hits)}` } : undefined
31}
32
33export function redactOutput(output, patterns) {
34 return redactValue(output, inContext(patterns, 'tool-output'))
35}
36
37// Native tool.call result. A clean result goes back untouched (core keeps its own messages).
38// A redacted one becomes the hook's own { result }, which core re-validates and re-maps, so
39// the model's text is rebuilt from the redacted value. What cannot be rebuilt that way is
40// withheld with its redacted text instead: an error, a secret that shows only in core's
41// text, or one in `context` (a hook may add context entries but never rewrite them).
42export function redactResult(r, patterns) {
43 const out = inContext(patterns, 'tool-output')
44 if (typeof r.deny === 'string') {
45 const d = redactValue(r.deny, out)
46 return d.hits.length ? { deny: d.value } : r
47 }
48 const result = redactValue(r.result, out)
49 const context = redactValue(r.context ?? [], out)
50 const text = redactValue(r.text ?? '', out)
51 if (result.hits.length + context.hits.length + text.hits.length === 0) return r
52 if (r.isError || context.hits.length || result.hits.length === 0) {
53 const shown = typeof r.text === 'string' ? text.value : typeof result.value === 'string' ? result.value : (JSON.stringify(result.value) ?? '')
54 return { deny: [NOTICE + shown, ...context.value].join('\n') }
55 }
56 return r.context ? { result: result.value, context: r.context } : { result: result.value }
57}
58
59export function logLines(hits, tool, event) {
60 return hits.map((h) => `[secret-redactor] redacted ${h.name} in ${tool ?? 'unknown tool'} ${event}`)
61}
62lib/common.mjs 214 lines1// Shared helpers for the mods under mods/. scripts/generate-mods.mjs copies this file
2// byte-for-byte to mods/<id>/lib/common.mjs, so edit it here and run `npm run mods:write`.
3//
4// Pure on purpose: no imports, no environment, no I/O. Claude Code's native mod runtime
5// imports it and has no Node, and every harness must reach the same verdict on the same
6// input. Regexes compile without the `u` flag so Node and Python `re.ASCII`
7// (tests/validate-mods.py) read them the same way.
8
9export const LIMITS = Object.freeze({
10 // A redaction that still changes after this many passes is withheld as a whole.
11 maxPasses: 8,
12 // A guard candidate longer than this is not evaluated and counts as a hit.
13 maxCandidateChars: 65536,
14 // Guard candidates are not evaluated past this combined length; the call counts as a hit.
15 maxTotalChars: 262144,
16 // Only the tail of a leaf's context (key or previous sibling) is prefixed to it.
17 maxContextChars: 256,
18})
19
20const MARKER_PREFIX = '[REDACTED:'
21export const OVERFLOW_MARKER = '[REDACTED:overflow]'
22const OVERFLOW_HIT = Object.freeze({ id: 'overflow', name: 'redaction did not converge' })
23export const TOO_LARGE_HIT = Object.freeze({ id: 'too-large', name: 'input too large to evaluate' })
24
25const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
26const PATTERN_KEYS = new Set(['id', 'name', 'description', 'regex', 'flags', 'context', 'severity', 'agents'])
27const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '')
28
29// Parser diagnostics can quote credential-bearing data; never expose it in hook logs.
30export function parseJson(text, source) {
31 try {
32 return JSON.parse(text)
33 } catch {
34 throw new Error(`${source}: invalid JSON`)
35 }
36}
37
38// Validates a patterns.json-shaped list and compiles it. Throws on anything malformed so a
39// bad extension file fails closed in the caller instead of silently matching nothing.
40export function compilePatterns(list, source = 'patterns') {
41 if (!Array.isArray(list)) throw new Error(`${source}: expected an array of patterns`)
42 const seen = new Set()
43 return list.map((p, i) => {
44 const where = `${source}[${i}]`
45 if (p === null || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${where}: expected an object`)
46 for (const k of Object.keys(p)) if (!PATTERN_KEYS.has(k)) throw new Error(`${where}: unknown key ${k}`)
47 if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`${where}: id must be kebab-case`)
48 if (seen.has(p.id)) throw new Error(`${where}: duplicate id ${p.id}`)
49 seen.add(p.id)
50 if (typeof p.name !== 'string' || p.name === '') throw new Error(`${where}: name must be a non-empty string`)
51 if (typeof p.regex !== 'string' || p.regex === '') throw new Error(`${where}: regex must be a non-empty string`)
52 if (p.flags !== '' && p.flags !== 'i') throw new Error(`${where}: flags must be "" or "i"`)
53 if (!isStringArray(p.context) || p.context.length === 0) throw new Error(`${where}: context must be a non-empty string array`)
54 for (const k of ['description', 'severity']) {
55 if (p[k] !== undefined && typeof p[k] !== 'string') throw new Error(`${where}: ${k} must be a string`)
56 }
57 if (p.agents !== undefined && !isStringArray(p.agents)) throw new Error(`${where}: agents must be a string array`)
58 let rx, rxg
59 try {
60 rx = new RegExp(p.regex, p.flags)
61 rxg = new RegExp(p.regex, 'g' + p.flags)
62 } catch {
63 throw new Error(`${where}: regex does not compile`)
64 }
65 return Object.freeze({
66 id: p.id,
67 name: p.name,
68 severity: p.severity,
69 context: Object.freeze([...p.context]),
70 agents: p.agents ? Object.freeze([...p.agents]) : undefined,
71 rx,
72 rxg,
73 marker: `${MARKER_PREFIX}${p.id}]`,
74 })
75 })
76}
77
78// Every string value (not key) in depth-first order. The order follows the input, so the
79// same payload always yields the same list.
80export function stringLeaves(value, out = []) {
81 if (typeof value === 'string') out.push(value)
82 else if (Array.isArray(value)) for (const v of value) stringLeaves(v, out)
83 else if (value !== null && typeof value === 'object') for (const v of Object.values(value)) stringLeaves(v, out)
84 return out
85}
86
87// Guard candidates: every string leaf, plus every all-string array joined with spaces so
88// an argv-style command (["rm", "-rf", "/"]) reads like the shell line it runs.
89export function candidates(value) {
90 const out = stringLeaves(value)
91 const joinArrays = (v) => {
92 if (Array.isArray(v)) {
93 if (v.length > 1 && v.every((s) => typeof s === 'string')) out.push(v.join(' '))
94 v.forEach(joinArrays)
95 } else if (v !== null && typeof v === 'object') {
96 Object.values(v).forEach(joinArrays)
97 }
98 }
99 joinArrays(value)
100 return out
101}
102
103// The patterns tagged with `context` that match any text, in catalog order. Oversized input
104// is not evaluated (regex cost is unbounded there) and reports TOO_LARGE_HIT instead.
105export function matchPatterns(patterns, texts, context) {
106 let total = 0
107 for (const t of texts) {
108 total += t.length
109 if (t.length > LIMITS.maxCandidateChars || total > LIMITS.maxTotalChars) return [TOO_LARGE_HIT]
110 }
111 return patterns.filter((p) => p.context.includes(context) && texts.some((t) => p.rx.test(t)))
112}
113
114// One pattern pass after another until the text stops changing. Each pass can expose a new
115// match (password-in-url can swallow an earlier marker), so a single pass is not enough.
116function fixedPoint(text, patterns, hits) {
117 let x = text
118 for (let pass = 0; pass <= LIMITS.maxPasses; pass++) {
119 let y = x
120 for (const p of patterns) {
121 y = y.replace(p.rxg, (m) => {
122 if (m === '') return m
123 hits.add(p)
124 return p.marker
125 })
126 }
127 if (y === x) return x
128 x = y
129 }
130 hits.add(OVERFLOW_HIT)
131 return OVERFLOW_MARKER
132}
133
134// Redacts `s` with `ctx` in front so a secret split across a key and its value, or across
135// argv elements ("Bearer", "<token>"), still matches; then strips the context back off.
136function redactLeaf(s, ctx, patterns, hits) {
137 if (ctx === '') return fixedPoint(s, patterns, hits)
138 const pre = ctx.slice(-LIMITS.maxContextChars) + ' '
139 const y = fixedPoint(pre + s, patterns, hits)
140 if (y === OVERFLOW_MARKER) return y
141 const r = fixedPoint(pre, patterns, new Set())
142 if (y.startsWith(r)) return fixedPoint(y.slice(r.length), patterns, hits)
143 // A match spans the boundary: keep from the marker that covers it. Slicing changes
144 // anchors and boundaries, so also check the standalone leaf after contextual matching.
145 let d = 0
146 while (d < r.length && y[d] === r[d]) d++
147 const i = y.lastIndexOf(MARKER_PREFIX, d)
148 return fixedPoint(i >= 0 ? y.slice(i) : y, patterns, hits)
149}
150
151function walk(v, ctx, patterns, hits) {
152 if (typeof v === 'string') {
153 const local = new Set()
154 const out = redactLeaf(v, ctx, patterns, local)
155 if (out !== v) for (const h of local) hits.add(h)
156 return out
157 }
158 if (Array.isArray(v)) {
159 let prev = null
160 return v.map((el) => {
161 const out = walk(el, typeof prev === 'string' ? prev : ctx, patterns, hits)
162 prev = el
163 return out
164 })
165 }
166 if (v !== null && typeof v === 'object') {
167 const named = typeof v.name === 'string' ? v.name : typeof v.key === 'string' ? v.key : null
168 // fromEntries, not assignment: `out[k] = v` would drop an own "__proto__" key.
169 return Object.fromEntries(
170 Object.entries(v).map(([k, x]) => [
171 walk(k, '', patterns, hits),
172 walk(x, k === 'value' && named !== null ? named : k, patterns, hits),
173 ]),
174 )
175 }
176 return v
177}
178
179// Returns a copy of `value` with every match replaced by `[REDACTED:<id>]`, plus the
180// patterns that fired (catalog order, unique). Keys are redacted too; two keys that redact
181// to the same marker collapse into one, which loses data but never leaks it.
182export function redactValue(value, patterns) {
183 const hits = new Set()
184 const out = walk(value, '', patterns, hits)
185 const order = [...patterns, OVERFLOW_HIT]
186 return { value: out, hits: order.filter((p) => hits.has(p)) }
187}
188
189// Same shape as `value`, every string replaced by `text`. The fallback when redaction
190// itself failed: withhold everything rather than pass anything through.
191export function maskValue(value, text) {
192 if (typeof value === 'string') return text
193 if (Array.isArray(value)) return value.map((v) => maskValue(v, text))
194 if (value !== null && typeof value === 'object') {
195 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskValue(v, text)]))
196 }
197 return value
198}
199
200// JSON with object keys sorted at every level, so equal values hash equally.
201export function canonicalJson(value) {
202 if (Array.isArray(value)) return '[' + value.map((v) => (v === undefined ? 'null' : canonicalJson(v))).join(',') + ']'
203 if (value !== null && typeof value === 'object') {
204 const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
205 return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
206 }
207 return JSON.stringify(value) ?? 'null'
208}
209
210export async function sha256Hex(text) {
211 const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
212 return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
213}
214lib/patterns.mjs 76 lines1// Generated by scripts/generate-mods.mjs from patterns.json. Do not edit.
2export default [
3 {
4 "id": "github-fine-grained-pat",
5 "name": "GitHub fine-grained personal access token",
6 "description": "github_pat_ prefix followed by 22+ [A-Za-z0-9_]. Listed before github-pat so a fine-grained body that happens to contain ghp_ is consumed whole.",
7 "regex": "github_pat_[A-Za-z0-9_]{22,255}",
8 "flags": "",
9 "severity": "critical",
10 "context": [
11 "tool-input",
12 "tool-output"
13 ]
14 },
15 {
16 "id": "github-pat",
17 "name": "GitHub token (ghp/gho/ghu/ghs/ghr)",
18 "description": "GitHub classic PAT / OAuth / user-to-server / server-to-server / refresh token: 3-letter prefix, underscore, 36-255 alphanumerics.",
19 "regex": "(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}",
20 "flags": "",
21 "severity": "critical",
22 "context": [
23 "tool-input",
24 "tool-output"
25 ]
26 },
27 {
28 "id": "aws-secret-key",
29 "name": "AWS secret access key (context-anchored)",
30 "description": "40+ chars of [A-Za-z0-9/+] ONLY when preceded by an AWS secret-key name (aws_secret_access_key, AWS_SECRET_KEY, SecretAccessKey, secretAccessKey, --aws-secret-access-key ...) plus a separator (:, =, =>, or whitespace) with optional quotes/escaped quotes. A bare 40-char base62 run is deliberately NOT matched: it is indistinguishable from a 40-hex git SHA. The match consumes the key name and separator. Listed before aws-access-key so an AKIA-looking prefix inside the value cannot split the secret.",
31 "regex": "(?:aws[_.-]?secret[_.-]?(?:access[_.-]?)?key|secret[_.-]?access[_.-]?key)[\\\\\"']{0,4}(?:[ \\t]*(?::|=>?)[ \\t\\r\\n]*|[ \\t]+)[\\\\\"']{0,4}[A-Za-z0-9/+]{40,}",
32 "flags": "i",
33 "severity": "critical",
34 "context": [
35 "tool-input",
36 "tool-output"
37 ]
38 },
39 {
40 "id": "aws-access-key",
41 "name": "AWS access key ID",
42 "description": "AKIA (long-term) or ASIA (STS temporary) followed by 16 uppercase alphanumerics. No boundary anchors (no lookbehind available): fails closed, may over-redact inside long uppercase blobs.",
43 "regex": "(?:AKIA|ASIA)[0-9A-Z]{16}",
44 "flags": "",
45 "severity": "high",
46 "context": [
47 "tool-input",
48 "tool-output"
49 ]
50 },
51 {
52 "id": "generic-bearer",
53 "name": "HTTP Bearer token",
54 "description": "Bearer (any case) + horizontal whitespace + 16+ token chars [A-Za-z0-9._~+/-] + optional = padding. Spec said '+'; 16+ avoids redacting prose such as 'bearer of'. Token class excludes '[' so it can never extend into a [REDACTED:..] marker.",
55 "regex": "bearer[ \\t]+[A-Za-z0-9._~+/-]{16,}=*",
56 "flags": "i",
57 "severity": "high",
58 "context": [
59 "tool-input",
60 "tool-output"
61 ]
62 },
63 {
64 "id": "password-in-url",
65 "name": "Credentials embedded in URL",
66 "description": "scheme://user:password@ . Scheme bounded to 30 chars (avoids quadratic scans on long alnum runs). User/password classes exclude whitespace, quotes, backslash, '/', '@', angle brackets and backtick, so a match cannot cross whitespace, a JSON string quote, or a path. Must stay LAST in the array: its password class accepts '[' and ':' so it can swallow earlier [REDACTED:..] markers (harmless, single pass), and nothing after it can re-form.",
67 "regex": "[A-Za-z][A-Za-z0-9+.-]{0,30}://[^ \\t\\r\\n\"'\\\\/@:?#<>`]*:[^ \\t\\r\\n\"'\\\\/@<>`]+@",
68 "flags": "",
69 "severity": "high",
70 "context": [
71 "tool-input",
72 "tool-output"
73 ]
74 }
75];
76