SLOPSHOPPER

multichat

Run several Claude Code chats side by side safely: where the week's usage lands at this pace, a question before two chats edit the same file, heavy builds…

newpaneguardcommandtoastprompt
v0.1.2PolyForm-Noncommercial-1.0.0updated 2026-10-09nakadaharuki/multichat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · multichat
│ ┃ multichat ✕ › fix the failing auth test and add an audit log call │ ┃ Usage │ ┃ 5h ██████░░░░░░░░░░░░░░ 31% → NaN% by ⏺ Read(src/auth.ts) │ ┃ reset resets in NaN min ⎿ Read 6 lines │ ┃ Context ██████████░░░░░░░░░░ 49% ⏺ Update(src/auth.ts) │ ┃ Chats on this PC ⎿ Added 2 lines, removed 1 line │ ┃ this chat · fix the failing auth test and ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ add an audit log … · idle · 3 files changed ⎿ Denied by multichat: multichat refused this: rm -rf delet │ ┃ Held and refused in this chat │ ┃ under a minute · rm -rf deletes a whole tree ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /multichat │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · multichat
Usage 5h ██████░░░░░░░░░░░░░░ 31% → NaN% by reset resets in NaN min Context ██████████░░░░░░░░░░ 49% Chats on this PC this chat · fix the failing auth test and add an audit log … · idle · 3 files changed Held and refused in this chat under a minute · rm -rf deletes a whole tree
README

multichat

One mod for running several Claude Code chats side by side on the desktop. Install it and it works: nothing to configure, nothing to switch.

Chats running at once share four things. multichat looks after each:

