SLOPSHOPPER

secrets-veil

Masks secret values in tool results before the model reads them: named vendor variables, value-shape patterns, and high-entropy tokens. No reveal path.

newguardtoaststatustimer
★ 291v0.0.6MITupdated 2026-10-06yonatangross/orchestkit/mods/secrets-veil
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secrets-veil
› fix the failing auth test and add an audit log call ╭────────────────────────╮ │ secrets-veil │ ● secrets-veil: masked 1 value in Bash, 35 bytes in, 24 bytes out; result 291 bytes before,│ masked 1 value in Bash │ ⏺ Read(src/auth.ts) ╰────────────────────────╯ ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secrets-veil: 1 masked this session
README

secrets-veil

A Claude Code mod that masks secret values in tool results before the model reads them. It arms once per session and covers every tool result afterwards. There is no reveal path: once a value is covered, it stays covered for the session.

How it works

Three layers, checked on every tool result:

  1. Named masking. At session.start the mod reads 21 widely known vendor variable names with one literal $.env.get call site per name:

ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GOOGLE_API_KEY MISTRAL_API_KEY GROQ_API_KEY OPENROUTER_API_KEY HF_TOKEN GITHUB_TOKEN GH_TOKEN GITLAB_TOKEN NPM_TOKEN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AZURE_OPENAI_API_KEY STRIPE_SECRET_KEY SLACK_BOT_TOKEN VERCEL_TOKEN CLOUDFLARE_API_TOKEN OP_SERVICE_ACCOUNT_TOKEN

A resolved value is kept only if it is at least 8 characters. Every occurrence of a kept value is masked in tool output.

  1. Value shapes. Vendor prefixes and markers: sk-ant-, ops_, ghp_, github_pat_, xoxb-, AKIA, Bearer (20 or more following characters), and PEM private key blocks.
  1. High entropy. A token of 20 or more characters from [A-Za-z0-9+/=_-] whose Shannon entropy is at or above 4.3 bits per character is masked. The threshold is chosen so that counting alone, not luck, protects git output: any hex string (a 40-char git sha, a 64-char hash) has entropy at most 4.0, a UUID at most 4.09, both below the bar always. Uniform random tokens over the same alphabet measured a median of 4.60 bits/char (200k samples, 2026-09-16). Ordinary words, paths and identifiers measured 2.9 to 4.0. Masked output never reveals length beyond an 8-bullet floor.

If zero named values resolve at session.start, the mod emits one $.ui.notice saying the veil is running on value patterns and entropy only. Silence is not allowed: an operator must know the named layer is empty.

It fails closed. A tool result is never returned unmasked. If a tool.call arrives before session.start has armed the veil (a mod enabled or hot-reloaded mid-session; on CC 2.1.282 a reload re-dispatches session.start about a second after "reloaded"), the mod arms itself on the spot with the same named reads. If those reads fail it falls back to value shapes and entropy alone, and if a result cannot be masked at all it is withheld with { deny } rather than shown. The same holds for a result, or a single value in it, that is larger than a fixed size cap, a value the veil cannot rebuild faithfully (a Buffer, a Map), and any failure the runtime sees in the hook: each is withheld, none is passed through.

Seeing it work

When a tool result had at least one value covered, the mod says so where you can see it (Claude Code shows each line with the plugin name in front):

  • a toast: masked 1 value in Bash;
  • a status line under the prompt: 3 masked this session;
  • the byte counts in the debug log only, via $.ui.log(text, { to: "debug" }): 40 bytes in, 24 bytes out (UTF-8 bytes of the covered values, then of the bullets that replaced them; a bullet is 3 bytes, at most 8 per value), plus the whole result's byte size before and after masking. Without to: "debug" a $.ui.log line lands in the transcript, and a per-secret byte length is a hint about the secret, so it stays out of anything a human or the transcript sees.

These lines carry counts only, never a value. Nothing is shown when nothing was masked. A refused toast or status never unmasks a result. Every awaited UI call has a deadline through $.clock.after (a mod has no ambient timers): 3 s for engine calls, 120 s for the copy question, so a stuck call never holds the masked result.

Copy to your clipboard (opt-in)

Opt in with SECRETS_VEIL_OFFER_COPY=1, and only when **no other installed mod hooks ui.copy, ui.* or ***. Claude Code dispatches ui.copy as a hookable event (wildcard hooks included) before the clipboard write, so any other installed mod on that event receives the raw value and could log it or send it on. This mod never passes the value to Claude; whether anything else sees it depends on the other mods you run.

With the flag set, a masked result also opens the Claude Code question dialog ($.ui.ask): "Copy the masked value to your clipboard? This mod sends it to the clipboard only, never to Claude; another installed mod that hooks clipboard events could still receive it." The answers are Copy to clipboard and Keep hidden. Copy to clipboard hands the first covered value to $.ui.copy (OSC 52 in the terminal) and nothing else: the tool result the model reads stays masked, and the question, the toast, the status line and this mod's log carry counts only. A missing dialog, a refused or timed-out copy, a question nobody answers within 120 s, or Keep hidden all leave the value covered. The option is off by default because a dialog on every masked result would be noise.

What a copy leaves behind, outside this mod:

  • Claude Code's own debug log records the copied value's length (for example $.ui.copy (secrets-veil): 40 chars, ... copied), never the value.
  • The clipboard keeps the value until something replaces it, and clipboard managers, pbcopy/pbpaste history tools and terminal multiplexer buffers (tmux, screen) may keep their own copies. Clear them if that matters.

Measured on CC 2.1.282 (2026-09-25): the model read 8 bullets, the debug log said $.ui.copy (secrets-veil): 40 chars, path native, OSC 52 written; copied, and the clipboard held 40 bytes.

What this mod deliberately does not do

  • No reveal to the model. There is no hover reveal, no /veil command, no ui.render, no ui.press, no command.register. A reveal path re-arms the secret one interaction away from the model and the transcript. The opt-in copy above goes to the human's clipboard only.
  • No network, no process, no storage. Negative pins: process.run, http.fetch and store.* are absent from the module. Its $.ui calls are notice, toast, status and log, each carrying counts, never a value, plus ask and copy when the copy offer is opted in; only copy ever receives a value. $.clock.after arms the UI deadlines.
  • No reuse of the unmasked run. Core 2.1.282 reuses a run's own messages when a tool.call answer names it by ref and its result is undefined or deep-equal. A masked answer always carries a changed result; one without a result has its ref dropped.
  • No names beyond the 21. The mod reads only the names listed above.

Why your own variable names are not listed

This is a public repository. A deployment's own variable names are sensitive even without values: they disclose infrastructure and client relationships. They cannot be listed in this public mod, and this mod does not read any config file to collect them.

A team that wants its own names masked should keep a private fork of this mod, in a private repository, that adds its own literal $.env.get call sites in hooks/register.ts. The validator records environment reads statically, so each name must appear as a string literal at its own call site. Do not commit that fork's name list anywhere public.

Layout

  • hooks/register.ts - session arming and result masking (the only I/O)
  • src/mask.ts - pure masking logic: table build, patterns, entropy
  • src/types.ts - shared types, no reveal surface
  • tests/register.test.ts - drives the shipped hook module
  • tests/mask.test.ts - pure unit tests, including the 5 ms / 1 MB budget

