SLOPSHOPPER

safe-keys

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

newpanebandspinnerrowsguard
v1.0.0MITupdated 2026-10-09markfulton/safe-keys
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · safe-keys
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ safe-keys │ ● safe-keys: safe-keys: session.start: home ok, plugin root ok, transcr│ Safe Keys stored $DATABASE_URL, │ ● safe-keys: safe-keys: command: /safe-keys registered │ $STRIPE_SECRET_KEY │ ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /safe-keys ⎿ safe-keys: Safe Keys is on. ⎿ safe-keys: Stored this session: $DATABASE_URL, $STRIPE_SECRET_KEY ⎿ safe-keys: Environment export: not needed yet ⎿ safe-keys: Transcript: /Users/dev/.claude/projects/app/preview.jsonl ⎿ safe-keys: Diag log: /Users/dev/.claude/safe-keys-diag.log ⎿ safe-keys: Inbox: drop a key in /Users/dev/.claude/safe-keys-inbox.txt to store it without pasting. ● safe-keys: safe-keys: 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. ● safe-keys: safe-keys: session.append rewrote a row at door tool-result ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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> &nbsp;&bull;&nbsp; <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> &nbsp;&bull;&nbsp; <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> &nbsp;&bull;&nbsp; <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> &nbsp;&bull;&nbsp; <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>

Safe Keys

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).

How it works

<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 landWhat Safe Keys does
The prompt box, on pasteReplaced before the text is queued or drawn
The message the model readsReplaced before it enters the session
The transcript file on diskEvery 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 toolThe value is substituted into the arguments at the moment the tool runs
Tool output coming backStored values become names again. A key a tool prints, from a cat .env say, is stored and named too
The screenEvery 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.

Install in two steps

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>

Using it

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.
  • Drop a key in ~/.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.

What it does not do

  • A key typed character by character is caught at submit, not while typing.
  • Inside single quotes no shell expands a variable, so there the value itself goes in. Double quotes are the clean path.
  • Detection is heuristic. A low-entropy custom secret with no label and no credential word near it is not caught. Use the inbox for those.
  • Function hooks may change between Claude Code releases. The validator tells you before a session does.

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 .

License

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.

Source 2 files
hooks/safe-keys.ts 727 lines
1// 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};
727
lib/detect.js 665 lines
1// 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