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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-08 13:17:54 -04:00
co-authored by Will Pfleger
parent e87f265d25
commit cef78d723d
10 changed files with 1195 additions and 1 deletions
@@ -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),
+7
View File
@@ -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;
@@ -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 ? (
<PermissionRequestCardBlock
agentPubkey={getPermissionRequestAgentPubkey(
message,
isKnownAgentPubkey,
)}
channelId={channelId}
content={message.body}
editSignerPubkey={message.editSignerPubkey}
interactive
ownerPubkey={message.ownerPubkey}
signerPubkey={message.signerPubkey}
/>
) : null}
{continuationMetadataNode}
<MessageReactions
messageId={message.id}
@@ -909,6 +925,7 @@ export const MessageRow = React.memo(
prev.message.kind === next.message.kind &&
prev.message.pending === next.message.pending &&
prev.message.edited === next.message.edited &&
prev.message.editSignerPubkey === next.message.editSignerPubkey &&
// Value comparisons, not identity: these arrays are rebuilt with fresh
// identities on every ingest/refetch even when unchanged — identity
// checks made every row re-render on every streamed event in an open
@@ -0,0 +1,90 @@
/**
* Wrapper that handles the current-viewer identity check for the
* `PermissionRequestCard`, keeping React hooks out of the memo-heavy
* `MessageRow` component.
*
* Renders nothing when `computePermissionRequest` returns null (no trusted
* sentinel, wrong signer, or non-interactive surface).
*/
import * as React from "react";
import { useIdentityQuery } from "@/shared/api/hooks";
import { computePermissionRequest } from "@/shared/lib/computePermissionRequest";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { PermissionRequestCard } from "@/shared/ui/permission-request-card";
export type PermissionRequestCardBlockProps = {
/** Message body content — may contain the sentinel or may not. */
content: string;
/** Whether this is an interactive render surface. Non-interactive → no card. */
interactive: boolean;
/**
* Normalized hex pubkey of the known agent that signed the kind-9 event.
* Undefined → card disabled (no agent signer on this message).
*/
agentPubkey: string | undefined;
/** Raw signer pubkey from the signed event envelope. */
signerPubkey: string | undefined;
/**
* Signer pubkey of the most recent authorized kind-40003 edit, if any.
* Used to enforce edit authenticity: only agent-signed edits resolve the card.
*/
editSignerPubkey?: string;
/** Verified owner pubkey for the agent (from the agent's profile). */
ownerPubkey?: string | null;
/** Channel ID for routing the permission decision click. */
channelId: string;
};
export const PermissionRequestCardBlock = React.memo(
function PermissionRequestCardBlock({
content,
interactive,
agentPubkey,
signerPubkey,
editSignerPubkey,
ownerPubkey,
channelId,
}: PermissionRequestCardBlockProps) {
const identityQuery = useIdentityQuery();
const viewerPubkey = identityQuery.data?.pubkey;
const request = computePermissionRequest(
content,
interactive,
agentPubkey,
signerPubkey,
editSignerPubkey,
);
if (request === null || !agentPubkey) return null;
const isOwner =
!!viewerPubkey &&
!!ownerPubkey &&
normalizePubkey(viewerPubkey) === normalizePubkey(ownerPubkey);
return (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-permission-request=""
>
<PermissionRequestCard
agentPubkey={agentPubkey}
channelId={channelId}
isOwner={isOwner}
request={request}
/>
</AttachmentGroup>
);
},
(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,
);
@@ -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<TimelineMessage, "kind" | "signerPubkey">,
isKnownAgentPubkey: (pubkey: string) => boolean,
): string | undefined {
if (
message.kind === KIND_STREAM_MESSAGE &&
message.signerPubkey &&
isKnownAgentPubkey(message.signerPubkey)
) {
return message.signerPubkey;
}
return undefined;
}
@@ -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);
});
@@ -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;
}
@@ -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(""), "");
});
});
+211
View File
@@ -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<string, string>;
/** 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<string, string>;
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<string, string> {
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
return Object.values(v as Record<string, unknown>).every(isSafeString);
}
function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload {
if (typeof v !== "object" || v === null) return false;
const p = v as Record<string, unknown>;
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;
}
@@ -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, string>,
): 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<string | null>(null);
const now = Date.now() / 1000;
const expired = request.expiresAt <= now;
if (expired) {
return (
<div className="mt-1.5 text-xs text-muted-foreground">Timed out</div>
);
}
if (submitted !== null) {
return (
<div className="mt-1.5 text-xs text-muted-foreground">Decision sent</div>
);
}
return (
<div className="mt-1.5 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5">
{request.optionIds.map((optionId) => {
const label = request.labels[optionId] ?? optionId;
return (
<button
key={optionId}
type="button"
className={buttonClass(isDenyLabel(label))}
data-testid={`permission-decision-${optionId}`}
onClick={() => {
setSubmitted(optionId);
void sendPermissionDecision(
agentPubkey,
channelId,
request.requestNonce,
optionId,
).catch(() => {
// Relay rejected the send. Re-enable so the user can retry.
setSubmitted(null);
});
}}
>
{label}
</button>
);
})}
</div>
{request.hasDurableRule && request.durableRuleNote !== null ? (
<p className="max-w-[28rem] text-[11px] leading-4 text-amber-700 dark:text-amber-400 opacity-80">
⚠ {request.durableRuleNote}
</p>
) : null}
</div>
);
}
/**
* 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 (
<span className="text-[11px] text-muted-foreground opacity-70">
{" "}
· expires in {label}
</span>
);
}
export function PermissionRequestCard({
className,
request,
agentPubkey,
channelId,
isOwner,
}: PermissionRequestCardProps) {
if (request.state === "resolved") {
const resolvedLabel = outcomeLabel(
request.outcome,
request.chosenOptionId,
request.labels,
);
return (
<Attachment
className={cn(
"max-w-[min(100%,32rem)] shrink-0 shadow-none",
className,
)}
orientation="horizontal"
state="done"
>
<AttachmentMedia className="text-muted-foreground">
<ShieldCheck aria-hidden="true" className="h-4 w-4" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="whitespace-normal text-muted-foreground line-clamp-2">
Permission request resolved
</AttachmentTitle>
<div className="mt-0.5 text-xs text-muted-foreground">
{resolvedLabel}
</div>
</AttachmentContent>
</Attachment>
);
}
// Pending state
const pending = request as PermissionRequestPending;
const expired = pending.expiresAt <= Date.now() / 1000;
return (
<Attachment
className={cn("max-w-[min(100%,32rem)] shrink-0 shadow-none", className)}
orientation="horizontal"
state={expired ? "done" : "idle"}
>
<AttachmentMedia className="text-amber-600 dark:text-amber-400">
<ShieldCheck aria-hidden="true" className="h-4 w-4" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="whitespace-normal text-amber-700 dark:text-amber-400 line-clamp-2">
Permission request
{!expired ? <ExpiryCountdown expiresAt={pending.expiresAt} /> : null}
</AttachmentTitle>
{isOwner ? (
<PermissionButtons
agentPubkey={agentPubkey}
channelId={channelId}
request={pending}
/>
) : (
<div className="mt-1 text-xs text-muted-foreground">
Waiting for owner approval
</div>
)}
</AttachmentContent>
</Attachment>
);
}