/** * Resolver registry for guardian requests. * * The decision primitive validates identity/status, asks the kind's resolver * to plan the gateway ACL outcome (`prepare`, before any status write), * commits the status CAS + outcome atomically via `guardian_requests_decide`, * and then dispatches the kind's daemon-domain follow-through (`resolve`: * pending-interaction resume, call answering, notifications, verification-code * delivery). Follow-through failures surface as `resolverFailed` but never * disturb the committed decision — atomic decide made reopen-on-failed-persist * obsolete. * * The registry is intentionally a simple Map keyed by request kind. New * request kinds can register resolvers here without touching the core * decision primitive. */ import type { CreateOutboundSessionIpcResponse } from "@vellumai/gateway-client"; import { answerCall } from "../calls/call-domain.js"; import type { GuardianRequestAclOutcome, GuardianRequestWire, } from "../channels/gateway-guardian-requests.js"; import { getGuardianRequestOrNull } from "../channels/gateway-guardian-requests.js"; import { audienceForReader } from "../channels/message-audience.js"; import { findContactChannel } from "../contacts/contact-store.js"; import { findConversation } from "../daemon/conversation-registry.js"; import { isSlackDmConversation } from "../messaging/providers/slack/message-metadata.js"; import { emitNotificationSignal } from "../notifications/emit-signal.js"; import { parseQuestionAnswerActionId } from "../notifications/guardian-question-mode.js"; import { isNotificationSourceChannel, type NotificationSourceChannel, } from "../notifications/signal.js"; import type { TrustedContactDecisionPayload, TrustedContactVerificationSentPayload, } from "../notifications/trusted-contact-payloads.js"; import type { QuestionBatchSubmission } from "../permissions/question-prompter.js"; import type { UserDecision } from "../permissions/types.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js"; import { type ApprovalAction, DENYING_ACTION_SET, } from "../runtime/channel-approval-types.js"; import { deliverChannelReply } from "../runtime/gateway-client.js"; import { introductionMode, parseRequesterSignals, type RequesterIdentitySignals, resolveTrustBinding, } from "../runtime/introduction-policy.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; import { readBatchMetadata, resolvePendingQuestion, } from "../runtime/question-resolution.js"; import { resolveInlineGrantWaitMs } from "../tools/tool-approval-handler.js"; import { getLogger } from "../util/logger.js"; import { channelCanAddressOneReaderInBand, channelCanCompleteCodeHandshakeInDm, channelDeliversToUserId, resolveDeliverCallbackUrlForChannel, resolveRequesterDeliveryTarget, stripTurnDestination, } from "./guardian-channel-delivery.js"; const log = getLogger("guardian-request-resolvers"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Deliver the verification code straight to the requester's DM so the * guardian is never an out-of-band courier for the secret. * * Used on the channels with a guaranteed private path to a requester's user * id: Slack, where posting to a `U…` id opens a 1:1 DM, and Discord, whose * `dm`-marked route resolves a user snowflake to a DM channel. The `threadTs` * query param is dropped because it points at the guardian's channel thread * and would raise `thread_not_found` in the DM. The code is sent as a durable * (non-ephemeral) message the requester can refer back to when verifying. * * The verification session is identity-bound to the requester, so delivering * the code to them directly does not widen who can consume it — it only removes * the guardian relay step. * * Returns whether the code was delivered. */ async function deliverVerificationCodeToRequester(params: { replyCallbackUrl: string; requesterExternalUserId: string; verificationCode: string; assistantId: string; }): Promise { const callbackUrl = stripTurnDestination(params.replyCallbackUrl); try { await deliverChannelReply(callbackUrl, { chatId: params.requesterExternalUserId, text: "Great news — your access request was approved! " + `Your verification code is: \`${params.verificationCode}\`. ` + "Reply with it here to complete verification. The code expires in 10 minutes.", assistantId: params.assistantId, }); return true; } catch (err) { log.error( { err, requesterExternalUserId: params.requesterExternalUserId }, "Failed to auto-deliver verification code to requester", ); return false; } } /** * The guardian-facing verification-code message. * * Delivered on up to three routes (in band, a guardian DM follow-up, and a * guardian DM when their channel has no in-band route), so the wording lives * here rather than being retyped at each one. */ function guardianVerificationCodeText( requesterLabel: string, secret: string, ): string { return ( `You approved access for ${requesterLabel}. ` + `Give them this verification code: \`${secret}\`. ` + `The code expires in 10 minutes.` ); } /** Requester-facing approval notice when the guardian relays the code. */ const APPROVED_COURIER_NOTICE = "Your access request has been approved! " + "Please enter the 6-digit verification code you receive from the guardian."; /** Requester-facing notice when the code could not be handed to the guardian. */ const APPROVED_CODE_UNDELIVERED_NOTICE = "Your access request was approved, but we were unable to " + "deliver the verification code. Please try again later."; /** * Build a requester-facing channel notice (approval/denial/courier text). * * Posts to the originating chat. On a Slack shared channel it goes out as an * ephemeral message visible only to the requester — and because * `chat.postEphemeral` needs a channel ID, `chatId` stays the channel * (`requesterChatId`), never the requester's `U…` user ID. */ function buildRequesterChannelNotice(params: { channel: string; requesterChatId: string; requesterExternalUserId: string; text: string; assistantId: string; }): Parameters[1] { const payload: Parameters[1] = { chatId: params.requesterChatId, text: params.text, assistantId: params.assistantId, }; payload.audience = audienceForReader( params.channel, params.requesterChatId, params.requesterExternalUserId, ); return payload; } /** * Emit the `verification_sent` lifecycle signal on guardian approve. * * Always `visibleInSourceNow: true` so the notification pipeline suppresses * delivery — the guardian already received the code (on-channel via the channel * reply, off-channel via the inline reply text), so this records the lifecycle * transition without sending a redundant "approved" message. It also stands in * for `guardian_decision` on approve (which would notify), so the pipeline * doesn't announce approval before verification. */ function emitVerificationSentSignal( payload: TrustedContactVerificationSentPayload, conversationId: string | null | undefined, ): void { void emitNotificationSignal({ sourceEventName: "ingress.trusted_contact.verification_sent", sourceChannel: payload.sourceChannel, sourceContextId: conversationId ?? "", attentionHints: { requiresAction: false, urgency: "low", isAsyncBackground: true, visibleInSourceNow: true, }, contextPayload: payload, dedupeKey: `trusted-contact:verification-sent:${payload.verificationSessionId}`, }); } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Actor context for the entity making the decision. */ export interface ActorContext { /** Auth-identity principal ID of the deciding actor (undefined for callback-only actors). */ actorPrincipalId: string | undefined; /** Channel-native external user ID (Telegram user ID, E.164 phone, etc.) of the deciding actor (undefined for desktop actors). Maps to `decided_by_external_user_id` DB column. */ actorExternalUserId: string | undefined; /** Channel the decision arrived on. */ channel: string; /** Principal ID for authorization — must match the request's guardianPrincipalId. */ guardianPrincipalId: string | undefined; } /** The decision being applied. */ export interface ResolverDecision { /** The effective action (approve_once or reject). */ action: ApprovalAction; /** Optional user-supplied text (e.g. answer text for pending questions). */ userText?: string; } /** Channel delivery context for resolvers that need to send messages. */ export interface ChannelDeliveryContext { /** URL to POST channel replies to. */ replyCallbackUrl: string; /** Chat ID of the guardian receiving the reply. */ guardianChatId: string; /** Assistant ID for attribution. */ assistantId: string; /** Optional bearer token for authenticated delivery. */ bearerToken?: string; } /** Emission context threaded from callers to handleConfirmationResponse. */ export interface ResolverEmissionContext { source?: "button" | "inline_nl" | "auto_deny" | "timeout" | "system"; causedByRequestId?: string; decisionText?: string; } /** Context passed to a resolver's `prepare`, before any status write. */ export interface PrepareContext { /** The guardian request, still pending. */ request: GuardianRequestWire; /** The decision being applied. */ decision: ResolverDecision; /** Actor context for the entity making the decision. */ actor: ActorContext; } /** * Outcome plan a resolver produces before the decision commits. * * `aclOutcome` (when present) is committed by the gateway in the SAME * transaction as the status CAS. `persistFailureReason` becomes the * `resolverFailureReason` surfaced to callers when the atomic decide throws — * the request stays pending gateway-side and the guardian can retry. * `ok: false` aborts the decision before any status write. */ export type DecisionOutcomePlan = | { ok: true; aclOutcome?: GuardianRequestAclOutcome; persistFailureReason: string; } | { ok: false; reason: string }; /** Context passed to each resolver after the atomic decide succeeds. */ export interface ResolverContext { /** The guardian request record (already resolved to its terminal status). */ request: GuardianRequestWire; /** The decision being applied. */ decision: ResolverDecision; /** Actor context for the entity making the decision. */ actor: ActorContext; /** Optional channel delivery context — present when the decision arrived via a channel message. */ channelDeliveryContext?: ChannelDeliveryContext; /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */ emissionContext?: ResolverEmissionContext; /** * Raw outbound-session mint returned by the atomic decide when the planned * outcome was `mint_outbound_session` — the secret transits back for * daemon-owned code delivery. */ mintedSession?: CreateOutboundSessionIpcResponse; } /** Discriminated result from a resolver. */ export type ResolverResult = | { ok: true; applied: true; grantMinted?: boolean; guardianReplyText?: string; } | { ok: false; reason: string }; /** Interface that kind-specific resolvers implement. */ export interface GuardianRequestResolver { /** The request kind this resolver handles (matches guardian_requests.kind). */ kind: string; /** * Plan the gateway ACL outcome for this decision, BEFORE any status write. * Kinds without gateway-owned outcomes omit this — their decide is a plain * status CAS. */ prepare?(context: PrepareContext): DecisionOutcomePlan; /** Execute daemon-domain follow-through after the atomic decide commits. */ resolve(context: ResolverContext): Promise; } // --------------------------------------------------------------------------- // Resolver implementations // --------------------------------------------------------------------------- /** * Resolves `tool_approval` requests — the channel/desktop approval path. * * Adapts the existing `handleChannelDecision` logic: looks up the pending * interaction by conversation ID, maps the decision to the session's * confirmation response, and resolves the interaction. * * Side effects are deferred to callers that wire into existing channel * approval infrastructure. This resolver focuses on validating that the * request shape is appropriate for tool_approval handling. */ const pendingInteractionResolver: GuardianRequestResolver = { kind: "tool_approval", async resolve(ctx: ResolverContext): Promise { const { request, decision } = ctx; if (!request.sourceConversationId) { return { ok: false, reason: "tool_approval request missing conversationId", }; } // Look up the pending interaction directly by requestId. const interaction = pendingInteractions.get(request.id); if (!interaction) { // The pending interaction was already consumed (stale) or not found. // The decision CAS already committed, so this is not an error — just // means the interaction was resolved by another path (e.g. timeout). log.warn( { event: "resolver_tool_approval_stale", requestId: request.id, conversationId: request.sourceConversationId, }, "Tool approval resolver: pending interaction not found (already consumed or timed out)", ); return { ok: false, reason: "pending_interaction_not_found" }; } // Map action to the permission system's UserDecision type and notify session. // resolveConfirmation() owns pendingInteractions deregistration. const userDecision: UserDecision = DENYING_ACTION_SET.has(decision.action) ? "deny" : "allow"; // Route-owned confirmations (e.g. the ACP spawn/steer approval gate in // acp-routes.ts) carry a `directResolve` and are NOT owned by any // Conversation.prompter, so handleConfirmationResponse below would no-op // and the caller would block until timeout. Resolve them directly, exactly // as the POST /v1/confirm route does (see approval-routes.ts). if (interaction.directResolve) { pendingInteractions.resolve( request.id, userDecision === "allow" ? "approved" : "rejected", ); interaction.directResolve(userDecision); log.info( { event: "resolver_tool_approval_applied", requestId: request.id, action: decision.action, conversationId: request.sourceConversationId, toolName: request.toolName, directResolve: true, }, "Tool approval resolver: direct-resolve interaction resolved", ); return { ok: true, applied: true }; } const conversation = findConversation(interaction.conversationId); if (!conversation) { return { ok: false, reason: `conversation_not_found: ${interaction.conversationId}`, }; } conversation.handleConfirmationResponse(request.id, userDecision, { emissionContext: ctx.emissionContext, }); log.info( { event: "resolver_tool_approval_applied", requestId: request.id, action: decision.action, conversationId: request.sourceConversationId, toolName: request.toolName, }, "Tool approval resolver: pending interaction resolved", ); return { ok: true, applied: true }; }, }; /** * Resolves `pending_question` requests. Two variants share the kind, * distinguished by provenance fields: * * - Voice-call questions carry a `callSessionId` (+ `pendingQuestionId`); * the answer is delivered to the live call session via `answerCall`. * - `ask_question` prompts carry neither — the request id IS the pending * `question` interaction's requestId (mirroring `tool_approval` rows), and * the answer resolves that interaction so the parked tool call returns. * * Side-effect failures surface as `resolverFailed` — the committed decision * stands (no reopen). */ const pendingQuestionResolver: GuardianRequestResolver = { kind: "pending_question", async resolve(ctx: ResolverContext): Promise { const { request, decision, actor: _actor } = ctx; if (!request.callSessionId) { return resolveAskQuestionInteraction(request, decision); } if (!request.pendingQuestionId) { return { ok: false, reason: "pending_question request missing pendingQuestionId", }; } // Derive the answer text from the decision. For approve actions, use the // guardian's text if present; otherwise use a default affirmative answer. // For reject, use the text or a default denial. const answerText = decision.userText ?? (decision.action === "reject" ? "No" : "Yes"); // 1. Deliver the answer to the voice call session. const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText, pendingQuestionId: request.pendingQuestionId, }); if (!("ok" in answerResult) || !answerResult.ok) { const errorMsg = "error" in answerResult ? answerResult.error : "Unknown error"; log.warn( { event: "resolver_pending_question_answer_failed", requestId: request.id, callSessionId: request.callSessionId, error: errorMsg, }, "Pending question resolver: answerCall failed", ); // The decision CAS has already committed so we don't roll back the // resolution, but we signal failure so the decision primitive skips // grant minting and callers see the side-effect failure. return { ok: false, reason: "answer_call_failed" }; } log.info( { event: "resolver_pending_question_applied", requestId: request.id, action: decision.action, callSessionId: request.callSessionId, pendingQuestionId: request.pendingQuestionId, answerText, answerCallOk: "ok" in (answerResult as Record) ? (answerResult as Record).ok : false, }, "Pending question resolver: decision applied", ); return { ok: true, applied: true }; }, }; /** * Resolve an `ask_question` pending interaction from a guardian decision. * * The decision's `userText` carries either an answer-option token * (`answer_` / `answer_skip`, from a tapped card button — see * {@link parseQuestionAnswerActionId}) or the guardian's free-typed answer. * A reject decision with no text is an explicit skip. Submission validation * and the prompter hand-off go through the shared * {@link resolvePendingQuestion}, the same core `/v1/question-response` uses, * so an app-card answer and a channel answer resolve identically. */ async function resolveAskQuestionInteraction( request: GuardianRequestWire, decision: ResolverDecision, ): Promise { const interaction = pendingInteractions.get(request.id); if (!interaction || interaction.kind !== "question") { // The parked prompt is gone (answered elsewhere, timed out, or the daemon // restarted). The committed decision stands; there is just nothing left to // resume. return { ok: false, reason: "no_pending_question_interaction" }; } const { orderedIds, optionsById } = readBatchMetadata(interaction); if (orderedIds.length !== 1) { // Channel cards are only created for single-question batches (the // promotion gate enforces it); a multi-question interaction cannot be // answered by one decision. return { ok: false, reason: "question_batch_not_single" }; } const questionId = orderedIds[0]!; let submission: QuestionBatchSubmission; const answerSelection = decision.userText ? parseQuestionAnswerActionId(decision.userText.trim()) : null; if (answerSelection?.kind === "skip") { submission = { questionId, kind: "skip" }; } else if (answerSelection?.kind === "option") { const optionId = (optionsById[questionId] ?? [])[answerSelection.index]; if (!optionId) { return { ok: false, reason: "question_option_index_out_of_range" }; } submission = { questionId, kind: "option", optionId }; } else if (decision.userText && decision.userText.trim().length > 0) { submission = { questionId, kind: "free_text", text: decision.userText.trim(), }; } else if (DENYING_ACTION_SET.has(decision.action)) { submission = { questionId, kind: "skip" }; } else { // Bare approval with no text (e.g. "CODE approve") — affirm without // inventing content; the model reads the affirmation in context. submission = { questionId, kind: "free_text", text: "Yes" }; } const outcome = resolvePendingQuestion(request.id, { kind: "submit", submissions: [submission], }); if (outcome.status !== "resolved") { log.warn( { event: "resolver_ask_question_not_resolved", requestId: request.id, outcome: outcome.status, ...(outcome.status === "invalid" ? { message: outcome.message } : {}), }, "Ask-question resolver: pending interaction did not resolve", ); return { ok: false, reason: outcome.status === "invalid" ? "question_submission_invalid" : "no_pending_question_interaction", }; } log.info( { event: "resolver_ask_question_applied", requestId: request.id, action: decision.action, submissionKind: submission.kind, conversationId: outcome.conversationId, }, "Ask-question resolver: pending interaction resolved", ); return { ok: true, applied: true }; } /** * The four introduction-card outcomes for an access request. The generic * decision pair maps onto them: `approve_once` → `verify_code` (handshake), * `reject` → `leave_unverified`. */ type IntroductionOutcome = | "verify_code" | "trust" | "leave_unverified" | "block"; /** * Wire action → introduction outcome. Exhaustive over `ApprovalAction` so a * future action addition fails to compile here instead of silently falling * into a default outcome. */ const OUTCOME_BY_ACTION = { approve_once: "verify_code", verify_code: "verify_code", trust: "trust", reject: "leave_unverified", leave_unverified: "leave_unverified", block: "block", } as const satisfies Record; /** * The introduction outcome a decision action resolves to for an access request. * The generic decision pair folds onto the card outcomes (`reject` → * `leave_unverified`, `approve_once` → `verify_code`); the introduction actions * map to themselves. Every outcome is itself an `ApprovalAction`, so a caller * that must reflect the resolved *outcome* rather than the raw button — the * resolved-card projection, so a `reject` that parked the contact at * `unverified` reads as the neutral "Left unverified" and not "Denied" — can * normalize through this. It does not apply the bot handshake→trust coercion, * which does not affect the park/deny distinction the card cares about. */ export function introductionOutcomeForAction( action: ApprovalAction, ): ApprovalAction { return OUTCOME_BY_ACTION[action]; } /** Derived access-request decision facts shared by `prepare` and `resolve`. */ interface AccessRequestDerivation { channel: NotificationSourceChannel; requesterExternalUserId: string; requesterChatId: string; requesterDisplayName: string | null; signals: RequesterIdentitySignals; outcome: IntroductionOutcome; } /** * Derive the effective introduction outcome and requester identity facts for * an access-request decision. Pure over the request row + action, so * `prepare` (outcome planning) and `resolve` (follow-through) branch * identically. */ function deriveAccessRequestDecision( request: GuardianRequestWire, action: ApprovalAction, ): AccessRequestDerivation { const channel: NotificationSourceChannel = isNotificationSourceChannel( request.sourceChannel, ) ? request.sourceChannel : "vellum"; const requesterExternalUserId = request.requesterExternalUserId ?? ""; const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? ""; // Resolve display names from the contacts database for enriched payloads const requesterContactResult = requesterExternalUserId ? findContactChannel({ channelType: channel, address: requesterExternalUserId, }) : null; const requesterDisplayName = requesterContactResult?.contact.displayName ?? null; const signals = parseRequesterSignals(request.requesterSignals); let outcome: IntroductionOutcome = OUTCOME_BY_ACTION[action]; // A bot cannot return a verification code, so a handshake approval on a // bot requester can never complete. Coerce it to direct trust — the // guardian's intent ("let it in") is unambiguous. Logged once, in // `prepare` (this derivation runs again in `resolve`). if (outcome === "verify_code" && signals.isBot === true) { outcome = "trust"; } return { channel, requesterExternalUserId, requesterChatId, requesterDisplayName, signals, outcome, }; } /** * Deliver a requester-facing decision notice. On-channel decisions reply via * the channel delivery context (ephemeral on Slack shared channels); * off-channel (desktop) decisions post via the channel's deliver URL — on * Slack and Discord routed to the requester's user ID so the notice opens a DM * instead of posting into a shared channel. Delivery failures are logged, never * thrown: the notice is best-effort and must not fail the decision. * * Discord takes the deliver-URL route in both cases, because its in-band route * cannot be kept to one reader (see `channelCanAddressOneReaderInBand`). * Posting a decision back in-band there would announce "your access request * was declined" to a whole community channel, so Discord has one route to a * requester and it is a DM. */ async function deliverRequesterNotice(params: { channel: NotificationSourceChannel; requesterChatId: string; requesterExternalUserId: string; assistantId: string; channelDeliveryContext: ChannelDeliveryContext | undefined; desktopDeliverUrl: string | null; text: string; }): Promise { const { channel, requesterChatId, requesterExternalUserId, assistantId, desktopDeliverUrl, text, } = params; // Re-applied rather than assumed. The access-request resolver already drops // the context for these channels, because its verification-code branch needs // the same rule and never reaches this helper; repeating it here keeps the // helper correct for any caller that has not, at the cost of one idempotent // check. const channelDeliveryContext = channelCanAddressOneReaderInBand(channel) ? params.channelDeliveryContext : undefined; if (channelDeliveryContext) { try { await deliverChannelReply( channelDeliveryContext.replyCallbackUrl, buildRequesterChannelNotice({ channel, requesterChatId, requesterExternalUserId, text, assistantId, }), ); } catch (err) { log.error( { err, requesterChatId }, "Failed to deliver requester decision notice", ); } return; } if (desktopDeliverUrl && requesterChatId) { const targetChatId = resolveRequesterDeliveryTarget({ channel, requesterChatId, requesterExternalUserId, }); try { await deliverChannelReply(desktopDeliverUrl, { chatId: targetChatId, text, assistantId, }); } catch (err) { log.error( { err, requesterChatId }, "Failed to deliver requester decision notice (desktop decision path)", ); } } } /** * Emit the guardian-facing denial lifecycle signal and, unless suppressed, * deliver the "declined" notice to the requester. Both the `leave_unverified` * and `block` outcomes call this for the lifecycle signal, but the requester * notice is delivered only for `block` (in denied mode) — `leave_unverified` * always passes `suppressRequesterNotice: true`, staying a silent park at * `unverified`. The notice text is a plain decline that does not reveal whether * the sender was blocked. */ async function notifyRequesterOfDenial(params: { channel: NotificationSourceChannel; requesterChatId: string; requesterExternalUserId: string; assistantId: string; channelDeliveryContext: ChannelDeliveryContext | undefined; desktopDeliverUrl: string | null; /** * Whether the guardian decided on a channel at all, as distinct from whether * a route to the requester survived suppression. The lifecycle signal keys * on the former; passing the suppressed context would drop the guardian's * record of every Discord denial. */ decidedOnChannel: boolean; deniedPayload: TrustedContactDecisionPayload; requestId: string; conversationId: string | null; /** * Admitted-mode introduction nudges never send the requester a denial * text — the sender made no request and (for leave-unverified) keeps * whatever access the floor grants. Guardian-facing decision signals * still emit. */ suppressRequesterNotice?: boolean; }): Promise { const { channel, requesterChatId, requesterExternalUserId, assistantId, channelDeliveryContext, desktopDeliverUrl, decidedOnChannel, deniedPayload, requestId, conversationId, suppressRequesterNotice, } = params; if (!suppressRequesterNotice) { await deliverRequesterNotice({ channel, requesterChatId, requesterExternalUserId, assistantId, channelDeliveryContext, desktopDeliverUrl, // Active-denial copy, deliberately distinct from the expiry notice // ("…expired before it was reviewed") so a reviewed decline never reads // as a request that simply timed out. text: "Your access request was declined.", }); } // Exactly one signal per denial: the payload's `decision: "denied"` // carries the verdict, and the pipeline can only dedupe within a single // event stream — a second event name would materialize a second // conversation for the same decision. The approve path holds the same // one-signal invariant via `verification_sent` standing in for // `guardian_decision`. if (decidedOnChannel) { void emitNotificationSignal({ sourceEventName: "ingress.trusted_contact.guardian_decision", sourceChannel: channel, sourceContextId: conversationId ?? "", attentionHints: { requiresAction: false, urgency: "medium", isAsyncBackground: false, visibleInSourceNow: false, }, contextPayload: deniedPayload, dedupeKey: `trusted-contact:guardian-decision:${requestId}`, }); } } /** * Resolves `access_request` requests — the introduction card's trust-setting * decision for a first-contact sender. * * Four outcomes (see `introduction-policy.ts`): * - `verify_code` (also reached via `approve_once`): mints an identity-bound * verification session so the requester proves control of the channel. * - `trust`: activates the contact directly, no code. `verifiedVia` records * the binding strength: `manual` for a workspace-vouched identity, * `manual_channel_claim` for an external/stranger the platform is not * vouching for. * - `leave_unverified` (also reached via `reject`): persists the sender as an * `unverified_contact` so discovery does not re-fire. * - `block`: persists the sender's channel as `revoked` (gateway ACL is the * source of truth). * * `prepare` maps the outcome onto the gateway `aclOutcome` committed * atomically with the status CAS; `resolve` runs the daemon-domain * follow-through (requester/guardian notices, verification-code delivery * from the decide's `mintedSession`, lifecycle signals). * * A bot requester can never return a code, so handshake approvals are * coerced to direct trust. */ const accessRequestResolver: GuardianRequestResolver = { kind: "access_request", prepare(ctx: PrepareContext): DecisionOutcomePlan { const { request, decision } = ctx; const { channel, requesterExternalUserId, requesterChatId, requesterDisplayName, signals, outcome, } = deriveAccessRequestDecision(request, decision.action); if (outcome !== OUTCOME_BY_ACTION[decision.action]) { log.info( { event: "resolver_access_request_bot_coercion", requestId: request.id, action: decision.action, }, "Access request resolver: handshake approval on a bot coerced to direct trust", ); } if (outcome === "leave_unverified") { // Persist the denied sender as an unverified_contact so future inbound // resolves as unverified_contact rather than re-triggering discovery. // Skipped for desktop-origin (vellum) requests, which carry no channel // identity — those deny as a plain status CAS. if (!requesterExternalUserId || channel === "vellum") { return { ok: true, persistFailureReason: "decision_persist_failed" }; } return { ok: true, aclOutcome: { type: "seed_unverified", sourceChannel: channel, externalUserId: requesterExternalUserId, ...(requesterDisplayName ? { displayName: requesterDisplayName } : {}), }, persistFailureReason: "seed_unverified_failed", }; } if (outcome === "block") { if (!requesterExternalUserId || channel === "vellum") { // No channel identity to revoke — nothing can land on the gateway, // so the decision is aborted before any status write. return { ok: false, reason: "block_missing_channel_identity" }; } return { ok: true, aclOutcome: { type: "block", sourceChannel: channel, externalUserId: requesterExternalUserId, ...(requesterDisplayName ? { displayName: requesterDisplayName } : {}), reason: "introduction_block", }, persistFailureReason: "block_persist_failed", }; } // Voice approvals: directly activate the trusted contact without minting // a verification session. The caller is already on the line and the // call setup flow's in-call wait loop will detect the approved status. // The gateway fails the decide closed when the row carries no channel // identity — a caller the ACL source of truth never verified must not // resolve as approved. if (channel === "phone") { return { ok: true, aclOutcome: { type: "activate_member", sourceChannel: "phone", ...(requesterExternalUserId ? { externalUserId: requesterExternalUserId } : {}), ...(requesterChatId ? { externalChatId: requesterChatId } : {}), }, persistFailureReason: "voice_activation_failed", }; } // Direct trust: activate the contact without a handshake. The binding // strength is derived from the platform's identity signals — a // workspace-vouched identity records `manual` (internal_workspace_match); // an external/stranger records `manual_channel_claim` // (inbound_channel_claim), never handshake-equivalent provenance. if (outcome === "trust") { // A trust without a channel identity cannot land on the gateway ACL — // fail closed before any status write, mirroring the block guard. if (!requesterExternalUserId || channel === "vellum") { return { ok: false, reason: "trust_missing_channel_identity" }; } const binding = resolveTrustBinding(channel, signals); return { ok: true, aclOutcome: { type: "activate_member", sourceChannel: channel, externalUserId: requesterExternalUserId, externalChatId: requesterChatId, ...(requesterDisplayName ? { displayName: requesterDisplayName } : {}), verifiedVia: binding.verifiedVia, }, persistFailureReason: "trust_activation_failed", }; } // Non-voice approvals: mint an identity-bound verification session so the // requester can verify their identity. The raw secret transits back on // the decide response for daemon-owned delivery. // // The session is addressed to the requester, not to the room the request // arrived in. On the channels that reach a person by user id, that room is // one other people can read, so recording it as the destination describes // somewhere the code is never sent. `expectedChatId` keeps the room, which // is where the conversation is, and it is not a credential: whenever both // identity fields are set, `checkIdentityMatch` requires the user match. const destinationAddress = channelDeliversToUserId(channel) ? requesterExternalUserId || requesterChatId : requesterChatId; return { ok: true, aclOutcome: { type: "mint_outbound_session", channel, expectedExternalUserId: requesterExternalUserId, expectedChatId: requesterChatId, identityBindingStatus: "bound", destinationAddress, verificationPurpose: "trusted_contact", }, persistFailureReason: "verification_session_mint_failed", }; }, async resolve(ctx: ResolverContext): Promise { const { request, decision } = ctx; const { channel, requesterExternalUserId, requesterChatId, requesterDisplayName, outcome, } = deriveAccessRequestDecision(request, decision.action); const decidedByExternalUserId = ctx.actor.actorExternalUserId ?? ""; const assistantId = DAEMON_INTERNAL_ASSISTANT_ID; const desktopDeliverUrl = resolveDeliverCallbackUrlForChannel(channel); // The reply context serves two readers on two different channels, so it is // suppressed per reader rather than wholesale. // // `requesterInBandContext` governs the requester-facing notice, which is // addressed into the REQUEST's channel: on Discord that is a guild channel // nobody can be singled out in, so it is dropped and the notice takes the // DM route the desktop path uses. // // `guardianInBandContext` governs the guardian's own copy of a verification // code, which is addressed into the channel the GUARDIAN replied on. A // Telegram guardian deciding a Discord request still has a private chat of // their own, and dropping their route because the requester's is public // would leave them with no confirmation and no code. const requesterInBandContext = channelCanAddressOneReaderInBand(channel) ? ctx.channelDeliveryContext : undefined; const guardianInBandContext = channelCanAddressOneReaderInBand( ctx.actor.channel, ) ? ctx.channelDeliveryContext : undefined; // Where the guardian's own copy goes when their channel has no in-band // route. Resolved from THEIR channel, never the requester's, or the code // is handed to the wrong transport. const guardianDmUrl = resolveDeliverCallbackUrlForChannel( ctx.actor.channel, ); // Guardian-facing label prefers the contact display name over the raw ID. const requesterLabel = requesterDisplayName || requesterExternalUserId || requesterChatId || "the requester"; const decidedByContactResult = decidedByExternalUserId ? findContactChannel({ channelType: channel, address: decidedByExternalUserId, }) : null; const decidedByDisplayName = decidedByContactResult?.contact.displayName ?? null; // Requester-facing lifecycle notices are mode-gated: an admitted-mode // nudge's sender made no request. See introductionMode(). const mode = introductionMode(request.requestTrigger); const deniedPayload: TrustedContactDecisionPayload = { sourceChannel: channel, requesterExternalUserId, requesterChatId, decidedByExternalUserId, requesterDisplayName, decidedByDisplayName, decision: "denied", }; if (outcome === "leave_unverified") { log.info( { event: "resolver_access_request_denied", requestId: request.id }, "Access request resolver: leave unverified", ); await notifyRequesterOfDenial({ channel, requesterChatId, requesterExternalUserId, assistantId, channelDeliveryContext: requesterInBandContext, decidedOnChannel: ctx.channelDeliveryContext !== undefined, desktopDeliverUrl, deniedPayload, requestId: request.id, conversationId: request.sourceConversationId, // Leave-unverified is a silent park: the sender stays an `unverified` // contact and is never told (per docs/trusted-contact-access.md — they // only learn if they message again). Unlike `block`, this notice is // always suppressed, in both denied and admitted modes; the // guardian-facing lifecycle signals still emit. suppressRequesterNotice: true, }); return { ok: true, applied: true, // Desktop actors (vellum channel) receive inline reply text; channel // actors get replies delivered via the channel delivery context. An // admitted sender keeps whatever access the floor grants. ...(ctx.actor.channel === "vellum" ? { guardianReplyText: mode.leaveUnverifiedGuardianReply(requesterLabel), } : {}), }; } if (outcome === "block") { log.info( { event: "resolver_access_request_blocked", requestId: request.id }, "Access request resolver: block", ); // The requester sees the same denial notice as leave-unverified — the // block itself is not revealed. await notifyRequesterOfDenial({ channel, requesterChatId, requesterExternalUserId, assistantId, channelDeliveryContext: requesterInBandContext, decidedOnChannel: ctx.channelDeliveryContext !== undefined, desktopDeliverUrl, deniedPayload, requestId: request.id, conversationId: request.sourceConversationId, suppressRequesterNotice: !mode.notifyRequesterOnDeny, }); return { ok: true, applied: true, ...(ctx.actor.channel === "vellum" ? { guardianReplyText: `Blocked ${requesterLabel}. Their messages will no longer reach the assistant.`, } : {}), }; } // Voice approvals: the caller was activated atomically with the decide; // the call setup flow's in-call wait loop detects the approved status. if (channel === "phone") { log.info( { event: "resolver_access_request_voice_approved", requestId: request.id, channel, requesterExternalUserId, }, "Access request resolver: voice approval — direct trusted-contact activation (no verification session)", ); return { ok: true, applied: true }; } if (outcome === "trust") { log.info( { event: "resolver_access_request_trusted", requestId: request.id, channel, requesterExternalUserId, }, "Access request resolver: direct trust — contact activated without handshake", ); // Notify the requester they're in. Admitted-mode nudges skip this — // the sender was already conversing and made no request. if (mode.notifyRequesterOnTrust) { await deliverRequesterNotice({ channel, requesterChatId, requesterExternalUserId, assistantId, channelDeliveryContext: requesterInBandContext, desktopDeliverUrl, text: "Your access request has been approved. You can message the assistant here.", }); } return { ok: true, applied: true, ...(ctx.actor.channel === "vellum" ? { guardianReplyText: `Trusted ${requesterLabel}. They can now message the assistant — no verification code needed.`, } : {}), }; } // Non-voice approvals: the identity-bound verification session was minted // atomically with the decide; its raw secret arrives via `mintedSession`. const session = ctx.mintedSession; if (!session) { log.error( { event: "resolver_access_request_missing_mint", requestId: request.id, }, "Access request resolver: decide returned no mintedSession for a verify_code outcome", ); return { ok: false, reason: "minted_session_missing" }; } log.info( { event: "resolver_access_request_approved", requestId: request.id, verificationSessionId: session.sessionId, channel, requesterExternalUserId, }, "Access request resolver: minted verification session", ); // Two readers, decided separately. The guardian's copy of the code is // addressed on the channel THEY replied on; the requester's notice on the // channel the request came from. Those can differ (a Telegram guardian // deciding a Discord request), and the old single `if (channelDelivery // Context)` treated them as one, so suppressing a route for either reader // silently suppressed it for both. let requesterNotified = false; let codeDelivered = false; const codeText = guardianVerificationCodeText( requesterLabel, session.secret, ); if (guardianInBandContext) { codeDelivered = true; try { const codePayload: Parameters[1] = { chatId: guardianInBandContext.guardianChatId, text: codeText, assistantId, }; // On Slack shared channels, deliver the verification code as ephemeral // so only the guardian sees the secret — not all channel members. codePayload.audience = audienceForReader( ctx.actor.channel, guardianInBandContext.guardianChatId, ctx.actor.actorExternalUserId, ); await deliverChannelReply( guardianInBandContext.replyCallbackUrl, codePayload, ); } catch (err) { log.error( { err, guardianChatId: guardianInBandContext.guardianChatId }, "Failed to deliver verification code to guardian", ); codeDelivered = false; } // If the guardian approved in a shared channel (not a DM), also send // them a DM with the verification code for better privacy and // discoverability. On Slack, posting to a user ID opens a DM. const guardianUserId = ctx.actor.actorExternalUserId; if ( codeDelivered && ctx.actor.channel === "slack" && guardianUserId && !isSlackDmConversation(guardianInBandContext.guardianChatId) ) { const dmCallbackUrl = stripTurnDestination( guardianInBandContext.replyCallbackUrl, ); try { await deliverChannelReply(dmCallbackUrl, { chatId: guardianUserId, text: codeText, assistantId, }); } catch (err) { // Best-effort: the code was already delivered in the shared channel log.warn( { err, guardianUserId }, "Failed to send guardian DM confirmation with verification code", ); } } } else if (ctx.actor.channel === "vellum") { // A desktop guardian receives the code inline via `guardianReplyText`. codeDelivered = true; } else if (guardianDmUrl && decidedByExternalUserId) { // A channel guardian whose own channel has no in-band route. Without // this they approve and are never given the code they are meant to pass // on, because the inline reply text only reaches a desktop actor. try { await deliverChannelReply(guardianDmUrl, { chatId: decidedByExternalUserId, text: codeText, assistantId, }); codeDelivered = true; } catch (err) { log.error( { err, decidedByExternalUserId }, "Failed to deliver verification code to guardian DM", ); } } // The requester. `requesterInBandContext` is the route back along their own // inbound message; `desktopDeliverUrl` is the callback-less route to them. const requesterReplyUrl = requesterInBandContext?.replyCallbackUrl ?? desktopDeliverUrl; if (requesterReplyUrl && requesterChatId) { if (codeDelivered) { // Where the channel can complete a code handshake in a DM, send the // code straight to the requester. Everywhere else the guardian relays // it and the requester gets a courier notice. const requesterCodeDelivered = channelCanCompleteCodeHandshakeInDm(channel) && requesterExternalUserId ? await deliverVerificationCodeToRequester({ replyCallbackUrl: requesterReplyUrl, requesterExternalUserId, verificationCode: session.secret, assistantId, }) : false; if (requesterCodeDelivered) { requesterNotified = true; } else { try { await deliverChannelReply( requesterReplyUrl, requesterInBandContext ? buildRequesterChannelNotice({ channel, requesterChatId, requesterExternalUserId, text: APPROVED_COURIER_NOTICE, assistantId, }) : { chatId: resolveRequesterDeliveryTarget({ channel, requesterChatId, requesterExternalUserId, }), text: APPROVED_COURIER_NOTICE, assistantId, }, ); requesterNotified = true; } catch (err) { log.error( { err, requesterChatId }, "Failed to notify requester of access request approval", ); } } } else { try { await deliverChannelReply( requesterReplyUrl, requesterInBandContext ? buildRequesterChannelNotice({ channel, requesterChatId, requesterExternalUserId, text: APPROVED_CODE_UNDELIVERED_NOTICE, assistantId, }) : { chatId: resolveRequesterDeliveryTarget({ channel, requesterChatId, requesterExternalUserId, }), text: APPROVED_CODE_UNDELIVERED_NOTICE, assistantId, }, ); } catch (err) { log.error( { err, requesterChatId }, "Failed to notify requester of delivery failure", ); } } } if (codeDelivered) { // Record the verification_sent lifecycle transition for every off-channel // approve. The session is minted and the guardian has the code via // `guardianReplyText` regardless of whether (or how) the requester was // notified — mirroring the on-channel branch, which keys off guardian // receipt rather than requester delivery. Without this, approves on // channels with no deliverable callback (e.g. email) would silently skip // the audit/lifecycle record. emitVerificationSentSignal( { sourceChannel: channel, requesterExternalUserId, requesterChatId, requesterDisplayName, decidedByDisplayName, verificationSessionId: session.sessionId, }, request.sourceConversationId, ); } const verificationReplyText = requesterNotified ? `Access approved for ${requesterLabel}. Give them this verification code: \`${session.secret}\`. The code expires in 10 minutes.` : `Access approved for ${requesterLabel}. Give them this verification code: \`${session.secret}\`. The code expires in 10 minutes. I could not notify them automatically, so please tell them to send the code manually.`; return { ok: true, applied: true, // Desktop actors (vellum channel) receive inline reply text; channel // actors get replies delivered via the channel delivery context. ...(ctx.actor.channel === "vellum" ? { guardianReplyText: verificationReplyText } : {}), }; }, }; /** * Resolves `tool_grant_request` requests — asynchronous grant escalation for * non-guardian channel actors. * * Unlike `tool_approval`, this kind does NOT require a pending interaction in * the session tracker. The request represents an async escalation: the * requester's tool call was already denied, and the guardian request exists * solely so the guardian can mint a scoped grant. * * On approve: the decision primitive mints the grant (after this resolver * runs). This resolver optionally notifies the requester to retry. * * On reject: optionally notifies the requester that their request was denied. */ const toolGrantRequestResolver: GuardianRequestResolver = { kind: "tool_grant_request", async resolve(ctx: ResolverContext): Promise { const { request, decision, channelDeliveryContext } = ctx; const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? ""; const assistantId = DAEMON_INTERNAL_ASSISTANT_ID; if (decision.action === "reject") { log.info( { event: "resolver_tool_grant_request_denied", requestId: request.id, toolName: request.toolName, }, "Tool grant request resolver: deny", ); if (channelDeliveryContext && requesterChatId) { try { const grantDenialPayload: Parameters[1] = { chatId: requesterChatId, text: `Your request to use "${request.toolName}" has been denied by the guardian.`, assistantId, }; grantDenialPayload.audience = audienceForReader( request.sourceChannel, requesterChatId, request.requesterExternalUserId, ); await deliverChannelReply( channelDeliveryContext.replyCallbackUrl, grantDenialPayload, ); } catch (err) { log.error( { err, requesterChatId }, "Failed to notify requester of tool grant request denial", ); } } return { ok: true, applied: true }; } // On approve: grant minting is handled by the decision primitive after // this resolver runs. This resolver only handles requester notification. log.info( { event: "resolver_tool_grant_request_approved", requestId: request.id, toolName: request.toolName, }, "Tool grant request resolver: approved (grant minting deferred to the decision primitive)", ); // Re-read the guardian request to check whether an inline grant waiter // has already claimed this request. When followupState is // 'inline_wait_active', the requester's original tool call is blocking // on the grant and will resume automatically — sending a "please retry" // notification would be stale and confusing (and could cause duplicate // attempts or one-time-grant denials). // // Staleness guard: the inline_wait_active marker is persisted and can // outlive the actual waiter if the daemon crashes or restarts during // the wait. To avoid permanently suppressing the retry notification, we // treat the marker as stale if the encoded start timestamp is older than // the maximum wait budget plus a 30s buffer. The budget is read from the // same resolver the waiter itself uses, so a config change moves both // together; sizing this off a constant would let it fall below the real // wait and declare a live waiter dead. const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000; const freshRequest = await getGuardianRequestOrNull(request.id); const followupState = freshRequest?.followupState ?? ""; let inlineWaitActive = followupState.startsWith("inline_wait_active"); if (inlineWaitActive && freshRequest) { // The followupState encodes the wall-clock epoch when the inline wait // started (e.g. 'inline_wait_active:1700000000000'). We use this // instead of updatedAt because the decide CAS sets updatedAt = now, // making updatedAt always fresh by the time this resolver runs. const colonIdx = followupState.indexOf(":"); const waitStartMs = colonIdx !== -1 ? Number(followupState.slice(colonIdx + 1)) : NaN; const markerAgeMs = Number.isFinite(waitStartMs) ? Date.now() - waitStartMs : Infinity; // Treat unparseable timestamps as stale for safety. const stalenessThresholdMs = resolveInlineGrantWaitMs() + INLINE_WAIT_STALENESS_BUFFER_MS; if (markerAgeMs > stalenessThresholdMs) { log.warn( { event: "resolver_tool_grant_request_stale_inline_wait", requestId: request.id, toolName: request.toolName, markerAgeMs, stalenessThresholdMs, waitStartMs, }, "inline_wait_active marker is stale (daemon likely crashed during wait) — sending retry notification", ); inlineWaitActive = false; } } if (inlineWaitActive) { log.info( { event: "resolver_tool_grant_request_skip_retry_notification", requestId: request.id, toolName: request.toolName, followupState: freshRequest?.followupState, }, "Skipping requester retry notification — inline grant wait is active and will resume the original invocation", ); } else if (channelDeliveryContext && requesterChatId) { try { const grantApprovalPayload: Parameters[1] = { chatId: requesterChatId, text: `Your request to use "${request.toolName}" has been approved. Please retry your request.`, assistantId, }; grantApprovalPayload.audience = audienceForReader( request.sourceChannel, requesterChatId, request.requesterExternalUserId, ); await deliverChannelReply( channelDeliveryContext.replyCallbackUrl, grantApprovalPayload, ); } catch (err) { log.error( { err, requesterChatId }, "Failed to notify requester of tool grant request approval", ); } } return { ok: true, applied: true, grantMinted: false }; }, }; // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- const resolverRegistry = new Map(); /** Register a resolver for a given request kind. */ function registerResolver(resolver: GuardianRequestResolver): void { resolverRegistry.set(resolver.kind, resolver); } /** Look up the resolver for a given request kind. */ export function getResolver(kind: string): GuardianRequestResolver | undefined { return resolverRegistry.get(kind); } /** Return all registered resolver kinds (for diagnostics). */ export function getRegisteredKinds(): string[] { return Array.from(resolverRegistry.keys()); } // Register built-in resolvers registerResolver(pendingInteractionResolver); registerResolver(pendingQuestionResolver); registerResolver(accessRequestResolver); registerResolver(toolGrantRequestResolver);