SLOPSHOPPER

ccpii

Replaces personal data in prompts and tool results with numbered placeholders (⟦EMAIL_0⟧, ⟦PERSON_1⟧) found by openai/privacy-filter, and restores them in tool…

newrowsguardcommandtoaststatus
v0.2.0MITupdated 2026-10-26xchacha20-poly1305/ccpii
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccpii
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ ccpii │ ⏺ Read(src/auth.ts) │ Prompt not sent: the privacy filter │ ⎿ Read 6 lines │ stopped before it was ready (Cannot │ ⏺ Update(src/auth.ts) │ destructure property 'code' from null or │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ccpii ⎿ ccpii: filter: the privacy filter stopped before it was ready (Cannot destructure property 'code' from null or undefi ⎿ ccpii: device: the CPU answers; GPU.md says how to run the filter on an Intel GPU ⎿ ccpii: filtering: on ⎿ ccpii: placeholders: 0, saved in /Users/dev/.claude/ccpii/v2/preview-session.json ⎿ ccpii: ⎿ ccpii: /ccpii status what the filter is doing ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ccpii: the privacy filter stopped before it was ready (Cannot destructure property 'code' from null or undefined
README

ccpii

Claude Code Personally Identifiable Information.

A Claude Code mod that keeps personal data away from the model. Before the model reads a prompt or a tool result, openai/privacy-filter runs over the text locally and each detected span is replaced with a numbered placeholder of its kind: ⟦EMAIL_0⟧, ⟦PERSON_1⟧, ⟦PHONE_0⟧. The same value always gets the same placeholder for the whole session. Only text in the ⟦⟧ brackets is a placeholder, so a bare EMAIL_0 in code or docs is left as it is and never swapped for a real value.

  • A section added to the system prompt tells the model what the placeholders are and that it can use them as they are. Claude Code renders the system prompt once per conversation, so in a conversation the mod joined later (turned on mid-session, or a resumed one started without it) the same notice is added to the conversation instead, once, with the first placeholders.
  • When the model calls a tool, the placeholders in the arguments are replaced with the real values just before the tool runs, and the tool's output is filtered again.
  • In the transcript the user sees the real values: messages and tool rows are restored for display only; the stored conversation keeps the placeholders.
  • The engine's own reminders (token budget, tool listings, todo and plan-mode reminders) are not filtered; every other attachment is, a file you mention included.
  • If the filter cannot check something, the prompt is not sent and tool output is withheld from the model (fail-closed), with the reason and what to do in the status line and in the withheld text.

Placeholder kinds: ACCOUNT, ADDRESS, DATE, EMAIL, PERSON, PHONE, URL, SECRET.

Install

From agent-plugins:

claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install ccpii@anrong-plugins

Setup

Needs uv on PATH. Then, in a session:

/ccpii install

It installs the Python dependencies into uv's cache and downloads the model's int8 ONNX export with its config and Viterbi calibration (about 1.6 GB) to ~/.cache/ccpii/privacy-filter. Until it is installed everything is withheld; /ccpii pause lets content through meanwhile. The same install from a shell:

uv run --script server/pii_server.py --download

On an Intel GPU

On an Intel GPU with XMX matrix engines (Arc, including the Arc 130T/140T in Core Ultra 200H), the filter can run its forward pass there, about five times as fast on long texts (a 15 KB file: 0.75 s against 3.8 s). ccpii does not make the GPU model: the model's ONNX exports use onnxruntime-only operators, and the conversion to OpenVINO depends on exact library versions. With an OpenVINO model at ~/.cache/ccpii/privacy-filter/openvino-int8/model.xml, the sidecar uses the GPU, keeps the CPU model loaded beside it, and gives each GPU call 5 s before the CPU answers instead: with system memory short, the i915 driver has been seen to hang the GPU's compute work. A converted model is at xchacha20-poly1305/privacy-filter-openvino; GPU.md says how to put it in place, what the model must be, how the fallback works, and how it was converted. /ccpii status says which device answers and how often the GPU has fallen back.

Another model or backend

Set the Detection endpoint option (/config › ccpii) to the URL of any service that answers ccpii's small HTTP protocol, and ccpii uses it in place of the bundled filter. BACKENDS.md describes the protocol and ships two ready-made endpoints: any Hugging Face token-classification model, and Microsoft Presidio.

What is left unmasked

Some of what the model reports is no private data: the model takes a company or a product named after a person (Morgan Stanley, Julia) for a person, and a person's name is often public, a git author or a colleague in a review. Two options (/config › ccpii) leave such spans unmasked:

OptionWhat it takesWhat it leaves unmasked
Kinds left unmaskedPlaceholder kinds, comma-separated: PERSON, URL. A detector's label works too (private_person, or another backend's PER).Every span of those kinds
Words left unmaskedWords, comma-separated, in any case: your login name, a product name.Every span that holds one of them, so a whole ls -l owner column, or Charles Darwin for darwin

A word counts in a span unless it runs on into ASCII letters or digits there: ann is not in Joanna, while 张伟 is in 张伟明天, since Chinese has no spaces between words. /ccpii status lists what the options leave unmasked.

Three mistakes of the model are left unmasked without any option: a hex digest taken for an account number (a git commit, a sha256), a file path taken for a URL (when a text is a path and nothing else, as pwd prints), and a file mode taken for a person (drwxr-xr-x in ls -l). A path is one that starts with /, ~, . or a drive letter, or whose first segment has no dot, port or user, unlike a host's (hooks/vault.ts against example.com/a); a URL with a scheme is never taken for a path.

/ccpii

It runs at once, mid-turn too, so it works even while the filter holds everything back.

CommandWhat it does
/ccpii statusWhat the filter is doing, how many placeholders there are
/ccpii installDownload the model
/ccpii restartStart the filter process again
/ccpii pauseLet content reach the model unfiltered, until /ccpii resume or a reload
/ccpii resumeFilter again

How it runs

Each session starts one sidecar process (server/pii_server.py) serving POST /detect on a Unix socket in a private directory under /tmp. The forward pass runs on onnxruntime (CPU); tokenizing, windows, Viterbi decoding and character offsets are opf's own code, pinned to one commit. It takes about 2 s to load, about 2.2 GB of memory, and roughly 1 s per 4 KB of text. Claude Code gives each request 30 s, so a long text goes in overlapping pieces, at most 16,384 UTF-16 code units a request, one request at a time. A sidecar that was ready and then dies is started again by the next request; one that dies before it is ready is not retried, and /ccpii restart starts it. Either way the directory it left behind is removed at the next start, after a reload of the mod too.

The placeholder table of each session is saved to ~/.claude/ccpii/v2/<session-id>.json (directory mode 700), so a resumed session can still restore its placeholders.

Files

  • hooks/register.ts: the hooks and /ccpii.
  • hooks/vault.ts: the placeholder table (masking and restoring).
  • hooks/policy.ts: which detected spans are masked (the options and the model's known mistakes).
  • hooks/filter.ts: runs the sidecar and calls it.
  • server/pii_server.py: the sidecar and the install.
  • server/gpu_worker.py: runs a GPU model (GPU.md), a child of the sidecar.
  • examples/: detection endpoints for other backends (BACKENDS.md).

Limits

  • Permission prompts show the arguments with placeholders, and permission rules match against them.
  • A real value the model writes on its own is not filtered: only what the model reads is.
  • The model can miss or over-detect spans: in testing it took IBAN, companies and products named after people (Morgan Stanley, Julia) and random path names for people, and a user name in ls -l output for a person; it missed some Chinese names, and ran one on into the words after it. What is left unmasked says what is done about it. It is a data-minimisation aid, not an anonymisation guarantee.

License

MIT

Source 5 files
hooks/register.ts 377 lines
1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Register } from 'claude-code'
3
4import type { Vault } from '../types'
5import {
6  configureFilter,
7  describeFilter,
8  describeGpu,
9  detect,
10  filterStatus,
11  installFilter,
12  startFilter,
13} from './filter'
14import { describePolicy, maskedSpans, policyOf } from './policy'
15import type { Policy } from './policy'
16import { CLOSE, KINDS, OPEN, emptyVault, mask, placeholderOf, unmask, unmaskDeep } from './vault'
17
18type Blocks = Args<'session.append'>['message']['content']
19
20// Change the tag when Vault's fields change: it declines what an earlier
21// version stored, which outlives a reload.
22const vaultAtom = atom({ plugin: 'ccpii', key: 'vault' } as const, emptyVault(''), {
23  shape: 'vault-2',
24})
25
26/**
27 * Attachments that are the engine's own prose (a reminder, a listing, a mode
28 * change), never the person's data: masking them garbles what the engine
29 * tells the model. Every other attachment is masked, one whose name is new
30 * included, since a file the person mentioned is an attachment too.
31 */
32const ENGINE_PROSE = new Set([
33  'total_tokens_reminder',
34  'deferred_tools_delta',
35  'todo_reminder',
36  'plan_mode',
37  'plan_mode_reentry',
38  'plan_mode_exit',
39])
40
41const WITHHELD = '[ccpii] Withheld:'
42
43const NOTICE = {
44  id: 'ccpii:notice',
45  scope: 'session',
46  text: `# Privacy placeholders (ccpii)
47
48A privacy filter runs in this session. Before you read them, the user's messages and every tool result have personal or secret data replaced by placeholders of the form ${OPEN}KIND_N${CLOSE}, KIND being what was detected: usually one of ${[
49    ...new Set(Object.values(KINDS)),
50  ].join(', ')}, or another detector's label (for example ${placeholderOf('EMAIL', 0)}, ${placeholderOf('PERSON', 1)}). Only text in these brackets is a placeholder: a bare word that looks like one is just text. The same placeholder always stands for the same real value for the whole session, and different values get different numbers.
51
52- Treat each placeholder as the value it stands for. Write it verbatim wherever you need that value, including inside tool call arguments (file paths, shell commands, file contents, edit strings): the placeholders are replaced with the real values just before a tool runs, and the tool's output is filtered again before you see it.
53- Do not guess, reconstruct or ask for the real values, and do not invent placeholders that you have not seen.
54- In replies to the user, use the placeholders as they are; the user's screen shows the real values in their place.
55- Content that starts with "${WITHHELD}" was held back because the filter could not check it; tell the user the reason it gives instead of guessing what it held.`,
56} as const
57
58const USAGE = `/ccpii status    what the filter is doing
59/ccpii install   download the privacy-filter model (about 1.6 GB)
60/ccpii restart   start the filter process again
61/ccpii pause     let content reach the model unfiltered until /ccpii resume or a reload
62/ccpii resume    filter again`
63
64/** This session's table, as loaded or last changed; reset by a reload, restored from `$.state`. */
65let live: Vault | undefined
66/** Kept in the module alone, so a reload always filters again. */
67let isPaused = false
68let saving: Promise<void> = Promise.resolve()
69let isSaveDirMade = false
70/** The conversation whose notice this module has claimed, and whether it was this module's to add. */
71let noticeClaim: { sessionId: string; isNew: Promise<boolean> } | undefined
72
73export const register: Register = (on, options) => {
74  const policy = policyOf(String(options.unmaskedKinds ?? ''), String(options.unmaskedWords ?? ''))
75
76  on('session.start', async ($, e, next) => {
77    const started = await next(e)
78    await $.command.register({
79      name: 'ccpii',
80      description: 'Privacy filter: status, install, restart, pause, resume',
81      argumentHint: '[status|install|restart|pause|resume]',
82      // Runs at once, mid-turn too: the way out when the filter holds everything back.
83      immediate: true,
84    })
85    // Fire and forget: the status line is a courtesy, and the hook that set
86    // this up may be long gone, its session with it, by the next change.
87    configureFilter(() => void showStatus($).catch(() => {}), String(options.endpoint ?? ''))
88    await startFilter($)
89
90    return started
91  })
92
93  on('command.run', { command: 'ccpii' }, async ($, e) => ({ text: await runCommand($, policy, e.args.trim()) }))
94
95  on('prompt.compose', async ($, e, next) => {
96    const composed = await next(e)
97    // Rendered before the main conversation's first answer, this is the
98    // system prompt the conversation keeps; a subagent's comes later.
99    if (!(await $.session.messages()).some(message => message.role === 'assistant')) {
100      await markNotice($, 'system prompt')
101    }
102
103    return { sections: [...composed.sections, NOTICE] }
104  })
105
106  // A slash command's text is not rewritable here; its expansion reaches the
107  // model as a `command` row, which `session.append` masks.
108  on('prompt.submit', async ($, e, next) => {
109    if (isPaused || e.command !== undefined || e.isMeta) return next(e)
110    const texts = [e.text, ...(e.context ?? [])]
111    let masked: string[]
112    try {
113      masked = await maskTexts($, policy, texts)
114    } catch (error) {
115      $.ui.toast(`Prompt not sent: ${reasonOf(error)}`)
116
117      return { drop: `ccpii: ${reasonOf(error)}` }
118    }
119    const [text = e.text, ...context] = masked
120    if (masked.some((one, i) => one !== texts[i]) && (await claimNotice($))) {
121      context.push(NOTICE.text)
122    }
123
124    return next(context.length === 0 ? { ...e, text } : { ...e, text, context })
125  }).catch(($, e, next) =>
126    next.called ? next(e) : { drop: 'ccpii: the privacy filter could not check the prompt' },
127  )
128
129  // Every row the model reads is masked here: tool results, command
130  // expansions, attachments, hook context, deliveries. A prompt already masked
131  // at `prompt.submit` comes through unchanged, as its placeholders are skipped.
132  on('session.append', async ($, e, next) => {
133    if (isPaused || !isReadByModel(e) || isOwnNote($, e)) return next(e)
134    let content: Blocks
135    let isChanged: boolean
136    try {
137      ;({ content, isChanged } = await maskBlocks($, policy, e.message.content))
138    } catch (error) {
139      content = withheld(e.message.content, reasonOf(error))
140      isChanged = true
141    }
142    const stored = await next({ ...e, message: { ...e.message, content } })
143    // The first placeholders (or withheld text) of a conversation whose system
144    // prompt has no notice: the notice follows them in, once. A subagent's
145    // prompt is rendered when it starts, and has it.
146    if (e.agentId === undefined && isChanged && (await claimNotice($))) {
147      await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: NOTICE.text }] } })
148    }
149
150    return stored
151  }).catch(($, e, next) => {
152    if (next.called || isPaused || !isReadByModel(e)) return next(e)
153
154    return next({ ...e, message: { ...e.message, content: withheld(e.message.content, 'the filter hook failed') } })
155  })
156
157  on('tool.call', async ($, e, next) => next(unmaskDeep(await currentVault($), e))).catch(
158    ($, e, next) =>
159      next.called ? next(e) : { deny: 'ccpii: the placeholders in this call could not be restored.' },
160  )
161
162  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
163    const vault = await read($, vaultAtom)
164
165    return next({ ...e, props: { ...e.props, text: unmask(vault, e.props.text) } })
166  })
167
168  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
169    const vault = await read($, vaultAtom)
170
171    return next({ ...e, props: { ...e.props, text: unmask(vault, e.props.text) } })
172  })
173
174  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
175    const vault = await read($, vaultAtom)
176    const input = unmaskDeep(vault, e.props.input)
177
178    return input === e.props.input ? next(e) : next({ ...e, props: { ...e.props, input } })
179  })
180}
181
182async function runCommand($: EngineInterface, policy: Policy, args: string): Promise<string> {
183  switch (args) {
184    case '':
185    case 'status': {
186      const vault = await currentVault($)
187      const unmasked = describePolicy(policy)
188      const lines = [
189        `filter: ${describeFilter()}`,
190        ...(filterStatus() === 'external' ? [] : [`device: ${describeGpu()}`]),
191        `filtering: ${isPaused ? 'PAUSED, content reaches the model unfiltered' : 'on'}`,
192        ...(unmasked === undefined ? [] : [`left unmasked: ${unmasked}`]),
193        `placeholders: ${Object.keys(vault.byPlaceholder).length}, saved in ${await savePath($, vault.sessionId)}`,
194      ]
195
196      return [...lines, '', USAGE].join('\n')
197    }
198    case 'install':
199      if (filterStatus() === 'external') return `Nothing to install: ${describeFilter()}.`
200      await installFilter($)
201
202      return `${describeFilter()}. The status line shows when it is done.`
203    case 'restart':
204      await startFilter($)
205
206      return `${describeFilter()}.`
207    case 'pause':
208      isPaused = true
209      await showStatus($)
210
211      return 'Filtering paused. Prompts and tool results now reach the model unfiltered, until /ccpii resume or a reload. Placeholders already issued are still restored in tool calls.'
212    case 'resume':
213      isPaused = false
214      await showStatus($)
215
216      return `Filtering on; ${describeFilter()}.`
217    default:
218      return `Unknown argument "${args}".\n\n${USAGE}`
219  }
220}
221
222/**
223 * Whether this conversation's model has been told about the placeholders,
224 * kept in `$.store` by session id so a resumed conversation knows. The
225 * system prompt is rendered once per conversation (--system-prompt-snapshot),
226 * so a mod loaded after the first request adds its section to nothing; the
227 * notice then goes into the conversation itself.
228 */
229async function markNotice($: EngineInterface, where: 'system prompt' | 'conversation'): Promise<void> {
230  await $.store.set(`notice:${await $.session.id()}`, where)
231}
232
233/** True once per conversation that has no notice yet, and records that it now gets one. */
234async function claimNotice($: EngineInterface): Promise<boolean> {
235  const sessionId = await $.session.id()
236  if (noticeClaim?.sessionId === sessionId) return false
237  // Claimed before the next await, so two rows masked at once add one notice.
238  const isNew = (async () => {
239    if ((await $.store.get(`notice:${sessionId}`)) !== undefined) return false
240    await markNotice($, 'conversation')
241
242    return true
243  })()
244  noticeClaim = { sessionId, isNew }
245
246  return isNew
247}
248
249/** The notice this module appended, which passes as it is. */
250function isOwnNote($: EngineInterface, e: Args<'session.append'>): boolean {
251  return e.door === 'note' && e.origin.kind === 'plugin' && 'name' in e.origin && e.origin.name === $.plugin.name
252}
253
254/** Every row the model reads that may carry data: not its own, not a notice, not the engine's own prose. */
255function isReadByModel(e: Args<'session.append'>): boolean {
256  const { type, name } = e.message
257  if (type === 'attachment') return name === undefined || !ENGINE_PROSE.has(name)
258
259  return type !== 'assistant' && type !== 'system'
260}
261
262/** Masks each text against one table, issuing placeholders as it goes. */
263async function maskTexts($: EngineInterface, policy: Policy, texts: readonly string[]): Promise<string[]> {
264  if (texts.every(text => text.trim() === '')) return [...texts]
265  const spans = await detect($, texts)
266  await currentVault($)
267
268  // From here to the end nothing awaits, so no other hook changes `live`
269  // between reading it and writing it back.
270  const before = live ?? emptyVault('')
271  let vault = before
272  const masked = texts.map((text, i) => {
273    const result = mask(vault, text, maskedSpans(policy, text, spans[i] ?? []))
274    vault = result.vault
275
276    return result.text
277  })
278  if (vault !== before) {
279    live = vault
280    saving = saving.then(() => save($)).catch(error => $.ui.log(`ccpii: saving failed: ${error}`, { to: 'debug' }))
281  }
282
283  return masked
284}
285
286/** `content` with its texts masked, and whether any of them changed. */
287async function maskBlocks($: EngineInterface, policy: Policy, content: Blocks): Promise<{ content: Blocks; isChanged: boolean }> {
288  const texts: string[] = []
289  mapTexts(content, text => (texts.push(text), text))
290  if (texts.length === 0) return { content, isChanged: false }
291  const masked = await maskTexts($, policy, texts)
292  if (masked.every((text, i) => text === texts[i])) return { content, isChanged: false }
293  let i = 0
294
295  return { content: mapTexts(content, text => masked[i++] ?? text), isChanged: true }
296}
297
298function withheld(content: Blocks, reason: string): Blocks {
299  return mapTexts(content, () => `${WITHHELD} ${reason}.`).map(block =>
300    block.type === 'tool_result' ? { ...block, is_error: true } : block,
301  )
302}
303
304/**
305 * Rewrites every text the model reads in `content`, in document order: text
306 * blocks and tool results, a tool result's content being a string or blocks.
307 */
308function mapTexts(content: Blocks, fn: (text: string) => string): Blocks {
309  return content.map(block => {
310    if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: fn(block.text) }
311    if (block.type !== 'tool_result') return block
312    if (typeof block.content === 'string') return { ...block, content: fn(block.content) }
313    if (Array.isArray(block.content)) return { ...block, content: mapTexts(block.content as Blocks, fn) }
314
315    return block
316  })
317}
318
319function reasonOf(error: unknown): string {
320  return error instanceof Error ? error.message : String(error)
321}
322
323/**
324 * The current session's table: `live` when it is this session's, else what
325 * `$.state` holds across a reload, else the file a resumed session left.
326 */
327async function currentVault($: EngineInterface): Promise<Vault> {
328  const sessionId = await $.session.id()
329  if (live?.sessionId === sessionId) return live
330
331  const held = await read($, vaultAtom)
332  const restored = held.sessionId === sessionId ? held : await readSaved($, sessionId)
333  // Another hook may have loaded it, and issued placeholders, meanwhile.
334  if (live?.sessionId !== sessionId) live = restored
335
336  return live
337}
338
339async function readSaved($: EngineInterface, sessionId: string): Promise<Vault> {
340  const path = await savePath($, sessionId)
341  if (!(await $.fs.exists(path))) return emptyVault(sessionId)
342
343  return { ...(JSON.parse(await $.fs.read(path)) as Vault), sessionId }
344}
345
346async function save($: EngineInterface): Promise<void> {
347  const vault = live
348  if (vault === undefined) return
349  await update($, vaultAtom, () => vault)
350  const path = await savePath($, vault.sessionId)
351  if (!isSaveDirMade) {
352    // Only the owner may read the real values the table holds.
353    await $.process.run(['mkdir', '-p', '-m', '700', path.slice(0, path.lastIndexOf('/'))])
354    isSaveDirMade = true
355  }
356  await $.fs.write(path, JSON.stringify(vault))
357  await showStatus($)
358}
359
360async function savePath($: EngineInterface, sessionId: string): Promise<string> {
361  const home = await $.env.get('HOME')
362
363  // v2: the bracketed placeholders; a table of the bare ones is not read.
364  return `${home}/.claude/ccpii/v2/${sessionId}.json`
365}
366
367async function showStatus($: EngineInterface): Promise<void> {
368  if (isPaused) {
369    $.ui.status('PAUSED, content reaches the model unfiltered')
370  } else if (filterStatus() === 'ready' || filterStatus() === 'external') {
371    const vault = live ?? (await read($, vaultAtom))
372    $.ui.status(`${Object.keys(vault.byPlaceholder).length} placeholders`)
373  } else {
374    $.ui.status(describeFilter())
375  }
376}
377
hooks/filter.ts 445 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Span } from './vault'
4
5export type FilterStatus =
6  /** Not started by this module yet. */
7  | 'idle'
8  /** Spawned; the socket is not bound yet. */
9  | 'starting'
10  /** The socket is bound and the model loads; requests wait for it. */
11  | 'loading'
12  | 'ready'
13  /** The checkpoint is not installed; nothing is retried until `/ccpii install`. */
14  | 'missing'
15  | 'installing'
16  /** The model failed to load; the process lives on, answering errors. */
17  | 'failed'
18  /** The process is gone. */
19  | 'exited'
20  /** No sidecar: the `endpoint` option names a detection service of the person's own. */
21  | 'external'
22
23const sidecarDirRef = { plugin: 'ccpii', key: 'sidecarDir' } as const
24
25/** How long a request waits for a starting sidecar to bind its socket. */
26const LISTEN_WAIT_MS = 5_000
27
28/**
29 * The privacy-filter sidecar this module runs: a Python process serving
30 * `POST /detect` on a Unix socket in a private directory, or the install
31 * that precedes it. One child at a time; each start or install supersedes
32 * the one before, and a superseded child's output changes nothing.
33 */
34const sidecar = {
35  status: 'idle' as FilterStatus,
36  /** What went wrong, for `missing`, `failed` and `exited`. */
37  error: undefined as string | undefined,
38  onChange: () => {},
39  /** The socket the current child named on its `listening` line. */
40  socketPath: undefined as string | undefined,
41  listening: Promise.resolve(),
42  stopChild: undefined as (() => void) | undefined,
43  generation: 0,
44  /**
45   * Whether the current child got as far as `ready`. One that did and then
46   * died is started again by the next request; one that died before it was
47   * ready would most likely die again, so it waits for /ccpii restart.
48   */
49  wasReady: false,
50  endpoint: undefined as string | undefined,
51  /** Which device answers now, from the sidecar's `device` lines. */
52  device: 'cpu' as 'cpu' | 'gpu',
53  /** Whether the sidecar found a GPU model (its `gpu starting` line). */
54  hasGpuModel: false,
55  /** The GPU failures the CPU took over from, and the last one's reason. */
56  fallbacks: 0,
57  lastFallback: undefined as string | undefined,
58}
59
60/**
61 * `endpoint`, when not empty, is the URL of a detection service that answers
62 * `POST {"texts"}` as the sidecar does (BACKENDS.md); no sidecar is run then.
63 */
64export function configureFilter(onChange: () => void, endpoint: string): void {
65  sidecar.onChange = onChange
66  sidecar.endpoint = endpoint.trim() === '' ? undefined : endpoint.trim()
67}
68
69export function filterStatus(): FilterStatus {
70  return sidecar.status
71}
72
73export async function startFilter($: EngineInterface): Promise<void> {
74  const generation = supersede()
75  if (sidecar.endpoint !== undefined) {
76    report(generation, 'external')
77    return
78  }
79  sidecar.wasReady = false
80  // A child stopped by a signal it cannot catch (a crash, or this module
81  // unloading) leaves its directory behind; `$.state` outlives a reload.
82  sidecar.socketPath = undefined
83  const { value: previousDir } = await $.state.get(sidecarDirRef)
84  if (previousDir !== undefined && isSidecarDir(previousDir)) await $.process.run(['rm', '-rf', previousDir])
85  let markListening = () => {}
86  sidecar.listening = new Promise(resolve => (markListening = resolve))
87  report(generation, 'starting')
88
89  sidecar.device = 'cpu'
90  sidecar.hasGpuModel = false
91  const isCurrent = () => generation === sidecar.generation
92  sidecar.stopChild = spawnScript($, 'pii_server.py', ['--serve'], isCurrent, {
93    onLine: (word, rest) => {
94      // Bound means requests are taken: each waits for the model to load.
95      if (word === 'listening') {
96        sidecar.socketPath = rest
97        // Lost, it costs one empty directory left after the next reload.
98        $.state.set(sidecarDirRef, rest.slice(0, rest.lastIndexOf('/'))).catch(() => {})
99        report(generation, 'loading')
100        markListening()
101      }
102      if (word === 'ready') sidecar.wasReady = true
103      if (word === 'ready' || word === 'missing') report(generation, word)
104      if (word === 'failed') report(generation, 'failed', rest)
105      if (word === 'gpu') sidecar.hasGpuModel = true
106      if (word === 'device') {
107        sidecar.device = rest === 'gpu' ? 'gpu' : 'cpu'
108        sidecar.onChange()
109      }
110      if (word === 'fallback') {
111        sidecar.fallbacks += 1
112        sidecar.lastFallback = rest
113        $.ui.log(`GPU fallback: ${rest}`, { to: 'debug' })
114      }
115    },
116    onExit: reason => {
117      markListening()
118      if (sidecar.status !== 'missing') report(generation, 'exited', reason)
119    },
120  })
121}
122
123export async function installFilter($: EngineInterface): Promise<void> {
124  if (sidecar.status === 'installing' || sidecar.status === 'external') return
125  const generation = supersede()
126  report(generation, 'installing')
127
128  const isCurrent = () => generation === sidecar.generation
129  sidecar.stopChild = spawnScript($, 'pii_server.py', ['--download'], isCurrent, {
130    onLine: (word, rest) => {
131      if (word === 'installed') startFilter($).catch(error => report(generation, 'exited', String(error)))
132      if (word === 'failed') report(generation, 'missing', `the install failed: ${rest}`)
133    },
134    onExit: reason => {
135      if (sidecar.status === 'installing') report(generation, 'missing', `the install stopped: ${reason}`)
136    },
137  })
138}
139
140/** The GPU side in a sentence, for /ccpii status. */
141export function describeGpu(): string {
142  if (!sidecar.hasGpuModel) return 'the CPU answers; GPU.md says how to run the filter on an Intel GPU'
143  const fallbacks =
144    sidecar.fallbacks === 0 ? '' : `; fell back to the CPU ${sidecar.fallbacks === 1 ? 'once' : `${sidecar.fallbacks} times`}, last: ${sidecar.lastFallback}`
145  const now = sidecar.device === 'gpu' ? 'the GPU answers' : 'the CPU answers until the GPU worker is ready'
146
147  return `${now}${fallbacks}`
148}
149
150/**
151 * The spans in each of `texts`, offsets inside its text; throws, saying why
152 * in plain words, when the filter cannot answer.
153 *
154 * The host ends a fetch after 30 s, with no way to ask for longer, so no
155 * request carries more than `REQUEST_UNITS`: a longer text goes in pieces,
156 * the pieces in as many requests as they need, and the requests one at a
157 * time across all callers, since the filter answers one after another and a
158 * request waiting behind another's would spend its own 30 s waiting.
159 */
160export async function detect($: EngineInterface, texts: readonly string[]): Promise<Span[][]> {
161  const spans: Span[][] = texts.map(() => [])
162  for (const request of packRequests(texts.flatMap(cutPieces))) {
163    const answers = await inTurn(() => post($, request.map(piece => piece.body)))
164    request.forEach((piece, i) => {
165      for (const span of answers[i] ?? []) {
166        const start = piece.start + span.start
167        // A span belongs to the piece whose own part it starts in; the
168        // overlap of the next or previous piece sees it too.
169        if (start < piece.ownStart || start >= piece.ownEnd) continue
170        spans[piece.text]?.push({ label: span.label, start, end: piece.start + span.end })
171      }
172    })
173  }
174
175  return spans
176}
177
178/**
179 * The most one request carries, in UTF-16 code units: the bundled filter
180 * answers that in about 4 s on a laptop CPU, well inside the 30 s.
181 */
182const REQUEST_UNITS = 16_384
183
184/**
185 * Text a piece carries past its own part on either side, so a span near a
186 * cut is seen whole and with its context. The model looks 128 tokens either
187 * way; 1,024 units hold that in any script.
188 */
189const OVERLAP_UNITS = 1_024
190
191/**
192 * Part of one text: `body` is `text.slice(start, ...)`, and the spans kept
193 * from it are those starting in `[ownStart, ownEnd)`.
194 */
195type Piece = { text: number; start: number; ownStart: number; ownEnd: number; body: string }
196
197function cutPieces(text: string, index: number): Piece[] {
198  if (text.length <= REQUEST_UNITS) {
199    return text === '' ? [] : [{ text: index, start: 0, ownStart: 0, ownEnd: text.length, body: text }]
200  }
201  const pieces: Piece[] = []
202  for (let ownStart = 0; ownStart < text.length; ) {
203    const ownEnd = cutAfter(text, ownStart, REQUEST_UNITS - 2 * OVERLAP_UNITS)
204    const start = boundary(text, Math.max(0, ownStart - OVERLAP_UNITS))
205    const end = boundary(text, Math.min(text.length, ownEnd + OVERLAP_UNITS))
206    pieces.push({ text: index, start, ownStart, ownEnd, body: text.slice(start, end) })
207    ownStart = ownEnd
208  }
209
210  return pieces
211}
212
213/**
214 * Where a piece's own part starting at `from` ends: after the last line
215 * break within `limit` units when that keeps at least half of them, else at
216 * `limit`, never between the halves of a surrogate pair.
217 */
218function cutAfter(text: string, from: number, limit: number): number {
219  if (text.length - from <= limit) return text.length
220  const lineEnd = text.lastIndexOf('\n', from + limit - 1)
221
222  return lineEnd >= from + limit / 2 ? lineEnd + 1 : boundary(text, from + limit)
223}
224
225/** `at`, or one before it when `at` would split a surrogate pair. */
226function boundary(text: string, at: number): number {
227  const code = text.charCodeAt(at - 1)
228
229  return at > 0 && at < text.length && code >= 0xd800 && code <= 0xdbff ? at - 1 : at
230}
231
232/** Pieces in order, grouped into requests of at most `REQUEST_UNITS`. */
233function packRequests(pieces: Piece[]): Piece[][] {
234  const requests: Piece[][] = []
235  let units = REQUEST_UNITS
236  for (const piece of pieces) {
237    if (units + piece.body.length > REQUEST_UNITS) {
238      requests.push([])
239      units = 0
240    }
241    requests[requests.length - 1]?.push(piece)
242    units += piece.body.length
243  }
244
245  return requests
246}
247
248/** The last request handed to `inTurn`; the next one starts after it settles. */
249let queue: Promise<unknown> = Promise.resolve()
250
251function inTurn<T>(request: () => Promise<T>): Promise<T> {
252  const turn = queue.then(request)
253  queue = turn.catch(() => {})
254
255  return turn
256}
257
258/** One `POST {"texts"}` to the endpoint or the sidecar, its answer checked. */
259async function post($: EngineInterface, texts: readonly string[]): Promise<Span[][]> {
260  if (sidecar.status === 'external' && sidecar.endpoint !== undefined) {
261    const response = await $.http.fetch(sidecar.endpoint, {
262      method: 'POST',
263      headers: { 'Content-Type': 'application/json' },
264      body: JSON.stringify({ texts }),
265    })
266    if (!response.ok) throw new Error(`the detection service answered ${response.status}: ${response.text.slice(0, 200)}`)
267
268    return readSpans(response.text, texts, 'the detection service')
269  }
270  if (sidecar.status === 'exited' && sidecar.wasReady) {
271    await startFilter($)
272  }
273  if (sidecar.status === 'starting') await Promise.race([sidecar.listening, $.clock.sleep(LISTEN_WAIT_MS)])
274  if ((sidecar.status !== 'loading' && sidecar.status !== 'ready') || sidecar.socketPath === undefined) {
275    throw new Error(describeFilter())
276  }
277
278  const response = await $.http.fetch('http://ccpii/detect', {
279    method: 'POST',
280    headers: { 'Content-Type': 'application/json' },
281    body: JSON.stringify({ texts }),
282    socketPath: sidecar.socketPath,
283  })
284  if (!response.ok) throw new Error(`the privacy filter answered ${response.status}: ${response.text}`)
285
286  return readSpans(response.text, texts, 'the privacy filter')
287}
288
289/**
290 * The spans of a `/detect` answer, checked against the texts they are for:
291 * a list per text, each span a label and offsets inside its text. Any other
292 * answer throws, so what it was about is withheld rather than sent unchecked.
293 */
294function readSpans(body: string, texts: readonly string[], who: string): Span[][] {
295  let parsed: unknown
296  try {
297    parsed = JSON.parse(body)
298  } catch {
299    throw new Error(`${who} answered something other than JSON`)
300  }
301  const spans = (parsed as { spans?: unknown } | null)?.spans
302  if (!Array.isArray(spans) || spans.length !== texts.length) {
303    throw new Error(`${who} answered no "spans" list with one entry per text`)
304  }
305
306  return spans.map((list: unknown, i) => {
307    const length = texts[i]?.length ?? 0
308    if (!Array.isArray(list)) throw new Error(`${who} answered spans that are not a list`)
309
310    return list.map((span: unknown) => {
311      const { label, start, end } = (span ?? {}) as Partial<Span>
312      const isValid =
313        typeof label === 'string' &&
314        Number.isInteger(start) &&
315        Number.isInteger(end) &&
316        (start as number) >= 0 &&
317        (start as number) < (end as number) &&
318        (end as number) <= length
319      if (!isValid) throw new Error(`${who} answered a span that is not { label, start, end } inside its text`)
320
321      return { label, start, end } as Span
322    })
323  })
324}
325
326/** The status in a sentence the person can act on. */
327export function describeFilter(): string {
328  switch (sidecar.status) {
329    case 'idle':
330      return 'the privacy filter has not started'
331    case 'starting':
332      return 'the privacy filter is still starting'
333    case 'loading':
334      return 'the privacy-filter model is loading'
335    case 'ready':
336      return `the privacy filter is ready (${sidecar.device === 'gpu' ? 'GPU' : 'CPU'})`
337    case 'missing':
338      return `the privacy-filter model is not installed${sidecar.error ? ` (${sidecar.error})` : ''}; run /ccpii install`
339    case 'installing':
340      return 'the privacy-filter model is being installed; see /ccpii status'
341    case 'failed':
342      return `the privacy-filter model failed to load (${sidecar.error}); run /ccpii restart`
343    case 'external':
344      return `the detection service at ${sidecar.endpoint} is in use`
345    case 'exited':
346      return sidecar.wasReady
347        ? `the privacy filter stopped (${sidecar.error}); the next request starts it again`
348        : `the privacy filter stopped before it was ready (${sidecar.error}); run /ccpii restart`
349  }
350}
351
352/** Ends the current child and returns the generation of the next. */
353function supersede(): number {
354  sidecar.stopChild?.()
355  sidecar.stopChild = undefined
356
357  return ++sidecar.generation
358}
359
360function report(generation: number, status: FilterStatus, error?: string): void {
361  if (generation !== sidecar.generation) return
362  sidecar.status = status
363  sidecar.error = error
364  sidecar.onChange()
365}
366
367/**
368 * Runs `server/<script>` with `args` for as long as the module lives,
369 * handing each stdout line on as its first word and the rest while
370 * `isCurrent()`. Returns what stops it.
371 */
372function spawnScript(
373  $: EngineInterface,
374  script: string,
375  args: readonly string[],
376  isCurrent: () => boolean,
377  handlers: { onLine: (word: string, rest: string) => void; onExit: (reason: string) => void },
378): () => void {
379  const child = $.process.spawn({
380    argv: ['uv', 'run', '--quiet', '--script', `${$.plugin.root}/server/${script}`, ...args],
381  })
382
383  void (async () => {
384    let pending = ''
385    let stderrTail = ''
386    try {
387      for await (const { stream, text } of child) {
388        if (stream === 'stderr') {
389          $.ui.log(text.trimEnd(), { to: 'debug' })
390          stderrTail = (stderrTail + text).slice(-300)
391          continue
392        }
393        pending += text
394        for (let newline = pending.indexOf('\n'); newline >= 0; newline = pending.indexOf('\n')) {
395          const line = pending.slice(0, newline).trim()
396          pending = pending.slice(newline + 1)
397          const space = line.indexOf(' ')
398          if (isCurrent()) handlers.onLine(space < 0 ? line : line.slice(0, space), space < 0 ? '' : line.slice(space + 1))
399        }
400      }
401      const { code, signal } = await child.result
402      const how = exitReason(code, signal)
403      const said = lastLine(stderrTail)
404      if (isCurrent()) handlers.onExit(said === '' ? how : `${how}: ${said}`)
405    } catch (error) {
406      if (isCurrent()) handlers.onExit(error instanceof Error ? error.message : String(error))
407    }
408  })()
409
410  return () => void child.return({ code: null, signal: null })
411}
412
413/**
414 * How a child ended, in words. The child is `uv`, which exits with 128 + N
415 * when the Python process under it dies of signal N.
416 */
417function exitReason(code: number | null, signal: string | null): string {
418  if (signal !== null) return `it was killed by ${signal}`
419  if (code !== null && code > 128) return `it was killed by ${SIGNALS[code - 128] ?? `signal ${code - 128}`}`
420
421  return `it exited with code ${code}`
422}
423
424const SIGNALS: Readonly<Record<number, string>> = {
425  1: 'SIGHUP',
426  2: 'SIGINT',
427  6: 'SIGABRT',
428  9: 'SIGKILL',
429  11: 'SIGSEGV',
430  15: 'SIGTERM',
431}
432
433/** A directory the sidecar makes: `/tmp/ccpii.<random>`, nothing above or beside it. */
434function isSidecarDir(path: string): boolean {
435  const name = path.slice('/tmp/'.length)
436
437  return path.startsWith('/tmp/ccpii.') && name.length > 'ccpii.'.length && !name.includes('/') && !name.includes('..')
438}
439
440function lastLine(text: string): string {
441  const lines = text.trim().split('\n')
442
443  return (lines[lines.length - 1] ?? '').trim()
444}
445
hooks/policy.ts 132 lines
1import { kindOf } from './vault'
2import type { Span } from './vault'
3
4/**
5 * Which detected spans are masked: all of them but what the person left
6 * unmasked in the options and what the model is known to mistake.
7 */
8export type Policy = {
9  /** Placeholder kinds never masked (`PERSON`, `URL`). */
10  unmaskedKinds: ReadonlySet<string>
11  /** Words never masked, lower-cased. */
12  unmaskedWords: readonly string[]
13}
14
15/**
16 * The policy of the two options, each a comma-separated list. A kind is
17 * written as its placeholder kind (`PERSON`) or as the detector's label
18 * (`private_person`), in any case.
19 */
20export function policyOf(kinds: string, words: string): Policy {
21  return {
22    unmaskedKinds: new Set(listOf(kinds).map(kindOf)),
23    unmaskedWords: listOf(words).map(word => word.toLowerCase()),
24  }
25}
26
27/** What the policy leaves unmasked, for `/ccpii status`; undefined when it is nothing but the model's mistakes. */
28export function describePolicy(policy: Policy): string | undefined {
29  const parts = [
30    ...(policy.unmaskedKinds.size === 0 ? [] : [`kinds ${[...policy.unmaskedKinds].join(', ')}`]),
31    ...(policy.unmaskedWords.length === 0 ? [] : [`words ${policy.unmaskedWords.join(', ')}`]),
32  ]
33
34  return parts.length === 0 ? undefined : parts.join('; ')
35}
36
37/** The spans of `text` the policy masks. */
38export function maskedSpans(policy: Policy, text: string, spans: readonly Span[]): Span[] {
39  return spans.filter(span => {
40    const value = text.slice(span.start, span.end)
41
42    return (
43      !policy.unmaskedKinds.has(kindOf(span.label)) &&
44      !holdsUnmaskedWord(policy, value) &&
45      !isHashTakenForAccount(span.label, value) &&
46      !isPathTakenForUrl(span.label, value) &&
47      !isModeTakenForPerson(span.label, value)
48    )
49  })
50}
51
52function listOf(option: string): string[] {
53  return option
54    .split(',')
55    .map(item => item.trim())
56    .filter(item => item !== '')
57}
58
59/**
60 * Whether `text` holds one of the unmasked words, in any case. Where an end
61 * of the word and the character beside it are both ASCII letters or digits,
62 * the word runs on and does not count (`ann` is not in `Joanna`); Chinese
63 * has no spaces between words, so `张伟` counts in `张伟明天`.
64 */
65function holdsUnmaskedWord(policy: Policy, text: string): boolean {
66  const lower = text.toLowerCase()
67
68  return policy.unmaskedWords.some(word => {
69    for (let at = lower.indexOf(word); at >= 0; at = lower.indexOf(word, at + 1)) {
70      const before = lower[at - 1]
71      const after = lower[at + word.length]
72      const runsOn = (beside: string | undefined, edge: string | undefined) =>
73        beside !== undefined && edge !== undefined && isAsciiWordChar(beside) && isAsciiWordChar(edge)
74      if (!runsOn(before, word[0]) && !runsOn(after, word[word.length - 1])) return true
75    }
76
77    return false
78  })
79}
80
81function isAsciiWordChar(char: string): boolean {
82  const code = char.charCodeAt(0) | 0x20 // lower-case a letter, leave a digit
83  return (code >= 48 && code <= 57) || (code >= 97 && code <= 122)
84}
85
86/**
87 * Whether the model took a hex digest (a git commit, a sha256) for an account
88 * number, as it does: account numbers are digits and letters beyond `f`.
89 * A hex `secret` is still masked; a key is often written in hex.
90 */
91function isHashTakenForAccount(label: string, text: string): boolean {
92  if (label !== 'account_number' || text.length < 7) return false
93  let hasDigit = false
94  let hasHexLetter = false
95  for (let i = 0; i < text.length; i++) {
96    const code = text.charCodeAt(i) | 0x20 // lower-case a letter, leave a digit
97    if (code >= 48 && code <= 57) hasDigit = true
98    else if (code >= 97 && code <= 102) hasHexLetter = true
99    else return false
100  }
101
102  return hasDigit && hasHexLetter
103}
104
105/**
106 * Whether the model took a file path for a URL, as it does when a text is a
107 * path and nothing else (`pwd`, a listing of one file). A path starts at the
108 * root, the home or the current directory, or a drive letter; or else its
109 * first segment, unlike a host, has no dot, port or user (`hooks/vault.ts`
110 * against `example.com/a`). A URL with a scheme is never a path.
111 */
112function isPathTakenForUrl(label: string, text: string): boolean {
113  if (label !== 'private_url' || text.includes('://') || text.startsWith('//')) return false
114  if (text.startsWith('/') || text.startsWith('~') || text.startsWith('.')) return true
115  if (text[1] === ':' && (text[2] === '\\' || text[2] === '/')) return true
116  const slash = text.indexOf('/')
117  if (slash < 0) return false
118  const first = text.slice(0, slash)
119
120  return !first.includes('.') && !first.includes(':') && !first.includes('@')
121}
122
123/** Whether the model took a file mode in `ls -l` output (`drwxr-xr-x`) for a person, as it does. */
124function isModeTakenForPerson(label: string, text: string): boolean {
125  if (label !== 'private_person' || text.length !== 10 || !'-dlcbps'.includes(text[0] ?? '')) return false
126  for (let i = 1; i < text.length; i++) {
127    if (!'rwxsStT-'.includes(text[i] ?? '')) return false
128  }
129
130  return true
131}
132
hooks/vault.ts 165 lines
1import type { Vault } from '../types'
2
3/** A detected span, offsets in UTF-16 code units as JavaScript strings index. */
4export type Span = { label: string; start: number; end: number }
5
6/** openai/privacy-filter's labels and the placeholder kind each one becomes. */
7export const KINDS: Readonly<Record<string, string>> = {
8  account_number: 'ACCOUNT',
9  private_address: 'ADDRESS',
10  private_date: 'DATE',
11  private_email: 'EMAIL',
12  private_person: 'PERSON',
13  private_phone: 'PHONE',
14  private_url: 'URL',
15  secret: 'SECRET',
16}
17
18const UNKNOWN_KIND = 'PII'
19
20/**
21 * The placeholder kind for a label: privacy-filter's own by KINDS, any other
22 * backend's label as it is, upper-cased, each character outside A-Z, 0-9
23 * and `_` made `_` (Presidio's `EMAIL_ADDRESS` stays `EMAIL_ADDRESS`).
24 */
25export function kindOf(label: string): string {
26  const known = KINDS[label]
27  if (known !== undefined) return known
28  let kind = ''
29  for (const char of label.toUpperCase()) {
30    const code = char.charCodeAt(0)
31    const isKept = (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95
32    kind += isKept && char.length === 1 ? char : '_'
33  }
34
35  return kind === '' ? UNKNOWN_KIND : kind
36}
37
38/**
39 * What a placeholder is wrapped in: `⟦EMAIL_0⟧`. Text seldom holds these
40 * brackets, so a bare `EMAIL_0` in a file stays what it is, and only the
41 * placeholders this table issued are ever restored.
42 */
43export const OPEN = '⟦'
44export const CLOSE = '⟧'
45
46export const placeholderOf = (kind: string, number: number): string => `${OPEN}${kind}_${number}${CLOSE}`
47
48export const emptyVault = (sessionId: string): Vault => ({
49  sessionId,
50  counters: {},
51  byValue: {},
52  byPlaceholder: {},
53})
54
55/**
56 * Replaces each span of `text` with its placeholder, issuing new ones as
57 * needed. Returns the vault unchanged (the same object) when nothing new was
58 * issued, so a caller can tell whether it has anything to save.
59 */
60export function mask(
61  vault: Vault,
62  text: string,
63  spans: readonly Span[],
64): { vault: Vault; text: string } {
65  const kept = withoutOverlaps(spans).filter(span => !holdsPlaceholder(vault, text.slice(span.start, span.end)))
66  if (kept.length === 0) return { vault, text }
67
68  let next = vault
69  const issue = (kind: string, value: string): string => {
70    const known = next.byValue[value]
71    if (known !== undefined) return known
72    if (next === vault) {
73      next = {
74        sessionId: vault.sessionId,
75        counters: { ...vault.counters },
76        byValue: { ...vault.byValue },
77        byPlaceholder: { ...vault.byPlaceholder },
78      }
79    }
80    const number = next.counters[kind] ?? 0
81    const placeholder = placeholderOf(kind, number)
82    next.counters[kind] = number + 1
83    next.byValue[value] = placeholder
84    next.byPlaceholder[placeholder] = value
85    return placeholder
86  }
87
88  let masked = ''
89  let cursor = 0
90  for (const span of kept) {
91    masked += text.slice(cursor, span.start)
92    masked += issue(kindOf(span.label), text.slice(span.start, span.end))
93    cursor = span.end
94  }
95  masked += text.slice(cursor)
96
97  return { vault: next, text: masked }
98}
99
100/** Puts the real value back for every placeholder of the vault in `text`. */
101export function unmask(vault: Vault, text: string): string {
102  let restored = ''
103  let cursor = 0
104  for (const found of placeholders(vault, text)) {
105    restored += text.slice(cursor, found.start) + found.value
106    cursor = found.end
107  }
108
109  return cursor === 0 ? text : restored + text.slice(cursor)
110}
111
112/** `unmask` over every string inside a JSON-like value; unchanged parts are kept by identity. */
113export function unmaskDeep<T>(vault: Vault, value: T): T {
114  if (typeof value === 'string') return unmask(vault, value) as T
115  if (Array.isArray(value)) {
116    const items = value.map(item => unmaskDeep(vault, item))
117
118    return (items.some((item, i) => item !== value[i]) ? items : value) as T
119  }
120  if (value !== null && typeof value === 'object') {
121    let changed = false
122    const entries = Object.entries(value).map(([key, item]) => {
123      const restored = unmaskDeep(vault, item)
124      changed ||= restored !== item
125
126      return [key, restored] as const
127    })
128
129    return (changed ? Object.fromEntries(entries) : value) as T
130  }
131
132  return value
133}
134
135/** Spans in order, each one that overlaps an earlier one dropped. */
136function withoutOverlaps(spans: readonly Span[]): Span[] {
137  const sorted = [...spans]
138    .filter(span => span.end > span.start)
139    .sort((a, b) => a.start - b.start || b.end - a.end)
140  const kept: Span[] = []
141  for (const span of sorted) {
142    const last = kept[kept.length - 1]
143    if (last === undefined || span.start >= last.end) kept.push(span)
144  }
145
146  return kept
147}
148
149/** Whether `text` already holds one of the vault's placeholders: masking it again would hide the placeholder itself. */
150function holdsPlaceholder(vault: Vault, text: string): boolean {
151  return !placeholders(vault, text).next().done
152}
153
154/** Each of the vault's placeholders in `text`, in order, with the value it stands for. */
155function* placeholders(vault: Vault, text: string): Generator<{ start: number; end: number; value: string }> {
156  for (let start = text.indexOf(OPEN); start >= 0; start = text.indexOf(OPEN, start + 1)) {
157    const close = text.indexOf(CLOSE, start + 1)
158    if (close < 0) return
159    const value = vault.byPlaceholder[text.slice(start, close + 1)]
160    if (value === undefined) continue
161    yield { start, end: close + 1, value }
162    start = close
163  }
164}
165
types/index.d.ts 25 lines
1/**
2 * The placeholder table of one session: which real value each placeholder
3 * stands for, and the next number per kind.
4 */
5export type Vault = {
6  /** The session the table belongs to; '' before any session has loaded it. */
7  sessionId: string
8  /** The next free number per placeholder kind (`EMAIL` → 2 after EMAIL_1). */
9  counters: Record<string, number>
10  /** Real value → placeholder. */
11  byValue: Record<string, string>
12  /** Placeholder → real value. */
13  byPlaceholder: Record<string, string>
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    ccpii: {
19      vault: Shaped<Vault>
20      /** The directory of the sidecar this session runs, so a reloaded module can remove a dead one's. */
21      sidecarDir: string
22    }
23  }
24}
25