SharedWhat multichat does
The plan's usage limitsThe pane says where the week will land at this pace, and turns red when it runs out before the reset
The filesBefore an edit, it asks when another open chat changed the same file in the last 30 minutes (Edit anyway / Cancel)
The PCA heavy command (install, build, test, type check) waits while two other chats already run one. After 3 holds in 10 minutes it goes through, so nothing is stuck
The repository and your keysrm -rf, force push, reset --hard and clean -f are refused (they throw away other chats' work too). Detected keys, emails and public IPs become [REDACTED-…] placeholders. Tool calls containing placeholders are refused; values are never automatically restored

/multichat opens a pane with the usage, every chat on this PC and what was held or refused.

English and Japanese, following Claude Code's language setting.

Install

/plugin marketplace add nakadaharuki/multichat
/plugin install multichat@multichat

Claude Code v2.1.287 or later. Made for the desktop app (the Code tab); it also works in the terminal.

What it touches

Mods are not sandboxed, so here it is before you install. The code is three files: hooks/register.tsx, hooks/guard.ts (reads shell commands) and hooks/redact.ts (finds secrets).

  • No network, no files, no environment variables, no outside programs
  • $.store (this plugin's own storage on this PC): one entry per chat, with a heartbeat, the chat's first prompt cut to 48 characters (with secrets already hidden), the files it changed in the last 30 minutes and the kind of heavy command it runs (npm install, never its arguments). A chat closed for a day is removed
  • It reads Claude Code's language setting and the usage figures the status line shows
  • It watches Bash, PowerShell, Edit, Write and NotebookEdit calls, and rewrites tool results only to hide secrets

No secret vault or reverse mapping is retained. Each detected occurrence gets a new placeholder; repeated values need not have the same placeholder. Tool inputs containing placeholders (including placeholders from old conversations) are refused, including file edits, to prevent accidental credential replacement. Use credentials configured outside the conversation in the destination tool instead of asking Claude to forward a hidden value.

Every JSON field in a tool result is inspected, including data, keys, error text and context. Credential names such as token retain their detection context in structured JSON too. Non-enumerable properties, symbols and accessors are rejected; tools receive only the inspected copy of a JSON input. If a result exceeds the inspection limits, contains unsupported objects, or cannot be inspected, the entire result is withheld with a fixed error. Binary/image content is not a supported secret-redaction format and may be withheld or altered; this is a text-pattern filter, not an assurance that every kind of secret is detected.

The command reader sees the command text only: a script file, an alias, variable-based execution or cmd /c gets through. It is not a shell sandbox. Active command substitutions such as $(...) and Bash backticks are refused, even inside double quotes; bash -lc strings are inspected. Bash backslash-newline sequences are refused conservatively even in literal quoted text; use single-line commands instead. Malformed quotes and excessive nesting fail closed. Keep deny rules in settings.json as well.

Tests

Offline security regressions using Node 24+ (no installs, real credentials, network or shell execution):

node --test tests/security.node.mjs

Host integration tests require the Claude Code plugin test environment:

claude plugin validate .
claude plugin test .

Credits

hooks/redact.ts is Ray Amjad's secret-redactor (MIT, LICENSE.secret-redactor). The rest is PolyForm Noncommercial 1.0.0: free for personal and other noncommercial use.

日本語

デスクトップ版 Claude Code で、チャットを何本も並べて走らせるための Mod です。入れるだけで動き、設定も切り替えもありません。

  • 使用量: /multichat のペインに、このペースだと週の枠がリセット時に何 % になるかを出し、リセット前に尽きるなら赤くします
  • ファイル: 別のチャットが 30 分以内に変えたファイルを編集する前に聞きます(編集する/やめる)
  • PC: 別のチャットが 2 本重い処理(install・build・test・型検査)を走らせている間は、新しい重い処理を待たせます。10 分で 3 回待たせたら通します
  • リポジトリと鍵: rm -rf・強制 push・reset --hard・clean -f は止めます。検出した鍵・メール・公開 IP は伏せ字にします。秘密値の対応表は保持せず、自動復元もしません。伏せ字を含む道具の入力は、送信や誤った上書きを防ぐため止めます

認証情報は会話の外で道具に設定してください。data を含むJSONの全項目を検査し、上限超過や検査失敗時は結果全体を伏せます。画像・バイナリの秘密検出は対象外で、結果を伏せたり変更したりする場合があります。文字列パターンによる検出であり、すべての秘密を検出する保証ではありません。コマンド置換は拒否しますが、シェルの隔離機能ではないため、設定側の拒否規則も維持してください。

構造化JSONでも token などの親キーを判定に使います。非列挙・Symbol・アクセサーのプロパティは拒否し、道具へ渡すのは検査済みのコピーだけです。Bashのバックスラッシュ改行は、引用文字列内も含めて拒否します。代わりに1行のコマンドを使ってください。

/multichat で、使用量・この PC のチャット・止めたことを欄に出します。通信・ファイル・環境変数・外部プログラムは使いません。

ライセンス: PolyForm Noncommercial 1.0.0(個人など商用でない利用は自由)。hooks/redact.ts だけは Ray Amjad の secret-redactor で MIT(LICENSE.secret-redactor)。

Source 3 files
hooks/register.tsx 404 lines
1// multichat: run several Claude Code chats side by side without them getting in each other's way.
2//
3// Parallel chats share four things, and this mod looks after each, with nothing to switch:
4// - the plan's usage limits: the pane (/multichat) says where the week and the 5 hours will land at this pace
5// - the files: before an edit, it asks when another open chat changed the same file in the last 30 minutes
6// - the PC: a heavy command (install, build, test) waits while two other chats already run one
7// - the repository: commands that throw away work (rm -rf, force push, reset --hard, clean -f) are refused,
8//   and keys, emails and IPs in what Claude reads are swapped for placeholders (./redact.ts)
9//
10// The chats meet in $.store, which every session of this plugin on this PC shares: each chat writes
11// only its own key (chat:<session id>) with a heartbeat, its label, the files it changed and the heavy
12// command it runs. No network, no files, no programs.
13
14import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
15
16type Next = (e: never) => Promise<ToolCallResult<string>>
17import { cleanForce, commandsOf, forcePush, heavyKind, resetHard, rmRecursiveForce } from './guard'
18import type { Shell } from './guard'
19import { hiddenCount, makeConfig, protectToolCall, scrubPii, scrubSecrets, walk } from './redact'
20
21// Ray Amjad's secret-redactor (MIT, ./redact.ts) with its defaults: secrets, emails and public IPs
22// become [REDACTED-…] placeholders. Tool calls with placeholders are refused;
23// no secret value is retained or automatically restored into another tool.
24const cfg = makeConfig({})
25const scrub = (text: string, parentKey?: string) => scrubPii(scrubSecrets(text, cfg, parentKey), cfg)
26
27const MIN = 60_000
28const HOUR = 60 * MIN
29const PANE = 'multichat'
30const LIVE_MS = 2 * MIN // a chat whose heartbeat is older than this is closed
31const BEAT_MS = 30_000
32const RECENT_MS = 30 * MIN // another chat's edit this recent counts
33const HEAVY_AT_ONCE = 2 // heavy commands that may run at once across chats
34const HOLDS = 3 // after this many holds in 10 minutes a command goes through anyway
35const WINDOWS: Record<string, number> = { seven_day: 7 * 24 * HOUR, five_hour: 5 * HOUR }
36
37// ---- words ----
38
39const MESSAGES = {
40  en: {
41    cmd: 'Open the multichat pane: usage, the chats running side by side, and what was held or refused',
42    week: 'Week',
43    fiveHour: '5h',
44    context: 'Context',
45    atReset: '→ {pct}% by reset',
46    heavy: '{n} building',
47    noUsage: 'Usage shows after the first reply',
48    askEdit: '"{other}" changed {file} {ago} ago. Edit it here too?',
49    edit: 'Edit anyway',
50    cancel: 'Cancel',
51    cancelled: 'The user stopped this edit: another open chat ("{other}") changed {file} {ago} ago. Tell the user, and ask before touching the file again.',
52    held: 'Held back: {n} other chats are already running heavy work ({kinds}) on this PC. Run this command again in 1 to 2 minutes, or do lighter work first.',
53    refused: 'multichat refused this: {what}. If it is really needed, ask the user to run it themselves.',
54    what: { 'rm-rf': 'rm -rf deletes a whole tree', 'force-push': 'a force push overwrites the remote', 'reset-hard': 'reset --hard throws away uncommitted work (other chats\' too)', 'clean-force': 'clean -f deletes untracked files (other chats\' too)' },
55    ago: (m: number) => (m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h`),
56    left: (ms: number) => (ms >= 24 * HOUR ? `${Math.round(ms / (24 * HOUR))} d` : ms >= HOUR ? `${Math.round(ms / HOUR)} h` : `${Math.max(1, Math.round(ms / MIN))} min`),
57    paneUsage: 'Usage',
58    paneChats: 'Chats on this PC',
59    paneLog: 'Held and refused in this chat',
60    resets: 'resets in {left}',
61    thisChat: 'this chat',
62    idle: 'idle',
63    working: 'working',
64    files: '{n} files changed',
65    none: 'Nothing yet',
66    untitled: 'new chat',
67    hid: 'hid {n} secret value(s) from Claude',
68    hidPrompt: 'hid {n} secret value(s) from the prompt',
69    unsafeInput: 'multichat refused an input containing a redaction placeholder or data it could not inspect. Use a tool with credentials configured outside the conversation.',
70    unsafeShell: 'multichat refused shell syntax it cannot safely inspect. Use a simple command without command substitutions.',
71    unsafePrompt: 'multichat withheld a prompt that could not be safely inspected.',
72  },
73  ja: {
74    cmd: 'multichat の欄を開く: 使用量・並行して動くチャット・止めたこと',
75    week: '週',
76    fiveHour: '5時間',
77    context: '文脈',
78    atReset: '→ リセット時 {pct}%',
79    heavy: '重い処理 {n}',
80    noUsage: '使用量は最初の返事の後に出ます',
81    askEdit: '「{other}」が {ago}前に {file} を変更しました。ここでも編集しますか?',
82    edit: '編集する',
83    cancel: 'やめる',
84    cancelled: '利用者がこの編集を止めました。開いている別のチャット(「{other}」)が {ago}前に {file} を変更しています。利用者に伝え、このファイルに触る前に確かめてください。',
85    held: '待ってもらいました: この PC で別のチャット {n} 本が重い処理({kinds})を走らせています。1〜2 分後にこのコマンドをもう一度走らせるか、先に軽い作業をしてください。',
86    refused: 'multichat が止めました: {what}。どうしても要るなら、利用者に自分で走らせてもらってください。',
87    what: { 'rm-rf': 'rm -rf は木ごと消す', 'force-push': '強制 push は遠くの履歴を上書きする', 'reset-hard': 'reset --hard は書きかけ(別のチャットの分も)を捨てる', 'clean-force': 'clean -f は追跡していないファイル(別のチャットの分も)を消す' },
88    ago: (m: number) => (m < 1 ? '1分以内' : m < 60 ? `${m}分` : `${Math.floor(m / 60)}時間`),
89    left: (ms: number) => (ms >= 24 * HOUR ? `${Math.round(ms / (24 * HOUR))}日` : ms >= HOUR ? `${Math.round(ms / HOUR)}時間` : `${Math.max(1, Math.round(ms / MIN))}分`),
90    paneUsage: '使用量',
91    paneChats: 'この PC のチャット',
92    paneLog: 'この会話で止めたこと',
93    resets: 'リセットまで {left}',
94    thisChat: 'この会話',
95    idle: '待機',
96    working: '作業中',
97    files: '変更 {n} 件',
98    none: 'まだありません',
99    untitled: '新しいチャット',
100    hid: '秘密の値を {n} 個伏せました',
101    hidPrompt: 'プロンプトの秘密の値を {n} 個伏せました',
102    unsafeInput: '伏せ字を含む入力、または安全に検査できない入力を止めました。認証情報を会話の外で設定済みの道具を使ってください。',
103    unsafeShell: '安全に検査できないシェル構文を止めました。コマンド置換を含まない単純なコマンドを使ってください。',
104    unsafePrompt: '安全に検査できないプロンプトを伏せました。',
105  },
106}
107type Lang = keyof typeof MESSAGES
108type Words = (typeof MESSAGES)['en']
109let lang: Lang = 'en'
110const w = (): Words => MESSAGES[lang] as Words
111const t = (key: keyof Words, params: Record<string, string | number> = {}) =>
112  String(w()[key]).replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''))
113// Claude Code's language setting: "japanese", "日本語", "ja-JP" → ja; anything else → en
114const pickLang = (setting: unknown): Lang => (typeof setting === 'string' && /^(ja\b|ja[-_]|japanese|日本)/i.test(setting.trim()) ? 'ja' : 'en')
115
116// ---- the shared ledger ----
117
118type Chat = { id: string; label: string; root: string; at: number; busy: boolean; heavy: string | null; files: Record<string, number> }
119type Limit = { kind: string; pct: number; resetsAt: number | null; proj: number | null; runsOutIn: number | null }
120type LogLine = { at: number; text: string }
121
122const me: Chat = { id: '', label: '', root: '', at: 0, busy: false, heavy: null, files: {} }
123let others: Chat[] = []
124let limits: Limit[] = []
125let context: number | null = null
126let log: LogLine[] = []
127let lastNow = 0 // the clock at the last tick: drawing reads it (a render hook does not wait on the clock)
128const holds: number[] = []
129const allowed = new Map<string, number>() // file → the other chat's edit time already said yes to
130
131const norm = (p: string) => p.replace(/\\/g, '/').replace(/^([a-z]):/, (_, d) => `${d.toUpperCase()}:`)
132const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p
133const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
134const labelOf = (c: Chat) => c.label || base(c.root) || t('untitled')
135
136async function save($: EngineInterface, now: number) {
137  lastNow = Math.max(lastNow, now)
138  me.at = now
139  for (const [f, at] of Object.entries(me.files)) if (now - at > RECENT_MS) delete me.files[f]
140  if (me.id) await $.store.set(`chat:${me.id}`, me)
141}
142
143async function readOthers($: EngineInterface, now: number) {
144  lastNow = now
145  const list: Chat[] = []
146  for (const key of await $.store.keys()) {
147    if (!key.startsWith('chat:') || key === `chat:${me.id}`) continue
148    const c = (await $.store.get(key)) as Chat | undefined
149    if (!c || typeof c.at !== 'number') continue
150    // a chat closed for a day is cleared, so the store does not grow
151    if (now - c.at > 24 * HOUR) await $.store.delete(key)
152    else if (now - c.at <= LIVE_MS) list.push(c)
153  }
154  others = list
155}
156
157// Where the window lands at this pace: used so far over the time gone, across the whole window
158function project(kind: string, pct: number, resetsAt: number | null, now: number): Limit {
159  const span = WINDOWS[kind]
160  if (!span || resetsAt === null) return { kind, pct, resetsAt, proj: null, runsOutIn: null }
161  const gone = span - (resetsAt - now)
162  // too early in the window to say anything (the first 5% of it)
163  if (gone < span * 0.05 || pct <= 0) return { kind, pct, resetsAt, proj: null, runsOutIn: null }
164  const rate = pct / gone
165  const proj = Math.round(pct + rate * (resetsAt - now))
166  const runsOutIn = proj > 100 ? (100 - pct) / rate : null // from now until the window is used up
167  return { kind, pct, resetsAt, proj, runsOutIn }
168}
169
170async function measure($: EngineInterface, now: number) {
171  try {
172    const u = await $.session.usage()
173    context = typeof u.context?.percent === 'number' ? Math.round(u.context.percent) : null
174    limits = u.rateLimits
175      .filter(r => r.kind in WINDOWS)
176      .map(r => project(r.kind, r.percentUsed, r.resetsAt ? Date.parse(r.resetsAt) : null, now))
177  } catch {
178    // no usage where nothing measures it (claude -p)
179  }
180}
181
182async function tick($: EngineInterface) {
183  const now = await $.clock.now()
184  await measure($, now)
185  await save($, now)
186  await readOthers($, now)
187  $.ui.invalidate('ui.render')
188}
189
190const note = async ($: EngineInterface, text: string) => {
191  log = [...log, { at: await $.clock.now(), text }].slice(-30)
192  $.ui.invalidate('ui.render')
193}
194
195// ---- guards ----
196
197const REFUSE: [keyof Words['what'], (words: string[]) => boolean][] = [
198  ['rm-rf', rmRecursiveForce],
199  ['force-push', forcePush],
200  ['reset-hard', resetHard],
201  ['clean-force', cleanForce],
202]
203
204async function shell($: EngineInterface, e: { command?: string }, next: Next, kind: Shell) {
205  let commands: string[][]
206  try {
207    commands = commandsOf(String(e.command ?? ''), kind)
208  } catch {
209    return { deny: t('unsafeShell') }
210  }
211  const hit = REFUSE.find(([, test]) => commands.some(test))
212  if (hit) {
213    const what = w().what[hit[0]]
214    await note($, what)
215    return { deny: t('refused', { what }) }
216  }
217  const heavy = commands.map(heavyKind).find(Boolean) ?? null
218  if (!heavy) return next(e as never)
219  const now = await $.clock.now()
220  await readOthers($, now)
221  const busy = others.filter(c => c.heavy)
222  while (holds.length && now - (holds[0] ?? now) > 10 * MIN) holds.shift()
223  if (busy.length >= HEAVY_AT_ONCE && holds.length < HOLDS) {
224    holds.push(now)
225    const kinds = busy.map(c => c.heavy).join(', ')
226    await note($, `${heavy} — ${t('heavy', { n: busy.length })}`)
227    return { deny: t('held', { n: busy.length, kinds }) }
228  }
229  me.heavy = heavy
230  await save($, now)
231  try {
232    return await next(e as never)
233  } finally {
234    me.heavy = null
235    await save($, await $.clock.now())
236  }
237}
238
239async function edit($: EngineInterface, file: string, e: unknown, next: Next) {
240  const path = norm(file)
241  const now = await $.clock.now()
242  await readOthers($, now)
243  const clash = others
244    .map(c => ({ c, at: c.files?.[path] }))
245    .filter((x): x is { c: Chat; at: number } => typeof x.at === 'number' && now - x.at <= RECENT_MS)
246    .sort((a, b) => b.at - a.at)[0]
247  if (clash && allowed.get(path) !== clash.at) {
248    const params = { other: short(labelOf(clash.c), 40), file: base(path), ago: w().ago(Math.floor((now - clash.at) / MIN)) }
249    let answer = ''
250    try {
251      answer = await $.ui.ask(t('askEdit', params), { header: 'multichat', options: [t('edit'), t('cancel')] })
252    } catch {
253      // nobody to ask, or the dialog was closed: do not edit
254    }
255    if (answer !== t('edit')) {
256      await note($, `${base(path)} — ${t('cancel')}`)
257      return { deny: t('cancelled', params) }
258    }
259    allowed.set(path, clash.at)
260  }
261  const r = await next(e as never)
262  if (r.deny === undefined && !r.isError) {
263    me.files[path] = await $.clock.now()
264    await save($, me.files[path])
265  }
266  return r
267}
268
269// ---- drawing ----
270
271export const register: Register = on => {
272
273  on('session.start', async ($, e, next) => {
274    const started = await next(e)
275    lang = pickLang(((await $.settings.read().catch(() => ({}))) as { language?: unknown })?.language)
276    me.id = await $.session.id()
277    me.root = norm(await $.session.root())
278    const mine = (await $.store.get(`chat:${me.id}`)) as Chat | undefined
279    if (mine) Object.assign(me, { label: mine.label, files: mine.files ?? {} })
280    $.clock.every(BEAT_MS, () => void tick($))
281    await tick($)
282    try {
283      await $.command.register({ name: 'multichat', description: t('cmd'), immediate: true })
284    } catch {
285      // already registered by the load before a hot reload
286    }
287    return started
288  })
289
290  // the chat's label in the other chats: its first prompt, cut short
291  // (a pasted key never reaches the model as itself, nor the label)
292  on('prompt.submit', async ($, e, next) => {
293    const before = hiddenCount()
294    let text: string
295    try {
296      text = walk(e.text, scrub) as string
297    } catch {
298      return next({ ...e, text: t('unsafePrompt') })
299    }
300    if (hiddenCount() > before) $.ui.toast(t('hidPrompt', { n: hiddenCount() - before }))
301    if (!me.label) me.label = short(text.replace(/\s+/g, ' ').trim(), 48)
302    me.busy = true
303    await save($, await $.clock.now())
304    return next({ ...e, text })
305  })
306
307  // what a tool reads back: where a secret usually arrives (a cat of .env, a Read of a config)
308  on('tool.call', async ($, e, next) => {
309    const before = hiddenCount()
310    // Inspect the whole envelope, including text, context and denial details.
311    const r = await protectToolCall(e, next, scrub, t('unsafeInput'))
312    if (hiddenCount() > before) $.ui.notice(e.tool_use_id, t('hid', { n: hiddenCount() - before }))
313    return r
314  })
315
316  // the blocks on the first message (CLAUDE.md and friends): secrets only, the user's own email stays
317  on('prompt.context', async ($, e, next) => {
318    try {
319      const r = await next(e)
320      return walk(r, (text, parentKey) => scrubSecrets(text, cfg, parentKey)) as typeof r
321    } catch {
322      return { blocks: [] }
323    }
324  })
325
326  on('turn.complete', async ($, e, next) => {
327    if (e.agentId === undefined) {
328      me.busy = false
329      await tick($)
330    }
331    return next(e)
332  })
333
334  on('session.measure', async ($, e, next) => {
335    await measure($, await $.clock.now())
336    $.ui.invalidate('ui.render')
337    return next(e)
338  })
339
340  on('tool.call', { tool: 'Bash' }, ($, e, next) => shell($, e, next as never, 'bash'))
341  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => shell($, e, next as never, 'powershell'))
342  on('tool.call', { tool: 'Edit' }, ($, e, next) => edit($, e.file_path, e, next as never))
343  on('tool.call', { tool: 'Write' }, ($, e, next) => edit($, e.file_path, e, next as never))
344  on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => edit($, e.notebook_path, e, next as never))
345
346  on('command.run', { command: 'multichat' }, async $ => {
347    await $.ui.open({ id: PANE, title: 'multichat' })
348    return { text: '' }
349  })
350
351  // the pane: the same three things in full
352  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
353    const { Box, Text } = $.ui.resolve(e)
354    const now = lastNow
355    const bar = (pct: number) => {
356      const n = Math.max(0, Math.min(20, Math.round(pct / 5)))
357      return '█'.repeat(n) + '░'.repeat(20 - n)
358    }
359    const chats = [me, ...others]
360    const gap = e.surface === 'desktop' ? 1 : 0
361    return (
362      <Box flexDirection="column" gap={gap}>
363        <Box flexDirection="column">
364          <Text bold>{t('paneUsage')}</Text>
365          {limits.length === 0 && context === null && <Text dimColor>{t('noUsage')}</Text>}
366          {limits.map(l => (
367            <Text color={l.runsOutIn !== null ? 'red' : undefined}>
368              {(l.kind === 'seven_day' ? t('week') : t('fiveHour')).padEnd(5)} {bar(l.pct)} {Math.round(l.pct)}%
369              {l.proj !== null ? ` ${t('atReset', { pct: l.proj })}` : ''}
370              {l.resetsAt !== null ? `  ${t('resets', { left: w().left(l.resetsAt - now) })}` : ''}
371            </Text>
372          ))}
373          {context !== null && (
374            <Text>
375              {t('context').padEnd(5)} {bar(context)} {context}%
376            </Text>
377          )}
378        </Box>
379        <Box flexDirection="column">
380          <Text bold>{t('paneChats')}</Text>
381          {chats.map(c => (
382            <Text dimColor={!c.busy && !c.heavy}>
383              {c === me ? `${t('thisChat')} · ` : ''}
384              {short(labelOf(c), 48)} · {c.heavy ?? (c.busy ? t('working') : t('idle'))} · {t('files', { n: Object.keys(c.files ?? {}).length })}
385            </Text>
386          ))}
387        </Box>
388        <Box flexDirection="column">
389          <Text bold>{t('paneLog')}</Text>
390          {log.length === 0 && <Text dimColor>{t('none')}</Text>}
391          {log
392            .slice(-8)
393            .reverse()
394            .map(l => (
395              <Text dimColor>
396                {w().ago(Math.floor((now - l.at) / MIN))} · {l.text}
397              </Text>
398            ))}
399        </Box>
400      </Box>
401    )
402  })
403}
404
hooks/guard.ts 230 lines
1// Reads a shell command the way the shell will: quotes and escapes resolved, wrappers
2// (sudo, env, timeout, xargs, find -exec) skipped, bash -c "…" and pwsh -Command "…" read again.
3// It sees the command text only: a script file, an alias or cmd /c gets through.
4// Active command substitutions and malformed quotes are rejected, not guessed.
5
6export type Shell = 'bash' | 'powershell'
7
8function assertInspectable(command: string, shell: Shell) {
9  // Removing a Bash continuation can join tokens or create a substitution.
10  // Refuse it conservatively (even in quoted text) rather than insert a space.
11  if (shell === 'bash' && /\\\r?\n/.test(command)) throw new Error('Bash line continuation is not supported')
12  let quote = ''
13  const escape = shell === 'bash' ? '\\' : '`'
14  for (let i = 0; i < command.length; i++) {
15    const c = command[i]
16    if (quote === "'") {
17      if (c === "'") quote = ''
18      continue
19    }
20    if (c === escape && i + 1 < command.length && (!quote || shell === 'powershell' || /[$`"\\\n\r]/.test(command[i + 1]))) {
21      i++
22      continue
23    }
24    if ((c === '$' && command[i + 1] === '(') || (shell === 'bash' && c === '`')) {
25      throw new Error('Command substitution is not supported')
26    }
27    if (c === quote) quote = ''
28    else if (!quote && (c === '"' || c === "'")) quote = c
29  }
30  if (quote) throw new Error('Unclosed shell quote')
31}
32
33
34// A command line split into simple commands, each a list of words with quotes and
35// escapes resolved. Splits at ; & | newlines and ` in bash. A line ending in the
36// escape character in PowerShell continues on the next; Bash continuations
37// have already been refused. A quoted
38// string with spaces stays one word. What stands inside ( ), $( ) or { } is read
39// twice: as part of the command around it (`rm (Join-Path a b) -Recurse -Force`)
40// and as a command of its own (`% { rm $_ -Recurse -Force }`). Command
41// substitutions are refused before this tokenizer, including inside quotes.
42const simpleCommands = (command: string, shell: Shell): string[][] => {
43  const out: string[][] = []
44  let words: string[] = []
45  let nested: number[] = []
46  let word = ''
47  let has = false
48  let quote = ''
49  const endWord = () => {
50    if (has) words.push(word)
51    word = ''
52    has = false
53  }
54  const endCommand = () => {
55    endWord()
56    for (const at of [0, ...nested]) if (words.length > at) out.push(words.slice(at))
57    words = []
58    nested = []
59  }
60  const escape = shell === 'bash' ? '\\' : '`'
61  const s = shell === 'bash' ? command : command.replace(/`\r?\n/g, ' ')
62  for (let i = 0; i < s.length; i++) {
63    const c = s[i]
64    if (quote) {
65      if (c === quote) quote = ''
66      else if (c === escape && quote === '"' && i + 1 < s.length && (shell === 'powershell' || /[$`"\\]/.test(s[i + 1]))) word += s[++i]
67      else word += c
68      continue
69    }
70    if (c === '"' || c === "'") (quote = c), (has = true)
71    else if (c === escape && i + 1 < s.length) (word += s[++i]), (has = true)
72    else if (c === '\n' || c === ';' || c === '&' || c === '|' || (shell === 'bash' && c === '`')) endCommand()
73    else if (c === '(' || c === '{' || (c === '$' && s[i + 1] === '(')) {
74      if (c === '$') i++
75      endWord()
76      nested.push(words.length)
77    } else if (c === ')' || c === '}' || /\s/.test(c)) endWord()
78    else (word += c), (has = true)
79  }
80  endCommand()
81  return out
82}
83
84// A shell started from this one runs a string of its own: that string is read again
85// with the inner shell's grammar. (cmd /c and a script file are not followed.)
86const SHELLS: Record<string, [(word: string) => boolean, Shell]> = {
87  bash: [w => /^-[A-Za-z]*c[A-Za-z]*$/.test(w), 'bash'],
88  sh: [w => /^-[A-Za-z]*c[A-Za-z]*$/.test(w), 'bash'],
89  zsh: [w => /^-[A-Za-z]*c[A-Za-z]*$/.test(w), 'bash'],
90  dash: [w => /^-[A-Za-z]*c[A-Za-z]*$/.test(w), 'bash'],
91  pwsh: [w => w.length >= 2 && '-command'.startsWith(w.toLowerCase()), 'powershell'],
92  powershell: [w => w.length >= 2 && '-command'.startsWith(w.toLowerCase()), 'powershell'],
93}
94export const commandsOf = (command: string, shell: Shell, depth = 0): string[][] => {
95  if (depth > 8 || command.length > 100_000) throw new Error('Shell inspection limit')
96  assertInspectable(command, shell)
97  return simpleCommands(command, shell).flatMap(words => {
98    const i = commandIndex(words)
99    const inner = i < 0 ? undefined : SHELLS[program(words[i])]
100    const at = inner ? words.findIndex((w, k) => k > i && inner[0](w)) : -1
101    return at < 0 || at + 1 >= words.length ? [words] : [words, ...commandsOf(words[at + 1], inner![1], depth + 1)]
102  })
103}
104
105// Words that run the command after them, and the options of theirs that take a value.
106const WRAPPERS: Record<string, RegExp | null> = {
107  sudo: /^-(u|g|C|h|p|r|t|U|T)$/,
108  doas: /^-(u|C)$/,
109  env: /^-(u|C|S)$/,
110  nice: /^-n$/,
111  ionice: /^-[cn]$/,
112  nohup: null,
113  time: null,
114  timeout: /^-[ks]$/,
115  command: null,
116  exec: /^-a$/,
117  xargs: /^-(I|L|n|P|d|E|s|a)$/,
118  caffeinate: /^-[tw]$/,
119}
120// Shell words that may stand before a command.
121const KEYWORDS = /^(if|then|else|elif|fi|while|until|do|done|!)$/
122const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
123
124// The program a word names, without its path or .exe: /bin/rm, .\rm, "C:\…\git.exe".
125export const program = (word: string) => word.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
126
127// The index of the word that is the command proper, after keywords, assignments and
128// wrappers. `find … -exec cmd` and `xargs cmd` continue at cmd. -1 when none.
129export const commandIndex = (words: string[]): number => {
130  let i = 0
131  while (i < words.length) {
132    const w = words[i]
133    if (KEYWORDS.test(w) || ASSIGNMENT.test(w)) i++
134    else if (program(w) in WRAPPERS) {
135      const takesValue = WRAPPERS[program(w)]
136      i++
137      if (program(w) === 'timeout' && /^\d/.test(words[i] ?? '')) i++
138      while (i < words.length && (words[i].startsWith('-') || ASSIGNMENT.test(words[i]))) i += takesValue?.test(words[i]) ? 2 : 1
139      if (program(w) === 'timeout' && /^\d/.test(words[i] ?? '')) i++
140    } else if (program(w) === 'find') {
141      const at = words.findIndex((x, k) => k > i && /^-(exec|execdir|ok|okdir)$/.test(x))
142      return at < 0 ? -1 : commandIndex(words.slice(at + 1)) + (at + 1)
143    } else return i
144  }
145  return -1
146}
147
148export const forcePush = (words: string[]): boolean => {
149  let i = commandIndex(words)
150  if (i < 0 || program(words[i]) !== 'git') return false
151  // global options between git and its subcommand: -C <path>, -c <k=v>, --git-dir <path>, --no-pager
152  i++
153  while (i < words.length && words[i].startsWith('-')) i += /^(-[Cc]|--(git-dir|work-tree|namespace|exec-path|super-prefix|config-env))$/.test(words[i]) ? 2 : 1
154  if (words[i] !== 'push') return false
155  return words.slice(i + 1).some(w => /^--force(-with-lease|-if-includes)?(=|$)/.test(w) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(w) || /^\+\S/.test(w))
156}
157
158// rm -rf in bash, Remove-Item -Recurse -Force and its aliases in PowerShell. Options
159// may be abbreviated (GNU: --rec --for; PowerShell: -r -fo) and clustered (-rf, -Rf).
160const RM = /^(rm|remove-item|ri|del|erase|rd|rmdir)$/
161export const rmRecursiveForce = (words: string[]): boolean => {
162  const i = commandIndex(words)
163  if (i < 0 || !RM.test(program(words[i]))) return false
164  let recursive = false
165  let force = false
166  for (const raw of words.slice(i + 1)) {
167    if (!raw.startsWith('-') || raw === '--') continue
168    const w = raw.toLowerCase()
169    if (w.startsWith('--')) {
170      if (w.length >= 3 && '--recursive'.startsWith(w)) recursive = true
171      if (w.length >= 3 && '--force'.startsWith(w)) force = true
172    } else if (w.length >= 2 && '-recurse'.startsWith(w)) recursive = true
173    else if (w.length >= 3 && '-force'.startsWith(w)) force = true
174    else if (/^-[a-z]{1,5}$/.test(w)) {
175      // a cluster of short options: -rf, -Rf, -rfv
176      if (w.includes('r')) recursive = true
177      if (w.includes('f')) force = true
178    }
179  }
180  return recursive && force
181}
182
183// The git subcommand and the words after it, past git's global options. null when not git.
184const gitArgs = (words: string[]): string[] | null => {
185  let i = commandIndex(words)
186  if (i < 0 || program(words[i]) !== 'git') return null
187  i++
188  while (i < words.length && words[i].startsWith('-')) i += /^(-[Cc]|--(git-dir|work-tree|namespace|exec-path|super-prefix|config-env))$/.test(words[i]) ? 2 : 1
189  return words.slice(i)
190}
191
192// git reset with --hard: throws away uncommitted work, a parallel chat's included
193export const resetHard = (words: string[]): boolean => {
194  const a = gitArgs(words)
195  return a !== null && a[0] === 'reset' && a.includes('--hard')
196}
197
198// git clean with -f (alone or in a cluster) or --force: deletes untracked files
199export const cleanForce = (words: string[]): boolean => {
200  const a = gitArgs(words)
201  return a !== null && a[0] === 'clean' && a.slice(1).some(w => w === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(w))
202}
203
204// Heavy work: installs, builds, tests, type checks. A short label for it, never its arguments.
205const RUNNERS = /^(npm|pnpm|yarn|bun|cargo|go|dotnet|gradle|gradlew|mvn|docker|make|tsc|jest|vitest|pytest|playwright|webpack)$/
206const ALONE = /^(tsc|jest|vitest|pytest|playwright|webpack|make)$/
207const VERB = /^(install|i|ci|add|build|test|t|compile|restore|typecheck|lint|e2e)$/
208const SCRIPT = /^(build|test|typecheck|lint|e2e|check|compile|tsc)(:|$)/
209export const heavyKind = (words: string[]): string | null => {
210  let i = commandIndex(words)
211  if (i < 0) return null
212  // npx tsc, bunx vitest: the tool after the runner
213  if (/^(npx|pnpx|bunx)$/.test(program(words[i]))) {
214    i = words.findIndex((x, k) => k > i && !x.startsWith('-'))
215    if (i < 0) return null
216  }
217  const p = program(words[i])
218  if (!RUNNERS.test(p)) return null
219  if (ALONE.test(p)) return p
220  const rest = words.slice(i + 1).filter(w => !w.startsWith('-'))
221  const verb = rest[0] ?? ''
222  if (p === 'docker') return verb === 'build' ? 'docker build' : null
223  // a bare yarn / pnpm / bun installs
224  if (!verb) return /^(yarn|pnpm|bun)$/.test(p) ? `${p} install` : null
225  if (verb === 'run') return SCRIPT.test(rest[1] ?? '') ? `${p} run ${rest[1].split(':')[0]}` : null
226  if (VERB.test(verb)) return `${p} ${verb}`
227  // yarn build, pnpm test:unit
228  return /^(yarn|pnpm|bun)$/.test(p) && SCRIPT.test(verb) ? `${p} ${verb.split(':')[0]}` : null
229}
230
hooks/redact.ts 368 lines
1// From Ray Amjad's secret-redactor (MIT, github.com/ray-amjad/awesome-claude-code-function-hooks@12b5fea).
2// Licence: ../LICENSE.secret-redactor. The hooks that used it are in register.tsx.
3
4
5
6// Keeps three classes of value out of the transcript: secrets, email
7// addresses and IP addresses.
8//
9// Each occurrence gets an opaque placeholder. No reverse mapping or secret
10// values are retained: a placeholder must never become a credential in a tool.
11// Tool calls containing placeholders are refused to avoid both disclosure and
12// accidentally writing a placeholder over a working credential.
13
14type Kind = 'SECRET' | 'EMAIL' | 'IP'
15
16export type Config = {
17  secrets: boolean
18  pii: boolean
19  notify: boolean
20  minEntropy: number
21  minLength: number
22  contextMinEntropy: number
23  privateIps: boolean
24  allow: ReadonlySet<string>
25  allowEmails: readonly string[]
26  allowPrefix: readonly string[]
27  denyPrefix: readonly string[]
28}
29
30// ------------------------------------------------------------- placeholders
31
32let hidden = 0
33const TAG = /\[REDACTED-/i
34
35function mint(kind: Kind, _value: string): string {
36  hidden++
37  return `[REDACTED-${kind}-${hidden.toString(16).padStart(8, '0')}]`
38}
39
40// ------------------------------------------------------------ secret tests
41
42// Shapes that belong to one vendor and mean one thing. These are hidden on
43// sight, whatever their entropy.
44const VENDOR = new RegExp(
45  [
46    'sk-ant-[A-Za-z0-9_-]{16,}',
47    'sk-[A-Za-z0-9_-]{20,}',
48    'gh[pousr]_[A-Za-z0-9]{20,}',
49    'github_pat_[A-Za-z0-9_]{20,}',
50    'xox[baprse]-[A-Za-z0-9-]{10,}',
51    'xapp-[0-9]-[A-Za-z0-9-]{10,}',
52    'A(?:KIA|SIA)[0-9A-Z]{16}',
53    '[sr]k_(?:live|test)_[A-Za-z0-9]{16,}',
54    'whsec_[A-Za-z0-9]{16,}',
55    'AIza[0-9A-Za-z_-]{30,}',
56    'ya29\\.[A-Za-z0-9_-]{20,}',
57    'npm_[A-Za-z0-9]{30,}',
58    'dop_v1_[a-f0-9]{40,}',
59    'glpat-[A-Za-z0-9_-]{16,}',
60    'shpat_[a-f0-9]{32,}',
61    'SG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}',
62    'eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}',
63    'https://hooks\\.slack\\.com/services/[A-Za-z0-9/+_-]{16,}',
64    'https://discord(?:app)?\\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]{16,}',
65  ].join('|'),
66  'g',
67)
68
69const PRIVATE_KEY = /-----BEGIN[^\n-]{0,40}PRIVATE KEY-----[\s\S]*?-----END[^\n-]{0,40}PRIVATE KEY-----/g
70
71// Base64 padding rides along with the token; `=` is kept out of the run so a
72// `name=value` pair does not read as one token.
73// The password inside a connection string: postgres://user:REDACTED@host.
74const URL_PASSWORD = /\b([a-z][a-z0-9+.-]{1,20}:\/\/[^\s/@:]{1,80}):([^\s/@]{3,200})@/gi
75
76// A run of the characters a key is made of. `/` and `.` are left out on
77// purpose: with them in, every long file path becomes a candidate.
78const CANDIDATE = /[A-Za-z0-9+_-]{12,200}={0,2}/g
79
80// A name to the left of the candidate that says the candidate is a key.
81const NAMED =
82  /(?:key|token|secret|password|passwd|pwd|credential|auth|bearer|private|signature|session|cookie|dsn|salt|nonce|otp)["'\]\s]{0,4}[:=]{1,2}\s*["'`]?\s*$/i
83
84const HEX_ONLY = /^[0-9a-f]+$/i
85const DIGITS_ONLY = /^[0-9]+$/
86
87// Prefixes that name a PUBLIC object id, not a key. Stripe hands these out in
88// dashboards, invoices and source code; hiding them makes a session useless
89// and protects nothing. `pk_live_` is Stripe's publishable key, also public.
90// Public ids with a shape rather than a prefix: a YouTube channel id.
91const PUBLIC_SHAPE = /^(?:UC[A-Za-z0-9_-]{22}|PL[A-Za-z0-9_-]{16,32})$/
92
93const PUBLIC_PREFIX =
94  /^(?:price|prod|cus|sub|sched|in|ch|pi|cs|py|re|txn|il|si|seti|evt|acct|promo|coupon|plan|card|ba|src|dp|du|iv|ii|rcpt|file|link|pm|tok|pk|test|toolu|msg|req|run|wf)_/i
95
96// A name written in code (`archived-modules-marker`, `handleCheckoutSession`,
97// `lesson_article_outline_open_v1`) reads as high entropy but is a word list.
98// A key is not built out of words.
99const WORD_PART = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3}|[A-Z]{2,})$/
100const ALNUM_ONLY = /^[A-Za-z0-9]+$/
101const SEGMENTS = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g
102const WORDY = /^[A-Za-z]?[a-z]{2,}$/
103
104// `playerEventBatchV1Schema` splits into six segments of which four are
105// words; a random key splits into many segments of which almost none are.
106function isCamelName(token: string): boolean {
107  if (!ALNUM_ONLY.test(token)) return false
108  const segs = token.match(SEGMENTS)
109  if (!segs || segs.join('') !== token) return false
110  const words = segs.filter((seg) => WORDY.test(seg)).length
111  return words >= 2 && words >= segs.length - 2
112}
113
114function looksLikeName(token: string, named: boolean): boolean {
115  if (isCamelName(token)) return true
116  // Every character used once: an alphabet constant, not a key. A random key
117  // of this length repeats a character with near certainty. A name beside the
118  // token outweighs this, so the rule only runs when there is none.
119  if (!named && token.length >= 20 && new Set(token).size === token.length) return true
120  const parts = token.split(/[_-]/)
121  if (parts.length >= 2 && parts.every((p) => WORD_PART.test(p))) return true
122  // Three or more separated parts, two of them plain words: a naming
123  // convention (`META_Conv_Lookalike-Customers_FreeTrial_2024Q1`), not a key.
124  return parts.length >= 3 && parts.filter((p) => /^[A-Za-z]{4,}$/.test(p)).length >= 2
125}
126
127function entropy(text: string): number {
128  const counts = new Map<string, number>()
129  for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1)
130  let h = 0
131  for (const n of counts.values()) {
132    const p = n / text.length
133    h -= p * Math.log2(p)
134  }
135  return h
136}
137
138function looksSecret(token: string, before: string, cfg: Config): boolean {
139  if (token.startsWith('REDACTED-')) return false
140  if (cfg.allow.has(token)) return false
141  if (DIGITS_ONLY.test(token)) return false
142  if (PUBLIC_SHAPE.test(token)) return false
143  if (PUBLIC_PREFIX.test(token) && !cfg.denyPrefix.some((p) => token.startsWith(p))) return false
144  if (cfg.allowPrefix.some((p) => token.startsWith(p))) return false
145
146  const named = NAMED.test(before)
147  if (looksLikeName(token, named)) return false
148
149  const h = entropy(token)
150
151  // A bare hex run is a git SHA or a checksum far more often than it is a
152  // key, so hex needs a name beside it before it is hidden.
153  if (HEX_ONLY.test(token)) return named && token.length >= 24 && h >= cfg.contextMinEntropy
154
155  const mixed = /[a-z]/.test(token) && /[A-Z]/.test(token) && /[0-9]/.test(token)
156  if (mixed && token.length >= cfg.minLength && h >= cfg.minEntropy) return true
157  // Next to a key's name the bar is lower, but the token still has to look
158  // like a key: letters and digits together, not a word.
159  const alnum = /[0-9]/.test(token) && /[A-Za-z]/.test(token)
160  return named && alnum && token.length >= 16 && h >= cfg.contextMinEntropy
161}
162
163export function scrubSecrets(text: string, cfg: Config, parentKey?: string): string {
164  const prefix = parentKey === undefined ? '' : `${JSON.stringify(parentKey).slice(-64)}:`
165  let out = text
166  out = out.replace(PRIVATE_KEY, (m) => mint('SECRET', m))
167  out = out.replace(VENDOR, (m) => (cfg.allow.has(m) ? m : mint('SECRET', m)))
168  out = out.replace(URL_PASSWORD, (m, head: string, password: string) =>
169    cfg.allow.has(password) ? m : `${head}:${mint('SECRET', password)}@`,
170  )
171  out = out.replace(CANDIDATE, (token: string, offset: number, whole: string) => {
172    // JSON objects must retain the same named-secret context as textual JSON.
173    const before = offset < 64 ? (prefix + whole.slice(0, offset)).slice(-64) : whole.slice(offset - 64, offset)
174    return looksSecret(token, before, cfg) ? mint('SECRET', token) : token
175  })
176  return out
177}
178
179// --------------------------------------------------------------- PII tests
180
181const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24}/g
182
183const OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
184const IPV4 = new RegExp(`(?<![0-9.])(?:${OCTET}\\.){3}${OCTET}(?![0-9.])`, 'g')
185const IPV6 = /(?<![0-9A-Za-z:])(?:[0-9A-Fa-f]{1,4}:){4,7}[0-9A-Fa-f]{1,4}(?![0-9A-Za-z:])/g
186
187function isPrivateV4(ip: string): boolean {
188  const p = ip.split('.').map(Number)
189  if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true
190  if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true
191  if (p[0] === 192 && p[1] === 168) return true
192  if (p[0] === 169 && p[1] === 254) return true
193  if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true
194  if (p[0] >= 224) return true // multicast, reserved, broadcast
195  if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true // benchmarking
196  // RFC 5737: ranges reserved for documentation. A fixture, never a person.
197  if (p[0] === 192 && p[1] === 0 && p[2] === 2) return true
198  if (p[0] === 198 && p[1] === 51 && p[2] === 100) return true
199  if (p[0] === 203 && p[1] === 0 && p[2] === 113) return true
200  return false
201}
202
203// Addresses that only ever stand in for a real one: form placeholders, docs
204// and test fixtures. Hiding them is noise.
205const EXAMPLE_DOMAIN =
206  /@(?:example\.(?:com|org|net)|examples?\.[a-z]+|test|invalid|localhost|acme\.com|(?:your)?domain\.com|company\.com|email\.com|mail\.com|foo\.com|bar\.com|sample\.com|placeholder\.[a-z]+)$/i
207const EXAMPLE_LOCAL = /^(?:you|your|user|username|name|email|someone|test|example|placeholder|first\.last|jane|john|alice|bob|teammate|colleague|member|admin)(?:[.+_-]?[a-z0-9]{0,12})?@/i
208
209function allowedEmail(address: string, cfg: Config): boolean {
210  const low = address.toLowerCase()
211  if (low.endsWith('@users.noreply.github.com')) return true
212  if (low.startsWith('noreply@') || low.startsWith('no-reply@')) return true
213  if (EXAMPLE_DOMAIN.test(low) || EXAMPLE_LOCAL.test(low)) return true
214  return cfg.allowEmails.some((a) => {
215    const rule = a.toLowerCase().trim()
216    return rule.startsWith('@') ? low.endsWith(rule) : low === rule
217  })
218}
219
220export function scrubPii(text: string, cfg: Config): string {
221  let out = text
222  out = out.replace(EMAIL, (m) => (allowedEmail(m, cfg) || cfg.allow.has(m) ? m : mint('EMAIL', m)))
223  out = out.replace(IPV4, (m) => {
224    if (cfg.allow.has(m)) return m
225    if (!cfg.privateIps && isPrivateV4(m)) return m
226    return mint('IP', m)
227  })
228  out = out.replace(IPV6, (m) => {
229    if (cfg.allow.has(m)) return m
230    const low = m.toLowerCase()
231    if (!cfg.privateIps && (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd'))) return m
232    return mint('IP', m)
233  })
234  return out
235}
236
237// ------------------------------------------------------------- the walkers
238
239const MAX_STRING = 8_000_000
240const MAX_DEPTH = 12
241const MAX_NODES = 100_000
242
243// Never return an uninspected subtree. Callers must withhold the entire result
244// on failure. Inspect JSON keys too, and do not invoke getters or toJSON hooks.
245function walk(value: unknown, fn: (s: string, parentKey?: string) => string): unknown {
246  let nodes = 0
247  let characters = 0
248  const visit = (v: unknown, depth: number, parentKey?: string): unknown => {
249    if (++nodes > MAX_NODES || depth > MAX_DEPTH) throw new Error('Inspection limit')
250    if (typeof v === 'string') {
251      characters += v.length
252      if (characters > MAX_STRING) throw new Error('Inspection limit')
253      return fn(v, parentKey)
254    }
255    if (v === null || v === undefined || typeof v === 'number' || typeof v === 'boolean') return v
256    if (typeof v !== 'object') throw new Error('Unsupported result')
257    const array = Array.isArray(v)
258    if (array && v.length > MAX_NODES) throw new Error('Inspection limit')
259    const proto = Object.getPrototypeOf(v)
260    if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) throw new Error('Unsupported result')
261    const out: Record<string, unknown> | unknown[] = array ? new Array(v.length) : {}
262    for (const k of Reflect.ownKeys(v)) {
263      if (typeof k !== 'string') throw new Error('Unsupported result')
264      const prop = Object.getOwnPropertyDescriptor(v, k)
265      if (!prop || !('value' in prop)) throw new Error('Unsupported result')
266      if (array && k === 'length') continue
267      // The host marks its results with a hidden `then: undefined` so they are not taken
268      // for promises. No text can hide in an undefined or a function, and the copy leaves
269      // them out, so dropping them beats withholding every result.
270      if (!prop.enumerable && (prop.value === undefined || typeof prop.value === 'function')) continue
271      // A tool can read non-enumerable properties even though JSON omits them.
272      if (!prop.enumerable || (array && !/^(0|[1-9][0-9]*)$/.test(k))) throw new Error('Unsupported result')
273      const key = visit(k, depth + 1) as string
274      Object.defineProperty(out, key, { value: visit(prop.value, depth + 1, array ? parentKey : k), enumerable: true, writable: true, configurable: true })
275    }
276    return out
277  }
278  return visit(value, 0)
279}
280
281function inspectedToolInput(value: unknown): unknown {
282  return walk(value, text => {
283    if (TAG.test(text)) throw new Error('Redacted input')
284    return text
285  })
286}
287
288export function safeToolInput(value: unknown): boolean {
289  try {
290    inspectedToolInput(value)
291    return true
292  } catch {
293    return false
294  }
295}
296
297// The fallback contains no part of the original result or exception.
298export function protectToolResult<T>(value: T, scrub: (s: string, parentKey?: string) => string): T | { isError: true; result: string; text: string } {
299  try {
300    return walk(value, scrub) as T
301  } catch {
302    const text = 'multichat withheld a tool result that could not be safely inspected.'
303    return { isError: true, result: text, text }
304  }
305}
306
307export async function protectToolCall<E, R>(
308  event: E,
309  next: (event: E) => Promise<R>,
310  scrub: (s: string, parentKey?: string) => string,
311  denied = 'multichat refused an input containing a redaction placeholder or data it could not inspect.',
312): Promise<R | { deny: string } | { isError: true; result: string; text: string }> {
313  let inspected: E
314  try {
315    inspected = inspectedToolInput(event) as E
316  } catch {
317    return { deny: denied }
318  }
319  try {
320    // Only the inspected copy reaches the tool, never the original object.
321    return protectToolResult(await next(inspected), scrub)
322  } catch {
323    const text = 'multichat withheld a tool failure that could not be safely inspected.'
324    return { isError: true, result: text, text }
325  }
326}
327
328// ---------------------------------------------------------------- register
329
330function bool(v: unknown, fallback: boolean): boolean {
331  if (typeof v === 'boolean') return v
332  if (v === 'true') return true
333  if (v === 'false') return false
334  return fallback
335}
336
337function num(v: unknown, fallback: number): number {
338  const n = Number(v)
339  return Number.isFinite(n) ? n : fallback
340}
341
342function strings(v: unknown): string[] {
343  if (Array.isArray(v)) return v.map(String)
344  if (typeof v === 'string' && v.trim()) return v.split(',').map((s) => s.trim()).filter(Boolean)
345  return []
346}
347
348export function makeConfig(options: Record<string, unknown>): Config {
349  return {
350    secrets: bool(options.secrets, true),
351    pii: bool(options.pii, true),
352    notify: bool(options.notify, true),
353    minEntropy: num(options.minEntropy, 3.6),
354    minLength: Math.max(8, num(options.minLength, 24)),
355    contextMinEntropy: num(options.contextMinEntropy, 3.0),
356    privateIps: bool(options.redactPrivateIps, false),
357    allow: new Set(strings(options.allow)),
358    allowEmails: strings(options.allowEmails),
359    allowPrefix: strings(options.allowPrefixes),
360    denyPrefix: strings(options.denyPrefixes),
361  }
362}
363
364// multichat: the hooks that used these live in register.tsx (a plugin has one hooks module).
365// How many values have been hidden so far, so a hook can tell what one pass hid.
366export const hiddenCount = (): number => hidden
367export { walk }
368