From 5eefd3c54562cab014d3253c5288e2fd41fdde44 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 6 Oct 2026 10:12:49 -0400 Subject: [PATCH 1/2] fix(session): explain selector misses instead of protocol-validation-failed A hunk session command that selects its window with --repo or --session-path looks the session up before authorization. When that lookup matched no single session, the daemon collapsed the error into a redacted protocol-validation-failed, so callers in a different checkout (for example another git worktree) could not tell why the command failed. Authorize a selector miss as list instead, since the lookup error reveals nothing list would not, and answer it with the lookup error without dispatching the action. Session-scoped callers still learn nothing, and malformed bodies stay redacted. --- .changeset/quiet-worktrees-explain.md | 5 + .../src/session/broker/brokerServer.test.ts | 125 +++++++++++++++++- .../hunk/src/session/broker/brokerServer.ts | 50 ++++++- test/session/cli.test.ts | 28 ++++ 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-worktrees-explain.md diff --git a/.changeset/quiet-worktrees-explain.md b/.changeset/quiet-worktrees-explain.md new file mode 100644 index 000000000..568736712 --- /dev/null +++ b/.changeset/quiet-worktrees-explain.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +`hunk session` commands that select a window with `--repo` or `--session-path` now say which path matched no live session, instead of failing with `protocol-validation-failed` (for example, when run from a different git worktree than the Hunk window). diff --git a/packages/hunk/src/session/broker/brokerServer.test.ts b/packages/hunk/src/session/broker/brokerServer.test.ts index ec9f758a9..e4ab67d99 100644 --- a/packages/hunk/src/session/broker/brokerServer.test.ts +++ b/packages/hunk/src/session/broker/brokerServer.test.ts @@ -106,6 +106,7 @@ async function authenticatedFetch( port: number, path: string, init: SessionBrokerSignedRequestInit = {}, + options: { targetSpecific?: boolean } = {}, ) { const credentials = await loadOrCreateHunkSessionBrokerCredentials(); const caller = new SessionBrokerCallerClient({ @@ -120,7 +121,7 @@ async function authenticatedFetch( ? ((JSON.parse(init.body) as { action?: string }).action ?? "") : ""; return caller.request(path, init, { - targetSpecific: path === "/session-api" && action !== "list", + targetSpecific: options.targetSpecific ?? (path === "/session-api" && action !== "list"), }); } @@ -939,6 +940,128 @@ describe("Hunk session daemon server", () => { } }); + test("reports why a repo selector matched no session instead of a redacted failure", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const original = SessionBrokerState.prototype.dispatchCommand; + const dispatched: string[] = []; + SessionBrokerState.prototype.dispatchCommand = (({ command }: { command: string }) => { + dispatched.push(command); + return Promise.reject(new Error("A selector miss must not dispatch.")); + }) as SessionBrokerState["dispatchCommand"]; + + const server = await serveSessionBrokerDaemon(); + let socket: WebSocket | undefined; + + try { + // The live window is showing /repo; the caller is in a different checkout. + socket = await openRegisteredSession(port); + for (const request of [ + { action: "get", selector: { repoRoot: "/elsewhere" } }, + { + action: "reload", + selector: { repoRoot: "/elsewhere" }, + nextInput: { kind: "vcs", staged: false, options: {} }, + }, + ]) { + const response = await authenticatedFetch(port, "/session-api", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "No active session matches repoRoot /elsewhere.", + }); + } + expect(dispatched).toEqual([]); + } finally { + SessionBrokerState.prototype.dispatchCommand = original; + socket?.close(); + server.stop(true); + } + }); + + test("never dispatches a request authorized only to report a selector miss", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + // The selector misses during authorization and matches by the time the handler runs, as + // when a window registers in between. The request was authorized only as `list`. + const originalGetSession = SessionBrokerState.prototype.getSession; + const originalDispatch = SessionBrokerState.prototype.dispatchCommand; + let lookups = 0; + SessionBrokerState.prototype.getSession = function (this: SessionBrokerState) { + lookups += 1; + if (lookups === 1) throw new Error("No active session matches repoRoot /repo."); + return { sessionId: "session-1" } as ReturnType; + } as SessionBrokerState["getSession"]; + const dispatched: string[] = []; + SessionBrokerState.prototype.dispatchCommand = (({ command }: { command: string }) => { + dispatched.push(command); + return Promise.reject(new Error("A selector miss must not dispatch.")); + }) as SessionBrokerState["dispatchCommand"]; + + const server = await serveSessionBrokerDaemon(); + + try { + const response = await authenticatedFetch(port, "/session-api", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "reload", + selector: { repoRoot: "/repo" }, + nextInput: { kind: "vcs", staged: false, options: {} }, + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "The matching Hunk session changed while the request was in flight; retry.", + }); + expect(dispatched).toEqual([]); + } finally { + SessionBrokerState.prototype.getSession = originalGetSession; + SessionBrokerState.prototype.dispatchCommand = originalDispatch; + server.stop(true); + } + }); + + test("keeps malformed session API bodies redacted", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = await serveSessionBrokerDaemon(); + + try { + const response = await authenticatedFetch( + port, + "/session-api", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "reload", + selector: { repoRoot: "/repo" }, + nextInput: { kind: "vcs", staged: false, options: { notAnOption: true } }, + }), + }, + // An unparseable body has no trustworthy target, so the daemon signs it untargeted. + { targetSpecific: false }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "protocol-validation-failed" }); + } finally { + server.stop(true); + } + }); + test("serves review notes through the session API", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; diff --git a/packages/hunk/src/session/broker/brokerServer.ts b/packages/hunk/src/session/broker/brokerServer.ts index 5766f063e..7ed580b24 100644 --- a/packages/hunk/src/session/broker/brokerServer.ts +++ b/packages/hunk/src/session/broker/brokerServer.ts @@ -3,6 +3,7 @@ import { SessionBrokerAuthenticator, createSessionBrokerDaemon, type SessionBrokerAuthenticatedControlFacts, + type SessionBrokerAuthenticatedControlResult, type SessionBrokerController, } from "@hunk/session-broker"; import { @@ -357,14 +358,29 @@ function resolveNavigateCommandInput( }; } -/** Map each Hunk action to the generic operation and exact producer command scope it requires. */ +/** + * Map each Hunk action to the generic operation and exact producer command scope it requires. + * + * A `--repo` or `--session-path` selector names its session only after a lookup, so the lookup + * runs before authorization. When it matches no single session, the request is authorized as + * `list` instead: the lookup error names nothing a `list` would not, so a caller allowed to list + * learns why its selector missed, and a session-scoped caller still learns nothing. Malformed + * bodies keep throwing so the daemon answers with its redacted protocol failure. + */ function sessionApiAuthorizationFacts( state: HunkSessionBrokerState, bytes: Uint8Array, ): SessionBrokerAuthenticatedControlFacts { const input = parseJsonRequestBytes(bytes); if (input.action === "list") return { operation: "list", targetSpecific: false }; - const sessionId = input.selector.sessionId ?? state.getSession(input.selector).sessionId; + let sessionId = input.selector.sessionId; + if (sessionId === undefined) { + try { + sessionId = state.getSession(input.selector).sessionId; + } catch { + return { operation: "list", targetSpecific: true }; + } + } if (["get", "context", "review", "comment-list"].includes(input.action)) { return { operation: "get", sessionId, targetSpecific: true }; } @@ -389,6 +405,33 @@ function sessionApiAuthorizationFacts( }; } +/** + * Answer a targeted request whose selector missed with the lookup error, never the action. + * + * The request was authorized only as `list`, so it must not dispatch even when a session + * matching its selector registered after authorization. + */ +function sessionLookupFailure( + state: HunkSessionBrokerState, + bytes: Uint8Array, +): SessionBrokerAuthenticatedControlResult { + const input = parseJsonRequestBytes(bytes); + if (input.action !== "list") { + try { + state.getSession(input.selector); + } catch (error) { + return { + body: { error: error instanceof Error ? error.message : "No active session matches." }, + status: 400, + }; + } + } + return { + body: { error: "The matching Hunk session changed while the request was in flight; retry." }, + status: 409, + }; +} + export async function handleSessionApiRequest( state: HunkSessionBrokerState, request: Request, @@ -740,6 +783,9 @@ export async function serveSessionBrokerDaemon( resolve: (body) => sessionApiAuthorizationFacts(state, body), resolveFailureTargetSpecific: (body) => parseJsonRequestBytes(body).action !== "list", handle: async (body, facts) => { + if (facts.operation === "list" && parseJsonRequestBytes(body).action !== "list") { + return sessionLookupFailure(state, body); + } const response = await handleSessionApiRequest(state, request, body, facts.sessionId); return { body: (await response.json()) as never, status: response.status }; }, diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index 85a1a9bc0..ee7efff5b 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -378,6 +378,34 @@ sessionDescribe("session CLI integration", () => { } }); + test("repo selectors that match no live session explain the miss", async () => { + const port = await reserveLoopbackPort(); + const fixture = createFixtureFiles( + "selector-miss", + ["export const a = 1;"], + ["export const a = 2;"], + ); + // Another checkout, such as a sibling worktree, that no live window is showing. + const elsewhere = createFixtureFiles("selector-elsewhere", [""], [""]); + const session = spawnHunkSession(fixture, port); + + try { + await waitForRegisteredSessions(port); + + for (const args of [ + ["get", "--repo", elsewhere.dir], + ["reload", "--repo", elsewhere.dir, "--", "diff"], + ]) { + const result = runSessionCli(args, port); + expect(result.proc.exitCode).not.toBe(0); + expect(result.stderr).toContain("No active session matches repoRoot"); + expect(result.stderr).not.toContain("protocol-validation-failed"); + } + } finally { + await cleanupHunkSession(session, fixture, port); + } + }); + test("reload replaces what a live session is showing", async () => { const port = await reserveLoopbackPort(); const fixture = createFixtureFiles( From 2b200c85ac34953e819e7bbadf7bb79e8559500e Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 6 Oct 2026 10:55:22 -0400 Subject: [PATCH 2/2] refactor(session-broker): make selector misses a first-class resolution failure Move selector-miss handling from Hunk's adapter into the generic broker. `resolve` now throws SessionBrokerTargetResolutionError, and handleAuthenticatedControl authorizes only its disclosure (against `list` by default), answers with the message, and never runs `handle`. This removes the implicit "operation is list but action is not" check, the second lookup in the handler, and the 409 retry path that existed only because the lookup error was lost between resolve and handle. Audit events now record the disclosure as outcome `target-resolution-failed` instead of an ordinary authenticated `list`. The response a caller receives is unchanged (a signed, target-specific 400 carrying the lookup error), so no session wire or broker protocol revision bump is needed. --- .changeset/quiet-worktrees-explain.md | 2 +- .../src/session/broker/brokerServer.test.ts | 46 ---------- .../hunk/src/session/broker/brokerServer.ts | 47 ++-------- packages/session-broker/src/daemon.test.ts | 91 ++++++++++++++++++- packages/session-broker/src/daemon.ts | 68 +++++++++++++- packages/session-broker/src/types.ts | 7 +- 6 files changed, 173 insertions(+), 88 deletions(-) diff --git a/.changeset/quiet-worktrees-explain.md b/.changeset/quiet-worktrees-explain.md index 568736712..472d9137b 100644 --- a/.changeset/quiet-worktrees-explain.md +++ b/.changeset/quiet-worktrees-explain.md @@ -2,4 +2,4 @@ "hunkdiff": patch --- -`hunk session` commands that select a window with `--repo` or `--session-path` now say which path matched no live session, instead of failing with `protocol-validation-failed` (for example, when run from a different git worktree than the Hunk window). +`hunk session` commands that select a window with `--repo` or `--session-path` now say which path matched no live session, instead of failing with `protocol-validation-failed` (for example, when run from a different git worktree than the Hunk window). The session daemon produces this message, so it appears once a daemon from this release is running; an older daemon keeps answering with `protocol-validation-failed` until it restarts. diff --git a/packages/hunk/src/session/broker/brokerServer.test.ts b/packages/hunk/src/session/broker/brokerServer.test.ts index e4ab67d99..84d544f71 100644 --- a/packages/hunk/src/session/broker/brokerServer.test.ts +++ b/packages/hunk/src/session/broker/brokerServer.test.ts @@ -985,52 +985,6 @@ describe("Hunk session daemon server", () => { } }); - test("never dispatches a request authorized only to report a selector miss", async () => { - const port = await reserveLoopbackPort(); - process.env.HUNK_MCP_HOST = "127.0.0.1"; - process.env.HUNK_MCP_PORT = String(port); - - // The selector misses during authorization and matches by the time the handler runs, as - // when a window registers in between. The request was authorized only as `list`. - const originalGetSession = SessionBrokerState.prototype.getSession; - const originalDispatch = SessionBrokerState.prototype.dispatchCommand; - let lookups = 0; - SessionBrokerState.prototype.getSession = function (this: SessionBrokerState) { - lookups += 1; - if (lookups === 1) throw new Error("No active session matches repoRoot /repo."); - return { sessionId: "session-1" } as ReturnType; - } as SessionBrokerState["getSession"]; - const dispatched: string[] = []; - SessionBrokerState.prototype.dispatchCommand = (({ command }: { command: string }) => { - dispatched.push(command); - return Promise.reject(new Error("A selector miss must not dispatch.")); - }) as SessionBrokerState["dispatchCommand"]; - - const server = await serveSessionBrokerDaemon(); - - try { - const response = await authenticatedFetch(port, "/session-api", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - action: "reload", - selector: { repoRoot: "/repo" }, - nextInput: { kind: "vcs", staged: false, options: {} }, - }), - }); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ - error: "The matching Hunk session changed while the request was in flight; retry.", - }); - expect(dispatched).toEqual([]); - } finally { - SessionBrokerState.prototype.getSession = originalGetSession; - SessionBrokerState.prototype.dispatchCommand = originalDispatch; - server.stop(true); - } - }); - test("keeps malformed session API bodies redacted", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; diff --git a/packages/hunk/src/session/broker/brokerServer.ts b/packages/hunk/src/session/broker/brokerServer.ts index 7ed580b24..64fa73ded 100644 --- a/packages/hunk/src/session/broker/brokerServer.ts +++ b/packages/hunk/src/session/broker/brokerServer.ts @@ -1,9 +1,9 @@ import { SESSION_BROKER_ADMIN_SCOPE_VERSION, SessionBrokerAuthenticator, + SessionBrokerTargetResolutionError, createSessionBrokerDaemon, type SessionBrokerAuthenticatedControlFacts, - type SessionBrokerAuthenticatedControlResult, type SessionBrokerController, } from "@hunk/session-broker"; import { @@ -362,10 +362,11 @@ function resolveNavigateCommandInput( * Map each Hunk action to the generic operation and exact producer command scope it requires. * * A `--repo` or `--session-path` selector names its session only after a lookup, so the lookup - * runs before authorization. When it matches no single session, the request is authorized as - * `list` instead: the lookup error names nothing a `list` would not, so a caller allowed to list - * learns why its selector missed, and a session-scoped caller still learns nothing. Malformed - * bodies keep throwing so the daemon answers with its redacted protocol failure. + * runs before authorization. When it matches no single session, the lookup error becomes a + * target-resolution failure that the daemon discloses only to callers allowed to `list`: the error + * names nothing a `list` would not, so the Hunk CLI learns why its selector missed, and a + * session-scoped caller still learns nothing. Malformed bodies keep throwing plain errors so the + * daemon answers with its redacted protocol failure. */ function sessionApiAuthorizationFacts( state: HunkSessionBrokerState, @@ -377,8 +378,10 @@ function sessionApiAuthorizationFacts( if (sessionId === undefined) { try { sessionId = state.getSession(input.selector).sessionId; - } catch { - return { operation: "list", targetSpecific: true }; + } catch (error) { + throw new SessionBrokerTargetResolutionError( + error instanceof Error ? error.message : "No active session matches.", + ); } } if (["get", "context", "review", "comment-list"].includes(input.action)) { @@ -405,33 +408,6 @@ function sessionApiAuthorizationFacts( }; } -/** - * Answer a targeted request whose selector missed with the lookup error, never the action. - * - * The request was authorized only as `list`, so it must not dispatch even when a session - * matching its selector registered after authorization. - */ -function sessionLookupFailure( - state: HunkSessionBrokerState, - bytes: Uint8Array, -): SessionBrokerAuthenticatedControlResult { - const input = parseJsonRequestBytes(bytes); - if (input.action !== "list") { - try { - state.getSession(input.selector); - } catch (error) { - return { - body: { error: error instanceof Error ? error.message : "No active session matches." }, - status: 400, - }; - } - } - return { - body: { error: "The matching Hunk session changed while the request was in flight; retry." }, - status: 409, - }; -} - export async function handleSessionApiRequest( state: HunkSessionBrokerState, request: Request, @@ -783,9 +759,6 @@ export async function serveSessionBrokerDaemon( resolve: (body) => sessionApiAuthorizationFacts(state, body), resolveFailureTargetSpecific: (body) => parseJsonRequestBytes(body).action !== "list", handle: async (body, facts) => { - if (facts.operation === "list" && parseJsonRequestBytes(body).action !== "list") { - return sessionLookupFailure(state, body); - } const response = await handleSessionApiRequest(state, request, body, facts.sessionId); return { body: (await response.json()) as never, status: response.status }; }, diff --git a/packages/session-broker/src/daemon.test.ts b/packages/session-broker/src/daemon.test.ts index 31e41aad9..fec8f365e 100644 --- a/packages/session-broker/src/daemon.test.ts +++ b/packages/session-broker/src/daemon.test.ts @@ -12,7 +12,7 @@ import { } from "@hunk/session-broker-core"; import { SessionBroker } from "./broker"; import { SESSION_BROKER_ADMIN_STOP_CLOSE_REASON } from "./admin"; -import { createSessionBrokerDaemon } from "./daemon"; +import { SessionBrokerTargetResolutionError, createSessionBrokerDaemon } from "./daemon"; import { createSessionBrokerProtocolParsers } from "./protocolParsers"; import type { AuthenticatedCallerRequest, @@ -1402,6 +1402,95 @@ describe("session broker daemon", () => { daemon.shutdown(); }); + test("discloses target resolution failures only to callers allowed to list, without handling", async () => { + const audited: { operation: string; decision: string; outcome: string }[] = []; + const caller = (principal: Partial) => + createSessionBrokerDaemon({ + broker: createBroker(), + exposeHttpApi: true, + appId: "session-broker", + appRevision: 1, + callerAuthenticator: { + authenticate: async () => + authenticatedRequest({ + kind: "caller", + appId: "session-broker", + principalId: "test-caller", + keyId: "test-key", + grantId: "test-grant", + operations: ["list", "get", "dispatch"], + commands: [], + ...principal, + }), + }, + authorizer: async () => true, + audit: ({ operation, decision, outcome }) => { + audited.push({ operation, decision, outcome }); + }, + }); + let handled = 0; + const control = { + resolve: () => { + throw new SessionBrokerTargetResolutionError("No active session matches repoRoot /x."); + }, + handle: () => { + handled += 1; + return { body: { ok: true } }; + }, + }; + const request = () => new Request("http://broker.test/custom", { method: "POST", body: "{}" }); + + const lister = caller({}); + const disclosed = await lister.handleAuthenticatedControl(request(), control); + expect(disclosed.status).toBe(400); + const disclosedEnvelope = (await disclosed.json()) as { + body: unknown; + authentication: { appContract?: unknown }; + }; + expect(disclosedEnvelope.body).toEqual({ error: "No active session matches repoRoot /x." }); + // The caller signed a targeted request, so the answer must carry the app contract. + expect(disclosedEnvelope.authentication.appContract).toBeDefined(); + lister.shutdown(); + + // A session-scoped caller can never list, so it learns nothing about other sessions. + const scoped = caller({ sessionId: "session-1" }); + const denied = await scoped.handleAuthenticatedControl(request(), control); + expect(denied.status).toBe(403); + expect(await authenticatedBody(denied)).toEqual({ error: "authorization-denied" }); + scoped.shutdown(); + + const unlisted = caller({ operations: ["get", "dispatch"] }); + expect((await unlisted.handleAuthenticatedControl(request(), control)).status).toBe(403); + unlisted.shutdown(); + + expect(handled).toBe(0); + expect(audited).toEqual([ + { operation: "list", decision: "allow", outcome: "target-resolution-failed" }, + { operation: "list", decision: "deny", outcome: "authorization-failed" }, + { operation: "list", decision: "deny", outcome: "authorization-failed" }, + ]); + }); + + test("keeps other resolve failures redacted", async () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + exposeHttpApi: true, + ...authenticatedHttpApi, + }); + const response = await daemon.handleAuthenticatedControl( + new Request("http://broker.test/custom", { method: "POST", body: "{}" }), + { + resolve: () => { + throw new Error("private parser detail"); + }, + handle: () => ({ body: { ok: true } }), + }, + ); + expect(response.status).toBe(400); + expect(await authenticatedBody(response)).toEqual({ error: "protocol-validation-failed" }); + daemon.shutdown(); + }); + test("rejects daemon state limits that were not configured on its broker controller", () => { expect(() => createSessionBrokerDaemon({ diff --git a/packages/session-broker/src/daemon.ts b/packages/session-broker/src/daemon.ts index 411dd49d0..9fadedeb4 100644 --- a/packages/session-broker/src/daemon.ts +++ b/packages/session-broker/src/daemon.ts @@ -92,6 +92,31 @@ export interface SessionBrokerAuthenticatedControlResult { readonly status?: number; } +/** + * Reports that an authenticated control's selector resolved to no single session. + * + * Thrown from `handleAuthenticatedControl`'s `resolve` once the body has parsed. The daemon never + * runs `handle` for it; it answers with `message` only when the caller holds + * `disclosureOperation`, whose grant must already reveal everything the message names (with + * `list`, the matching session IDs and titles). Every other `resolve` error stays a redacted + * protocol failure. + */ +export class SessionBrokerTargetResolutionError extends Error { + readonly disclosureOperation: CallerOperation; + readonly targetSpecific: boolean; + + constructor( + message: string, + options: { disclosureOperation?: CallerOperation; targetSpecific?: boolean } = {}, + ) { + super(message); + this.name = "SessionBrokerTargetResolutionError"; + this.disclosureOperation = options.disclosureOperation ?? "list"; + // A selector that failed to resolve was aimed at a session, so the caller signed it targeted. + this.targetSpecific = options.targetSpecific ?? true; + } +} + /** * Configure the revision-tolerant admin scope (`status` and `stop`). * @@ -1016,7 +1041,10 @@ export class SessionBrokerDaemon< let facts: SessionBrokerAuthenticatedControlFacts; try { facts = options.resolve(body); - } catch { + } catch (error) { + if (error instanceof SessionBrokerTargetResolutionError) { + return this.answerTargetResolutionFailure(request, authenticated, error); + } let targetSpecific = false; try { targetSpecific = options.resolveFailureTargetSpecific?.(body) ?? false; @@ -1059,6 +1087,41 @@ export class SessionBrokerDaemon< }); } + /** + * Answer a control whose selector resolved to no single session, without running its action. + * + * Authorizes only the disclosure, audited under its own outcome so a missed `reload` never + * reads as a successful `list`. + */ + private async answerTargetResolutionFailure( + request: Request, + authenticated: AuthenticatedCallerRequest, + failure: SessionBrokerTargetResolutionError, + ): Promise { + const allowed = await this.authorize( + request, + authenticated, + { operation: failure.disclosureOperation }, + "target-resolution-failed", + ); + if (!allowed) { + return this.authenticatedResponse( + authenticated, + { error: "authorization-denied" }, + 403, + failure.targetSpecific, + ); + } + const inactive = this.rejectInactiveRequest(authenticated); + if (inactive) return inactive; + return this.authenticatedResponse( + authenticated, + { error: failure.message }, + 400, + failure.targetSpecific, + ); + } + private async authenticateRequest( request: Request, body: Uint8Array, @@ -1121,6 +1184,7 @@ export class SessionBrokerDaemon< command?: string; commandVersion?: number; }, + allowedOutcome: "authenticated" | "target-resolution-failed" = "authenticated", ): Promise { const principal: CallerPrincipal = authenticated.principal; const allowedByGrant = callerPrincipalAllows(principal, { @@ -1151,7 +1215,7 @@ export class SessionBrokerDaemon< ...(facts.commandVersion === undefined ? {} : { commandVersion: facts.commandVersion }), requestId: authenticated.requestId, decision: allowedByApp ? "allow" : "deny", - outcome: allowedByApp ? "authenticated" : "authorization-failed", + outcome: allowedByApp ? allowedOutcome : "authorization-failed", timestamp: Date.now(), }); return allowedByApp; diff --git a/packages/session-broker/src/types.ts b/packages/session-broker/src/types.ts index 700e613d2..d0f547ed1 100644 --- a/packages/session-broker/src/types.ts +++ b/packages/session-broker/src/types.ts @@ -133,7 +133,12 @@ export interface SessionBrokerAuditEvent { readonly commandVersion?: number; readonly requestId?: string; readonly decision: "allow" | "deny"; - readonly outcome: "authenticated" | "authentication-failed" | "authorization-failed"; + readonly outcome: + | "authenticated" + | "authentication-failed" + | "authorization-failed" + /** The caller may learn why its selector matched no single session; no action ran. */ + | "target-resolution-failed"; readonly timestamp: number; }