fix(agent): an empty exec allowlist now fails closed; test the allowlist at all

Mutation slice 2 scored 0% before this, and the reason was blunt: **the exec
allowlist had no test whatsoever.** The clipboard restriction could be
inverted, weakened, or deleted outright and the suite stayed green.

That control matters because the clipboard is readable by every process in the
session. A token restricted to `cat` that may still reach the clipboard is not
restricted to anything — the holder simply copies the secret out, and the
allowlist looks enforced while containing nothing. Now tested: a
command-restricted token is refused the clipboard, a token with no allowlist
is not (so the rule is conditional rather than a blanket ban passing as one),
an allowlisted command is permitted, and one outside the list is refused
without the sink ever running.

**A fail-open found while triaging one of the survivors.** An empty
`exec_allowlist` was DROPPED at issue time, which made the token unrestricted.
Automation that computes an allowlist and arrives at zero entries means
"permit nothing" and was silently handed "permit everything" — a fail-open in
the one field whose entire purpose is restriction. It is now refused at issue
time with an error saying which of the two readings the caller probably
wanted. Reachable only over the raw socket protocol, since commander requires
at least one value.

**An audit-accuracy gap.** A denial and a missing entry deliberately return
the SAME message, so the response cannot be used to enumerate ids — the audit
log is where the difference is supposed to be recorded, and nothing read it.
The two reasons could be swapped with every test passing. Testing it needed
two corrections of my own: the CLI resolves references locally so the request
never reaches the daemon, and the shared token in that file is revoked by an
earlier test, so both calls were refused at the token check instead. It also
needed a stronger assertion than the one I first wrote — checking that both
reasons merely APPEAR passes when they are swapped, which is exactly the
mutation. It now asserts the pairing: which id got which reason.

Three survivors are recorded as genuinely unkillable with reasons, rather than
chased: the two `length > 0` comparisons cannot differ from `>= 0` now that an
empty allowlist cannot be stored, and the capability early-return is redundant
with the length check below it.

agent slices 1-2 (mutants 1-16): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 16:07:25 -04:00
1 parent 12b945ca7e
commit 17b94d3e26
3 files changed
+219 -2

No files matched your search

@@ -452,6 +452,42 @@ describe("malformed daemon input cannot corrupt held state", () => {
expect(JSON.stringify(owner)).not.toContain("abandon");
});
it("refuses an empty exec allowlist rather than making the token unrestricted", async () => {
// Found by mutation testing. An empty allowlist used to be dropped, so a
// caller computing an allowlist that came out empty — meaning "permit
// nothing" — silently received a token permitting EVERY command. A
// fail-open in the one field whose entire job is restriction.
const r = await rawRequest({
op: "token-issue",
cap,
fingerprint: FINGERPRINT,
scopes: ["use"],
ttl: 600,
uses: 1,
exec_allowlist: [],
});
expect(r["ok"]).toBe(false);
expect(String(r["error"])).toContain("permit no command");
expect(r["token"]).toBeUndefined();
});
it("still accepts an allowlist with entries", async () => {
// The refusal above must be specific to the empty case; rejecting real
// allowlists would push callers toward omitting the field entirely, which
// is the permissive option.
const r = await rawRequest({
op: "token-issue",
cap,
fingerprint: FINGERPRINT,
scopes: ["use"],
ttl: 600,
uses: 1,
exec_allowlist: ["cat"],
});
expect(r["ok"]).toBe(true);
expect((r["record"] as Record<string, unknown>)["exec_allowlist"]).toEqual(["cat"]);
});
it("token-issue refuses a bare-string scope and a non-numeric ttl without crashing", async () => {
const strScope = await rawRequest({
op: "token-issue", cap, fingerprint: FINGERPRINT, scopes: "reveal", ttl: 600, uses: 1,