SLOPSHOPPER

qsm-secrets

A credential reaches the command that needs it without its value reaching the agent: one compiled binary, qsm-secrets, that resolves a key from a Bitwarden…

newguardtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qsm-secrets
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ qsm-secrets │ ● qsm-secrets: prompt.submit failed (throw): qsm-secrets capture printe│ qsm-secrets ERROR: tool.call: qsm-secrets │ ● qsm-secrets: capture failed; the prompt was dropped │ guard printed no outcome envelope (exit │ ⏺ Read(src/auth.ts) │ 0): no stderr │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ qsm-secrets │ ⏺ Bash(bun test) │ qsm-secrets ERROR: tool.call: qsm-secrets │ ⎿ Denied by qsm-secrets: qsm-secrets: the credential guard failed (t│ guard printed no outcome envelope (exit │ │ 0): no stderr │ ● Done. refresh now rejects expired claims and logs an audit event. ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM ● qsm-secrets: session.append failed (throw): qsm-secrets mask printed no outcome envelope (exit 0): no stderr ● qsm-secrets: the mask failed; the row's text was withheld ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

qsm-secrets

Lets the agent use a credential without ever reading it: the value goes from your secret store to the command that needs it.

Why it exists

A token that enters the agent's context is a token in a transcript, a log, and every model call after it. qsm-secrets exists so the agent can use a credential without ever reading it: the secret flows from the store into the command's environment or pipe, and the agent sees only that it was there. It takes the stance that the agent has no need to know a secret's value, only that the task ran. It refuses to be a password manager or a place where secrets are typed. When the harness carries secrets to commands on its own, this plugin can go.

What you get

KindNameWhat it does
Skillqsm-secretsFires when a task needs a secret, and routes it through the wrapper.
Skillcontent-denylistBuilds a machine-local gitleaks rule list of employer, client and internal names, after you approve it.
Hooktool.call (guard)Denies a Bash command that reads a stored value outside the wrapper, read from its syntax tree through sudo, env, xargs, flock, find -exec, qsm-secrets env -- <cmd>, sh -c, subshells and pipelines, with a code and the verb to use instead. It also denies a placeholder inside single quotes.
Hooktool.call (Read, Grep)Denies reading the keyring files, the vault's data directory and the rendered config directory.
Hooktool.call (use by id)Runs a command holding {{secret:<id>}} with the value in one environment variable.
Hooksession.appendMasks every stored value in every row the conversation keeps (tool results, file reads, responses) before it is stored; a row it cannot mask is withheld.
Hookprompt.submitStores a pasted secret under cap_<hex> (or under <name> for a pasted name=value) and gives the model a placeholder; a prompt it cannot check is not sent.
Hooksession.endScrubs every stored value from the transcript and its subagents' and removes rendered files.
CLIqsm-secrets20 verbs, see USAGE.md.
Paneqml/askpass/A Quickshell pane that asks the person for a secret, a sudo -A, ssh or git answer, or an allow or deny, and returns it to the asker.

Install

claude plugin install qsm-secrets@qsm-marketplace
bun plugins/qsm-secrets/scripts/install.ts
qsm-setup activate qsm-secrets -- --backend bitwarden   # or libsecret
qsm-secrets doctor

Install compiles qsm-secrets into $QSM_BIN_DIR (default ~/.local/bin) and runs cargo install rbw when rbw is missing (minutes). The backend is Bitwarden through rbw, or libsecret through secret-tool, strictly local. Nothing works until you choose one. See INSTALL.md.

Use

qsm-secrets set github-token                                 # you type the value at the backend's prompt
qsm-secrets set github-token --gui                           # you type it in the askpass pane instead
qsm-secrets askpass enable --sudo --git                      # prints the fish lines for SUDO_ASKPASS and GIT_ASKPASS
qsm-secrets pipe github-token -- gh auth login --with-token  # value on stdin
qsm-secrets env npm-token=NPM_TOKEN -- npm publish           # value in one variable
qsm-secrets list                                             # key names of every namespace, never a value
qsm-secrets check npm-token=NPM_TOKEN -- npm whoami          # does the stored token still work: ok or failed
qsm-secrets rename old-name new-name                         # value never printed, old name removed last
qsm-secrets prune --dry-run                                  # captures no transcript names; drop --dry-run to remove
qsm-secrets render ./app.env.tpl --out app.env               # fills {{secret:<id>}}, prints the path

Paste a token into a prompt and the hook stores it. The model reads {{secret:cap_<hex>}} instead of the value. Paste deploy-key=<token> and it is stored as the key deploy-key in the qsm namespace, and the model reads {{secret:qsm/deploy-key}}; a name that is already stored is never overwritten, the value gets a cap_<hex> and the toast says why.

Caveats

  • A guardrail, not a sandbox: the guard reads a command's syntax tree and names the commands it runs, so it does not see a script file's body, a command built from a variable, a shell function, or a wrapper it does not know. It runs qsm-secrets guard once per Bash call, about 90 ms with the compiled binary. A command it cannot parse (zsh-only syntax such as *(.)) is refused with the cause; rewrite it in bash syntax. A guard that cannot answer denies every Bash call except qsm-secrets doctor and the install script, so a broken install can be repaired.
  • A placeholder goes in double quotes or bare: inside single quotes it is never replaced, so the guard refuses it.
  • A rendered file is plaintext while the session lives. Another process of the same user can read it. An environment variable is visible to the process that holds it.
  • Capture: the terminal already showed the text, and a secret sent before the hook existed is not recalled. A failed capture stops the prompt (it is dropped with the cause and the fix, and you send it again). A hex-only secret is caught only after a credential name (token=, secret:).
  • Masking: every row is masked against every stored value of six or more characters, one process per row. Not masked: a value printed re-encoded (base64, escaped), inside an image or PDF, or on the screen just before its row is rewritten; the session end swaps what the engine stores as made. A row the mask cannot read is withheld, with a toast. Without a chosen backend every row is withheld.
  • prune trusts the transcripts: run it after your sessions end, since a capture made in a live session is named in its transcript only once the prompt is rewritten.
  • get <key> 3>FILE is a last resort and leaves a secret on disk until you remove it.
  • set needs a terminal, or --gui, and stores one line only.
  • The default namespace is qsm (--folder or QSM_SECRETS_FOLDER); list with neither shows every namespace. A libsecret entry stored before namespaces existed fails list and mask until qsm-secrets rename <key> <new> --legacy moves it into one.
  • The askpass pane runs on Quickshell (qs on PATH) as an instance of its own, started on demand and addressed by its config path. Without qs or the installed pane the call answers missing; there is no terminal fallback. 120 seconds without an answer, or Escape, is failed with the reason.
  • The pane's colours are the qsm palette in ~/.config/qsm-marketplace/colors.json, which install writes once from the defaults and never replaces; an open pane takes an edit at once.
  • The pane names the parent process that asked, with its executable and the first 12 hex of its sha256, so you see which binary wants the answer. It is not proof: the request's nonce is in the argv of qs ipc call, which any process of your user can read, so one outside the guard can write a forged answer to the reply socket. The guard refuses an agent's Bash that calls the pane's target, runs a qsm-askpass-<kind> link or qsm-secrets askpass <kind> itself, or names the socket directory; a determined agent can still route around it. Peer credentials on the reply socket are not checked.
  • With SSH_ASKPASS_PROMPT=confirm ssh gets a yes or no (Allow is exit 0), with none the pane shows the prompt as a notice.
  • libsecret needs a Secret Service on the session bus (GNOME Keyring, KeePassXC). Without one the call answers missing. It never falls back to Bitwarden.
  • The content denylist is only as good as the names you approved. A name never written down on this machine is not found.
  • Capture catches a known token shape (ghp_, glpat-, sk-, AKIA, xox, JWT), a value after a credential name (a password from 6 characters), or a 32-character token that is random for its alphabet and is not a path, a name, an id, a hash or base64 of text. On a labelled corpus the test holds precision 1.0 and recall of at least 0.95; that guards a regression and is not the precision on your own text. The model also sees the last four characters when the secret has 20 or more.
  • render writes to $XDG_RUNTIME_DIR/qsm-secrets/<session>/<name> (directory 0700, file 0600). The session end removes it.
  • The content denylist lives at ${XDG_CONFIG_HOME:-~/.config}/qsm/content-denylist.toml, never in a repository.
Source 5 files
hooks/register.tsx 391 lines
1// qsm-secrets' function-hooks module: the noun, `$.secrets`, one method per verb, and the guard that
2// refuses a Bash command that would print a stored credential into the tool result (the decision is
3// `qsm-secrets guard`, which reads the command's syntax tree with the WASM parser compiled into the
4// binary, because this sandbox cannot load it), a Read or Grep of the files that hold credentials, and the capture of a pasted secret
5// before the prompt enters the session (the decision is `qsm-secrets capture`), and the use of a
6// captured id: replaced in the command by substitute, masked in the result by `qsm-secrets mask`.
7import type { ApiContentBlock, On, SessionAppendInput } from "claude-code";
8import { type PathEnv, protectedRoots, reaches } from "../lib/protected.ts";
9import { substitute } from "../lib/substitute.ts";
10import { argvOf, failure, registerNoun, unwrap, type VerbRow, verbs } from "./noun.generated.ts";
11
12// The guard runs before every Bash call, so a parse that hangs must not hold the call for long.
13const GUARD_TIMEOUT_MS = 5000;
14
15// A backend that wants an unlock prompt must not hold the prompt for long.
16const CAPTURE_TIMEOUT_MS = 10_000;
17
18// What a person typed or pasted: the terminal, the remote bridge, the SDK host, a channel's human. Every
19// other origin is text the engine, another session or a model wrote (a task notification, a subagent
20// hand-back, a schedule): it holds ids and paths that look like tokens, and a capture would replace them.
21const PERSON_ORIGINS: ReadonlySet<string> = new Set(["composer", "bridge", "sdk", "channel"]);
22
23/** What a failed hook says about itself in the log: the kind and the message, which names the verb,
24 * the exit code, the outcome code or stderr; never a stdout, which may hold the prompt. */
25function cause(error: { readonly kind: string; readonly message?: string }): string {
26  return `${error.kind}: ${(error.message ?? "no message").slice(0, 400)}`;
27}
28
29// differs: hooks sandbox, which imports no root lib
30const isRecord = (value: unknown): value is Record<string, unknown> =>
31  typeof value === "object" && value !== null && !Array.isArray(value);
32
33const isStrings = (value: unknown): value is string[] =>
34  Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
35
36const isBlocks = (value: unknown): value is ApiContentBlock[] =>
37  Array.isArray(value) &&
38  value.every((item: unknown) => isRecord(item) && typeof item.type === "string");
39
40interface MaskData {
41  readonly texts: string[];
42}
43
44interface CaptureData {
45  readonly text: string;
46  readonly captured: readonly { readonly id: string }[];
47  readonly taken: string[];
48}
49
50const isMaskData = (value: unknown): value is MaskData => isRecord(value) && isStrings(value.texts);
51
52const isCaptureData = (value: unknown): value is CaptureData =>
53  isRecord(value) &&
54  typeof value.text === "string" &&
55  Array.isArray(value.captured) &&
56  value.captured.every((item: unknown) => isRecord(item) && typeof item.id === "string") &&
57  isStrings(value.taken);
58
59interface Envelope {
60  readonly code: string;
61  readonly message: string | undefined;
62  readonly reason: string | undefined;
63}
64
65/** The outcome envelope a binary printed on stdout, or undefined when stdout is not one. */
66function envelopeOf(stdout: string): Envelope | undefined {
67  let parsed: unknown;
68  try {
69    parsed = JSON.parse(stdout);
70  } catch {
71    return undefined;
72  }
73  if (!isRecord(parsed) || typeof parsed.exit !== "number" || typeof parsed.code !== "string") {
74    return undefined;
75  }
76  return {
77    code: parsed.code,
78    message: typeof parsed.message === "string" ? parsed.message : undefined,
79    reason:
80      isRecord(parsed.data) && typeof parsed.data.reason === "string"
81        ? parsed.data.reason
82        : undefined,
83  };
84}
85
86/** A verb's data from a `process.run` result, checked by `isData`. unwrap quotes a stdout that is no
87 * envelope, which for capture and mask can be the prompt or the result, so that case is rebuilt from
88 * exit code and stderr. */
89function dataOf<T>(
90  argv: string[],
91  ran: { exitCode: number; stdout: string; stderr: string },
92  isData: (value: unknown) => value is T,
93): T {
94  if (envelopeOf(ran.stdout) === undefined) {
95    const stderr = ran.stderr.trim().slice(0, 300);
96    throw new Error(
97      `${argv.slice(0, 2).join(" ")} printed no outcome envelope (exit ${ran.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`,
98    );
99  }
100  const data = unwrap(argv, ran);
101  if (!isData(data)) {
102    throw new Error(`${argv.slice(0, 2).join(" ")} answered data of an unexpected shape`);
103  }
104  return data;
105}
106
107/** The row of a verb the noun was generated with. */
108function verbOf(name: string): VerbRow {
109  const row = verbs[name];
110  if (row === undefined) {
111    throw new Error(`qsm.json has no verb ${name}`);
112  }
113  return row;
114}
115
116/** What `qsm-secrets guard` said: the reason to deny with, or null when the command may run. A verb
117 * that did not answer is an error, and the hook's catch denies the call. */
118function refusalOf(ran: { exitCode: number; stdout: string; stderr: string }): string | null {
119  const envelope = envelopeOf(ran.stdout);
120  if (envelope === undefined) {
121    const stderr = ran.stderr.trim().slice(0, 300);
122    throw new Error(
123      `qsm-secrets guard printed no outcome envelope (exit ${ran.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`,
124    );
125  }
126  if (envelope.code === "ok") {
127    return null;
128  }
129  if (envelope.code === "blocked" && envelope.reason !== undefined) {
130    return envelope.reason;
131  }
132  throw new Error(
133    `qsm-secrets guard answered ${envelope.code}: ${(envelope.message ?? "").slice(0, 200)}`,
134  );
135}
136
137// The one command a broken guard lets through, so the cause can be read: it changes nothing and
138// reads no value. Reinstalling is the person's step in their own terminal, never the agent's: an
139// allowed path to an install script is a path the agent could write its own script to.
140const RECOVERY = /^\s*qsm-secrets doctor\s*$/;
141
142/** The guard runs first, on the command as written, so it sees a placeholder before the substitution
143 * rewrites it. */
144function registerGuard(on: On): void {
145  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
146    const argv = argvOf(verbOf("guard"));
147    const ran = await $.process.run(argv, { stdin: e.command, timeoutMs: GUARD_TIMEOUT_MS });
148    const reason = refusalOf(ran);
149    if (reason === null) {
150      return next(e);
151    }
152    $.ui.log(`refused a Bash command: ${reason.slice(0, 80)}`);
153    return { deny: reason };
154  }).catch(($, e, next) => {
155    failure($, "tool.call", next.error);
156    const reason = cause(next.error);
157    if (RECOVERY.test((e as { command?: string }).command ?? "")) {
158      $.ui.log("the credential guard failed; a recovery command was let through");
159      return next(e);
160    }
161    $.ui.log("the credential guard failed; the call was denied");
162    return { deny: `qsm-secrets: the credential guard failed (${reason}); the call was not run` };
163  });
164}
165
166/** Read and Grep put a file's lines into the tool result as surely as `cat` does, and a Glob names
167 * the files of the store. A Grep also walks everything under its path, so a path above a
168 * protected directory is refused too; a Glob lists names only, like `ls`, so only a path inside one is. */
169function registerFileGuard(on: On): void {
170  // The shipped types list no Grep or Glob (a build without the tool has nothing to guard), so their
171  // names are cast; a build that has the tool routes its calls here.
172  const guarded = [
173    { tool: "Read" as const, ancestors: false, globs: false },
174    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the engine's declared tool names list no Grep
175    { tool: "Grep" as "Read", ancestors: true, globs: false },
176    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the engine's declared tool names list no Glob
177    { tool: "Glob" as "Read", ancestors: false, globs: true },
178  ];
179  for (const { tool, ancestors, globs } of guarded) {
180    on("tool.call", { tool }, async ($, e, next) => {
181      const input = e as { file_path?: string; path?: string; pattern?: string };
182      // A Grep or a Glob with no path searches the working directory, so that is its path.
183      const target =
184        input.file_path ?? input.path ?? (ancestors || globs ? await $.session.cwd() : undefined);
185      if (target === undefined) {
186        return next(e);
187      }
188      const [home, dataHome, runtimeDir, passwordStore] = await Promise.all([
189        $.env.get("HOME"),
190        $.env.get("XDG_DATA_HOME"),
191        $.env.get("XDG_RUNTIME_DIR"),
192        $.env.get("PASSWORD_STORE_DIR"),
193      ]);
194      const env: PathEnv = { home, dataHome, runtimeDir, passwordStore };
195      const roots = protectedRoots(env);
196      let real: string | undefined;
197      try {
198        real = (await $.fs.stat(target, { resolve: true })).realPath;
199      } catch {
200        // a path that is not there is judged by its spelling
201      }
202      // A Glob's own pattern may start at a protected directory (`~/.password-store/**`): its part
203      // before the first wildcard is a path too.
204      const written = globs ? input.pattern?.split(/[*?[{]/)[0] : undefined;
205      const lead =
206        written !== undefined && written !== "" && !/^[/~$]/.test(written)
207          ? `${target}/${written}`
208          : written;
209      const spellings = [target, real, lead].filter(
210        (path): path is string => path !== undefined && path !== "",
211      );
212      if (!spellings.some((path) => reaches(path, roots, env, ancestors) !== undefined)) {
213        return next(e);
214      }
215      $.ui.log(`refused a ${tool} of ${target}`);
216      return {
217        deny: `[qsm-secrets keyring-files] ${tool} of ${target} would put stored credentials, or the names of the store's files, in this tool result. Use \`qsm-secrets pipe <key> -- <cmd>\` or \`qsm-secrets env <key>=<VAR> -- <cmd>\`; for a rendered config, read its template, which holds placeholders.`,
218      };
219    }).catch(($, _e, next) => {
220      failure($, "tool.call", next.error);
221      const reason = cause(next.error);
222      $.ui.log("the file guard failed; the call was denied");
223      return { deny: `qsm-secrets: the file guard failed (${reason}); the call was not run` };
224    });
225  }
226}
227
228/** A command naming an id runs with the value in its environment; its result is masked on the way
229 * into the conversation, by the session.append hook. */
230function registerSubstitution(on: On): void {
231  on("tool.call", { tool: "Bash" }, (_$, e, next) => {
232    const { command, ids } = substitute(e.command);
233    return ids.length === 0 ? next(e) : next({ ...e, command });
234  }).catch(($, _e, next) => {
235    failure($, "tool.call", next.error, next.error.kind);
236    $.ui.log("the id substitution failed; the call was denied");
237    return {
238      deny: `qsm-secrets: the id substitution failed (${cause(next.error)}); the call was not run`,
239    };
240  });
241}
242
243/**
244 * Rewrites every text of a row's content, in a fixed order: a text block, a tool_result's string
245 * content, and the text blocks inside a tool_result's content. Media and other blocks are kept.
246 */
247function mapTexts(content: ApiContentBlock[], map: (text: string) => string): ApiContentBlock[] {
248  const mapBlock = (block: ApiContentBlock): ApiContentBlock => {
249    if (block.type === "text" && typeof block.text === "string") {
250      return { ...block, text: map(block.text) };
251    }
252    if (block.type === "tool_result" && typeof block.content === "string") {
253      return { ...block, content: map(block.content) };
254    }
255    if (block.type === "tool_result" && isBlocks(block.content)) {
256      return { ...block, content: block.content.map(mapBlock) };
257    }
258    return block;
259  };
260  return content.map(mapBlock);
261}
262
263function withheld(reason: string): string {
264  return `qsm-secrets: this text could not be masked and was withheld (${reason.slice(0, 300)})`;
265}
266
267function rewritten(e: SessionAppendInput, content: ApiContentBlock[]): SessionAppendInput {
268  return { ...e, message: { ...e.message, content } };
269}
270
271/** Every row the conversation keeps, whatever its door, passes through one mask before it is stored. */
272function registerMask(on: On): void {
273  on("session.append", async ($, e, next) => {
274    const texts: string[] = [];
275    mapTexts(e.message.content, (text) => {
276      texts.push(text);
277      return text;
278    });
279    if (texts.every((text) => text === "")) {
280      return next(e);
281    }
282    const argv = argvOf(verbOf("mask"), { all: true });
283    const ran = await $.process.run(argv, {
284      stdin: JSON.stringify(texts),
285      timeoutMs: CAPTURE_TIMEOUT_MS,
286    });
287    const done = [...dataOf(argv, ran, isMaskData).texts];
288    if (done.length !== texts.length) {
289      throw new Error(`the mask returned ${done.length} texts for ${texts.length}`);
290    }
291
292    return next(
293      rewritten(
294        e,
295        mapTexts(e.message.content, () => {
296          const text = done.shift();
297          if (text === undefined) {
298            throw new Error("the mask returned too few texts");
299          }
300          return text;
301        }),
302      ),
303    );
304  }).catch(($, e, next) => {
305    // Fails closed: a row that could not be masked may hold a value, so its text is replaced.
306    failure($, "session.append", next.error, "a row could not be masked and was withheld");
307    $.ui.log("the mask failed; the row's text was withheld");
308    const notice = withheld(cause(next.error));
309    return next(
310      rewritten(
311        e,
312        mapTexts(e.message.content, (text) => (text === "" ? text : notice)),
313      ),
314    );
315  });
316}
317
318/** The session is over: its rendered files go, and a transcript holds the pasted prompt raw (the
319 * engine writes it before the capture runs), so the value in it is swapped for its placeholder. */
320function registerScrub(on: On): void {
321  on("session.end", async ($, e, next) => {
322    const session = await $.session.id();
323    // The scrub first: a failed clean must not leave the transcript holding a pasted value. A failed
324    // scrub must not leave the rendered files behind either, so each step runs and reports alone.
325    const failed: string[] = [];
326    for (const step of [
327      () => $.secrets.scrub({ transcript: session, detach: true }),
328      () => $.secrets.clean({ session }),
329    ]) {
330      try {
331        await step();
332      } catch (error) {
333        failed.push(
334          cause({
335            kind: "failed",
336            // differs: hooks sandbox, root lib's messageOf
337            message: error instanceof Error ? error.message : String(error),
338          }),
339        );
340      }
341    }
342    if (failed.length > 0) {
343      throw new Error(failed.join("; "));
344    }
345    return next(e);
346  }).catch(($, e, next) => {
347    failure($, "session.end", next.error, "the cleanup or the scrub did not finish");
348    $.ui.log("the session-end cleanup failed; the session ended");
349    return next(e);
350  });
351}
352
353export function register(on: On): void {
354  // Adds $.secrets: one method per qsm-secrets verb, generated by lib/generate-noun.ts. Never
355  // hand-edit noun.generated.ts or types/index.d.ts.
356  registerNoun(on);
357  registerGuard(on);
358  registerFileGuard(on);
359  registerSubstitution(on);
360  registerMask(on);
361  registerScrub(on);
362
363  on("prompt.submit", async ($, e, next) => {
364    if (!PERSON_ORIGINS.has(e.origin.kind)) {
365      $.ui.log(`a ${e.origin.kind} prompt is not the person's own; it was not captured`);
366      return next(e);
367    }
368    const argv = argvOf(verbOf("capture"));
369    const ran = await $.process.run(argv, { stdin: e.text, timeoutMs: CAPTURE_TIMEOUT_MS });
370    const done = dataOf(argv, ran, isCaptureData);
371    if (done.captured.length === 0) {
372      return next(e);
373    }
374    const ids = done.captured.map((c) => c.id).join(", ");
375    const why = done.taken.map((name) => `; ${name} is already stored, so it got a generated id`);
376    $.ui.toast(`qsm-secrets: captured ${done.captured.length} secret(s) as ${ids}${why.join("")}`);
377    return next({ ...e, text: done.text });
378  }).catch(($, e, next) => {
379    // Fails closed: a prompt that could not be checked may hold a value, so it is not sent. The
380    // log names the cause, never the text.
381    failure($, "prompt.submit", next.error, "capture failed, the prompt was not sent");
382    const reason = cause(next.error);
383    $.ui.log("capture failed; the prompt was dropped");
384    return next.called
385      ? next(e)
386      : {
387          drop: `qsm-secrets: capture failed (${reason}); the prompt was not sent. Fix the cause (qsm-secrets doctor names it), then send the prompt again`,
388        };
389  });
390}
391
lib/protected.ts 162 lines
1// The places a stored credential lives on disk, as a pure function of the environment. Import-free,
2// because hooks/register.tsx imports it inside the engine sandbox and the guard verb imports it too.
3
4export interface PathEnv {
5  readonly home?: string | undefined;
6  /** `XDG_DATA_HOME` */
7  readonly dataHome?: string | undefined;
8  /** `XDG_RUNTIME_DIR` */
9  readonly runtimeDir?: string | undefined;
10  /** `PASSWORD_STORE_DIR`, where `pass` keeps its gpg files when that is not `~/.password-store` */
11  readonly passwordStore?: string | undefined;
12}
13
14export interface Root {
15  readonly path: string;
16  /** `store` holds credentials themselves; `rendered` holds config files with values filled in,
17   * which a command may be handed by path but never read; `askpass` holds the reply sockets of the
18   * askpass pane, which no command may name. */
19  readonly kind: "store" | "rendered" | "askpass";
20}
21
22/** The variables a path is resolved through; the same set `expand` substitutes and the guard watches. */
23export const PATH_VARIABLES: ReadonlySet<string> = new Set([
24  "HOME",
25  "XDG_DATA_HOME",
26  "XDG_RUNTIME_DIR",
27  "PASSWORD_STORE_DIR",
28]);
29
30/** The path variables of a process environment, as a `PathEnv`. */
31export function pathEnvOf(env: Readonly<Record<string, string | undefined>>): PathEnv {
32  return {
33    home: env.HOME,
34    dataHome: env.XDG_DATA_HOME,
35    runtimeDir: env.XDG_RUNTIME_DIR,
36    passwordStore: env.PASSWORD_STORE_DIR,
37  };
38}
39
40/** The keyring files, the rbw data directory, the pass store and the rendered config directory. */
41export function protectedRoots(env: PathEnv): Root[] {
42  const roots: Root[] = [];
43  const data = env.dataHome ?? (env.home === undefined ? undefined : `${env.home}/.local/share`);
44  if (data !== undefined) {
45    roots.push({ path: `${data}/keyrings`, kind: "store" }, { path: `${data}/rbw`, kind: "store" });
46  }
47  if (env.home !== undefined) {
48    roots.push({ path: `${env.home}/.password-store`, kind: "store" });
49  }
50  if (env.passwordStore !== undefined && env.passwordStore !== "") {
51    roots.push({ path: normalizePath(expand(env.passwordStore, env)), kind: "store" });
52  }
53  if (env.runtimeDir !== undefined && env.runtimeDir !== "") {
54    roots.push({ path: `${env.runtimeDir}/qsm-secrets`, kind: "rendered" });
55  }
56  return roots;
57}
58
59/** `~`, `$HOME`, `${HOME}` and the two XDG variables as the shell would expand them; a variable
60 * that is not set stays as written. */
61export function expand(text: string, env: PathEnv): string {
62  let out = env.home === undefined ? text : text.replace(/^~(?=\/|$)/, env.home);
63  const variables: [string, string | undefined][] = [
64    ["HOME", env.home],
65    ["XDG_DATA_HOME", env.dataHome],
66    ["XDG_RUNTIME_DIR", env.runtimeDir],
67    ["PASSWORD_STORE_DIR", env.passwordStore],
68  ];
69  for (const [name, value] of variables) {
70    if (value !== undefined) {
71      out = out
72        .replaceAll(`\${${name}}`, value)
73        .replaceAll(new RegExp(`\\$${name}\\b`, "g"), value);
74    }
75  }
76  return out;
77}
78
79/** `a//b`, `a/./b` and `a/x/../b` folded, so a spelling cannot hide a path. */
80export function normalizePath(path: string): string {
81  const absolute = path.startsWith("/");
82  const kept: string[] = [];
83  for (const part of path.split("/")) {
84    if (part === "" || part === ".") {
85      continue;
86    }
87    if (part === ".." && kept.length > 0 && kept.at(-1) !== "..") {
88      kept.pop();
89    } else if (part !== ".." || !absolute) {
90      kept.push(part);
91    }
92  }
93  return `${absolute ? "/" : ""}${kept.join("/")}`;
94}
95
96/** The root a text mentions, in the spelling it was written or folded. For a word of a command, so a
97 * root may sit anywhere in it (`open('<root>/x')` in a `python -c` body). */
98export function mentioned(text: string, roots: readonly Root[], env: PathEnv): Root | undefined {
99  const spelled = expand(text, env);
100  const folded = normalizePath(spelled);
101  return roots.find((root) => spelled.includes(root.path) || folded.includes(root.path));
102}
103
104/** The root a path lies inside, or, with `ancestors`, the root a search from that path would walk into. */
105export function reaches(
106  path: string,
107  roots: readonly Root[],
108  env: PathEnv,
109  ancestors: boolean,
110): Root | undefined {
111  const folded = normalizePath(expand(path, env));
112  return roots.find(
113    (root) =>
114      folded === root.path ||
115      folded.startsWith(`${root.path}/`) ||
116      (ancestors && (folded === "/" || root.path.startsWith(`${folded}/`))),
117  );
118}
119
120/** A glob word as a pattern over one path part: `*`, `?` and `[...]` as the shell reads them. */
121function partPattern(part: string): RegExp {
122  const source = Array.from(part)
123    .map((char) => {
124      if (char === "*") {
125        return "[^/]*";
126      }
127      if (char === "?") {
128        return "[^/]";
129      }
130      return /[A-Za-z0-9_\-[\]]/.test(char) ? char : `\\${char}`;
131    })
132    .join("");
133  try {
134    return new RegExp(`^${source}$`);
135  } catch {
136    return /^$/;
137  }
138}
139
140/** The root a glob word could expand to: `~/.local/share/keyr*\/login.keyring` reaches the keyring
141 * directory, because each leading part of the pattern may match the root's part at that depth. */
142export function globMentioned(
143  text: string,
144  roots: readonly Root[],
145  env: PathEnv,
146): Root | undefined {
147  if (!/[*?[]/.test(text)) {
148    return undefined;
149  }
150  const parts = normalizePath(expand(text, env)).split("/");
151  return roots.find((root) => {
152    const rootParts = root.path.split("/");
153    return (
154      parts.length >= rootParts.length &&
155      rootParts.every((rootPart, at) => {
156        const part = parts[at];
157        return part !== undefined && partPattern(part).test(rootPart);
158      })
159    );
160  });
161}
162
lib/substitute.ts 62 lines
1// The command a hook runs in place of one that names a stored secret by id. Pure and import-free,
2// because hooks/register.tsx imports it inside the engine sandbox.
3
4/** Where captured pastes are stored, apart from the keys a person set. */
5export const CAPTURE_NAMESPACE = "capture";
6
7// A placeholder is `{{secret:<id>}}` or, for a key the person named, `{{secret:<namespace>/<key>}}`;
8// for a long secret the last four characters follow as `…abcd`.
9const PLACEHOLDER = /\{\{secret:((?:[\w-]+\/)?[\w-]+)\}\}(?:…[\w-]{4})?/g;
10
11/** Each distinct id a text holds a placeholder for, in order of appearance. */
12export function placeholderIds(text: string): string[] {
13  return [
14    ...new Set([...text.matchAll(PLACEHOLDER)].flatMap((m) => (m[1] === undefined ? [] : [m[1]]))),
15  ];
16}
17
18export interface Substitution {
19  /** The command to run; the input unchanged when it names no id. */
20  readonly command: string;
21  /** Each distinct id the command names, in order of appearance. */
22  readonly ids: string[];
23}
24
25const variableOf = (id: string): string =>
26  `QSM_SECRET_${id.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}`;
27
28/** The namespace and key an id stands for: `<namespace>/<key>` as written, else a captured paste. */
29export function locate(id: string): { namespace: string; key: string } {
30  const slash = id.indexOf("/");
31  return slash === -1
32    ? { namespace: CAPTURE_NAMESPACE, key: id }
33    : { namespace: id.slice(0, slash), key: id.slice(slash + 1) };
34}
35
36const quoted = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
37
38/**
39 * `qsm-secrets env <key>=QSM_SECRET_<ID> --folder <namespace> -- sh -c '<command>'`, one `env` per id, so
40 * the value reaches that one command through its environment and is never in the command text.
41 */
42export function substitute(command: string): Substitution {
43  const ids = placeholderIds(command);
44  if (ids.length === 0) {
45    return { command, ids };
46  }
47  const owners = new Map<string, string>();
48  for (const id of ids) {
49    const taken = owners.get(variableOf(id));
50    if (taken !== undefined) {
51      throw new Error(`${taken} and ${id} would share ${variableOf(id)}; rename one of them`);
52    }
53    owners.set(variableOf(id), id);
54  }
55  const body = command.replace(PLACEHOLDER, (_, id: string) => `"$${variableOf(id)}"`);
56  const envs = ids.map((id) => {
57    const { namespace, key } = locate(id);
58    return `qsm-secrets env ${key}=${variableOf(id)} --folder ${namespace} --`;
59  });
60  return { command: `${envs.join(" ")} sh -c ${quoted(body)}`, ids };
61}
62
hooks/noun.generated.ts 139 lines
1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Secrets } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9  readonly name: string;
10  readonly flag: string | null;
11  readonly boolean?: true;
12}
13
14export interface VerbRow {
15  readonly words: readonly string[];
16  readonly args: readonly VerbArg[];
17  readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21  set: { words: ["qsm-secrets","set"], args: [{ name: "key", flag: null }, { name: "folder", flag: "--folder" }, { name: "gui", flag: "--gui", boolean: true }] },
22  askpassEnable: { words: ["qsm-secrets","askpass","enable"], args: [{ name: "sudo", flag: "--sudo", boolean: true }, { name: "ssh", flag: "--ssh", boolean: true }, { name: "git", flag: "--git", boolean: true }, { name: "decide", flag: "--decide", boolean: true }] },
23  has: { words: ["qsm-secrets","has"], args: [{ name: "key", flag: null }, { name: "folder", flag: "--folder" }] },
24  list: { words: ["qsm-secrets","list"], args: [{ name: "folder", flag: "--folder" }] },
25  inspect: { words: ["qsm-secrets","inspect"], args: [{ name: "folder", flag: "--folder" }] },
26  rm: { words: ["qsm-secrets","rm"], args: [{ name: "key", flag: null }, { name: "folder", flag: "--folder" }] },
27  rename: { words: ["qsm-secrets","rename"], args: [{ name: "from", flag: null }, { name: "to", flag: null }, { name: "folder", flag: "--folder" }, { name: "toFolder", flag: "--to-folder" }, { name: "legacy", flag: "--legacy", boolean: true }] },
28  prune: { words: ["qsm-secrets","prune"], args: [{ name: "dryRun", flag: "--dry-run", boolean: true }] },
29  guard: { words: ["qsm-secrets","guard"], args: [] },
30  capture: { words: ["qsm-secrets","capture"], args: [] },
31  mask: { words: ["qsm-secrets","mask"], args: [{ name: "id", flag: "--id" }, { name: "all", flag: "--all", boolean: true }] },
32  scrub: { words: ["qsm-secrets","scrub"], args: [{ name: "transcript", flag: null }, { name: "detach", flag: "--detach", boolean: true }] },
33  render: { words: ["qsm-secrets","render"], args: [{ name: "template", flag: null }, { name: "out", flag: "--out" }, { name: "session", flag: "--session" }] },
34  clean: { words: ["qsm-secrets","clean"], args: [{ name: "session", flag: "--session" }] },
35  doctor: { words: ["qsm-secrets","doctor"], args: [{ name: "folder", flag: "--folder" }] },
36};
37
38/** One verb's argv: its words, each option in declaration order, the
39 * passthrough rest after `--`, and `--json` last. */
40export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
41  const argv = [...row.words];
42  for (const arg of row.args) {
43    const value = options[arg.name];
44    const values = Array.isArray(value) ? value : [value];
45    for (const one of values) {
46      if (one === undefined || one === false) {
47        continue;
48      }
49      argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
50    }
51  }
52  const rest = options.rest;
53  if (row.passthrough && Array.isArray(rest)) {
54    argv.push("--", ...rest.map(String));
55  }
56  argv.push("--json");
57  return argv;
58}
59
60/** The binary prints one outcome envelope on stdout under --json:
61 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
62 * code and message become the error. A stale or misbehaving binary that
63 * prints something else (not JSON, or JSON with no numeric `exit`) throws
64 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
65export function unwrap(
66  argv: readonly string[],
67  result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
68  label = argv.slice(0, 2).join(" "),
69): unknown {
70  const text = result.stdout.trim();
71  const stderr = result.stderr.trim().slice(0, 200);
72  const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
73  if (text.length === 0) {
74    throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
75  }
76  let parsed: unknown;
77  try {
78    parsed = JSON.parse(text);
79  } catch {
80    parsed = undefined;
81  }
82  if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
83    throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
84  }
85  const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
86  if (envelope.exit !== 0) {
87    throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
88  }
89  return envelope.data;
90}
91
92/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
93 * when the cause may carry something the toast must not show, else the cause; the cause goes to
94 * the log and comes back for the caller's own policy. */
95export function failure(
96  $: EngineInterface,
97  event: string,
98  error: Caught["error"],
99  fixedReason?: string,
100): string {
101  const said = error.message ?? error.kind;
102  $.ui.toast(`qsm-secrets ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
103  $.ui.log(`${event} failed (${error.kind}): ${said}`);
104  return said;
105}
106
107/** Adds $.secrets to the engine. Call once from the plugin's own
108 * `register(on, options)` — never call the engine.create hook's own
109 * `next(e)` result from anywhere but here. */
110export function registerNoun(on: On): void {
111  on("engine.create", async (_$, e, next) => {
112    const beneath = await next(e);
113    const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
114      const argv = argvOf(row, options);
115      let result: Awaited<ReturnType<typeof beneath.process.run>>;
116      try {
117        result = await beneath.process.run(argv);
118      } catch (error) {
119        // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
120        // check that cannot run leaves the spawn's own error as the cause.
121        const gone = await beneath.session
122          .cwd()
123          .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
124          .catch(() => null);
125        if (gone !== null) {
126          throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
127        }
128        throw error;
129      }
130      return unwrap(argv, result, row.words.join(" "));
131    };
132    const methods = Object.entries(verbs).map(([method, row]) => [
133      method,
134      (options?: Readonly<Record<string, unknown>>) => run(row, options),
135    ]);
136    return { ...beneath, secrets: Object.fromEntries(methods) as Secrets };
137  });
138}
139
types/index.d.ts 68 lines
1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Secrets = {
5  set(options: {
6    key: string;
7    folder?: string;
8    gui?: boolean;
9  }): Promise<unknown>;
10  askpassEnable(options: {
11    sudo?: boolean;
12    ssh?: boolean;
13    git?: boolean;
14    decide?: boolean;
15  }): Promise<unknown>;
16  has(options: {
17    key: string;
18    folder?: string;
19  }): Promise<unknown>;
20  list(options: {
21    folder?: string;
22  }): Promise<unknown>;
23  inspect(options: {
24    folder?: string;
25  }): Promise<unknown>;
26  rm(options: {
27    key: string;
28    folder?: string;
29  }): Promise<unknown>;
30  rename(options: {
31    from: string;
32    to: string;
33    folder?: string;
34    toFolder?: string;
35    legacy?: boolean;
36  }): Promise<unknown>;
37  prune(options: {
38    dryRun?: boolean;
39  }): Promise<unknown>;
40  guard(): Promise<unknown>;
41  capture(): Promise<unknown>;
42  mask(options: {
43    id?: (string)[];
44    all?: boolean;
45  }): Promise<unknown>;
46  scrub(options: {
47    transcript: string;
48    detach?: boolean;
49  }): Promise<unknown>;
50  render(options: {
51    template: string;
52    out: string;
53    session?: string;
54  }): Promise<unknown>;
55  clean(options: {
56    session?: string;
57  }): Promise<unknown>;
58  doctor(options: {
59    folder?: string;
60  }): Promise<unknown>;
61};
62
63declare module 'claude-code' {
64  interface EngineInterface {
65    secrets: Secrets;
66  }
67}
68