From cef78d723db5d50b5750be880163db615c7954ef Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Sat, 8 Aug 2026 13:17:54 -0400 Subject: [PATCH] feat(desktop): render permission-request sentinel card in thread timeline Implements the desktop side of issue #4938: a kind-9 sentinel published by buzz-acp is parsed and displayed as an interactive card in the thread view. Parser (permissionRequest.ts): - Discriminated union PermissionRequestPending | PermissionRequestResolved aligned to the frozen schema (event b31c716e): requestNonce, sessionId, turnId, optionIds[], labels{}, hasDurableRule, durableRuleNote, outcome, chosenOptionId, originalEventId. - All untrusted display strings capped at 200 chars; optionIds bounded to 10. - extractPermissionRequest() and stripPermissionRequestSentinel() are render-safe (never throw). Compute helper (computePermissionRequest.ts): - D1 signer gate: kind-9 must be signed by the channel's known agent pubkey. - Edit authenticity: resolved state accepted only from kind-40003 signed by the original agent (not owner, not attacker). - selectProseOrPermission() mirrors selectProseOrNudge() from configNudge. Card (permission-request-card.tsx): - Pending: renders option buttons from optionIds + labels[optionId]. Deny-style button heuristic uses label text (opaque optionIds carry no semantic). ExpiryCountdown ticks down to expiresAt; expired cards show 'Timed out'. D5 durable-rule disclosure (durableRuleNote) shown below buttons when hasDurableRule is true. - Resolved: shows outcome label derived from outcome + chosenOptionId. - Actionable buttons gated: isOwner && state === 'pending' only. - sendPermissionDecision() is fire-and-forget; button disables on click with 'Decision sent'; re-enables on relay error so the user can retry. Integration (MessageRow.tsx, formatTimelineMessages.ts, types.ts): - editSignerPubkey threaded from formatTimelineMessages through TimelineMessage so the edit authenticity gate has the raw event signer without re-querying. - PermissionRequestCardBlock wraps useIdentityQuery to resolve the current viewer, computes isOwner, and renders the card when a trusted sentinel is present. React.memo with custom comparator keeps MessageRow re-render budget clean. Auth helper (permissionRequestAuthPubkey.ts): - getPermissionRequestAgentPubkey() returns the agent pubkey only for known-agent-signed kind-9 events, mirroring getConfigNudgeAuthorPubkey. Tests: 33 named tests covering all 6 frozen fixtures verbatim plus rejection cases (bad version, unknown state, size bounds, malformed JSON, field invariants) and the full signer/edit-authenticity gate matrix. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../messages/lib/formatTimelineMessages.ts | 9 +- desktop/src/features/messages/types.ts | 7 + .../src/features/messages/ui/MessageRow.tsx | 17 + .../ui/PermissionRequestCardBlock.tsx | 90 ++++++ .../ui/permissionRequestAuthPubkey.ts | 30 ++ .../lib/computePermissionRequest.test.mjs | 206 ++++++++++++ .../shared/lib/computePermissionRequest.ts | 75 +++++ .../src/shared/lib/permissionRequest.test.mjs | 297 ++++++++++++++++++ desktop/src/shared/lib/permissionRequest.ts | 211 +++++++++++++ .../src/shared/ui/permission-request-card.tsx | 254 +++++++++++++++ 10 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/ui/PermissionRequestCardBlock.tsx create mode 100644 desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts create mode 100644 desktop/src/shared/lib/computePermissionRequest.test.mjs create mode 100644 desktop/src/shared/lib/computePermissionRequest.ts create mode 100644 desktop/src/shared/lib/permissionRequest.test.mjs create mode 100644 desktop/src/shared/lib/permissionRequest.ts create mode 100644 desktop/src/shared/ui/permission-request-card.tsx diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index ab35ecfcc..ebbd2a00e 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -262,7 +262,12 @@ export function formatTimelineMessages( // the original (`h`, `p` mentions, etc.) stay untouched. const editsByTargetId = new Map< string, - { content: string; tags: string[][]; createdAt: number } + { + content: string; + tags: string[][]; + createdAt: number; + signerPubkey: string; + } >(); for (const event of events) { if ( @@ -293,6 +298,7 @@ export function formatTimelineMessages( content: event.content, tags: event.tags, createdAt: event.created_at, + signerPubkey: normalizePubkey(event.pubkey), }); } } @@ -504,6 +510,7 @@ export function formatTimelineMessages( : undefined, time: formatTime(event.created_at), body: edit ? edit.content : event.content, + editSignerPubkey: edit?.signerPubkey, parentId: thread.parentId, rootId: thread.rootId, depth: getDepth(event), diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index ec656f223..1b4217013 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -24,6 +24,13 @@ export type TimelineMessage = { * user that cryptographically signed the event. */ signerPubkey?: string; + /** + * Signer pubkey of the most recent authorized kind-40003 edit, normalized to + * lowercase hex. Present only when an edit exists. Used by the + * `PermissionRequestCard` to enforce edit authenticity: only edits signed by + * the original agent may resolve the card. + */ + editSignerPubkey?: string; author: string; /** True when the displayed author is known to be an agent. */ isAgent?: boolean; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 51d9832c1..710fa245e 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -29,6 +29,8 @@ import { KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; +import { getPermissionRequestAgentPubkey } from "@/features/messages/ui/permissionRequestAuthPubkey"; +import { PermissionRequestCardBlock } from "@/features/messages/ui/PermissionRequestCardBlock"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -647,6 +649,20 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> {renderBody()} + {channelId && message.isAgent ? ( + + ) : null} {continuationMetadataNode} + + + ); + }, + (prev, next) => + prev.content === next.content && + prev.interactive === next.interactive && + prev.agentPubkey === next.agentPubkey && + prev.signerPubkey === next.signerPubkey && + prev.editSignerPubkey === next.editSignerPubkey && + prev.ownerPubkey === next.ownerPubkey && + prev.channelId === next.channelId, +); diff --git a/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts new file mode 100644 index 000000000..932fcda80 --- /dev/null +++ b/desktop/src/features/messages/ui/permissionRequestAuthPubkey.ts @@ -0,0 +1,30 @@ +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Returns the agent pubkey to use for the `PermissionRequestCard` for a given + * message, or `undefined` when the permission-card path should be disabled. + * + * The card is enabled ONLY when: + * 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener + * wire format (kind:9). + * 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` — + * authenticates against the raw event signer (NOT `message.pubkey`, + * which may be a relay-delegated display author). + * + * Mirrors `getConfigNudgeAuthorPubkey` — same signer-vs-delegated-author + * distinction, same test-friendly pure-function shape. + */ +export function getPermissionRequestAgentPubkey( + message: Pick, + isKnownAgentPubkey: (pubkey: string) => boolean, +): string | undefined { + if ( + message.kind === KIND_STREAM_MESSAGE && + message.signerPubkey && + isKnownAgentPubkey(message.signerPubkey) + ) { + return message.signerPubkey; + } + return undefined; +} diff --git a/desktop/src/shared/lib/computePermissionRequest.test.mjs b/desktop/src/shared/lib/computePermissionRequest.test.mjs new file mode 100644 index 000000000..18b5c55ab --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.test.mjs @@ -0,0 +1,206 @@ +/** + * Named test matrix for `computePermissionRequest` and `selectProseOrPermission`. + * + * Fixtures use the frozen schema (event b31c716e). + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computePermissionRequest, + selectProseOrPermission, +} from "./computePermissionRequest.ts"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const ATTACKER_PUBKEY = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const OWNER_PUBKEY = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +const PENDING_PAYLOAD = { + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9999999999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, +}; + +const RESOLVED_PAYLOAD = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 9999999999, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + outcome: "applied", + chosenOptionId: "opt-allow", +}; + +function fence(payload) { + return `\`\`\`buzz:permission-request\n${JSON.stringify(payload)}\n\`\`\``; +} + +function body(payload) { + return `May I?\n\n${fence(payload)}`; +} + +// ── computePermissionRequest ────────────────────────────────────────────────── + +test("test_not_interactive_returns_null", () => { + assert.equal( + computePermissionRequest( + body(PENDING_PAYLOAD), + false, + AGENT_PUBKEY, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_missing_agentPubkey_returns_null", () => { + assert.equal( + computePermissionRequest( + body(PENDING_PAYLOAD), + true, + undefined, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_missing_signerPubkey_returns_null", () => { + assert.equal( + computePermissionRequest( + body(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + undefined, + ), + null, + ); +}); + +test("test_forged_card_wrong_signer_returns_null", () => { + // agentPubkey (channel's known agent) ≠ signerPubkey (event signer) + assert.equal( + computePermissionRequest( + body(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + ATTACKER_PUBKEY, + ), + null, + ); +}); + +test("test_valid_signer_returns_payload", () => { + const result = computePermissionRequest( + body(PENDING_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ); + assert.deepEqual(result, PENDING_PAYLOAD); +}); + +test("test_signer_check_is_case_insensitive", () => { + const result = computePermissionRequest( + body(PENDING_PAYLOAD), + true, + AGENT_PUBKEY.toUpperCase(), + AGENT_PUBKEY.toLowerCase(), + ); + assert.deepEqual(result, PENDING_PAYLOAD); +}); + +test("test_no_sentinel_returns_null", () => { + assert.equal( + computePermissionRequest( + "No sentinel here", + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ), + null, + ); +}); + +test("test_agent_signed_edit_resolves_card", () => { + const result = computePermissionRequest( + body(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, // original event signer + AGENT_PUBKEY, // edit signer == agent ✓ + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); +}); + +test("test_owner_signed_edit_does_not_resolve", () => { + assert.equal( + computePermissionRequest( + body(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + OWNER_PUBKEY, // edit signer is owner, not agent ✗ + ), + null, + ); +}); + +test("test_attacker_signed_edit_does_not_resolve", () => { + assert.equal( + computePermissionRequest( + body(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + ATTACKER_PUBKEY, // attacker edit ✗ + ), + null, + ); +}); + +test("test_resolved_body_with_no_edit_arrived_parses_body_directly", () => { + // When editSignerPubkey is undefined, no edit-authenticity check runs. + // If the original event body happened to contain a resolved sentinel, we + // return it. This handles the edge case where the edit arrives before we + // query the original event. + const result = computePermissionRequest( + body(RESOLVED_PAYLOAD), + true, + AGENT_PUBKEY, + AGENT_PUBKEY, + undefined, + ); + assert.deepEqual(result, RESOLVED_PAYLOAD); +}); + +// ── selectProseOrPermission ─────────────────────────────────────────────────── + +test("test_selectProseOrPermission_returns_markdown_when_no_request", () => { + const node = "markdown-node"; + assert.equal(selectProseOrPermission(null, node), node); +}); + +test("test_selectProseOrPermission_returns_null_when_request_present", () => { + // Pass a typed object directly (not parsed from content) + assert.equal(selectProseOrPermission(PENDING_PAYLOAD, "markdown-node"), null); +}); diff --git a/desktop/src/shared/lib/computePermissionRequest.ts b/desktop/src/shared/lib/computePermissionRequest.ts new file mode 100644 index 000000000..c3568f8c1 --- /dev/null +++ b/desktop/src/shared/lib/computePermissionRequest.ts @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import type { PermissionRequestPayload } from "@/shared/lib/permissionRequest"; +import { extractPermissionRequest } from "@/shared/lib/permissionRequest"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Pure helper that computes the active `PermissionRequestPayload` for a + * message body. + * + * The card is active ONLY when: + * 1. `interactive` is true — non-interactive surfaces (search snippets, etc.) + * never render actionable cards. + * 2. `agentPubkey` is provided and matches `signerPubkey` — authenticates + * the sentinel against the raw event signer from the signed envelope, not + * a relay-delegated author. This enforces the D1 requirement that forged + * cards (wrong signer) never become actionable. + * 3. For resolved state: `editSignerPubkey` must equal `agentPubkey` — only + * edits signed by the original agent may flip the card to resolved. + * Owner-signed or attacker-signed edits are rejected. + * + * Extracted into its own module so it can be tested without pulling in + * markdown.tsx's heavy dependency chain. + */ +export function computePermissionRequest( + content: string, + interactive: boolean, + /** Normalized hex pubkey of the known agent for this channel (from signed envelope). */ + agentPubkey: string | undefined | null, + /** Raw signer pubkey of the message event (from the signed envelope's pubkey field). */ + signerPubkey: string | undefined | null, + /** + * Signer pubkey of the most recent kind-40003 edit for this message, if any. + * Undefined/null means no edit has arrived. Only edits where + * `editSignerPubkey === agentPubkey` may resolve the card. + */ + editSignerPubkey?: string | null, +): PermissionRequestPayload | null { + if (!interactive || !agentPubkey || !signerPubkey) return null; + + // D1 signer gate: the kind-9 must be signed by the known agent. + if (normalizePubkey(signerPubkey) !== normalizePubkey(agentPubkey)) { + return null; + } + + const payload = extractPermissionRequest(content); + if (payload === null) return null; + + // For resolved state (edit has arrived): verify the edit was signed by the + // original agent. Owner-signed or attacker-signed edits are rejected. + if ( + payload.state === "resolved" && + editSignerPubkey !== undefined && + editSignerPubkey !== null + ) { + if (normalizePubkey(editSignerPubkey) !== normalizePubkey(agentPubkey)) { + return null; + } + } + + return payload; +} + +/** + * Returns `markdownNode` when no trusted permission-request payload is present, + * or `null` when the card should suppress the prose. + * + * Mirrors `selectProseOrNudge` from computeConfigNudge.ts — same prose- + * suppression contract. + */ +export function selectProseOrPermission( + request: PermissionRequestPayload | null, + markdownNode: ReactNode, +): ReactNode { + return request === null ? markdownNode : null; +} diff --git a/desktop/src/shared/lib/permissionRequest.test.mjs b/desktop/src/shared/lib/permissionRequest.test.mjs new file mode 100644 index 000000000..f6ab54f25 --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.test.mjs @@ -0,0 +1,297 @@ +/** + * Named test matrix for the `permissionRequest` sentinel parser. + * + * All fixtures are verbatim from Duncan's frozen schema (event b31c716e). + * Tests cover: parse, reject, and sentinel extraction/stripping. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// ── Import via dynamic import to work with the ESM test runner ──────────────── +// The tests run against the compiled JS (tsc outputs CJS); for the mjs runner +// we use a relative path that resolves after build or through tsx. + +const mod = await import("./permissionRequest.js").catch( + () => import("./permissionRequest.ts"), +); +const { extractPermissionRequest, stripPermissionRequestSentinel } = mod; + +// ── Fixtures (verbatim from event b31c716e) ─────────────────────────────────── + +const PENDING_NORMAL = { + v: 1, + state: "pending", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, +}; + +const PENDING_DURABLE = { + v: 1, + state: "pending", + requestNonce: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow-once", "opt-allow-always", "opt-deny"], + labels: { + "opt-allow-once": "Allow once", + "opt-allow-always": "Always allow", + "opt-deny": "Deny", + }, + hasDurableRule: true, + durableRuleNote: + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.", +}; + +const RESOLVED_APPLIED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + outcome: "applied", + chosenOptionId: "opt-allow", +}; + +const RESOLVED_TIMED_OUT = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + outcome: "timed_out", + chosenOptionId: null, +}; + +const RESOLVED_CANCELLED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + outcome: "cancelled", + chosenOptionId: null, +}; + +const RESOLVED_REJECTED = { + v: 1, + state: "resolved", + requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4", + originalEventId: + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + sessionId: "sess-abc", + turnId: "turn-xyz", + expiresAt: 1786206732, + optionIds: ["opt-allow", "opt-deny"], + labels: { "opt-allow": "Allow once", "opt-deny": "Deny" }, + hasDurableRule: false, + durableRuleNote: null, + outcome: "rejected", + chosenOptionId: null, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wrap(payload) { + return `Some prose above.\n\n\`\`\`buzz:permission-request\n${JSON.stringify(payload)}\n\`\`\`\n`; +} + +// ── Parse: happy-path fixtures ──────────────────────────────────────────────── + +describe("extractPermissionRequest — pending fixtures", () => { + it("test_pending_normal_parses_correctly", () => { + const result = extractPermissionRequest(wrap(PENDING_NORMAL)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "pending"); + assert.equal(result.requestNonce, "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4"); + assert.equal(result.sessionId, "sess-abc"); + assert.equal(result.turnId, "turn-xyz"); + assert.equal(result.expiresAt, 1786206732); + assert.deepEqual(result.optionIds, ["opt-allow", "opt-deny"]); + assert.deepEqual(result.labels, { + "opt-allow": "Allow once", + "opt-deny": "Deny", + }); + assert.equal(result.hasDurableRule, false); + assert.equal(result.durableRuleNote, null); + // pending has no originalEventId, outcome, chosenOptionId + assert.ok(!("originalEventId" in result)); + assert.ok(!("outcome" in result)); + assert.ok(!("chosenOptionId" in result)); + }); + + it("test_pending_durable_rule_parses_correctly", () => { + const result = extractPermissionRequest(wrap(PENDING_DURABLE)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "pending"); + assert.equal(result.hasDurableRule, true); + assert.equal( + result.durableRuleNote, + "Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.", + ); + assert.deepEqual(result.optionIds, [ + "opt-allow-once", + "opt-allow-always", + "opt-deny", + ]); + assert.equal(result.labels["opt-allow-always"], "Always allow"); + }); +}); + +describe("extractPermissionRequest — resolved fixtures", () => { + it("test_resolved_applied_parses_correctly", () => { + const result = extractPermissionRequest(wrap(RESOLVED_APPLIED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "applied"); + assert.equal(result.chosenOptionId, "opt-allow"); + assert.equal( + result.originalEventId, + "deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead", + ); + }); + + it("test_resolved_timed_out_parses_correctly", () => { + const result = extractPermissionRequest(wrap(RESOLVED_TIMED_OUT)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "timed_out"); + assert.equal(result.chosenOptionId, null); + }); + + it("test_resolved_cancelled_parses_correctly", () => { + const result = extractPermissionRequest(wrap(RESOLVED_CANCELLED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "cancelled"); + assert.equal(result.chosenOptionId, null); + }); + + it("test_resolved_rejected_parses_correctly", () => { + const result = extractPermissionRequest(wrap(RESOLVED_REJECTED)); + assert.ok(result !== null, "should parse"); + assert.equal(result.state, "resolved"); + assert.equal(result.outcome, "rejected"); + assert.equal(result.chosenOptionId, null); + }); +}); + +// ── Parse: rejection cases ──────────────────────────────────────────────────── + +describe("extractPermissionRequest — rejection cases", () => { + it("test_no_sentinel_returns_null", () => { + assert.equal(extractPermissionRequest("just prose, no fence"), null); + }); + + it("test_wrong_version_returns_null", () => { + const bad = { ...PENDING_NORMAL, v: 2 }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_unknown_state_returns_null", () => { + const bad = { ...PENDING_NORMAL, state: "unknown" }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_empty_optionIds_returns_null", () => { + const bad = { ...PENDING_NORMAL, optionIds: [] }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_too_many_optionIds_returns_null", () => { + const bad = { + ...PENDING_NORMAL, + optionIds: Array.from({ length: 11 }, (_, i) => `opt-${i}`), + labels: Object.fromEntries( + Array.from({ length: 11 }, (_, i) => [`opt-${i}`, `Option ${i}`]), + ), + }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_label_exceeding_200_chars_returns_null", () => { + const longLabel = "x".repeat(201); + const bad = { + ...PENDING_NORMAL, + labels: { "opt-allow": longLabel, "opt-deny": "Deny" }, + }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_missing_requestNonce_returns_null", () => { + const { requestNonce: _, ...bad } = PENDING_NORMAL; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_resolved_missing_originalEventId_returns_null", () => { + const { originalEventId: _, ...bad } = RESOLVED_APPLIED; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_resolved_originalEventId_wrong_length_returns_null", () => { + const bad = { ...RESOLVED_APPLIED, originalEventId: "tooshort" }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); + + it("test_invalid_json_returns_null", () => { + const content = "```buzz:permission-request\n{not valid json}\n```\n"; + assert.equal(extractPermissionRequest(content), null); + }); + + it("test_empty_fence_body_returns_null", () => { + const content = "```buzz:permission-request\n\n```\n"; + assert.equal(extractPermissionRequest(content), null); + }); + + it("test_non_finite_expiresAt_returns_null", () => { + const bad = { ...PENDING_NORMAL, expiresAt: Infinity }; + assert.equal(extractPermissionRequest(wrap(bad)), null); + }); +}); + +// ── stripPermissionRequestSentinel ─────────────────────────────────────────── + +describe("stripPermissionRequestSentinel", () => { + it("test_strip_removes_fence_and_preserves_prose", () => { + const content = `Some prose.\n\n\`\`\`buzz:permission-request\n${JSON.stringify(PENDING_NORMAL)}\n\`\`\`\n`; + const stripped = stripPermissionRequestSentinel(content); + assert.ok(!stripped.includes("buzz:permission-request")); + assert.ok(stripped.includes("Some prose.")); + }); + + it("test_strip_no_sentinel_returns_original", () => { + const content = "just prose here"; + assert.equal(stripPermissionRequestSentinel(content), content); + }); + + it("test_strip_empty_string_returns_empty", () => { + assert.equal(stripPermissionRequestSentinel(""), ""); + }); +}); diff --git a/desktop/src/shared/lib/permissionRequest.ts b/desktop/src/shared/lib/permissionRequest.ts new file mode 100644 index 000000000..e3c3c2ac7 --- /dev/null +++ b/desktop/src/shared/lib/permissionRequest.ts @@ -0,0 +1,211 @@ +/** + * Utilities for extracting and parsing the `buzz:permission-request` sentinel + * that `buzz-acp` publishes as a kind:9 reply into the triggering thread when + * an `ask`-policy permission request is admitted. + * + * Wire format (versioned discriminated union, schema v1 — frozen at event + * b31c716e): + * + * ```buzz:permission-request + * {"v":1,"state":"pending", … } + * ``` + * + * The prose above the fence is the plaintext fallback for non-card clients. + * Desktop strips the sentinel and renders a `PermissionRequestCard` instead. + * + * Security invariants: + * - `agentPubkey` and `channelId` are derived from the SIGNED EVENT ENVELOPE, + * never from sentinel JSON. + * - `optionId` values are opaque — treated as arbitrary strings; never + * interpreted as ACP kinds by the renderer. + * - Labels come from `labels[optionId]` — harness-provided display strings, + * not raw ACP kind names. + * - All untrusted display strings are size-bounded (≤ 200 chars) and + * HTML-escaped by React at render time. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** + * Pending sentinel — the card is actionable. + * + * `requestNonce` and `expiresAt` are trusted as unsigned ints from the harness. + * `labels` values are untrusted display strings (capped at 200 chars). + */ +export type PermissionRequestPending = { + v: 1; + state: "pending"; + requestNonce: string; + sessionId: string | null; + turnId: string | null; + expiresAt: number; + /** Opaque option IDs. Size-bounded: ≤ 10. */ + optionIds: string[]; + /** Harness-provided display labels keyed by optionId. Each ≤ 200 chars. */ + labels: Record; + /** True when an `allow_always` option is present (D5 durable-rule disclosure). */ + hasDurableRule: boolean; + /** + * Human-readable durable-rule disclosure note. Non-null only when + * `hasDurableRule === true`. E.g. "Includes an 'Always allow' option — + * creates a machine-wide durable rule in Codex." + */ + durableRuleNote: string | null; +}; + +/** + * Resolved sentinel — the card is non-actionable (archived state). + * + * Published by the harness as a kind-40003 edit signed by the original agent. + * `originalEventId` is the kind-9 event ID — correlates the edit to the card. + */ +export type PermissionRequestResolved = { + v: 1; + state: "resolved"; + requestNonce: string; + originalEventId: string; + sessionId: string | null; + turnId: string | null; + expiresAt: number; + optionIds: string[]; + labels: Record; + hasDurableRule: boolean; + durableRuleNote: string | null; + /** One of "applied" | "timed_out" | "cancelled" | "rejected". */ + outcome: string; + /** Non-null only when outcome === "applied". */ + chosenOptionId: string | null; +}; + +export type PermissionRequestPayload = + | PermissionRequestPending + | PermissionRequestResolved; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FENCE_OPEN = "```buzz:permission-request"; +const FENCE_CLOSE = "```"; + +/** Maximum character length for any untrusted display string in the sentinel. */ +const MAX_LABEL_CHARS = 200; + +/** Maximum number of option IDs in a sentinel (PERMISSION_OPTIONS_MAX). */ +const MAX_OPTION_IDS = 10; + +// ── Extractor ───────────────────────────────────────────────────────────────── + +/** + * Extract the `PermissionRequestPayload` from a message body, if present. + * + * Returns `null` when: + * - the sentinel fence is absent + * - the JSON inside is malformed + * - the parsed value does not match the expected shape + * + * Never throws — all errors are swallowed so this is safe in the render path. + */ +export function extractPermissionRequest( + content: string, +): PermissionRequestPayload | null { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return null; + + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) return null; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) return null; + + const json = content.slice(jsonStart + 1, closeIdx).trim(); + if (!json) return null; + + try { + const parsed: unknown = JSON.parse(json); + return isPermissionRequestPayload(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Strip the `buzz:permission-request` sentinel block (and any preceding blank + * line) from a message body. Returns the original string unchanged when no + * sentinel is present. + * + * Used so the prose fallback renders without the raw code block. + */ +export function stripPermissionRequestSentinel(content: string): string { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return content; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, openIdx); + if (closeIdx === -1) return content; + + const afterFence = closeIdx + `\n${FENCE_CLOSE}`.length; + const prose = content.slice(0, openIdx).replace(/\n{2,}$/, "\n"); + return prose + content.slice(afterFence); +} + +// ── Type guards ──────────────────────────────────────────────────────────────── + +function isSafeString(v: unknown): v is string { + return typeof v === "string" && v.length <= MAX_LABEL_CHARS; +} + +function isNullableString(v: unknown): v is string | null { + return v === null || isSafeString(v); +} + +function isLabelsRecord(v: unknown): v is Record { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + return Object.values(v as Record).every(isSafeString); +} + +function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + if (p.v !== 1) return false; + + // Shared fields present in both states + if (typeof p.requestNonce !== "string" || p.requestNonce.length === 0) { + return false; + } + if (!isNullableString(p.sessionId)) return false; + if (!isNullableString(p.turnId)) return false; + if (typeof p.expiresAt !== "number" || !Number.isFinite(p.expiresAt)) { + return false; + } + if ( + !Array.isArray(p.optionIds) || + p.optionIds.length === 0 || + p.optionIds.length > MAX_OPTION_IDS || + !p.optionIds.every((id) => typeof id === "string" && id.length > 0) + ) { + return false; + } + if (!isLabelsRecord(p.labels)) return false; + if (typeof p.hasDurableRule !== "boolean") return false; + if (!isNullableString(p.durableRuleNote)) return false; + + if (p.state === "pending") { + return true; + } + + if (p.state === "resolved") { + // originalEventId: 64-char hex string + if ( + typeof p.originalEventId !== "string" || + p.originalEventId.length !== 64 + ) { + return false; + } + if (typeof p.outcome !== "string" || p.outcome.length === 0) return false; + // chosenOptionId: string or null (non-null only on "applied") + if (p.chosenOptionId !== null && typeof p.chosenOptionId !== "string") { + return false; + } + return true; + } + + return false; +} diff --git a/desktop/src/shared/ui/permission-request-card.tsx b/desktop/src/shared/ui/permission-request-card.tsx new file mode 100644 index 000000000..c64c76152 --- /dev/null +++ b/desktop/src/shared/ui/permission-request-card.tsx @@ -0,0 +1,254 @@ +/** + * Inline card rendered when the desktop detects a `buzz:permission-request` + * sentinel in a kind:9 message body. Mirrors the `ConfigNudgeCard` pattern. + * + * Security invariants enforced by the caller (`MessageRow`): + * - `request` is only non-null when the kind-9 signer equals the known agent + * pubkey for this channel (D1 signer gate in `computePermissionRequest`). + * - Resolved state (`state === "resolved"`) requires the edit to have been + * signed by the original agent (edit authenticity gate). + * + * Actionable buttons render ONLY when: + * (a) `request.state === "pending"` AND + * (b) `isOwner` is true (the current viewer is the verified agent owner). + * All other viewers see a read-only card. + */ +import * as React from "react"; +import { ShieldCheck } from "lucide-react"; + +import { sendPermissionDecision } from "@/shared/api/agentControl"; +import { cn } from "@/shared/lib/cn"; +import { + Attachment, + AttachmentContent, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; +import type { + PermissionRequestPayload, + PermissionRequestPending, +} from "@/shared/lib/permissionRequest"; + +export type PermissionRequestCardProps = { + className?: string; + request: PermissionRequestPayload; + /** Hex pubkey of the agent that published the sentinel. */ + agentPubkey: string; + /** Channel ID for routing the permission decision. */ + channelId: string; + /** + * True when the current viewer is the verified agent owner. + * Absent or false → read-only card (buttons suppressed). + */ + isOwner?: boolean; +}; + +/** + * Heuristic: treat an option as "deny" when its harness label contains deny, + * reject, or block (case-insensitive). Opaque optionIds carry no inherent + * semantics — the label is the only display hint available. + */ +function isDenyLabel(label: string): boolean { + const lower = label.toLowerCase(); + return ( + lower.includes("deny") || + lower.includes("reject") || + lower.includes("block") + ); +} + +function buttonClass(deny: boolean): string { + return deny + ? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50" + : "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50"; +} + +/** + * Outcome display label — maps the harness outcome string to human copy. + */ +function outcomeLabel( + outcome: string, + chosenOptionId: string | null, + labels: Record, +): string { + if (outcome === "applied" && chosenOptionId !== null) { + const chosen = labels[chosenOptionId]; + return chosen ? `Approved: ${chosen}` : "Approved"; + } + if (outcome === "timed_out") return "Timed out"; + if (outcome === "cancelled") return "Cancelled"; + if (outcome === "rejected") return "Denied"; + return outcome; +} + +/** + * Allow/Deny buttons for a pending, owner-visible permission card. + * On click: disables locally and shows "Decision sent". Convergence to final + * state comes from the agent's kind-40003 edit or expiry — no promise of + * immediate resolution from the harness response. + */ +function PermissionButtons({ + agentPubkey, + channelId, + request, +}: { + agentPubkey: string; + channelId: string; + request: PermissionRequestPending; +}) { + const [submitted, setSubmitted] = React.useState(null); + + const now = Date.now() / 1000; + const expired = request.expiresAt <= now; + + if (expired) { + return ( +
Timed out
+ ); + } + + if (submitted !== null) { + return ( +
Decision sent
+ ); + } + + return ( +
+
+ {request.optionIds.map((optionId) => { + const label = request.labels[optionId] ?? optionId; + return ( + + ); + })} +
+ {request.hasDurableRule && request.durableRuleNote !== null ? ( +

+ ⚠ {request.durableRuleNote} +

+ ) : null} +
+ ); +} + +/** + * Countdown display for a pending card. Updates every second until expiry. + * Returns null when already expired (buttons handle that state). + */ +function ExpiryCountdown({ expiresAt }: { expiresAt: number }) { + const [secsLeft, setSecsLeft] = React.useState(() => + Math.max(0, Math.round(expiresAt - Date.now() / 1000)), + ); + + React.useEffect(() => { + if (secsLeft <= 0) return; + const id = setInterval(() => { + const remaining = Math.max(0, Math.round(expiresAt - Date.now() / 1000)); + setSecsLeft(remaining); + if (remaining <= 0) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [expiresAt, secsLeft]); + + if (secsLeft <= 0) return null; + const mins = Math.floor(secsLeft / 60); + const secs = secsLeft % 60; + const label = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + return ( + + {" "} + · expires in {label} + + ); +} + +export function PermissionRequestCard({ + className, + request, + agentPubkey, + channelId, + isOwner, +}: PermissionRequestCardProps) { + if (request.state === "resolved") { + const resolvedLabel = outcomeLabel( + request.outcome, + request.chosenOptionId, + request.labels, + ); + return ( + + + + + + Permission request resolved + +
+ {resolvedLabel} +
+
+
+ ); + } + + // Pending state + const pending = request as PermissionRequestPending; + const expired = pending.expiresAt <= Date.now() / 1000; + + return ( + + + + + + Permission request + {!expired ? : null} + + {isOwner ? ( + + ) : ( +
+ Waiting for owner approval +
+ )} +
+
+ ); +}