Keeps pasted API keys and credentials out of the conversation, the transcript, the screen and the model, while tools can still use them as $NAME

<img alt="Safe Keys" src="assets/logo-light.png" width="480" height="114">
<h3 align="center">Paste an API key into Claude Code. Nothing keeps it.</h3>
<a href="https://club.reinventing.ai/safe-keys?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=nav-guide"><strong>Setup guide</strong></a> • <a href="https://club.reinventing.ai/?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=nav-club"><strong>Agent Ops Club</strong></a> • <a href="https://club.reinventing.ai/ai-employees?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=nav-employees"><strong>AI Employees</strong></a> • <a href="https://club.reinventing.ai/events?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=nav-sessions"><strong>Live sessions</strong></a> • <a href="https://club.reinventing.ai/faq?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=nav-faq"><strong>FAQ</strong></a>
<img alt="Stars" src="https://img.shields.io/github/stars/markfulton/safe-keys?style=flat-square&color=E3B341&logo=github&logoColor=white&label=Stars"> <img alt="MIT license" src="https://img.shields.io/badge/License-MIT-3FB950?style=flat-square"> <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-D97757?style=flat-square&logo=anthropic&logoColor=white"> <img alt="Windows, macOS and Linux" src="https://img.shields.io/badge/Windows_macOS_Linux-ready-2B2B2B?style=flat-square">
<a href="https://club.reinventing.ai/safe-keys?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=btn-guide"><img src="assets/btn-guide.png" width="211" height="60" alt="Get the Safe Keys setup guide, free"></a> <a href="https://club.reinventing.ai/register?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=btn-join"><img src="assets/btn-join.png" width="198" height="60" alt="Join the Agent Ops Club free"></a> <a href="https://club.reinventing.ai/events?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=btn-sessions"><img src="assets/btn-sessions.png" width="161" height="60" alt="Agent Ops Club live sessions"></a> <a href="https://club.reinventing.ai/?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=btn-club"><img src="assets/btn-club.png" width="184" height="60" alt="Visit the Agent Ops Club"></a>
<img src="assets/hero.jpg" width="900" alt="A brass key dissolving into a stream of blue characters that form a tag, drifting toward a laptop">
⭐ <em>Found something useful? Star the repo. It takes a second and helps the next person find it.</em>
You are working with Claude Code. A service emails you a new API key. You paste it into the chat and say "set this". That key is now in the conversation, in the transcript file on your disk, on your screen, and in the model's context. It stays in that transcript for as long as the file exists.
Safe Keys is a Claude Code plugin that catches the key the moment you submit it, stores it outside the conversation, and shows it everywhere as a name instead: $OPENROUTER_API_KEY, $BRIGHTDATA_API_KEY, $SAFE_KEY_1. When a tool needs the real value, it gets it, for that one command, and the output that comes back is cleaned before the model reads it.
I built it after pasting a Bright Data key into my own session and watching the redactor I had at the time miss it. The miss is explained in docs/DETAILS.md, and the fix is the context rule in lib/detect.js.
Created by Mark Fulton of Reinventing.AI, founder of Vibe Coding is Life (340,000+ members).
<img src="assets/how-it-works.png" width="900" alt="You type a message with a key in it. The chat row, the model, the transcript on disk and the tool output all show $BRIGHTDATA_API_KEY. Only the tool, at the moment it runs, gets the real value.">
One paste, five places it would normally land, one place it actually does.
| Where a key could land | What Safe Keys does |
|---|---|
| The prompt box, on paste | Replaced before the text is queued or drawn |
| The message the model reads | Replaced before it enters the session |
| The transcript file on disk | Every stored row is rewritten before it is written. The two records that are not rows are overwritten in place a few seconds later |
| Bash and PowerShell commands | $NAME is a real environment variable, so the shell expands it and the command never carries the value |
| Every other tool | The value is substituted into the arguments at the moment the tool runs |
| Tool output coming back | Stored values become names again. A key a tool prints, from a cat .env say, is stored and named too |
| The screen | Every drawn row is scrubbed on the terminal, the desktop app, VS Code and mobile |
Real values live in the plugin's memory for the length of the session, and nowhere else. The detector knows 38 key shapes by name (OpenAI, Anthropic, GitHub, Stripe, Supabase, AWS and the rest), reads labels like BRIGHTDATA_API_KEY= and Authorization: Bearer, and catches a bare UUID when a word like "key" or "token" sits near it, which is the case every entropy test misses. It leaves dates, versions, git SHAs, file paths and URLs alone.
1. Turn on function hooks, once, in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
2. Put the plugin where Claude Code loads skills from:
git clone https://github.com/markfulton/safe-keys ~/.claude/skills/safe-keys
Open a new session. The first line of the session says Safe Keys is on. Paste a key and watch the row change.
Prefer to try it in one session first? Start Claude Code with claude --plugin-dir /path/to/safe-keys. Function hooks are an early access surface (Claude Code 2.1.286 at the time of writing), and claude plugin validate /path/to/safe-keys confirms your build reads the plugin before a session does. node on the PATH is needed for the on-disk pass; the in-memory layers run without it.
<table> <tr><td align="center" width="900">
<h2>Get the setup guide and the clean-up walkthrough, free</h2>
<a href="https://club.reinventing.ai/safe-keys?utm_source=github&utm_medium=readme&utm_campaign=safe-keys&utm_content=cta-guide"><img src="assets/btn-guide.png" width="211" height="60" alt="Get the Safe Keys setup guide, free"></a>
</td></tr> </table>
Paste a key the way you always did. The session confirms the name in one line, and the model uses the name:
curl -s https://openrouter.ai/api/v1/key -H "Authorization: Bearer $OPENROUTER_API_KEY"
/keys shows what is stored this session (names only), where the transcript and the log are./keys scan dry-runs every transcript on your machine and counts what it finds, by record type./keys clean overwrites those spans in place with a same-length marker, so every file stays valid and every session still resumes./keys forget empties the vault for the rest of the session.~/.claude/safe-keys-inbox.txt, one per line, and it is stored on your next message without touching the chat at all.~/.claude/safe-keys-diag.log records which hooks fired and what each did, never a value.
The full design, every rule and every exclusion, with the tests that hold them, is in docs/DETAILS.md. Verify it yourself:
node lib/detect.unit.mjs
claude plugin validate .
claude plugin test .
MIT. Built by Mark Fulton at Reinventing.AI. Safe Keys is one of the free tools in the Agent Ops Club, alongside the open source AI Employees.
hooks/safe-keys.ts 727 lines1// Safe Keys: a Claude Code function-hook plugin that keeps pasted credentials out of the
2// conversation, the transcript file, the screen and the model, while tools can still use them.
3//
4// Layers, each independent of the others:
5// 1. prompt.edit a key pasted into the prompt box is replaced before it is queued or drawn
6// 2. prompt.submit the message the model and the transcript receive carries a name, not a value
7// 3. session.append every row a conversation stores (prompt, response, tool result, notice,
8// subagent rows) is scrubbed before it is written
9// 4. tool.call $NAME in a tool's arguments is resolved: as a real environment variable for
10// Bash and PowerShell (the shell expands it, so the permission classifier never
11// sees a value), textually for every other tool; stored values and new keys in a
12// result are replaced before the model reads them
13// 5. ui.render every drawn component is scrubbed on every surface
14// 6. scripts/scrub.mjs the records that are not rows (queue-operation, last-prompt) and older
15// sessions, overwritten in place with same-length markers
16// 7. diagnostics ~/.claude/safe-keys-diag.log says which hooks fired and what each did,
17// never a value
18//
19// Real values live only in this module's memory for the length of the session, plus the engine
20// process environment under literal names (hooks/env-table.js), which survives a hot reload.
21
22import type { Register } from "claude-code";
23import { Vault, redactText, forDisplay, mapStrings, rewriteContent, usageNote } from "../lib/detect.js";
24
25const PLUGIN = "safe-keys";
26const INBOX = "safe-keys-inbox.txt";
27const DIAG = "safe-keys-diag.log";
28
29// Any engine interface. Every call below is feature-detected and wrapped so a method this build
30// lacks costs one diag line, never a failure.
31type Engine = any;
32
33const vault = new Vault();
34
35// BEGIN GENERATED ENV TABLE (scripts/gen-env-table.mjs, from lib/detect.js ENV_VOCABULARY; do not edit by hand)
36const ENV_NAMES: readonly string[] = ["ANTHROPIC_API_KEY","OPENAI_API_KEY","OPENROUTER_API_KEY","GITHUB_TOKEN","GITLAB_TOKEN","AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY","GOOGLE_API_KEY","GOOGLE_OAUTH_TOKEN","SLACK_TOKEN","STRIPE_SECRET_KEY","STRIPE_PUBLISHABLE_KEY","STRIPE_WEBHOOK_SECRET","RESEND_API_KEY","SUPABASE_ACCESS_TOKEN","SUPABASE_SERVICE_ROLE_KEY","SUPABASE_SECRET_KEY","SUPABASE_PUBLISHABLE_KEY","SUPABASE_ANON_KEY","APIFY_TOKEN","HF_TOKEN","NPM_TOKEN","VERCEL_TOKEN","CLOUDFLARE_API_TOKEN","TWILIO_AUTH_TOKEN","TWILIO_API_KEY","SENDGRID_API_KEY","MAILGUN_API_KEY","DIGITALOCEAN_TOKEN","LINEAR_API_KEY","NOTION_TOKEN","GROQ_API_KEY","PERPLEXITY_API_KEY","REPLICATE_API_TOKEN","FLY_API_TOKEN","TELEGRAM_BOT_TOKEN","DOPPLER_TOKEN","PYPI_TOKEN","BRIGHTDATA_API_KEY","KIE_API_KEY","ELEVENLABS_API_KEY","HIGGSFIELD_API_KEY","DEEPSEEK_API_KEY","MISTRAL_API_KEY","COHERE_API_KEY","GEMINI_API_KEY","DATABASE_URL","PRIVATE_KEY","JWT","SAFE_KEY_1","SAFE_KEY_2","SAFE_KEY_3","SAFE_KEY_4","SAFE_KEY_5","SAFE_KEY_6","SAFE_KEY_7","SAFE_KEY_8","SAFE_TOKEN_1","SAFE_TOKEN_2","SAFE_TOKEN_3","SAFE_TOKEN_4","SAFE_PASSWORD_1","SAFE_PASSWORD_2","SAFE_PASSWORD_3","SAFE_PASSWORD_4","SAFE_SECRET_1","SAFE_SECRET_2","SAFE_SECRET_3","SAFE_SECRET_4"];
37
38/** Sets `name` in the engine process (and every tool it starts after). False when the name is not in the table. */
39async function envSet($: Engine, name: string, value: string): Promise<boolean> {
40 switch (name) {
41 case "ANTHROPIC_API_KEY": await $.env.set("ANTHROPIC_API_KEY", value); return true;
42 case "OPENAI_API_KEY": await $.env.set("OPENAI_API_KEY", value); return true;
43 case "OPENROUTER_API_KEY": await $.env.set("OPENROUTER_API_KEY", value); return true;
44 case "GITHUB_TOKEN": await $.env.set("GITHUB_TOKEN", value); return true;
45 case "GITLAB_TOKEN": await $.env.set("GITLAB_TOKEN", value); return true;
46 case "AWS_ACCESS_KEY_ID": await $.env.set("AWS_ACCESS_KEY_ID", value); return true;
47 case "AWS_SECRET_ACCESS_KEY": await $.env.set("AWS_SECRET_ACCESS_KEY", value); return true;
48 case "GOOGLE_API_KEY": await $.env.set("GOOGLE_API_KEY", value); return true;
49 case "GOOGLE_OAUTH_TOKEN": await $.env.set("GOOGLE_OAUTH_TOKEN", value); return true;
50 case "SLACK_TOKEN": await $.env.set("SLACK_TOKEN", value); return true;
51 case "STRIPE_SECRET_KEY": await $.env.set("STRIPE_SECRET_KEY", value); return true;
52 case "STRIPE_PUBLISHABLE_KEY": await $.env.set("STRIPE_PUBLISHABLE_KEY", value); return true;
53 case "STRIPE_WEBHOOK_SECRET": await $.env.set("STRIPE_WEBHOOK_SECRET", value); return true;
54 case "RESEND_API_KEY": await $.env.set("RESEND_API_KEY", value); return true;
55 case "SUPABASE_ACCESS_TOKEN": await $.env.set("SUPABASE_ACCESS_TOKEN", value); return true;
56 case "SUPABASE_SERVICE_ROLE_KEY": await $.env.set("SUPABASE_SERVICE_ROLE_KEY", value); return true;
57 case "SUPABASE_SECRET_KEY": await $.env.set("SUPABASE_SECRET_KEY", value); return true;
58 case "SUPABASE_PUBLISHABLE_KEY": await $.env.set("SUPABASE_PUBLISHABLE_KEY", value); return true;
59 case "SUPABASE_ANON_KEY": await $.env.set("SUPABASE_ANON_KEY", value); return true;
60 case "APIFY_TOKEN": await $.env.set("APIFY_TOKEN", value); return true;
61 case "HF_TOKEN": await $.env.set("HF_TOKEN", value); return true;
62 case "NPM_TOKEN": await $.env.set("NPM_TOKEN", value); return true;
63 case "VERCEL_TOKEN": await $.env.set("VERCEL_TOKEN", value); return true;
64 case "CLOUDFLARE_API_TOKEN": await $.env.set("CLOUDFLARE_API_TOKEN", value); return true;
65 case "TWILIO_AUTH_TOKEN": await $.env.set("TWILIO_AUTH_TOKEN", value); return true;
66 case "TWILIO_API_KEY": await $.env.set("TWILIO_API_KEY", value); return true;
67 case "SENDGRID_API_KEY": await $.env.set("SENDGRID_API_KEY", value); return true;
68 case "MAILGUN_API_KEY": await $.env.set("MAILGUN_API_KEY", value); return true;
69 case "DIGITALOCEAN_TOKEN": await $.env.set("DIGITALOCEAN_TOKEN", value); return true;
70 case "LINEAR_API_KEY": await $.env.set("LINEAR_API_KEY", value); return true;
71 case "NOTION_TOKEN": await $.env.set("NOTION_TOKEN", value); return true;
72 case "GROQ_API_KEY": await $.env.set("GROQ_API_KEY", value); return true;
73 case "PERPLEXITY_API_KEY": await $.env.set("PERPLEXITY_API_KEY", value); return true;
74 case "REPLICATE_API_TOKEN": await $.env.set("REPLICATE_API_TOKEN", value); return true;
75 case "FLY_API_TOKEN": await $.env.set("FLY_API_TOKEN", value); return true;
76 case "TELEGRAM_BOT_TOKEN": await $.env.set("TELEGRAM_BOT_TOKEN", value); return true;
77 case "DOPPLER_TOKEN": await $.env.set("DOPPLER_TOKEN", value); return true;
78 case "PYPI_TOKEN": await $.env.set("PYPI_TOKEN", value); return true;
79 case "BRIGHTDATA_API_KEY": await $.env.set("BRIGHTDATA_API_KEY", value); return true;
80 case "KIE_API_KEY": await $.env.set("KIE_API_KEY", value); return true;
81 case "ELEVENLABS_API_KEY": await $.env.set("ELEVENLABS_API_KEY", value); return true;
82 case "HIGGSFIELD_API_KEY": await $.env.set("HIGGSFIELD_API_KEY", value); return true;
83 case "DEEPSEEK_API_KEY": await $.env.set("DEEPSEEK_API_KEY", value); return true;
84 case "MISTRAL_API_KEY": await $.env.set("MISTRAL_API_KEY", value); return true;
85 case "COHERE_API_KEY": await $.env.set("COHERE_API_KEY", value); return true;
86 case "GEMINI_API_KEY": await $.env.set("GEMINI_API_KEY", value); return true;
87 case "DATABASE_URL": await $.env.set("DATABASE_URL", value); return true;
88 case "PRIVATE_KEY": await $.env.set("PRIVATE_KEY", value); return true;
89 case "JWT": await $.env.set("JWT", value); return true;
90 case "SAFE_KEY_1": await $.env.set("SAFE_KEY_1", value); return true;
91 case "SAFE_KEY_2": await $.env.set("SAFE_KEY_2", value); return true;
92 case "SAFE_KEY_3": await $.env.set("SAFE_KEY_3", value); return true;
93 case "SAFE_KEY_4": await $.env.set("SAFE_KEY_4", value); return true;
94 case "SAFE_KEY_5": await $.env.set("SAFE_KEY_5", value); return true;
95 case "SAFE_KEY_6": await $.env.set("SAFE_KEY_6", value); return true;
96 case "SAFE_KEY_7": await $.env.set("SAFE_KEY_7", value); return true;
97 case "SAFE_KEY_8": await $.env.set("SAFE_KEY_8", value); return true;
98 case "SAFE_TOKEN_1": await $.env.set("SAFE_TOKEN_1", value); return true;
99 case "SAFE_TOKEN_2": await $.env.set("SAFE_TOKEN_2", value); return true;
100 case "SAFE_TOKEN_3": await $.env.set("SAFE_TOKEN_3", value); return true;
101 case "SAFE_TOKEN_4": await $.env.set("SAFE_TOKEN_4", value); return true;
102 case "SAFE_PASSWORD_1": await $.env.set("SAFE_PASSWORD_1", value); return true;
103 case "SAFE_PASSWORD_2": await $.env.set("SAFE_PASSWORD_2", value); return true;
104 case "SAFE_PASSWORD_3": await $.env.set("SAFE_PASSWORD_3", value); return true;
105 case "SAFE_PASSWORD_4": await $.env.set("SAFE_PASSWORD_4", value); return true;
106 case "SAFE_SECRET_1": await $.env.set("SAFE_SECRET_1", value); return true;
107 case "SAFE_SECRET_2": await $.env.set("SAFE_SECRET_2", value); return true;
108 case "SAFE_SECRET_3": await $.env.set("SAFE_SECRET_3", value); return true;
109 case "SAFE_SECRET_4": await $.env.set("SAFE_SECRET_4", value); return true;
110 default: return false;
111 }
112}
113
114/** Reads `name` from the engine process. Undefined when unset or not in the table. */
115async function envGet($: Engine, name: string): Promise<string | undefined> {
116 switch (name) {
117 case "ANTHROPIC_API_KEY": return $.env.get("ANTHROPIC_API_KEY");
118 case "OPENAI_API_KEY": return $.env.get("OPENAI_API_KEY");
119 case "OPENROUTER_API_KEY": return $.env.get("OPENROUTER_API_KEY");
120 case "GITHUB_TOKEN": return $.env.get("GITHUB_TOKEN");
121 case "GITLAB_TOKEN": return $.env.get("GITLAB_TOKEN");
122 case "AWS_ACCESS_KEY_ID": return $.env.get("AWS_ACCESS_KEY_ID");
123 case "AWS_SECRET_ACCESS_KEY": return $.env.get("AWS_SECRET_ACCESS_KEY");
124 case "GOOGLE_API_KEY": return $.env.get("GOOGLE_API_KEY");
125 case "GOOGLE_OAUTH_TOKEN": return $.env.get("GOOGLE_OAUTH_TOKEN");
126 case "SLACK_TOKEN": return $.env.get("SLACK_TOKEN");
127 case "STRIPE_SECRET_KEY": return $.env.get("STRIPE_SECRET_KEY");
128 case "STRIPE_PUBLISHABLE_KEY": return $.env.get("STRIPE_PUBLISHABLE_KEY");
129 case "STRIPE_WEBHOOK_SECRET": return $.env.get("STRIPE_WEBHOOK_SECRET");
130 case "RESEND_API_KEY": return $.env.get("RESEND_API_KEY");
131 case "SUPABASE_ACCESS_TOKEN": return $.env.get("SUPABASE_ACCESS_TOKEN");
132 case "SUPABASE_SERVICE_ROLE_KEY": return $.env.get("SUPABASE_SERVICE_ROLE_KEY");
133 case "SUPABASE_SECRET_KEY": return $.env.get("SUPABASE_SECRET_KEY");
134 case "SUPABASE_PUBLISHABLE_KEY": return $.env.get("SUPABASE_PUBLISHABLE_KEY");
135 case "SUPABASE_ANON_KEY": return $.env.get("SUPABASE_ANON_KEY");
136 case "APIFY_TOKEN": return $.env.get("APIFY_TOKEN");
137 case "HF_TOKEN": return $.env.get("HF_TOKEN");
138 case "NPM_TOKEN": return $.env.get("NPM_TOKEN");
139 case "VERCEL_TOKEN": return $.env.get("VERCEL_TOKEN");
140 case "CLOUDFLARE_API_TOKEN": return $.env.get("CLOUDFLARE_API_TOKEN");
141 case "TWILIO_AUTH_TOKEN": return $.env.get("TWILIO_AUTH_TOKEN");
142 case "TWILIO_API_KEY": return $.env.get("TWILIO_API_KEY");
143 case "SENDGRID_API_KEY": return $.env.get("SENDGRID_API_KEY");
144 case "MAILGUN_API_KEY": return $.env.get("MAILGUN_API_KEY");
145 case "DIGITALOCEAN_TOKEN": return $.env.get("DIGITALOCEAN_TOKEN");
146 case "LINEAR_API_KEY": return $.env.get("LINEAR_API_KEY");
147 case "NOTION_TOKEN": return $.env.get("NOTION_TOKEN");
148 case "GROQ_API_KEY": return $.env.get("GROQ_API_KEY");
149 case "PERPLEXITY_API_KEY": return $.env.get("PERPLEXITY_API_KEY");
150 case "REPLICATE_API_TOKEN": return $.env.get("REPLICATE_API_TOKEN");
151 case "FLY_API_TOKEN": return $.env.get("FLY_API_TOKEN");
152 case "TELEGRAM_BOT_TOKEN": return $.env.get("TELEGRAM_BOT_TOKEN");
153 case "DOPPLER_TOKEN": return $.env.get("DOPPLER_TOKEN");
154 case "PYPI_TOKEN": return $.env.get("PYPI_TOKEN");
155 case "BRIGHTDATA_API_KEY": return $.env.get("BRIGHTDATA_API_KEY");
156 case "KIE_API_KEY": return $.env.get("KIE_API_KEY");
157 case "ELEVENLABS_API_KEY": return $.env.get("ELEVENLABS_API_KEY");
158 case "HIGGSFIELD_API_KEY": return $.env.get("HIGGSFIELD_API_KEY");
159 case "DEEPSEEK_API_KEY": return $.env.get("DEEPSEEK_API_KEY");
160 case "MISTRAL_API_KEY": return $.env.get("MISTRAL_API_KEY");
161 case "COHERE_API_KEY": return $.env.get("COHERE_API_KEY");
162 case "GEMINI_API_KEY": return $.env.get("GEMINI_API_KEY");
163 case "DATABASE_URL": return $.env.get("DATABASE_URL");
164 case "PRIVATE_KEY": return $.env.get("PRIVATE_KEY");
165 case "JWT": return $.env.get("JWT");
166 case "SAFE_KEY_1": return $.env.get("SAFE_KEY_1");
167 case "SAFE_KEY_2": return $.env.get("SAFE_KEY_2");
168 case "SAFE_KEY_3": return $.env.get("SAFE_KEY_3");
169 case "SAFE_KEY_4": return $.env.get("SAFE_KEY_4");
170 case "SAFE_KEY_5": return $.env.get("SAFE_KEY_5");
171 case "SAFE_KEY_6": return $.env.get("SAFE_KEY_6");
172 case "SAFE_KEY_7": return $.env.get("SAFE_KEY_7");
173 case "SAFE_KEY_8": return $.env.get("SAFE_KEY_8");
174 case "SAFE_TOKEN_1": return $.env.get("SAFE_TOKEN_1");
175 case "SAFE_TOKEN_2": return $.env.get("SAFE_TOKEN_2");
176 case "SAFE_TOKEN_3": return $.env.get("SAFE_TOKEN_3");
177 case "SAFE_TOKEN_4": return $.env.get("SAFE_TOKEN_4");
178 case "SAFE_PASSWORD_1": return $.env.get("SAFE_PASSWORD_1");
179 case "SAFE_PASSWORD_2": return $.env.get("SAFE_PASSWORD_2");
180 case "SAFE_PASSWORD_3": return $.env.get("SAFE_PASSWORD_3");
181 case "SAFE_PASSWORD_4": return $.env.get("SAFE_PASSWORD_4");
182 case "SAFE_SECRET_1": return $.env.get("SAFE_SECRET_1");
183 case "SAFE_SECRET_2": return $.env.get("SAFE_SECRET_2");
184 case "SAFE_SECRET_3": return $.env.get("SAFE_SECRET_3");
185 case "SAFE_SECRET_4": return $.env.get("SAFE_SECRET_4");
186 default: return undefined;
187 }
188}
189
190/** The marker listing the names Safe Keys exported, so a reload re-imports only those. */
191async function markerGet($: Engine): Promise<string | undefined> { return $.env.get("SAFE_KEYS_EXPORTED"); }
192async function markerSet($: Engine, value: string): Promise<void> { await $.env.set("SAFE_KEYS_EXPORTED", value); }
193// END GENERATED ENV TABLE
194
195let home: string | undefined;
196let transcriptPath: string | undefined;
197let sessionCwd: string | undefined;
198let sessionId: string | undefined;
199let pluginRoot: string | undefined;
200let envWorks: boolean | undefined;
201let scrubbing = false;
202let nodeMissing = false;
203const once = new Set<string>();
204
205// ---------------------------------------------------------------------------------------------
206// Diagnostics: a rolling log, never a value.
207
208const diagLines: string[] = [];
209let diagFlushing = false;
210
211function diag($: Engine, line: string): void {
212 diagLines.push(new Date().toISOString() + " " + line);
213 if (diagLines.length > 400) diagLines.splice(0, diagLines.length - 400);
214 try {
215 $.ui.log(PLUGIN + ": " + line, { to: "debug" });
216 } catch {
217 // no debug sink on this build
218 }
219 void flushDiag($);
220}
221
222function diagOnce($: Engine, key: string, line: string): void {
223 if (once.has(key)) return;
224 once.add(key);
225 diag($, line);
226}
227
228async function flushDiag($: Engine): Promise<void> {
229 if (diagFlushing || !home || diagLines.length === 0) return;
230 diagFlushing = true;
231 try {
232 const path = home + "/.claude/" + DIAG;
233 const previous = (await fsRead($, path)) ?? "";
234 const merged = previous.split("\n").filter(Boolean).concat(diagLines);
235 diagLines.length = 0;
236 await $.fs.write(path, merged.slice(-500).join("\n") + "\n");
237 } catch {
238 // best effort
239 } finally {
240 diagFlushing = false;
241 }
242}
243
244async function fsRead($: Engine, path: string): Promise<string | undefined> {
245 try {
246 const r = await $.fs.read(path);
247 return typeof r === "string" ? r : typeof r?.text === "string" ? r.text : undefined;
248 } catch {
249 return undefined;
250 }
251}
252
253// ---------------------------------------------------------------------------------------------
254// Session facts.
255
256async function learnSession($: Engine): Promise<void> {
257 if (!home) {
258 try {
259 home = (await $.env.get("USERPROFILE")) ?? (await $.env.get("HOME"));
260 } catch {
261 home = undefined;
262 }
263 if (home) home = home.replace(/\\/g, "/").replace(/\/$/, "");
264 }
265 if (!pluginRoot) {
266 try {
267 const root = $.plugin.root;
268 if (typeof root === "string") pluginRoot = root.replace(/\\/g, "/").replace(/\/$/, "");
269 } catch {
270 pluginRoot = undefined;
271 }
272 }
273 if (!sessionId) {
274 try {
275 sessionId = await $.session.id();
276 } catch {
277 sessionId = undefined;
278 }
279 }
280 if (!sessionCwd) {
281 try {
282 sessionCwd = await $.session.cwd();
283 } catch {
284 sessionCwd = undefined;
285 }
286 }
287}
288
289/** The transcript file: what classic.SessionStart said, or the engine's own layout from cwd and id. */
290function resolveTranscript(): string | undefined {
291 if (transcriptPath) return transcriptPath;
292 if (!home || !sessionCwd || !sessionId) return undefined;
293 return home + "/.claude/projects/" + sessionCwd.replace(/[^A-Za-z0-9]/g, "-") + "/" + sessionId + ".jsonl";
294}
295
296// ---------------------------------------------------------------------------------------------
297// Environment export: each stored value becomes a real variable, under a literal name, for every
298// tool process the engine starts. A reload re-imports exactly the names this plugin exported.
299
300const MARKER_VERSION = "v2:";
301
302async function exportEnv($: Engine): Promise<void> {
303 if (envWorks === false || vault.size === 0) return;
304 const exported: string[] = [];
305 for (const [name, value] of vault.entries()) {
306 // Only what the user handed over. A value a tool printed is masked, never exported.
307 if (!vault.isExported(name)) continue;
308 const alias = vault.envNameFor(name);
309 try {
310 if (await envSet($, alias, value)) exported.push(alias);
311 if (envWorks === undefined) {
312 envWorks = true;
313 diag($, "env: $.env.set works; stored names are real variables for Bash and PowerShell");
314 }
315 } catch (err) {
316 envWorks = false;
317 diag($, "env: $.env.set unavailable (" + String((err as Error)?.name) + "); tools get textual substitution only");
318 return;
319 }
320 }
321 try {
322 await markerSet($, MARKER_VERSION + exported.join(","));
323 } catch {
324 // the marker is a convenience for reloads
325 }
326}
327
328/** Unsets every name the marker lists and clears the marker: the forget command and a stale marker both end here. */
329async function unexportAll($: Engine, marker: string | undefined): Promise<number> {
330 const list = (marker ?? "").replace(/^v\d+:/, "");
331 let n = 0;
332 for (const name of list.split(",")) {
333 if (!name || !ENV_NAMES.includes(name)) continue;
334 try {
335 await envSet($, name, undefined as unknown as string);
336 n++;
337 } catch {
338 // best effort
339 }
340 }
341 try {
342 await markerSet($, MARKER_VERSION);
343 } catch {
344 // best effort
345 }
346 return n;
347}
348
349async function importEnv($: Engine): Promise<number> {
350 let marker: string | undefined;
351 try {
352 marker = await markerGet($);
353 } catch {
354 return 0;
355 }
356 if (!marker) return 0;
357 if (!marker.startsWith(MARKER_VERSION)) {
358 // Written by an earlier build that exported tool-output values too. Clear it rather than trust it.
359 const n = await unexportAll($, marker);
360 diag($, "env: cleared " + n + " name(s) exported by an earlier build");
361 return 0;
362 }
363 let n = 0;
364 for (const name of marker.slice(MARKER_VERSION.length).split(",")) {
365 if (!name || !ENV_NAMES.includes(name)) continue;
366 try {
367 const value = await envGet($, name);
368 if (typeof value === "string" && value.length >= 8) {
369 vault.stash(value, name);
370 n++;
371 }
372 } catch {
373 // skip
374 }
375 }
376 return n;
377}
378
379// ---------------------------------------------------------------------------------------------
380// Disk pass: scripts/scrub.mjs under node, same-length in-place; an in-process exact-value fallback.
381
382async function runDiskScrub($: Engine, why: string): Promise<void> {
383 if (scrubbing) return;
384 scrubbing = true;
385 try {
386 await learnSession($);
387 const path = resolveTranscript();
388 if (!path) {
389 diag($, "disk (" + why + "): transcript path unknown");
390 return;
391 }
392 if (!nodeMissing && pluginRoot) {
393 try {
394 const r = await $.process.run(["node", pluginRoot + "/scripts/scrub.mjs", "--file", path, "--quiet", "--values-stdin", "--prompts-only"], {
395 stdin: JSON.stringify({ values: vault.values() }),
396 timeoutMs: 20000,
397 });
398 const out = String(r?.stdout ?? "").trim();
399 diag($, "disk (" + why + "): " + (out || "no output") + " exit=" + String(r?.exitCode));
400 if (r?.exitCode === 0) return;
401 } catch (err) {
402 const msg = String((err as Error)?.message ?? err);
403 nodeMissing = /ENOENT|not found|cannot start|spawn/i.test(msg);
404 diag($, "disk (" + why + "): scrub.mjs did not run (" + msg.slice(0, 80) + ")" + (nodeMissing ? "; node missing, in-process fallback from now on" : ""));
405 }
406 }
407 if (vault.size === 0) return;
408 const text = await fsRead($, path);
409 if (text === undefined || vault.scrub(text) === text) return;
410 const before = await statSize($, path);
411 const clean = vault.scrub(text);
412 const after = await statSize($, path);
413 if (before !== undefined && before === after) {
414 await $.fs.write(path, clean);
415 diag($, "disk (" + why + "): a stored value was on disk; rewrote the file with names");
416 } else {
417 diag($, "disk (" + why + "): a stored value is on disk but the file is being written; will retry");
418 }
419 } catch (err) {
420 diag($, "disk (" + why + "): failed (" + String((err as Error)?.message ?? err).slice(0, 80) + ")");
421 } finally {
422 scrubbing = false;
423 }
424}
425
426async function statSize($: Engine, path: string): Promise<number | undefined> {
427 try {
428 const s = await $.fs.stat(path);
429 return typeof s?.size === "number" ? s.size : undefined;
430 } catch {
431 return undefined;
432 }
433}
434
435function scheduleDiskScrub($: Engine, why: string): void {
436 void runDiskScrub($, why);
437 for (const ms of [3000, 15000]) {
438 try {
439 $.clock.after(ms, () => void runDiskScrub($, why + " +" + ms / 1000 + "s"));
440 } catch (err) {
441 diagOnce($, "clock", "clock.after unavailable (" + String((err as Error)?.name) + "); no delayed disk pass");
442 break;
443 }
444 }
445}
446
447// ---------------------------------------------------------------------------------------------
448// The inbox: a file the user drops a key into instead of pasting it. Read and emptied on the next prompt.
449
450async function drainInbox($: Engine): Promise<string[]> {
451 if (!home) return [];
452 const path = home + "/.claude/" + INBOX;
453 const raw = await fsRead($, path);
454 if (!raw || !raw.trim()) return [];
455 const placeholders: string[] = [];
456 for (const line of raw.split(/\r?\n/)) {
457 const value = line.trim();
458 if (value.length < 8) continue;
459 const { hits } = redactText(value, new Vault(), { knownOnly: true });
460 const name = hits.length === 1 && hits[0].value === value ? hits[0].name : "SAFE_KEY";
461 placeholders.push(vault.stash(value, name, "inbox"));
462 }
463 try {
464 await $.fs.write(path, "");
465 } catch {
466 diag($, "inbox: could not empty the file");
467 }
468 return placeholders;
469}
470
471// ---------------------------------------------------------------------------------------------
472// Shared text cleaners.
473
474/** Stored values to names, then any new key of a known shape or label stored (as tool output, never exported) and replaced too. */
475function cleanKnown(text: string): string {
476 return redactText(vault.scrub(text), vault, { knownOnly: true, source: "output" }).text;
477}
478
479/** The same with the full detector: for text the user typed. */
480function cleanFull(text: string): string {
481 return redactText(vault.scrub(text), vault).text;
482}
483
484function announce($: Engine, fresh: string[], where: string): void {
485 if (fresh.length === 0) return;
486 diag($, where + ": stored " + fresh.length + " value(s) as " + fresh.join(", "));
487 try {
488 $.ui.log(PLUGIN + ": stored " + fresh.join(", ") + " (" + where + "). Use the name like a shell variable.");
489 } catch {
490 // cosmetic
491 }
492 try {
493 $.ui.toast("Safe Keys stored " + fresh.join(", "), { timeoutMs: 6000 });
494 } catch {
495 // cosmetic
496 }
497}
498
499function freshSince(countBefore: number): string[] {
500 return vault.placeholders().slice(countBefore);
501}
502
503// ---------------------------------------------------------------------------------------------
504
505export const register: Register = (on) => {
506 on("classic.SessionStart", async ($, e: any, next) => {
507 if (typeof e?.transcript_path === "string") transcriptPath = e.transcript_path.replace(/\\/g, "/");
508 if (typeof e?.cwd === "string") sessionCwd = e.cwd;
509 if (typeof e?.session_id === "string") sessionId = e.session_id;
510 return next(e);
511 });
512
513 on("session.start", async ($, e, next) => {
514 await learnSession($);
515 const imported = await importEnv($);
516 diag($, "session.start: home " + (home ? "ok" : "unknown") + ", plugin root " + (pluginRoot ? "ok" : "unknown") + ", transcript " + (resolveTranscript() ? "resolvable" : "unresolved") + (imported ? ", re-imported " + imported + " name(s) after a reload" : ""));
517 try {
518 await $.command.register({ name: "safe-keys", description: "Safe Keys: stored names, transcript scan, cleanup", argumentHint: "[status|scan|clean|forget]" });
519 diag($, "command: /safe-keys registered");
520 } catch (err) {
521 // The folder's SKILL.md already owns /safe-keys as a skill command on some builds; take a second name.
522 diag($, "command.register /safe-keys refused (" + String((err as Error)?.message ?? err).slice(0, 120) + "); trying /keys");
523 try {
524 await $.command.register({ name: "keys", description: "Safe Keys: stored names, transcript scan, cleanup", argumentHint: "[status|scan|clean|forget]" });
525 diag($, "command: /keys registered");
526 } catch (err2) {
527 diag($, "command.register /keys refused (" + String((err2 as Error)?.message ?? err2).slice(0, 120) + ")");
528 }
529 }
530 try {
531 $.ui.log(PLUGIN + ": on. A pasted key is stored and shown as a name such as $OPENROUTER_API_KEY; the value never enters the transcript. /keys for status.");
532 } catch {
533 // cosmetic
534 }
535 return next(e);
536 });
537
538 // 1. Composer: a paste is replaced before the text is queued or drawn. Keystrokes are short and cheap.
539 on("prompt.edit", async ($, e: any, next) => {
540 diagOnce($, "prompt.edit", "prompt.edit fires on this surface");
541 if (typeof e?.inputText !== "string" || e.inputText.length < 8) return next(e);
542 const before = vault.size;
543 const { text, hits } = redactText(e.inputText, vault);
544 if (hits.length === 0) return next(e);
545 const r = await next({ ...e, inputText: text });
546 void exportEnv($);
547 announce($, freshSince(before), "prompt.edit");
548 return r;
549 });
550
551 // 2. The prompt: the message the model and the transcript receive.
552 on("prompt.submit", async ($, e: any, next) => {
553 await learnSession($);
554 const before = vault.size;
555 const inbox = await drainInbox($);
556 const { text, hits } = redactText(e.text, vault);
557 const stored = vault.size > 0;
558 const context = stored ? [...(e.context ?? []), usageNote(vault.placeholders())] : e.context;
559
560 // Pass the clean text down first, so the redaction holds even if a notice call below fails.
561 const r = await next(hits.length > 0 || stored ? { ...e, text, ...(context ? { context } : {}) } : e);
562
563 if (hits.length > 0 || inbox.length > 0) {
564 void exportEnv($);
565 scheduleDiskScrub($, "prompt.submit");
566 try {
567 $.ui.invalidate("ui.render");
568 } catch {
569 // the render hook catches the next draw
570 }
571 announce($, freshSince(before), inbox.length > 0 && hits.length === 0 ? "inbox" : "prompt");
572 }
573 return r;
574 });
575
576 // 3. Every stored row.
577 on("session.append", async ($, e: any, next) => {
578 const content = e?.message?.content;
579 if (!Array.isArray(content)) return next(e);
580 const full = e.door === "prompt";
581 const before = vault.size;
582 const rewritten = rewriteContent(content, full ? cleanFull : cleanKnown, cleanKnown);
583 const changed = rewritten !== content;
584 if (!changed) return next(e);
585 diagOnce($, "append:" + String(e.door), "session.append rewrote a row at door " + String(e.door));
586 const fresh = freshSince(before);
587 if (fresh.length > 0) {
588 void exportEnv($);
589 announce($, fresh, "session.append " + String(e.door));
590 }
591 return next({ ...e, message: { ...e.message, content: rewritten } });
592 }).catch(($, e: any, next) => {
593 // The rewrite failed: still replace any stored value with the plainest possible pass.
594 try {
595 const content = mapStrings(e?.message?.content, (s: string) => vault.scrub(s));
596 if (content !== e?.message?.content) return next({ ...e, message: { ...e.message, content } });
597 } catch {
598 // nothing more to try
599 }
600 return next(e);
601 });
602
603 // 4. Tools: names resolved on the way in, values and new keys replaced on the way out.
604 on("tool.call", async ($, e: any, next) => {
605 let call = e;
606 if (vault.size > 0) {
607 await exportEnv($);
608 const tool = String(e?.tool);
609 if ((tool === "Bash" || tool === "PowerShell") && typeof e.command === "string") {
610 const shell = tool === "PowerShell" ? "powershell" : "bash";
611 const command = envWorks ? vault.restoreForShell(e.command, shell) : vault.restore(e.command);
612 if (command !== e.command) {
613 call = { ...e, command };
614 diag($, "tool.call " + tool + ": resolved stored name(s) " + (envWorks ? "as environment variables" : "textually"));
615 }
616 } else {
617 // Value positions only (KEY=, "key":, Bearer, --flag, or the whole argument): a name
618 // mentioned in prose or source code the model writes stays a name.
619 const restored = mapStrings(e, (s: string) => vault.restoreValues(s));
620 if (restored !== e) {
621 call = restored;
622 diag($, "tool.call " + tool + ": substituted stored value(s) into value positions of the arguments");
623 }
624 }
625 }
626 const r = await next(call);
627 if (!r || r.deny !== undefined) return r;
628
629 // Only a structured result is rewritten here. A string or primitive result is left as it is:
630 // the engine validates a hook's own result against the tool's output schema, and the
631 // session.append layer still scrubs the stored row before the model reads it.
632 if (!r.result || typeof r.result !== "object") return r;
633 const before = vault.size;
634 let result: unknown;
635 try {
636 result = mapStrings(r.result, cleanKnown);
637 } catch (err) {
638 diag($, "tool.call " + String(e?.tool) + ": result rewrite failed (" + String((err as Error)?.message ?? err).slice(0, 80) + "); row scrub still applies");
639 return r;
640 }
641 if (result === r.result) return r;
642
643 const fresh = freshSince(before);
644 if (fresh.length > 0) {
645 void exportEnv($);
646 announce($, fresh, "tool.call " + String(e?.tool) + " result");
647 } else {
648 diag($, "tool.call " + String(e?.tool) + ": a stored value in the result was replaced by its name");
649 }
650 // A note for the model, on its own calls only: the engine keeps no context on a plugin's own $.tool.call.
651 const context = [...(r.context ?? [])];
652 if (fresh.length > 0 && typeof e?.tool_use_id === "string") context.push("The tool output contained credential(s), now stored and usable as " + fresh.join(", ") + ". Use the name(s), never the value.");
653 return { result, ...(context.length > 0 ? { context } : {}), ...(r.isError === true ? { isError: true } : {}) };
654 });
655
656 // 5. Screen.
657 on("ui.render", async ($, e: any, next) => {
658 const props = e?.props;
659 if (!props || typeof props !== "object") return next(e);
660 const component = String(e?.component);
661 if ((component === "UserMessage" || component === "AssistantMessage") && typeof props.text === "string") {
662 const text = forDisplay(props.text, vault, { knownOnly: component === "AssistantMessage" });
663 if (text === props.text) return next(e);
664 diagOnce($, "render:" + component + ":" + String(e?.surface), "ui.render masked " + component + " on " + String(e?.surface));
665 return next({ ...e, props: { ...props, text } });
666 }
667 const scrubbed = mapStrings(props, (s: string) => forDisplay(s, vault, { knownOnly: true }));
668 if (scrubbed === props) return next(e);
669 diagOnce($, "render:" + component + ":" + String(e?.surface), "ui.render masked " + component + " on " + String(e?.surface));
670 return next({ ...e, props: scrubbed });
671 });
672
673 on("turn.complete", async ($, e, next) => {
674 if (vault.size > 0) scheduleDiskScrub($, "turn.complete");
675 return next(e);
676 });
677
678 on("session.end", async ($, e, next) => {
679 await runDiskScrub($, "session.end");
680 await flushDiag($);
681 return next(e);
682 });
683
684 // /safe-keys status | scan | clean | forget
685 on("command.run", { command: ["safe-keys", "keys"] }, async ($, e: any) => {
686 const arg = String(e?.args ?? "").trim().toLowerCase();
687 await learnSession($);
688 if (arg === "forget") {
689 const n = vault.size;
690 let marker: string | undefined;
691 try {
692 marker = await markerGet($);
693 } catch {
694 marker = undefined;
695 }
696 const cleared = await unexportAll($, marker);
697 vault.forget();
698 diag($, "command: forgot " + n + " stored value(s), unset " + cleared + " environment name(s)");
699 return { text: "Safe Keys: forgot " + n + " stored value(s) and unset " + cleared + " environment name(s). Tools started from now on see none of them." };
700 }
701 if (arg === "scan" || arg === "clean") {
702 if (!pluginRoot) return { text: "Safe Keys: plugin root unknown; run node <plugin>/scripts/scrub.mjs --all --dry-run yourself." };
703 try {
704 const argv = ["node", pluginRoot + "/scripts/scrub.mjs", "--all", "--report", "--quiet", "--values-stdin"];
705 if (arg === "scan") argv.push("--dry-run");
706 const r = await $.process.run(argv, { stdin: JSON.stringify({ values: vault.values() }), timeoutMs: 300000 });
707 const out = String(r?.stdout ?? "").trim().split("\n").pop() ?? "";
708 diag($, "command " + arg + ": " + out);
709 return { text: "Safe Keys " + arg + ": " + out + (arg === "scan" ? "\nRun /safe-keys clean to overwrite them in place." : "") };
710 } catch (err) {
711 return { text: "Safe Keys: scan failed (" + String((err as Error)?.message ?? err).slice(0, 120) + ")" };
712 }
713 }
714 const names = vault.placeholders();
715 const lines = [
716 "Safe Keys is on.",
717 names.length ? "Stored this session: " + names.join(", ") : "Nothing stored this session.",
718 "Environment export: " + (envWorks === undefined ? "not needed yet" : envWorks ? "on" : "unavailable (textual substitution)"),
719 "Transcript: " + (resolveTranscript() ?? "unknown"),
720 "Diag log: " + (home ? home + "/.claude/" + DIAG : "unknown"),
721 "Inbox: drop a key in " + (home ? home + "/.claude/" + INBOX : "~/.claude/" + INBOX) + " to store it without pasting.",
722 "Commands: /safe-keys scan (dry run over every transcript), /safe-keys clean (overwrite in place), /safe-keys forget.",
723 ];
724 return { text: lines.join("\n") };
725 });
726};
727lib/detect.js 665 lines1// Safe Keys: detection and the in-memory vault.
2//
3// A plain ES module with no Node and no DOM, so the same code runs inside the
4// Claude Code hook environment (hooks/safe-keys.ts imports it) and under Node
5// (scripts/scrub.mjs imports it, lib/detect.unit.mjs tests it).
6//
7// A stored value is shown everywhere as an environment-variable style name:
8// `$OPENROUTER_API_KEY`, `$BRIGHTDATA_API_KEY`, `$SAFE_KEY_1`. The name says what
9// the value is, it reads as an ordinary shell variable to every permission
10// classifier, and if a substitution ever misses it expands to nothing in a shell
11// and stays a harmless literal elsewhere: the design fails closed.
12
13/** The display-only marker for a value that looks secret but is not in the vault. */
14export const HIDDEN = "[hidden]";
15
16/** Names the engine's `$.env.set` can take, literal by literal (hooks/env-table.js). */
17export const GENERIC_SLOTS = { KEY: 8, TOKEN: 4, PASSWORD: 4, SECRET: 4 };
18
19// ---------------------------------------------------------------------------
20// Rule 1: known key shapes, most specific first. Each one names itself.
21
22export const KNOWN = [
23 { re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, name: "ANTHROPIC_API_KEY" },
24 { re: /\bsk-or-v1-[A-Za-z0-9]{20,}/g, name: "OPENROUTER_API_KEY" },
25 { re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, name: "OPENAI_API_KEY" },
26 { re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}/g, name: "GITHUB_TOKEN" },
27 { re: /\bgithub_pat_[A-Za-z0-9_]{30,}/g, name: "GITHUB_TOKEN" },
28 { re: /\bglpat-[A-Za-z0-9_-]{20,}/g, name: "GITLAB_TOKEN" },
29 { re: /\bAKIA[A-Z0-9]{16}\b/g, name: "AWS_ACCESS_KEY_ID" },
30 { re: /\bAIza[A-Za-z0-9_-]{30,}/g, name: "GOOGLE_API_KEY" },
31 { re: /\bya29\.[A-Za-z0-9_-]{30,}/g, name: "GOOGLE_OAUTH_TOKEN" },
32 { re: /\bxox[abprse]-[A-Za-z0-9-]{10,}/g, name: "SLACK_TOKEN" },
33 { re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, name: "STRIPE_SECRET_KEY" },
34 { re: /\bpk_(?:live|test)_[A-Za-z0-9]{16,}/g, name: "STRIPE_PUBLISHABLE_KEY" },
35 { re: /\bwhsec_[A-Za-z0-9]{20,}/g, name: "STRIPE_WEBHOOK_SECRET" },
36 { re: /\bre_[A-Za-z0-9_-]{20,}/g, name: "RESEND_API_KEY" },
37 { re: /\bsbp_[A-Za-z0-9]{30,}/g, name: "SUPABASE_ACCESS_TOKEN" },
38 { re: /\bsb_secret_[A-Za-z0-9_-]{20,}/g, name: "SUPABASE_SECRET_KEY" },
39 { re: /\bsb_publishable_[A-Za-z0-9_-]{20,}/g, name: "SUPABASE_PUBLISHABLE_KEY" },
40 { re: /\bapify_api_[A-Za-z0-9]{20,}/g, name: "APIFY_TOKEN" },
41 { re: /\bhf_[A-Za-z0-9]{30,}/g, name: "HF_TOKEN" },
42 { re: /\bnpm_[A-Za-z0-9]{30,}/g, name: "NPM_TOKEN" },
43 { re: /\bdop_v1_[a-f0-9]{60,}/g, name: "DIGITALOCEAN_TOKEN" },
44 { re: /\blin_api_[A-Za-z0-9]{30,}/g, name: "LINEAR_API_KEY" },
45 { re: /\bntn_[A-Za-z0-9]{40,}/g, name: "NOTION_TOKEN" },
46 { re: /\bgsk_[A-Za-z0-9]{40,}/g, name: "GROQ_API_KEY" },
47 { re: /\bpplx-[A-Za-z0-9]{40,}/g, name: "PERPLEXITY_API_KEY" },
48 { re: /\br8_[A-Za-z0-9]{30,}/g, name: "REPLICATE_API_TOKEN" },
49 { re: /\bfo1_[A-Za-z0-9_-]{30,}/g, name: "FLY_API_TOKEN" },
50 { re: /\bFlyV1 fm2_[A-Za-z0-9_+/=,-]{30,}/g, name: "FLY_API_TOKEN" },
51 { re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, name: "SENDGRID_API_KEY" },
52 { re: /\bkey-[0-9a-f]{32}\b/g, name: "MAILGUN_API_KEY" },
53 { re: /\bSK[0-9a-f]{32}\b/g, name: "TWILIO_API_KEY" },
54 { re: /\b\d{8,10}:AA[A-Za-z0-9_-]{33,}/g, name: "TELEGRAM_BOT_TOKEN" },
55 { re: /\bdp\.(?:st|pt|sa)\.[A-Za-z0-9_-]{30,}/g, name: "DOPPLER_TOKEN" },
56 { re: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{20,}/g, name: "PYPI_TOKEN" },
57 { re: /\bvcp_[A-Za-z0-9]{20,}/g, name: "VERCEL_TOKEN" },
58 // A JWT whose payload also starts with `{"` (base64 `eyJ`): Supabase legacy keys, most bearer JWTs.
59 { re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{10,}/g, name: "JWT" },
60 { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, name: "PRIVATE_KEY" },
61 // A connection string that carries a password.
62 { re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^\s:/@"']+:[^\s/@"']{4,}@[^\s"'<>]+/g, name: "DATABASE_URL" },
63];
64
65// ---------------------------------------------------------------------------
66// Rule 2: a label that says "credential", then = or :, then the value.
67// `BRIGHTDATA_API_KEY=...`, `x-api-key: ...`, `"token": "..."`, `--password ...`,
68// `Authorization: Bearer ...`.
69
70const LABEL_WORD =
71 "(?:api[_ -]?key|apikey|access[_ -]?key|secret[_ -]?key|private[_ -]?key|service[_ -]?role[_ -]?key|" +
72 "client[_ -]?secret|webhook[_ -]?secret|signing[_ -]?secret|secret|access[_ -]?token|refresh[_ -]?token|" +
73 "auth[_ -]?token|bot[_ -]?token|api[_ -]?token|token|password|passwd|passphrase|pwd|credentials?)";
74
75const ASSIGNED = new RegExp(
76 "(?:^|[^A-Za-z0-9_$])[\"']?((?:[A-Za-z][A-Za-z0-9_.-]*[_.-])?" + LABEL_WORD + ")[\"']?\\s*(?::|=|=>)\\s*(?:bearer\\s+)?[\"']?([^\\s\"',;]{8,})",
77 "gi",
78);
79const BEARER = /\bbearer\s+([A-Za-z0-9_.~+/=-]{16,})/gi;
80const FLAG = /(?:^|\s)--?([a-z][a-z0-9-]*?(?:key|token|secret|password|pass))(?:=|\s+)["']?([^\s"']{8,})/gi;
81
82// ---------------------------------------------------------------------------
83// Rule 3: a credential word nearby, then a token. Catches "the new key is <uuid>",
84// which no shape and no entropy test can see: a UUID tops out at 4.09 bits per
85// character. Prompts only (never tool output, where ids are everywhere).
86
87const CRED_WORD = /\b(?:api[ _-]?keys?|apikeys?|keys?|tokens?|secrets?|passwords?|passphrase|credentials?|bearer|auth)\b/i;
88const CONTEXT_TOKEN = /[A-Za-z0-9][A-Za-z0-9_-]{14,}[A-Za-z0-9]/g;
89const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
90const UPPER_IDENT = /^[A-Z][A-Z0-9_]*$/;
91const DATE_LIKE = /^\d{4}-\d{2}-\d{2}(?:[T_-][\d:.-]+)?$/;
92const VERSION_LIKE = /^v?\d+(?:\.\d+){1,3}(?:-[A-Za-z0-9.]+)?$/;
93const CONTEXT_WINDOW = 56;
94
95// ---------------------------------------------------------------------------
96// Rule 4: a bare high-entropy token (prompts only).
97
98const CANDIDATE = /[A-Za-z0-9_-]{24,}/g;
99const URL_SPAN = /https?:\/\/[^\s<>"')\]]+/g;
100
101/** Identifiers Claude Code, the API and common services mint themselves. Random looking, never secret. */
102const ENGINE_ID_PREFIXES = /^(?:toolu_|msg_|msgid_|req_|agent-|task_|wf_|local_|preview-|session_|hook-|call_|run_|thread_|batch_|cs_|pi_|ch_|cus_|sub_|evt_|in_|price_|prod_)/i;
103
104/** Characters no credential carries: a regex literal, a type annotation, a template, an escape. */
105const BAD_VALUE_CHARS = /[()[\]{}<>|\\`]/;
106
107/** Values that are placeholders, examples or redactions, never a credential. */
108const NOT_A_VALUE = /^(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|<[^>]*>|\[hidden\]\**|\[redacted\]|x{4,}|\*{4,}|\.{3,}|your[_-]?[a-z_-]*|change[_-]?me|example[_-]?[a-z_-]*|placeholder|redacted|none|null|undefined|true|false)$/i;
109
110/** Messages the engine composes for the model (task notifications, reminders). Not user pastes. */
111const ENGINE_MESSAGE = /^\s*<(?:task-notification|system-reminder|command-(?:name|message|args)|local-command-(?:stdout|stderr|caveat)|ci-monitor-event|ide_[a-z_]+)\b/;
112
113/** Vendor words near a value give it its name: "bright data ... key is X" becomes $BRIGHTDATA_API_KEY. */
114const VENDORS = [
115 [/bright\s*data/i, "BRIGHTDATA"], [/openrouter/i, "OPENROUTER"], [/openai/i, "OPENAI"], [/anthropic|claude/i, "ANTHROPIC"],
116 [/github/i, "GITHUB"], [/gitlab/i, "GITLAB"], [/supabase/i, "SUPABASE"], [/stripe/i, "STRIPE"], [/resend/i, "RESEND"],
117 [/apify/i, "APIFY"], [/vercel/i, "VERCEL"], [/cloudflare/i, "CLOUDFLARE"], [/twilio/i, "TWILIO"], [/sendgrid/i, "SENDGRID"],
118 [/mailgun/i, "MAILGUN"], [/\bkie\b/i, "KIE"], [/eleven\s*labs/i, "ELEVENLABS"], [/higgsfield/i, "HIGGSFIELD"], [/notion/i, "NOTION"],
119 [/linear/i, "LINEAR"], [/slack/i, "SLACK"], [/gemini|google/i, "GOOGLE"], [/\baws\b|amazon/i, "AWS"], [/\bfly\.io|\bfly\b/i, "FLY"],
120 [/netlify/i, "NETLIFY"], [/heroku/i, "HEROKU"], [/digital\s*ocean/i, "DIGITALOCEAN"], [/replicate/i, "REPLICATE"], [/groq/i, "GROQ"],
121 [/perplexity/i, "PERPLEXITY"], [/mistral/i, "MISTRAL"], [/cohere/i, "COHERE"], [/hugging\s*face/i, "HF"], [/\bnpm\b/i, "NPM"],
122 [/pypi/i, "PYPI"], [/telegram/i, "TELEGRAM"], [/discord/i, "DISCORD"], [/bunny/i, "BUNNY"], [/podia/i, "PODIA"], [/zapier/i, "ZAPIER"],
123 [/make\.com/i, "MAKE"], [/deepseek/i, "DEEPSEEK"], [/zoom/i, "ZOOM"], [/meta\b|facebook/i, "META"], [/namecheap/i, "NAMECHEAP"],
124 [/godaddy/i, "GODADDY"], [/porkbun/i, "PORKBUN"], [/doppler/i, "DOPPLER"], [/azure/i, "AZURE"], [/firebase/i, "FIREBASE"],
125];
126
127// ---------------------------------------------------------------------------
128
129/**
130 * In-memory map between names and the real values. Lives in the hook module's
131 * own environment for the length of the session; never written to disk, to
132 * `$.store` or to `$.state`.
133 */
134export class Vault {
135 constructor() {
136 /** @type {Map<string, string>} name (no $) to value */
137 this.byName = new Map();
138 /** @type {Map<string, string>} value to placeholder (with $) */
139 this.byValue = new Map();
140 /** @type {Map<string, string>} name to the environment-variable name a shell can read */
141 this.envAlias = new Map();
142 /** @type {Map<string, "prompt"|"inbox"|"output">} where each value came from */
143 this.sources = new Map();
144 this.slotsUsed = { KEY: 0, TOKEN: 0, PASSWORD: 0, SECRET: 0 };
145 }
146
147 /**
148 * Whether `name` may be exported into tool environments. A value the user pasted or dropped in
149 * the inbox is theirs to hand to tools. A value a tool merely printed (a test fixture, a stale
150 * key in an old file) is only ever masked: exporting it under a real name such as GITHUB_TOKEN
151 * would override the person's working credential for every process that follows.
152 */
153 isExported(name) {
154 return this.sources.get(name) !== "output";
155 }
156
157 sourceOf(name) {
158 return this.sources.get(name);
159 }
160
161 get size() {
162 return this.byName.size;
163 }
164
165 /** Whether `name` is one hooks/env-table.js can set literally. */
166 static isEnvName(name) {
167 return ENV_VOCABULARY.has(name);
168 }
169
170 /**
171 * Stores `value` under `base` (or `base_2`, `base_3` when the base holds another
172 * value) and returns its placeholder, `$NAME`. A value already stored keeps its name.
173 */
174 stash(value, base, source = "prompt") {
175 const existing = this.byValue.get(value);
176 if (existing) {
177 // A value the user later pastes themselves becomes theirs to export.
178 const name = existing.slice(1);
179 if (source !== "output" && this.sources.get(name) === "output") this.sources.set(name, source);
180 return existing;
181 }
182 let name = cleanName(base) || "SAFE_KEY";
183 if (/^SAFE_(?:KEY|TOKEN|PASSWORD|SECRET)$/.test(name)) name = this.nextSlot(name.slice(5)) ?? name;
184 let candidate = name;
185 for (let n = 2; this.byName.has(candidate); n++) candidate = name + "_" + n;
186 this.byName.set(candidate, value);
187 this.byValue.set(value, "$" + candidate);
188 this.sources.set(candidate, source);
189 this.envAlias.set(candidate, Vault.isEnvName(candidate) ? candidate : this.nextSlot("KEY") ?? candidate);
190 return "$" + candidate;
191 }
192
193 /** The next free literal slot of a kind (`SAFE_KEY_3`), or undefined when the kind is full. */
194 nextSlot(kind) {
195 const max = GENERIC_SLOTS[kind];
196 if (!max) return undefined;
197 for (let i = this.slotsUsed[kind] + 1; i <= max; i++) {
198 const name = "SAFE_" + kind + "_" + i;
199 if (!this.byName.has(name) && !Array.from(this.envAlias.values()).includes(name)) {
200 this.slotsUsed[kind] = i;
201 return name;
202 }
203 }
204 return undefined;
205 }
206
207 placeholderFor(value) {
208 return this.byValue.get(value);
209 }
210
211 valueFor(name) {
212 return this.byName.get(name.replace(/^\$\{?|\}$/g, ""));
213 }
214
215 /** The environment-variable name a shell resolves for `name` (itself, or a SAFE_KEY_n slot). */
216 envNameFor(name) {
217 return this.envAlias.get(name) ?? name;
218 }
219
220 /** Bare names, no `$`. */
221 names() {
222 return Array.from(this.byName.keys());
223 }
224
225 /** Names with `$`: what the model sees and writes. */
226 placeholders() {
227 return this.names().map((n) => "$" + n);
228 }
229
230 /** `[name, value]` pairs. */
231 entries() {
232 return Array.from(this.byName.entries());
233 }
234
235 /** Every stored value, longest first. */
236 values() {
237 return Array.from(this.byValue.keys()).sort((a, b) => b.length - a.length);
238 }
239
240 forget() {
241 this.byName.clear();
242 this.byValue.clear();
243 this.envAlias.clear();
244 this.sources.clear();
245 this.slotsUsed = { KEY: 0, TOKEN: 0, PASSWORD: 0, SECRET: 0 };
246 }
247
248 /**
249 * Puts real values back only where a placeholder stands as a VALUE: after `=` or `:` (an .env
250 * line, a JSON or YAML field), after `Bearer`, after a `--flag`, or as the whole string. A
251 * placeholder named in prose ("the key is shown as $NAME") is left alone, so documentation and
252 * source code written by the model keep the name. For every tool that is not a shell.
253 */
254 restoreValues(text) {
255 if (this.byName.size === 0 || typeof text !== "string" || text.indexOf("$") === -1) return text;
256 const names = this.names().sort((a, b) => b.length - a.length);
257 const alt = names.map(escapeRe).join("|");
258 const whole = text.match(new RegExp("^\\s*\\$\\{?(" + alt + ")\\}?\\s*$"));
259 if (whole) return this.byName.get(whole[1]);
260 const re = new RegExp("([=:]\\s*[\"']?|[Bb]earer\\s+|--?[A-Za-z][\\w-]*(?:=|\\s+)[\"']?)(?:\\$\\{(" + alt + ")\\}|\\$(" + alt + ")(?![A-Za-z0-9_]))", "g");
261 return text.replace(re, (all, lead, a, b) => lead + this.byName.get(a ?? b));
262 }
263
264 /**
265 * Puts real values back where a placeholder appears, `$NAME` or `${NAME}`, whole
266 * names only, longest name first so `$X_2` is never eaten by `$X`.
267 */
268 restore(text) {
269 if (this.byName.size === 0 || typeof text !== "string" || text.indexOf("$") === -1) return text;
270 let out = text;
271 for (const name of this.names().sort((a, b) => b.length - a.length)) {
272 const value = this.byName.get(name);
273 const re = new RegExp("\\$\\{" + name + "\\}|\\$" + name + "(?![A-Za-z0-9_])", "g");
274 if (re.test(out)) out = out.replace(re, () => value);
275 }
276 return out;
277 }
278
279 /**
280 * Rewrites `$NAME` to the form a shell reads from its environment: `$ALIAS` for
281 * Bash, `$env:ALIAS` for PowerShell. Placeholders inside single quotes, which no
282 * shell expands, take the real value instead.
283 */
284 restoreForShell(text, shell) {
285 if (this.byName.size === 0 || typeof text !== "string" || text.indexOf("$") === -1) return text;
286 const names = this.names().sort((a, b) => b.length - a.length);
287 const spans = singleQuotedSpans(text);
288 let out = "";
289 let last = 0;
290 const re = new RegExp("\\$\\{(" + names.map(escapeRe).join("|") + ")\\}|\\$(" + names.map(escapeRe).join("|") + ")(?![A-Za-z0-9_])", "g");
291 let m;
292 while ((m = re.exec(text)) !== null) {
293 const name = m[1] ?? m[2];
294 const quoted = spans.some(([s, e]) => m.index >= s && m.index < e);
295 let replacement;
296 // Single quotes expand nothing, and a value that was never exported has no variable to expand.
297 if (quoted || !this.isExported(name)) replacement = this.byName.get(name);
298 else if (shell === "powershell") replacement = m[1] ? "${env:" + this.envNameFor(name) + "}" : "$env:" + this.envNameFor(name);
299 else replacement = m[1] ? "${" + this.envNameFor(name) + "}" : "$" + this.envNameFor(name);
300 out += text.slice(last, m.index) + replacement;
301 last = m.index + m[0].length;
302 }
303 return out + text.slice(last);
304 }
305
306 /** Replaces any stored value that appears in `text` (a tool result, a drawn row) with its placeholder. */
307 scrub(text) {
308 if (this.byName.size === 0 || typeof text !== "string") return text;
309 let out = text;
310 for (const value of this.values()) {
311 const placeholder = this.byValue.get(value);
312 if (out.indexOf(value) !== -1) out = out.split(value).join(placeholder);
313 // The JSON-escaped form (a PEM key's newlines, a quote inside a password).
314 const escaped = JSON.stringify(value).slice(1, -1);
315 if (escaped !== value && out.indexOf(escaped) !== -1) out = out.split(escaped).join(placeholder);
316 // The URL-encoded form (a password inside a connection string pasted into a URL).
317 const encoded = safeEncode(value);
318 if (encoded !== value && out.indexOf(encoded) !== -1) out = out.split(encoded).join(placeholder);
319 }
320 return out;
321 }
322}
323
324function safeEncode(value) {
325 try {
326 return encodeURIComponent(value);
327 } catch {
328 return value;
329 }
330}
331
332function escapeRe(s) {
333 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
334}
335
336/** `[start, end)` spans of single-quoted text in a shell command. Naive, no nesting. */
337function singleQuotedSpans(text) {
338 const spans = [];
339 let open = -1;
340 let inDouble = false;
341 for (let i = 0; i < text.length; i++) {
342 const ch = text[i];
343 if (ch === "\\" && open === -1) {
344 i++;
345 continue;
346 }
347 if (ch === '"' && open === -1) inDouble = !inDouble;
348 if (ch === "'" && !inDouble) {
349 if (open === -1) open = i;
350 else {
351 spans.push([open, i + 1]);
352 open = -1;
353 }
354 }
355 }
356 return spans;
357}
358
359// ---------------------------------------------------------------------------
360// Names.
361
362/** The literal environment names hooks/env-table.js knows how to set. Kept in step with that file. */
363export const ENV_VOCABULARY = new Set([
364 "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GITHUB_TOKEN", "GITLAB_TOKEN",
365 "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "GOOGLE_API_KEY", "GOOGLE_OAUTH_TOKEN", "SLACK_TOKEN",
366 "STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET", "RESEND_API_KEY",
367 "SUPABASE_ACCESS_TOKEN", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SECRET_KEY", "SUPABASE_PUBLISHABLE_KEY",
368 "SUPABASE_ANON_KEY", "APIFY_TOKEN", "HF_TOKEN", "NPM_TOKEN", "VERCEL_TOKEN", "CLOUDFLARE_API_TOKEN",
369 "TWILIO_AUTH_TOKEN", "TWILIO_API_KEY", "SENDGRID_API_KEY", "MAILGUN_API_KEY", "DIGITALOCEAN_TOKEN",
370 "LINEAR_API_KEY", "NOTION_TOKEN", "GROQ_API_KEY", "PERPLEXITY_API_KEY", "REPLICATE_API_TOKEN",
371 "FLY_API_TOKEN", "TELEGRAM_BOT_TOKEN", "DOPPLER_TOKEN", "PYPI_TOKEN", "BRIGHTDATA_API_KEY",
372 "KIE_API_KEY", "ELEVENLABS_API_KEY", "HIGGSFIELD_API_KEY", "DEEPSEEK_API_KEY", "MISTRAL_API_KEY",
373 "COHERE_API_KEY", "GEMINI_API_KEY", "DATABASE_URL", "PRIVATE_KEY", "JWT",
374 ...Array.from({ length: GENERIC_SLOTS.KEY }, (_, i) => "SAFE_KEY_" + (i + 1)),
375 ...Array.from({ length: GENERIC_SLOTS.TOKEN }, (_, i) => "SAFE_TOKEN_" + (i + 1)),
376 ...Array.from({ length: GENERIC_SLOTS.PASSWORD }, (_, i) => "SAFE_PASSWORD_" + (i + 1)),
377 ...Array.from({ length: GENERIC_SLOTS.SECRET }, (_, i) => "SAFE_SECRET_" + (i + 1)),
378]);
379
380function cleanName(base) {
381 return String(base ?? "")
382 .toUpperCase()
383 .replace(/[^A-Z0-9]+/g, "_")
384 .replace(/^_+|_+$/g, "")
385 .replace(/^(\d)/, "_$1")
386 .slice(0, 48);
387}
388
389/** `BRIGHTDATA_API_KEY` from `BRIGHTDATA_API_KEY=`, `X_API_KEY` from `x-api-key:`, `SAFE_KEY` from a bare `key:`. */
390export function nameFromLabel(label) {
391 const name = cleanName(label);
392 if (!name) return "SAFE_KEY";
393 const bare = /^(?:API_KEY|APIKEY|KEY|ACCESS_KEY|SECRET_KEY|TOKEN|ACCESS_TOKEN|AUTH_TOKEN|API_TOKEN|SECRET|CLIENT_SECRET|PASSWORD|PASSWD|PASSPHRASE|PWD|CREDENTIALS?|BEARER)$/;
394 if (bare.test(name)) return "SAFE_" + kindOf(name);
395 return name;
396}
397
398function kindOf(word) {
399 const w = word.toUpperCase();
400 if (/PASS/.test(w)) return "PASSWORD";
401 if (/TOKEN|BEARER/.test(w)) return "TOKEN";
402 if (/SECRET/.test(w)) return "SECRET";
403 return "KEY";
404}
405
406/** A vendor named within `window` characters before `pos`, as a name prefix. */
407function vendorBefore(text, pos, window = 120) {
408 const before = text.slice(Math.max(0, pos - window), pos);
409 for (const [re, vendor] of VENDORS) if (re.test(before)) return vendor;
410 return undefined;
411}
412
413function contextName(text, pos, credWord) {
414 const vendor = vendorBefore(text, pos);
415 const kind = kindOf(credWord);
416 if (vendor) return vendor + "_" + (kind === "KEY" ? "API_KEY" : kind);
417 return "SAFE_" + kind;
418}
419
420// ---------------------------------------------------------------------------
421// Detection.
422
423/** True for text the engine wrote rather than the user: leave it untouched. */
424export function isEngineMessage(text) {
425 return ENGINE_MESSAGE.test(text);
426}
427
428/** Shannon entropy in bits per character. */
429export function entropy(value) {
430 const counts = new Map();
431 for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
432 let bits = 0;
433 for (const n of counts.values()) {
434 const p = n / value.length;
435 bits -= p * Math.log2(p);
436 }
437 return bits;
438}
439
440/**
441 * Whether a bare token with no known prefix looks like a credential rather than a
442 * slug, a git SHA or a word. Tuned to leave article slugs and commit hashes alone.
443 */
444export function looksRandom(token) {
445 if (ENGINE_ID_PREFIXES.test(token)) return false;
446 if (NOT_A_VALUE.test(token)) return false;
447 const hasLetter = /[A-Za-z]/.test(token);
448 const hasDigit = /\d/.test(token);
449 const hexOnly = /^[0-9a-f]+$/i.test(token);
450 const separators = (token.match(/[-_]/g) ?? []).length;
451 const bits = entropy(token);
452 if (hexOnly) {
453 // A 40 character lowercase hex token is a git commit hash, not a secret.
454 if (token.length === 40 && token === token.toLowerCase()) return false;
455 return token.length >= 32 && bits > 3.3;
456 }
457 if (!hasLetter || !hasDigit) return false;
458 if (separators >= 2) return bits >= 4.6;
459 return bits >= 4.0;
460}
461
462/** Whether a token near a credential word is plausibly the credential itself. */
463function plausibleContextValue(token) {
464 if (UUID.test(token)) return true;
465 if (ENGINE_ID_PREFIXES.test(token) || NOT_A_VALUE.test(token)) return false;
466 if (DATE_LIKE.test(token) || VERSION_LIKE.test(token)) return false;
467 if (UPPER_IDENT.test(token) && (token.match(/\d/g) ?? []).length < 6) return false; // an env var NAME
468 if (!/[A-Za-z]/.test(token) || !/\d/.test(token)) return false;
469 if (/^[0-9a-f]{40}$/.test(token)) return false; // git SHA
470 return true;
471}
472
473function spans(text, re) {
474 const out = [];
475 re.lastIndex = 0;
476 let m;
477 while ((m = re.exec(text)) !== null) out.push([m.index, m.index + m[0].length]);
478 return out;
479}
480
481function inside(pos, ranges) {
482 for (const [s, e] of ranges) if (pos >= s && pos < e) return true;
483 return false;
484}
485
486/**
487 * Every sensitive value in `text`, non-overlapping, in document order.
488 * `knownOnly` keeps to rules 1 and 2 (shapes and labels): for tool output and
489 * assistant text, where random-looking ids are everywhere.
490 * @returns {Array<{start:number,end:number,value:string,name:string,rule:string}>}
491 */
492export function findSensitive(text, options = {}) {
493 const hits = [];
494 if (typeof text !== "string" || text.length < 8 || isEngineMessage(text)) return hits;
495 const knownOnly = options.knownOnly === true;
496 const pathAdjacent = (start, end) => /[\\/]/.test(text[start - 1] ?? "") || /[\\/]/.test(text[end] ?? "");
497 const push = (start, end, name, rule) => {
498 if (end - start < 8) return;
499 const value = text.slice(start, end);
500 if (NOT_A_VALUE.test(value)) return;
501 if (rule !== "shape" && (BAD_VALUE_CHARS.test(value) || pathAdjacent(start, end))) return;
502 for (const h of hits) if (start < h.end && end > h.start) return; // overlap: first wins
503 hits.push({ start, end, value, name, rule });
504 };
505
506 for (const { re, name } of KNOWN) for (const [s, e] of spans(text, re)) push(s, e, name, "shape");
507
508 let m;
509 ASSIGNED.lastIndex = 0;
510 while ((m = ASSIGNED.exec(text)) !== null) {
511 const value = m[2];
512 if (/^https?:\/\//i.test(value) && !/:\/\/[^/@\s]+:[^/@\s]+@/.test(value)) continue; // a URL, not a credential
513 const start = m.index + m[0].length - value.length;
514 push(start, start + value.length, nameFromLabel(m[1]), "label");
515 }
516 BEARER.lastIndex = 0;
517 while ((m = BEARER.exec(text)) !== null) {
518 const start = m.index + m[0].length - m[1].length;
519 push(start, start + m[1].length, "SAFE_TOKEN", "bearer");
520 }
521 FLAG.lastIndex = 0;
522 while ((m = FLAG.exec(text)) !== null) {
523 const start = m.index + m[0].length - m[2].length;
524 push(start, start + m[2].length, nameFromLabel(m[1]), "flag");
525 }
526
527 if (knownOnly) return hits.sort((a, b) => a.start - b.start);
528
529 const urls = spans(text, URL_SPAN);
530
531 CONTEXT_TOKEN.lastIndex = 0;
532 while ((m = CONTEXT_TOKEN.exec(text)) !== null) {
533 if (inside(m.index, urls)) continue;
534 const token = m[0];
535 if (!plausibleContextValue(token)) continue;
536 const before = text.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
537 const cred = before.match(CRED_WORD);
538 if (!cred) continue;
539 // The word must be the last credential word in the window, so the token follows it.
540 const lastCred = [...before.matchAll(new RegExp(CRED_WORD.source, "gi"))].pop();
541 push(m.index, m.index + token.length, contextName(text, m.index, lastCred?.[0] ?? cred[0]), "context");
542 }
543
544 CANDIDATE.lastIndex = 0;
545 while ((m = CANDIDATE.exec(text)) !== null) {
546 if (inside(m.index, urls)) continue;
547 if (looksRandom(m[0])) push(m.index, m.index + m[0].length, vendorBefore(text, m.index) ? vendorBefore(text, m.index) + "_API_KEY" : "SAFE_KEY", "entropy");
548 }
549
550 return hits.sort((a, b) => a.start - b.start);
551}
552
553/** Rewrites `text` with every hit replaced by its placeholder from `vault`. */
554export function redactText(text, vault, options = {}) {
555 const hits = findSensitive(text, options);
556 if (hits.length === 0) return { text, hits };
557 let out = "";
558 let last = 0;
559 const source = options.source ?? "prompt";
560 for (const h of hits) {
561 out += text.slice(last, h.start) + vault.stash(h.value, h.name, source);
562 last = h.end;
563 }
564 out += text.slice(last);
565 return { text: out, hits };
566}
567
568/** Display only: stored values become their names; anything else that looks secret becomes [hidden]. */
569export function forDisplay(text, vault, options = {}) {
570 if (typeof text !== "string") return text;
571 const scrubbed = vault.scrub(text);
572 const hits = findSensitive(scrubbed, options);
573 if (hits.length === 0) return scrubbed;
574 let out = "";
575 let last = 0;
576 for (const h of hits) {
577 out += scrubbed.slice(last, h.start) + HIDDEN;
578 last = h.end;
579 }
580 return out + scrubbed.slice(last);
581}
582
583/** Applies `fn` to every string inside a JSON-like value, preserving structure. Returns the same object when nothing changed. */
584export function mapStrings(value, fn) {
585 if (typeof value === "string") return fn(value);
586 if (Array.isArray(value)) {
587 let changed = false;
588 const next = value.map((item) => {
589 const mapped = mapStrings(item, fn);
590 if (mapped !== item) changed = true;
591 return mapped;
592 });
593 return changed ? next : value;
594 }
595 if (value && typeof value === "object") {
596 let changed = false;
597 const next = {};
598 for (const [k, v] of Object.entries(value)) {
599 const mapped = mapStrings(v, fn);
600 if (mapped !== v) changed = true;
601 next[k] = mapped;
602 }
603 return changed ? next : value;
604 }
605 return value;
606}
607
608/**
609 * Rewrites the blocks of one stored row (session.append's `message.content`): text blocks through
610 * `cleanText`, a tool_result's string or text-block content through `cleanResult`. Returns the same
611 * array when nothing changed; every block it does not author is kept by identity.
612 */
613export function rewriteContent(content, cleanText, cleanResult) {
614 if (!Array.isArray(content)) return content;
615 let changed = false;
616 const out = content.map((block) => {
617 if (!block || typeof block !== "object") return block;
618 if (block.type === "text" && typeof block.text === "string") {
619 const t = cleanText(block.text);
620 if (t === block.text) return block;
621 changed = true;
622 return { ...block, text: t };
623 }
624 if (block.type === "tool_result") {
625 if (typeof block.content === "string") {
626 const t = cleanResult(block.content);
627 if (t === block.content) return block;
628 changed = true;
629 return { ...block, content: t };
630 }
631 if (Array.isArray(block.content)) {
632 let inner = false;
633 const mapped = block.content.map((b) => {
634 if (b && b.type === "text" && typeof b.text === "string") {
635 const t = cleanResult(b.text);
636 if (t !== b.text) {
637 inner = true;
638 return { ...b, text: t };
639 }
640 }
641 return b;
642 });
643 if (!inner) return block;
644 changed = true;
645 return { ...block, content: mapped };
646 }
647 }
648 return block;
649 });
650 return changed ? out : content;
651}
652
653/** The standing note the model reads: what a placeholder is and which are live. */
654export function usageNote(placeholders) {
655 const live = placeholders.length > 0 ? "Stored and usable now: " + placeholders.join(", ") + "." : "No key is stored yet.";
656 return (
657 "Safe Keys is active in this session. A credential the user pastes is stored outside the conversation and shown as an " +
658 "environment-variable name such as $OPENROUTER_API_KEY or $SAFE_KEY_1. " + live + " " +
659 "Use the name exactly as written wherever the value is needed. In a Bash or PowerShell command it is a real environment " +
660 "variable (write it unquoted or in double quotes, never single quotes); in any other tool argument or file content the real " +
661 "value is substituted at the moment the tool runs. Never ask the user to paste the value again, never guess it, never print it. " +
662 "If a request using the name fails to authenticate, the key itself is wrong or expired; say so."
663 );
664}
665