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…

Lets the agent use a credential without ever reading it: the value goes from your secret store to the command that needs it.
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.
| Kind | Name | What it does |
|---|---|---|
| Skill | qsm-secrets | Fires when a task needs a secret, and routes it through the wrapper. |
| Skill | content-denylist | Builds a machine-local gitleaks rule list of employer, client and internal names, after you approve it. |
| Hook | tool.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. |
| Hook | tool.call (Read, Grep) | Denies reading the keyring files, the vault's data directory and the rendered config directory. |
| Hook | tool.call (use by id) | Runs a command holding {{secret:<id>}} with the value in one environment variable. |
| Hook | session.append | Masks 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. |
| Hook | prompt.submit | Stores 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. |
| Hook | session.end | Scrubs every stored value from the transcript and its subagents' and removes rendered files. |
| CLI | qsm-secrets | 20 verbs, see USAGE.md. |
| Pane | qml/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. |
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.
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.
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.token=, secret:).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.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.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.~/.config/qsm-marketplace/colors.json, which install writes once from the defaults and never replaces; an open pane takes an edit at once.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.SSH_ASKPASS_PROMPT=confirm ssh gets a yes or no (Allow is exit 0), with none the pane shows the prompt as a notice.missing. It never falls back to Bitwarden.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.${XDG_CONFIG_HOME:-~/.config}/qsm/content-denylist.toml, never in a repository.hooks/register.tsx 391 lines1// 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}
391lib/protected.ts 162 lines1// 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}
162lib/substitute.ts 62 lines1// 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}
62hooks/noun.generated.ts 139 lines1// 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}
139types/index.d.ts 68 lines1// 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