mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +00:00
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
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
/**
|
|
* Sink command-spec parsing.
|
|
*
|
|
* `--exec 'sh -c "..."'` is the most common sink invocation there is, and
|
|
* splitting the spec on whitespace alone broke exactly that shape: sh
|
|
* received the fragments of the quoted script as separate argv entries and
|
|
* died on an unterminated string.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { parseCommandSpec } from "../src/sinks.js";
|
|
|
|
describe("parseCommandSpec", () => {
|
|
it("keeps a pre-split argv untouched", () => {
|
|
expect(parseCommandSpec(["wc", "-c"])).toEqual(["wc", ["-c"]]);
|
|
});
|
|
|
|
it("splits a single quoted spec on whitespace", () => {
|
|
expect(parseCommandSpec(["wc -c"])).toEqual(["wc", ["-c"]]);
|
|
});
|
|
|
|
it("treats a double-quoted section as one argument", () => {
|
|
expect(parseCommandSpec(['sh -c "echo hello world"'])).toEqual([
|
|
"sh",
|
|
["-c", "echo hello world"],
|
|
]);
|
|
});
|
|
|
|
it("treats a single-quoted section as one argument", () => {
|
|
expect(parseCommandSpec(["sh -c 'echo hello world'"])).toEqual([
|
|
"sh",
|
|
["-c", "echo hello world"],
|
|
]);
|
|
});
|
|
|
|
it("supports quotes nested inside the other quote style", () => {
|
|
expect(parseCommandSpec(["sh -c \"echo 'hi there'\""])).toEqual([
|
|
"sh",
|
|
["-c", "echo 'hi there'"],
|
|
]);
|
|
});
|
|
|
|
it("honours backslash escapes outside single quotes", () => {
|
|
expect(parseCommandSpec(["cmd a\\ b"])).toEqual(["cmd", ["a b"]]);
|
|
expect(parseCommandSpec(["cmd 'a\\ b'"])).toEqual(["cmd", ["a\\ b"]]);
|
|
});
|
|
|
|
it("collapses runs of whitespace", () => {
|
|
expect(parseCommandSpec([" wc -c "])).toEqual(["wc", ["-c"]]);
|
|
});
|
|
|
|
it("does not expand variables — no shell is involved", () => {
|
|
// The secret arrives via the environment; a spec that looks like a shell
|
|
// expansion must reach the child literally rather than being substituted
|
|
// by us.
|
|
expect(parseCommandSpec(["cmd $SEEDPASS_SECRET"])).toEqual([
|
|
"cmd",
|
|
["$SEEDPASS_SECRET"],
|
|
]);
|
|
});
|
|
|
|
it("rejects an unterminated quote instead of guessing", () => {
|
|
expect(() => parseCommandSpec(['sh -c "oops'])).toThrow(/unterminated double quote/);
|
|
expect(() => parseCommandSpec(["sh -c 'oops"])).toThrow(/unterminated single quote/);
|
|
});
|
|
|
|
it("rejects an empty command", () => {
|
|
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();
|
|
}
|
|
});
|
|
});
|