Hides secrets in tool results before Claude reads them: the secret values of every .env and .env.* from your session's folder up to the drive root, and…

Four small Claude Code mods, free to use under the MIT licence. A mod is a plugin made of function hooks: Claude Code calls it at every step (a tool call, a slash command, a redraw), and it can answer, change or watch that step.
| Mod | What it does | Command |
|---|---|---|
| secret-guard | Reads every .env and .env.* from your session's folder up to the drive root when a session starts and hides their secret values (keys named like PASSWORD, SECRET, TOKEN, API_KEY) in every tool result before Claude reads it, along with anything that looks like a secret on its own: KEY=VALUE under such a name, the password in postgres://user:REDACTED@host, private keys, JWTs, AWS keys, Kubernetes Secret data. A secret reaches Claude as ‹hidden: DB_PASSWORD›. It tells Claude, in every conversation, to use $DB_PASSWORD instead of printing the value, and it refuses the few commands that would print a whole env file or a decrypted secret. | /secret-guard lists the protected files and key names |
| plan-meter | A one-line band above the prompt that says how far your plan is: plan ▸ Ship the export · phases 1/3 · steps 3/6 (50%) · now: API · Claude's tasks 2/5. It reads your plan file (and the phase files it links to) in many formats, and Claude's own task list. It updates when a file changes. | /plan-meter on / /plan-meter off shows or hides the band; /plan-meter opens a pane with the details; /plan-meter docs/roadmap.md picks a file |
| done-gate | When Claude marks a task done while code it changed has not been tested since, it tells Claude, in the tool result Claude reads, and you, in a toast. A band shows the last test run: done-gate ▸ tests ✔ passed 4 min ago · 2 files changed since. It warns; it never blocks. | /done-gate on / /done-gate off shows or hides the band; /done-gate lists the changed files and the last test command |
| context-meter | A band above the prompt with what fills the context window, by category and in /context's colours (context ▸ 90k of 1M · 9% · compacts at 987k), and a countdown to when the prompt cache expires, which turns from green through amber to red: cache ▸ 41:07 left (1h TTL, assumed: subscription). A Compact button (or c while the band has focus) runs the same compaction as /compact. | /context-meter shows or hides the band (on / off to set it); the button |
Tested on Claude Code 2.1.291 on Windows 11. Mods are an early-access feature, so the API can change between versions; if a mod stops loading after an update, check claude plugin validate on its folder.
git clone https://github.com/vumichien/claude-code-mods-kit.git
claude --plugin-dir claude-code-mods-kit/plugins/plan-meter
--plugin-dir loads the mod for that session only. Repeat the flag to load more than one.
claude plugin marketplace add vumichien/claude-code-mods-kit
claude plugin install secret-guard@chien-mods
claude plugin install plan-meter@chien-mods
claude plugin install done-gate@chien-mods
claude plugin install context-meter@chien-mods
Start a new session afterwards. To remove one: claude plugin uninstall plan-meter@chien-mods.
The bands start hidden. plan-meter, done-gate and context-meter each draw a band above the prompt, but only when you ask: type /plan-meter on, /done-gate on or /context-meter on when you want to see it, and off to hide it again (a bare /context-meter flips it). The choice lasts for the session. Hiding a band changes only what is drawn: the plan is still read, done-gate still tells Claude when a task is marked done too early, and context-meter still measures, so a band is current the moment it comes back. To have a band from the start, set that mod's band option to on.
Every option has a default, so all four mods work without any. plan-meter, done-gate and context-meter share one: band, off (default) or on, whether the band shows before you switch it with its command. To change one, use /plugin configure <name>@chien-mods inside Claude Code, pass --config key=value to claude plugin install, or pipe a JSON object to claude plugin configure <name>@chien-mods --values-stdin. With --plugin-dir, put them in a settings file: --settings '{"pluginConfigs":{"done-gate":{"options":{"testCommands":"make ci"}}}}'.
secret-guard
mode: value (default) hides secrets in results and refuses the commands listed below. command instead refuses any call whose command or path names a protected env file, except to load it (source .env, --env-file .env), without reading values or changing results; it is simpler, but it blocks harmless commands and misses reads that don't name the file.secretFiles (default .env, .env.*, !*.example): comma-separated globs of the env files to read. A file name is looked for in the session's folder and every folder above it, up to the drive root. A path is read where it points: ~/vault//.env (from your home folder; goes up to 8 folders deep and skips node_modules, .git, .venv, venv and __pycache__), an absolute path, or one relative to the session's folder. !glob leaves files out. Each file must be in .env format.secretKeys: comma-separated key names to treat as secrets on top of the built-in rule. The rule: a key is a secret when a part of its name (split at _, -, . and camelCase) is PASS, PASSWD, PASSWORD, PW, PWD, SECRET, TOKEN, KEY, DSN, CREDENTIAL or PRIVATE, or ends with one of the first seven (APIKEY, DBPASS). So DB_PASSWORD, apiKey and AWS_SECRET_ACCESS_KEY are secrets; DB_NAME, DB_HOST, ACCOUNT_ID, MAX_TOKENS, TOKENIZER_PATH and the shell's own PWD are not.identifierKeys: comma-separated keys that match the rule but hold names, not secrets (KMS_KEY_ID, SSH_KEY_NAME). They are never hidden.Values shorter than 8 characters are never hidden (except the password in a URL such as postgres://user:REDACTED@host, which is always hidden), so PW=1 or TOKEN_TTL=60 does not mask every 1 or 60. The values of non-secret keys are never hidden at all, so database names, hosts, users and account ids stay readable.
In value mode it refuses these commands, each time naming a way to do the same without printing the secret: cat, type, Get-Content, less, more, head, tail or bat of a protected file (cat .env | cut -d= -f1 is allowed, it prints names); a bare env, printenv, set or Get-ChildItem env:; and, unless the output goes to a file or a variable (> out.json, VALUE=$(...)), aws ssm ... --with-decryption, aws secretsmanager get-secret-value and kubectl get secret ... -o yaml|json. It never refuses loading a file: source .env, . .env, set -a, --env-file .env.
Every conversation starts with a short # secret-guard note to Claude: the protected files and key names (names only), what ‹hidden: NAME› means, and the convention (reference $NAME, let scripts load the env file, never print a secret, report only whether a command worked). So you no longer need to remind each session. The note is rebuilt after a compaction or /clear. Messages Claude sends to another agent or session (SendMessage) are scrubbed like tool results. So is what Claude Code attaches to a message on its own, which never passes through a tool call: a file Claude read, attached again after a compaction; a file changed on disk; a file you @-mention; a settings hook's output.
plan-meter
plan: comma-separated paths, relative to the project, tried in order; the first one that matches a file wins. A * in any part matches anything, and among several matches the most recently changed file wins. Default: plans/*/plan.md, PLAN.md, plan.md, TODO.md, TASKS.md, ROADMAP.md, todo.txt, TODO.org.refreshSeconds (default 15, at least 5): how often the plan is read again, so an edit you make in your own editor shows up too. Edits Claude makes show up at once.The band shows only when there is a plan or a task list. The pane draws in the terminal and the desktop app; under claude -p, /plan-meter answers with the band's line. (The command was /plan up to 0.1.0; Claude Code 2.1.294 has a built-in /plan, which refused the name.)
context-meter
cacheTtl: auto (default), 5m or 1h. A mod cannot read the cache lifetime Claude Code asks for, so auto follows Claude Code's defaults: one hour on a Claude subscription (the session reports rate-limit windows), five minutes with an API key or a cloud provider. Set it when you know better: you set promptCacheTtl or ENABLE_PROMPT_CACHING_1H, or you are drawing on usage credits, where Claude Code drops to five minutes. The band always says which lifetime it assumed and why.breakdown: summary (default) estimates the categories locally and sends nothing. full counts them with the token-count API after every turn, as /context does: more exact, one request per tool and memory file.Every request of the main conversation that hits the cache resets its timer, so the clock restarts at each model request, from the moment it was sent, not only when a turn ends. While Claude works the band says the cache is being kept warm; the countdown runs between turns, from the last request. A subagent's requests have caches of their own and are left out. After a compaction it starts again with the next message. The button is hidden while a turn runs and before the conversation's first reply, when Claude Code refuses a compaction ("Not enough messages to compact"); if a compaction is refused or a hook vetoes it, the band says why, and so does a toast. If you set CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, which can only bring auto-compaction earlier, the header shows that point and marks it as your setting (compacts at 500k (your 50% setting)), since the breakdown Claude Code returns may still give its default; that one environment variable is all context-meter reads. In the desktop app and the IDE extensions, which run Claude Code through its SDK, Claude Code cannot compact between turns yet, so there the button runs /compact as if you had typed it. Below the bar, every category and the free part get a coloured entry with their tokens and share of the window, wrapped onto as many rows as they need. Compacting is a model call: the band costs no tokens, the button does. With little room above the prompt the band keeps two rows, the fill and the cache clock.
done-gate
testCommands: comma-separated commands that run your tests, added to the usual runners. Example: ./scripts/check.sh, make ci. Each one counts when it is the command itself, with or without arguments, so check does not match git checkout.ignore (default .md,.mdx,.markdown,.txt,.rst,.adoc,.org): file endings whose changes need no test run.The usual runners it knows: pytest, python -m pytest or unittest, npm/pnpm/yarn/bun test or run test, vitest, jest, mocha, go test, cargo test, cargo nextest, mvn test or verify, gradle test, dotnet test, rspec, phpunit, mix test, swift test, ctest, make test or check, tox, nox, deno test, claude plugin test. A runner counts when it is a command in the line, after &&, ; or cd api && and behind FOO=1, npx, uv run or poetry run; a runner named inside an argument (echo pytest, git commit -m "fix pytest") does not.
plan-meter does not ask you to write your plan its way. It recognises these, alone or mixed in one file:
| Format | Example | What it counts | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Checklist | - [x] write the schema | One step per item. Bullets -, *, +, 1., 1). Marks: x or X done; / or ~ in progress (Obsidian); - cancelled, left out of the count; a space, >, <, ! or ? still to do. | ||||||||
| Status table | `\ | Phase \ | Name \ | Status \ | with a row \ | 2 \ | API \ | 🚧 In progress \ | ` | One phase per row. The status column is the one headed Status, State, Progress, Done or Done?, Trạng thái, Tình trạng, ステータス or 状態. The name comes from a Name, Title, Task, Item, What, Deliverable, Tên or Công việc column, else Phase, Step, Milestone, Stage or Giai đoạn, else the first cell with words in it (so a Phase column holding only 2 is skipped). |
| Headings as phases | ## Phase 2: API (in progress), ## Step 3 ✅ | Used when the file has no status table. A heading named Phase, Step, Stage, Milestone, Sprint, Part or Task with a number (also Giai đoạn 1, Bước 2, フェーズ1) is a phase. Its status comes from a mark in it, a status at its end ((done), [WIP], — done, : in progress), the checklist beneath it (all ticked is done, some ticked is in progress), or the phase file it links to. Any other heading counts only when it ends with a status alone: ## Setup (done). | ||||||||
| Linked phase files | Phase 1 | Links in the plan (in text, tables or headings) to Markdown files whose name starts with phase, step, stage, milestone, sprint, part or task and goes on with a number or a dash: phase-01-schema.md, phase1.md, steps.md, part_2.md, but not department.md. Relative to the plan's folder, up to 30; a #section part is ignored, and two links to one file count it once. Their checklists add to the steps. A phase whose status the plan leaves blank or unknown takes the file's status from its frontmatter or status line, else from its checklist; a status the plan states, such as Pending, wins. A plan with no phases of its own takes one phase per linked file. | ||||||||
| YAML frontmatter | title: Ship the export / status: in_progress | The plan's title and its own status. | ||||||||
| Status line | Status: Draft, phase 3 next, Status: …, Status (2026-10-07): … | The plan's own status, shown as written when nothing in the file can be counted. | ||||||||
| org-mode | * TODO write the schema, ** DONE tests | One step per headline. DONE done; DOING, IN-PROGRESS, STARTED, WAITING, HOLD in progress; CANCELLED left out; TODO, NEXT to do. | ||||||||
| todo.txt | x 2026-10-01 call the bank | A file named todo.txt (or *.todo.txt): one step per line, x at the start is done. |
Status words it understands in tables, headings, status lines and frontmatter, in English, Vietnamese and Japanese. When a cell holds several, the first one wins, so Done (review pending) is done and Not started is to do.
[x]in-progress, in_progress), WIP, doing, ongoing, active, started, running, in review, reviewing, blocked, đang, đang làm, 進行中, 🚧 🔄 ⏳ ▶[ ]The title is the frontmatter title:, else the first # heading (a leading Plan: is dropped). Anything inside a fenced code block is skipped.
Claude's own tasks. When Claude keeps a task list (its TodoWrite, TaskCreate and TaskUpdate tools), the band adds Claude's tasks done/total, and the pane lists them. That list is the session's: it starts empty in a new session.
What it cannot read. A plan that says how far it is only in prose ("we finished the API last week") has nothing to count; the band then shows its status line if it has one. Headings named Phase with no status and no checklist beneath give no phase count, rather than a made-up 0 of N. On the 21 plan files in the author's own writing workspace, 10 gave a phase count, 8 a step count, 10 had only a status line to show, and 1 had nothing plan-meter could read.
pytest -q | tail -20, pytest; echo done, pytest || true), done-gate reads the runner's own summary line in the output instead: 5 passed, 1 failed, 18 pass … 0 fail, test result: ok, ok pkg, OK. Failure words win. With no summary to read, the run counts for nothing.python scripts/report.py, ./build.sh) clears that file, since running a script checks at least that it runs. Reading it (cat, git diff) does not.completed, or a TodoWrite item newly completed) while changed files are unchecked, Claude reads this beside the tool's result, and you see the same as a toast:done-gate: "Add the export" was marked done, but 1 code file was changed and no test has run in this session: src/export.py. Before you report this task as finished, run the tests that cover these files, or tell the user plainly that they were not tested and why.
sed -i, a code generator) or outside Claude Code, nor a test run sent to the background, whose result it cannot know.aws ssm get-parameter --query Parameter.Value --output text, a password file, a column of a CSV), is not recognised; that is why the commands above are refused. To stop Claude from reading a file at all, use permission deny rules such as Read(./.env), the sandbox, OS file permissions or a secrets manager.secretFiles names its path.# secret-guard note (so Claude tells you) and in /secret-guard, and it still refuses cat of that file. Fix the file, or leave it out with ! in secretFiles. A file it cannot read at all (no permission) makes it refuse every call in that session; claude plugin disable secret-guard@chien-mods turns it off.password: "hunter2-test" in a test), the values of a ConfigMap listed together with a Secret. When Claude then edits that file, the marker is turned back into the value, so the edit works. It leaves alone a value already masked (sk_****…), a key inside a quoted pattern (rg -c "API_SECRET=" deploy/*.ini) and an attribute assigned in code (self.api_key = config.service_key).‹hidden: NAME›. When an Edit, Write, MultiEdit or NotebookEdit carries that marker, secret-guard puts the real value back only when the target file already holds it, so a value is restored where it was and never copied into a new file. Otherwise the call is refused and Claude is told why. A marker in a Bash or PowerShell command that stands for a value is refused, never filled in. A marker that stands for no value secret-guard knows (a note or a README quoting the format, ‹hidden: NAME›) is plain text, written and run as it is; so is a marker the file already holds as text.aws_secret_access_key, SecretAccessKey) or when it sits on the same line as an access key id; a bare 40-character string elsewhere is not, since every git commit hash would match./secret-guard says why; if a check failed, or a secret sat in a result as a number it can't replace, /secret-guard counts the results it withheld.Mods are not sandboxed. A mod's hooks run with your permissions and can read files and start processes. These four are short; read them first. claude plugin validate plugins/<name> lists every hook a mod registers and every call it makes. None of the four starts a process or uses the network of its own. (context-meter's breakdown: full asks Claude Code to count tokens, which Claude Code does with the API; its Compact button asks Claude Code for a compaction, which is a model call.)
claude plugin validate and claude plugin test (plugins/<name>/tests/) and type-checks with tsc..env of fake canary values, a demo plan with two phase files, and a small Python file, real Claude Code sessions (2.1.291, haiku) ran with the three mods, once loaded by --plugin-dir and once installed from this repository with claude plugin marketplace add vumichien/claude-code-mods-kit:cat .env, Claude received two ‹hidden: …› markers and no canary value. Since 0.3.0 that command is refused before it runs, with the command that prints the key names only and the way to load the file without printing it; rerun on 2.1.295 with --plugin-dir, cat .env was refused and a following grep -r DEMO_ . reached Claude with two markers and no canary value;/plan (now /plan-meter) answered phases 0/2 · steps 1/4 (25%) before that session and steps 2/4 (50%) after it, with no model turn.echo, cat counted as running a file, and parsing and path cases) are fixed and each has a test.validate, tsc and its 26 tests, which drive its band, countdown and button against the engine's test host, and in one live terminal session (2.1.294, a 1M window): before /compact the band read 178k of 1M · 18%, against the 179,681 tokens Claude Code recorded for the compaction; once the compaction finished, before any new message, it read 98k of 1M · 10%, and the cache line had reset to starts with the next message.validate, tsc and its 47 tests, and in live sessions loaded with --plugin-dir (the debug log confirms it replaced the installed 0.2.0) in a throwawayhooks/register.ts 259 lines1import type { Register } from 'claude-code'
2
3import { namesProtectedFile, refusal } from './commands'
4import { DEFAULT_SECRET_FILES, findSecretFiles, isProtectedName, parseFileRules } from './files'
5import { MARKER, Vault, failClosed, fill, marker, mergeSecrets, nameSet, parseEnv, resolveMarkers } from './secrets'
6import type { Secret } from './secrets'
7
8// The input fields a person's first rule would look at for a file name.
9const INPUT_FIELDS = ['command', 'file_path', 'path', 'pattern', 'glob'] as const
10const SHELLS = new Set(['Bash', 'PowerShell'])
11// The context block's name: Claude reads it as `# secret-guard` in the first message of every conversation.
12const BLOCK = 'secret-guard'
13const MAX_NAMES = 80
14
15const baseName = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
16const hasMarker = (text: string) => new RegExp(MARKER.source).test(text)
17
18type Input = Record<string, unknown>
19type Texts = { path: string; texts: { text: string; isOld: boolean }[]; fill: (values: ReadonlyMap<string, string>) => Input }
20
21// The text fields of a call that writes a file, and how to put values back into them.
22function writerTexts(input: Input): Texts | undefined {
23 const str = (v: unknown) => (typeof v === 'string' ? v : '')
24 const swap = (fields: string[]) => (values: ReadonlyMap<string, string>) =>
25 ({ ...input, ...Object.fromEntries(fields.map(f => [f, fill(str(input[f]), values)])) })
26 switch (input.tool) {
27 case 'Edit':
28 return { path: str(input.file_path), texts: [{ text: str(input.old_string), isOld: true }, { text: str(input.new_string), isOld: false }], fill: swap(['old_string', 'new_string']) }
29 case 'Write':
30 return { path: str(input.file_path), texts: [{ text: str(input.content), isOld: false }], fill: swap(['content']) }
31 case 'NotebookEdit':
32 return { path: str(input.notebook_path), texts: [{ text: str(input.new_source), isOld: false }], fill: swap(['new_source']) }
33 case 'MultiEdit': {
34 const edits = Array.isArray(input.edits) ? (input.edits as Input[]) : []
35 return {
36 path: str(input.file_path),
37 texts: edits.flatMap(e => [{ text: str(e.old_string), isOld: true }, { text: str(e.new_string), isOld: false }]),
38 fill: values => ({ ...input, edits: edits.map(e => ({ ...e, old_string: fill(str(e.old_string), values), new_string: fill(str(e.new_string), values) })) }),
39 }
40 }
41 default:
42 return undefined
43 }
44}
45
46// The status line and one log line per name hidden (names only, never a value).
47function report($: { ui: { status: (text: string) => unknown; log: (text: string) => unknown } }, hidden: number, found: ReadonlySet<string>, where: string): void {
48 $.ui.status(`secret-guard: ${hidden} hidden this session`)
49 for (const name of found) $.ui.log(`hid ${name} from ${where}`)
50}
51
52const RESTORE_TEXT = (tool: string, path: string) => `secret-guard: this ${tool} holds ‹hidden: …›, a placeholder for a secret value. secret-guard puts the real value back only into a file that already holds it, and ${path || 'the target file'} does not, or holds several values it could stand for. Edit around the value (pick an old_string without the placeholder), or write the file with a command that reads the value from its env file without printing it.`
53
54export const register: Register = (on, options) => {
55 const mode = options.mode === 'command' ? 'command' : 'value'
56 const rule = { extra: nameSet(options.secretKeys), identifiers: nameSet(options.identifierKeys) }
57 const fileRules = parseFileRules(String(options.secretFiles ?? '').trim() || DEFAULT_SECRET_FILES)
58 // Values live in the Vault only: never in $.ui, $.store, $.state, a log or the context block.
59 let fileSecrets: Secret[] = []
60 let vault = new Vault([], rule)
61 // The env files found, nearest first, and the one being looked at (named if loading fails).
62 const envPaths: string[] = []
63 let current: string | undefined
64 let hidden = 0
65 // 'ready' once the env files were looked for and read (or there are none). Until then, or after a failure,
66 // value mode refuses every call: a guard that silently loaded nothing would pass every value through.
67 let loading: 'pending' | 'ready' | 'failed' = 'pending'
68 let loadError = ''
69 // Files that were read but could not be parsed: skipped, the rest still protected. Name and reason only.
70 const skipped: { file: string; reason: string }[] = []
71 const skippedText = () => skipped.map(s => `${s.file} (${s.reason})`).join(', ')
72 // Results withheld because the check itself failed (the .catch below).
73 let withheld = 0
74 // The context block was built before the files were loaded, so it is rebuilt once they are.
75 let contextStale = false
76
77 const isProtected = (path: string) => {
78 const name = baseName(path)
79 return isProtectedName(name, fileRules) || envPaths.some(p => baseName(p) === name)
80 }
81
82 const contextText = (): string => {
83 const use = 'To use a secret, reference it as $NAME (PowerShell: $env:NAME), or let the command or script load the env file: source FILE, set -a; . FILE; set +a, or --env-file FILE.'
84 const never = 'Never echo, cat or otherwise print a protected file or a secret value. To check a credential, run the command that uses it and report only whether it worked.'
85 if (mode === 'command') return [`secret-guard refuses any tool call that names a protected env file (such as .env or .env.local), except to load it.`, use, never].join('\n')
86 if (loading === 'failed') return `secret-guard could not load ${current ?? 'an env file'}, so every tool call is refused this session. Tell the user; \`claude plugin disable secret-guard@chien-mods\` turns it off.`
87 const names = [...new Set(fileSecrets.map(s => s.name))]
88 const shown = names.slice(0, MAX_NAMES).join(', ') + (names.length > MAX_NAMES ? ` and ${names.length - MAX_NAMES} more` : '')
89 const head = loading === 'pending'
90 ? 'secret-guard is loading the env files of this session.'
91 : envPaths.length === 0
92 ? 'secret-guard found no env file above this session.'
93 : `secret-guard protects the secrets in ${envPaths.filter(p => !skipped.some(s => s.file === p)).join(', ') || 'no file'}. Protected keys (names only): ${shown || 'none'}.`
94 const warning = skipped.length > 0
95 ? [`secret-guard could not parse ${skippedText()}, so it does not protect the values in that file. Tell the user; never print that file.`]
96 : []
97 return [
98 head,
99 ...warning,
100 'In tool results a secret shows as ‹hidden: NAME›, where NAME is its key or a kind (jwt, private-key, dsn-password, aws-secret-access-key, k8s-secret). It is a placeholder, not the value: never put it in a command. In an Edit or Write, secret-guard puts the real value back only into a file that already holds it.',
101 use,
102 never,
103 ].join('\n')
104 }
105
106 on('session.start', async ($, e, next) => {
107 await $.command.register({ name: 'secret-guard', description: 'Show which env files and keys secret-guard protects' })
108 // Command mode matches file names only, so it never reads the values.
109 if (mode === 'command') {
110 loading = 'ready'
111 return next(e)
112 }
113 try {
114 // session.start can fire again in one load (an enable, a worker respawn): start the list over.
115 envPaths.length = 0
116 loading = 'pending'
117 const home = fileRules.paths.some(p => p.startsWith('~/')) ? ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))) : undefined
118 // Every protected file from the session's folder up to the root, so a project's own .env does not hide
119 // the workspace .env above it. The two starts usually share their upper folders, so each is looked at once.
120 envPaths.push(...await findSecretFiles(dir => $.fs.list(dir), [e.cwd, await $.session.root()], fileRules, home))
121 const lists: Secret[][] = []
122 skipped.length = 0
123 for (const file of envPaths) {
124 current = file
125 // A file that cannot be read fails the load; one that cannot be parsed is skipped and named.
126 const text = String(await $.fs.read(file))
127 try {
128 lists.push(parseEnv(text, rule))
129 } catch (err) {
130 skipped.push({ file, reason: err instanceof Error ? err.message : 'unknown error' })
131 }
132 }
133 fileSecrets = mergeSecrets(lists)
134 vault = new Vault(fileSecrets, rule)
135 loading = 'ready'
136 if (skipped.length > 0) $.ui.status(`secret-guard: skipped ${skippedText()}; its values are not protected`)
137 } catch (err) {
138 loading = 'failed'
139 loadError = err instanceof Error ? err.message : 'unknown error'
140 $.ui.status('secret-guard: could not load an env file, tool calls are refused')
141 }
142 if (contextStale) await $.ui.invalidate('prompt.context')
143 return next(e)
144 })
145
146 // A standing note in the first message of every conversation (and again after a compaction or /clear): the
147 // protected key names, what a marker means and how to use a secret without printing it. Names only.
148 on('prompt.context', async ($, e, next) => {
149 const below = await next(e)
150 contextStale = loading === 'pending'
151 return { ...below, blocks: [...below.blocks.filter(b => b.name !== BLOCK), { name: BLOCK, text: contextText() }] }
152 })
153
154 on('command.run', { command: 'secret-guard' }, async () => {
155 if (mode === 'command') return { text: 'mode command: refuses any call whose command or path names a protected env file, except to load it; values are not read' }
156 if (loading === 'failed') return { text: `could not load ${current ?? 'an env file'} (${loadError}), so value mode refuses every tool call` }
157 // The start hook has not finished: still running, or skipped by the engine (it overran its budget).
158 if (loading === 'pending') return { text: 'has not loaded the env files yet (its start hook did not finish), so value mode refuses every tool call' }
159 const counts = `${hidden} values hidden this session${withheld > 0 ? `; ${withheld} results withheld because they could not be checked` : ''}`
160 if (envPaths.length === 0) return { text: `mode value; no env file found above this session; values that look like secrets are still hidden; ${counts}` }
161 const names = [...new Set(fileSecrets.map(s => s.name))].join(', ') || 'none'
162 const files = envPaths.filter(p => !skipped.some(s => s.file === p)).join(', ') || 'no file'
163 const skips = skipped.length > 0 ? `; skipped ${skippedText()}, its values are not protected` : ''
164 return { text: `mode value; protects ${fileSecrets.length} values from ${files}: ${names}${skips}; ${counts}` }
165 })
166
167 on('tool.call', async ($, e, next) => {
168 const input = e as unknown as Input
169 if (mode === 'command') {
170 const named = INPUT_FIELDS.some(field => typeof input[field] === 'string' && namesProtectedFile(input[field] as string, isProtected))
171 if (named) return { deny: 'secret-guard: this call names a protected env file. Print key names only: sed -E "s/=.*/=<hidden>/" .env, or load it without printing: source .env, --env-file .env' }
172 return next(e)
173 }
174
175 if (loading !== 'ready') return { deny: 'secret-guard has not loaded the env files, so this call was not run' }
176 if (SHELLS.has(e.tool)) {
177 const why = refusal(typeof input.command === 'string' ? input.command : '', isProtected, label => vault.values(label).length > 0)
178 if (why !== undefined) return { deny: why }
179 }
180 let call = e
181 const writer = writerTexts(input)
182 if (writer !== undefined && writer.texts.some(t => hasMarker(t.text))) {
183 let fileText = ''
184 try {
185 fileText = String(await $.fs.read(writer.path))
186 } catch {
187 // A new file holds no value to put back.
188 }
189 const values = resolveMarkers(writer.texts, fileText, label => vault.values(label))
190 if (values === undefined) return { deny: RESTORE_TEXT(e.tool, writer.path) }
191 call = writer.fill(values) as unknown as typeof e
192 // A marker the file holds as text stays text: only the values put back are counted.
193 const restored = [...values].filter(([label, value]) => value !== marker(label)).length
194 if (restored > 0) $.ui.log(`put ${restored} hidden values back into ${writer.path}`)
195 }
196
197 const ran = await next(call)
198 const found = new Set<string>()
199 const where = `a ${e.tool} result`
200 // A refusal from beneath is read by the model too, so its reason is checked like a result.
201 if (ran.deny !== undefined) {
202 const deny = vault.scrubText(ran.deny, found)
203 if (found.size === 0) return ran
204 hidden += found.size
205 report($, hidden, found, where)
206 return { deny }
207 }
208 // An errored result has no typed record to answer with, so its scrubbed text goes back as the error.
209 if (ran.isError) {
210 const text = vault.scrubText(ran.text ?? String(ran.result), found)
211 if (found.size === 0 && vault.find([ran.result]).length === 0) return ran
212 hidden += found.size
213 report($, hidden, found, where)
214 return { deny: text }
215 }
216 vault.scrubText(ran.text ?? '', found)
217 const result = vault.scrub(ran.result, found) as typeof ran.result
218 const context = ran.context?.map(c => vault.scrubText(c, found))
219 // A value left after scrubbing (an object key, which scrub keeps so the shape stays valid) withholds the result.
220 // So does a number, which cannot become a marker without breaking the result's shape.
221 if (vault.find([result, context ?? []]).length > 0) {
222 withheld += 1
223 return failClosed(true, 'throw')
224 }
225 if (found.size === 0) return ran
226 hidden += found.size
227 report($, hidden, found, where)
228 return { result, ...(context ? { context } : {}) }
229 }).catch(($, e, next) => {
230 if (next.called) withheld += 1
231 return failClosed(next.called, next.error.kind)
232 })
233
234 // What the engine attaches on its own never passes tool.call: a file read before a compaction (attached again,
235 // read fresh from disk), a file changed on disk, an @-mentioned file, a settings hook's output. Scrub it too.
236 on('prompt.attachment', async ($, e, next) => {
237 const below = await next(e)
238 if (mode === 'command' || typeof below.text !== 'string') return below
239 const found = new Set<string>()
240 const text = vault.scrubText(below.text, found)
241 if (found.size === 0) return below
242 hidden += found.size
243 report($, hidden, found, `a ${e.type} attachment`)
244 return { ...below, text }
245 }).catch(() => ({ text: null }))
246
247 // A message to another agent or session (SendMessage) leaves this conversation: scrub it the same way.
248 on('session.send', async ($, e, next) => {
249 if (mode === 'command') return next(e)
250 if (loading !== 'ready') return { isDelivered: false, reason: 'secret-guard has not loaded the env files, so this message was not sent' }
251 const found = new Set<string>()
252 const text = vault.scrubText(e.text, found)
253 if (found.size === 0) return next(e)
254 hidden += found.size
255 report($, hidden, found, 'a message sent out')
256 return next({ ...e, text })
257 }).catch(() => ({ isDelivered: false, reason: 'secret-guard could not check this message, so it was not sent' }))
258}
259hooks/commands.ts 81 lines1// The shell commands secret-guard refuses: in value mode the few that print a whole env file, the whole
2// environment or a decrypted secret to the screen, each refusal naming a way that does not print it; in
3// command mode any call that names a protected file. Loading a file (source, --env-file) is always allowed.
4
5import { MARKER } from './secrets'
6
7export type IsProtected = (path: string) => boolean
8
9const CAT_LIKE = new Set(['cat', 'type', 'get-content', 'gc', 'less', 'more', 'head', 'tail', 'bat'])
10// Commands that pass on everything they are given: a dump piped into one of these still reaches the screen.
11const DISPLAY = new Set(['cat', 'type', 'less', 'more', 'head', 'tail', 'bat', 'tee', 'sort', 'uniq', 'column', 'out-host', 'out-string', 'write-output', 'write-host', 'format-list', 'format-table'])
12// Words in front of the command itself.
13const PREFIXES = new Set(['sudo', 'command', 'exec', 'time', 'nohup', '&'])
14
15const words = (text: string): string[] => (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^(["'])(.*)\1$/, '$2'))
16const baseName = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
17const commandName = (word: string) => baseName(word).toLowerCase().replace(/\.exe$/, '')
18
19// The command and its arguments, past `FOO=1` assignments and `sudo`.
20function parseStage(stage: string): { name: string; args: string[] } {
21 const all = words(stage)
22 let i = 0
23 while (i < all.length && (/^[A-Za-z_]\w*=/.test(all[i] ?? '') || PREFIXES.has(commandName(all[i] ?? '')))) i += 1
24 return { name: commandName(all[i] ?? ''), args: all.slice(i + 1) }
25}
26
27// Output sent to a file (`> out`, `>> out`, `| Out-File`), not `2>/dev/null` or `2>&1`.
28const redirected = (statement: string) => /(^|[^0-9&<>])>>?(?!&)\s*\S|&>|\|\s*(out-file|set-content|add-content)\b|-OutFile\b/i.test(statement)
29// Output captured into a variable: `X=$(...)`, `export X=$(...)`, PowerShell `$x = ...`.
30const captured = (statement: string) => /^\s*(?:(?:export|local|readonly)\s+|declare\s+(?:-\w+\s+)*)?[A-Za-z_]\w*=["']?(?:\$\(|`)|^\s*\$[\w:]+\s*=/.test(statement)
31
32const MARKER_TEXT = (label: string) => `secret-guard: this command holds ‹hidden: ${label}›, a placeholder for a secret, not its value. Reference the variable instead ($${label}, or $env:${label} in PowerShell), or let the command load its env file (source .env, set -a; . .env; set +a, or --env-file .env).`
33const FILE_TEXT = (file: string) => `secret-guard: this prints a protected env file (${file}), secrets included. To see its key names: sed -E 's/=.*/=<hidden>/' ${file}. To use the values without printing them: source ${file} (or set -a; . ${file}; set +a, or --env-file ${file}), then reference $NAME.`
34const ENV_TEXT = 'secret-guard: this prints every environment variable, secrets included. List the names only (env | cut -d= -f1; PowerShell: Get-ChildItem env: | Select-Object Name), or print one variable that is not a secret.'
35const SSM_TEXT = 'secret-guard: this prints a decrypted SSM parameter. Capture it without printing it, VALUE=$(aws ssm get-parameter --name NAME --with-decryption --query Parameter.Value --output text), or redirect it to a file, then report only whether it worked.'
36const SECRETS_MANAGER_TEXT = 'secret-guard: this prints a secret from Secrets Manager. Capture it without printing it, VALUE=$(aws secretsmanager get-secret-value --secret-id NAME --query SecretString --output text), or redirect it to a file, then report only whether it worked.'
37const KUBECTL_TEXT = "secret-guard: this prints a Kubernetes Secret's data. Redirect it to a file (> secret.yaml), or see its key names and sizes only: kubectl describe secret NAME."
38
39// Why a value-mode command is refused, or undefined to let it run. `hasValue` says whether a marker's label stands
40// for a value secret-guard knows; a marker that stands for none is plain text (a document quoting the format).
41export function refusal(command: string, isProtected: IsProtected, hasValue: (label: string) => boolean): string | undefined {
42 const label = [...command.matchAll(MARKER)].map(m => m[1] ?? '').find(hasValue)
43 if (label !== undefined) return MARKER_TEXT(label)
44 for (const statement of command.split(/\r?\n|;|&&|\|\|/)) {
45 const hidden = redirected(statement) || captured(statement)
46 const all = words(statement)
47 const lower = all.map(w => w.toLowerCase())
48 if (!hidden) {
49 if (lower.some(w => w.startsWith('--with-decryption'))) return SSM_TEXT
50 if (lower.includes('get-secret-value')) return SECRETS_MANAGER_TEXT
51 if (lower.some(w => commandName(w) === 'kubectl') && lower.includes('get') && lower.some(w => /^secrets?(\/|$)/.test(w))
52 && /(?:^|\s)(?:-o|--output)(?:\s+|=)?(?:yaml|json|jsonpath|go-template|template|custom-columns)/.test(lower.join(' '))) return KUBECTL_TEXT
53 }
54 const stages = statement.split('|').map(parseStage)
55 for (let i = 0; i < stages.length; i++) {
56 const { name, args } = stages[i] ?? { name: '', args: [] }
57 // Whatever this stage prints reaches the screen: nothing after it but commands that pass it all on.
58 const shown = !hidden && stages.slice(i + 1).every(s => DISPLAY.has(s.name))
59 if (!shown) continue
60 if (CAT_LIKE.has(name)) {
61 const file = args.find(a => !a.startsWith('-') && isProtected(a))
62 if (file !== undefined) return FILE_TEXT(file)
63 }
64 if ((name === 'env' || name === 'printenv' || name === 'set') && args.length === 0) return ENV_TEXT
65 if (['get-childitem', 'gci', 'dir', 'ls'].includes(name) && args.some(a => /^env:\\?\*?$/i.test(a))) return ENV_TEXT
66 }
67 }
68 return undefined
69}
70
71// The old rule (`.env` as a file token, also inside a glob such as `**/.env*`), not `.venv` or `.env.example`.
72const ENV_FILE = /(?<![\w.])\.env(?![\w.])/
73// Loading a file without printing it: `source X`, `. X`, `--env-file X`, `env_file: X`.
74const LOADS = /(?:\bsource|(?:^|[\s;&|(])\.)\s+\S+|--env-file(?:=|\s+)\S+|env_file:\s*\S+/g
75
76// Command mode: does any field of the call name a protected file, other than to load it?
77export function namesProtectedFile(text: string, isProtected: IsProtected): boolean {
78 const rest = text.replace(LOADS, ' ')
79 return ENV_FILE.test(rest) || rest.split(/[\s'"=;|&<>()`,]+/).some(token => token !== '' && isProtected(token))
80}
81hooks/files.ts 157 lines1// Which env files secret-guard reads: the `secretFiles` globs, looked for from the session's folder up to the
2// drive root (a bare file name) or at a path of their own (`~/secrets/**/.env`).
3
4export const DEFAULT_SECRET_FILES = '.env, .env.*, !*.example'
5
6export type FileRules = {
7 // File-name globs, looked for in every folder from the session's up to the root.
8 names: RegExp[]
9 // Path globs (they hold a `/`), from the home folder (`~/`), absolute, or relative to the session's folder.
10 paths: string[]
11 // `!glob`: a file whose name (or, for a glob with a `/`, whose path) matches is never read.
12 excludes: RegExp[]
13}
14
15// `*` and `?` stay inside one folder; `**` crosses folders.
16export function globRegExp(glob: string): RegExp {
17 let source = ''
18 for (let i = 0; i < glob.length; i++) {
19 const ch = glob[i] ?? ''
20 if (ch === '*' && glob[i + 1] === '*') {
21 source += '.*'
22 i += 1
23 } else if (ch === '*') source += '[^/]*'
24 else if (ch === '?') source += '[^/]'
25 else source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
26 }
27 return new RegExp(`^${source}$`)
28}
29
30const hasSlash = (text: string) => /[\\/]/.test(text)
31const slashes = (path: string) => path.replace(/\\/g, '/')
32
33export function parseFileRules(option: unknown): FileRules {
34 const rules: FileRules = { names: [], paths: [], excludes: [] }
35 for (const entry of String(option ?? '').split(',').map(e => e.trim()).filter(Boolean)) {
36 if (entry.startsWith('!')) rules.excludes.push(globRegExp(slashes(entry.slice(1))))
37 else if (hasSlash(entry)) rules.paths.push(slashes(entry))
38 else rules.names.push(globRegExp(entry))
39 }
40 return rules
41}
42
43export function excluded(path: string, rules: FileRules): boolean {
44 const full = slashes(path)
45 const name = full.slice(full.lastIndexOf('/') + 1)
46 return rules.excludes.some(re => re.test(name) || re.test(full))
47}
48
49// A file name (no folder) the name globs protect: what the command checks match a path's last part against.
50export function isProtectedName(name: string, rules: FileRules): boolean {
51 return rules.names.some(re => re.test(name)) && !excluded(name, rules)
52}
53
54// The folder itself, then each parent up to and including the root (`C:\`, `/`), nearest first.
55// Each keeps the start's own separator, so a path /secret-guard shows reads as one path.
56export function ancestorDirs(start: string): string[] {
57 const sep = start.includes('\\') ? '\\' : '/'
58 const out: string[] = []
59 let dir = start.replace(/[\\/]+$/, '')
60 for (;;) {
61 const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
62 if (cut < 0) {
63 // `C:` alone means the drive's current folder, so the root is listed as `C:\`; an empty dir is Unix's `/`.
64 out.push(`${dir}${sep}`)
65 return out
66 }
67 out.push(dir)
68 dir = dir.slice(0, cut)
69 }
70}
71
72export function join(dir: string, name: string): string {
73 const sep = dir.includes('\\') ? '\\' : '/'
74 return dir.endsWith(sep) ? `${dir}${name}` : `${dir}${sep}${name}`
75}
76
77export type Entry = { name: string; kind: string }
78export type Lister = (dir: string) => Promise<readonly Entry[]>
79
80// Folders a `**` never walks into, and how far it goes, so a broad glob cannot stall the session's start.
81const SKIP_DIRS = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__'])
82const MAX_DEPTH = 8
83const MAX_DIRS = 2000
84
85// A folder that cannot be listed (missing, no permission) holds nothing secret-guard could read either.
86async function listed(list: Lister, dir: string): Promise<readonly Entry[]> {
87 try {
88 return await list(dir)
89 } catch {
90 return []
91 }
92}
93
94// Every protected file, nearest first: the name globs in each folder from each start up to the root, then each
95// path glob. A file is listed once, by the first spelling found.
96export async function findSecretFiles(list: Lister, starts: readonly string[], rules: FileRules, home: string | undefined): Promise<string[]> {
97 const out: string[] = []
98 const seen = new Set<string>()
99 const add = (path: string) => {
100 const key = slashes(path)
101 if (seen.has(key) || excluded(path, rules)) return
102 seen.add(key)
103 out.push(path)
104 }
105 const looked = new Set<string>()
106 if (rules.names.length > 0) {
107 for (const start of starts) {
108 for (const dir of ancestorDirs(start)) {
109 if (looked.has(slashes(dir))) continue
110 looked.add(slashes(dir))
111 const names = (await listed(list, dir)).filter(e => e.kind !== 'dir' && rules.names.some(re => re.test(e.name))).map(e => e.name).sort()
112 for (const name of names) add(join(dir, name))
113 }
114 }
115 }
116 let budget = MAX_DIRS
117 const walk = async (dir: string, segs: readonly string[], depth: number): Promise<void> => {
118 const [seg, ...rest] = segs
119 if (seg === undefined || budget <= 0) return
120 budget -= 1
121 const entries = await listed(list, dir)
122 if (seg === '**') {
123 await walk(dir, rest, depth)
124 if (depth >= MAX_DEPTH) return
125 for (const e of entries) if (e.kind === 'dir' && !SKIP_DIRS.has(e.name)) await walk(join(dir, e.name), segs, depth + 1)
126 return
127 }
128 const re = globRegExp(seg)
129 for (const e of entries) {
130 if (!re.test(e.name)) continue
131 if (rest.length === 0) {
132 if (e.kind !== 'dir') add(join(dir, e.name))
133 } else if (e.kind === 'dir') await walk(join(dir, e.name), rest, depth)
134 }
135 }
136 for (const pattern of rules.paths) {
137 let base: string
138 let rel: string
139 if (pattern.startsWith('~/')) {
140 if (home === undefined) continue
141 base = home
142 rel = pattern.slice(2)
143 } else if (/^[A-Za-z]:\//.test(pattern)) {
144 base = `${pattern.slice(0, 2)}\\`
145 rel = pattern.slice(3)
146 } else if (pattern.startsWith('/')) {
147 base = '/'
148 rel = pattern.slice(1)
149 } else {
150 base = starts[0] ?? '.'
151 rel = pattern.replace(/^\.\//, '')
152 }
153 await walk(base, rel.split('/').filter(Boolean), 0)
154 }
155 return out
156}
157hooks/secrets.ts 318 lines1// Pure helpers for secret-guard: which key names hold secrets, parse an env file, and the Vault that finds
2// secret values in any text (the env files' own, and ones that only look like secrets) and hides them.
3
4export type Secret = { name: string; value: string }
5
6// Shorter values are never hidden, so `PW=1` or `TOKEN_TTL=60` does not mask every 1 or 60 in a result.
7export const MIN_SECRET_LEN = 8
8
9// A key holds a secret when one part of its name (split at `_`, `-`, `.` and camelCase) is one of these words,
10// or ends with one (APIKEY, DBPASS). Whole parts, so PWD's cousins TOKENIZER, MAX_TOKENS, PASSPORT, KEYBOARD
11// are not secrets.
12const SECRET_WORDS = ['PASS', 'PASSWD', 'PASSWORD', 'PW', 'PWD', 'SECRET', 'SECRETS', 'TOKEN', 'KEY', 'DSN', 'CREDENTIAL', 'CREDENTIALS', 'PRIVATE']
13const SECRET_ENDINGS = ['PASS', 'PASSWD', 'PASSWORD', 'PWD', 'SECRET', 'TOKEN', 'KEY']
14// The shell's own working-directory variables: paths, in every env listing.
15const SHELL_NAMES = new Set(['PWD', 'OLDPWD'])
16
17export type KeyRule = { extra: ReadonlySet<string>; identifiers: ReadonlySet<string> }
18
19// Comma-separated option text to an upper-case set.
20export function nameSet(option: unknown): Set<string> {
21 return new Set(String(option ?? '').split(',').map(k => k.trim().toUpperCase()).filter(Boolean))
22}
23
24export function keyParts(name: string): string[] {
25 return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean)
26}
27
28export function isSecretKey(name: string, rule: KeyRule): boolean {
29 const upper = name.toUpperCase()
30 if (rule.identifiers.has(upper) || SHELL_NAMES.has(upper)) return false
31 if (rule.extra.has(upper)) return true
32 return keyParts(name).some(part => SECRET_WORDS.includes(part) || SECRET_ENDINGS.some(word => part.endsWith(word)))
33}
34
35const DOUBLE_QUOTED_ESCAPES: Record<string, string> = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' }
36
37// A quoted value as python-dotenv reads it: double quotes take backslash escapes, single quotes are literal,
38// and a comment may follow the closing quote. A quote that does not close on its line (a multiline value) is
39// not supported, so it throws: the guard then refuses calls rather than protect a wrong value.
40function quotedValue(value: string, lineNumber: number): string {
41 const quote = value[0]
42 let inner = ''
43 for (let i = 1; i < value.length; i++) {
44 const ch = value[i]
45 if (ch === '\\' && quote === '"' && i + 1 < value.length) {
46 const next = value[i + 1] ?? ''
47 inner += DOUBLE_QUOTED_ESCAPES[next] ?? `\\${next}`
48 i += 1
49 } else if (ch === quote) {
50 const rest = value.slice(i + 1).trim()
51 if (rest !== '' && !rest.startsWith('#')) throw new Error(`unsupported .env syntax on line ${lineNumber}`)
52 return inner
53 } else inner += ch
54 }
55 throw new Error(`unsupported .env syntax on line ${lineNumber} (a quoted value that does not close)`)
56}
57
58// The secret values of an env file: keys whose name holds a secret, values long enough to hide.
59export function parseEnv(text: string, rule: KeyRule): Secret[] {
60 const secrets: Secret[] = []
61 text.split(/\r?\n/).forEach((raw, index) => {
62 const line = raw.trim()
63 if (line === '' || line.startsWith('#')) return
64 const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
65 if (match === null) return
66 const name = match[1] ?? ''
67 const rest = (match[2] ?? '').trim()
68 const value = rest.startsWith('"') || rest.startsWith("'") ? quotedValue(rest, index + 1) : rest.replace(/\s+#.*$/, '')
69 if (!isSecretKey(name, rule) || value.length < MIN_SECRET_LEN) return
70 secrets.push({ name, value })
71 })
72 return byLength(secrets)
73}
74
75// Longest first, so a value that contains another is replaced whole.
76function byLength(secrets: Secret[]): Secret[] {
77 return secrets.sort((a, b) => b.value.length - a.value.length)
78}
79
80// Several env files merged, nearest first: a value found in two files keeps the nearest file's name.
81export function mergeSecrets(lists: readonly (readonly Secret[])[]): Secret[] {
82 const byValue = new Map<string, Secret>()
83 for (const list of lists) for (const s of list) if (!byValue.has(s.value)) byValue.set(s.value, s)
84 return byLength([...byValue.values()])
85}
86
87export const marker = (label: string) => `‹hidden: ${label}›`
88// A marker as it reaches a tool's input; the label is a key name or a kind such as jwt.
89export const MARKER = /‹hidden: ([^›\n]{1,100})›/g
90
91// Values that name or point at a secret rather than hold one: `$VAR`, `${{ secrets.X }}`, `<your key>`, a type.
92const NOT_A_VALUE = new Set(['true', 'false', 'null', 'none', 'nil', 'undefined', 'string', 'number', 'boolean', 'unknown', 'object'])
93// `bare`: the value stood unquoted in the text, where code names a variable rather than a literal.
94export function isSecretLiteral(value: string, bare = false): boolean {
95 if (value.length < MIN_SECRET_LEN || NOT_A_VALUE.has(value.toLowerCase())) return false
96 if (/^[$%<‹@]|^\{\{|^#\{/.test(value)) return false
97 // A path to a key file is not the key: ~/.ssh/id_rsa, ./certs/app.pem, C:\keys\app.p12.
98 if (/^(~|\.{1,2})[\\/]|^[A-Za-z]:[\\/]/.test(value)) return false
99 if (/\$\{|\$\(|process\.env|os\.environ|getenv|ENV\[/.test(value)) return false
100 // An unquoted attribute in code, not a literal: `self.api_key = config.service_key`. Generated values hold digits.
101 if (bare && /^[A-Za-z_]+(\.[A-Za-z_]+)+$/.test(value)) return false
102 // A mask someone already put there: ********, xxxxxxxx, ........, or a kept prefix and a masked rest (sk_****).
103 return !/^([*xX.•])\1*$/.test(value) && !/[*•]{4,}$/.test(value)
104}
105
106// A detected value is hidden everywhere else in the session only when it looks generated (long, letters and
107// digits), so `sortKey: "createdAt"` does not hide every createdAt that follows.
108function looksGenerated(value: string): boolean {
109 return value.length >= 12 && /[0-9]/.test(value) && /[A-Za-z]/.test(value)
110}
111
112// KEY=VALUE, KEY: VALUE, "KEY": "VALUE", --key=VALUE. An unquoted value after `:` must end its line (YAML),
113// so `apiKey: string;` or `token: config.token,` in code is left alone; after `=` it must end at a space or
114// a shell separator, so `password=pw,` (a keyword argument) is too.
115const KEYED = /(?<![\w.$])(["']?)([A-Za-z_][\w.-]*)\1([ \t]*(?::|=(?![=>~]))[ \t]*)("(?:[^"\\\n]|\\.)*"|'[^'\n]*'|[^\s'"`,;(){}[\]<>=]+)/g
116const PRIVATE_KEY = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY( BLOCK)?-----[\s\S]*?(?:-----END \1PRIVATE KEY\2-----|$)/g
117const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g
118// scheme://user:password@host: only the password goes.
119const DSN = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/@'"]*):([^\s@/'"]+)@/gi
120const AWS_KEY_ID = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/
121// A 40-character secret access key on the same line as an access key id (a credentials CSV, a log line).
122const AWS_SECRET = /(?<![A-Za-z0-9/+=])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+=])/g
123
124// Holds every value secret-guard knows (the env files', and ones it found in results) in memory only, and
125// hides them. `found` collects the names of what was hidden.
126export class Vault {
127 private named: Secret[]
128 private readonly byLabel = new Map<string, Set<string>>()
129
130 constructor(fileSecrets: readonly Secret[], private readonly rule: KeyRule) {
131 this.named = [...fileSecrets]
132 for (const s of fileSecrets) this.note(s.name, s.value)
133 }
134
135 // The env files' secrets plus the generated-looking values found since.
136 get size(): number {
137 return this.named.length
138 }
139
140 // The values a marker can stand for, for putting them back into a file that already holds one.
141 values(label: string): string[] {
142 return [...(this.byLabel.get(label) ?? [])]
143 }
144
145 private note(label: string, value: string): void {
146 const set = this.byLabel.get(label) ?? new Set<string>()
147 set.add(value)
148 this.byLabel.set(label, set)
149 }
150
151 private remember(label: string, value: string, everywhere: boolean): void {
152 this.note(label, value)
153 if (everywhere && !this.named.some(s => s.value === value)) this.named = byLength([...this.named, { name: label, value }])
154 }
155
156 // A value already hidden (the text is scrubbed twice: the tool's text and its record) is left as it is.
157 private hide(label: string, value: string, found: Set<string>, everywhere: boolean): string {
158 if (value.includes('‹hidden: ')) return value
159 found.add(label)
160 this.remember(label, value, everywhere)
161 return marker(label)
162 }
163
164 private exact(text: string, found: Set<string>): string {
165 for (const s of this.named) {
166 if (!text.includes(s.value)) continue
167 found.add(s.name)
168 text = text.split(s.value).join(marker(s.name))
169 }
170 return text
171 }
172
173 scrubText(text: string, found: Set<string>): string {
174 text = this.exact(text, found)
175 text = text.replace(PRIVATE_KEY, block => this.hide('private-key', block, found, true))
176 text = this.kubernetes(text, found)
177 text = text.replace(KEYED, (all, quote: string, key: string, sep: string, raw: string, offset: number, whole: string) => {
178 const quoted = /^["']/.test(raw)
179 const value = quoted ? raw.slice(1, -1) : raw
180 if (!isSecretKey(key, this.rule) || !isSecretLiteral(value, !quoted)) return all
181 // A key inside a quoted string (`rg -c "API_SECRET=" deploy/*.ini`): the quote after it closes that
182 // string, and what follows up to the next quote is more command, not a value.
183 if (quoted && (whole.slice(whole.lastIndexOf('\n', offset) + 1, offset).split(raw[0] ?? '').length - 1) % 2 === 1) return all
184 // A name that is only `key` (a React key, a YAML selector) hides a value only when it looks generated.
185 if (/^keys?$/i.test(key) && !looksGenerated(value)) return all
186 const after = whole.slice(offset + all.length)
187 // Unquoted after `:`, the value must end its line (YAML); after `=`, it must end at a space, a quote or a
188 // shell separator (`password=pw_var,` and `f(token=t)` are code).
189 if (!quoted && sep.includes(':') && !/^[ \t]*(#.*)?(\r?\n|$)/.test(after)) return all
190 if (!quoted && sep.includes('=') && !/^(\s|["'`;&|]|$)/.test(after)) return all
191 const shown = quoted ? `${raw[0]}${marker(key)}${raw[0]}` : marker(key)
192 found.add(key)
193 this.remember(key, value, looksGenerated(value))
194 return `${quote}${key}${quote}${sep}${shown}`
195 })
196 text = text.replace(DSN, (all, scheme: string, user: string, password: string) => {
197 if (/^[$%<{*‹]/.test(password)) return all
198 found.add('dsn-password')
199 this.remember('dsn-password', password, looksGenerated(password))
200 return `${scheme}${user}:${marker('dsn-password')}@`
201 })
202 text = text.split('\n').map(line => {
203 if (!AWS_KEY_ID.test(line)) return line
204 const withSecret = line.replace(AWS_SECRET, token =>
205 /[A-Z]/.test(token) && /[a-z]/.test(token) && /[0-9]/.test(token) ? this.hide('aws-secret-access-key', token, found, true) : token)
206 return withSecret.replace(new RegExp(AWS_KEY_ID.source, 'g'), id => this.hide('aws-access-key-id', id, found, true))
207 }).join('\n')
208 text = text.replace(JWT, token => this.hide('jwt', token, found, true))
209 // A value found just now may also stand bare elsewhere in the same text.
210 return this.exact(text, found)
211 }
212
213 // The values under a Kubernetes Secret's `data:` / `stringData:`, whatever their names.
214 private kubernetes(text: string, found: Set<string>): string {
215 if (/^\s*kind:\s*Secret\s*$/m.test(text)) {
216 const lines = text.split('\n')
217 let blockIndent = -1
218 let valueIndent = -1
219 for (let i = 0; i < lines.length; i++) {
220 const line = lines[i] ?? ''
221 const indent = line.length - line.trimStart().length
222 if (line.trim() === '') continue
223 if (valueIndent >= 0 && indent > valueIndent) {
224 // The lines of a `key: |` block scalar.
225 lines[i] = `${line.slice(0, indent)}${this.hide('k8s-secret', line.trim(), found, false)}`
226 continue
227 }
228 valueIndent = -1
229 if (blockIndent >= 0 && indent > blockIndent) {
230 const entry = /^(\s*)([\w.-]+):[ \t]*(\S.*)$/.exec(line)
231 if (entry === null) continue
232 const [, lead, key, value] = entry as unknown as [string, string, string, string]
233 if (/^[|>][-+0-9]*$/.test(value.trim())) valueIndent = indent
234 else lines[i] = `${lead}${key}: ${this.hide('k8s-secret', value.trim(), found, looksGenerated(value.trim()))}`
235 continue
236 }
237 blockIndent = /^\s*(data|stringData):\s*$/.test(line) ? indent : -1
238 }
239 text = lines.join('\n')
240 }
241 if (/"kind"\s*:\s*"Secret"/.test(text)) {
242 text = text.replace(/("(?:data|stringData)"\s*:\s*\{)([^{}]*)\}/g, (_all, head: string, body: string) =>
243 `${head}${body.replace(/("(?:[^"\\]|\\.)*"\s*:\s*)"((?:[^"\\]|\\.)*)"/g, (_e, key: string, value: string) =>
244 `${key}"${this.hide('k8s-secret', value, found, looksGenerated(value))}"`)}}`)
245 }
246 return text
247 }
248
249 // Every string in a tool's result: object keys stay (the result keeps its shape), a value under a secret
250 // key (`{ "apiKey": "..." }` from an MCP tool) is hidden whole.
251 scrub(value: unknown, found: Set<string>): unknown {
252 if (typeof value === 'string') return this.scrubText(value, found)
253 if (Array.isArray(value)) return value.map(v => this.scrub(v, found))
254 if (value !== null && typeof value === 'object') {
255 return Object.fromEntries(Object.entries(value).map(([k, v]) => {
256 if (typeof v === 'string' && isSecretKey(k, this.rule) && isSecretLiteral(v) && !v.includes('‹hidden: ')) {
257 found.add(k)
258 this.remember(k, v, looksGenerated(v))
259 return [k, marker(k)]
260 }
261 return [k, this.scrub(v, found)]
262 }))
263 }
264 // Numbers stay numbers so the result keeps its shape; find still sees them, and the guard withholds.
265 return value
266 }
267
268 // The names of known values left in any string, number, array item, object value or object key.
269 find(values: readonly unknown[]): string[] {
270 const found = new Set<string>()
271 const visit = (value: unknown): void => {
272 if (typeof value === 'string') {
273 for (const s of this.named) if (value.includes(s.value)) found.add(s.name)
274 } else if (typeof value === 'number' || typeof value === 'bigint') {
275 // A numeric value (a 12-digit PIN) can sit in a result as a number, not as text.
276 visit(String(value))
277 } else if (Array.isArray(value)) value.forEach(visit)
278 else if (value !== null && typeof value === 'object') {
279 for (const [key, inner] of Object.entries(value)) {
280 visit(key)
281 visit(inner)
282 }
283 }
284 }
285 values.forEach(visit)
286 return [...found]
287 }
288}
289
290export function fill(text: string, values: ReadonlyMap<string, string>): string {
291 return text.replace(MARKER, (all, label: string) => values.get(label) ?? all)
292}
293
294// The value behind each marker in an Edit or Write, when the target file settles it: each value must already be
295// in the file (restoring, never spreading), and every old_string must then match the file. Undefined when a
296// marker has no such value or could stand for several. A marker whose label stands for no value secret-guard
297// knows (a document quoting the format) is plain text and stays as written; so does a marker the file already
298// holds as text.
299export function resolveMarkers(texts: readonly { text: string; isOld: boolean }[], fileText: string, values: (label: string) => readonly string[]): Map<string, string> | undefined {
300 const labels = [...new Set(texts.flatMap(t => [...t.text.matchAll(MARKER)].map(m => m[1] ?? '')))]
301 .filter(label => values(label).length > 0)
302 let combos: Map<string, string>[] = [new Map()]
303 for (const label of labels) {
304 const candidates = [...values(label), marker(label)].filter(v => fileText.includes(v))
305 combos = combos.flatMap(c => candidates.map(v => new Map([...c, [label, v]])))
306 if (combos.length > 64) return undefined
307 }
308 const fits = combos.filter(c => texts.every(t => !t.isOld || fileText.includes(fill(t.text, c))))
309 return fits.length === 1 ? fits[0] : undefined
310}
311
312// What secret-guard answers when its own hook failed: refuse before the tool ran, withhold after.
313export function failClosed(called: boolean, kind: string): { deny: string } {
314 return called
315 ? { deny: 'secret-guard could not check this result, so it was withheld' }
316 : { deny: `secret-guard could not check this call (${kind}), so it was not run` }
317}
318