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
27 changes: 11 additions & 16 deletions assistant/src/__tests__/channel-guardian.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -1567,15 +1566,14 @@ 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', () => {
const result = createVerificationChallenge('asst-1', 'voice');

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', () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -3275,23 +3271,22 @@ 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', () => {
const msg = composeVerificationTelegram(
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)');
});
Expand Down
13 changes: 6 additions & 7 deletions assistant/src/config/vellum-skills/telegram-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
```
Expand All @@ -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 <secret>` 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."

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <secret>` 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 |
4 changes: 2 additions & 2 deletions assistant/src/config/vellum-skills/twilio-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <secret>` 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"`:

Expand Down
1 change: 0 additions & 1 deletion assistant/src/daemon/handlers/config-telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions assistant/src/runtime/approval-message-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Comment thread
noanflaherty marked this conversation as resolved.

case 'guardian_verify_status_bound':
return 'A guardian is currently active for this channel.';
Expand Down
6 changes: 2 additions & 4 deletions assistant/src/runtime/channel-guardian-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
};
}
Expand Down
8 changes: 4 additions & 4 deletions assistant/src/runtime/guardian-verification-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ export interface ChannelVerifyReplyVars {
const templates: Record<TextVerifyTemplateKey, (vars: GuardianVerifyTemplateVars) => 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) => {
Expand All @@ -96,12 +96,12 @@ const templates: Record<TextVerifyTemplateKey, (vars: GuardianVerifyTemplateVars

[GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST]: (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.`;
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)`;
},
};

Expand Down
2 changes: 1 addition & 1 deletion assistant/src/runtime/routes/channel-route-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>.`;
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 {
Expand Down
19 changes: 13 additions & 6 deletions assistant/src/runtime/routes/inbound-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>`, `/guardian_verify@BotName <code>`,
* 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 <code>` (legacy command format)
* 2. `/guardian_verify@BotName <code>` (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];
Comment thread
noanflaherty marked this conversation as resolved.
Comment thread
noanflaherty marked this conversation as resolved.
}

export async function handleChannelInbound(
Expand Down
20 changes: 9 additions & 11 deletions gateway/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>` -- standard format
- `/guardian_verify@BotName <code>` -- 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 <code>` (or `/guardian_verify@BotName <code>` for Telegram group chats). This format is still accepted for backward compatibility but is no longer the recommended flow.

#### Explicit Rebind Policy

Expand All @@ -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 <secret> to the bot"
User->>TG: /guardian_verify <secret>
Desktop-->>User: Display verification code
User->>TG: <replies with code>
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)
Expand All @@ -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

Expand Down Expand Up @@ -248,13 +246,13 @@ flowchart TD
HAS_BINDING -- No --> DENY_ESCALATE["Deny: escalate_no_guardian"]
HAS_BINDING -- Yes --> CREATE_APPROVAL["Create approval request<br/>+ notify guardian (dual-surface)"]

ESCALATE_CHECK -- No --> VERIFY_CHECK{"Starts with<br/>/guardian_verify?"}
ESCALATE_CHECK -- No --> VERIFY_CHECK{"Guardian verify<br/>code or command?"}
VERIFY_CHECK -- Yes --> VERIFY["Validate challenge<br/>→ create guardian binding"]
VERIFY_CHECK -- No --> ROLE_RESOLVE["Resolve actor role<br/>(guardian-context-resolver)"]
ROLE_RESOLVE --> APPROVAL_INTERCEPT["Approval interception<br/>+ 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

Expand Down
Loading
Loading