Verifying locally

cd mods/secrets-veil
npm ci
npx tsc --noEmit
claude plugin validate .
npx vitest run

Tests use synthetic values built in the test files and non-vendor names prefixed VEIL_TEST_. No real secret value or name appears in this repository.

Source 3 files
hooks/register.ts 445 lines
1/**
2 * Hook registration for secrets-veil (literal-only design).
3 *
4 * A secret that appears in a tool result is masked before the model reads it.
5 *
6 * Events:
7 * - session.start: read the 21 vendor variable names, one literal $.env.get
8 *   call site per name, and arm the mask table (named values + shape
9 *   patterns + high-entropy tokens). If zero named values resolve, emit one
10 *   $.ui.notice: the veil runs on value patterns and entropy only.
11 * - tool.call: run the tool via next(), then deep-mask every string in the
12 *   result (plain text, result.stdout, and any nested field). When at least
13 *   one value was masked, say so where the human can see it: one $.ui.toast
14 *   and one $.ui.status line with the count only. The UTF-8 byte counts
15 *   (bytes of the covered values in, bytes of the bullets out) go to the
16 *   debug log only ($.ui.log with { to: "debug" }), because a per-secret
17 *   byte length is itself a hint about the secret. Every awaited UI call has
18 *   a timeout, so a stuck dialog or engine call never holds the result.
19 *
20 * There is no reveal path: no ui.render, no ui.press, no command.register,
21 * no hover. Once covered, a value stays covered for the session.
22 * Negative pins: no process.run, no http.fetch, no store.*.
23 *
24 * The 21 names below are the widely known vendor variable names and are the
25 * only names this mod reads. A deployment's own variable names cannot be
26 * listed in a public mod; see README.md. The validator records env reads
27 * statically, so every read must be a string literal at its call site
28 * (measured: a variable argument is refused, probe B, rc=1, 2026-09-16).
29 */
30
31import { buildTable, mask, DEFAULT_PATTERNS } from "../src/mask.js";
32import type { DollarAPI, MaskTable, ToolCallEvent, ToolCallResult } from "../src/types.js";
33
34/** A named value shorter than this is not worth a table entry. */
35const MIN_NAMED_VALUE_LENGTH = 8;
36
37/** Synthetic id for the session.start notice (there is no toolUseId yet). */
38const NOTICE_ID = "secrets-veil";
39
40/** Module-scope state (per session): the armed mask table. */
41let table: MaskTable | null = null;
42
43/** Module-scope state (per session): values masked since session.start. */
44let maskedThisSession = 0;
45
46/** Module-scope state (per session): SECRETS_VEIL_OFFER_COPY=1 turns the copy offer on. */
47let offerCopy = false;
48
49/** The two answers the copy offer gives. */
50export const COPY = "Copy to clipboard";
51export const KEEP = "Keep hidden";
52
53/**
54 * The copy offer's question. It names the count and the tool, never a value:
55 * the question is drawn in the terminal and may be logged.
56 */
57export function copyQuestion(values: number, tool: string): string {
58  const which = values === 1 ? "the masked value" : `the first of ${values} masked values`;
59  return `secrets-veil covered a secret in ${tool}. Copy ${which} to your clipboard? This mod sends it to the clipboard only, never to Claude; another installed mod that hooks clipboard events could still receive it.`;
60}
61
62/** What one tool result's masking did, in counts and UTF-8 bytes. */
63export interface MaskStats {
64  /** Number of values covered. */
65  values: number;
66  /** UTF-8 bytes of the covered values. */
67  bytesIn: number;
68  /** UTF-8 bytes of the bullets that replaced them. */
69  bytesOut: number;
70}
71
72const encoder = new TextEncoder();
73
74/** UTF-8 byte length of a string. */
75export function utf8Bytes(text: string): number {
76  return encoder.encode(text).length;
77}
78
79/**
80 * Deep-mask every string in a tool result. Strings are masked in place in a
81 * fresh structure; numbers, booleans and nulls pass through untouched.
82 * Covers plain text results (result), structured results (result.stdout,
83 * result.stderr) and any nested field. Adds what it covered to stats.
84 */
85/** Most characters of string content one result may carry before it is withheld. */
86export const MAX_MASK_CHARS = 8_000_000;
87
88/**
89 * Longest single string handed to mask(). mask() is one synchronous call,
90 * so the time cap below cannot interrupt it; a string over this length is
91 * withheld before the call instead.
92 */
93export const MAX_STRING_CHARS = 1_000_000;
94
95/** Most milliseconds masking one result may take (well inside the hook's own budget). */
96export const MAX_MASK_MS = 4_000;
97
98/** Thrown when a result is too large, too slow, or of a shape the veil will not rebuild. */
99export class VeilRefusal extends Error {}
100
101/** Work budget shared by one maskDeep walk. */
102interface MaskBudget {
103  chars: number;
104  deadline: number;
105}
106
107/** A plain object or array: the only containers maskDeep rebuilds faithfully. */
108function isPlainContainer(value: object): boolean {
109  if (Array.isArray(value)) return true;
110  const proto = Object.getPrototypeOf(value);
111  return proto === Object.prototype || proto === null;
112}
113
114function maskDeep(value: unknown, activeTable: MaskTable, covered: Set<string>, budget: MaskBudget): unknown {
115  if (Date.now() > budget.deadline) {
116    throw new VeilRefusal("masking ran past its time cap");
117  }
118  if (typeof value === "string") {
119    if (value.length > MAX_STRING_CHARS) {
120      throw new VeilRefusal("a single value is longer than the masking cap");
121    }
122    budget.chars += value.length;
123    if (budget.chars > MAX_MASK_CHARS) {
124      throw new VeilRefusal("result is larger than the masking cap");
125    }
126    const masked = mask(value, activeTable);
127    for (const span of masked.spans) {
128      covered.add(value.slice(span.start, span.end));
129    }
130    return masked.text;
131  }
132  if (value !== null && typeof value === "object") {
133    // A Buffer, Map, Set or class instance would come back as a plain object
134    // the host may reject for shape, and a rejected answer leaves the
135    // original standing. Refuse it instead of rebuilding it wrong.
136    if (!isPlainContainer(value)) {
137      throw new VeilRefusal("result holds a value the veil cannot rebuild");
138    }
139    if (Array.isArray(value)) {
140      return value.map((item) => maskDeep(item, activeTable, covered, budget));
141    }
142    const out: Record<string, unknown> = {};
143    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
144      out[key] = maskDeep(val, activeTable, covered, budget);
145    }
146    return out;
147  }
148  return value;
149}
150
151/**
152 * Count the distinct values covered in one result. A tool result can carry
153 * the same output twice (a Bash result has it in stdout and in text), so a
154 * value is counted once however many fields it appeared in.
155 */
156export function statsOf(covered: Set<string>): MaskStats {
157  const stats: MaskStats = { values: 0, bytesIn: 0, bytesOut: 0 };
158  for (const value of covered) {
159    stats.values += 1;
160    stats.bytesIn += utf8Bytes(value);
161    stats.bytesOut += utf8Bytes("•".repeat(Math.min(8, value.length)));
162  }
163  return stats;
164}
165
166/**
167 * The toast line for one masked tool result (Claude Code prefixes the plugin
168 * name). Counts only: a byte length would leak the length of a secret.
169 */
170export function toastText(stats: MaskStats, tool: string): string {
171  const noun = stats.values === 1 ? "value" : "values";
172  return `masked ${stats.values} ${noun} in ${tool}`;
173}
174
175/** The debug-only line: the byte counts that prove the masking. */
176export function debugText(stats: MaskStats, tool: string, before: number, after: number): string {
177  return `${toastText(stats, tool)}, ${stats.bytesIn} bytes in, ${stats.bytesOut} bytes out; result ${before} bytes before, ${after} bytes after`;
178}
179
180/** A human answers the copy question; every other UI call is a quick engine call. */
181export const ASK_TIMEOUT_MS = 120_000;
182export const UI_TIMEOUT_MS = 3_000;
183
184/** What withTimeout resolves to when the call did not settle in time. */
185export const TIMED_OUT = Symbol("secrets-veil.timed-out");
186
187/**
188 * Run one UI call with a deadline from $.clock.after (a mod has no ambient
189 * timers). A synchronous throw becomes a rejection. If the clock itself is
190 * unavailable the call is awaited without a deadline.
191 */
192export async function withTimeout($: DollarAPI, call: () => unknown, ms: number): Promise<unknown> {
193  const pending = Promise.resolve().then(call);
194  let expire: (value: typeof TIMED_OUT) => void = () => undefined;
195  const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
196    expire = resolve;
197  });
198  let timer: { cancel?: () => void } | undefined;
199  try {
200    timer = $.clock.after(ms, () => expire(TIMED_OUT));
201  } catch {
202    return pending;
203  }
204  try {
205    return await Promise.race([pending, timeout]);
206  } finally {
207    timer?.cancel?.();
208  }
209}
210
211/**
212 * Keep the engine from reusing the unmasked run. Core 2.1.282 compares a
213 * tool.call answer with the run it names by `ref` and reuses that run's own
214 * messages when `result` is undefined or deep-equal
215 * (Qyn=(e,n)=>e.deny===void 0&&(e.result===void 0||e.result===n.result||Ln(e.result)===Ln(n.result))).
216 * A masked answer with a `result` differs, so keeping `ref` is safe and keeps
217 * the run's metadata. A masked answer WITHOUT `result` would be reused
218 * unmasked, so its `ref` is dropped.
219 */
220export function answerFor(masked: unknown, changed: boolean): unknown {
221  if (!changed || masked === null || typeof masked !== "object") return masked;
222  const answer = masked as Record<string, unknown>;
223  if ("ref" in answer && answer.result === undefined) {
224    const { ref: _unmaskedRun, ...rest } = answer;
225    return rest;
226  }
227  return masked;
228}
229
230/**
231 * Offer the human a copy of the first covered value. The value goes to
232 * $.ui.copy and nowhere else: not the tool result, not a toast, not the log.
233 * Any refusal (no dialog, no clipboard) leaves the value covered.
234 */
235async function offerToCopy($: DollarAPI, covered: Set<string>, tool: string): Promise<void> {
236  const [first] = covered;
237  // No presence check: the validator allows $ members only as calls; a missing
238  // ask or copy throws and the catch leaves the value covered.
239  if (first === undefined) return;
240  let answer: unknown = KEEP;
241  try {
242    // A timed-out question counts as Keep hidden.
243    answer = await withTimeout($, () => $.ui.ask(copyQuestion(covered.size, tool), [COPY, KEEP]), ASK_TIMEOUT_MS);
244  } catch {
245    return;
246  }
247  if (answer !== COPY) return;
248  try {
249    const copied = (await withTimeout($, () => $.ui.copy({ text: first }), UI_TIMEOUT_MS)) as
250      | { isCopied: boolean; reason?: string }
251      | typeof TIMED_OUT;
252    const done = copied !== TIMED_OUT && copied.isCopied;
253    const reason = copied === TIMED_OUT ? "timed out" : (copied.reason ?? "no clipboard");
254    // No length in the toast: the length of a secret is a hint about it.
255    await withTimeout(
256      $,
257      () =>
258        $.ui.toast(
259          done
260            ? "copied to your clipboard; Claude still sees dots"
261            : `nothing copied (${reason}); the value stays covered`
262        ),
263      UI_TIMEOUT_MS
264    );
265  } catch {
266    // A refused copy leaves the value covered; masking is already done.
267  }
268}
269
270/**
271 * Read the 21 vendor names, one literal $.env.get call site per name (the
272 * validator records env reads statically). Keep a value only when it is at
273 * least MIN_NAMED_VALUE_LENGTH characters; a rejected read counts as absent.
274 */
275async function readNamed($: DollarAPI): Promise<Record<string, string>> {
276  const named: Record<string, string> = {};
277
278  // One literal call site per vendor name. Keep a value only when it is at
279  // least MIN_NAMED_VALUE_LENGTH characters; a rejected read counts as
280  // absent, never as a failure of the session.
281  const anthropicApiKey = await $.env.get("ANTHROPIC_API_KEY").catch(() => undefined);
282  if (anthropicApiKey && anthropicApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.ANTHROPIC_API_KEY = anthropicApiKey;
283  const openaiApiKey = await $.env.get("OPENAI_API_KEY").catch(() => undefined);
284  if (openaiApiKey && openaiApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.OPENAI_API_KEY = openaiApiKey;
285  const geminiApiKey = await $.env.get("GEMINI_API_KEY").catch(() => undefined);
286  if (geminiApiKey && geminiApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.GEMINI_API_KEY = geminiApiKey;
287  const googleApiKey = await $.env.get("GOOGLE_API_KEY").catch(() => undefined);
288  if (googleApiKey && googleApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.GOOGLE_API_KEY = googleApiKey;
289  const mistralApiKey = await $.env.get("MISTRAL_API_KEY").catch(() => undefined);
290  if (mistralApiKey && mistralApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.MISTRAL_API_KEY = mistralApiKey;
291  const groqApiKey = await $.env.get("GROQ_API_KEY").catch(() => undefined);
292  if (groqApiKey && groqApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.GROQ_API_KEY = groqApiKey;
293  const openrouterApiKey = await $.env.get("OPENROUTER_API_KEY").catch(() => undefined);
294  if (openrouterApiKey && openrouterApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.OPENROUTER_API_KEY = openrouterApiKey;
295  const hfToken = await $.env.get("HF_TOKEN").catch(() => undefined);
296  if (hfToken && hfToken.length >= MIN_NAMED_VALUE_LENGTH) named.HF_TOKEN = hfToken;
297  const githubToken = await $.env.get("GITHUB_TOKEN").catch(() => undefined);
298  if (githubToken && githubToken.length >= MIN_NAMED_VALUE_LENGTH) named.GITHUB_TOKEN = githubToken;
299  const ghToken = await $.env.get("GH_TOKEN").catch(() => undefined);
300  if (ghToken && ghToken.length >= MIN_NAMED_VALUE_LENGTH) named.GH_TOKEN = ghToken;
301  const gitlabToken = await $.env.get("GITLAB_TOKEN").catch(() => undefined);
302  if (gitlabToken && gitlabToken.length >= MIN_NAMED_VALUE_LENGTH) named.GITLAB_TOKEN = gitlabToken;
303  const npmToken = await $.env.get("NPM_TOKEN").catch(() => undefined);
304  if (npmToken && npmToken.length >= MIN_NAMED_VALUE_LENGTH) named.NPM_TOKEN = npmToken;
305  const awsAccessKeyId = await $.env.get("AWS_ACCESS_KEY_ID").catch(() => undefined);
306  if (awsAccessKeyId && awsAccessKeyId.length >= MIN_NAMED_VALUE_LENGTH) named.AWS_ACCESS_KEY_ID = awsAccessKeyId;
307  const awsSecretAccessKey = await $.env.get("AWS_SECRET_ACCESS_KEY").catch(() => undefined);
308  if (awsSecretAccessKey && awsSecretAccessKey.length >= MIN_NAMED_VALUE_LENGTH) named.AWS_SECRET_ACCESS_KEY = awsSecretAccessKey;
309  const awsSessionToken = await $.env.get("AWS_SESSION_TOKEN").catch(() => undefined);
310  if (awsSessionToken && awsSessionToken.length >= MIN_NAMED_VALUE_LENGTH) named.AWS_SESSION_TOKEN = awsSessionToken;
311  const azureOpenaiApiKey = await $.env.get("AZURE_OPENAI_API_KEY").catch(() => undefined);
312  if (azureOpenaiApiKey && azureOpenaiApiKey.length >= MIN_NAMED_VALUE_LENGTH) named.AZURE_OPENAI_API_KEY = azureOpenaiApiKey;
313  const stripeSecretKey = await $.env.get("STRIPE_SECRET_KEY").catch(() => undefined);
314  if (stripeSecretKey && stripeSecretKey.length >= MIN_NAMED_VALUE_LENGTH) named.STRIPE_SECRET_KEY = stripeSecretKey;
315  const slackBotToken = await $.env.get("SLACK_BOT_TOKEN").catch(() => undefined);
316  if (slackBotToken && slackBotToken.length >= MIN_NAMED_VALUE_LENGTH) named.SLACK_BOT_TOKEN = slackBotToken;
317  const vercelToken = await $.env.get("VERCEL_TOKEN").catch(() => undefined);
318  if (vercelToken && vercelToken.length >= MIN_NAMED_VALUE_LENGTH) named.VERCEL_TOKEN = vercelToken;
319  const cloudflareApiToken = await $.env.get("CLOUDFLARE_API_TOKEN").catch(() => undefined);
320  if (cloudflareApiToken && cloudflareApiToken.length >= MIN_NAMED_VALUE_LENGTH) named.CLOUDFLARE_API_TOKEN = cloudflareApiToken;
321  const opServiceAccountToken = await $.env.get("OP_SERVICE_ACCOUNT_TOKEN").catch(() => undefined);
322  if (opServiceAccountToken && opServiceAccountToken.length >= MIN_NAMED_VALUE_LENGTH) named.OP_SERVICE_ACCOUNT_TOKEN = opServiceAccountToken;
323
324  return named;
325}
326
327/**
328 * The table of last resort: value shapes and entropy, no named values. Pure,
329 * no $ call, so it cannot be refused the way an env read can.
330 */
331function shapesOnlyTable(): MaskTable {
332  return buildTable([], {}, DEFAULT_PATTERNS, { entropy: true });
333}
334
335/**
336 * Arm the veil where session.start never did: a module enabled or reloaded
337 * mid-session can see tool.call before (or without) session.start. Measured
338 * on CC 2.1.282 (2026-09-26): a hot reload re-dispatches session.start about
339 * a second after "reloaded", so a call in that window found table === null
340 * and the old code returned the result UNMASKED. Named reads first; if they
341 * fail, shapes and entropy alone. Never null.
342 */
343async function armLazily($: DollarAPI): Promise<MaskTable> {
344  try {
345    const named = await readNamed($);
346    return buildTable(Object.keys(named), named, DEFAULT_PATTERNS, { entropy: true });
347  } catch {
348    // Pure and $-free; if even this throws, tool.call's guard answers { deny }.
349    return shapesOnlyTable();
350  }
351}
352
353/**
354 * Register the secrets-veil hooks.
355 */
356/** What on() returns on CC 2.1.282: a registration that takes one .catch. */
357export interface Registration {
358  catch(handler: (...args: unknown[]) => unknown): unknown;
359}
360
361/** The answer when anything goes wrong after the tool ran: withhold, never pass through. */
362export const WITHHELD = "secrets-veil could not mask this result, so it is withheld rather than shown unmasked.";
363
364export function register(on: (event: string, hook: unknown) => Registration, _options?: unknown): void {
365  on("session.start", async ($: DollarAPI, e: { cwd: string }, next: (ev: { cwd: string }) => Promise<unknown>) => {
366    const named = await readNamed($);
367    const names = Object.keys(named);
368    table = buildTable(names, named, DEFAULT_PATTERNS, { entropy: true });
369    maskedThisSession = 0;
370    // Off unless the human opts in: a dialog on every masked result would be noise.
371    const offer = await $.env.get("SECRETS_VEIL_OFFER_COPY").catch(() => undefined);
372    offerCopy = offer === "1";
373
374    // FAIL LOUD on an empty named list. Silence is not allowed: say plainly
375    // that only the value shapes and entropy are standing guard.
376    if (names.length === 0) {
377      try {
378        await withTimeout(
379          $,
380          () =>
381            $.ui.notice(
382              NOTICE_ID,
383              "secrets-veil: no named secret values resolved from the environment; " +
384                "the veil is running on value patterns and entropy only, with no named secrets."
385            ),
386          UI_TIMEOUT_MS
387        );
388      } catch {
389        // Notice refused; masking continues either way.
390      }
391    }
392    return next(e); // a session.start hook must answer (CC 2.1.282 skips a hook that returns nothing)
393  });
394
395  on("tool.call", async ($: DollarAPI, e: ToolCallEvent, next?: (ev: ToolCallEvent) => Promise<ToolCallResult>) => {
396    // First, let the tool execute.
397    const result = next ? ((await next(e)) ?? {}) : {};
398    // From here on nothing may fail open: on CC 2.1.282 a hook that fails
399    // after next(e) leaves the original (unmasked) result standing, so every
400    // failure below answers { deny } instead. The copy offer runs inside this
401    // block too, so a failure there withholds rather than passes through.
402    try {
403      if (!table) {
404        // A mod enabled or reloaded mid-session can see tool.call first.
405        table = await armLazily($);
406      }
407      const covered = new Set<string>();
408      const masked = maskDeep(result, table, covered, { chars: 0, deadline: Date.now() + MAX_MASK_MS });
409      const stats = statsOf(covered);
410      if (stats.values > 0) {
411        maskedThisSession += stats.values;
412        const tool = typeof e?.tool === "string" && e.tool.length > 0 ? e.tool : "a tool result";
413        const line = toastText(stats, tool);
414        // Each UI call is best effort: a refused surface never unmasks or fails the result.
415        try {
416          await withTimeout($, () => $.ui.toast(line), UI_TIMEOUT_MS);
417        } catch {
418          // Toast refused (no surface, or a host without toasts); the result stays masked.
419        }
420        try {
421          await withTimeout($, () => $.ui.status(`${maskedThisSession} masked this session`), UI_TIMEOUT_MS);
422        } catch {
423          // Status refused; the result stays masked.
424        }
425        if (offerCopy) {
426          await offerToCopy($, covered, tool);
427        }
428        try {
429          // Byte counts go to the debug log only, never the transcript.
430          const debugLine = debugText(stats, tool, utf8Bytes(JSON.stringify(result)), utf8Bytes(JSON.stringify(masked)));
431          await withTimeout($, () => $.ui.log(debugLine, { to: "debug" }), UI_TIMEOUT_MS);
432        } catch {
433          // Debug log refused; the result stays masked.
434        }
435      }
436      return answerFor(masked, stats.values > 0);
437    } catch {
438      return { deny: WITHHELD };
439    }
440  })
441    // The runtime's own failure path (a throw it sees, or the hook overrunning
442    // its budget) also answers { deny }: the last resort, never pass-through.
443    .catch(() => ({ deny: WITHHELD }));
444}
445
src/mask.ts 578 lines
1// secrets-veil: mask.ts - pure secret masking logic
2// Reused from commit 30720692 (buildTable, mask, DEFAULT_PATTERNS, wouldMask)
3// with one new layer added: high-entropy token masking. The layer is opt-in
4// per table via buildTable options, so existing callers keep exact behavior.
5
6import type { MaskTable, MaskTableEntry, MaskSpan, MaskResult, EntropyConfig } from "./types";
7
8/**
9 * High-entropy layer constants.
10 *
11 * A token counts as a possible secret when it is at least
12 * ENTROPY_MIN_LENGTH characters from [A-Za-z0-9+/=_-] and its Shannon
13 * entropy per character is at or above ENTROPY_THRESHOLD.
14 *
15 * Why 4.3 bits per char:
16 * - Any string over a 16-symbol alphabet (hex) has entropy at most
17 *   log2(16) = 4.0, and a UUID (hex plus dash) at most log2(17) = 4.09.
18 *   Both are below 4.3 ALWAYS, by counting, not on average: a 40-char git
19 *   sha, a 64-char sha256 and every UUID can never be masked by this layer.
20 * - Measured on this machine (2026-09-16, 200k samples): a uniform random
21 *   32-char token over the 67-symbol veil alphabet has median entropy 4.60
22 *   bits/char (5th percentile 4.35), so most random tokens cross 4.3.
23 * - Ordinary lowercase words and camelCase identifiers measured 2.9 to 4.0
24 *   bits/char, below the threshold. Paths, branch names and URLs reach the
25 *   bar only when scored as one mixed-alphabet run, which the tokenizer rule
26 *   in mask() prevents: ordinary separator runs are split before scoring.
27 * The residual false negatives (very low diversity random tokens) are the
28 * safe direction: the veil misses some tokens rather than masking shas.
29 */
30export const ENTROPY_MIN_LENGTH = 20;
31export const ENTROPY_THRESHOLD = 4.3;
32export const ENTROPY_ALPHABET = "[A-Za-z0-9+/=_-]";
33
34/**
35 * Subresource Integrity value: public in every package-lock.json on the
36 * registry and in this repository, never a secret. A run that is exactly
37 * 'sha1-', 'sha256-', 'sha384-' or 'sha512-' followed by base64 (plus any
38 * padding) is exempt from the entropy layer entirely (FIX ROUND of #4189).
39 */
40export const SRI_VALUE_RE = /^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/;
41
42/**
43 * Minimum mean chunk length for a mixed-case segment to count as camelCase
44 * or PascalCase and therefore word-like (FIX ROUND B of HOLD 4189).
45 * Human compound identifiers chunk into 3+ character words (ReviewPR ->
46 * Review,PR mean 4; CTAOverlay -> CTA,Overlay mean 5), while random base64
47 * alternates case every 1 to 2 characters (mean chunk 1 to 2).
48 */
49export const CAMELCASE_MIN_MEAN_CHUNK = 3;
50
51const CAMEL_CHUNK_RE = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g;
52
53/**
54 * Mean chunk length of a camelCase/PascalCase segment, or 0 when the chunk
55 * regex does not tile the segment completely. Chunks are uppercase runs not
56 * followed by lowercase, optional single uppercase plus a lowercase run,
57 * and digit runs: ReviewPR -> Review,PR; CTAOverlay -> CTA,Overlay;
58 * aB3dE6fG -> a,B,3,d,E,6,f,G.
59 */
60export function camelChunkMeanLength(seg: string): number {
61  let covered = 0;
62  let count = 0;
63  for (const m of seg.matchAll(CAMEL_CHUNK_RE)) {
64    covered += m[0].length;
65    count++;
66  }
67  if (count === 0 || covered !== seg.length) return 0;
68  return seg.length / count;
69}
70
71/**
72 * Word-likeness of a separator-split segment (HOLD 4189, fix round 3).
73 *
74 * A segment is WORD-LIKE when it is 7 characters or fewer (short pieces
75 * cannot hide a secret on their own), or when it reads like a human word:
76 * letters of a single case (all lower or all upper), digits only, lowercase
77 * letters mixed with digits with no uppercase (hex, slugs), or a camelCase
78 * or PascalCase compound whose chunks average 3+ characters (FIX ROUND B:
79 * ReviewPR, ShowcaseDemo, CTAOverlay are identifier words, not secret
80 * bodies; masking docs/site/public/thumbnails/CIN-ReviewPR.png and
81 * orchestkit-demos/src/components/terminal-flow/CTAOverlay.tsx was a
82 * precision bug). It is RANDOM-LOOKING when it is 8+ characters mixing
83 * upper and lower case, or mixing upper case with digits, without long
84 * word chunks: that is the signature of base64url and base64 secret
85 * bodies, which never appear in paths, branch names or URLs.
86 *
87 * A segment containing any character outside [A-Za-z0-9] is neither and
88 * returns false, so a run holding one keeps its whole-run treatment.
89 */
90export function isWordLikeSegment(seg: string): boolean {
91  if (seg.length === 0) return true; // empty piece from a boundary split
92  if (seg.length <= 7) return true; // short segments are never random
93  let hasUpper = false;
94  let hasLower = false;
95  let hasDigit = false;
96  for (let i = 0; i < seg.length; i++) {
97    const ch = seg[i];
98    if (ch >= "a" && ch <= "z") {
99      hasLower = true;
100    } else if (ch >= "A" && ch <= "Z") {
101      hasUpper = true;
102    } else if (ch >= "0" && ch <= "9") {
103      hasDigit = true;
104    } else {
105      return false;
106    }
107  }
108  if (hasUpper && hasLower) {
109    // mixed case, 8+: word-like only as a camelCase/PascalCase compound
110    // (FIX ROUND B); random base64 alternates case too often to chunk long
111    return camelChunkMeanLength(seg) >= CAMELCASE_MIN_MEAN_CHUNK;
112  }
113  if (hasUpper && hasDigit) {
114    // upper case with digits, 8+: same chunk test (CTA2024 -> 3.5, word;
115    // Ab3dE6fG -> 1.7, random)
116    return camelChunkMeanLength(seg) >= CAMELCASE_MIN_MEAN_CHUNK;
117  }
118  return true;
119}
120
121/**
122 * Shannon entropy of a string, in bits per character, over its empirical
123 * character distribution. Empty strings have entropy 0.
124 */
125export function shannonEntropy(token: string): number {
126  if (token.length === 0) return 0;
127  const freq = new Map<string, number>();
128  for (const ch of token) {
129    freq.set(ch, (freq.get(ch) ?? 0) + 1);
130  }
131  let bits = 0;
132  for (const count of freq.values()) {
133    const p = count / token.length;
134    bits -= p * Math.log2(p);
135  }
136  return bits;
137}
138
139/**
140 * True when a standalone value crosses the entropy bar.
141 */
142export function isHighEntropyToken(token: string): boolean {
143  return (
144    token.length >= ENTROPY_MIN_LENGTH &&
145    shannonEntropy(token) >= ENTROPY_THRESHOLD
146  );
147}
148
149/**
150 * Build the mask table from named values and shape patterns.
151 * Called once at session.start; pure function for testability.
152 * @param envNames - list of VAR names whose values were read at session start
153 * @param envValues - map of name -> value from $.env.get at session start
154 * @param patterns - hardcoded shape patterns (sk-ant-, ghp_, etc.)
155 * @param options - set { entropy: true } to also scan for high-entropy tokens
156 * @returns MaskTable ready for mask() calls
157 */
158export function buildTable(
159  envNames: readonly string[],
160  envValues: Record<string, string>,
161  patterns: readonly string[],
162  options?: { entropy?: boolean }
163): MaskTable {
164  const entries: MaskTableEntry[] = [];
165
166  // Add env values >= 8 chars that are non-empty
167  for (const name of envNames) {
168    const value = envValues[name];
169    if (value && value.length >= 8) {
170      entries.push({
171        type: "env",
172        name,
173        value,
174        pattern: value, // exact match
175      });
176    }
177  }
178
179  // Add shape patterns (prefixes that indicate secrets)
180  for (const pattern of patterns) {
181    entries.push({
182      type: "pattern",
183      pattern,
184    });
185  }
186
187  const entropy: EntropyConfig | null = options?.entropy
188    ? { minLength: ENTROPY_MIN_LENGTH, thresholdBitsPerChar: ENTROPY_THRESHOLD }
189    : null;
190
191  return { entries, entropy };
192}
193
194/**
195 * Default secret patterns (prefixes and patterns).
196 * Vendor prefixes such as sk-ant-, ghp_, xoxb-, AKIA, Bearer, BEGIN PRIVATE KEY.
197 */
198export const DEFAULT_PATTERNS: readonly string[] = [
199  "sk-ant-",
200  "ops_",
201  "ghp_",
202  "github_pat_",
203  "xoxb-",
204  "sk_live_",
205  "rk_live_",
206  "AKIA",
207  "Bearer ",
208  "-----BEGIN ",
209];
210
211/** One raw match: a half-open range, plus the variable name for a named value. */
212interface MatchRecord {
213  start: number;
214  end: number;
215  name?: string;
216}
217
218/**
219 * The same set as the regex class \s (ECMAScript WhiteSpace plus
220 * LineTerminator), tested by char code: a regex call per character was
221 * the dominant cost on long inputs.
222 */
223function isSpaceCode(c: number): boolean {
224  return (
225    c === 0x20 || (c >= 0x09 && c <= 0x0d) || c === 0xa0 || c === 0x1680 ||
226    (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 ||
227    c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff
228  );
229}
230
231/**
232 * Push a span when the value crosses the entropy bar (length floor and
233 * threshold). Shared by the whole-run and per-segment paths; the length
234 * check runs first so short values skip entropy scoring entirely (perf).
235 */
236function pushIfHighEntropy(
237  matches: Array<MatchRecord>,
238  start: number,
239  value: string,
240  entropy: EntropyConfig
241): void {
242  if (
243    value.length >= entropy.minLength &&
244    shannonEntropy(value) >= entropy.thresholdBitsPerChar
245  ) {
246    matches.push({ start, end: start + value.length });
247  }
248}
249
250/**
251 * Score one '='-split piece of a run, from run[pieceStart] to run[pieceEnd].
252 * The piece splits at '/', '-' and '_' only when every resulting segment is
253 * word-like (isWordLikeSegment); otherwise the piece is scored whole. This
254 * is the round-3 rule that restored recall on base64url secret shapes
255 * (HOLD 5697167579) while keeping paths and branch names split.
256 */
257function scorePiece(
258  matches: Array<MatchRecord>,
259  absStart: number,
260  run: string,
261  pieceStart: number,
262  pieceEnd: number,
263  entropy: EntropyConfig
264): void {
265  const piece = run.slice(pieceStart, pieceEnd);
266  let segStart = -1;
267  for (let i = 0; i <= piece.length; i++) {
268    const ch = i < piece.length ? piece[i] : "/"; // sentinel flushes the tail
269    if (ch === "/" || ch === "-" || ch === "_") {
270      if (segStart !== -1 && !isWordLikeSegment(piece.slice(segStart, i))) {
271        // a random-looking segment: the whole piece stays unsplit
272        pushIfHighEntropy(matches, absStart, piece, entropy);
273        return;
274      }
275      segStart = -1;
276    } else if (segStart === -1) {
277      segStart = i;
278    }
279  }
280  // every segment word-like: score each on its own
281  segStart = -1;
282  for (let i = 0; i <= piece.length; i++) {
283    const ch = i < piece.length ? piece[i] : "/";
284    if (ch === "/" || ch === "-" || ch === "_") {
285      if (segStart !== -1) {
286        pushIfHighEntropy(matches, absStart + segStart, piece.slice(segStart, i), entropy);
287        segStart = -1;
288      }
289    } else if (segStart === -1) {
290      segStart = i;
291    }
292  }
293}
294
295/**
296 * Mask secrets in text, returning masked text and spans for UI overlay.
297 * Pure function; no I/O. Must complete in under 5 ms for 1 MB input.
298 * @param text - text to mask
299 * @param table - mask table from buildTable()
300 * @returns MaskResult with masked text and spans (original positions)
301 */
302export function mask(text: string, table: MaskTable): MaskResult {
303  const spans: MaskSpan[] = [];
304  let maskedText = text;
305
306  // Collect all matches with their positions
307  const matches: Array<MatchRecord> = [];
308
309  for (const entry of table.entries) {
310    if (entry.type === "env") {
311      // Exact match for env values
312      let searchPos = 0;
313      while (true) {
314        const idx = maskedText.indexOf(entry.value, searchPos);
315        if (idx === -1) break;
316        matches.push({
317          start: idx,
318          end: idx + entry.value.length,
319          name: entry.name,
320        });
321        // Every occurrence, overlapping ones included: a copy that starts
322        // inside another must not leave a fragment visible. indexOf keeps
323        // this linear; the spans are unioned below.
324        searchPos = idx + 1;
325      }
326    } else {
327      // Pattern prefix match
328      let searchPos = 0;
329      // One forward search for "-----END ": the nearest END at or after a
330      // BEGIN only moves forward, so a cached position (or "none left") is
331      // reused instead of re-searching from every BEGIN (quadratic on many
332      // BEGINs with no END).
333      let endMarkerAt = -2; // -2: not searched yet; -1: none after endSearchFrom
334      let endSearchFrom = 0;
335      while (true) {
336        const idx = maskedText.indexOf(entry.pattern, searchPos);
337        if (idx === -1) break;
338
339        // Determine the end of the secret
340        let end = idx + entry.pattern.length;
341
342        // For Bearer, extend to end of token (non-whitespace)
343        if (entry.pattern === "Bearer ") {
344          while (end < maskedText.length && !isSpaceCode(maskedText.charCodeAt(end))) {
345            end++;
346          }
347          // Require at least 20 chars for Bearer tokens
348          if (end - idx < 26) {
349            // "Bearer " (7) + 20+ chars
350            searchPos = idx + 1;
351            continue;
352          }
353        }
354
355        // For BEGIN ... PRIVATE KEY, find the END marker
356        if (entry.pattern === "-----BEGIN ") {
357          if (endMarkerAt === -2 || (endMarkerAt !== -1 && endMarkerAt < idx)) {
358            endSearchFrom = idx;
359            endMarkerAt = maskedText.indexOf("-----END ", idx);
360          }
361          const endMarker = endMarkerAt !== -1 && idx >= endSearchFrom ? endMarkerAt : -1;
362          if (endMarker !== -1) {
363            const finalDash = maskedText.indexOf("-----", endMarker + 10);
364            if (finalDash !== -1) {
365              end = finalDash + 5;
366            }
367          }
368        }
369
370        // For other patterns, extend to end of token (non-whitespace)
371        if (
372          entry.pattern !== "Bearer " &&
373          entry.pattern !== "-----BEGIN "
374        ) {
375          while (end < maskedText.length && !isSpaceCode(maskedText.charCodeAt(end))) {
376            end++;
377          }
378        }
379
380        matches.push({ start: idx, end });
381        // Resume at the end of this match, never one character later: any
382        // later prefix hit inside [idx, end) would end at the same run end,
383        // so it is already covered, and the overlap step below unions spans
384        // instead of dropping them. Restarting inside the run was quadratic.
385        searchPos = Math.max(end, idx + 1);
386      }
387    }
388  }
389
390  // High-entropy token layer (opt-in per table).
391  //
392  // Tokenizer rule (HOLD 4189, fix round 3): a run is scored WHOLE when it
393  // contains '+' anywhere, or '=' only as trailing padding, or when a
394  // separator split would NOT leave every segment word-like; otherwise it
395  // splits and each segment is scored on its own. A run matching
396  // SRI_VALUE_RE is skipped entirely.
397  //
398  // Why: '+' and '=' padding occur in base64 value runs (an AWS secret
399  // access key is 40 chars of [A-Za-z0-9/+], and standard base64 ends in
400  // '=') but never in path, branch or URL segments, so they mark a run as a
401  // value rather than a location. A mid-run '=' is the opposite: URLs like
402  // ?ref=<branch> put a branch name after '=' (#4189 FIX ROUND), so it
403  // always splits like a key=value boundary. Round 2 also split every '-'
404  // and '_' unconditionally, but those characters are ordinary alphabet in
405  // base64url, which is what JWTs, OpenAI project keys, Google keys and
406  // most modern tokens use: the split cut real secrets into segments under
407  // the 20-char floor and recall collapsed (measured in HOLD 5697167579:
408  // JWT 99.6% -> 13.8%, sk-proj- 100 -> 43.4). The character mix now
409  // decides: '-', '_' and '/' split only when every resulting segment is
410  // word-like (paths, branch names, slugs), and a run with any random-
411  // looking segment is scored whole (secrets). Both directions are pinned
412  // in tests/entropy-corpus.test.ts: the repo-output corpus (paths,
413  // branches, URLs, npm integrity lines) must mask 0 spans (mutation M5),
414  // and 500 seeded samples per real secret shape must mask at >= 98%
415  // (>= 95% for the two threshold-limited shapes) (mutation M4).
416  const entropy = table.entropy;
417  if (entropy) {
418    const tokenRe = new RegExp(`${ENTROPY_ALPHABET}{${entropy.minLength},}`, "g");
419    let m: RegExpExecArray | null;
420    while ((m = tokenRe.exec(maskedText)) !== null) {
421      const run = m[0];
422      if (SRI_VALUE_RE.test(run)) {
423        // public integrity hash, never a secret
424        continue;
425      }
426      if (run.indexOf("+") !== -1 || /^[^=]+=+$/.test(run)) {
427        // base64-like run: '+' anywhere, or '=' only as trailing padding;
428        // score it whole
429        pushIfHighEntropy(matches, m.index, run, entropy);
430      } else {
431        // ordinary run: a mid-run '=' always splits (key=value, round 2);
432        // each piece then splits at '/', '-', '_' only when every segment
433        // is word-like, else the piece is scored whole
434        let pieceStart = 0;
435        for (let i = 0; i <= run.length; i++) {
436          if (i === run.length || run[i] === "=") {
437            if (i > pieceStart) {
438              scorePiece(matches, m.index + pieceStart, run, pieceStart, i, entropy);
439            }
440            pieceStart = i + 1;
441          }
442        }
443      }
444    }
445  }
446
447  // Sort by start, longest first at a tie; skipped when the layers already
448  // produced ascending, non-tied starts (one pattern on a long input).
449  let ordered = true;
450  for (let k = 1; k < matches.length; k++) {
451    if (matches[k].start <= matches[k - 1].start) {
452      ordered = false;
453      break;
454    }
455  }
456  if (!ordered) matches.sort((a, b) => {
457    if (a.start !== b.start) return a.start - b.start;
458    return b.end - a.end; // longer matches first
459  });
460
461  // Union overlapping or touching matches. Dropping a match that starts
462  // inside an earlier one lost its tail whenever it reached further (a
463  // named value ending inside a prefix token, two overlapping named values,
464  // an entropy piece next to a prefix hit), leaving part of a secret
465  // visible. A merged span covers everything any layer matched.
466  const merged: Array<{ start: number; end: number; name?: string; names: number }> = [];
467  for (const match of matches) {
468    const last = merged[merged.length - 1];
469    if (last && match.start <= last.end) {
470      if (match.end > last.end) last.end = match.end;
471      if (match.name !== undefined && match.name !== last.name) last.names += 1;
472    } else {
473      merged.push({ start: match.start, end: match.end, name: match.name, names: match.name === undefined ? 0 : 1 });
474    }
475  }
476
477  // Build the masked text in one pass: slices and bullets joined once
478  // (per-span slice + concat + unshift was quadratic on many spans).
479  const parts: string[] = [];
480  let cursor = 0;
481  for (const span of merged) {
482    parts.push(text.slice(cursor, span.start));
483    parts.push("\u2022".repeat(Math.min(8, span.end - span.start)));
484    cursor = span.end;
485    spans.push({
486      start: span.start,
487      end: span.end,
488      value: text.slice(span.start, span.end),
489      // A span merged from several named values names none of them.
490      name: span.names === 1 ? span.name : undefined,
491    });
492  }
493  parts.push(text.slice(cursor));
494  maskedText = parts.join("");
495
496  return { text: maskedText, spans };
497}
498
499/**
500 * Check if a value would be masked (used for testing).
501 */
502export function wouldMask(value: string, table: MaskTable): boolean {
503  for (const entry of table.entries) {
504    if (entry.type === "env" && entry.value === value) {
505      return true;
506    }
507    if (entry.type === "pattern" && value.startsWith(entry.pattern)) {
508      return true;
509    }
510  }
511  // Same tokenizer rule as mask(): '+' anywhere or '=' as trailing padding
512  // means the value is a base64-like run and is scored whole. Otherwise a
513  // mid-run '=' always splits (round 2), and each piece splits at '/', '-'
514  // and '_' only when every segment is word-like (round 3), else the piece
515  // is scored whole. SRI values are public and never mask.
516  const entropy = table.entropy;
517  if (entropy) {
518    if (SRI_VALUE_RE.test(value)) {
519      return false;
520    }
521    if (/[+]/.test(value) || /^[^=]+=+$/.test(value)) {
522      return (
523        value.length >= entropy.minLength &&
524        shannonEntropy(value) >= entropy.thresholdBitsPerChar
525      );
526    }
527    let pieceStart = 0;
528    for (let i = 0; i <= value.length; i++) {
529      if (i === value.length || value[i] === "=") {
530        if (i > pieceStart) {
531          const piece = value.slice(pieceStart, i);
532          let segStart = -1;
533          let allWordLike = true;
534          for (let j = 0; j <= piece.length; j++) {
535            const ch = j < piece.length ? piece[j] : "/";
536            if (ch === "/" || ch === "-" || ch === "_") {
537              if (segStart !== -1 && !isWordLikeSegment(piece.slice(segStart, j))) {
538                allWordLike = false;
539                break;
540              }
541              segStart = -1;
542            } else if (segStart === -1) {
543              segStart = j;
544            }
545          }
546          if (allWordLike) {
547            segStart = -1;
548            for (let j = 0; j <= piece.length; j++) {
549              const ch = j < piece.length ? piece[j] : "/";
550              if (ch === "/" || ch === "-" || ch === "_") {
551                if (segStart !== -1) {
552                  const seg = piece.slice(segStart, j);
553                  if (
554                    seg.length >= entropy.minLength &&
555                    shannonEntropy(seg) >= entropy.thresholdBitsPerChar
556                  ) {
557                    return true;
558                  }
559                  segStart = -1;
560                }
561              } else if (segStart === -1) {
562                segStart = j;
563              }
564            }
565          } else if (
566            piece.length >= entropy.minLength &&
567            shannonEntropy(piece) >= entropy.thresholdBitsPerChar
568          ) {
569            return true;
570          }
571        }
572        pieceStart = i + 1;
573      }
574    }
575  }
576  return false;
577}
578
src/types.ts 149 lines
1// secrets-veil: types.ts - shared type definitions
2// Reused from commit 30720692 and trimmed: the reveal surface (RevealedState,
3// UIPressEvent, CommandRegisterEvent, ui.render/ui.press on OnFn) is gone by
4// design. This mod has no reveal path; its ui calls carry counts, never values.
5
6/**
7 * Configuration for the high-entropy value layer.
8 */
9export interface EntropyConfig {
10  /** Minimum token length to consider */
11  minLength: number;
12  /** Minimum Shannon entropy in bits per character */
13  thresholdBitsPerChar: number;
14}
15
16/**
17 * An entry in the mask table.
18 */
19export type MaskTableEntry =
20  | {
21      type: "env";
22      name: string;
23      value: string;
24      pattern: string;
25    }
26  | {
27      type: "pattern";
28      pattern: string;
29    };
30
31/**
32 * The mask table built at session.start from named values and patterns.
33 * The optional entropy layer is a scan config, not an entry: it applies to
34 * whole tokens found in text, not to a fixed value.
35 */
36export interface MaskTable {
37  entries: readonly MaskTableEntry[];
38  entropy?: EntropyConfig | null;
39}
40
41/**
42 * A span in the original text that was masked.
43 */
44export interface MaskSpan {
45  /** Start position in original text */
46  start: number;
47  /** End position in original text */
48  end: number;
49  /** The original value that was masked */
50  value: string;
51  /** The env var name, if applicable */
52  name?: string;
53}
54
55/**
56 * Result of masking text.
57 */
58export interface MaskResult {
59  /** The masked text (with secrets replaced by bullets) */
60  text: string;
61  /** Spans indicating where secrets were found */
62  spans: MaskSpan[];
63}
64
65/**
66 * The $ dependency object (subset used by secrets-veil).
67 * Deliberately minimal: env reads, one notice, and the three calls that make
68 * masking visible (toast, status, debug log), each carrying counts only,
69 * never a value. No ui.render, no ui.press, no commands, no process, no
70 * http, no store.
71 */
72export interface DollarAPI {
73  env: {
74    get(name: string): Promise<string | undefined>;
75  };
76  clock: {
77    /** One-shot timer; the mod sandbox has no ambient timers. */
78    after(ms: number, fn: () => void): { cancel?: () => void };
79  };
80  ui: {
81    notice(id: string, message: string): void;
82    toast(text: string): Promise<void>;
83    status(line: string): Promise<void>;
84    /** { to: "debug" } writes the debug log only; the default is the transcript. */
85    log(text: string, options?: { to?: "transcript" | "debug" }): Promise<void>;
86    /** Opt-in only: the AskUserQuestion dialog, resolves to the picked label. */
87    ask(question: string, options: readonly string[]): Promise<unknown>;
88    /** Opt-in only: writes to the human's clipboard (OSC 52); the model never sees it. */
89    copy(spec: { text: string }): Promise<{ isCopied: boolean; reason?: string }>;
90  };
91}
92
93/**
94 * Event types for function hooks.
95 */
96export interface ToolCallEvent {
97  tool: string;
98  args: Record<string, unknown>;
99  tool_use_id?: string;
100}
101
102export interface ToolCallResult {
103  deny?: string;
104  result?: unknown;
105  text?: string;
106  isError?: boolean;
107  context?: string[];
108}
109
110export interface SessionStartEvent {
111  surface?: "terminal" | "desktop" | "mobile";
112  isInteractive?: boolean;
113}
114
115/**
116 * Event filter passed to on() (e.g. { component: "ToolResult" }).
117 */
118export type HookMatcher = Record<string, unknown>;
119
120/**
121 * The next() function signature: pass the event down the hook chain
122 * and await the (possibly modified) result.
123 */
124export type NextFn<E, R = unknown> = (event: E) => Promise<R>;
125
126/**
127 * A function-hook handler: receives the $ API, the event and next().
128 */
129export type HookHandler<E, R = unknown> = (
130  api: DollarAPI,
131  event: E,
132  next: NextFn<E, R>
133) => Promise<unknown>;
134
135/**
136 * The on() registrar Claude Code passes to a function-hooks module.
137 * Only two events: session.start arms the veil, tool.call masks results.
138 * There is no ui.render, no ui.press and no command.register on purpose.
139 */
140export interface OnFn {
141  (event: "session.start", matcher: HookMatcher, handler: HookHandler<SessionStartEvent>): void;
142  (event: "tool.call", matcher: HookMatcher, handler: HookHandler<ToolCallEvent, ToolCallResult>): void;
143}
144
145/**
146 * The register() entry point a function-hooks module exports.
147 */
148export type Register = (on: OnFn) => void;
149