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…

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:
| Shared | What multichat does |
|---|---|
| The plan's usage limits | The pane says where the week will land at this pace, and turns red when it runs out before the reset |
| The files | Before an edit, it asks when another open chat changed the same file in the last 30 minutes (Edit anyway / Cancel) |
| The PC | A 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 keys | rm -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.
/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.
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).
$.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 removedlanguage setting and the usage figures the status line showsBash, PowerShell, Edit, Write and NotebookEdit calls, and rewrites tool results only to hide secretsNo 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.
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 .
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 のペインに、このペースだと週の枠がリセット時に何 % になるかを出し、リセット前に尽きるなら赤くします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)。
hooks/register.tsx 404 lines1// 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}
404hooks/guard.ts 230 lines1// 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}
230hooks/redact.ts 368 lines1// 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