diff --git a/assistant/src/__tests__/channel-guardian.test.ts b/assistant/src/__tests__/channel-guardian.test.ts index ad0dffea13a..c834b09f7a2 100644 --- a/assistant/src/__tests__/channel-guardian.test.ts +++ b/assistant/src/__tests__/channel-guardian.test.ts @@ -390,26 +390,25 @@ describe('guardian service challenge validation', () => { expect(result.challengeId).toBeDefined(); expect(result.secret).toBeDefined(); expect(result.secret.length).toBe(64); // 32 bytes hex-encoded - expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`); + expect(result.verifyCommand).toBe(result.secret); expect(result.ttlSeconds).toBe(600); expect(result.instruction).toBeDefined(); expect(result.instruction.length).toBeGreaterThan(0); - expect(result.instruction).toContain('/guardian_verify'); + expect(result.instruction).toContain('code you were given'); }); test('createVerificationChallenge produces a non-empty instruction for telegram channel', () => { const result = createVerificationChallenge('asst-1', 'telegram'); expect(result.instruction).toBeDefined(); expect(result.instruction.length).toBeGreaterThan(0); - expect(result.instruction).toContain(result.verifyCommand); + expect(result.instruction).toContain('code you were given'); }); test('createVerificationChallenge produces a non-empty instruction for sms channel', () => { const result = createVerificationChallenge('asst-1', 'sms'); expect(result.instruction).toBeDefined(); expect(result.instruction.length).toBeGreaterThan(0); - expect(result.instruction).toContain('/guardian_verify'); - expect(result.instruction).toContain(result.verifyCommand); + expect(result.instruction).toContain('code you were given'); }); test('validateAndConsumeChallenge succeeds with correct secret', () => { @@ -1567,7 +1566,7 @@ describe('voice guardian challenge generation', () => { test('voice challenge verifyCommand contains the six-digit secret', () => { const result = createVerificationChallenge('asst-1', 'voice'); - expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`); + expect(result.verifyCommand).toBe(result.secret); }); test('voice challenge instruction contains voice-specific copy', () => { @@ -1575,7 +1574,6 @@ describe('voice guardian challenge generation', () => { expect(result.instruction).toContain('six-digit code'); expect(result.instruction).toContain(result.secret); - expect(result.instruction).toContain('minutes'); }); test('voice challenge secrets are different across calls', () => { @@ -2774,15 +2772,13 @@ describe('outbound SMS verification', () => { ); // Code should NOT appear in the message — user sees it in the app expect(challengeSms).not.toContain('123456'); - expect(challengeSms).toContain('10 minutes'); - expect(challengeSms).toContain('Vellum assistant app'); + expect(challengeSms).toContain('code you were given'); const resendSms = composeVerificationSms( GUARDIAN_VERIFY_TEMPLATE_KEYS.RESEND, { code: 'ABCDEF', expiresInMinutes: 5 }, ); expect(resendSms).not.toContain('ABCDEF'); - expect(resendSms).toContain('5 minutes'); expect(resendSms).toContain('resent'); const alreadySms = composeVerificationSms( @@ -2986,7 +2982,7 @@ describe('outbound Telegram verification', () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(telegramDeliverCalls.length).toBe(1); expect(telegramDeliverCalls[0].chatId).toBe('123456789'); - expect(telegramDeliverCalls[0].text).toContain('/guardian_verify'); + expect(telegramDeliverCalls[0].text).toContain('code you were given'); }); test('start_outbound for telegram without bot username fails', () => { @@ -3275,15 +3271,14 @@ describe('outbound Telegram verification', () => { expect(revoked).toBeNull(); }); - test('telegram template includes /guardian_verify instruction without code', () => { + test('telegram template includes verification instruction without code', () => { const msg = composeVerificationTelegram( GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST, { code: 'abc123', expiresInMinutes: 10 }, ); - // Should mention /guardian_verify but NOT include the actual code - expect(msg).toContain('/guardian_verify'); + // Should ask user to reply with code but NOT include the actual code + expect(msg).toContain('code you were given'); expect(msg).not.toContain('abc123'); - expect(msg).toContain('Vellum assistant app'); }); test('telegram resend template includes (resent) suffix', () => { @@ -3291,7 +3286,7 @@ describe('outbound Telegram verification', () => { GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_RESEND, { code: 'xyz789', expiresInMinutes: 5 }, ); - expect(msg).toContain('/guardian_verify'); + expect(msg).toContain('code you were given'); expect(msg).not.toContain('xyz789'); expect(msg).toContain('(resent)'); }); diff --git a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md index 280cd01d3ec..7d8a30ebcf4 100644 --- a/assistant/src/config/vellum-skills/telegram-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/telegram-setup/SKILL.md @@ -46,15 +46,14 @@ If the webhook secret changes (e.g., secret rotation), the gateway's credential ### Step 4: Register Bot Commands -Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` and `/guardian_verify` commands: +Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` command: ```json { "type": "telegram_config", "action": "set_commands", "commands": [ - { "command": "new", "description": "Start a new conversation" }, - { "command": "guardian_verify", "description": "Verify your guardian identity" } + { "command": "new", "description": "Start a new conversation" } ] } ``` @@ -74,9 +73,9 @@ Now link the user's Telegram account as the trusted guardian for this bot. Tell } ``` -2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify ` to your bot from your Telegram account within 10 minutes." +2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the `secret` code to the user. Tell them: "You'll receive a message from your Telegram bot asking for a verification code. Reply to that message with the code shown here." -3. Wait for the user to confirm they have sent the command. The verification happens automatically when the bot receives the `/guardian_verify` message — the channel inbound handler validates the token and creates the guardian binding. +3. Wait for the user to confirm they have replied with the code. The verification happens automatically when the bot receives the code — the channel inbound handler validates it and creates the guardian binding. 4. If the user confirms success: "Guardian verified! Your Telegram account is now the trusted guardian for this bot." @@ -109,7 +108,7 @@ Summarize what was done: - Bot identity: @{botUsername} - Bot verified and credentials stored securely via daemon - Webhook registration: handled automatically by the gateway -- Bot commands registered: /new, /guardian_verify +- Bot commands registered: /new - Guardian identity: {verified | not configured} - Guardian verification status: {verified via challenge | skipped} - Routing configuration validated @@ -144,5 +143,5 @@ The following steps still require **manual** action: |------|---------| | Bot token from @BotFather | User must create a bot and provide the token via secure prompt | | Bot command registration | Registered via the setup skill (Step 4 above) | -| Guardian verification | User sends `/guardian_verify ` to the bot (Step 5 above) | +| Guardian verification | User replies with the verification code in the bot's Telegram chat (Step 5 above) | | Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration | diff --git a/assistant/src/config/vellum-skills/twilio-setup/SKILL.md b/assistant/src/config/vellum-skills/twilio-setup/SKILL.md index f1b62ff8ede..daf805f5362 100644 --- a/assistant/src/config/vellum-skills/twilio-setup/SKILL.md +++ b/assistant/src/config/vellum-skills/twilio-setup/SKILL.md @@ -214,9 +214,9 @@ Now link the user's phone number as the trusted SMS guardian for this assistant. } ``` -2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify ` to your bot via SMS within 10 minutes." +2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the `secret` code to the user. Tell them: "You'll receive an SMS asking for a verification code. Reply to that SMS with the code shown here." -3. Wait for the user to confirm they have sent the verification code via SMS to the assistant's phone number. +3. Wait for the user to confirm they have replied with the verification code via SMS to the assistant's phone number. 4. Check verification status by sending `guardian_verification` with `action: "status"` and `channel: "sms"`: diff --git a/assistant/src/daemon/handlers/config-telegram.ts b/assistant/src/daemon/handlers/config-telegram.ts index 01c3e8e3c94..8b91446585e 100644 --- a/assistant/src/daemon/handlers/config-telegram.ts +++ b/assistant/src/daemon/handlers/config-telegram.ts @@ -223,7 +223,6 @@ export async function setTelegramCommands( const resolvedCommands = commands ?? [ { command: 'new', description: 'Start a new conversation' }, { command: 'help', description: 'Show available commands' }, - { command: 'guardian_verify', description: 'Verify your guardian identity' }, ]; try { diff --git a/assistant/src/runtime/approval-message-composer.ts b/assistant/src/runtime/approval-message-composer.ts index 7eb69ff2246..47c2c3b4d78 100644 --- a/assistant/src/runtime/approval-message-composer.ts +++ b/assistant/src/runtime/approval-message-composer.ts @@ -219,10 +219,10 @@ export function getFallbackMessage(context: ApprovalMessageContext): string { case 'guardian_verify_challenge_setup': if (context.channel === 'voice') { // Voice challenges use a six-digit numeric code that can be spoken aloud - const code = context.verifyCommand?.replace('/guardian_verify ', '') ?? 'the verification code'; - return `To complete guardian verification, speak or enter the six-digit code: ${code}. This code expires in ${Math.round((context.ttlSeconds ?? 600) / 60)} minutes.`; + const code = context.verifyCommand ?? 'the verification code'; + return `To complete guardian verification, speak or enter the six-digit code: ${code}.`; } - return `To complete guardian verification, send ${context.verifyCommand ?? 'the verification command'} within ${context.ttlSeconds ?? 60} seconds.`; + return `To complete guardian verification, reply in the channel with the code you were given.`; case 'guardian_verify_status_bound': return 'A guardian is currently active for this channel.'; diff --git a/assistant/src/runtime/channel-guardian-service.ts b/assistant/src/runtime/channel-guardian-service.ts index e065a539074..3616202b014 100644 --- a/assistant/src/runtime/channel-guardian-service.ts +++ b/assistant/src/runtime/channel-guardian-service.ts @@ -119,19 +119,17 @@ export function createVerificationChallenge( createdBySessionId: sessionId, }); - const verifyCommand = `/guardian_verify ${secret}`; const ttlSeconds = CHALLENGE_TTL_MS / 1000; return { challengeId, secret, - verifyCommand, + verifyCommand: secret, ttlSeconds, instruction: composeApprovalMessage({ scenario: 'guardian_verify_challenge_setup', channel, - verifyCommand, - ttlSeconds, + verifyCommand: secret, }), }; } diff --git a/assistant/src/runtime/guardian-verification-templates.ts b/assistant/src/runtime/guardian-verification-templates.ts index 9b60f91b938..1834385558c 100644 --- a/assistant/src/runtime/guardian-verification-templates.ts +++ b/assistant/src/runtime/guardian-verification-templates.ts @@ -81,12 +81,12 @@ export interface ChannelVerifyReplyVars { const templates: Record string> = { [GUARDIAN_VERIFY_TEMPLATE_KEYS.CHALLENGE_REQUEST]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Guardian verification requested. Reply with the code shown in your Vellum assistant app to verify. It expires in ${vars.expiresInMinutes} minutes.`; + return `${prefix}Guardian verification requested. Reply with the code you were given.`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.RESEND]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Guardian verification requested. Reply with the code shown in your Vellum assistant app to verify. It expires in ${vars.expiresInMinutes} minutes. (resent)`; + return `${prefix}Guardian verification requested. Reply with the code you were given. (resent)`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.ALREADY_VERIFIED]: (_vars) => { @@ -96,12 +96,12 @@ const templates: Record { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Guardian verification requested. Reply with /guardian_verify followed by the code shown in your Vellum assistant app. It expires in ${vars.expiresInMinutes} minutes.`; + return `${prefix}Guardian verification requested. Reply with the code you were given.`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_RESEND]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Guardian verification requested. Reply with /guardian_verify followed by the code shown in your Vellum assistant app. It expires in ${vars.expiresInMinutes} minutes. (resent)`; + return `${prefix}Guardian verification requested. Reply with the code you were given. (resent)`; }, }; diff --git a/assistant/src/runtime/routes/channel-route-shared.ts b/assistant/src/runtime/routes/channel-route-shared.ts index 23fc83d41ae..61969beccfe 100644 --- a/assistant/src/runtime/routes/channel-route-shared.ts +++ b/assistant/src/runtime/routes/channel-route-shared.ts @@ -111,7 +111,7 @@ export function buildGuardianDenyContext( return `Permission denied for "${toolName}": guardian approval was required, but requester identity could not be verified for this channel. In your next assistant reply, explain this clearly, avoid retrying yet, and ask the user to message from a verifiable direct account/chat before retrying.`; } - return `Permission denied for "${toolName}": guardian approval was required, but no guardian is configured for this channel. In your next assistant reply, explain this and offer guardian setup. Mention that setup provides a verification token to send as /guardian_verify .`; + return `Permission denied for "${toolName}": guardian approval was required, but no guardian is configured for this channel. In your next assistant reply, explain this and offer guardian setup. Mention that setup provides a verification code that the user replies with in the channel.`; } export function buildPromptDeliveryFailureContext(toolName: string): string { diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index 5d0317464bb..77e29a7e85c 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -67,14 +67,21 @@ import { handleApprovalInterception } from './guardian-approval-interception.js' const log = getLogger('runtime-http'); /** - * Parse a `/guardian_verify` command from message content. - * Supports `/guardian_verify `, `/guardian_verify@BotName `, - * and normalized whitespace. - * Returns the verification code if the message is a verify command, or undefined otherwise. + * Parse a guardian verification code from message content. + * Accepts three formats: + * 1. `/guardian_verify ` (legacy command format) + * 2. `/guardian_verify@BotName ` (Telegram group format) + * 3. A bare code (hex string or 6-digit numeric) as the entire message + * Returns the verification code if recognized, or undefined otherwise. */ function parseGuardianVerifyCommand(content: string): string | undefined { - const match = content.match(/^\/guardian_verify(?:@\S+)?\s+(\S+)/); - return match?.[1]; + // Legacy /guardian_verify command format + const commandMatch = content.match(/^\/guardian_verify(?:@\S+)?\s+(\S+)/); + if (commandMatch) return commandMatch[1]; + + // Bare code: 64-char hex (standard channels) or 6-digit numeric (voice) + const bareMatch = content.match(/^([0-9a-fA-F]{64}|\d{6})$/); + return bareMatch?.[1]; } export async function handleChannelInbound( diff --git a/gateway/ARCHITECTURE.md b/gateway/ARCHITECTURE.md index d5906467f51..a29efb9f97b 100644 --- a/gateway/ARCHITECTURE.md +++ b/gateway/ARCHITECTURE.md @@ -172,14 +172,12 @@ The guardian system adds a cryptographic trust layer for channel-based interacti All channel ingress paths canonicalize the `assistantId` via `normalizeAssistantId()` (from `util/platform.ts`) before any DB operations. The system uses `"self"` as the canonical single-tenant identifier, but callers may pass the real assistant name (e.g., `"vellum-true-eel"`) or `"self"` depending on context. `normalizeAssistantId()` maps any known lockfile assistant ID to `"self"`, ensuring consistent DB key usage regardless of how the caller identifies the assistant. This canonicalization runs at every ingress boundary: the guardian IPC handler (`config-channels.ts`), the guardian context resolver, the relay server, and the inbound message handler. -#### Guardian Verify Command Parsing +#### Guardian Verify Code Parsing -The `/guardian_verify` command supports two formats to accommodate Telegram's bot command convention: +The inbound message handler (`inbound-message-handler.ts`) accepts verification codes in two formats: -- `/guardian_verify ` -- standard format -- `/guardian_verify@BotName ` -- Telegram auto-appends the bot's username to commands in group chats - -The inbound message handler (`inbound-message-handler.ts`) normalizes both formats: it strips the `@BotName` suffix and collapses whitespace before extracting the verification token. This means users can verify from group chats without needing to manually edit the command. +- **Bare code**: A 64-character hex string (SMS/Telegram) or 6-digit numeric code (voice) sent as the entire message body. This is the primary flow — the user receives a channel message asking them to reply with the code they were given, and simply replies with the code. +- **Legacy command**: `/guardian_verify ` (or `/guardian_verify@BotName ` for Telegram group chats). This format is still accepted for backward compatibility but is no longer the recommended flow. #### Explicit Rebind Policy @@ -204,8 +202,8 @@ sequenceDiagram Desktop->>Daemon: guardian_verify IPC (action: create_challenge) Daemon->>Daemon: Generate random secret, hash (SHA-256), store challenge (10min TTL) Daemon-->>Desktop: Return secret + instruction - Desktop-->>User: Display: "Send /guardian_verify to the bot" - User->>TG: /guardian_verify + Desktop-->>User: Display verification code + User->>TG: TG->>GW: POST /webhooks/telegram (webhook secret validated) GW->>GW: Verify webhook secret, normalize update GW->>Daemon: POST /v1/channels/inbound (X-Gateway-Origin proof) @@ -218,7 +216,7 @@ sequenceDiagram GW->>TG: sendMessage: "You are now the guardian" ``` -The raw secret is shown only once in the desktop UI. Only the SHA-256 hash is persisted. Challenges expire after 10 minutes. Consumed challenges cannot be reused. Rate limiting (5 invalid attempts per 15-minute window, 30-minute lockout) protects against brute-force attacks. +The raw secret is shown only once in the desktop UI and delivered to the channel in an outbound message prompting the user to reply with it. Only the SHA-256 hash is persisted. Challenges expire after 10 minutes. Consumed challenges cannot be reused. Rate limiting (5 invalid attempts per 15-minute window, 30-minute lockout) protects against brute-force attacks. #### Inbound Message Decision Chain @@ -248,13 +246,13 @@ flowchart TD HAS_BINDING -- No --> DENY_ESCALATE["Deny: escalate_no_guardian"] HAS_BINDING -- Yes --> CREATE_APPROVAL["Create approval request
+ notify guardian (dual-surface)"] - ESCALATE_CHECK -- No --> VERIFY_CHECK{"Starts with
/guardian_verify?"} + ESCALATE_CHECK -- No --> VERIFY_CHECK{"Guardian verify
code or command?"} VERIFY_CHECK -- Yes --> VERIFY["Validate challenge
→ create guardian binding"] VERIFY_CHECK -- No --> ROLE_RESOLVE["Resolve actor role
(guardian-context-resolver)"] ROLE_RESOLVE --> APPROVAL_INTERCEPT["Approval interception
+ message processing"] ``` -This ordering ensures that ingress ACL decisions are finalized before any agent processing occurs. The `/guardian_verify` command is intercepted after ACL enforcement but before the agent loop, so it never triggers inference. +This ordering ensures that ingress ACL decisions are finalized before any agent processing occurs. Guardian verification codes (bare codes or the legacy `/guardian_verify` command) are intercepted after ACL enforcement but before the agent loop, so they never trigger inference. #### Actor Role Resolution diff --git a/skills/telegram-setup/SKILL.md b/skills/telegram-setup/SKILL.md index 29ecccb45fa..4235b05ed39 100644 --- a/skills/telegram-setup/SKILL.md +++ b/skills/telegram-setup/SKILL.md @@ -47,13 +47,11 @@ This endpoint automatically: - Stores the bot token with bot username metadata - Generates a webhook secret if one does not already exist - Triggers an immediate gateway webhook reconcile -- Registers bot commands (`/new`, `/guardian_verify`) +- Registers bot commands (`/new`) If the request fails, check the response body for an error message. If the token is invalid, tell the user and ask them to re-enter the token via the secure prompt (repeat Step 1). -On success, check the `commandsRegistered` field in the response. Confirm to the user which commands were registered (e.g., "Registered bot commands: /new, /guardian_verify"). - -> **Note:** Telegram's command menu may take a few minutes to propagate to all clients. Users can type commands manually (e.g., `/guardian_verify `) even if the command menu hasn't updated yet. +On success, check the `commandsRegistered` field in the response. Confirm to the user which commands were registered (e.g., "Registered bot commands: /new"). ### Step 3: Webhook Registration (Automatic) @@ -74,9 +72,9 @@ curl -sf -X POST http://localhost:7821/v1/integrations/guardian/challenge \ -d '{"channel":"telegram"}' ``` -2. The response includes `secret` and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify ` to your bot from your Telegram account within 10 minutes." +2. The response includes `secret` and `instruction`. Display the `secret` code to the user. Tell them: "You'll receive a message from your Telegram bot asking for a verification code. Reply to that message with the code shown here." -3. Wait for the user to confirm they have sent the command. The verification happens automatically when the bot receives the `/guardian_verify` message — the channel inbound handler validates the token and creates the guardian binding. +3. Wait for the user to confirm they have replied with the code. The verification happens automatically when the bot receives the code — the channel inbound handler validates it and creates the guardian binding. 4. If the user confirms success: "Guardian verified! Your Telegram account is now the trusted guardian for this bot." @@ -113,7 +111,7 @@ If the binding is absent and the user said they completed the verification: Summarize what was done: - Bot verified and credentials stored securely - Webhook registration: handled automatically by the gateway -- Bot commands registered (list the commands from `commandsRegistered`) +- Bot commands registered: /new - Guardian identity verified (if completed and binding confirmed) - Routing configuration validated @@ -146,5 +144,5 @@ The following steps still require **manual** action: |------|---------| | Bot token from @BotFather | User must create a bot and provide the token via secure prompt | | Bot configuration and command registration | Configured via the setup skill (Step 2 above) using the `/v1/integrations/telegram/setup` endpoint | -| Guardian verification | User sends `/guardian_verify ` to the bot (Step 4 above) | +| Guardian verification | User replies with the verification code in the bot's Telegram chat (Step 4 above) | | Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration |