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

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.
Three layers, checked on every tool result:
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.
sk-ant-, ops_, ghp_, github_pat_, xoxb-, AKIA, Bearer (20 or more following characters), and PEM private key blocks.[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.
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):
masked 1 value in Bash;3 masked this session;$.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.
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:
$.ui.copy (secrets-veil): 40 chars, ... copied), never the value.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.
/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.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.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.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.
hooks/register.ts - session arming and result masking (the only I/O)src/mask.ts - pure masking logic: table build, patterns, entropysrc/types.ts - shared types, no reveal surfacetests/register.test.ts - drives the shipped hook moduletests/mask.test.ts - pure unit tests, including the 5 ms / 1 MB budgetcd 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.
hooks/register.ts 445 lines1/**
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}
445src/mask.ts 578 lines1// 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}
578src/types.ts 149 lines1// 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