SLOPSHOPPER

secret-redactor

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

guard
A shopper browsing a rack in a slop shop
README

Secret Redactor

Reach and declared footprint

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.

What it does

  • Before a tool runs, it scans every string in the tool input against the credential patterns in patterns.json. These cover GitHub tokens, AWS access key IDs and secret keys, HTTP Bearer tokens and passwords embedded in URLs.
  • A hit asks for confirmation.
  • The reason names the pattern, never the matched text.
  • After a tool runs, it replaces every match in the tool output with [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].

Configuration

VANGUARD_SECRET_REDACTOR_PATTERNS is an optional path to a JSON file of extra patterns.

  • The extra patterns are appended to the built-in list. They never replace it.
  • The file is an array of entries with the same keys as patterns.json:
  • id: kebab-case, and not a built-in id.
  • name
  • description
  • regex
  • flags: "" or "i".
  • context: "tool-input", "tool-output" or both.
  • severity
  • The file fails closed. If it is set and cannot be read, is not valid JSON, is malformed, reuses a built-in id or names any other context:
  • before a tool runs, every call is denied;
  • after a tool runs, the output is withheld.
  • Do not let an extension regex match a [REDACTED:...] marker. Its redaction never settles, so the output comes back as [REDACTED:overflow].

Per-harness behavior

HarnessSurfaceBefore a tool runsAfter a tool runs
Claude Codenativetool.check: asks on a hit, never loosens the engine verdicttool.call: returns the redacted result
Claude CodeclassicPreToolUse: asks on a hitPostToolUse: updatedToolOutput with the redacted output
KirohookDegraded: the whole payload is scanned and a hit is deniedUnsupported: Kiro documents no way to replace output
CodexhookDegraded: Codex rejects ask, so a hit is denied with a reasonUnsupported: Codex cannot replace output

On the native surface, some results cannot be rebuilt from a redacted value:

  • an errored call;
  • a credential that shows only in the text the model reads;
  • a credential in context added by another hook.

Instead of the result, the model reads a notice followed by the redacted text.

Limitations

  • Classic PostToolUse fires only after a tool succeeds. Output from a failed tool is not redacted there.
  • Classic Claude Code ignores a replacement whose shape differs from the tool's output. The withheld output then cannot take effect in two cases:
  • the hook's stdin is invalid;
  • the redaction fails on output that is not an object.
  • Two keys can redact to the same marker. They collapse into one. This loses data but may invalidate the tool's output schema; classic Claude Code may then ignore the replacement and retain the original output.
  • When redaction itself fails, the withheld replacement keeps the output's keys and masks only its values. Credentials embedded in keys are not protected by that fallback.
  • An ask is settled by the permission mode, so a bypass mode may auto-approve it.
  • Patterns are regexes, not proof. A credential format they do not describe passes through, and a long uppercase run that starts like an AWS key is over-redacted.
  • The native API is early access, verified against Claude Code 2.1.296.
  • Classic, Kiro and Codex hooks need Node 22+.

Install

See mods/README.md.

Source 4 files
hooks/register.ts 20 lines
1import 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}
20
lib/core.mjs 62 lines
1// 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}
62
lib/common.mjs 214 lines
1// 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}
214
lib/patterns.mjs 76 lines
1// 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