mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 03:12:07 +00:00
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
This commit is contained in:
1 parent
7f6edf10cc
commit
135a6268ce
3 files changed
+181
-13
No files matched your search
@@ -159,12 +159,28 @@ export async function stdinSink(
|
||||
});
|
||||
}
|
||||
|
||||
const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: "wl-copy", args: [] },
|
||||
{ cmd: "xclip", args: ["-selection", "clipboard"] },
|
||||
{ cmd: "xsel", args: ["--clipboard", "--input"] },
|
||||
{ cmd: "pbcopy", args: [] },
|
||||
{ cmd: "clip.exe", args: [] },
|
||||
/**
|
||||
* 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 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -177,6 +193,52 @@ const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [
|
||||
* 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, {
|
||||
@@ -211,13 +273,31 @@ function feedClipboardTool(secret: string, cmd: string, args: string[]): Promise
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy the secret to the system clipboard via the first available tool. */
|
||||
export async function clipboardSink(secret: string): Promise<SinkResult> {
|
||||
/**
|
||||
* 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 { cmd, args } of CLIPBOARD_COMMANDS) {
|
||||
for (const tool of CLIPBOARD_TOOLS) {
|
||||
try {
|
||||
await feedClipboardTool(secret, cmd, args);
|
||||
return { sink: "clipboard", detail: `copied via ${cmd}` };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,21 @@ function secretModeOn(s: Session): boolean {
|
||||
return Boolean(s.config["secret_mode_enabled"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a copied secret should sit on the clipboard before being wiped.
|
||||
* 0 (or a non-positive value) disables the auto-clear. Parity with Python's
|
||||
* clipboard_clear_delay.
|
||||
*/
|
||||
function clipboardClearSeconds(s: Session): number {
|
||||
const raw = Number(s.config["clipboard_clear_delay"] ?? 0);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
||||
}
|
||||
|
||||
/** Copy a secret to the clipboard, honouring the configured auto-clear delay. */
|
||||
async function copyToClipboard(s: Session, value: string): Promise<{ detail: string }> {
|
||||
return clipboardSink(value, { clearAfterSeconds: clipboardClearSeconds(s) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a secret, honouring Secret Mode.
|
||||
*
|
||||
@@ -155,7 +170,7 @@ function secretModeOn(s: Session): boolean {
|
||||
async function reveal(s: Session, value: string, descriptor: string): Promise<void> {
|
||||
if (secretModeOn(s)) {
|
||||
try {
|
||||
const result = await clipboardSink(value);
|
||||
const result = await copyToClipboard(s, value);
|
||||
ok(s.ui, `${descriptor} copied to clipboard (${result.detail}).`);
|
||||
} catch (e) {
|
||||
fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`);
|
||||
@@ -630,7 +645,7 @@ async function entryDetails(s: Session, id: string): Promise<void> {
|
||||
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic);
|
||||
if (choice === "c") {
|
||||
try {
|
||||
const r = await clipboardSink(secret.value);
|
||||
const r = await copyToClipboard(s, secret.value);
|
||||
ok(s.ui, `Copied ${secret.descriptor} (${r.detail}).`);
|
||||
} catch (e) {
|
||||
fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`);
|
||||
|
||||
@@ -68,3 +68,76 @@ describe("parseCommandSpec", () => {
|
||||
expect(() => parseCommandSpec([""])).toThrow(/empty command/);
|
||||
});
|
||||
});
|
||||
|
||||
import { clipboardSink } from "../src/sinks.js";
|
||||
import { mkdtemp, writeFile, readFile, chmod } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
/**
|
||||
* A fake wl-copy/wl-paste pair backed by a state file, placed first on PATH.
|
||||
* clipboardSink tries wl-copy first, so these win over any real tools and the
|
||||
* test does not depend on a display or a system clipboard.
|
||||
*/
|
||||
async function fakeClipboard(): Promise<{ dir: string; stateFile: string; restore: () => void }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "seedpass-clip-"));
|
||||
const stateFile = join(dir, "clip.txt");
|
||||
await writeFile(stateFile, "");
|
||||
await writeFile(join(dir, "wl-copy"), `#!/bin/sh\ncat > "${stateFile}"\n`);
|
||||
// -n: no trailing newline, matching the real wl-paste flag clipboardSink uses.
|
||||
await writeFile(join(dir, "wl-paste"), `#!/bin/sh\ncat "${stateFile}"\n`);
|
||||
await chmod(join(dir, "wl-copy"), 0o755);
|
||||
await chmod(join(dir, "wl-paste"), 0o755);
|
||||
const savedPath = process.env["PATH"];
|
||||
process.env["PATH"] = `${dir}:${savedPath ?? ""}`;
|
||||
return {
|
||||
dir,
|
||||
stateFile,
|
||||
restore: () => {
|
||||
if (savedPath === undefined) delete process.env["PATH"];
|
||||
else process.env["PATH"] = savedPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("clipboardSink auto-clear", () => {
|
||||
it("copies the secret and, by default, never wipes it", async () => {
|
||||
const clip = await fakeClipboard();
|
||||
try {
|
||||
await clipboardSink("s3cr3t");
|
||||
expect(await readFile(clip.stateFile, "utf8")).toBe("s3cr3t");
|
||||
// No clear scheduled: it stays put.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
expect(await readFile(clip.stateFile, "utf8")).toBe("s3cr3t");
|
||||
} finally {
|
||||
clip.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("wipes the secret after the delay when it is still ours", async () => {
|
||||
const clip = await fakeClipboard();
|
||||
try {
|
||||
const r = await clipboardSink("wipe-me", { clearAfterSeconds: 0.15 });
|
||||
expect(r.detail).toContain("clears in");
|
||||
expect(await readFile(clip.stateFile, "utf8")).toBe("wipe-me");
|
||||
await new Promise((res) => setTimeout(res, 400));
|
||||
expect(await readFile(clip.stateFile, "utf8")).toBe("");
|
||||
} finally {
|
||||
clip.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a value the user copied since the secret untouched", async () => {
|
||||
const clip = await fakeClipboard();
|
||||
try {
|
||||
await clipboardSink("old-secret", { clearAfterSeconds: 0.15 });
|
||||
// The user copies something else before the timer fires.
|
||||
await writeFile(clip.stateFile, "user's own copy");
|
||||
await new Promise((res) => setTimeout(res, 400));
|
||||
expect(await readFile(clip.stateFile, "utf8")).toBe("user's own copy");
|
||||
} finally {
|
||||
clip.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user