Files
SeedPass/js/packages/cli/src/sinks.ts
T
thePR0M3TH3ANandClaude Opus 5 135a6268ce feat(tui): actually clear the clipboard after the configured delay
clipboard_clear_delay has been in the default config (45s), settable, and
read by nothing: clipboardSink copied and returned. That made TUI Secret
Mode -- whose entire purpose is to route secrets off the screen -- strictly
less safe than Python's, because it parked them on a session-wide clipboard
indefinitely.

clipboardSink now takes an optional clearAfterSeconds. When set, it schedules
a wipe after the delay, but only if the clipboard still holds the value it
put there, so a value the user copied in the meantime is left alone -- the
same guard Python's copy_to_clipboard(text, timeout) applies via a read-back.
Where the clipboard cannot be read (no paste tool), it clears unconditionally,
because leaving a secret parked is the worse outcome. The timer is unref'd:
it fires while the long-running TUI is up but never holds a process open, so
the one-shot CLI and the agent are unchanged (they pass no delay).

Both TUI clipboard paths -- Secret Mode reveals and the entry 'C' action --
now route through the profile's clipboard_clear_delay.

Tests use a fake wl-copy/wl-paste pair on PATH: the secret is wiped after the
delay, and a value copied over it in the meantime survives.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-17 22:22:12 -04:00

309 lines
11 KiB
TypeScript

