SLOPSHOPPER

secret-guard

A mod that redacts secret-shaped strings (GitHub, Anthropic, OpenAI, Google, AWS, Slack and Stripe keys, PEM private keys) to [redacted:<kind>] before they are…

newtoastprompt
★ 1v0.1.0MITupdated 2026-10-06insession-space/claude-ship/plugins/secret-guard
A shopper browsing a rack in a slop shop
README

English | 日本語

secret-guard

A Claude Code mod that replaces secret-shaped strings with [redacted:<kind>] before they are kept in the conversation. It covers two places: your prompt, before it is sent, and the model's response, before it is stored and sent back with the next request.

A command hook that scans Bash commands and staged diffs cannot see what you paste into the prompt or what the model writes in its reply. This mod covers those two places.

Install

claude plugin marketplace add insession-space/claude-ship
claude plugin install secret-guard@claude-ship

Restart Claude Code to load it. The mod is the hooks module listed under modules in hooks/hooks.json (hooks/guard.ts). A Claude Code build without mods does not read that module, so nothing happens there.

What it does

Your prompt

When you submit a prompt, the mod replaces each secret-shaped string with [redacted:<kind>] before the prompt enters the session. The model, the transcript file and the user message on screen all get the redacted text. The mod then shows a toast with the number of strings and their kinds, for example:

secret-guard: redacted 1 secret-shaped string before sending (google). Consider rotating it.

The toast never contains the value itself. The mod treats a pasted value as exposed and suggests rotating it, because the value has already left your clipboard.

Prompts that do not come from you (a background task's notification, a peer session's message, a scheduled prompt) are redacted the same way.

The model's response

When the model's response is about to be stored (session.append, door response), the mod redacts the text blocks of that row. This covers the main conversation and every subagent. Thinking blocks and tool calls are kept as they are, because the engine puts them back unchanged. The mod shows no toast for a response.

What it detects

Only shapes with a known prefix, to keep false positives low. There is no generic "high-entropy string" detection.

KindShape
githubghp_ / gho_ / ghu_ / ghs_ / ghr_ followed by 36 or more letters and digits, github_pat_ followed by 70 or more characters
anthropicsk-ant- followed by 32 or more characters
openaisk-proj- followed by 32 or more characters; sk- followed by 32 or more characters that include upper case, lower case and digits
googleAIza followed by exactly 35 characters
awsAKIA / ASIA followed by exactly 16 upper-case letters and digits
slackxoxa- / xoxb- / xoxp- / xoxr- / xoxs- followed by digits, - and 8 or more characters
stripesk_live_ / rk_live_ followed by 16 or more letters and digits
private-keya PEM block from -----BEGIN ... PRIVATE KEY----- to -----END ... PRIVATE KEY-----, or a block cut before its END line (the header and the base64 lines after it)

A match must not be glued to a preceding letter or digit (disk-... is not an sk- key). A PEM block whose body has no base64 run of 16 characters or more (an example with ... in it) is left alone.

Limitations

  • Text already shown while streaming. The screen draws the model's response as it streams. The mod rewrites the response row when it is stored, after the stream. The engine's own description of session.append says the screen, an SDK stream or Remote Control may show the row just before its rewrite. So characters already drawn while streaming are not hidden by this mod. What the model reads afterwards and what the transcript file keeps are the redacted form. Rewriting the stream itself (turn.step) is a different mechanism and this mod does not do it.
  • Tool calls the model writes. A value the model puts into a tool call's arguments (a Bash command, a file to write) stays as it is. The engine puts every tool_use block of a response row back unchanged, so a session.append hook cannot rewrite it, and rewriting it would also change what the tool runs.
  • Tool results are not redacted. See below.
  • Prompt history. The mod rewrites the prompt at prompt.submit. Whether the prompt history you reach with the up arrow keeps the text you typed is not stated by the API, so do not rely on this mod for that.
  • Shapes not in the table. A Gemini key created in Google AI Studio has the AIza shape and is covered. Other providers' keys, passwords and tokens without a known prefix are not.

What it does not redact

  • Tool results (door tool-result). The mod leaves them alone, for these reasons:
  • The screen draws a tool result from its structured record (toolUseResult), which the engine stores as made, beside the row. Redacting the row's text would not remove the value from the screen or from the transcript file.
  • The model would read [redacted:...] in place of the real file contents. It could then write the placeholder back into a file, or fail to match an Edit, which changes how the agent works.
  • When the model copies a value from a tool result into its reply, that reply is redacted.
  • Secrets sent to outside services, files being written, and rotating keys are out of scope.

When something fails

  • If checking your prompt fails, the mod does not send it, and shows the reason in place of the prompt. Send it again.
  • If redacting a response fails, the mod replaces each text block of that row with [redacted:secret-guard-error], so the unchecked text is not stored.

Display language

The language setting of Claude Code first, then the language of your latest prompt, then English.

