Redacts or blocks credentials (detected by betterleaks) before they are sent to the LLM

認証情報を含むテキストが LLM に送られる前に、[REDACTED-xxxxxxxxxxxx] に置き換える(またはその送信を拒否する)Claude Code の mod です。検出は betterleaks に任せていて、この mod は検出ルールを持ちません。
動作確認: Claude Code 2.1.287、betterleaks 1.7.4。mods は Claude Code 2.1.287 以降で使えます。
| 経路 | フックするイベント | redact(既定) | block |
|---|---|---|---|
| ユーザーのプロンプトと、添付のコンテキスト | prompt.submit | 置換して送信 | 送信を中止(drop) |
| ツールの出力(Read、Bash、MCP、サブエージェントを含む) | tool.call | 結果の文字列を置換 | 結果を破棄して deny |
@file や CLAUDE.md などエンジンが差し込む本文 | prompt.attachment | 置換 | その添付を落とす |
!コマンド(bash モード)の入力と出力 | session.append | 置換 | 本文を伏せ文に差し替え |
プレースホルダは HMAC-SHA256(鍵, 秘密値) の先頭 12 桁の 16 進数です。同じ値は、プロンプトでも .env でも別のツールの出力でも同じ [REDACTED-…] になるので、モデルは「この 2 か所は同じ値」「こちらは別の値」という文脈を保てます。
hashKey です。空のときは初回の起動で乱数を作ってプラグインの store に保存するので、セッションや再起動をまたいでも同じプレースホルダになります。redact したとき、モデルだけが読む短い注記(英語)を添えます。内容は、認証情報フィルタが値をプレースホルダに置き換えたこと、各プレースホルダの検出ルール(github-pat など)、本物の値は見えないこと、ユーザーの画面には本物の値が見えているかどうか(restoreInDisplay に従う)、ツール入力にプレースホルダをそのまま渡せるかどうか(restoreInToolInput に従う)、そのまま作業を続けてよく値をユーザーに求めないこと、です。これでモデルは、ファイルが壊れていると誤解したり値の入力を求めたりせずに作業を続けられます。
| 経路 | 注記の置き場所 |
|---|---|
| ツール結果 | context(ユーザーには見えない) |
| 失敗したツール結果 | 置換後のエラー文の末尾 |
| プロンプト | context の末尾 |
添付(@file など) | 置換後の本文の末尾 |
!コマンド | 置換後の行の末尾 |
block とスキャン失敗のときは、これまでどおり拒否の理由を返します。クリーンな入力には何も足しません。
モデルが [REDACTED-…] を含むコマンドを書いたとき、tool.call の入力の中だけ、この mod が覚えている(メモリ上の)対応で本物の値に戻してから実行します。たとえば curl -H "Authorization: token [REDACTED-…]" はそのまま動きます。実行結果に本物の値が出てきても、出力側でまた置換されます。
curl の宛先など)へ本物の値が渡ることがあります。それを避けたい場合は restoreInToolInput を false にします。モデルに渡る内容と履歴は置換したまま、人間が見る描画だけ本物の値に戻します(ui.render)。モデルの返答、ツールの行と結果、自分のプロンプト、スラッシュコマンドの出力、質問ダイアログが対象です。
restoreInDisplay を false にします。turn.step は送るメッセージを書き換えられず、書き換えた応答は履歴に記録されます)。claude -p や --output-format stream-json の出力は描画を通らないので、置換されたままです。thinking と、折りたたまれたツール群の 1 行要約も対象外です。--resume で再開した直後は、過去の行はプレースホルダのままです。その値にもう一度出会う(たとえば .env を再度読む)と、描き直されて本物の値になります。hashKey が空なら初回の起動で鍵を自動生成し、プラグイン専用の store(Claude Code の設定ディレクトリ下の JSON ファイル。平文)に保存します。設定画面の hashKey は書き換わりません。決まった鍵を使いたいときは /plugin configure で hashKey を入力します(入力があれば store は使いません)。保存に失敗すると通知し、その起動の間だけ使うメモリ上の鍵で続けます。前提: betterleaks が PATH にあること(brew install betterleaks、mise use -g aqua:betterleaks/betterleaks、go install github.com/betterleaks/betterleaks/v2@latest など)。
GitHub リポジトリ skpersonal/claude-code-block-creds-mod をマーケットプレイスとして追加し、そこから入れます。
# Claude Code のセッション内で
/plugin marketplace add skpersonal/claude-code-block-creds-mod
/plugin install block-creds@block-creds-marketplace
追加と導入を 1 コマンドにまとめることもできます(Claude Code 2.1.275 以降)。
/plugin install block-creds --marketplace skpersonal/claude-code-block-creds-mod
セッションを開かずにシェルから入れる場合は次のとおりです。
claude plugin marketplace add skpersonal/claude-code-block-creds-mod
claude plugin install block-creds@block-creds-marketplace # 既定はユーザースコープ
claude plugin install block-creds@block-creds-marketplace --scope project # リポジトリ単位で有効にする場合
/reload-plugins を実行すると読み込まれます。/plugin を開き、Installed タブに block-creds があれば有効です。skpersonal/claude-code-block-creds-mod#v0.1.0 のように #ref を付けます。claude plugin update block-creds@block-creds-marketplace、削除は claude plugin uninstall block-creds@block-creds-marketplace です。claude --plugin-dir /path/to/claude-code-block-creds-mod を使います。| 名前 | 既定値 | 内容 |
|---|---|---|
mode | redact | redact: 置換して送る。block: 送らない |
betterleaksPath | betterleaks | 実行ファイルの名前またはパス |
configPath | なし | betterleaks の設定ファイル(-c)。省略すると betterleaks の既定ルール |
hashKey | 初回に自動生成 | プレースホルダ用の鍵。空なら store に自動生成 |
restoreInToolInput | true | ツール入力でプレースホルダを本物の値に戻す |
restoreInDisplay | true | 画面の描画でプレースホルダを本物の値に戻す(モデルに渡る内容は変わらない) |
/plugin configure block-creds@block-creds-marketplace で設定できます。
セッション中の件数は /block-creds で確認できます。
置換・ブロック・スキャン失敗のときは、toast に加えて、プロンプト下の status 行に内容を表示します。status 行は次のメッセージを送るまで残ります。どちらもモデルには渡りません。
検出は betterleaks の既定ルールのままです。たとえば AWS のアクセスキー ID は、近く(5 行以内)にシークレットキーがあるときだけ報告され、ID 単体は報告されません。URL に埋め込まれたパスワード(postgres://user:REDACTED@host)も既定では検出されません。足したい場合は、既定を継承した設定ファイルを作って configPath に指定します。
# betterleaks.toml
[extend]
useDefault = true
[[rules]]
id = "aws-access-key-id"
description = "AWS access key ID, even without the secret"
regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b'''
keywords = ["a3t", "akia", "asia", "abia", "acca"]
filter = '''
entropy(finding["secret"]) <= 3.0
|| matchesAny(finding["secret"], [`.+EXAMPLE$`])
'''
cat なら置換して読めます)。!コマンド の行は会話に保存される前に書き換えるだけで、行そのものを拒否できません。block モードや検査失敗のときは、本文を「伏せた」という文に差し替えて保存します。prompt.section、prompt.context)は対象外です。--resume した会話の履歴など)は置換しません。--safe-mode、--bare、disableAllHooks のときは mod が読み込まれず、何も守られません。!コマンドの行をすべて止めます。起動できるようになれば自動で解除されます。.catch でそのテキストを破棄(session.append は伏せ文に差し替え)します。前提: Claude Code 2.1.287 以降、betterleaks、Node.js、pnpm(Biome・TypeScript・husky などの開発用ツール)。ビルド工程はなく、Claude Code が .ts を直接読み込みます。
.claude-plugin/types/ は手で作るものではなく、Claude Code がこの mod を読み込むたびに、インストールされている版に合わせて書き出します(.gitignore 済み)。clone した直後は存在しないので、tsc の前に一度読み込ませます。
claude -p "ok" --plugin-dir . < /dev/null
ls .claude-plugin/types # claude-code/ claude-code-tools/ claude-code-mcp/ tsconfig.json
claude-code/index.d.ts が API の正式な定義です(先頭の行に書き出した Claude Code の版が入ります)。イベントの入出力や $ のメソッドは、Web のドキュメントよりこちらを優先してください。.claude-plugin/types/tsconfig.json は上書きされます。そのため、.ts の import を許す allowImportingTsExtensions は、ルートの tsconfig.json 側に置いています。pnpm validate # claude plugin validate . --strict。登録しているイベントと呼び出す API の一覧を確認
pnpm test # claude plugin test。単体テストとイベントテスト(betterleaks はスタブ)
pnpm typecheck # tsc -p .
pnpm lint # biome check .(lint と整形の確認)
pnpm format # biome check --write .(自動修正)
最初に pnpm install を実行してください(husky が .husky/ の Git フックを有効にします)。コミット時には pre-commit フックが、ステージしたファイルへの lint-staged(Biome の自動修正)、pnpm typecheck、pnpm test を順に実行します。型チェックには手順 1 の型定義が必要です。
claude plugin test はプラグインのディレクトリを受け取る形式で、テストファイルや単体のテストを指定する方法は見つかっていません。全体で 1 秒未満です。validate が出す hooks: と calls: の行に、意図したイベントと API が並んでいるかを見てください。イベント名の綴りミスはここで分かります。claude-code/testing を使います。API 呼び出し($.process.run など)のスタブは { value } か { deny } を返し、イベント(tool.call など)のスタブはそのイベントの結果をそのまま返します。userConfig の値は test(名前, { options: { mode: 'block' } }, 本体) で渡します。スタブのテストでは、結果のスキーマ検証や、実際のセッションがモデルに何を送るかは分かりません(AWS のシークレットキーの取りこぼしは、この確認で見つかりました)。フックの結果を変えたときは、ダミーの認証情報で必ず実行してください。
# ダミーの .env を作業ディレクトリの外に作る(アクセスキー ID は [A-Z2-7] の 16 文字、シークレットは 40 文字)
mkdir -p /tmp/e2e && cd /tmp/e2e
AK="AKIA$(LC_ALL=C tr -dc 'A-Z2-7' </dev/urandom | head -c16)"
SK="$(LC_ALL=C tr -dc 'A-Za-z0-9/+' </dev/urandom | head -c40)"
GH="ghp_$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c36)"
printf 'AWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\nGITHUB_TOKEN=%s\n' "$AK" "$SK" "$GH" > .env
# 実行して、モデルに渡った内容(stream-json)に値が残っていないか数える
claude -p "Read .env and tell me every key and token in it, character for character." \
--plugin-dir /path/to/claude-code-block-creds-mod --output-format stream-json --verbose \
< /dev/null > out.jsonl
for s in "$AK" "$SK" "$GH"; do grep -c -F "$s" out.jsonl; done # すべて 0 なら漏れていない
grep -o '\[REDACTED-[0-9a-f]*\]' out.jsonl | sort | uniq -c # 同じ値は同じプレースホルダ
確認するとよい経路は 4 つです。Read、Bash の cat、プロンプトへの直接貼り付け、@.env のメンション。ツール入力での復元は、Claude に「Read した値を使って printf '%s' <値> > copy.txt を実行させる」と、copy.txt に本物の値が入ることで確かめられます(Bash などの許可には --allowedTools が要ります)。
block モードは、設定を --settings で渡して確かめました。
cat > block-settings.json <<'EOF'
{"pluginConfigs": {"block-creds@inline": {"options": {"mode": "block"}}, "@inline/block-creds": {"mode": "block"}}}
EOF
claude -p "My token is $GH . Say hi." --plugin-dir /path/to/claude-code-block-creds-mod --settings block-settings.json < /dev/null
# => Prompt dropped by a hook: block-creds: the prompt contains credentials (github-pat), so it was not sent
上の設定には 2 通りのキー表記を両方入れてあり、どちらが効いたかは切り分けていません。
hooks/register.ts が入口です。$(mods API)を渡せるのは同じファイル内の関数だけなので(validate が検査します)、betterleaks を起動する処理は register.ts にあります。純粋なロジックは redactor.ts(置換と HMAC)と scanner.ts(betterleaks の出力の解釈)に分けてあり、テストから直接 import できます。
betterleaks の出力は入れ子になることがあります。aws-secret-access-key は aws-access-token の ComponentSets[].components[] の中にだけ現れます。scanner.ts は全体をたどって Secret を集めているので、最上位だけを読む実装に戻さないでください。
また、betterleaks は secret の直後に < が来ると検出できません(…Qz</bash-stdout> は検出されず、…Qz の後に改行を挟めば検出されます)。!cmd の行は末尾がこの形になるため、session.append ではタグの前後に改行を入れたコピーをスキャンし、置換は元のテキストに対して行います(redactor.ts の forScan)。
hooks/register.ts 350 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2import {
3 appendToLastText,
4 collectStrings,
5 createRedactor,
6 forScan,
7 keyBytes,
8 type Mapping,
9 type Redactor,
10 redactionNotice,
11 replaceText,
12 toHex,
13 unredacted,
14} from './redactor.ts'
15import { buildArgv, type Finding, interpret, MIN_SCAN_LENGTH, type ScanOutcome } from './scanner.ts'
16
17const SCAN_TIMEOUT_MS = 15_000
18
19// Attachments the engine writes itself and that never carry file or tool content.
20const SKIP_ATTACHMENTS = new Set(['todo_reminder', 'plan_mode', 'plan_mode_exit', 'auto_mode', 'auto_mode_exit', 'skill_listing', 'deferred_tools_delta'])
21
22// Doors checked by `session.append`; the others are covered by the hooks above (the model's own rows are not user data).
23const APPEND_DOORS = new Set<string>(['command'])
24
25type Mode = 'redact' | 'block'
26
27type Ctx = {
28 mode: Mode
29 bin: string
30 configPath: string | undefined
31 restore: boolean
32 restoreDisplay: boolean
33 /** A key made because `hashKey` was empty; `loadStoredKey` keeps it in the plugin store, or swaps it for the one already there. */
34 generatedKey: string | undefined
35 /** Set by the first `loadStoredKey`, so later calls wait for the same work. */
36 keyLoad: Promise<void> | undefined
37 redactor: Redactor
38 stats: { redacted: number; blocked: number; failures: number; byRule: Map<string, number> }
39 /** Notices since the last prompt was sent; kept on the status line until the next prompt. */
40 notes: string[]
41 version: string | undefined
42 /** Whether `betterleaks version` ran last time it was checked; `undefined` until the first check. While it is not `true`, nothing is let through. */
43 available: boolean | undefined
44}
45
46type Verdict =
47 | { kind: 'clean' }
48 | { kind: 'redact'; mapping: Mapping; rules: string; notice: string }
49 | { kind: 'block'; rules: string }
50 | { kind: 'error'; error: string }
51
52function readCtx(options: PluginOptions): Ctx {
53 const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
54 const configuredKey = str(options['hashKey'])
55 const generatedKey = configuredKey === undefined ? toHex(crypto.getRandomValues(new Uint8Array(32))) : undefined
56 return {
57 mode: options['mode'] === 'block' ? 'block' : 'redact',
58 bin: str(options['betterleaksPath']) ?? 'betterleaks',
59 configPath: str(options['configPath']),
60 restore: options['restoreInToolInput'] !== false,
61 restoreDisplay: options['restoreInDisplay'] !== false,
62 generatedKey,
63 keyLoad: undefined,
64 redactor: createRedactor(keyBytes(configuredKey ?? generatedKey)),
65 stats: { redacted: 0, blocked: 0, failures: 0, byRule: new Map() },
66 notes: [],
67 version: undefined,
68 available: undefined,
69 }
70}
71
72async function scanTexts($: EngineInterface, ctx: Ctx, texts: readonly string[]): Promise<ScanOutcome> {
73 const joined = texts.join('\n\n')
74 if (joined.trim().length < MIN_SCAN_LENGTH) return { ok: true, findings: [] }
75 try {
76 const r = await $.process.run(buildArgv(ctx.bin, ctx.configPath), { stdin: joined, timeoutMs: SCAN_TIMEOUT_MS })
77 return interpret(r.exitCode, r.stdout, r.stderr)
78 } catch (err) {
79 return { ok: false, error: 'could not run ' + ctx.bin + ': ' + String(err) }
80 }
81}
82
83/** Shows a toast and keeps the notice on the status line until the next prompt (`clearNotes`). Neither reaches the model. */
84function notify($: EngineInterface, ctx: Ctx, text: string): void {
85 try {
86 $.ui.toast('block-creds: ' + text)
87 } catch {
88 // Nothing draws here (for example claude -p); the verdict still applies.
89 }
90 if (!ctx.notes.includes(text)) ctx.notes.push(text)
91 try {
92 $.ui.status('block-creds: ' + ctx.notes.join(' | '))
93 } catch {
94 // Nothing draws here; the verdict still applies.
95 }
96}
97
98/** The hook itself failed (threw or ran out of budget); its `.catch` withholds the text. */
99function hookFailed($: EngineInterface, ctx: Ctx, kind: string): void {
100 ctx.stats.failures += 1
101 notify($, ctx, 'check failed (' + kind + '), withheld')
102}
103
104function clearNotes($: EngineInterface, ctx: Ctx): void {
105 if (ctx.notes.length === 0) return
106 ctx.notes = []
107 try {
108 $.ui.status(undefined)
109 } catch {
110 // Nothing draws here; the verdict still applies.
111 }
112}
113
114function record(ctx: Ctx, findings: readonly Finding[], kind: 'redacted' | 'blocked'): string {
115 const rules = [...new Set(findings.map((f) => f.ruleId))]
116 const secrets = new Set(findings.map((f) => f.secret)).size
117 ctx.stats[kind] += secrets
118 for (const f of findings) ctx.stats.byRule.set(f.ruleId, (ctx.stats.byRule.get(f.ruleId) ?? 0) + 1)
119 return rules.join(', ')
120}
121
122/** Scans the texts and decides what has to happen to them. */
123async function judge($: EngineInterface, ctx: Ctx, texts: readonly string[]): Promise<Verdict> {
124 await loadStoredKey($, ctx)
125 // Without a working betterleaks nothing is checked, so nothing is let through, short texts included.
126 if (ctx.available !== true) await checkBinary($, ctx)
127 if (ctx.available !== true) {
128 ctx.stats.failures += 1
129 return { kind: 'error', error: ctx.bin + ' is not available' }
130 }
131 const out = await scanTexts($, ctx, texts)
132 if (!out.ok) {
133 ctx.stats.failures += 1
134 notify($, ctx, 'scan failed, withheld (' + out.error + ')')
135 return { kind: 'error', error: out.error }
136 }
137 if (out.findings.length === 0) return { kind: 'clean' }
138 if (ctx.mode === 'block') {
139 const rules = record(ctx, out.findings, 'blocked')
140 notify($, ctx, 'blocked (' + rules + ')')
141 return { kind: 'block', rules }
142 }
143 const rules = record(ctx, out.findings, 'redacted')
144 const before = ctx.redactor.known()
145 const mapping = await ctx.redactor.mapping(out.findings.map((f) => f.secret))
146 // Rows drawn before this value was known (a resumed session) can show it now.
147 if (ctx.restoreDisplay && ctx.redactor.known() > before) {
148 try {
149 $.ui.invalidate('ui.render')
150 } catch {
151 // Nothing draws here; the verdict still applies.
152 }
153 }
154 notify($, ctx, 'redacted ' + mapping.length + ' value(s) (' + rules + ')')
155 const rulesOf = new Map<string, Set<string>>()
156 for (const f of out.findings) {
157 const set = rulesOf.get(f.secret) ?? new Set<string>()
158 set.add(f.ruleId)
159 rulesOf.set(f.secret, set)
160 }
161 const notice = redactionNotice(
162 mapping.map(([secret, placeholder]) => ({ placeholder, rules: [...(rulesOf.get(secret) ?? [])] })),
163 { restoresInToolInput: ctx.restore, userSeesRealValues: ctx.restoreDisplay },
164 )
165 return { kind: 'redact', mapping, rules, notice }
166}
167
168/** What the model reads in place of a tool result that held credentials. */
169async function guardResult($: EngineInterface, ctx: Ctx, res: any) {
170 if (typeof res.deny === 'string') return res
171 const texts: string[] = typeof res.text === 'string' ? [res.text] : collectStrings(res.result)
172 if (Array.isArray(res.context)) texts.push(...res.context)
173 const v = await judge($, ctx, texts)
174 if (v.kind === 'clean') return res
175 if (v.kind === 'error') {
176 return { deny: 'block-creds could not check this tool result for credentials, so it was withheld. Do not retry the same call.' }
177 }
178 if (v.kind === 'block') {
179 return { deny: 'block-creds withheld this tool result because it contains credentials (' + v.rules + '). Do not retry the same call.' }
180 }
181 if (res.isError === true) {
182 const failure = typeof res.text === 'string' ? ctx.redactor.applyText(res.text, v.mapping) : 'The tool failed.'
183 return { deny: failure + '\n\n' + v.notice }
184 }
185 const context: string[] = Array.isArray(res.context) ? res.context : []
186 const redacted = context.map((c) => ctx.redactor.applyText(c, v.mapping))
187 const result = ctx.redactor.applyDeep(res.result, v.mapping)
188 // The scan read `text` (Read adds line numbers there), but core maps `result` again for the model: a secret that is not
189 // in `result` as found (a multi-line private key) is not replaced, so the result must not go through.
190 if (unredacted([...collectStrings(res.result), ...context], [...collectStrings(result), ...redacted], v.mapping)) {
191 notify($, ctx, 'withheld, could not redact (' + v.rules + ')')
192 return {
193 deny:
194 'block-creds withheld this tool result because it contains credentials (' + v.rules + ') that could not be replaced in it. Do not retry the same call.',
195 }
196 }
197 return { result, context: [...redacted, v.notice] }
198}
199
200async function checkBinary($: EngineInterface, ctx: Ctx): Promise<void> {
201 try {
202 const r = await $.process.run([ctx.bin, 'version'], { timeoutMs: 5_000 })
203 if (r.exitCode !== 0) throw new Error('exit ' + r.exitCode)
204 ctx.version = r.stdout.trim()
205 ctx.available = true
206 } catch {
207 ctx.available = false
208 notify($, ctx, ctx.bin + ' was not found or does not run. Install betterleaks (prompts and tool results are withheld until then).')
209 }
210}
211
212/**
213 * Makes placeholders the same after a restart when `hashKey` is empty: uses the key in the plugin store, or saves the generated one.
214 * (A sensitive `userConfig` row is not in `$.config.list()`, so the setting cannot be written from here.)
215 * It runs before the first scan, so the redactor is swapped while it holds nothing.
216 */
217function loadStoredKey($: EngineInterface, ctx: Ctx): Promise<void> {
218 ctx.keyLoad ??= (async () => {
219 const generated = ctx.generatedKey
220 if (generated === undefined) return
221 try {
222 const stored = await $.store.get('hashKey')
223 if (typeof stored === 'string' && stored !== '') ctx.redactor = createRedactor(keyBytes(stored))
224 else await $.store.set('hashKey', generated)
225 } catch (err) {
226 notify($, ctx, 'could not use the saved hashKey, so placeholders change after a restart (' + String(err) + ')')
227 }
228 })()
229 return ctx.keyLoad
230}
231
232function withheldRow(v: { kind: 'block'; rules: string } | { kind: 'error'; error: string }): string {
233 return v.kind === 'block'
234 ? 'block-creds withheld this text because it contains credentials (' + v.rules + ').'
235 : 'block-creds could not check this text for credentials, so it was withheld.'
236}
237
238const DISPLAY_FIELDS: Record<string, readonly string[]> = {
239 AssistantMessage: ['text'],
240 UserMessage: ['text'],
241 ToolUse: ['input', 'output'],
242 ToolResult: ['output'],
243 CommandOutput: ['text'],
244 AskUserQuestion: ['questions'],
245}
246
247function summary(ctx: Ctx): string {
248 const rules = [...ctx.stats.byRule].map(([rule, n]) => rule + ' x' + n).join(', ')
249 return [
250 'mode: ' + ctx.mode,
251 'betterleaks: ' + ctx.bin + (ctx.version ? ' ' + ctx.version : ' (not checked or not found)') + (ctx.configPath ? ', config ' + ctx.configPath : ''),
252 'this session: ' + ctx.stats.redacted + ' redacted, ' + ctx.stats.blocked + ' blocked, ' + ctx.stats.failures + ' scan failures',
253 rules ? 'rules hit: ' + rules : 'rules hit: none',
254 ].join('\n')
255}
256
257export function register(on: On, options: PluginOptions) {
258 const ctx = readCtx(options)
259
260 on('session.start', async ($, e, next) => {
261 await $.command.register({ name: 'block-creds', description: 'Show what block-creds redacted or blocked in this session' })
262 await checkBinary($, ctx)
263 await loadStoredKey($, ctx)
264 return next(e)
265 })
266
267 // Only the drawing changes; the stored messages, and so what the model reads, keep the placeholders.
268 on('ui.render', ($, e, next) => {
269 const fields = DISPLAY_FIELDS[e.component]
270 if (!ctx.restoreDisplay || fields === undefined) return next(e)
271 const props = e.props as Record<string, unknown>
272 const changed: Record<string, unknown> = {}
273 for (const field of fields) {
274 if (props[field] === undefined) continue
275 const r = ctx.redactor.restoreDeep(props[field])
276 if (r.changed) changed[field] = r.value
277 }
278 if (Object.keys(changed).length === 0) return next(e)
279 return next({ ...e, props: { ...props, ...changed } } as typeof e)
280 })
281
282 on('command.run', { command: 'block-creds' }, async () => ({ text: summary(ctx) }))
283
284 // Rows the engine adds to the conversation by itself: `!cmd` (bash mode) and its output arrive here, not at a tool or a prompt.
285 // A row cannot be refused, so a blocked or unchecked one is stored with its text replaced.
286 on('session.append', async ($, e, next) => {
287 if (!APPEND_DOORS.has(e.door)) return next(e)
288 const v = await judge($, ctx, collectStrings(e.message.content).map(forScan))
289 if (v.kind === 'clean') return next(e)
290 if (v.kind === 'redact') {
291 const content = appendToLastText(ctx.redactor.applyDeep(e.message.content, v.mapping), v.notice)
292 return next({ ...e, message: { ...e.message, content } })
293 }
294 return next({ ...e, message: { ...e.message, content: replaceText(e.message.content, withheldRow(v)) } })
295 }).catch(async ($, e, next) => {
296 // Fail closed: the row is stored, so what could not be checked is replaced rather than left as it is.
297 hookFailed($, ctx, next.error.kind)
298 return next({
299 ...e,
300 message: { ...e.message, content: replaceText(e.message.content, 'block-creds failed while checking this row, so its text was withheld.') },
301 })
302 })
303
304 on('prompt.submit', async ($, e, next) => {
305 clearNotes($, ctx)
306 const v = await judge($, ctx, [e.text, ...(e.context ?? [])])
307 if (v.kind === 'clean') return next(e)
308 if (v.kind === 'error') return { drop: 'block-creds could not check the prompt, so it was not sent (' + v.error + ')' }
309 if (v.kind === 'block') return { drop: 'block-creds: the prompt contains credentials (' + v.rules + '), so it was not sent' }
310 const text = ctx.redactor.applyText(e.text, v.mapping)
311 const context = [...(e.context ?? []).map((c) => ctx.redactor.applyText(c, v.mapping)), v.notice]
312 return next({ ...e, text, context })
313 }).catch(async ($, _e, next) => {
314 // Fail closed: a prompt this hook could not check must not be sent.
315 hookFailed($, ctx, next.error.kind)
316 return { drop: 'block-creds failed while checking the prompt, so it was not sent.' }
317 })
318
319 on('prompt.attachment', async ($, e, next) => {
320 if (SKIP_ATTACHMENTS.has(e.type)) return next(e)
321 const v = await judge($, ctx, [e.text])
322 if (v.kind === 'clean') return next(e)
323 if (v.kind === 'error' || v.kind === 'block') return { text: null }
324 return next({ ...e, text: ctx.redactor.applyText(e.text, v.mapping) + '\n\n' + v.notice })
325 }).catch(async ($, _e, next) => {
326 hookFailed($, ctx, next.error.kind)
327 return { text: null }
328 })
329
330 on('tool.call', async ($, e, next) => {
331 let input: typeof e = e
332 if (ctx.restore) {
333 // The model only knows placeholders; the tool needs the real value to work.
334 const { tool, tool_use_id, agentId, ...args } = e as Record<string, unknown>
335 const restored = ctx.redactor.restoreDeep(args)
336 if (restored.changed) {
337 const reserved: Record<string, unknown> = { tool }
338 if (tool_use_id !== undefined) reserved['tool_use_id'] = tool_use_id
339 if (agentId !== undefined) reserved['agentId'] = agentId
340 input = { ...reserved, ...restored.value } as typeof e
341 }
342 }
343 return guardResult($, ctx, await next(input))
344 }).catch(async ($, _e, next) => {
345 // Fail closed: a result this hook could not check must not reach the model.
346 hookFailed($, ctx, next.error.kind)
347 return { deny: 'block-creds failed while checking this tool result, so it was withheld.' }
348 })
349}
350hooks/redactor.ts 194 lines1// Pure redaction logic. No access to the mods API (`$`), so it can be tested without a session.
2
3const BLOCK_SIZE = 64
4const PLACEHOLDER_HEX = 12
5const PLACEHOLDER_RE = /\[REDACTED-[0-9a-f]{12}\]/g
6
7const encoder = new TextEncoder()
8
9export function toHex(bytes: Uint8Array): string {
10 let out = ''
11 for (const b of bytes) out += b.toString(16).padStart(2, '0')
12 return out
13}
14
15async function sha256(data: Uint8Array): Promise<Uint8Array> {
16 return new Uint8Array(await crypto.subtle.digest('SHA-256', data))
17}
18
19// The mods environment's Web Crypto only offers `digest`, so HMAC (RFC 2104) is built from it.
20export async function hmacSha256(key: Uint8Array, message: Uint8Array): Promise<Uint8Array> {
21 const k = new Uint8Array(BLOCK_SIZE)
22 k.set(key.length > BLOCK_SIZE ? await sha256(key) : key)
23 const inner = new Uint8Array(BLOCK_SIZE + message.length)
24 const outer = new Uint8Array(BLOCK_SIZE + 32)
25 for (let i = 0; i < BLOCK_SIZE; i++) {
26 inner[i] = (k[i] ?? 0) ^ 0x36
27 outer[i] = (k[i] ?? 0) ^ 0x5c
28 }
29 inner.set(message, BLOCK_SIZE)
30 outer.set(await sha256(inner), BLOCK_SIZE)
31 return sha256(outer)
32}
33
34export type Mapping = ReadonlyArray<readonly [secret: string, placeholder: string]>
35
36export type Redactor = {
37 /** Placeholders for these secrets, longest secret first (so a secret that contains another is replaced whole). */
38 mapping(secrets: readonly string[]): Promise<Mapping>
39 /** Replaces every secret in a string. */
40 applyText(text: string, mapping: Mapping): string
41 /** Replaces every secret in every string of an object or array, keeping its shape. */
42 applyDeep<T>(value: T, mapping: Mapping): T
43 /** Turns the placeholders this redactor handed out back into the secrets. Memory only. */
44 restoreDeep<T>(value: T): { value: T; changed: boolean }
45 /** How many placeholders this redactor can turn back. */
46 known(): number
47}
48
49export function keyBytes(keyText: string | undefined): Uint8Array {
50 if (keyText) return encoder.encode(keyText)
51 return crypto.getRandomValues(new Uint8Array(32))
52}
53
54/**
55 * The placeholder is a keyed hash of the secret, so the same secret is always the same
56 * placeholder (in a prompt, a tool result or another agent) and nothing has to be stored.
57 * The key stays in memory; without it the model cannot test guesses against the placeholder.
58 */
59export function createRedactor(key: Uint8Array): Redactor {
60 const byPlaceholder = new Map<string, string>()
61 const bySecret = new Map<string, string>()
62
63 async function placeholderOf(secret: string): Promise<string> {
64 const known = bySecret.get(secret)
65 if (known !== undefined) return known
66 const mac = await hmacSha256(key, encoder.encode(secret))
67 const placeholder = '[REDACTED-' + toHex(mac).slice(0, PLACEHOLDER_HEX) + ']'
68 bySecret.set(secret, placeholder)
69 byPlaceholder.set(placeholder, secret)
70 return placeholder
71 }
72
73 function applyText(text: string, mapping: Mapping): string {
74 let out = text
75 for (const [secret, placeholder] of mapping) {
76 if (out.includes(secret)) out = out.split(secret).join(placeholder)
77 }
78 return out
79 }
80
81 function mapStrings(value: unknown, fn: (s: string) => string): unknown {
82 if (typeof value === 'string') return fn(value)
83 if (Array.isArray(value)) return value.map((v) => mapStrings(v, fn))
84 if (value !== null && typeof value === 'object') {
85 const out: Record<string, unknown> = {}
86 for (const [k, v] of Object.entries(value)) out[k] = mapStrings(v, fn)
87 return out
88 }
89 return value
90 }
91
92 return {
93 async mapping(secrets) {
94 const unique = [...new Set(secrets.filter((s) => s.length > 0))]
95 unique.sort((a, b) => b.length - a.length)
96 const out: Array<readonly [string, string]> = []
97 for (const secret of unique) out.push([secret, await placeholderOf(secret)])
98 return out
99 },
100 applyText,
101 applyDeep<T>(value: T, mapping: Mapping): T {
102 return mapStrings(value, (s) => applyText(s, mapping)) as T
103 },
104 restoreDeep<T>(value: T): { value: T; changed: boolean } {
105 let changed = false
106 const restored = mapStrings(value, (s) =>
107 s.replace(PLACEHOLDER_RE, (p) => {
108 const secret = byPlaceholder.get(p)
109 if (secret === undefined) return p
110 changed = true
111 return secret
112 }),
113 ) as T
114 return { value: restored, changed }
115 },
116 known: () => byPlaceholder.size,
117 }
118}
119
120export type NoticeEntry = { placeholder: string; rules: readonly string[] }
121
122/** What the model reads beside redacted content, so it knows what the placeholders are and how to go on. */
123export type NoticeOptions = {
124 /** Placeholders in tool call arguments are swapped for the real value before the tool runs. */
125 restoresInToolInput: boolean
126 /** The user's screen shows the real values in place of the placeholders. */
127 userSeesRealValues: boolean
128}
129
130export function redactionNotice(entries: readonly NoticeEntry[], opts: NoticeOptions): string {
131 const list = entries.map((e) => e.placeholder + ' (' + e.rules.join(', ') + ')').join(', ')
132 return [
133 'block-creds (a credential filter) replaced credentials in this content with placeholders: ' + list + '.',
134 'The real values are never shown to you; the same value always gets the same placeholder.',
135 opts.userSeesRealValues
136 ? 'The user sees the real values on their screen, so refer to a value by its placeholder and the user will know which one you mean.'
137 : 'The user also sees the placeholders instead of the real values.',
138 opts.restoresInToolInput
139 ? 'You can pass a placeholder as-is in tool call arguments: it is swapped for the real value before the tool runs.'
140 : 'Tool call arguments receive the placeholder text literally, not the real value.',
141 'Continue the task. Do not ask the user for these values or try to recover them.',
142 ].join('\n')
143}
144
145type TextBlock = { type: string; text?: unknown }
146
147/** Sets the text of every text block to `text` and leaves the other blocks alone; with no text block, adds one. */
148export function replaceText<B extends TextBlock>(content: readonly B[], text: string): B[] {
149 if (!content.some((b) => b.type === 'text')) return [...content, { type: 'text', text } as B]
150 let first = true
151 return content.flatMap((b) => {
152 if (b.type !== 'text') return [b]
153 if (!first) return []
154 first = false
155 return [{ ...b, text }]
156 })
157}
158
159/** Adds a note after the last text block's text (a new text block is not safe to add to a stored row). */
160export function appendToLastText<B extends TextBlock>(content: readonly B[], note: string): B[] {
161 const out = [...content]
162 for (let i = out.length - 1; i >= 0; i--) {
163 const b = out[i]
164 if (b?.type === 'text' && typeof b.text === 'string') {
165 out[i] = { ...b, text: b.text + '\n\n' + note }
166 return out
167 }
168 }
169 return out
170}
171
172const BASH_TAG_RE = /<\/?bash-(?:input|stdout|stderr)>/g
173
174/** betterleaks misses a secret directly followed by `<` (e.g. `…key</bash-stdout>`), so scan a copy with the bash-mode tags on their own lines. */
175export function forScan(text: string): string {
176 return text.replace(BASH_TAG_RE, '\n$&\n')
177}
178
179/**
180 * Whether a redaction cannot be trusted: a secret is in none of the strings it was applied to (it was found in another
181 * form of the same content, so nothing replaced it), or is still in them afterwards.
182 */
183export function unredacted(original: readonly string[], redacted: readonly string[], mapping: Mapping): boolean {
184 return mapping.some(([secret]) => !original.some((s) => s.includes(secret)) || redacted.some((s) => s.includes(secret)))
185}
186
187/** Every string inside a value, for scanning a result that has no flattened `text`. */
188export function collectStrings(value: unknown, into: string[] = []): string[] {
189 if (typeof value === 'string') into.push(value)
190 else if (Array.isArray(value)) for (const v of value) collectStrings(v, into)
191 else if (value !== null && typeof value === 'object') for (const v of Object.values(value)) collectStrings(v, into)
192 return into
193}
194hooks/scanner.ts 57 lines1// Pure helpers for running betterleaks. The process itself is started in register.ts,
2// because a mod can only pass `$` to functions declared in its own hooks module.
3
4export type Finding = { ruleId: string; secret: string }
5
6export type ScanOutcome = { ok: true; findings: Finding[] } | { ok: false; error: string }
7
8/** Inputs shorter than this cannot hold a credential worth scanning for. */
9export const MIN_SCAN_LENGTH = 8
10
11export function buildArgv(bin: string, configPath: string | undefined): string[] {
12 const argv = [bin, 'stdin', '--no-banner', '-l', 'error', '-f', 'json', '-r', '-']
13 if (configPath) argv.push('-c', configPath)
14 return argv
15}
16
17/**
18 * A finding can carry further secrets inside it: `aws-access-token` lists the paired
19 * `aws-secret-access-key` under `ComponentSets[].components[]` and does not report it on its own.
20 * Every object with a `Secret` at any depth counts.
21 */
22function collectFindings(node: unknown, into: Finding[]): void {
23 if (Array.isArray(node)) {
24 for (const item of node) collectFindings(item, into)
25 return
26 }
27 if (node === null || typeof node !== 'object') return
28 const obj = node as Record<string, unknown>
29 if (typeof obj['Secret'] === 'string' && obj['Secret'].length > 0) {
30 into.push({ ruleId: typeof obj['RuleID'] === 'string' ? obj['RuleID'] : 'unknown', secret: obj['Secret'] })
31 }
32 for (const value of Object.values(obj)) collectFindings(value, into)
33}
34
35/**
36 * betterleaks exits 0 with `[]` when nothing was found and 1 with a JSON array when something was.
37 * Anything else (missing binary, bad config, timeout, cut-off output) is an error, not "clean".
38 */
39export function interpret(exitCode: number, stdout: string, stderr: string): ScanOutcome {
40 if (exitCode !== 0 && exitCode !== 1) {
41 return { ok: false, error: 'betterleaks exited with ' + exitCode + (stderr ? ': ' + stderr.trim().slice(0, 200) : '') }
42 }
43 let parsed: unknown
44 try {
45 parsed = JSON.parse(stdout)
46 } catch {
47 return { ok: false, error: 'betterleaks printed output that is not JSON' }
48 }
49 if (!Array.isArray(parsed)) return { ok: false, error: 'betterleaks printed JSON that is not a list' }
50 const findings: Finding[] = []
51 collectFindings(parsed, findings)
52 if (exitCode === 1 && findings.length === 0) {
53 return { ok: false, error: 'betterleaks reported leaks but listed none' }
54 }
55 return { ok: true, findings }
56}
57