From 03432cc216b814f2bd0a8863dfddcd87eb8183ec Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 18:40:26 -0700 Subject: [PATCH] feat(assistant): Keiki Face ID approval for write tools, 0.email link as fallback - write tools set approval: ask, approvalProof: verified, approvalFallback: tool - a call Keiki signed as approved on a verified surface (keiki_vault, slack, dashboard) runs directly; otherwise the 0.email review link is returned as before - /actions/:id and the approved webhook share decideAction --- apps/server/src/lib/assistant.test.ts | 7 ++ apps/server/src/lib/assistant.ts | 13 +++- apps/server/src/routes/assistant.ts | 104 +++++++++++++++----------- 3 files changed, 78 insertions(+), 46 deletions(-) diff --git a/apps/server/src/lib/assistant.test.ts b/apps/server/src/lib/assistant.test.ts index 10442c33c..e59444c4a 100644 --- a/apps/server/src/lib/assistant.test.ts +++ b/apps/server/src/lib/assistant.test.ts @@ -1105,6 +1105,13 @@ describe('one Keiki agent with account-scoped conversation principals', () => { expect(new URL(tool.webhookUrl).origin).toBe('https://callback.zero.test'); expect(new URL(tool.webhookUrl).pathname).toMatch(/^\/api\/assistant\/hooks\/tools\//); expect(tool.policy?.requiredScopes?.length).toBe(1); + const writes = tool.policy?.requiredScopes?.[0] === 'mail:request_action'; + expect(tool.policy).toMatchObject( + writes + ? { approval: 'ask', approvalProof: 'verified', approvalFallback: 'tool' } + : { requiredScopes: ['mail:read'] }, + ); + if (!writes) expect(tool.policy).not.toHaveProperty('approval'); } } finally { settings.KEIKI_CALLBACK_URL = undefined; diff --git a/apps/server/src/lib/assistant.ts b/apps/server/src/lib/assistant.ts index fa2190739..584b82571 100644 --- a/apps/server/src/lib/assistant.ts +++ b/apps/server/src/lib/assistant.ts @@ -138,7 +138,7 @@ export async function assistantTools(): Promise { : name === Tools.SendEmail ? 'Request owner approval to send a NEW email with these exact recipients, subject and body. For an existing saved draft use get_draft then send_draft instead. Use when the user asked to send; do not stop at compose_email or merely offer a send link.' : (tool.description ?? name) - }${writeTools.has(name) ? ' Returns approval instructions and a confirmation URL; nothing changes until the authenticated owner approves. Follow the returned instructions: only Keiki presentationDelivery.state = accepted confirms a review card, otherwise show the exact returned URL.' : ''}`, + }${writeTools.has(name) ? " Keiki first asks the owner to approve this exact call on a Face ID approval card when it can show one; follow Keiki's approval instructions then, and a call that runs after that approval returns its result. Otherwise it returns approval instructions and a confirmation URL; nothing changes until the authenticated owner approves. Follow the returned instructions: only Keiki presentationDelivery.state = accepted confirms a review card, otherwise show the exact returned URL." : ''}`, inputSchema: zodToJsonSchema( z.object({ connectionId: z @@ -154,7 +154,14 @@ export async function assistantTools(): Promise { ), executionType: 'webhook', webhookUrl: assistantCallbackUrl(`/tools/${cloudName(name)}`), - policy: { requiredScopes: [writeTools.has(name) ? 'mail:request_action' : 'mail:read'] }, + policy: writeTools.has(name) + ? { + requiredScopes: ['mail:request_action'], + approval: 'ask', + approvalProof: 'verified', + approvalFallback: 'tool', + } + : { requiredScopes: ['mail:read'] }, }), ), ]; @@ -206,7 +213,7 @@ For forwards, resolve the exact source message with get_thread, then call create If sending was explicitly requested, call send_email with the final content for a new email, or get_draft then send_draft with its current version for a saved draft. Do not merely offer a send link. Saving a draft is not send approval; a draft-and-send request needs a separate send approval after the save completes. Never infer owner approval from a chat yes. -Mutating tools return approval instructions and a Zero confirmation URL. Follow those instructions: only when Keiki adds presentationDelivery.state = accepted, direct the owner to the review card sent to this conversation, with the exact URL as browser fallback. Otherwise show that exact URL; never claim a card was delivered. A presentation field only requests delivery; do not call imessage_send_app again to deliver it. Nothing has been sent or changed until the authenticated owner reviews and approves. Opening a card or saying yes in chat is not approval. You cannot approve actions yourself or invent URLs. Never claim a save/send succeeded without its successful tool result. Use get_action_status to check a previous request, not another mutation. After an uncertain failure, inspect status/list_drafts instead of retrying the mutation automatically. +Mutating tools first need the owner's approval on Keiki's Face ID approval card when Keiki can show one: follow Keiki's approval instructions, and only the card approves; a reply in words can only decline. When Keiki cannot show that card, mutating tools return approval instructions and a Zero confirmation URL. Follow those instructions: only when Keiki adds presentationDelivery.state = accepted, direct the owner to the review card sent to this conversation, with the exact URL as browser fallback. Otherwise show that exact URL; never claim a card was delivered. A presentation field only requests delivery; do not call imessage_send_app again to deliver it. Nothing has been sent or changed until the authenticated owner reviews and approves. Opening a card or saying yes in chat is not approval; only the Face ID card or Zero's review page approves. You cannot approve actions yourself or invent URLs. Never claim a save/send succeeded without its successful tool result. Use get_action_status to check a previous request, not another mutation. After an uncertain failure, inspect status/list_drafts instead of retrying the mutation automatically. In messaging channels use concise plain text and Zero links, not HTML tags. Do not access other accounts or send unsolicited cross-channel messages.`, }, diff --git a/apps/server/src/routes/assistant.ts b/apps/server/src/routes/assistant.ts index 8df546e44..aeb107e1a 100644 --- a/apps/server/src/routes/assistant.ts +++ b/apps/server/src/routes/assistant.ts @@ -25,8 +25,8 @@ import { verifyWebhook, type IdentityWebhookAnswer } from '@onkeiki/agents/cloud import { assistantAppPresentation } from '../lib/assistant-approval'; import { and, eq, gt, isNull, sql, count } from 'drizzle-orm'; import { isAiEnabledForUser } from '../lib/ai-access'; +import type { Context, ErrorHandler } from 'hono'; import type { HonoContext } from '../ctx'; -import type { ErrorHandler } from 'hono'; import { env } from '../env'; import { Hono } from 'hono'; import { z } from 'zod'; @@ -223,6 +223,55 @@ async function identifyPhotonSender( return identityReply(`Connect your Zero account to use your assistant: ${linkUrl(token)}`); } +/** Keiki surfaces that tie a decision to a verified person (signed into the webhook body). A chat reply is not one. */ +const verifiedApprovalSurfaces = new Set(['keiki_vault', 'slack', 'dashboard']); + +/** Claims a pending action once and runs it when approved. */ +async function decideAction(c: Context, userId: string, id: string, approve: boolean) { + // Atomically claim once. A timeout after sending remains indeterminate and + // is never retried automatically: duplicate mail is worse than an error. + const action = await withAssistantDb( + async (db) => + ( + await db + .update(assistantAction) + .set({ status: approve ? 'running' : 'declined' }) + .where( + and( + eq(assistantAction.id, id), + eq(assistantAction.userId, userId), + eq(assistantAction.status, 'pending'), + gt(assistantAction.expiresAt, new Date()), + ), + ) + .returning() + )[0], + ); + if (!action) return c.json({ error: 'Action expired or already handled' }, 409); + if (!approve) return c.json({ status: 'declined' }); + try { + const result = await executeMailTool(userId, action.connectionId, action.tool, action.input); + await withAssistantDb((db) => + db + .update(assistantAction) + .set({ status: 'completed', result }) + .where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))), + ); + return c.json({ status: 'completed', result }); + } catch (error) { + await withAssistantDb((db) => + db + .update(assistantAction) + .set({ status: 'failed' }) + .where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))), + ); + if (error instanceof AssistantToolError) + return c.json({ error: error.message, code: error.code }, error.status); + console.error('[assistant action]', error); + return c.json({ error: 'Action failed. Check your mailbox before trying again.' }, 500); + } +} + /** Signed callbacks are mounted outside the session/JWT middleware. */ export const assistantHooks = new Hono() .use('*', async (c, next) => { @@ -312,6 +361,13 @@ export const assistantHooks = new Hono() principal: z .object({ userId: z.string().nullable(), scopes: z.array(z.string()) }) .optional(), + approval: z + .object({ + actionId: z.string(), + decidedVia: z.string().nullable(), + decidedBy: z.string().nullable(), + }) + .optional(), }), }) .safeParse(await c.req.json().catch(() => null)); @@ -438,6 +494,9 @@ export const assistantHooks = new Hono() ); if (!action) throw new Error('Pending action could not be loaded'); const awaitingApproval = action.status === 'pending' && action.expiresAt > new Date(); + const approvedVia = parsed.data.context.approval?.decidedVia; + if (awaitingApproval && approvedVia && verifiedApprovalSurfaces.has(approvedVia)) + return decideAction(c, userId, id, true); const url = actionUrl(id); const presentation = awaitingApproval ? assistantAppPresentation(parsed.data.context, id, url) @@ -607,47 +666,6 @@ export const assistantApi = new Hono() const userId = c.var.sessionUser!.id; if (!(await isAiEnabledForUser(userId))) return c.json({ error: 'AI is disabled' }, 403); const { approve } = z.object({ approve: z.boolean() }).parse(await c.req.json()); - // Atomically claim once. A timeout after sending remains indeterminate and - // is never retried automatically: duplicate mail is worse than an error. - const action = await withAssistantDb( - async (db) => - ( - await db - .update(assistantAction) - .set({ status: approve ? 'running' : 'declined' }) - .where( - and( - eq(assistantAction.id, c.req.param('id')), - eq(assistantAction.userId, userId), - eq(assistantAction.status, 'pending'), - gt(assistantAction.expiresAt, new Date()), - ), - ) - .returning() - )[0], - ); - if (!action) return c.json({ error: 'Action expired or already handled' }, 409); - if (!approve) return c.json({ status: 'declined' }); - try { - const result = await executeMailTool(userId, action.connectionId, action.tool, action.input); - await withAssistantDb((db) => - db - .update(assistantAction) - .set({ status: 'completed', result }) - .where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))), - ); - return c.json({ status: 'completed', result }); - } catch (error) { - await withAssistantDb((db) => - db - .update(assistantAction) - .set({ status: 'failed' }) - .where(and(eq(assistantAction.id, action.id), eq(assistantAction.userId, userId))), - ); - if (error instanceof AssistantToolError) - return c.json({ error: error.message, code: error.code }, error.status); - console.error('[assistant action]', error); - return c.json({ error: 'Action failed. Check your mailbox before trying again.' }, 500); - } + return decideAction(c, userId, c.req.param('id'), approve); }) .onError(handleAssistantError);