From a9e31215eaec773335e9cfec9a4e893d5ce6d328 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Wed, 19 Aug 2026 23:49:01 -0400 Subject: [PATCH] Throw on a denied agent request instead of returning a quiet zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every owner-op client method checks `ok` and throws, and none of those checks was tested. Removing one does not produce an error — it produces whatever the failed response coerces to, and for each of these that is a value which reads as a legitimate outcome: lock() -> 0, printed as "0 profiles locked" while the seeds are still resident and the user has been told the vault is locked highRiskLock() -> false, indistinguishable from "there was nothing to lock" highRiskStatus() -> { unlocked: false, expires_at: NaN } A refusal that looks like a successful no-op is worse than an error, because nobody retries it. Forced by pointing SEEDPASS_AGENT_CAP at the wrong capability, which is also the shape of the real failure: a caller that has lost owner authority. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- js/packages/cli/test/agentSecurity.test.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/js/packages/cli/test/agentSecurity.test.ts b/js/packages/cli/test/agentSecurity.test.ts index 176ad094..82c7e88a 100644 --- a/js/packages/cli/test/agentSecurity.test.ts +++ b/js/packages/cli/test/agentSecurity.test.ts @@ -712,6 +712,37 @@ describe("the wire protocol refuses shapes, not just values", () => { }); }); +describe("a denied request is not a quiet zero", () => { + it("throws instead of reporting a false success", async () => { + // Every owner-op client method checks `ok` and throws. Drop that check + // and the method returns whatever the failed response coerces to, which + // for each of these is a value that reads as a legitimate outcome: + // + // lock() -> 0 "0 profiles locked" — but the seeds are + // still resident, and the user was told the + // vault was locked. + // highRiskLock() -> false indistinguishable from "there was nothing + // to lock". + // highRiskStatus() -> { unlocked: false, expires_at: NaN } + // + // A refusal that looks like a successful no-op is worse than an error, + // because nobody retries it. + const { d, sock } = await daemonAt(() => FROZEN); + const previous = process.env["SEEDPASS_AGENT_CAP"]; + process.env["SEEDPASS_AGENT_CAP"] = "not-the-capability"; + try { + const client = new AgentClient(sock); + await expect(client.lock()).rejects.toThrow(/owner capability/); + await expect(client.highRiskLock(FINGERPRINT)).rejects.toThrow(/owner capability/); + await expect(client.highRiskStatus(FINGERPRINT)).rejects.toThrow(/owner capability/); + } finally { + if (previous === undefined) delete process.env["SEEDPASS_AGENT_CAP"]; + else process.env["SEEDPASS_AGENT_CAP"] = previous; + await d.stop(); + } + }); +}); + describe("starting a daemon over an existing socket", () => { it("refuses to replace a LIVE agent", async () => { // Without this check the second daemon deletes the socket and listens in