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
16 changes: 6 additions & 10 deletions assistant/src/__tests__/approval-message-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,12 @@ describe('approval-message-composer', () => {
expect(msg).toContain('Code did not match.');
});

test('guardian_verify_challenge_setup includes verifyCommand and ttlSeconds', () => {
test('guardian_verify_challenge_setup includes verifyCommand in N-digit code format', () => {
const msg = getFallbackMessage({
scenario: 'guardian_verify_challenge_setup',
verifyCommand: '/verify abc123',
ttlSeconds: 30,
verifyCommand: '123456',
});
expect(msg).toContain('/verify abc123');
expect(msg).toContain('30');
expect(msg).toContain('6-digit code: 123456');
});
});

Expand Down Expand Up @@ -195,16 +193,14 @@ describe('approval-message-composer', () => {
// -----------------------------------------------------------------------

describe('verification scenario resilience', () => {
test('guardian_verify_challenge_setup includes verify command and TTL', () => {
test('guardian_verify_challenge_setup includes verify code', () => {
const msg = composeApprovalMessage({
scenario: 'guardian_verify_challenge_setup',
verifyCommand: '/guardian_verify abc123def456',
ttlSeconds: 600,
verifyCommand: '987654',
});
expect(typeof msg).toBe('string');
expect(msg.length).toBeGreaterThan(0);
expect(msg).toContain('/guardian_verify abc123def456');
expect(msg).toContain('600');
expect(msg).toContain('6-digit code: 987654');
});

test('guardian_verify_failed includes failure reason', () => {
Expand Down
7 changes: 4 additions & 3 deletions assistant/src/__tests__/channel-guardian.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,21 +396,22 @@ describe('guardian service challenge validation', () => {
expect(result.ttlSeconds).toBe(600);
expect(result.instruction).toBeDefined();
expect(result.instruction.length).toBeGreaterThan(0);
expect(result.instruction).toContain('code you were given');
// Hex codes use generic "send the code:" format
expect(result.instruction).toContain(`the code: ${result.secret}`);
});

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('code you were given');
expect(result.instruction).toContain(`the code: ${result.secret}`);
});

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('code you were given');
expect(result.instruction).toContain(`the code: ${result.secret}`);
});

test('validateAndConsumeChallenge succeeds with correct secret', () => {
Expand Down
20 changes: 14 additions & 6 deletions assistant/src/runtime/approval-message-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,18 +216,26 @@ export function getFallbackMessage(context: ApprovalMessageContext): string {
case 'guardian_verify_failed':
return `Verification failed. ${context.failureReason ?? 'Please try again.'}`;

case 'guardian_verify_challenge_setup':
case 'guardian_verify_challenge_setup': {
// The instruction must include the code so the macOS client (and other
// consumers) can parse it from the instruction text. The
// "<N>-digit code: <code>" format is shared across channels for
// consistency; wording adapts to channel and code type.
const code = context.verifyCommand ?? 'the verification code';
// Detect whether the code is a short numeric (identity-bound outbound)
// or a high-entropy hex (inbound challenge) and adjust wording.
const isNumeric = /^\d{4,8}$/.test(code);
if (context.channel === 'voice') {
const code = context.verifyCommand ?? 'the verification code';
// Detect whether the code is a short numeric (identity-bound outbound)
// or a high-entropy hex (inbound challenge) and adjust wording.
const isNumeric = /^\d{4,8}$/.test(code);
if (isNumeric) {
return `To complete guardian verification, speak or enter the ${code.length}-digit code: ${code}.`;
}
return `To complete guardian verification, enter the code: ${code}.`;
}
return `To complete guardian verification, reply in the channel with the code you were given.`;
if (isNumeric) {
return `To complete guardian verification, send the ${code.length}-digit code: ${code}.`;
}
return `To complete guardian verification, send the code: ${code}.`;
}

case 'guardian_verify_status_bound':
return 'A guardian is currently active for this channel.';
Expand Down
34 changes: 26 additions & 8 deletions assistant/src/runtime/routes/inbound-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,22 @@ const log = getLogger('runtime-http');
* 3. A bare code as the entire message: 6-digit numeric OR 64-char hex
* (hex is retained for backward compatibility with in-flight inbound
* challenges that still use high-entropy secrets)
* Returns the verification code if recognized, or undefined otherwise.
* Returns `{ code, isExplicitCommand }` if recognized, or undefined otherwise.
* `isExplicitCommand` is true for legacy /guardian_verify commands (explicit
* intent) and false for bare codes (which need additional gating to avoid
* intercepting normal 6-digit messages like zip codes or PINs).
*/
function parseGuardianVerifyCommand(content: string): string | undefined {
function parseGuardianVerifyCommand(content: string): { code: string; isExplicitCommand: boolean } | undefined {
// Legacy /guardian_verify command format
const commandMatch = content.match(/^\/guardian_verify(?:@\S+)?\s+(\S+)/);
if (commandMatch) return commandMatch[1];
if (commandMatch) return { code: commandMatch[1], isExplicitCommand: true };

// Bare code: 6-digit numeric (identity-bound outbound sessions) or
// 64-char hex (unbound inbound challenges)
const bareMatch = content.match(/^([0-9a-fA-F]{64}|\d{6})$/);
return bareMatch?.[1];
if (bareMatch) return { code: bareMatch[1], isExplicitCommand: false };

return undefined;
}

export async function handleChannelInbound(
Expand Down Expand Up @@ -195,8 +200,8 @@ export async function handleChannelInbound(

// /guardian_verify must bypass the ACL membership check — users without a
// member record need to verify before they can be recognized as members.
const guardianVerifyCode = parseGuardianVerifyCommand(trimmedContent);
const isGuardianVerifyCommand = guardianVerifyCode !== undefined;
const guardianVerifyParsed = parseGuardianVerifyCommand(trimmedContent);
const isGuardianVerifyCommand = guardianVerifyParsed !== undefined;

// /start gv_<token> bootstrap commands must also bypass ACL — the user
// hasn't been verified yet and needs to complete the bootstrap handshake.
Expand Down Expand Up @@ -595,15 +600,28 @@ export async function handleChannelInbound(
// delivered via template-driven deterministic messages and the command
// is short-circuited — it NEVER enters the agent pipeline. This
// prevents verification commands from producing agent-generated copy.
//
// Bare 6-digit codes are only intercepted when there is actually a
// pending challenge or active outbound session for this channel.
// Without this guard, normal 6-digit messages (zip codes, PINs, etc.)
// would be swallowed by the verification handler and never reach the
// agent pipeline. Legacy /guardian_verify commands are always
// intercepted because the explicit command prefix signals clear intent.
const shouldInterceptVerification = guardianVerifyParsed !== undefined &&
(guardianVerifyParsed.isExplicitCommand ||
!!getPendingChallenge(canonicalAssistantId, sourceChannel) ||
!!findActiveSession(canonicalAssistantId, sourceChannel));

if (
!result.duplicate &&
guardianVerifyCode !== undefined &&
shouldInterceptVerification &&
guardianVerifyParsed !== undefined &&
body.senderExternalUserId
) {
const verifyResult = validateAndConsumeChallenge(
canonicalAssistantId,
sourceChannel,
guardianVerifyCode,
guardianVerifyParsed.code,
body.senderExternalUserId,
externalChatId,
body.senderUsername,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1501,46 +1501,60 @@ struct SettingsConnectTab: View {
private func guardianInstructionSubtext(channel: String) -> String {
if channel == "telegram" {
let handle = store.telegramBotUsername.map { "@\($0)" } ?? "your bot"
return "Message \(handle) with the below command within the next 10 minutes"
return "Message \(handle) with the below code within the next 10 minutes"
} else if channel == "voice" {
let number = store.twilioPhoneNumber ?? "your assistant"
return "Call \(number) and say the six-digit code below within the next 10 minutes"
} else {
let number = store.twilioPhoneNumber ?? "your assistant"
return "Text \(number) with the below command within the next 10 minutes"
return "Text \(number) with the below code within the next 10 minutes"
}
}

/// Extracts the `/guardian_verify <hex>` command from a raw instruction string.
/// Extracts a guardian verification code from a raw instruction string.
/// Supports three formats:
/// 1. Legacy `/guardian_verify <hex>` command
/// 2. "N-digit code: <digits>" (numeric codes, e.g. "6-digit code: 123456")
/// 3. "the code: <hex>" (high-entropy hex codes for inbound challenges)
private func extractGuardianCommand(from instruction: String) -> String? {
guard let range = instruction.range(of: #"`?/guardian_verify\s+[0-9a-fA-F]+`?"#, options: .regularExpression) else {
return nil
// Try legacy /guardian_verify command format first
if let range = instruction.range(of: #"`?/guardian_verify\s+[0-9a-fA-F]+`?"#, options: .regularExpression) {
return String(instruction[range]).trimmingCharacters(in: CharacterSet(charactersIn: "`"))
}
// Try N-digit code format (e.g., "6-digit code: 123456")
if let code = extractNumericCode(from: instruction) {
return code
}
// Try generic "the code: <hex>" format for high-entropy codes
if let range = instruction.range(of: #"the code:\s*([0-9a-fA-F]+)"#, options: .regularExpression) {
let match = String(instruction[range])
if let hexRange = match.range(of: #"[0-9a-fA-F]{6,}"#, options: .regularExpression) {
return String(match[hexRange])
}
}
return String(instruction[range]).trimmingCharacters(in: CharacterSet(charactersIn: "`"))
return nil
}

/// Extracts a six-digit verification code from voice-style instruction text.
/// Voice instructions use a format like "...six-digit code: 123456..." instead of the
/// `/guardian_verify <hex>` command used by Telegram and SMS channels.
private func extractVoiceGuardianCode(from instruction: String) -> String? {
guard let range = instruction.range(of: #"six-digit code:\s*(\d{6})"#, options: .regularExpression) else {
/// Extracts a numeric verification code from instruction text.
/// Matches the format "N-digit code: <digits>" used for identity-bound codes.
private func extractNumericCode(from instruction: String) -> String? {
guard let range = instruction.range(of: #"\d+-digit code:\s*(\d+)"#, options: .regularExpression) else {
return nil
}
let match = String(instruction[range])
// Extract just the digits from "six-digit code: 123456"
guard let digitRange = match.range(of: #"\d{6}"#, options: .regularExpression) else {
// Extract just the digits after "N-digit code: "
guard let colonRange = match.range(of: #":\s*"#, options: .regularExpression) else {
return nil
}
return String(match[digitRange])
return String(match[colonRange.upperBound...])
}

@ViewBuilder
private func guardianInstructionView(channel: String, instruction: String) -> some View {
// Voice uses a different instruction format ("six-digit code: 123456") vs
// Telegram/SMS which use "/guardian_verify <hex>".
let command: String? = channel == "voice"
? extractVoiceGuardianCode(from: instruction)
: extractGuardianCommand(from: instruction)
// All channels now use 6-digit numeric codes. extractGuardianCommand
// handles both the legacy /guardian_verify format and the new
// "six-digit code: 123456" format.
let command: String? = extractGuardianCommand(from: instruction)
let isCopied = guardianCommandCopiedChannel == channel

VStack(alignment: .leading, spacing: VSpacing.sm) {
Expand Down