Tests

claude plugin validate plugins/secret-guard
claude plugin test plugins/secret-guard

The test values are fake. Strings with a real prefix are put together at run time from two halves, so the source never holds a key-shaped literal.

Source 3 files
hooks/guard.ts 67 lines
1// シークレットの形をした文字列を、会話に記録される前に `[redacted:<種類>]` へ置き換える mod。
2// ユーザーの入力は prompt.submit で、モデルの返答は session.append(door `response`)で伏せる。
3import { atom, read, update } from 'claude-code'
4import type { ApiContentBlock, Register, SessionAppendInput } from 'claude-code'
5
6import type { Lang } from '../types'
7import { LABELS, hasJapanese, langFromSetting, redact } from './patterns'
8
9const lang = atom({ plugin: 'secret-guard', key: 'lang' } as const, null as Lang | null)
10
11/** 伏せる処理そのものが失敗したときに、返答の text ブロックの代わりに置く文字列 */
12const FAILED_PLACEHOLDER = '[redacted:secret-guard-error]'
13
14/** 返答の行の text ブロックを伏せる。何も伏せなかったら null を返す。 */
15const redactRow = (e: SessionAppendInput): SessionAppendInput | null => {
16  let isChanged = false
17  const content = e.message.content.map((block): ApiContentBlock => {
18    if (block.type !== 'text' || typeof block.text !== 'string') return block
19    const r = redact(block.text)
20    if (r.count === 0) return block
21    isChanged = true
22    return { ...block, text: r.text }
23  })
24  return isChanged ? { ...e, message: { ...e.message, content } } : null
25}
26
27export const register: Register = on => {
28  // settings の `language` が決まっていればそれを使い、無ければプロンプトの文字で決める
29  let isLangFromSettings = false
30
31  on('session.start', async ($, e, next) => {
32    const fromSettings = langFromSetting((await $.settings.read()).language)
33    isLangFromSettings = fromSettings !== null
34    if (fromSettings !== null) await update($, lang, () => fromSettings)
35
36    return next(e)
37  })
38
39  // ユーザーの入力。送る前に伏せ、件数と種類だけをトーストで伝える(値そのものは出さない)
40  on('prompt.submit', async ($, e, next) => {
41    // ユーザーが打った文だけで言語を決める(タスク通知などエンジン由来の文は英語で来る)
42    const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
43    if (isUserText && !isLangFromSettings) await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
44
45    const r = redact(e.text)
46    if (r.count === 0) return next(e)
47    const l = LABELS[(await read($, lang)) ?? 'en']
48    $.ui.toast(l.redacted(r.count, r.kinds), { timeoutMs: 10_000 })
49
50    return next({ ...e, text: r.text })
51  }).catch(async ($, e, next) => {
52    if (next.called) return next(e)
53    // 伏せられたか分からない入力は通さない
54    const saved = await read($, lang).catch(() => null)
55    return { drop: LABELS[saved ?? 'en'].failed }
56  })
57
58  // モデルの返答。保存とモデルへの送り直しの前に text ブロックを伏せる。
59  // ツール結果(door `tool-result`)は伏せない。理由は README の「伏せないもの」に書いた
60  on('session.append', { door: 'response' }, async ($, e, next) => next(redactRow(e) ?? e)).catch(($, e, next) => {
61    if (next.called) return next(e)
62    // 伏せられたか分からない text ブロックは残さない
63    const content = e.message.content.map(b => (b.type === 'text' ? { ...b, text: FAILED_PLACEHOLDER } : b))
64    return next({ ...e, message: { ...e.message, content } })
65  })
66}
67
hooks/patterns.ts 96 lines
1// シークレットの形をした文字列を見つけて `[redacted:<種類>]` に置き換える純粋関数と、トーストの文言。
2// 誤検知を抑えるため、既知の接頭辞を持つ形だけを探す。汎用の高エントロピー文字列は探さない。
3import type { Lang, SecretKind } from '../types'
4
5type Rule = {
6  kind: SecretKind
7  /** g フラグ付き。String.prototype.replace が lastIndex を戻すので使い回してよい */
8  pattern: RegExp
9  /** 形は合っていても値らしくないもの(説明用の例など)を外すときの追加の判定 */
10  accept?: (match: string) => boolean
11}
12
13/** 前後が英数字につながっていないこと(`disk-...` の中の `sk-` などを拾わない) */
14const L = '(?<![A-Za-z0-9_])'
15const R = '(?![A-Za-z0-9_])'
16
17const hasBase64Run = (s: string): boolean => /[A-Za-z0-9+/]{16,}/.test(s)
18
19// 並び順に意味がある。PEM ブロックを最初に伏せ、`sk-ant-` / `sk-proj-` を長い `sk-` より先に伏せる
20const RULES: readonly Rule[] = [
21  {
22    kind: 'private-key',
23    pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/g,
24    accept: hasBase64Run,
25  },
26  {
27    // END が無いブロック(貼り付けの途中で切れたもの)。ヘッダ行のあとに base64 の行が続く範囲を伏せる
28    kind: 'private-key',
29    pattern:
30      /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----(?:\r?\n[A-Za-z-]+: [^\r\n]*|\r?\n[ \t]*)*(?:\r?\n[ \t]*[A-Za-z0-9+/=]{16,}[ \t]*)+/g,
31  },
32  { kind: 'anthropic', pattern: new RegExp(`${L}sk-ant-[A-Za-z0-9_-]{32,}`, 'g') },
33  { kind: 'openai', pattern: new RegExp(`${L}sk-proj-[A-Za-z0-9_-]{32,}`, 'g') },
34  { kind: 'github', pattern: new RegExp(`${L}gh[pousr]_[A-Za-z0-9]{36,251}${R}`, 'g') },
35  { kind: 'github', pattern: new RegExp(`${L}github_pat_[A-Za-z0-9_]{70,}`, 'g') },
36  { kind: 'stripe', pattern: new RegExp(`${L}[sr]k_live_[A-Za-z0-9]{16,}${R}`, 'g') },
37  { kind: 'google', pattern: new RegExp(`${L}AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])`, 'g') },
38  { kind: 'aws', pattern: new RegExp(`${L}(?:AKIA|ASIA)[A-Z0-9]{16}${R}`, 'g') },
39  { kind: 'slack', pattern: new RegExp(`${L}xox[abprs]-[0-9]+-[A-Za-z0-9-]{8,}`, 'g') },
40  {
41    // 旧形式の `sk-` と英数字48文字など。ケバブケースの長い識別子を拾わないよう、
42    // 大文字・小文字・数字がそろっているものだけを伏せる
43    kind: 'openai',
44    pattern: new RegExp(`${L}sk-[A-Za-z0-9_-]{32,}`, 'g'),
45    accept: m => /[A-Z]/.test(m) && /[a-z]/.test(m.slice(3)) && /[0-9]/.test(m),
46  },
47]
48
49export type Redaction = {
50  text: string
51  /** 伏せた件数の合計 */
52  count: number
53  /** 伏せた種類(初出順、重複なし) */
54  kinds: SecretKind[]
55}
56
57export const placeholder = (kind: SecretKind): string => `[redacted:${kind}]`
58
59/** 文字列の中のシークレットの形を伏せる。見つからなければ同じ文字列と count 0 を返す。 */
60export const redact = (input: string): Redaction => {
61  let count = 0
62  const kinds: SecretKind[] = []
63  let text = input
64  for (const rule of RULES) {
65    text = text.replace(rule.pattern, match => {
66      if (rule.accept !== undefined && !rule.accept(match)) return match
67      count++
68      if (!kinds.includes(rule.kind)) kinds.push(rule.kind)
69      return placeholder(rule.kind)
70    })
71  }
72  return { text, count, kinds }
73}
74
75/** ひらがな・カタカナ・漢字を含むか(acceptance-progress と同じ判定)。 */
76export const hasJapanese = (text: string): boolean => /[぀-ヿ一-鿿]/.test(text)
77
78/** settings の `language` から表示言語を決める。決まらなければ null。 */
79export const langFromSetting = (language: unknown): Lang | null => {
80  if (typeof language !== 'string' || language.trim() === '') return null
81  return /^(ja([-_].*)?|日本語?|japanese)$/i.test(language.trim()) ? 'ja' : 'en'
82}
83
84export const LABELS = {
85  ja: {
86    redacted: (count: number, kinds: readonly SecretKind[]) =>
87      `secret-guard: シークレットの形をした文字列を ${count} 件伏せてから送りました(${kinds.join(', ')})。値のローテーションを検討してください。`,
88    failed: 'secret-guard: 入力の検査に失敗したので、送信を止めました。もう一度送ってください。',
89  },
90  en: {
91    redacted: (count: number, kinds: readonly SecretKind[]) =>
92      `secret-guard: redacted ${count} secret-shaped string${count === 1 ? '' : 's'} before sending (${kinds.join(', ')}). Consider rotating ${count === 1 ? 'it' : 'them'}.`,
93    failed: 'secret-guard: could not check the prompt, so it was not sent. Please send it again.',
94  },
95} as const
96
types/index.d.ts 15 lines
1/** 表示言語 */
2export type Lang = 'ja' | 'en'
3
4/** 伏せる形の種類。置き換え後の `[redacted:<種類>]` に入る */
5export type SecretKind = 'github' | 'anthropic' | 'openai' | 'google' | 'aws' | 'slack' | 'stripe' | 'private-key'
6
7declare module 'claude-code' {
8  interface PluginState {
9    'secret-guard': {
10      /** トーストの言語。値そのものや件数は持たない */
11      lang: Lang | null
12    }
13  }
14}
15