Files
SeedPass/js/packages/cli/test/sinks.test.ts
T
thePR0M3TH3ANandClaude Opus 5 e1ea9c2b48 Teach the mutation harness two blind spots it had, and close what they found
The harness only mutated block-form guards (`if (cond) {`), so every guard
written as a one-liner was invisible to it — which turned out to be most of
the load-bearing ones. src/highRisk.ts generated ZERO mutants and printed as
a clean sweep. Three changes to the tool:

- Mutate single-statement guards too (`if (cond) return ...;`). This alone
  surfaced 27 previously unreachable mutants, including the entire token
  validation chain: revoked, expired, exhausted, scope-not-granted.
- Report a target that generates no mutants as NOT MEASURED. Zero killed and
  zero survived prints exactly like a clean sweep, and it is the one result
  that must never be read as a pass.
- Add `kind`, `mnemonic` and `seed` to the security hints. A token's `kinds`
  list is half of what it may reach, and leaving the word out hid the shape
  check that stops a string `kinds` becoming a substring match.

Also adds `--kind` so a new mutation rule can be swept across every target
without re-judging the mutants already settled.

What the new mutants found, all of it previously unasserted:

- A token's `kinds` restriction was never tested. This file's own header
  claimed kinds and labels were both covered; only labels were. Removing the
  check let a token scoped to one kind read every other kind's secrets.
- `token-issue` would mint a token for a profile the agent is not holding.
  The seed being resident is the only proof the caller had authority over
  that vault.
- `token-issue`'s array shape checks for `scopes` and `kinds`. A string
  `kinds` turns tokenMaySee's exact match into String.includes.
- An empty high-risk factor. Refused in both implementations, tested in
  neither; it would wrap the partition key under nothing while
  `factorConfigured` still answered true.
- POST /api/v1/fingerprint/select accepting a profile that does not exist —
  the caller's string becomes ctx.fingerprint, which later requests join onto
  a path. AppDir repeats the check, so the guard's real value is answering
  404 instead of an unhandled 500.
- The API's requiresPassword flag is redundant for every route today, so
  disabling it changed nothing. Its purpose is the route that does not exist
  yet, whose author sets the flag and forgets the call — so the test now
  registers exactly that route and confirms the server layer stops it before
  the handler runs.
- sinks.ts drops SEEDPASS_-prefixed variables from a child's environment.
  Nothing on the allowlist starts with SEEDPASS_, so the backstop cannot fire
  against the real list. sinkEnv now takes the allowlist as a defaulted
  parameter purely so that future-edit case can be exercised.

All six targets now sweep to zero survivors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 18:19:15 -04:00

210 lines
7.9 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();
}
});
})
describe("command spec parsing keeps every argument", () => {
it("does not tokenize when more than one element was supplied", () => {
// Mutation testing found this unguarded: turning the `&&` into `||` makes
// a multi-element spec whose FIRST element contains whitespace get
// tokenized, silently discarding every element after it. Losing an
// argument changes which command runs — dropping a `--dry-run` or a
// target path is exactly the kind of silent change that matters for a
// sink handed a secret.
expect(parseCommandSpec(["echo hi", "there"])).toEqual(["echo hi", ["there"]]);
expect(parseCommandSpec(["wc", "-c"])).toEqual(["wc", ["-c"]]);
expect(parseCommandSpec(["a b", "c d", "e"])).toEqual(["a b", ["c d", "e"]]);
});
it("tokenizes only a lone element containing whitespace", () => {
expect(parseCommandSpec(["wc -c"])).toEqual(["wc", ["-c"]]);
// A lone element with no whitespace needs no splitting, and splitting it
// must not change it.
expect(parseCommandSpec(["ls"])).toEqual(["ls", []]);
});
it("refuses an empty command rather than running something unintended", () => {
expect(() => parseCommandSpec([])).toThrow(/empty command/);
expect(() => parseCommandSpec([""])).toThrow(/empty command/);
});
});
import { sinkEnv } from "../src/sinks.js";
describe("the environment a sink child receives", () => {
it("passes only the allowlist through, and never the caller's own env", async () => {
process.env["SEEDPASS_TEST_LEAK"] = "must-not-appear";
try {
const env = sinkEnv();
expect(env["SEEDPASS_TEST_LEAK"]).toBeUndefined();
// PATH is on the allowlist and the child genuinely needs it, so its
// presence is what proves the allowlist is being applied rather than
// the whole environment simply being empty.
expect(env["PATH"]).toBe(process.env["PATH"]);
} finally {
delete process.env["SEEDPASS_TEST_LEAK"];
}
});
it("drops a SEEDPASS_ variable even if the allowlist grows one", async () => {
// The backstop. It cannot fire against today's allowlist — which is why
// deleting it changed nothing anywhere — so the allowlist is injected
// here to stand in for the future edit that adds one by mistake.
process.env["SEEDPASS_SNEAKY"] = "must-not-appear";
try {
const env = sinkEnv({}, ["PATH", "SEEDPASS_SNEAKY"]);
expect(env["SEEDPASS_SNEAKY"]).toBeUndefined();
expect(env["PATH"]).toBeDefined();
} finally {
delete process.env["SEEDPASS_SNEAKY"];
}
});
it("still injects the exec sink's own secret variable", async () => {
// `extra` is deliberately exempt from the SEEDPASS_ rule: handing the
// secret to the child through the environment is the exec sink's entire
// job. A backstop that swallowed it would break the feature.
const env = sinkEnv({ SEEDPASS_SECRET: "the-secret" });
expect(env["SEEDPASS_SECRET"]).toBe("the-secret");
});
});