Claude asks for a secret in a masked box and gets a placeholder: the value stays in the session's memory, becomes real only inside a Bash command, is hidden…

A Claude Code mod that lets Claude use a secret it never sees. When a task needs an API key, a token or a password, Claude calls request_secret, a masked box opens beside the transcript, you paste the value, and Claude gets a placeholder such as [secret-pane:PARENT_REPO_PAT:3f9a1c2e]. The placeholder becomes the real value inside a Bash command, and the value is hidden again in everything that comes back. The value lives in the session's memory alone: the mod writes nothing to disk, and nothing of it reaches what the model reads.
A value that reaches the conversation is in the transcript for good: sent to the model on every turn after, kept in the session file, replayed into every fork. The one fix is never to send it. secret-pane closes the ways in, a paste and a tool result that prints the value, and still lets Claude run the command that needs it.
/plugin marketplace add bengous/claude-code-plugins
/plugin install secret-pane@bengous-plugins
Ask for something that needs a secret: set the PARENT_REPO_PAT secret on the three repositories. Claude calls secret-pane - request_secret with the variable's name and a one-line reason.
Claude then writes the placeholder in a Bash command where the value goes, as it would write the value:
curl -fsS -H "Authorization: Bearer [secret-pane:PARENT_REPO_PAT:3f9a1c2e]" https://api.github.com/user
printf '%s' [secret-pane:PARENT_REPO_PAT:3f9a1c2e] | gh secret set PARENT_REPO_PAT --repo org/repo
API_KEY=[secret-pane:PARENT_REPO_PAT:3f9a1c2e] npm test
That holds for a value the shell reads as plain text: letters, digits and _ . : / + = @ , -, starting with a letter, a digit or _, with no :=, as GitHub's tokens (ghp_…, github_pat_…) are. The value goes in as it is, wherever the placeholder stands: quoted or not, in a heredoc, after =, in a list (&&, ;, 2>&1).
A value holding any other character (a space, a quote, $, !, %) would change the command it lands in, so it goes in only as a whole single-quoted word, shell-quoted, in one command or a pipe of two; the answer to request_secret says which shape applies. A longer text is built with a pipe:
printf '%s' '[secret-pane:DB_PASSWORD:7c1e09ab]' | gh secret set DB_PASSWORD --repo org/repo
printf 'Authorization: Bearer %s' '[secret-pane:DB_PASSWORD:7c1e09ab]' | curl -fsS -H @- https://example.com/api
Whatever a command prints of a kept value comes back to Claude as its placeholder.
A command that names a placeholder is refused, its reason and its fix in one line, when:
/clear), or, for an inert value, it stands right after $ or ${ (the value would be a variable's name), $name (it would run into the name) or $name: (a zsh modifier); the fix says which, ${name} before the placeholder for the last two. The strict shape refuses any $ with its own fix;sh -c "..." and a pipe's stdbuf git commit included, may write the value into history or send it out: git with a subcommand outside the local ones (status, diff, log, fetch, clone, add, config, credential, ...; the list is GIT_LOCAL in hooks/shell.ts), or any gh but gh secret. An alias counts as unknown, so git ci is refused as git commit is. The refusal of git push says how to hand git the token instead, then push with no placeholder; that of gh, how to store the value as a secret or call GitHub's API with curl;;, & but in a redirection (2>&1), &&, ||, $, (, ), a backtick, <, a new line, a second pipe, a wrapper that runs its arguments as another command (sh, bash, env, xargs, eval, sudo, ssh...) or an assignment in front. A command naming a strict value holds every placeholder it names to that shape, an inert one included;A Write, an Edit or a NotebookEdit whose text holds a placeholder of this session is refused too: the file would hold the placeholder where the value was meant. The refusal names the Bash command that writes the value, printf 'NAME=%s\n' '<placeholder>' >> .env, and says to write NAME to mention the secret without its value. Every fix a refusal names is a command that runs, whatever the value holds: FIXES in hooks/shell.ts, which hooks/shell.spec.ts runs through the mod. A placeholder the session never minted is written as text, an example as in this README.
If you would rather not wait for Claude to think of the tool, say use secret-pane to ask me for PARENT_REPO_PAT.
session.start registers the tool with isDeferred: false, so Claude sees it on every turn.$.ui.open and drawn with an Input. The element has no password mode, so the mod masks it itself: the field draws one dot per character held, and each change hands back the dots kept plus what was typed or pasted. An edit in the middle of the field cannot be read that way, so it empties the field and the box says why. A hook's own time is capped, and time inside a host call is not counted, so the mod waits for you in short sleep processes (Start-Sleep through PowerShell on Windows), up to ten minutes; whatever ends the wait, the box closes and the slot frees.gh secret. If every value it names is inert (isInert in hooks/vault.ts), a placeholder right after a $ refuses it, and each placeholder is then replaced by its value, as it is; otherwise the command must take the strict shape, which a lexer reads (restoreStrict in hooks/shell.ts), each value shell-quoted. A refusal's fix names the command's placeholder when it names one, and the placeholder at fault in the strict shape. Then comes the check of the changes below, then the permission: a refused command asks nobody.session.start and session.end (a /clear too) empty it. Every kept value is hidden in one pass, placeholders matched first so a value found inside one never rewrites it.tool.call hook on Bash, Read, Write, Edit, NotebookEdit, Monitor, GetTask and LSP hides it in the tool's record, which the session file stores, the file a Write replaced included; a result holding none comes back as it came. A session.append hook hides it in every row the model reads, whatever door it came by: a tool's result, a task's notification, a hook's context, a prompt. A prompt.submit hook hides it in a prompt and its context before they are queued. Measured in a live claude -p session with a probe mod: a session.append rewrite of a Bash result and of a background task's output reached the model, and the session file kept the raw toolUseResult, which only tool.call changes.gh secret, anywhere in its text, sh -c "git commit ..." included (gh stack add -Am commits). The body of a heredoc whose delimiter is quoted, the commit message of git commit -m "$(cat <<'EOF' ... EOF)" among them, is text, not commands, unless a shell on its line runs it (bash <<'EOF'). The mod finds the repository the command acts on: the cd and pushd commands before it, composed, after a keyword or a brace too, a subshell's left in it; or else the session's directory, which follows Claude's cd; with the -C, --git-dir and --work-tree it hands git, or the GIT_DIR= and GIT_WORK_TREE= in front of it. There it runs git diff --cached and git diff and searches the lines they add, then reads every untracked file git does not ignore; it also reads the files the command is handed: each one gh's arguments name (gh gist create .env, --body-file log, -F body=@notes.md), the one git reads its message from (git commit -F msg), the one its standard input reads (< .env, a cat .env piped straight into it), and the one a substitution in an argument prints (--body "$(cat body.md)"). A file pattern (gh release upload v1 dist/*) is not expanded, so the files it names are not read. It searches each value's longest line, so a key spread over lines is found by a line of its body, not by its header. A hit refuses the command and names each file with its fix: .gitignore for an untracked one; the value out, or git rm --cached, for a tracked one; the file left out for one the command is handed. A check that cannot read every change refuses too, saying why: no repository for git (gh outside any repository carries no change), a directory the command makes itself (mkdir x && cd x && git init && git commit), git failing, an output over 4 MiB, more than 1000 untracked files, a cd to a place the mod cannot read (cd $DIR, popd). The values never leave the mod: git prints the changes, and the mod searches them.tool.check hook on Bash reads the session's verdict first; where it would ask, the mod asks through $.ui.ask instead, the command as Claude wrote it on show, with Run it and Don't run it alone. A query of a permission is left alone.base64, rev) or sends it wherever curl or ssh reach, GitHub's API included (curl -d), and the transformed output is not recognized. This keeps a value out of the transcript by accident; it is no sandbox against a model trying to leak one.request_secret says which shape a value takes, so it tells Claude one bit of the value: whether it holds a character the shell reads.{a,<placeholder>} brace-expands a value holding ,, ${x//<placeholder>/y} splits on its /, $(( <placeholder> )) reads it as arithmetic. And a placeholder in prose becomes the value: a heredoc that writes a pull request's body with it writes the value. The check before a commit or a publish reads that body when the command is handed it (--body-file, -F, --body "$(cat body.md)"); docker build --build-arg TOKEN=<placeholder> keeps the value in an image a later docker push publishes, which no check reads.hooks/shell.spec.ts runs a value of every inert character through bash unquoted, in '...', "..." and $'...', in both heredocs, in sh -c "...", an assignment and a substitution, and reads it back unchanged. zsh is not measured; a placeholder after $name: is refused all the same, since zsh would read the value as a modifier.PreToolUse or PostToolUse hook receives it (measured in the kit: the classic.PreToolUse hook beneath the mod sees the restored command). The permission dialog does not, nor an "always allow" rule in settings.local.json: the mod asks instead. In auto mode too, on purpose: the classifier would read the value; you are asked instead. A settings rule or the mode that allows or denies the command outright is left to decide. Not measured in a live session: that the mod's question draws while the check waits; the kit raises no permission check on a real call.printf 'NAME=%s\n' '<placeholder>' >> .env), and the file then holds the value in clear.tool.call list of How it works keep a raw value in the record the session file stores, though the model reads it hidden. On Windows that includes Grep, which this build has only there.git add -f of an ignored one or cp .env.local config.env && git add -A && git commit. It does not read a change to a tracked binary file, an untracked file over 4 MiB or a submodule either, nor a command run over ssh, which runs on another machine. It follows the session's cd, not a subagent's: a subagent's commit is checked where the session stands, unless its command cds first. And since it reads the working tree, it refuses a push too while a value stands in a file not committed yet..git/config sets runs, as it would on your own git diff. A clone brings no such setting; a repository unpacked from an archive can.git push inside an echo is checked too, and a git reached through a variable ($GIT commit) or a place set by export GIT_DIR=... in an earlier command is not./clear forgets every value, and a placeholder from before then refuses the command that names it. Claude asks again.request_secret while the box is open is answered with a refusal, not a second box.claude -p has no one to type, and a session drawn on a phone alone has no box: the tool answers that it needs a terminal or a desktop.powershell -NoProfile -Command Start-Sleep) are not measured.process.run other mods can see; a single process ended from the box would do with one, its abort not measured.claude --plugin-dir ./secret-pane
claude plugin validate secret-pane
claude plugin test secret-pane
bun test secret-pane
hooks/register.test.ts is the kit's suite: the box on the pane and in the band and what it refuses to keep, the placeholder in and out of Bash, the hiding in a later command's output, in a file read, in a Write's record and in a row any door appends, the refusals of commands and of file writes, /clear, the prompt and its context, what the settings hooks see. hooks/shell.spec.ts is the restoring of a placeholder in each shape, an inert value read back through bash, and the reading of git and gh in a command, hooks/scan.spec.ts the check of the changes, on a fake host and on a real repository, hooks/vault.spec.ts the vault and the field.
hooks/register.ts 584 lines1import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from "claude-code";
2
3import { type Found, type Host, needlesOf, scanChanges, scanTargets } from "./scan.ts";
4import { filled, FIXES, restoreCommand, shellQuote } from "./shell.ts";
5import {
6 createVault,
7 entered,
8 isInert,
9 mergeTyped,
10 PLUGIN,
11 VARIABLE_NAME,
12 walkStrings,
13} from "./vault.ts";
14
15export const TOOL = `mcp__${PLUGIN}__request_secret`;
16
17export const PANE = PLUGIN;
18
19export const FIELD = "secret";
20
21const DOT = "•";
22
23const WAIT_MS = 10 * 60_000;
24
25/** A hook's own time is capped; a wait spent inside a host call is not counted, so the box is waited for in short sleeps. */
26const POLL = ["sleep", "0.3"] as const;
27
28/** Native Windows has no `sleep` executable; PowerShell's is the one every install carries. */
29const POLL_WINDOWS = [
30 "powershell",
31 "-NoProfile",
32 "-Command",
33 "Start-Sleep -Milliseconds 300",
34] as const;
35
36const PANE_ROWS = 7;
37
38/** What one `$.fs.read` takes: a larger file rejects. */
39const READ_LIMIT = 4 * 1024 * 1024;
40
41/** The tools whose stored record can hold a command's output or a file's text. */
42type Reader = "Bash" | "Read" | "Write" | "Edit" | "NotebookEdit" | "Monitor" | "GetTask" | "LSP";
43
44/** The tools that write the text the model gave them into a file, a placeholder as text. */
45type FileWrite = Extract<ToolCallInput, { tool: "Write" | "Edit" | "NotebookEdit" }>;
46
47const ANYWHERE =
48 'Write the placeholder in a Bash command where the value goes, as you would write the value: curl -H "Authorization: Bearer <placeholder>" https://..., API_KEY=<placeholder> npm test.';
49
50const WHOLE_WORD = [
51 "Write the placeholder as a whole single-quoted word, '<placeholder>', in one Bash command or a pipe of two:",
52 `${FIXES.secret}, ${FIXES.bearer};`,
53 `to build a longer text with it, pipe it, as in ${FIXES.header}`,
54].join(" ");
55
56const DESCRIPTION = [
57 "Asks the user for a secret (an API key, a token, a password) in a masked box beside the transcript, so the value never enters the conversation.",
58 "Answers the variable name and a placeholder of the form [secret-pane:NAME:xxxxxxxx], which becomes the real value inside a Bash command; the value is hidden again in everything that comes back.",
59 ANYWHERE,
60 "A value holding characters the shell reads (a space, a quote, $...) goes in only as a whole single-quoted word, '<placeholder>', and the answer says which applies.",
61 "A command is refused, with the reason and the fix, when it would put the value where it cannot be taken back (a git subcommand that writes history or sends it out, a gh command other than gh secret, a commit while a change holds the value) or runs in the background.",
62 "No other tool restores a placeholder: Write and Edit refuse one. The value lives in this session's memory only: the mod writes nothing to disk, and a new session or /clear asks the user again.",
63 "Use it in place of asking the user to paste a secret into the chat.",
64].join(" ");
65
66const WITHHELD = `${PLUGIN}: could not check this result for a kept value, so the result was withheld. Narrow the call (head, grep, offset) and retry.`;
67
68const NOT_RUN = `${PLUGIN}: could not check this command against the kept values, so it was not run.`;
69
70const BLOCK_WITHHELD = `[${PLUGIN} withheld this text: the check for a kept value failed]`;
71
72const WRITE_UNCHECKED = `${PLUGIN}: could not check this text for a placeholder of this session, so the file was not written.`;
73
74const RUN = "Run it";
75
76const SKIP = "Don't run it";
77
78type Pending = {
79 readonly name: string;
80 readonly reason: string;
81 value: string;
82 error: string | null;
83 answer: string | undefined;
84 isInBand: boolean;
85};
86
87let pending: Pending | null = null;
88
89const vault = createVault();
90
91/** Each command restored for a call in flight, mapped to the command as Claude wrote it. */
92const asWritten = new Map<string, string>();
93
94/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof -- a tool argument is the model's own, `unknown` as the engine hands it; these reads ARE the boundary. */
95function argument(value: unknown): string {
96 return typeof value === "string" ? value.trim() : "";
97}
98
99/** The command a permission check reads for Bash, or nothing where the input is no Bash input. */
100function shellCommand(input: unknown): string | null {
101 if (typeof input !== "object" || input === null || !("command" in input)) return null;
102
103 return typeof input.command === "string" ? input.command : null;
104}
105/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof */
106
107type ReadResult = ToolCallResult<Reader>;
108
109type Answer = Exclude<ReadResult, { deny: string }>;
110
111type Errored = Extract<ReadResult, { isError: true }>;
112
113type Answered = Exclude<Answer, Errored>;
114
115/** The tool's answer with every kept value hidden, and without `ref`: core would read its own messages verbatim with it. */
116function hidden(ran: Answer, hide: (text: string) => string): ReadResult {
117 if (ran.isError === true) {
118 const errored: Errored = { isError: true, result: walkStrings(ran.result, hide) };
119
120 if (ran.text !== undefined) errored.text = hide(ran.text);
121
122 if (ran.context !== undefined) errored.context = ran.context.map(hide);
123
124 return errored;
125 }
126
127 // SAFETY: `walkStrings` keeps a plain value's shape and rewrites its strings alone, and a tool's record is plain data.
128 const result = walkStrings(ran.result, hide) as Answered["result"];
129 const answered: Answered = { result };
130
131 if (ran.context !== undefined) answered.context = ran.context.map(hide);
132
133 return answered;
134}
135
136function fileText(e: FileWrite): string {
137 if (e.tool === "Write") return e.content;
138
139 return e.tool === "Edit" ? e.new_string : e.new_source;
140}
141
142function writeRefused(tag: string): string {
143 return filled(
144 [
145 `${PLUGIN}: the file was not written: <placeholder> stands for a kept value, and this tool would write the placeholder as text.`,
146 `To put the value in a file, write it from Bash: ${FIXES.file}.`,
147 "To mention the secret without its value, write <NAME>.",
148 ].join(" "),
149 tag,
150 );
151}
152
153function hostOf($: EngineInterface): Host {
154 return {
155 run: (argv, cwd) => $.process.run(argv, cwd === undefined ? {} : { cwd }),
156 read: async (path) => {
157 const stat = await $.fs.stat(path);
158
159 return stat.kind === "file" && stat.size <= READ_LIMIT ? $.fs.read(path) : null;
160 },
161 exists: (path) => $.fs.exists(path),
162 };
163}
164
165function fixOf({ path, where }: Found): string {
166 if (where === "handed")
167 return `${path} (read by the command: leave it out, or take the value out of it)`;
168
169 return where === "tracked"
170 ? `${path} (tracked: take the value out, or stop tracking it with git rm --cached ${shellQuote(path)} and add it to .gitignore)`
171 : `${path} (untracked: add it to .gitignore)`;
172}
173
174/** Why a command that may commit or publish may not run while a change it could carry holds a kept value, or nothing. */
175async function changesRefusal($: EngineInterface, command: string): Promise<string | undefined> {
176 const targets = scanTargets(command, await $.env.get("HOME"));
177
178 if (targets.length === 0) return undefined;
179 const needles = needlesOf(vault.values());
180 const host = hostOf($);
181
182 for (const target of targets) {
183 const checked = `\`${target.label}\` may commit or publish, so the changes it could carry are read for a kept value first`;
184
185 if (target.kind === "unknown") {
186 return `${PLUGIN}: the command was not run: ${checked}, and the mod cannot tell where \`${target.spelling}\` leads: write that directory or file as a path.`;
187 }
188
189 const scan = await scanChanges(host, target, needles);
190
191 if (scan.kind === "found") {
192 return `${PLUGIN}: the command was not run: \`${target.label}\` may commit or publish, and a kept value stands in ${scan.files.map(fixOf).join(", ")}. Then run it again.`;
193 }
194
195 if (scan.kind === "failed") {
196 return `${PLUGIN}: the command was not run: ${checked}, and that check failed: ${scan.why}. Fix what it names and run it again; a /clear forgets the kept values, and the check with them.`;
197 }
198 }
199
200 return undefined;
201}
202
203function answerText(name: string, tag: string, value: string): string {
204 const kept = `The user entered ${name} (${value.length} characters). The value is kept in this session's memory; you can't see it. Its placeholder: ${tag}.`;
205
206 if (isInert(value)) return `${kept} ${filled(ANYWHERE, tag)}`;
207
208 return `${kept} The value holds characters the shell reads. ${filled(WHOLE_WORD, tag)}`;
209}
210
211type Drawn = Parameters<EngineInterface["ui"]["resolve"]>[0];
212
213/** A surface that draws an `Input`: every one but mobile, whose table has none. */
214type Typed = Drawn & { readonly surface: "terminal" | "desktop" | "vscode" };
215
216function drawBox($: EngineInterface, e: Typed, p: Pending) {
217 const { Box, Text, Input } = $.ui.resolve(e);
218 const focusHint = p.isInBand ? "ctrl+x then tab to focus, Enter on empty cancels" : "Esc cancels";
219 const count = p.value.length === 0 ? "" : `${p.value.length} characters · `;
220
221 return Box({
222 flexDirection: "column",
223 paddingX: 1,
224 children: [
225 Text({ bold: true, children: `Claude is asking for ${p.name}` }),
226 ...(p.reason === "" ? [] : [Text({ dimColor: true, children: p.reason })]),
227 Input({
228 key: FIELD,
229 label: "value ",
230 placeholder: "paste it here, it stays masked",
231 value: DOT.repeat(p.value.length),
232 submitLabel: "save",
233 autoFocus: true,
234 onInput: (drawn) => {
235 const merged = mergeTyped(p.value, drawn, DOT);
236
237 p.value = merged ?? "";
238 p.error =
239 merged === null
240 ? "an edit in the middle of the field cannot be read: the field was emptied, paste the value again"
241 : null;
242 $.ui.invalidate("ui.render");
243 },
244 onSubmit: () => {
245 if (p.isInBand && p.value.trim() === "") {
246 p.answer = "";
247
248 return;
249 }
250
251 const kept = entered(p.value);
252
253 if (kept.kind === "kept") p.answer = kept.value;
254 else p.error = kept.why;
255 $.ui.invalidate("ui.render");
256 },
257 }),
258 ...(p.error === null ? [] : [Text({ color: "yellow", children: p.error })]),
259 Text({
260 dimColor: true,
261 children: `${count}Enter saves · ${focusHint} · Claude never sees the value`,
262 }),
263 ],
264 });
265}
266
267/** Every text a row carries the model reads, with each kept value hidden; the row itself when none was. */
268function hiddenContent<B extends { readonly type: string }>(
269 content: readonly B[],
270 hide: (text: string) => string,
271): B[] {
272 return content.map((block) => {
273 if (block.type !== "text" && block.type !== "tool_result") return block;
274
275 // SAFETY: a text block's and a tool_result's content are plain data, and walkStrings keeps their shape.
276 return walkStrings(block, hide) as B;
277 });
278}
279
280function forget(): void {
281 vault.clear();
282 asWritten.clear();
283 pending = null;
284}
285
286export const register: Register = (on) => {
287 on("session.start", async ($, e, next) => {
288 forget();
289 await $.tool.register({
290 name: "request_secret",
291 description: DESCRIPTION,
292 inputSchema: {
293 type: "object",
294 properties: {
295 name: {
296 type: "string",
297 description: "The variable name the secret is known by, such as PARENT_REPO_PAT",
298 },
299 reason: {
300 type: "string",
301 description: "One short line telling the user why it is needed",
302 },
303 },
304 required: ["name", "reason"],
305 },
306 isDeferred: false,
307 });
308
309 return next(e);
310 });
311
312 // A /clear ends the conversation with no session.start after it: the values go with it.
313 on("session.end", (_$, e, next) => {
314 forget();
315
316 return next(e);
317 });
318
319 on("tool.call", { tool: "mcp__secret-pane__request_secret" }, async ($, e, next) => {
320 const name = argument(e.name);
321
322 if (!VARIABLE_NAME.test(name)) {
323 return {
324 result: `"${name}" is not a variable name: letters, digits and underscores, not starting with a digit.`,
325 };
326 }
327
328 if (!(await $.session.surfaces()).some((surface) => surface !== "mobile")) {
329 return {
330 result: `${PLUGIN} needs an interactive session with a terminal or a desktop to collect ${name}: nobody can type into the box here.`,
331 };
332 }
333
334 if (pending !== null) {
335 return {
336 result: `${PLUGIN} is already waiting for ${pending.name}. Ask for one secret at a time.`,
337 };
338 }
339
340 const request: Pending = {
341 name,
342 reason: argument(e.reason),
343 value: "",
344 error: null,
345 answer: undefined,
346 isInBand: false,
347 };
348
349 pending = request;
350
351 try {
352 const opened = await $.ui.open({
353 id: PANE,
354 title: PLUGIN,
355 focus: true,
356 closeOnEscape: true,
357 holdToasts: true,
358 rows: PANE_ROWS,
359 });
360
361 if (!opened.isPlaced) {
362 request.isInBand = true;
363 await $.ui.close({ id: PANE });
364 $.ui.invalidate("ui.render");
365 $.ui.toast(
366 `Claude asks for ${name}: press ctrl+x then tab to type it in the box above the prompt`,
367 { timeoutMs: 10_000 },
368 );
369 }
370
371 const poll = (await $.env.get("OS")) === "Windows_NT" ? POLL_WINDOWS : POLL;
372 const deadline = (await $.clock.now()) + WAIT_MS;
373
374 while (
375 request.answer === undefined &&
376 !next.signal.aborted &&
377 (await $.clock.now()) < deadline
378 ) {
379 await $.process.run(poll);
380 }
381 } finally {
382 // Whatever ends the wait, an Escape of the call included (it skips the hook with no `.catch`), the slot and the box go.
383 if (pending === request) pending = null;
384 await $.ui.close({ id: PANE }).catch(() => {});
385 $.ui.invalidate("ui.render");
386 }
387
388 const value = request.answer;
389
390 if (value === undefined) {
391 return { result: `${PLUGIN} waited for ${name} and nothing was entered. Nothing was kept.` };
392 }
393
394 if (value === "") return { result: `The user cancelled. ${name} was not kept.` };
395 const tag = vault.tagOf(name, value, await $.clock.now());
396
397 $.ui.toast(`${name} kept for this session`);
398
399 return { result: answerText(name, tag, value) };
400 }).catch(($, _e, next) => {
401 $.ui.log(`${PLUGIN}: request_secret failed: ${next.error.kind}`);
402
403 return { result: `${PLUGIN} failed before anything was kept. Nothing was written.` };
404 });
405
406 on("ui.render", { component: "Pane", requestId: "secret-pane" }, ($, e, next) =>
407 pending === null || e.surface === "mobile" ? next(e) : drawBox($, e, pending),
408 );
409
410 on("ui.render", { component: "AbovePrompt" }, ($, e, next) =>
411 pending === null || !pending.isInBand || e.surface === "mobile"
412 ? next(e)
413 : drawBox($, e, pending),
414 );
415
416 on("ui.close", (_$, e, next) => {
417 // A close before Enter is a cancel; one after it (the person's Escape in the poll's window, or the hook's own) changes nothing.
418 if (e.id === PANE && pending !== null && !pending.isInBand && pending.answer === undefined) {
419 pending.answer = "";
420 }
421
422 return next(e);
423 }).catch((_$, e, next) => next(e));
424
425 // The permission is decided on the command as it runs, value in. On a real call the mod restored,
426 // an ask is put to the person by the mod, the command as Claude wrote it on show: no dialog and no
427 // "always allow" rule carries the value, and in auto mode the classifier never reads it.
428 on("tool.check", { tool: "Bash" }, async ($, e, next) => {
429 const command = shellCommand(e.input);
430
431 const written =
432 e.tool_use_id === undefined || command === null ? undefined : asWritten.get(command);
433
434 if (written === undefined) return next(e);
435 const verdict = await next(e);
436
437 if (verdict.decision !== "ask") return verdict;
438
439 const choice = await $.ui
440 .ask(`${PLUGIN}: run this command? Each placeholder stands for a kept value.\n${written}`, [
441 RUN,
442 SKIP,
443 ])
444 .catch(() => SKIP);
445
446 return choice === RUN
447 ? {
448 decision: "allow",
449 reason: `${PLUGIN}: the person approved the command, its placeholder on show`,
450 }
451 : { decision: "deny", reason: `${PLUGIN}: the person refused the command` };
452 }).catch((_$, e, next) => {
453 const command = shellCommand(e.input);
454
455 if (command === null || !asWritten.has(command)) return next(e);
456
457 return {
458 decision: "deny",
459 reason: `${PLUGIN}: could not put this command to the person, so it is refused`,
460 };
461 });
462
463 // A placeholder of this session written into a file would stand there as text, where the value was meant.
464 on("tool.call", { tool: ["Write", "Edit", "NotebookEdit"] }, (_$, e, next) => {
465 const tag = vault.mintedIn(fileText(e));
466
467 return tag === undefined ? next(e) : { deny: writeRefused(tag) };
468 }).catch((_$, e, next) =>
469 next.called || !fileText(e).includes("[secret-pane:") ? next(e) : { deny: WRITE_UNCHECKED },
470 );
471
472 on(
473 "tool.call",
474 { tool: ["Bash", "Read", "Write", "Edit", "NotebookEdit", "Monitor", "GetTask", "LSP"] },
475 async ($, e, next) => {
476 let input = e;
477 let restoredCommand: string | null = null;
478
479 // An empty vault still reads the command: a placeholder of an earlier session would run as text.
480 if (e.tool === "Bash") {
481 const restoration = restoreCommand(e.command, vault.valueOf);
482
483 if (restoration.kind === "refused") {
484 return {
485 deny: `${PLUGIN}: the command was not run: ${restoration.why}${restoration.why.endsWith(".") ? "" : "."}`,
486 };
487 }
488
489 if (restoration.kind === "restored" && e.run_in_background === true) {
490 return {
491 deny: `${PLUGIN}: the command was not run: a background command's output is written to disk and read back past the mod. Run it in the foreground.`,
492 };
493 }
494
495 const unsafe = vault.isEmpty() ? undefined : await changesRefusal($, e.command);
496
497 if (unsafe !== undefined) return { deny: vault.hide(unsafe) };
498
499 if (restoration.kind === "restored") {
500 restoredCommand = restoration.command;
501 asWritten.set(restoredCommand, e.command);
502 input = { ...e, command: restoredCommand };
503 }
504 }
505
506 if (vault.isEmpty()) return next(input);
507 let changed = false;
508
509 const hide = (text: string) => {
510 const out = vault.hide(text);
511
512 if (out !== text) changed = true;
513
514 return out;
515 };
516
517 try {
518 const ran = await next(input);
519
520 if (ran.deny !== undefined) {
521 const deny = hide(ran.deny);
522
523 return changed ? { deny } : ran;
524 }
525
526 const answer = hidden(ran, hide);
527
528 return changed ? answer : ran;
529 } finally {
530 if (restoredCommand !== null) asWritten.delete(restoredCommand);
531 }
532 },
533 ).catch((_$, e, next) => {
534 const carriesTag = e.tool === "Bash" && e.command.includes("[secret-pane:");
535
536 if (vault.isEmpty() && !carriesTag) return next(e);
537
538 return { deny: next.called ? WITHHELD : NOT_RUN };
539 });
540
541 // The net under every door a text reaches the model by: a tool's result, a task's notification,
542 // a hook's context, a prompt. Only a row's text blocks and tool_result content may be rewritten.
543 on("session.append", (_$, e, next) => {
544 if (vault.isEmpty()) return next(e);
545 let changed = false;
546
547 const hide = (text: string) => {
548 const out = vault.hide(text);
549
550 if (out !== text) changed = true;
551
552 return out;
553 };
554
555 const content = hiddenContent(e.message.content, hide);
556
557 return changed ? next({ ...e, message: { ...e.message, content } }) : next(e);
558 }).catch((_$, e, next) => {
559 if (next.called || vault.isEmpty()) return next(e);
560
561 const content = hiddenContent(e.message.content, () => BLOCK_WITHHELD);
562
563 return next({ ...e, message: { ...e.message, content } });
564 });
565
566 on("prompt.submit", ($, e, next) => {
567 if (vault.isEmpty()) return next(e);
568 const text = vault.hide(e.text);
569 const context = e.context?.map((block) => vault.hide(block));
570
571 const contextChanged =
572 context !== undefined && context.some((block, at) => block !== e.context?.[at]);
573
574 if (text === e.text && !contextChanged) return next(e);
575 $.ui.toast(`${PLUGIN}: hid a kept value from the prompt`);
576
577 return next(context === undefined ? { ...e, text } : { ...e, text, context });
578 }).catch((_$, e, next) => {
579 if (next.called || vault.isEmpty()) return next(e);
580
581 return { drop: `${PLUGIN}: could not check this prompt for a kept value, so it was not sent.` };
582 });
583};
584hooks/scan.ts 493 lines1/**
2 * Before a git or gh command that may commit or publish, the changes it could
3 * carry are read for a kept value: the lines the index and the working tree
4 * add, and every untracked file not ignored. The values never leave the mod:
5 * git prints the changes, and the mod searches them.
6 */
7
8import { type Invocation, invocations, isLocal, parseInvocation, type Program } from "./shell.ts";
9
10/** The shortest line of a value searched for: shorter lines would match ordinary text. */
11const MIN_NEEDLE_LENGTH = 8;
12
13/** More untracked files than this and the scan gives up: reading them all would hold the command too long. */
14export const MAX_UNTRACKED = 1000;
15
16/** The global options of git that decide which repository and working tree a command acts on. */
17const PLACE_OPTIONS = new Set(["-C", "--git-dir", "--work-tree"]);
18
19/** The variables that set the same, as `GIT_DIR=... git commit` writes them. */
20const PLACE_VARIABLES = new Map([
21 ["GIT_DIR", "--git-dir"],
22 ["GIT_WORK_TREE", "--work-tree"],
23]);
24
25const DIFF_FLAGS = [
26 "-U0",
27 "--no-color",
28 "--no-ext-diff",
29 "--no-textconv",
30 "--src-prefix=a/",
31 "--dst-prefix=b/",
32];
33
34/**
35 * Settings every git of the scan runs with, whatever the repository says: no
36 * fsmonitor program, file names as they are, a diff of the whole tree from a
37 * subdirectory. `-c diff.relative=false` and not `--no-relative`: a git older
38 * than the option ignores an unknown setting, where it refuses the flag.
39 */
40const SETTINGS = [
41 "-c",
42 "core.fsmonitor=false",
43 "-c",
44 "core.quotePath=false",
45 "-c",
46 "diff.relative=false",
47];
48
49export type RunResult = {
50 readonly exitCode: number;
51 readonly stdout: string;
52 readonly stderr: string;
53 readonly isStdoutTruncated: boolean;
54};
55
56/** What a scan needs from the machine; the hooks module builds it from `$`, which cannot cross an import. */
57export type Host = {
58 /** Runs a command in `cwd`; absent, in the session's working directory, which follows Claude's `cd`. */
59 readonly run: (argv: readonly string[], cwd: string | undefined) => Promise<RunResult>;
60 /** A file's text, or null for what a read cannot take whole: no regular file, or one over 4 MiB. */
61 readonly read: (path: string) => Promise<string | null>;
62 /** Whether a path is there, relative to the session's directory or absolute. */
63 readonly exists: (path: string) => Promise<boolean>;
64};
65
66export type ScanTarget =
67 | {
68 readonly kind: "place";
69 readonly program: Program;
70 /** The command as a person names it, `git commit`, `gh pr`. */
71 readonly label: string;
72 /** Where the command runs, relative to the session's directory or absolute; none, that directory. */
73 readonly dir: string | undefined;
74 /** The `-C`, `--git-dir` and `--work-tree` the command gives git, handed to every git the scan runs. */
75 readonly options: readonly string[];
76 /** The files the command is handed to read and send: gh's arguments, git's message file, a file a substitution prints. */
77 readonly files: readonly string[];
78 }
79 | { readonly kind: "unknown"; readonly label: string; readonly spelling: string };
80
81/** Where a kept value was found: a change git holds, a file git would add, a file the command is handed. */
82export type Found = {
83 readonly path: string;
84 readonly where: "tracked" | "untracked" | "handed";
85};
86
87export type Scan =
88 | { readonly kind: "clean" }
89 | { readonly kind: "found"; readonly files: readonly Found[] }
90 | { readonly kind: "failed"; readonly why: string };
91
92/** A path as the shell would expand its leading `~`, or nothing where the mod cannot tell what it names. */
93function expanded(path: string, home: string | undefined): string | undefined {
94 const prefix = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u.exec(path)?.[0];
95
96 if (prefix !== undefined)
97 return home === undefined ? undefined : expanded(home + path.slice(prefix.length), undefined);
98
99 if (path === "-" || /[$`]/u.test(path)) return undefined;
100
101 return path.startsWith("~") ? undefined : path;
102}
103
104function joined(dir: string | undefined, path: string): string {
105 return dir === undefined || path.startsWith("/") ? path : `${dir}/${path}`;
106}
107
108/** Each argument of a gh command that may name a file it reads: `@file`, `name=@file` and `--option=file` included. */
109function ghPaths(args: readonly string[]): readonly string[] {
110 return args.flatMap((arg) => {
111 const value = arg.startsWith("--") && arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : arg;
112
113 const path = value.includes("=@")
114 ? value.slice(value.indexOf("=@") + 2)
115 : value.replace(/^@/u, "");
116
117 return path === "" || path.startsWith("-") || /\$\(|`/u.test(path) ? [] : [path];
118 });
119}
120
121/** The file a git command reads its message from: `-F file`, `-Ffile`, `--file file`, `--file=file`; `-` is its standard input. */
122function gitPaths(rest: readonly string[]): readonly string[] {
123 return rest.flatMap((word, at) => {
124 const next = rest[at + 1];
125
126 if ((word === "-F" || word === "--file") && next !== undefined)
127 return next === "-" ? [] : [next];
128
129 if (word.startsWith("--file="))
130 return word === "--file=-" ? [] : [word.slice("--file=".length)];
131
132 return /^-F[^-]/u.test(word) ? [word.slice(2)] : [];
133 });
134}
135
136/** The file a command substitution in an argument prints: `"$(cat body.md)"`, `"$(< body.md)"`. */
137const SUBSTITUTED_FILE = /\$\(\s*(?:cat\s+|<\s*)((?:'[^']*'|"[^"]*"|[^\s)'"<])+)\s*\)/gu;
138
139function substitutedPaths(args: readonly string[]): readonly string[] {
140 return args.flatMap((arg) =>
141 [...arg.matchAll(SUBSTITUTED_FILE)].map((match) => (match[1] ?? "").replaceAll(/['"]/gu, "")),
142 );
143}
144
145function targetOf(
146 program: Program,
147 { before, args, inputs, cds }: Invocation,
148 home: string | undefined,
149): ScanTarget {
150 const { options, subcommand, rest } = parseInvocation(program, args);
151 const label = subcommand === undefined ? program : `${program} ${subcommand}`;
152 let dir: string | undefined;
153
154 for (const cd of cds) {
155 const path = expanded(cd, home);
156
157 if (path === undefined) return { kind: "unknown", label, spelling: `cd ${cd}` };
158 dir = joined(dir, path);
159 }
160
161 // The variables git reads for its place, in front of the command, come before the options that override them.
162 const assigned = before.flatMap((word) => {
163 const [name = "", ...value] = word.split("=");
164 const option = PLACE_VARIABLES.get(name);
165
166 return option === undefined || value.length === 0
167 ? []
168 : [{ name: option, value: value.join("=") }];
169 });
170
171 const place: string[] = [];
172
173 for (const { name, value } of program === "git" ? [...assigned, ...options] : []) {
174 if (!PLACE_OPTIONS.has(name)) continue;
175 const path = value === undefined ? undefined : expanded(value, home);
176
177 if (path === undefined) return { kind: "unknown", label, spelling: `${name} ${value ?? ""}` };
178 place.push(name, path);
179 }
180
181 const files: string[] = [];
182 // git reads a relative file from where its `-C` puts it.
183 let base = dir;
184
185 for (let at = 0; at + 1 < place.length; at += 2)
186 if (place[at] === "-C") base = joined(base, place[at + 1] ?? "");
187
188 // `gh gist create .env`: the word after gh's subcommand names its action, its words may name files.
189 for (const path of program === "gh" ? ghPaths(rest.slice(1)) : []) {
190 const resolved = expanded(path, home);
191
192 if (resolved !== undefined) files.push(joined(dir, resolved));
193 }
194
195 // These name a file for certain: one the mod cannot tell is a place it cannot check.
196 const named = [
197 ...(program === "git" ? gitPaths(rest) : []).map((path) => ({ path, from: base })),
198 ...substitutedPaths(rest).map((path) => ({ path, from: program === "git" ? base : dir })),
199 ...inputs.map((path) => ({ path, from: dir })),
200 ];
201
202 for (const { path, from } of named) {
203 const resolved = expanded(path, home);
204
205 if (resolved === undefined) return { kind: "unknown", label, spelling: path };
206
207 if (!resolved.startsWith("/dev/")) files.push(joined(from, resolved));
208 }
209
210 return { kind: "place", program, label, dir, options: place, files };
211}
212
213/**
214 * Each place a git or gh command of the command may commit or publish from:
215 * a git subcommand outside the local ones, any gh subcommand but gh secret,
216 * the first one named for a place. A git or gh with no subcommand prints its
217 * help, and one run over ssh runs on another machine: neither gives one.
218 */
219export function scanTargets(command: string, home: string | undefined): readonly ScanTarget[] {
220 const targets = new Map<string, ScanTarget>();
221
222 for (const program of ["git", "gh"] as const) {
223 for (const call of invocations(command, program)) {
224 if (call.isRemote || isLocal(program, call.args)) continue;
225 const target = targetOf(program, call, home);
226
227 const key = JSON.stringify(
228 target.kind === "place"
229 ? [target.program, target.dir, target.options, target.files]
230 : target,
231 );
232
233 if (!targets.has(key)) targets.set(key, target);
234 }
235 }
236
237 return [...targets.values()];
238}
239
240/** What the scan looks for: each value's longest line, so a value spread over lines is found in a diff that splits it. */
241export function needlesOf(values: readonly string[]): readonly string[] {
242 return values.flatMap((value) => {
243 const longest = value
244 .split(/\r?\n/u)
245 .reduce((best, line) => (line.length > best.length ? line : best), "");
246
247 return longest.length >= MIN_NEEDLE_LENGTH ? [longest] : [];
248 });
249}
250
251/** The path of a `+++` header line, quotes git puts around an unusual name kept off. */
252/** The bytes git writes as a letter after a backslash in a quoted file name. */
253const NAMED_ESCAPES = new Map([
254 ["t", 9],
255 ["n", 10],
256 ['"', 34],
257 ["\\", 92],
258]);
259
260/** git's C quoting of an unusual file name undone: `\"`, `\\`, `\t`, `\n` and octal bytes. */
261function unquoted(quoted: string): string {
262 const bytes: number[] = [];
263
264 for (let at = 0; at < quoted.length; at += 1) {
265 const char = quoted[at] ?? "";
266 const octal = /^[0-7]{3}/u.exec(quoted.slice(at + 1))?.[0];
267
268 if (char === "\\" && octal !== undefined) {
269 bytes.push(Number.parseInt(octal, 8));
270 at += octal.length;
271 } else if (char === "\\" && NAMED_ESCAPES.has(quoted[at + 1] ?? "")) {
272 bytes.push(NAMED_ESCAPES.get(quoted[at + 1] ?? "") ?? 0);
273 at += 1;
274 } else {
275 bytes.push(...new TextEncoder().encode(char));
276 }
277 }
278
279 return new TextDecoder().decode(new Uint8Array(bytes));
280}
281
282/** The path of a `+++` header line: git's quotes undone, and the tab it puts after a name holding a space dropped. */
283function headerPath(line: string): string {
284 const path = line.slice("+++ ".length).replace(/\t$/u, "");
285 const bare = path.startsWith('"') && path.endsWith('"') ? unquoted(path.slice(1, -1)) : path;
286
287 return bare.startsWith("b/") ? bare.slice("b/".length) : bare;
288}
289
290/** The files whose added lines hold a needle, read off a `-U0` diff. */
291export function addedHits(diff: string, needles: readonly string[]): readonly string[] {
292 const hits = new Set<string>();
293 let path: string | undefined;
294 let isInHunk = false;
295
296 for (const line of diff.split("\n")) {
297 if (line.startsWith("diff ")) {
298 path = undefined;
299 isInHunk = false;
300 } else if (!isInHunk && line.startsWith("+++ ")) {
301 path = headerPath(line);
302 } else if (line.startsWith("@@")) {
303 isInHunk = true;
304 } else if (
305 isInHunk &&
306 path !== undefined &&
307 line.startsWith("+") &&
308 needles.some((needle) => line.includes(needle))
309 ) {
310 hits.add(path);
311 }
312 }
313
314 return [...hits];
315}
316
317function firstLine(text: string): string {
318 return text.trim().split("\n")[0] ?? "";
319}
320
321/** git's word for a place that is no repository at all. */
322const OUTSIDE = /not a git repository/u;
323
324type Ran =
325 | { readonly kind: "ran"; readonly stdout: string }
326 | { readonly kind: "failed"; readonly why: string; readonly isOutside: boolean };
327
328type Hits =
329 | { readonly kind: "read"; readonly files: readonly Found[] }
330 | { readonly kind: "failed"; readonly why: string; readonly isOutside: boolean };
331
332/** The changes of the repository at `target`, read for a needle: the lines the index and the working tree add, the untracked files. */
333async function repositoryHits(
334 host: Host,
335 target: Extract<ScanTarget, { kind: "place" }>,
336 needles: readonly string[],
337): Promise<Hits> {
338 const where = target.dir ?? "the working directory";
339
340 if (target.dir !== undefined) {
341 let isThere: boolean;
342
343 try {
344 isThere = await host.exists(target.dir);
345 } catch (error) {
346 const message = error instanceof Error ? error.message : String(error);
347
348 return {
349 kind: "failed",
350 why: `${target.dir} could not be looked up: ${message}`,
351 isOutside: false,
352 };
353 }
354
355 if (!isThere) {
356 return {
357 kind: "failed",
358 why: `${target.dir} does not exist yet: run the command that makes it in a Bash call of its own first`,
359 isOutside: false,
360 };
361 }
362 }
363
364 const git = async (args: readonly string[]): Promise<Ran> => {
365 const shown = `git ${args.join(" ")}`;
366 let ran: RunResult;
367
368 // FIXME: a repository's own clean filter (`filter.<driver>.clean` in its .git/config) runs on `git diff`,
369 // before the person is asked about the command; git has no setting that turns every filter off.
370 try {
371 ran = await host.run(["git", ...SETTINGS, ...target.options, ...args], target.dir);
372 } catch (error) {
373 const message = error instanceof Error ? error.message : String(error);
374
375 return {
376 kind: "failed",
377 why: `${shown} did not run in ${where}: ${message}`,
378 isOutside: false,
379 };
380 }
381
382 if (ran.exitCode !== 0) {
383 return {
384 kind: "failed",
385 why: `${shown} exited ${ran.exitCode} in ${where}: ${firstLine(ran.stderr)}`,
386 isOutside: OUTSIDE.test(ran.stderr),
387 };
388 }
389
390 return ran.isStdoutTruncated
391 ? {
392 kind: "failed",
393 why: `${shown} printed more than the 4 MiB the check reads`,
394 isOutside: false,
395 }
396 : { kind: "ran", stdout: ran.stdout };
397 };
398
399 const root = await git(["rev-parse", "--show-toplevel"]);
400
401 if (root.kind === "failed") return root;
402 const staged = await git(["diff", "--cached", ...DIFF_FLAGS]);
403
404 if (staged.kind === "failed") return staged;
405 const unstaged = await git(["diff", ...DIFF_FLAGS]);
406
407 if (unstaged.kind === "failed") return unstaged;
408
409 const listed = await git([
410 "ls-files",
411 "--others",
412 "--exclude-standard",
413 "-z",
414 "--full-name",
415 ":/",
416 ]);
417
418 if (listed.kind === "failed") return listed;
419 const untracked = listed.stdout.split("\0").filter((path) => path !== "");
420
421 if (untracked.length > MAX_UNTRACKED) {
422 return {
423 kind: "failed",
424 why: `${untracked.length} untracked files, more than the ${MAX_UNTRACKED} the check reads: add what is never committed to .gitignore`,
425 isOutside: false,
426 };
427 }
428
429 const files: Found[] = [
430 ...addedHits(staged.stdout, needles),
431 ...addedHits(unstaged.stdout, needles),
432 ].map((path) => ({ path, where: "tracked" }));
433
434 const top = root.stdout.trim();
435
436 for (const path of untracked) {
437 let text: string | null;
438
439 try {
440 text = await host.read(`${top}/${path}`);
441 } catch (error) {
442 const message = error instanceof Error ? error.message : String(error);
443
444 return { kind: "failed", why: `${path} could not be read: ${message}`, isOutside: false };
445 }
446
447 if (text !== null && needles.some((needle) => text.includes(needle)))
448 files.push({ path, where: "untracked" });
449 }
450
451 return { kind: "read", files };
452}
453
454/**
455 * What a command at `target` could carry, read for a needle: the files a gh
456 * command is handed, and the changes of the repository. Failed whenever the
457 * changes could not all be read, but for gh outside any repository, which
458 * carries none.
459 */
460export async function scanChanges(
461 host: Host,
462 target: Extract<ScanTarget, { kind: "place" }>,
463 needles: readonly string[],
464): Promise<Scan> {
465 if (needles.length === 0) return { kind: "clean" };
466 const files = new Map<string, Found>();
467
468 for (const path of target.files) {
469 let text: string | null;
470
471 // Most of a gh command's words are no path: one that names nothing is no file gh sends.
472 try {
473 text = (await host.exists(path)) ? await host.read(path) : null;
474 } catch (error) {
475 const message = error instanceof Error ? error.message : String(error);
476
477 return { kind: "failed", why: `${path} could not be read: ${message}` };
478 }
479
480 if (text !== null && needles.some((needle) => text.includes(needle)))
481 files.set(path, { path, where: "handed" });
482 }
483
484 const hits = await repositoryHits(host, target, needles);
485
486 if (hits.kind === "failed" && !(target.program === "gh" && hits.isOutside))
487 return { kind: "failed", why: hits.why };
488
489 for (const found of hits.kind === "read" ? hits.files : []) files.set(found.path, found);
490
491 return files.size === 0 ? { kind: "clean" } : { kind: "found", files: [...files.values()] };
492}
493hooks/shell.ts 833 lines1/**
2 * Where a Bash command may carry a kept value, and the command with each
3 * placeholder replaced by it. A value the shell reads as plain text wherever it
4 * stands (`isInert`) goes in as it is, anywhere. Any other goes in shell-quoted,
5 * as a whole single-quoted word in plain words joined by at most one pipe:
6 * anything else the lexer below reads is refused, an allow-list, since no list
7 * of what to refuse covers a shell. Either way no git or gh may write the value
8 * into history or send it out.
9 */
10
11import { isInert, nameOf } from "./vault.ts";
12
13const TAG_WORD = /^'(\[secret-pane:[A-Za-z_][A-Za-z0-9_]*:[0-9a-f]{8}\])'$/u;
14
15const TAG_ANYWHERE = /\[secret-pane:[A-Za-z_][A-Za-z0-9_]*:[0-9a-f]{8}\]/u;
16
17const TAGS = new RegExp(TAG_ANYWHERE.source, "gu");
18
19/**
20 * A placeholder right after `$` or `${` (its value would be a variable's
21 * name), after `$name` (the value would run into the name) or after `$name:`
22 * (a zsh modifier): the brace, the name, the colon, then the placeholder.
23 */
24const AFTER_DOLLAR = new RegExp(
25 String.raw`\$(\{)?(?:([A-Za-z_][A-Za-z0-9_]*)(:)?)?(?=(${TAG_ANYWHERE.source}))`,
26 "u",
27);
28
29/**
30 * The commands a refusal names as its fix, `<placeholder>` and `<NAME>` filled
31 * in: each one runs whatever the value holds, which shell.spec.ts holds them to.
32 */
33export const FIXES = {
34 file: "printf '<NAME>=%s\\n' '<placeholder>' >> .env",
35 header: "printf 'Authorization: Bearer %s' '<placeholder>' | curl -H @- https://...",
36 both: "printf 'Authorization: Bearer %s' '<placeholder>' | curl -H @- -d '<other>' https://...",
37 secret: "printf '%s' '<placeholder>' | gh secret set <NAME> --repo org/repo",
38 bearer: "curl --oauth2-bearer '<placeholder>' https://api.github.com/...",
39 credential:
40 "printf 'protocol=https\\nhost=github.com\\nusername=x-access-token\\npassword=%s\\n' '<placeholder>' | git credential approve",
41} as const;
42
43/** The fix every refusal of the strict shape ends on. */
44const ONE_COMMAND =
45 "with this value, a command naming the placeholder is one command or a pipe of two: run the others in a Bash call of their own";
46
47/** Outside quotes, any of these makes the command something other than words and one pipe. */
48const OPERATORS_REFUSED = new Set([";", "&", "(", ")", "<", "`", "$", "\n", "\\", "{", "}"]);
49
50/** Programs that run their arguments as another command: a placeholder handed to one is code. */
51const WRAPPERS = new Set([
52 "sh",
53 "bash",
54 "zsh",
55 "dash",
56 "fish",
57 "env",
58 "xargs",
59 "eval",
60 "exec",
61 "sudo",
62 "doas",
63 "nohup",
64 "time",
65 "timeout",
66 "nice",
67 "watch",
68 "command",
69 "builtin",
70 "ssh",
71 "parallel",
72 "find",
73]);
74
75/**
76 * The git subcommands that can neither write a message into history nor send text out. Any other,
77 * an alias included, may put a value where it cannot be taken back.
78 */
79const GIT_LOCAL = new Set([
80 "status",
81 "diff",
82 "log",
83 "show",
84 "fetch",
85 "pull",
86 "clone",
87 "ls-files",
88 "ls-remote",
89 "ls-tree",
90 "rev-parse",
91 "rev-list",
92 "blame",
93 "grep",
94 "describe",
95 "shortlog",
96 "reflog",
97 "cat-file",
98 "show-ref",
99 "for-each-ref",
100 "branch",
101 "checkout",
102 "switch",
103 "restore",
104 "reset",
105 "add",
106 "rm",
107 "mv",
108 "clean",
109 "init",
110 "remote",
111 "config",
112 "worktree",
113 "credential",
114 "bisect",
115 "help",
116 "version",
117]);
118
119const GIT_PUSH_REFUSED = [
120 "`git push` sends commits out, so a command naming a placeholder may not run it:",
121 `hand the token to a credential helper that stores it, with ${FIXES.credential}`,
122 "(gh's own helper keeps nothing and pushes with gh's login: gh auth status), then push in a command that names no placeholder",
123].join(" ");
124
125const GH_REFUSED = [
126 "a gh command other than gh secret sends text to GitHub, where it cannot be taken back:",
127 `store the value with ${FIXES.secret},`,
128 `call the API with ${FIXES.bearer},`,
129 "write <NAME> to mention it, or run that gh in a Bash call of its own that names no placeholder",
130].join(" ");
131
132const NOT_A_WORD = [
133 "the placeholder stands for a value holding characters the shell reads, so it goes in only as a whole single-quoted word, '<placeholder>':",
134 `to build a longer text with it, pipe it, as in ${FIXES.header}`,
135].join(" ");
136
137const MIXED = [
138 "<placeholder> could stand anywhere, but this command also names a value holding characters the shell reads,",
139 "which holds every placeholder of the command to the strict shape: write each one as a whole single-quoted word,",
140 `as in ${FIXES.both}, or run them in Bash calls of their own`,
141].join(" ");
142
143/** git's global options that take the next word as their value. */
144const GIT_VALUED_OPTIONS = new Set([
145 "-C",
146 "-c",
147 "--git-dir",
148 "--work-tree",
149 "--namespace",
150 "--exec-path",
151 "--config-env",
152]);
153
154/** gh's global options that take the next word as their value. */
155const GH_VALUED_OPTIONS = new Set(["-R", "--repo"]);
156
157/** The one gh subcommand that takes a value in order to keep it secret. */
158const GH_SINK = "secret";
159
160type Word = { readonly raw: string; readonly text: string };
161
162type Lexed =
163 | { readonly kind: "words"; readonly commands: readonly (readonly Word[])[] }
164 | { readonly kind: "refused"; readonly why: string };
165
166/**
167 * Words and pipes, quotes kept in `raw` and removed in `text`. A single-quoted
168 * span is literal; a double-quoted one may hold neither `$`, a backtick nor a
169 * backslash. Redirections `>`, `>>` and `>&` (`2>&1`) stay words: they run nothing.
170 */
171function lex(command: string): Lexed {
172 const commands: Word[][] = [[]];
173 let raw = "";
174 let text = "";
175 let at = 0;
176
177 const flush = () => {
178 if (raw !== "") commands.at(-1)?.push({ raw, text });
179 raw = "";
180 text = "";
181 };
182
183 while (at < command.length) {
184 const char = command[at] ?? "";
185
186 if (char === "'") {
187 const close = command.indexOf("'", at + 1);
188
189 if (close < 0) return { kind: "refused", why: "an unclosed single quote: close it" };
190 raw += command.slice(at, close + 1);
191 text += command.slice(at + 1, close);
192 at = close + 1;
193 continue;
194 }
195
196 if (char === '"') {
197 const close = command.indexOf('"', at + 1);
198
199 if (close < 0) return { kind: "refused", why: "an unclosed double quote: close it" };
200 const inner = command.slice(at + 1, close);
201
202 if (/[$`\\]/u.test(inner)) {
203 return {
204 kind: "refused",
205 why: 'a double-quoted span the shell expands ($, a backtick or a backslash inside "..."): with this value, quote that text with single quotes',
206 };
207 }
208
209 raw += command.slice(at, close + 1);
210 text += inner;
211 at = close + 1;
212 continue;
213 }
214
215 if (char === " " || char === "\t") {
216 flush();
217 at += 1;
218 continue;
219 }
220
221 if (char === "|") {
222 if (command[at + 1] === "|")
223 return { kind: "refused", why: `a list (\`||\`): ${ONE_COMMAND}` };
224 flush();
225
226 if (commands.length >= 2)
227 return { kind: "refused", why: `more than one pipe: ${ONE_COMMAND}` };
228 commands.push([]);
229 at += 1;
230 continue;
231 }
232
233 if (OPERATORS_REFUSED.has(char) && !(char === "&" && raw.endsWith(">"))) {
234 return {
235 kind: "refused",
236 why: `\`${char === "\n" ? "a new line" : char}\` outside quotes: ${ONE_COMMAND}`,
237 };
238 }
239
240 raw += char;
241 text += char;
242 at += 1;
243 }
244
245 flush();
246
247 if (commands.some((words) => words.length === 0))
248 return { kind: "refused", why: `an empty command around a pipe: ${ONE_COMMAND}` };
249
250 return { kind: "words", commands };
251}
252
253/**
254 * The program a word names, as Windows and macOS find it: its directory, by
255 * either slash, and a `.exe` dropped, the case folded (`Git.EXE` runs git).
256 */
257function basename(word: string): string {
258 const name = word
259 .slice(Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\")) + 1)
260 .toLowerCase();
261
262 return name.endsWith(".exe") ? name.slice(0, -".exe".length) : name;
263}
264
265export type Program = "git" | "gh";
266
267export type GlobalOption = { readonly name: string; readonly value: string | undefined };
268
269export type Parsed = {
270 readonly options: readonly GlobalOption[];
271 readonly subcommand: string | undefined;
272 /** The words after the subcommand. */
273 readonly rest: readonly string[];
274};
275
276/** A git or gh invocation's global options, each value the next word or after `=`, then the first word that is none. */
277export function parseInvocation(program: Program, args: readonly string[]): Parsed {
278 const valued = program === "git" ? GIT_VALUED_OPTIONS : GH_VALUED_OPTIONS;
279 const options: GlobalOption[] = [];
280
281 for (let at = 0; at < args.length; at += 1) {
282 const word = args[at] ?? "";
283 const equals = word.indexOf("=");
284
285 if (!word.startsWith("-")) return { options, subcommand: word, rest: args.slice(at + 1) };
286
287 if (word.startsWith("--") && equals > 0) {
288 options.push({ name: word.slice(0, equals), value: word.slice(equals + 1) });
289 } else if (valued.has(word)) {
290 options.push({ name: word, value: args[at + 1] });
291 at += 1;
292 } else {
293 options.push({ name: word, value: undefined });
294 }
295 }
296
297 return { options, subcommand: undefined, rest: [] };
298}
299
300/** Whether an invocation can neither write a message into history nor send text out: a local git subcommand, gh secret, or none. */
301export function isLocal(program: Program, args: readonly string[]): boolean {
302 const { subcommand } = parseInvocation(program, args);
303
304 if (program === "gh") return subcommand === undefined || subcommand === GH_SINK;
305
306 return subcommand === undefined || GIT_LOCAL.has(subcommand);
307}
308
309/** Why a git or gh invocation may not run beside a kept value, or nothing. */
310function publishRefusal(program: Program, args: readonly string[]): string | undefined {
311 if (isLocal(program, args)) return undefined;
312
313 if (program === "gh") return GH_REFUSED;
314 const { subcommand } = parseInvocation("git", args);
315
316 return subcommand === "push"
317 ? GIT_PUSH_REFUSED
318 : `\`git ${subcommand}\` may write the value into history or send it out, where it cannot be taken back (an alias counts as unknown): to mention the secret, write its name, or run that git in a Bash call of its own that names no placeholder`;
319}
320
321/** Why one simple command may not carry a value of the strict shape, or nothing; its git and gh are held to the local ones first. */
322function programRefusal(words: readonly Word[]): string | undefined {
323 const [program = ""] = words.map((word) => word.text);
324 const name = basename(program);
325
326 if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(program)) {
327 return "a variable assignment in front of the command: with this value, hand it to the command as an argument or on its standard input";
328 }
329
330 return WRAPPERS.has(name)
331 ? `\`${name}\` runs its arguments as another command: run that command itself`
332 : undefined;
333}
334
335/** Outside quotes, each of these ends a simple command. */
336const COMMAND_ENDS = new Set([";", "&", "|", "(", ")", "`", "\n"]);
337
338const REDIRECTION = /^(?:&>>|&>|<<<|<<-?|<>|<&|>>|>\||>&|<|>)/u;
339
340/**
341 * Where a redirection starting at `at` ends, whether a target word follows
342 * it, and whether that target is a file the command reads (`< .env`). No
343 * target after a here-string, whose word a command reads, nor after a
344 * duplicated descriptor (`2>&1`, `>&-`).
345 */
346type Redirection = { readonly end: number; readonly hasTarget: boolean; readonly isInput: boolean };
347
348function redirection(command: string, at: number): Redirection {
349 const operator = REDIRECTION.exec(command.slice(at))?.[0] ?? ">";
350 const end = at + operator.length;
351
352 const descriptor = operator.endsWith("&")
353 ? (/^[0-9]*-?/u.exec(command.slice(end))?.[0] ?? "")
354 : "";
355
356 if (operator === "<<<" || descriptor !== "")
357 return { end: end + descriptor.length, hasTarget: false, isInput: false };
358
359 return { end, hasTarget: command[end] !== "(", isInput: operator === "<" };
360}
361
362/** Inside double quotes, the characters a backslash escapes. */
363const DOUBLE_QUOTED_ESCAPES = new Set(['"', "\\", "$", "`", "\n"]);
364
365type Part =
366 | { readonly kind: "word"; readonly text: string; readonly isQuoted: boolean }
367 | { readonly kind: "input"; readonly text: string }
368 | { readonly kind: "end"; readonly char: string };
369
370/**
371 * The command cut into words and the operators that end a simple command, as
372 * the shell would cut it before any expansion: quotes and escapes removed, a
373 * comment and a redirection's target dropped. An unclosed quote runs to the end.
374 */
375function parts(command: string): readonly Part[] {
376 const out: Part[] = [];
377 let text = "";
378 let isWord = false;
379 let isQuoted = false;
380 let isTargetNext = false;
381 let isInputNext = false;
382 let at = 0;
383
384 const flush = () => {
385 if (isWord && isTargetNext && isInputNext) out.push({ kind: "input", text });
386 else if (isWord && !isTargetNext) out.push({ kind: "word", text, isQuoted });
387
388 if (isWord) isTargetNext = false;
389 text = "";
390 isWord = false;
391 isQuoted = false;
392 };
393
394 while (at < command.length) {
395 const char = command[at] ?? "";
396
397 if (char === "\\") {
398 const next = command[at + 1] ?? "";
399
400 // A backslash before a new line joins the two lines.
401 if (next !== "\n") {
402 text += next;
403 isWord = true;
404 }
405
406 at += 2;
407 } else if (char === "'") {
408 const close = command.indexOf("'", at + 1);
409 const end = close < 0 ? command.length : close;
410
411 text += command.slice(at + 1, end);
412 isWord = true;
413 isQuoted = true;
414 at = end + 1;
415 } else if (char === '"') {
416 at += 1;
417
418 while (at < command.length && command[at] !== '"') {
419 const inner = command[at] ?? "";
420 const next = command[at + 1] ?? "";
421
422 if (inner === "\\" && DOUBLE_QUOTED_ESCAPES.has(next)) {
423 text += next === "\n" ? "" : next;
424 at += 2;
425 } else {
426 text += inner;
427 at += 1;
428 }
429 }
430
431 isWord = true;
432 isQuoted = true;
433 at += 1;
434 } else if (char === "#" && !isWord) {
435 const newline = command.indexOf("\n", at);
436
437 at = newline < 0 ? command.length : newline;
438 } else if (char === " " || char === "\t" || char === "\r") {
439 flush();
440 at += 1;
441 } else if (char === "<" || char === ">" || (char === "&" && command[at + 1] === ">")) {
442 // A redirection stays in its command, wherever it stands (`git >/dev/null commit`): the number before it and its target are no words of it.
443 if (isWord && !isQuoted && /^\d+$/u.test(text)) {
444 text = "";
445 isWord = false;
446 } else {
447 flush();
448 }
449
450 const { end, hasTarget, isInput } = redirection(command, at);
451
452 isTargetNext = hasTarget;
453 isInputNext = isInput;
454 at = end;
455 } else if (COMMAND_ENDS.has(char)) {
456 flush();
457 isTargetNext = false;
458 out.push({ kind: "end", char });
459 at += 1;
460 } else {
461 text += char;
462 isWord = true;
463 at += 1;
464 }
465 }
466
467 flush();
468
469 return out;
470}
471
472export type Invocation = {
473 /** Found in the command an `ssh` hands its host: it runs on another machine. */
474 readonly isRemote: boolean;
475 /** The words before the program's in its simple command: assignments such as `GIT_DIR=...`, a wrapper. */
476 readonly before: readonly string[];
477 /** The words after the program's, to the end of its simple command. */
478 readonly args: readonly string[];
479 /** The files its standard input reads: a `<` redirection, or a `cat` piped into it. */
480 readonly inputs: readonly string[];
481 /** The targets of the `cd` commands before it that still decide where it runs, in order; none, the session's directory. */
482 readonly cds: readonly string[];
483};
484
485/** The words that may open a simple command and leave the rest of it the command it is. */
486const LEADING = new Set(["if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"]);
487
488/**
489 * Where a `cd` or `pushd` goes, as written: `~` with no argument, `-` for the
490 * directory before, which `popd` goes back to too; nothing for any other command.
491 */
492function placeChange(texts: readonly string[]): string | undefined {
493 const start = texts.findIndex((text) => !LEADING.has(text));
494 const [program = "", ...rest] = start < 0 ? [] : texts.slice(start);
495
496 if (program === "popd") return "-";
497
498 if (program !== "cd" && program !== "pushd") return undefined;
499
500 return rest.find((word) => word === "-" || !word.startsWith("-")) ?? "~";
501}
502
503function collect(
504 command: string,
505 program: Program,
506 base: readonly string[],
507 isRemote: boolean,
508 found: Invocation[],
509): void {
510 const saved: string[][] = [];
511 let cds = [...base];
512 let words: Extract<Part, { kind: "word" }>[] = [];
513 let inputs: string[] = [];
514 let piped: readonly string[] = [];
515 let isInBackticks = false;
516
517 /** Ends a simple command, and answers the files whose text it prints, for a pipe after it. */
518 const end = (): readonly string[] => {
519 const texts = words.map((word) => word.text);
520 const target = placeChange(texts);
521 const first = texts.find((text) => !LEADING.has(text)) ?? "";
522 const runsElsewhere = basename(first) === "ssh";
523 const read = [...inputs, ...piped];
524
525 if (target !== undefined) cds.push(target);
526
527 for (const [at, word] of words.entries()) {
528 if (basename(word.text) === program) {
529 found.push({
530 isRemote: isRemote || runsElsewhere,
531 before: texts.slice(0, at),
532 args: texts.slice(at + 1),
533 inputs: read,
534 cds: [...cds],
535 });
536 }
537
538 if (word.isQuoted && /[\s;&|()<>`]/u.test(word.text))
539 collect(word.text, program, cds, isRemote || runsElsewhere, found);
540 }
541
542 const printed =
543 basename(first) === "cat"
544 ? [
545 ...texts.slice(texts.indexOf(first) + 1).filter((text) => !text.startsWith("-")),
546 ...read,
547 ]
548 : [];
549
550 words = [];
551 inputs = [];
552
553 return printed;
554 };
555
556 for (const part of parts(command)) {
557 if (part.kind === "word") {
558 words.push(part);
559 continue;
560 }
561
562 if (part.kind === "input") {
563 inputs.push(part.text);
564 continue;
565 }
566
567 const printed = end();
568
569 piped = part.char === "|" ? printed : [];
570 const opens = part.char === "(" || (part.char === "`" && !isInBackticks);
571 const closes = part.char === ")" || (part.char === "`" && isInBackticks);
572
573 if (part.char === "`") isInBackticks = !isInBackticks;
574
575 if (opens) {
576 saved.push(cds);
577 cds = [...cds];
578 }
579
580 if (closes) cds = saved.pop() ?? cds;
581 }
582
583 end();
584}
585
586/**
587 * A heredoc whose delimiter is quoted (`<<'EOF'`, `<<"EOF"`, `<<\EOF`), its
588 * body text no shell expands; never the `<<` of a here-string's `<<<`.
589 */
590const LITERAL_HEREDOC =
591 /(?<!<)(<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\([^\s;&|<>()]+)))[^\n]*\n/gu;
592
593/** The programs that run the text they are handed as commands: a heredoc on their line is a script. */
594const RUNNERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "ssh", "eval", "source", "."]);
595
596/** The runners that are shell builtins, which no prefix can run: `sudo git add .` names no shell. */
597const BUILTIN_RUNNERS = new Set(["eval", "source", "."]);
598
599/** The words that may stand before a program and hand it the rest of the command. */
600const PREFIXES = new Set([
601 "sudo",
602 "doas",
603 "env",
604 "nohup",
605 "time",
606 "timeout",
607 "command",
608 "exec",
609 "nice",
610 "stdbuf",
611 "ionice",
612]);
613
614/**
615 * Whether a simple command of the line runs a heredoc's text: its program,
616 * past keywords and assignments, is a shell; or a prefix whose words, an
617 * option's value among them (`sudo -u deploy bash`), name one.
618 */
619function isRunOn(line: string): boolean {
620 return line.split(/[;&|()`]|\$\(/u).some((segment) => {
621 const words = segment
622 .trim()
623 .split(/\s+/u)
624 .map((word) => basename(word.replaceAll(/["']/gu, "")))
625 .filter((word) => !LEADING.has(word) && !/^[A-Za-z_][A-Za-z0-9_]*=/u.test(word));
626
627 const [program = ""] = words;
628
629 return PREFIXES.has(program)
630 ? words.some((word) => RUNNERS.has(word) && !BUILTIN_RUNNERS.has(word))
631 : RUNNERS.has(program);
632 });
633}
634
635/**
636 * The command with the body of each literal heredoc taken out, the commit
637 * message of `git commit -m "$(cat <<'EOF' ... EOF)"` first among them: text
638 * that never runs, which would read as commands. A body a shell on the
639 * heredoc's own line runs (`bash <<'EOF'`, `cat <<'EOF' | sh`, `ssh host
640 * <<'EOF'`) becomes an argument right after its `<<'EOF'`, quoted, so it is read as the
641 * command it is, on this machine or another.
642 */
643function withoutLiteralBodies(command: string): string {
644 let out = "";
645 let at = 0;
646
647 for (const match of command.matchAll(LITERAL_HEREDOC)) {
648 const start = match.index;
649
650 if (start < at) continue;
651 const lineStart = command.lastIndexOf("\n", start) + 1;
652 const lineEnd = start + match[0].length;
653 const isRun = isRunOn(command.slice(lineStart, lineEnd));
654 const delimiter = match[3] ?? match[4] ?? match[5] ?? "";
655 const stripsTabs = match[2] === "-";
656 // The body goes right after its own `<<'EOF'`, in the command the heredoc feeds, before any comment.
657 const tokenEnd = start + (match[1]?.length ?? 0);
658 let close = lineEnd;
659 let isClosed = false;
660
661 const body: string[] = [];
662
663 while (close < command.length) {
664 const next = command.indexOf("\n", close);
665 const end = next < 0 ? command.length : next;
666 const line = command.slice(close, end);
667
668 close = next < 0 ? command.length : next + 1;
669
670 if ((stripsTabs ? line.replace(/^\t+/u, "") : line) === delimiter) {
671 isClosed = true;
672 break;
673 }
674
675 body.push(line);
676 }
677
678 out += isRun
679 ? `${command.slice(at, tokenEnd)} ${shellQuote(body.join("\n"))}${command.slice(tokenEnd, lineEnd)}`
680 : command.slice(at, lineEnd);
681 at = isClosed ? close : command.length;
682 }
683
684 return out + command.slice(at);
685}
686
687/**
688 * Each word of the command that names `program`, with the words after it to
689 * the end of its simple command and the `cd` targets standing before it. A
690 * coarse cut on blanks, quotes and operators, no expansion; a quoted span that
691 * holds blanks or operators is read again as a command run from where it
692 * stands, so `sh -c "git commit"` names git too, and a literal heredoc's body
693 * is no command. A `cd` or `pushd` counts after a keyword or a brace too; a
694 * subshell's or a substitution's stays in it.
695 */
696export function invocations(command: string, program: Program): readonly Invocation[] {
697 const found: Invocation[] = [];
698
699 collect(withoutLiteralBodies(command), program, [], false, found);
700
701 return found;
702}
703
704/** A value as one single-quoted shell word, whatever it holds. */
705export function shellQuote(value: string): string {
706 return `'${value.replaceAll("'", `'\\''`)}'`;
707}
708
709export type Restoration =
710 | { readonly kind: "untouched" }
711 | { readonly kind: "restored"; readonly command: string; readonly handed: readonly string[] }
712 | { readonly kind: "refused"; readonly why: string };
713
714/** A text with a placeholder's own text and name where it says `<placeholder>` and `<NAME>`: a fix ready to run. */
715export function filled(text: string, tag: string): string {
716 return text.replaceAll("<placeholder>", tag).replaceAll("<NAME>", nameOf(tag));
717}
718
719/** The strict shape: each placeholder a whole single-quoted word, its value shell-quoted, in plain words joined by at most one pipe. */
720function restoreStrict(command: string, values: ReadonlyMap<string, string>): Restoration {
721 const lexed = lex(command);
722
723 if (lexed.kind === "refused") return { kind: "refused", why: lexed.why };
724 const out: string[] = [];
725
726 for (const words of lexed.commands) {
727 const restored: string[] = [];
728
729 for (const word of words) {
730 const tag = TAG_WORD.exec(word.raw)?.[1];
731 const value = tag === undefined ? undefined : values.get(tag);
732
733 if (value !== undefined) {
734 restored.push(shellQuote(value));
735 continue;
736 }
737
738 const stray = TAG_ANYWHERE.exec(word.raw)?.[0];
739
740 if (stray !== undefined) {
741 if (!isInert(values.get(stray) ?? ""))
742 return { kind: "refused", why: filled(NOT_A_WORD, stray) };
743 const other = [...values.keys()].find((key) => !isInert(values.get(key) ?? "")) ?? "";
744
745 return { kind: "refused", why: filled(MIXED, stray).replaceAll("<other>", other) };
746 }
747
748 restored.push(word.raw);
749 }
750
751 out.push(restored.join(" "));
752 }
753
754 // A value piped into a command reaches it too: every command of the pipe is held to the same rule.
755 for (const words of lexed.commands) {
756 const why = programRefusal(words);
757
758 if (why !== undefined) return { kind: "refused", why };
759 }
760
761 return { kind: "restored", command: out.join(" | "), handed: [...new Set(values.values())] };
762}
763
764/** Why a placeholder right after a `$` may not stand there, with the fix for its form. */
765function afterDollar(command: string): string | undefined {
766 const match = AFTER_DOLLAR.exec(command);
767
768 if (match === null) return undefined;
769 const [, , name, colon, tag = ""] = match;
770
771 if (name === undefined) {
772 return `a placeholder right after \`$\` or \`\${\` would turn its value into a variable's name: write ${tag} alone, with no \`$\`, \`\${\` or \`}\` around it`;
773 }
774
775 return colon === undefined
776 ? `a placeholder right after \`$${name}\` runs its value into the variable's name: end the name with braces, \`\${${name}}${tag}\``
777 : `a placeholder right after \`$${name}:\` makes its value a zsh modifier: end the name with braces, \`\${${name}}:${tag}\``;
778}
779
780/**
781 * The command with each placeholder put back, or why it may not be. A command
782 * that names no placeholder is untouched. `valueOf` answers a kept value, or
783 * nothing for a placeholder this session never minted. Once no git or gh word
784 * of the whole command may write a value into history or send it out, inert
785 * values go in as they are, wherever their placeholder stands; a command
786 * holding any other value takes the strict shape.
787 */
788export function restoreCommand(
789 command: string,
790 valueOf: (tag: string) => string | undefined,
791): Restoration {
792 const tags = new Set<string>(command.match(TAGS) ?? []);
793
794 if (tags.size === 0) return { kind: "untouched" };
795 const values = new Map<string, string>();
796
797 for (const tag of tags) {
798 const value = valueOf(tag);
799
800 if (value === undefined) {
801 return {
802 kind: "refused",
803 why: `${tag} is no placeholder of this session: a new session or a /clear forgot its value, ask for it again with request_secret`,
804 };
805 }
806
807 values.set(tag, value);
808 }
809
810 // With one placeholder, a fix names it; with more, which one it means is not the mod's to guess.
811 const [only] = tags.size === 1 ? tags : [];
812
813 for (const program of ["git", "gh"] as const) {
814 for (const { args } of invocations(command, program)) {
815 const why = publishRefusal(program, args);
816
817 if (why !== undefined)
818 return { kind: "refused", why: only === undefined ? why : filled(why, only) };
819 }
820 }
821
822 if (![...values.values()].every((value) => isInert(value))) return restoreStrict(command, values);
823 // The strict shape's lexer refuses every `$` with its own fix; an inert value goes in raw, where a `$` before it acts on it.
824 const dollar = afterDollar(command);
825
826 if (dollar !== undefined) return { kind: "refused", why: dollar };
827 let restored = command;
828
829 for (const [tag, value] of values) restored = restored.replaceAll(tag, value);
830
831 return { kind: "restored", command: restored, handed: [...new Set(values.values())] };
832}
833hooks/vault.ts 162 lines1export const PLUGIN = "secret-pane";
2
3const TAG_SOURCE = String.raw`\[secret-pane:[A-Za-z_][A-Za-z0-9_]*:[0-9a-f]{8}\]`;
4
5export const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
6
7/** The shortest value kept: shorter ones would hide ordinary words wherever they turn up. */
8export const MIN_VALUE_LENGTH = 8;
9
10/** The variable name a placeholder carries, `NAME` in `[secret-pane:NAME:xxxxxxxx]`. */
11export function nameOf(tag: string): string {
12 return tag.slice("[secret-pane:".length, tag.lastIndexOf(":"));
13}
14
15/** A value the shell reads as plain text: quoted or not, in a heredoc, after `=`, in a nested shell. */
16const INERT = /^[A-Za-z0-9_][A-Za-z0-9_.:/+=@,-]*$/u;
17
18/** Whether a value goes into a command as it is, wherever its placeholder stands; zsh expands an `=` after a `:` in an assignment. */
19export function isInert(value: string): boolean {
20 return INERT.test(value) && !value.includes(":=");
21}
22
23const WALK_DEPTH = 32;
24
25export type Vault = {
26 /** The placeholder standing for a value, minted on the first ask and stable after. */
27 readonly tagOf: (name: string, value: string, seed: number) => string;
28 /** The value behind a placeholder, or nothing for one this vault never minted. */
29 readonly valueOf: (tag: string) => string | undefined;
30 /** The first placeholder this vault minted that the text holds, or nothing. */
31 readonly mintedIn: (text: string) => string | undefined;
32 /** Every kept value, for a search the mod runs itself. */
33 readonly values: () => readonly string[];
34 /** The text with every kept value replaced by its placeholder, in one pass. */
35 readonly hide: (text: string) => string;
36 readonly isEmpty: () => boolean;
37 readonly clear: () => void;
38};
39
40function hex8(seed: number, salt: string): string {
41 let hash = 0x811c9dc5 ^ (seed >>> 0);
42
43 for (const char of `${seed}:${salt}`) {
44 hash ^= char.codePointAt(0) ?? 0;
45 hash = Math.imul(hash, 0x01000193) >>> 0;
46 }
47
48 return hash.toString(16).padStart(8, "0");
49}
50
51function escaped(value: string): string {
52 return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
53}
54
55export function createVault(): Vault {
56 const tagByValue = new Map<string, string>();
57 const valueByTag = new Map<string, string>();
58 let minted = 0;
59 let pattern: RegExp | null = null;
60
61 // One alternation, placeholders first so a value found inside one is never rewritten, then the
62 // values longest first so a value inside a longer one does not split it.
63 const matcher = (): RegExp => {
64 pattern ??= new RegExp(
65 [
66 TAG_SOURCE,
67 ...[...tagByValue.keys()]
68 .toSorted((a, b) => b.length - a.length)
69 .map((value) => escaped(value)),
70 ].join("|"),
71 "gu",
72 );
73
74 return pattern;
75 };
76
77 return {
78 tagOf: (name, value, seed) => {
79 const known = tagByValue.get(value);
80
81 if (known !== undefined) return known;
82 let tag = "";
83
84 do {
85 minted += 1;
86 tag = `[secret-pane:${name}:${hex8(seed + minted, `${name}:${minted}`)}]`;
87 } while (valueByTag.has(tag));
88
89 tagByValue.set(value, tag);
90 valueByTag.set(tag, value);
91 pattern = null;
92
93 return tag;
94 },
95 valueOf: (tag) => valueByTag.get(tag),
96 mintedIn: (text) => [...valueByTag.keys()].find((tag) => text.includes(tag)),
97 values: () => [...tagByValue.keys()],
98 hide: (text) =>
99 tagByValue.size === 0
100 ? text
101 : text.replace(matcher(), (found) => tagByValue.get(found) ?? found),
102 isEmpty: () => tagByValue.size === 0,
103 clear: () => {
104 tagByValue.clear();
105 valueByTag.clear();
106 pattern = null;
107 },
108 };
109}
110
111/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-runtime-typeof -- a tool's record is the engine's `unknown`, whatever shape the tool gave it; this walk IS the boundary that reads it, and the caller keeps the engine's own type for the shape it hands back. */
112/** Every string of a plain value rewritten, the shape kept; a value nested past the depth is left as it is. */
113export function walkStrings(value: unknown, rewrite: (text: string) => string, depth = 0): unknown {
114 if (typeof value === "string") return rewrite(value);
115
116 if (depth >= WALK_DEPTH || value === null || typeof value !== "object") return value;
117
118 if (Array.isArray(value)) return value.map((item) => walkStrings(item, rewrite, depth + 1));
119
120 return Object.fromEntries(
121 Object.entries(value).map(([key, item]) => [key, walkStrings(item, rewrite, depth + 1)]),
122 );
123}
124/* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-runtime-typeof */
125
126/**
127 * The value a masked field holds after the person typed, or nothing when the
128 * edit cannot be read. The field draws one dot per character held, so a change
129 * at the end hands back the dots kept, then what was typed or pasted; a dot
130 * after typed text is an edit in the middle, where the mod cannot tell which
131 * characters moved.
132 */
133export function mergeTyped(held: string, drawn: string, dot: string): string | null {
134 let kept = 0;
135
136 while (kept < drawn.length && kept < held.length && drawn[kept] === dot) kept += 1;
137 const typed = drawn.slice(kept);
138
139 return typed.includes(dot) ? null : held.slice(0, kept) + typed;
140}
141
142export type Entered =
143 | { readonly kind: "kept"; readonly value: string }
144 | { readonly kind: "refused"; readonly why: string };
145
146/** The value the box keeps on Enter, trimmed, or why it keeps none. */
147export function entered(held: string): Entered {
148 const value = held.trim();
149
150 if (value === "")
151 return { kind: "refused", why: "the field is empty: paste the value, or Esc to cancel" };
152
153 if (value.length < MIN_VALUE_LENGTH) {
154 return {
155 kind: "refused",
156 why: `at least ${MIN_VALUE_LENGTH} characters: a shorter value cannot be hidden wherever it turns up`,
157 };
158 }
159
160 return { kind: "kept", value };
161}
162