/**
* Secret sinks: deliver a secret to a destination without routing it
* through the CLI's stdout (plan section 9.3). The orchestrating agent sees
* only a delivery confirmation.
*/
import { spawn } from "node:child_process";
import process from "node:process";
export interface SinkResult {
sink: string;
detail: string;
exitCode?: number;
}
/** Env var the --exec sink injects into the child process. */
export const EXEC_ENV_VAR = "SEEDPASS_SECRET";
/**
* Variables that must never reach a sink child.
*
* A helper invoked to receive one password would otherwise inherit the
* parent seed, the master password, a bearer token and the agent socket
* path — everything needed to take the whole vault.
*/
const FORBIDDEN_ENV = [
"SEEDPASS_MNEMONIC",
"SEEDPASS_PASSWORD",
"SEEDPASS_TOKEN",
"SEEDPASS_AGENT_SOCK",
"SEEDPASS_AGENT_CAP",
"SEEDPASS_APP_DIR",
];
/** Variables a child plausibly needs to run at all. */
const PASSTHROUGH_ENV = ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "TZ", "TMPDIR", "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY"];
/**
* Build the environment for a sink child: a minimal allowlist, never the
* caller's full environment.
*/
export function sinkEnv(extra: Record<string, string> = {}): Record<string, string> {
const env: Record<string, string> = {};
for (const key of PASSTHROUGH_ENV) {
const value = process.env[key];
if (value !== undefined) env[key] = value;
}
for (const key of FORBIDDEN_ENV) delete env[key];
return { ...env, ...extra };
}
/**
* Split a sink command spec into [command, ...args].
*
* Commander's variadic options stop collecting at the next token starting
* with "-", so `--exec wc -c` loses the flag. Passing the whole command as
* one quoted string (`--exec "wc -c"`) is the documented way to include
* flags; split it here.
*
* Quotes group, they do not pass through. Splitting on whitespace alone was
* wrong for the single most common sink command there is:
* `--exec 'sh -c "do the thing"'` handed sh the two tokens `"do` and
* `the` and `thing"`, so sh died on an unterminated string. Note this is
* tokenization only — no shell runs here, so there is no expansion,
* globbing, or substitution, and a `$VAR` inside the spec stays literal.
*/
export function parseCommandSpec(spec: string[]): [string, string[]] {
const parts = spec.length === 1 && /\s/.test(spec[0]!) ? tokenize(spec[0]!) : spec;
const [cmd, ...args] = parts;
if (!cmd) throw new Error("empty command");
return [cmd, args];
}
/** Whitespace split that honours '...', "...", and backslash escapes. */
function tokenize(spec: string): string[] {
const tokens: string[] = [];
let current = "";
let started = false;
let quote: "'" | '"' | null = null;
for (let i = 0; i < spec.length; i++) {
const ch = spec[i]!;
if (quote === null && (ch === " " || ch === "\t" || ch === "\n")) {
if (started) tokens.push(current);
current = "";
started = false;
continue;
}
started = true;
if (quote === null && (ch === "'" || ch === '"')) {
quote = ch;
} else if (ch === quote) {
quote = null;
} else if (ch === "\\" && quote !== "'" && i + 1 < spec.length) {
// Backslash escapes the next character, except inside single quotes
// where POSIX makes it literal.
current += spec[++i]!;
} else {
current += ch;
}
}
if (quote !== null) {
throw new Error(`unterminated ${quote === "'" ? "single" : "double"} quote in command spec`);
}
if (started) tokens.push(current);
return tokens;
}
/** Run a command with the secret injected as an env var (never on argv). */
export async function execSink(
secret: string,
command: string,
args: string[],
): Promise<SinkResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "inherit", "inherit"],
env: sinkEnv({ [EXEC_ENV_VAR]: secret }),
});
child.on("error", reject);
child.on("close", (code) => {
resolve({
sink: "exec",
detail: `ran ${command} with ${EXEC_ENV_VAR} injected`,
exitCode: code ?? -1,
});
});
});
}
/** Pipe the secret to a command's stdin. */
export async function stdinSink(
secret: string,
command: string,
args: string[],
): Promise<SinkResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["pipe", "inherit", "inherit"],
env: sinkEnv(),
});
let spawnFailed = false;
child.on("error", (e) => {
spawnFailed = true;
reject(e);
});
// A command that exits before reading stdin makes this write fail with
// EPIPE. With no listener that becomes an *unhandled* error event, which
// crashes the whole process — and the session agent runs sinks in-process,
// so a use-scoped token holder could take the agent down (dropping every
// held seed) just by naming a command that exits early. Swallow the stdin
// error: the child's exit code, delivered on `close`, is the real result.
child.stdin.on("error", () => {});
child.stdin.end(secret);
child.on("close", (code) => {
if (spawnFailed) return;
resolve({ sink: "stdin", detail: `piped to ${command}`, exitCode: code ?? -1 });
});
});
}
/**
* Clipboard tools, each paired with the command that reads the same
* selection back. The read command lets a scheduled clear check that the
* clipboard still holds *our* secret before wiping it, so it never clobbers
* something the user copied in the meantime. `paste: null` means the platform
* has a writer but no reliable reader here (Windows `clip.exe`).
*/
const CLIPBOARD_TOOLS: Array<{
copy: { cmd: string; args: string[] };
paste: { cmd: string; args: string[] } | null;
}> = [
{ copy: { cmd: "wl-copy", args: [] }, paste: { cmd: "wl-paste", args: ["-n"] } },
{
copy: { cmd: "xclip", args: ["-selection", "clipboard"] },
paste: { cmd: "xclip", args: ["-selection", "clipboard", "-o"] },
},
{
copy: { cmd: "xsel", args: ["--clipboard", "--input"] },
paste: { cmd: "xsel", args: ["--clipboard", "--output"] },
},
{ copy: { cmd: "pbcopy", args: [] }, paste: { cmd: "pbpaste", args: [] } },
{ copy: { cmd: "clip.exe", args: [] }, paste: null },
];
/**
* Feed a secret to one clipboard tool.
*
* X11/Wayland clipboard tools (xclip, wl-copy) fork a resident process that
* owns the selection until another client takes it. So we must NOT wait for
* the process to exit, and must NOT let it inherit our stdio — a resident
* child holding our stdout blocks every reader of this CLI's output. We
* detach it, ignore its stdio, and treat "stdin accepted and flushed"
* as success.
*/
/** Read the current clipboard via a paste tool; null if it cannot be read. */
function readClipboard(cmd: string, args: string[]): Promise<string | null> {
return new Promise((resolve) => {
let out = "";
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"], env: sinkEnv() });
child.on("error", () => resolve(null));
child.stdout.on("data", (c: Buffer) => {
out += c.toString("utf8");
});
child.on("close", (code) => resolve(code === 0 ? out : null));
});
}
/**
* After `delaySeconds`, clear the clipboard — but only if it still holds the
* secret we put there, so a value the user copied since is left alone. When
* the clipboard cannot be read back (no paste tool, or it failed), clear
* unconditionally: leaving a secret parked on a session-wide clipboard is the
* worse failure. Mirrors Python's copy_to_clipboard(text, timeout).
*
* The timer is unref'd: it fires while the (long-running) TUI is still up, but
* never keeps a process alive on its own. A one-shot CLI that exits before the
* delay will not clear — the same limitation Python's daemon thread has.
*/
function scheduleClipboardClear(
secret: string,
delaySeconds: number,
tool: { copy: { cmd: string; args: string[] }; paste: { cmd: string; args: string[] } | null },
): void {
const timer = setTimeout(() => {
void (async () => {
if (tool.paste) {
const current = await readClipboard(tool.paste.cmd, tool.paste.args);
// Trailing newline is common from paste tools; compare trimmed.
if (current !== null && current.replace(/\n$/, "") !== secret) return;
}
try {
await feedClipboardTool("", tool.copy.cmd, tool.copy.args);
} catch {
// Best effort: nothing useful to do if the wipe itself fails.
}
})();
}, delaySeconds * 1000);
timer.unref?.();
}
function feedClipboardTool(secret: string, cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: ["pipe", "ignore", "ignore"],
detached: true,
env: sinkEnv(),
});
let settled = false;
const fail = (e: Error) => {
if (!settled) {
settled = true;
reject(e);
}
};
child.on("error", fail);
// An immediate non-zero exit means the tool rejected the input
// (e.g. no display); a tool that stays resident never fires this.
child.on("exit", (code) => {
if (code !== 0) fail(new Error(`${cmd} exited ${code}`));
});
child.stdin.on("error", fail);
child.stdin.end(secret, () => {
// Give a failing tool a moment to report before declaring success.
setTimeout(() => {
if (!settled) {
settled = true;
child.unref();
resolve();
}
}, 150);
});
});
}
/**
* Copy the secret to the system clipboard via the first available tool.
*
* With `clearAfterSeconds > 0`, schedule a clear that wipes the value after
* the delay (see scheduleClipboardClear). The default — no options — leaves
* the clipboard untouched afterwards, preserving the behaviour the one-shot
* CLI and the agent rely on; only the long-running TUI passes a delay.
*/
export async function clipboardSink(
secret: string,
opts: { clearAfterSeconds?: number } = {},
): Promise<SinkResult> {
let lastError: unknown = new Error("no clipboard tool found");
for (const tool of CLIPBOARD_TOOLS) {
try {
await feedClipboardTool(secret, tool.copy.cmd, tool.copy.args);
const clear = opts.clearAfterSeconds ?? 0;
if (clear > 0) scheduleClipboardClear(secret, clear, tool);
return {
sink: "clipboard",
detail:
clear > 0
? `copied via ${tool.copy.cmd}; clears in ${clear}s`
: `copied via ${tool.copy.cmd}`,
};
} catch (e) {
lastError = e;
}
}
throw new Error(
`clipboard delivery failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
);
}