SLOPSHOPPER

secret-pane

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…

panebandguardtoastprompttool
★ 6v0.3.0MITupdated 2026-10-15bengous/claude-code-plugins/secret-pane
A shopper browsing a rack in a slop shop
README

secret-pane

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.

Install

/plugin marketplace add bengous/claude-code-plugins
/plugin install secret-pane@bengous-plugins

Use

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.

  • Paste the value into the box. It draws dots and a character count, never the text.
  • Enter keeps it, trimmed. A value under 8 characters is not kept: the box says so and waits. A toast says when it is kept, and Claude gets the placeholder.
  • Esc cancels. Claude is told, and nothing is kept.

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:

  • the session never minted the placeholder (an earlier session's, one from before a /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;
  • a git or gh word anywhere in it, 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;
  • for a value of the strict shape, the placeholder is not a whole single-quoted word, or the command holds ;, & 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;
  • it runs in the background: its output is written to disk and read back past the mod.

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.

How it works

  • session.start registers the tool with isDeferred: false, so Claude sees it on every turn.
  • The box is a pane opened with $.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.
  • On a terminal narrower than 144 columns Claude Code does not place a pane nobody asked for, so the box draws in the band above the prompt instead: press ctrl+x then tab to type in it; Enter on an empty field cancels.
  • A Bash command is read in this order before it runs. A placeholder the session never minted refuses it. Each git and gh word of the whole command is held to the local subcommands and 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.
  • The vault maps each value to a placeholder that stays stable within the session, in the module's memory only; 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.
  • Two layers hide a value on the way back. A 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.
  • While a value is kept, a Bash command that may commit or publish is checked before it runs, placeholder or not: one that names a git subcommand outside the local ones, or a gh subcommand other than 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.
  • The permission is decided on the command as it runs, value in. On a real call the mod restored, a 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.
  • The hooks that guard fail closed: a check that breaks withholds the result or refuses the call, and a row whose check breaks reaches the model with its text withheld.

Limits

  • It hides what Claude reads, not what Claude does. With the placeholder, Claude can still run a command that transforms the value (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.
  • The answer to 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.
  • An inert value goes in as it is, so a shell construct around its placeholder still acts on it: {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.
  • The inert shape is checked in bash: 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.
  • The command with the real value in it is what runs: a settings 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.
  • Only Bash gets the real value. Write, Edit and NotebookEdit refuse a placeholder of this session; a step that needs the value in a file is a Bash command that writes it (printf 'NAME=%s\n' '<placeholder>' >> .env), and the file then holds the value in clear.
  • Tools outside the 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.
  • The check before a commit or a publish reads the changes as they stand before the command, never the history: a value committed by a way the mod does not see (a script, a subagent's worktree, a script a heredoc writes and the same command runs) is pushed with the rest, and so is a file the command itself brings in, 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.
  • To read the changes, the mod runs git in the repository the command names, before you are asked about the command: with no fsmonitor program, but a clean filter the repository's own .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.
  • The command is read as text, not as the shell runs it: a 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.
  • The engine writes a background task's output to a file under its temp directory; a command holding a placeholder is refused in the background for that reason, but a command with no placeholder that prints a kept value in the background leaves it there.
  • The vault is memory only: a hot reload of the mod, a new session or a /clear forgets every value, and a placeholder from before then refuses the command that names it. Claude asks again.
  • One secret at a time: a second 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.
  • A paste can show for one frame before it is redrawn as dots.
  • The box in a live terminal, the band fallback and the wait on Windows (powershell -NoProfile -Command Start-Sleep) are not measured.
  • The wait spawns one short process every 0.3 s while the box is open, each a process.run other mods can see; a single process ended from the box would do with one, its abort not measured.

Develop

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.

Source 4 files
hooks/register.ts 584 lines
1import 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};
584
hooks/scan.ts 493 lines
1/**
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}
493
hooks/shell.ts 833 lines
1/**
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}
833
hooks/vault.ts 162 lines
1export 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