SLOPSHOPPER

block-creds

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

newpanebandspinnerrowsguard
v0.6.0MITupdated 2026-10-08skpersonal/claude-code-block-creds-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · block-creds
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ block-creds │ ⏺ Read(src/auth.ts) │ block-creds: scan failed, withheld │ ⎿ Read 6 lines │ (betterleaks printed output that is not │ ⏺ Update(src/auth.ts) │ JSON) │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Read(/work/app/src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Denied by block-creds: block-creds could not check this tool resul│ block-creds │ │ block-creds: scan failed, withheld │ ● Done. refresh now rejects expired claims and logs an audit event. │ (betterleaks printed output that is not │ │ JSON) │ ✻ Worked for 42s · done 4:20 PM ╰────────────────────────────────────────────╯ › /block-creds ⎿ block-creds: mode: redact ⎿ block-creds: betterleaks: betterleaks (not checked or not found) ⎿ block-creds: this session: 0 redacted, 0 blocked, 9 scan failures ⎿ block-creds: rules hit: none ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ block-creds: block-creds: scan failed, withheld (betterleaks printed output that is not JSON)
README

block-creds

認証情報を含むテキストが 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-…]" はそのまま動きます。実行結果に本物の値が出てきても、出力側でまた置換されます。

  • 戻せるのは、この mod がその読み込み中に置換した値だけです。未知のプレースホルダはそのまま渡します。
  • この復元により、モデルが指示した先(curl の宛先など)へ本物の値が渡ることがあります。それを避けたい場合は restoreInToolInput を false にします。

画面表示での復元

モデルに渡る内容と履歴は置換したまま、人間が見る描画だけ本物の値に戻します(ui.render)。モデルの返答、ツールの行と結果、自分のプロンプト、スラッシュコマンドの出力、質問ダイアログが対象です。

  • 戻せるのは、この mod が置換した値だけです。未知のプレースホルダはそのままです。
  • 画面共有や録画には本物の値が映ります。無効にするには restoreInDisplay を false にします。
  • 履歴やモデルが読む内容は変わりません。送信時だけ置換して履歴は本物の値のままにする方法は、mods API にありません(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 です。
  • 手元の clone を 1 セッションだけ試すには claude --plugin-dir /path/to/claude-code-block-creds-mod を使います。

設定(userConfig)

名前既定値内容
moderedactredact: 置換して送る。block: 送らない
betterleaksPathbetterleaks実行ファイルの名前またはパス
configPathなしbetterleaks の設定ファイル(-c)。省略すると betterleaks の既定ルール
hashKey初回に自動生成プレースホルダ用の鍵。空なら store に自動生成
restoreInToolInputtrueツール入力でプレースホルダを本物の値に戻す
restoreInDisplaytrue画面の描画でプレースホルダを本物の値に戻す(モデルに渡る内容は変わらない)

/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$`])
'''

制限事項

  • 画像と PDF の中身は検査しません。
  • モデルが読む形と結果の中身が違うために置換できない値を含むツール結果は、redact モードでも deny します。たとえば Read で読んだ複数行の秘密鍵は、モデルが読むテキストに行番号が入るため、結果の中身と一致しません(Bash の cat なら置換して読めます)。
  • !コマンド の行は会話に保存される前に書き換えるだけで、行そのものを拒否できません。block モードや検査失敗のときは、本文を「伏せた」という文に差し替えて保存します。
  • システムプロンプト(prompt.section、prompt.context)は対象外です。
  • mod を読み込む前に会話に入っていた内容(--resume した会話の履歴など)は置換しません。
  • 検出精度は betterleaks のルールに依存します。未知の形式は見つかりません。
  • プロンプト、ツール結果、添付のそれぞれで betterleaks を 1 回起動します(1 回約 40 ms)。
  • --safe-mode、--bare、disableAllHooks のときは mod が読み込まれず、何も守られません。
  • 検査できないときは常に送りません(fail closed。設定で切り替えることはできません)。
  • betterleaks が起動できない間は、短いテキストも含めてプロンプト・添付・ツール結果・!コマンドの行をすべて止めます。起動できるようになれば自動で解除されます。
  • prompt.submit、prompt.attachment、tool.call、session.append のフックが失敗した場合(例外や時間切れ)も、.catch でそのテキストを破棄(session.append は伏せ文に差し替え)します。

開発

前提: Claude Code 2.1.287 以降、betterleaks、Node.js、pnpm(Biome・TypeScript・husky などの開発用ツール)。ビルド工程はなく、Claude Code が .ts を直接読み込みます。

1. 型定義を生成する(最初と Claude Code を更新した後)

.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 側に置いています。

2. 変更ごとに実行する

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' } }, 本体) で渡します。

3. 実際のセッションで確かめる

スタブのテストでは、結果のスキーマ検証や、実際のセッションがモデルに何を送るかは分かりません(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 通りのキー表記を両方入れてあり、どちらが効いたかは切り分けていません。

4. コードの構成

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)。

Source 3 files
hooks/register.ts 350 lines
1import 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}
350
hooks/redactor.ts 194 lines
1// 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}
194
hooks/scanner.ts 57 lines
1// 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