Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 67 additions & 5 deletions apps/chat/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ vi.mock("@brainstorm-os/sdk/object-menu", () => ({

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

type StubEntity = { id: string; type: string; properties: Record<string, unknown> };
type StubEntity = {
id: string;
type: string;
properties: Record<string, unknown>;
/** The row's host-written `created_by` — the snapshot's `ownerAppId`. */
ownerAppId?: string;
};

const created: { type: string; properties: Record<string, unknown> }[] = [];

Expand Down Expand Up @@ -259,10 +265,31 @@ describe("ChatApp", () => {
});

describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {
function proposalMessage(id: string, channelId: string, agentProposal: unknown): StubEntity {
const AGENT_FINGERPRINT = "ed25519:beef";

/** The vault's own agent member — SHELL-authored, so it can vouch for a card
* (Agent-Teams-3 closure b). */
const localAgent: StubEntity = {
id: "agt_1",
type: "brainstorm/Agent/v1",
ownerAppId: "shell",
properties: {
fingerprint: AGENT_FINGERPRINT,
pubkey: "pk-researcher",
displayName: "Researcher",
},
};

function proposalMessage(
id: string,
channelId: string,
agentProposal: unknown,
ownerAppId = AGENT_FINGERPRINT,
): StubEntity {
return {
id,
type: MESSAGE_TYPE,
ownerAppId,
properties: {
conversation: channelId,
body: "Proposed task: Ship the release notes — approve it to save it.",
Expand All @@ -286,7 +313,7 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {
};

it("renders a card for a pending proposal and approves through the host service", async () => {
await mount([channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
await mount([localAgent, channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
expect(container.querySelector('[data-testid="chat-proposal"]')).not.toBeNull();
const approve = container.querySelector(
'[data-testid="chat-proposal-approve"]',
Expand All @@ -298,7 +325,7 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {
});

it("discards through the host service", async () => {
await mount([channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
await mount([localAgent, channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
const discard = container.querySelector(
'[data-testid="chat-proposal-discard"]',
) as HTMLButtonElement;
Expand All @@ -310,6 +337,7 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {

it("opens what an approval created through the shared open intent", async () => {
await mount([
localAgent,
channel("c1", "general"),
proposalMessage("m1", "c1", {
...pendingTask,
Expand All @@ -329,6 +357,7 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {

it("renders a MALFORMED proposal as an ordinary message — never an approvable card", async () => {
await mount([
localAgent,
channel("c1", "general"),
// A kind the channel path cannot honour: main would refuse the approval.
proposalMessage("m1", "c1", {
Expand All @@ -347,7 +376,7 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {
ok: false,
reason: "already-decided",
} as unknown as { ok: true; status: string; createdEntityId: string });
await mount([channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
await mount([localAgent, channel("c1", "general"), proposalMessage("m1", "c1", pendingTask)]);
const approve = container.querySelector(
'[data-testid="chat-proposal-approve"]',
) as HTMLButtonElement;
Expand All @@ -358,6 +387,39 @@ describe("ChatApp — agent proposal cards (Agent-Teams-3)", () => {
"Someone already decided this one.",
);
});

it("CLOSURE (b): a card whose author is NOT a local agent renders as an ordinary message", async () => {
// The phishing surface slice 2 left open: a card synced in from another
// vault (or pasted by an app) showed an Approve button that main could
// only ever refuse. Now the message's HOST-written author must resolve to
// a shell-authored `Agent/v1` record before a card renders at all.
for (const foreignAuthor of ["brainstorm", `${AGENT_FINGERPRINT} (received)`, "io.evil.app"]) {
await mount([
localAgent,
channel("c1", "general"),
proposalMessage("m1", "c1", pendingTask, foreignAuthor),
]);
expect(container.querySelector('[data-testid="chat-proposal"]')).toBeNull();
expect(container.querySelector('[data-testid="chat-proposal-approve"]')).toBeNull();
// The body still reads honestly on its own — no silent drop.
expect(container.querySelector(".chat__line")?.textContent).toContain(
"Proposed task: Ship the release notes",
);
act(() => root.unmount());
container.remove();
}
// Re-mount so the shared afterEach has a live root to tear down.
await mount([channel("c1", "general")]);
});

it("CLOSURE (b): an Agent/v1 record NOT authored by the shell cannot vouch for a card", async () => {
await mount([
{ ...localAgent, ownerAppId: "io.evil.app" },
channel("c1", "general"),
proposalMessage("m1", "c1", pendingTask),
]);
expect(container.querySelector('[data-testid="chat-proposal"]')).toBeNull();
});
});

describe("ChatApp — app-icon unread badge (7.14)", () => {
Expand Down
115 changes: 115 additions & 0 deletions apps/chat/src/logic/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,121 @@ describe("buildMessageProperties", () => {
});
});

describe("proposal cards gate on a TRUSTED LOCAL AGENT author (Agent-Teams-3 closure b)", () => {
// The slice-2 review left this OPEN: Chat rendered an approve/discard card
// from ANY message carrying the `agentProposal` property, so a card synced in
// from another vault showed an Approve button that could only ever fail
// (main refuses it — inbound rows never carry a local agent fingerprint) —
// a UX lie and a phishing surface. The gate: the message's HOST-written
// author (`ownerAppId` = the row's `created_by`) must resolve to a
// shell-authored `Agent/v1` record, the renderer-side mirror of main's
// `readTrustedAgentDef` notion.
const AGENT_FP = "ed25519:00ff";
const pendingProposal = {
artifact: {
id: "prp_1",
kind: "task",
entityType: "brainstorm/Task/v1",
fields: { title: "Ship it" },
summary: "Ship it",
},
status: "pending",
};

function agentRow(ownerAppId: string, fingerprint = AGENT_FP): EntityLike {
return {
id: `agt_${fingerprint}`,
type: "brainstorm/Agent/v1",
ownerAppId,
properties: { fingerprint, pubkey: "pk", displayName: "Researcher" },
};
}

function cardMessage(ownerAppId: string): EntityLike {
return {
id: "m_card",
type: MESSAGE_TYPE,
ownerAppId,
properties: {
conversation: "c1",
body: "Proposed task: Ship it — approve it to save it.",
createdAt: "2026-08-01T10:00:00.000Z",
seq: 1,
sender: { kind: SenderKind.Assistant, personRef: "pk", displayName: "Researcher" },
agentProposal: pendingProposal,
},
};
}

it("attaches the proposal when the author is a shell-authored local agent", () => {
const rows = channelMessages([agentRow("shell"), cardMessage(AGENT_FP)], "c1");
expect(rows[0]?.proposal?.status).toBe("pending");
});

it("a synced-in card (foreign created_by) renders as an ordinary message, never a card", () => {
// The production receive path stamps `created_by` from the type prefix
// ("brainstorm"), and the dev bridge stamps "<pubkey> (received)" — neither
// can ever equal a local agent fingerprint.
for (const foreign of ["brainstorm", `${AGENT_FP} (received)`, "io.brainstorm.chat"]) {
const rows = channelMessages([agentRow("shell"), cardMessage(foreign)], "c1");
expect(rows[0]?.proposal).toBeUndefined();
expect(rows[0]?.body).toContain("Proposed task");
}
});

it("an Agent/v1 row NOT authored by the shell never vouches for a card", () => {
for (const forger of ["io.evil.app", "brainstorm", `${AGENT_FP} (received)`]) {
const rows = channelMessages([agentRow(forger), cardMessage(AGENT_FP)], "c1");
expect(rows[0]?.proposal).toBeUndefined();
}
});

it("a NON-CANONICAL spelling of a trusted fingerprint never matches", () => {
// The class that broke an earlier fix on this track: an attacker reaches
// for a second spelling of the same value. The comparison is exact, and
// the shell writes fingerprints in canonical lowercase hex, so every
// variant fails CLOSED (no card) rather than open.
for (const variant of [
AGENT_FP.toUpperCase(),
`${AGENT_FP} `,
` ${AGENT_FP}`,
`${AGENT_FP}`,
AGENT_FP.replace("ed25519:", "ED25519:"),
AGENT_FP.replace("ed25519:", "ed25519::"),
// A homoglyph — Cyrillic 'е' in "ed25519".
AGENT_FP.replace("e", "е"),
]) {
expect(
channelMessages([agentRow("shell"), cardMessage(variant)], "c1")[0]?.proposal,
).toBeUndefined();
// ...and the mirror: a variant on the AGENT ROW cannot vouch either.
expect(
channelMessages([agentRow("shell", variant), cardMessage(AGENT_FP)], "c1")[0]?.proposal,
).toBeUndefined();
}
});

it("a non-canonical status / kind spelling is not an approvable card", () => {
for (const bad of [
{ ...pendingProposal, status: "Pending" },
{ ...pendingProposal, status: "pending " },
{ ...pendingProposal, artifact: { ...pendingProposal.artifact, kind: "Task" } },
{ ...pendingProposal, artifact: { ...pendingProposal.artifact, kind: "task " } },
]) {
const row = { ...cardMessage(AGENT_FP) };
row.properties = { ...row.properties, agentProposal: bad };
expect(channelMessages([agentRow("shell"), row], "c1")[0]?.proposal).toBeUndefined();
}
});

it("no Agent/v1 record at all → no card (fail-closed default)", () => {
expect(channelMessages([cardMessage(AGENT_FP)], "c1")[0]?.proposal).toBeUndefined();
// And a bare toMessage (no trusted set supplied) stays fail-closed too —
// the unread/widget projections must never resurrect a card.
expect(toMessage(cardMessage(AGENT_FP)).proposal).toBeUndefined();
});
});

describe("buildChannelProperties", () => {
it("trims the name and drops an empty topic", () => {
expect(buildChannelProperties({ name: " general ", createdAt: "t" })).toEqual({
Expand Down
64 changes: 60 additions & 4 deletions apps/chat/src/logic/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import {
AGENT_TYPE,
MESSAGE_TYPE_URL,
type MessageAttachment,
type MessageDef,
Expand All @@ -29,12 +30,21 @@ export const CHANNEL_TYPE = "io.brainstorm.chat/Channel/v1";
/** Re-exported for the app + tests so the shared substrate id has one name. */
export const MESSAGE_TYPE = MESSAGE_TYPE_URL;

/** The `created_by` the shell stamps on rows it authors itself. Host-written —
* no app can set it — which is what makes it a usable trust signal here.
* Mirrors `SHELL_PRINCIPAL` in the main process's `agents/agent-record.ts`. */
export const SHELL_PRINCIPAL = "shell";

/** The minimal entity shape this module reads — a structural subset of the
* SDK `VaultEntity`, so helpers test against plain objects. */
export type EntityLike = {
id: string;
type: string;
properties: Record<string, unknown>;
/** The row's HOST-written `created_by` (the snapshot's `ownerAppId`). An app
* cannot set it, so it is the only author claim on a message worth trusting
* — `sender.personRef` is a self-assertion of whichever app wrote the row. */
ownerAppId?: string;
};

export type ChatChannel = {
Expand Down Expand Up @@ -135,10 +145,53 @@ function readAuthor(
return { authorRef: `anon:${fallbackId}`, authorName: "Someone" };
}

export function toMessage(entity: EntityLike): ChatMessage {
/**
* The fingerprints of this vault's own agent members — the set a proposal card
* must be authored by before it renders as an approvable card.
*
* The app-side mirror of main's `readTrustedAgentDef` notion, minus the crypto
* half (an app has no key primitives; main re-checks the pubkey↔fingerprint
* binding on every approve, and refuses otherwise). What this CAN check is the
* half that decides honesty: the `Agent/v1` record must be SHELL-authored, so
* an app-planted or synced-in record can never vouch for a card.
*/
export function trustedAgentAuthors(entities: readonly EntityLike[]): ReadonlySet<string> {
const out = new Set<string>();
for (const e of entities) {
if (e.type !== AGENT_TYPE || e.ownerAppId !== SHELL_PRINCIPAL) continue;
const fingerprint = str(e.properties.fingerprint);
if (fingerprint) out.add(fingerprint);
}
return out;
}

/** Per-call context for {@link toMessage}. An options BAG rather than a
* positional set on purpose: `entities.map(toMessage)` hands the callback an
* index, and a bag degrades that to the fail-closed default instead of
* throwing (or, worse, treating an index as a trust set). */
export type ToMessageContext = {
/** {@link trustedAgentAuthors} — the vault's own agent members. */
trustedAgents?: ReadonlySet<string>;
};

/**
* Project a message row. A proposal is attached ONLY when the row's
* host-written author is one of `context.trustedAgents`.
*
* FAIL-CLOSED BY DEFAULT (Agent-Teams-3 closure b): omit the set and no card is
* ever produced. A card is an approve button over a privileged main-side write,
* and a card that arrived from ANOTHER vault can only ever fail there (inbound
* rows never carry a local agent's fingerprint as `created_by`) — rendering the
* button anyway is both a UX lie and a phishing surface. The parse itself stays
* fail-closed too: a malformed card renders as an ordinary message, whose plain
* body already reads honestly on its own.
*/
export function toMessage(entity: EntityLike, context?: ToMessageContext): ChatMessage {
const author = readAuthor(entity.properties, entity.id);
const richBody = str(entity.properties.richBody);
const proposal = readChannelProposal(entity.properties);
const trusted = context?.trustedAgents;
const proposal =
trusted?.has(entity.ownerAppId ?? "") === true ? readChannelProposal(entity.properties) : null;
return {
id: entity.id,
channelId: str(entity.properties.conversation),
Expand All @@ -161,12 +214,15 @@ export function sortMessages(messages: readonly ChatMessage[]): ChatMessage[] {
);
}

/** The ordered messages of one channel from the live snapshot. */
/** The ordered messages of one channel from the live snapshot. Resolves the
* trusted-agent author set from the same snapshot, so a proposal card only
* renders for a local agent's own message (see {@link toMessage}). */
export function channelMessages(entities: readonly EntityLike[], channelId: string): ChatMessage[] {
if (!channelId) return [];
const trustedAgents = trustedAgentAuthors(entities);
const rows = entities
.filter((e) => e.type === MESSAGE_TYPE && str(e.properties.conversation) === channelId)
.map(toMessage);
.map((e) => toMessage(e, { trustedAgents }));
return sortMessages(rows);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/chat/src/widget-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export function shapeRecentMessages(

const messages = entities
.filter((e) => e.type === MESSAGE_TYPE && e.deletedAt === null)
.map(toMessage)
// No trusted-agent context: the widget shows message snippets, never an
// approvable card, so it takes `toMessage`'s fail-closed default.
.map((e) => toMessage(e))
.filter((m) => channelNames.has(m.channelId));

const rows = sortMessages(messages)
Expand Down
Loading
Loading