/** * End-to-end integration tests for the trusted-contact inline guardian approval feature. * * Verifies the full integration of M1-M4 milestones: * M1: RoutingState (trust-context-resolver.ts) * M2: Confirmation request guardian bridge (confirmation-request-guardian-bridge.ts) * M3: Pending approval notifier (inbound-message-handler.ts) * M4: Inline grant wait-and-resume (tool-approval-handler.ts) + * staleness guard (guardian-request-resolvers.ts) * * Covered UX flows: * a. Target flow: trusted contact -> guardian-gated action -> pending msg -> guardian approves -> tool executes * b. Prompt-path flow: confirmation_request bridges to guardian notification and resumes * c. No-binding flow: trusted contact without guardian binding fails fast (no dead-end wait) * d. Unknown actor flow: remains fail-closed (no interactive approval) * e. Guardian-only prompt delivery invariant: non-guardian never receives approval prompt UI * f. Timeout/stale flow: guardian decision after prompt timeout produces deterministic outcome */ import { beforeEach, describe, expect, mock, test } from "bun:test"; const testDir = process.env.VELLUM_WORKSPACE_DIR!; // --------------------------------------------------------------------------- // Mocks — must be set before any production imports // --------------------------------------------------------------------------- // Mock notification emission — capture calls const emittedSignals: Array> = []; mock.module("../notifications/emit-signal.js", () => ({ emitNotificationSignal: async (params: Record) => { emittedSignals.push(params); return { signalId: "test-signal", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "telegram", destination: "guardian-chat-1", success: true }, ], }; }, })); // Mock task run rules mock.module("../tasks/ephemeral-permissions.js", () => ({ getTaskRunRules: () => [], })); // Mock tool registry — provide a fake 'bash' tool const fakeTool = { name: "bash", description: "Run a shell command", category: "shell", defaultRiskLevel: "high", input_schema: {}, execute: async () => ({ content: "ok", isError: false }), }; mock.module("../tools/registry.js", () => ({ getTool: (name: string) => (name === "bash" ? fakeTool : undefined), resolveTool: (name: string) => (name === "bash" ? fakeTool : undefined), getAllTools: () => [fakeTool], })); // Mock channel guardian service — configurable per test let mockGuardianBinding: Record | null = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; mock.module("../runtime/channel-verification-service.js", () => ({ getGuardianBinding: (assistantId: string, channel: string) => { if ( assistantId === "self" && channel === "telegram" && mockGuardianBinding ) { return mockGuardianBinding; } return null; }, createOutboundSession: () => ({ sessionId: "test-session", secret: "123456", }), bindSessionIdentity: () => {}, findActiveSession: () => null, getPendingSession: () => null, isGuardian: () => false, resolveBootstrapToken: () => null, updateSessionDelivery: () => {}, updateSessionStatus: () => {}, validateAndConsumeVerification: () => ({ success: false, reason: "no_challenge", }), })); // Gateway session client — the resolver mints verification sessions here now. mock.module("../channels/gateway-verification-sessions.js", () => ({ createOutboundSession: async () => ({ sessionId: "test-session", secret: "123456", challengeHash: "hash", expiresAt: Date.now() + 600_000, ttlSeconds: 600, }), })); // Mock gateway client — capture delivery calls. `failDeliveryWhen` lets a test // simulate a delivery failure for a specific payload (e.g. a DM that can't be // opened) so fallback paths can be exercised. const deliveredReplies: Array<{ url: string; payload: Record; }> = []; let failDeliveryWhen: ((payload: Record) => boolean) | null = null; mock.module("../runtime/gateway-client.js", () => ({ deliverChannelReply: async ( url: string, payload: Record, ) => { if (failDeliveryWhen?.(payload)) { throw new Error("simulated delivery failure"); } deliveredReplies.push({ url, payload }); return { ok: true }; }, })); // Mock pending interactions (channel-approvals) let mockPendingApprovals: Array<{ requestId: string; toolName: string; input: Record; riskLevel: string; }> = []; mock.module("../runtime/channel-approvals.js", () => ({ getApprovalInfoByConversation: () => mockPendingApprovals, getChannelApprovalPrompt: () => null, buildApprovalUIMetadata: () => ({}), handleChannelDecision: () => ({ applied: false }), })); mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true, getGatewayInternalBaseUrl: () => "http://localhost:3000", })); // --------------------------------------------------------------------------- // Production imports (AFTER mocks) // --------------------------------------------------------------------------- // Guardian-request creation, delivery recording, and decisions all go through // the gateway client; the sim serves that whole surface. import { createGuardianGatewaySim } from "./guardian-gateway-sim.js"; const sim = createGuardianGatewaySim(); // The verification secret transits via the atomic decide's mintedSession. sim.state.mintedSecret = "123456"; mock.module("../channels/gateway-guardian-requests.js", () => sim.module); import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js"; import type { ActorContext } from "../approvals/guardian-request-resolvers.js"; import { getResolver } from "../approvals/guardian-request-resolvers.js"; import { getConfig } from "../config/loader.js"; import type { TrustContext } from "../daemon/trust-context-types.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { scopedApprovalGrants } from "../persistence/schema/index.js"; import { bridgeConfirmationRequestToGuardian } from "../runtime/confirmation-request-guardian-bridge.js"; import { resolveRoutingState } from "../runtime/trust-context-resolver.js"; import { resolveInlineGrantWaitMs, TC_GRANT_WAIT_MAX_MS, ToolApprovalHandler, waitForInlineGrant, } from "../tools/tool-approval-handler.js"; import type { ToolContext } from "../tools/types.js"; import { seedContactChannel } from "./helpers/seed-contact-channel.js"; import { setConfig } from "./helpers/set-config.js"; await initializeDb(); function resetTables(): void { const db = getDb(); db.delete(scopedApprovalGrants).run(); db.run("DELETE FROM messages"); db.run("DELETE FROM conversations"); sim.reset(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeToolContext(overrides: Partial = {}): ToolContext { return { workingDir: testDir, conversationId: "conv-1", assistantId: "self", requestId: "req-1", trustClass: "trusted_contact", executionChannel: "telegram", requesterExternalUserId: "requester-1", ...overrides, }; } function guardianActor(overrides: Partial = {}): ActorContext { return { actorPrincipalId: "test-principal-id", actorExternalUserId: "guardian-1", channel: "telegram", guardianPrincipalId: "test-principal-id", ...overrides, }; } function makeTrustedContactTrustContext(): TrustContext { return { sourceChannel: "telegram", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", guardianChatId: "guardian-chat-1", requesterExternalUserId: "requester-1", requesterChatId: "requester-chat-1", requesterIdentifier: "@requester", }; } // =========================================================================== // a. Target flow: trusted contact -> guardian-gated tool -> approve -> execute // =========================================================================== describe("(a) target flow: trusted-contact inline guardian approval end-to-end", () => { beforeEach(() => { resetTables(); emittedSignals.length = 0; deliveredReplies.length = 0; mockGuardianBinding = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; }); test("complete flow: routing state allows interactive + inline grant wait works via waitForInlineGrant", async () => { // Step 1: Verify routing state allows interactive turns for trusted contacts const trustCtx: TrustContext = { sourceChannel: "telegram", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", guardianChatId: "guardian-chat-1", }; const routing = resolveRoutingState(trustCtx); expect(routing.promptWaitingAllowed).toBe(true); expect(routing.guardianRouteResolvable).toBe(true); // Step 2: Verify the inline grant wait primitive works correctly end-to-end. // Create a guardian request (as the escalation path would), then approve. const req = sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:complete-flow", expiresAt: Date.now() + 60_000, }); // Stamp inline_wait_active const waitMarker = "inline_wait_active:" + Date.now(); await sim.module.updateGuardianRequest(req.id, { followupState: waitMarker, }); const approvalPromise = (async () => { await new Promise((r) => setTimeout(r, 80)); await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), }); })(); const waitResult = await waitForInlineGrant( req.id, { toolName: "bash", inputDigest: "sha256:complete-flow", consumingRequestId: "consume-complete", conversationId: "conv-1", requesterExternalUserId: "requester-1", executionChannel: "telegram", }, { maxWaitMs: 2_000, intervalMs: 20 }, ); await approvalPromise; expect(waitResult.outcome).toBe("granted"); if (waitResult.outcome === "granted") { expect(waitResult.grant.id).toBeDefined(); } }); }); // =========================================================================== // b. Prompt-path flow: confirmation_request bridges to guardian notification // =========================================================================== describe("(b) prompt-path flow: confirmation_request bridges to guardian", () => { beforeEach(() => { resetTables(); emittedSignals.length = 0; mockGuardianBinding = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; }); test("trusted-contact confirmation_request emits guardian.question and creates delivery records", async () => { const guardianRequest = sim.seedRequest({ id: `req-bridge-${Date.now()}`, kind: "tool_approval", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-bridge-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", status: "pending", expiresAt: Date.now() + 5 * 60_000, }); const trustContext = makeTrustedContactTrustContext(); const result = await bridgeConfirmationRequestToGuardian({ guardianRequest, trustContext, conversationId: "conv-bridge-1", toolName: "bash", }); expect("bridged" in result && result.bridged).toBe(true); // guardian.question notification was emitted expect(emittedSignals.length).toBeGreaterThan(0); expect(emittedSignals[0].sourceEventName).toBe("guardian.question"); const payload = emittedSignals[0].contextPayload as Record; expect(payload.requestId).toBe(guardianRequest.id); expect(payload.toolName).toBe("bash"); expect(payload.requesterIdentifier).toBe("@requester"); }); test("bridge + tool_grant_request both use guardian.question for unified routing", async () => { // The confirmation_request bridge and tool_grant_request helper both // use 'guardian.question' as the notification signal, ensuring consistent // guardian routing regardless of the approval path. const guardianRequest = sim.seedRequest({ id: `req-unified-${Date.now()}`, kind: "tool_approval", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-unified-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", status: "pending", expiresAt: Date.now() + 5 * 60_000, }); const trustContext = makeTrustedContactTrustContext(); await bridgeConfirmationRequestToGuardian({ guardianRequest, trustContext, conversationId: "conv-unified-1", toolName: "bash", }); // All emitted signals should use guardian.question const eventNames = emittedSignals.map((s) => s.sourceEventName); for (const name of eventNames) { expect(name).toBe("guardian.question"); } }); }); // =========================================================================== // c. No-binding flow: trusted contact fails fast without guardian binding // =========================================================================== describe("(c) no-binding flow: trusted contact fails fast without guardian binding", () => { beforeEach(() => { resetTables(); emittedSignals.length = 0; deliveredReplies.length = 0; mockGuardianBinding = null; // No guardian binding }); test("routing state blocks prompt waiting when no guardian binding exists", () => { const ctx: TrustContext = { sourceChannel: "telegram", trustClass: "trusted_contact", // No guardianExternalUserId — mirrors no binding }; const state = resolveRoutingState(ctx); expect(state.canBeInteractive).toBe(true); expect(state.guardianRouteResolvable).toBe(false); expect(state.promptWaitingAllowed).toBe(false); }); test("bridge skips when no guardian binding exists for channel", async () => { const guardianRequest = sim.seedRequest({ id: `req-nobinding-${Date.now()}`, kind: "tool_approval", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-nobinding", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", status: "pending", expiresAt: Date.now() + 5 * 60_000, }); const trustContext = makeTrustedContactTrustContext(); const result = await bridgeConfirmationRequestToGuardian({ guardianRequest, trustContext, conversationId: "conv-nobinding", toolName: "bash", }); expect("skipped" in result && result.skipped).toBe(true); if ("skipped" in result) { expect(result.reason).toBe("no_guardian_binding"); } expect(emittedSignals.length).toBe(0); }); }); // =========================================================================== // d. Unknown actor flow: remains fail-closed // =========================================================================== describe("(d) unknown actor flow: fail-closed with no interactive approval", () => { const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 }, }); beforeEach(() => { resetTables(); emittedSignals.length = 0; mockGuardianBinding = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; }); test("unknown actors get immediate denial with no escalation or wait", async () => { const toolName = "bash"; const input = { command: "ls" }; const context = makeToolContext({ trustClass: "unknown", executionChannel: "telegram", requesterExternalUserId: "unknown-user", }); const start = Date.now(); const result = await handler.checkPreExecutionGates( toolName, input, context, "high", Date.now(), ); const elapsed = Date.now() - start; expect(result.allowed).toBe(false); if (result.allowed) { return; } // Unknown actors get the verified-identity message expect(result.result.content).toContain("verified channel identity"); // No guardian request created — unknown actors don't escalate const requests = await sim.module.listGuardianRequestsOrEmpty({ kind: "tool_grant_request", status: "pending", }); expect(requests.length).toBe(0); // Near-instant: no inline wait for unknown actors expect(elapsed).toBeLessThan(200); }); test("unknown actors have promptWaitingAllowed=false regardless of guardian route", () => { const withRoute: TrustContext = { sourceChannel: "telegram", trustClass: "unknown", guardianExternalUserId: "guardian-1", }; const withoutRoute: TrustContext = { sourceChannel: "telegram", trustClass: "unknown", }; expect(resolveRoutingState(withRoute).promptWaitingAllowed).toBe(false); expect(resolveRoutingState(withRoute).canBeInteractive).toBe(false); expect(resolveRoutingState(withoutRoute).promptWaitingAllowed).toBe(false); expect(resolveRoutingState(withoutRoute).canBeInteractive).toBe(false); }); test("bridge skips unknown actor sessions entirely", async () => { const guardianRequest = sim.seedRequest({ id: `req-unknown-${Date.now()}`, kind: "tool_approval", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-unknown", requesterExternalUserId: "unknown-user", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", status: "pending", expiresAt: Date.now() + 5 * 60_000, }); const trustContext: TrustContext = { sourceChannel: "telegram", trustClass: "unknown", }; const result = await bridgeConfirmationRequestToGuardian({ guardianRequest, trustContext, conversationId: "conv-unknown", toolName: "bash", }); expect("skipped" in result && result.skipped).toBe(true); if ("skipped" in result) { expect(result.reason).toBe("not_bridgeable_trust_class"); } }); }); // =========================================================================== // e. Guardian-only prompt delivery invariant // =========================================================================== /** * Mirrors the `isBoundGuardianActor` guard from inbound-message-handler.ts. * Uses the same runtime-value shape so TypeScript treats the comparisons as * `string === string` rather than `'literal_a' === 'literal_b'` (which TS * flags as always-false under strict literal narrowing — TS2367/TS2872). */ function checkIsBoundGuardianActor(params: { trustClass: string; guardianExternalUserId: string | undefined; requesterExternalUserId: string; }): boolean { return ( params.trustClass === "guardian" && !!params.guardianExternalUserId && params.requesterExternalUserId === params.guardianExternalUserId ); } describe("(e) guardian-only prompt delivery invariant", () => { beforeEach(() => { deliveredReplies.length = 0; mockPendingApprovals = [ { requestId: "req-prompt-test", toolName: "bash", input: { command: "ls" }, riskLevel: "high", }, ]; }); test("trusted_contact does NOT receive approval prompt UI (notifier only sends waiting message)", async () => { // The startPendingApprovalPromptWatcher in inbound-message-handler.ts // has a guard: isBoundGuardianActor check. Non-guardian actors (including // trusted contacts) get () => {} (noop) for the watcher. Only guardian // actors matching the binding receive the prompt. const result = checkIsBoundGuardianActor({ trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", requesterExternalUserId: "requester-1", }); expect(result).toBe(false); // The prompt watcher would return a noop for trusted contacts }); test("unknown actors do NOT receive approval prompt UI", () => { const result = checkIsBoundGuardianActor({ trustClass: "unknown", guardianExternalUserId: "guardian-1", requesterExternalUserId: "unknown-user", }); expect(result).toBe(false); }); test("guardian actor that matches binding DOES receive approval prompt UI", () => { const result = checkIsBoundGuardianActor({ trustClass: "guardian", guardianExternalUserId: "guardian-1", requesterExternalUserId: "guardian-1", }); expect(result).toBe(true); }); test("guardian actor with identity mismatch does NOT receive approval prompt UI", () => { // After guardian rotation, old guardian identity should not receive prompts const result = checkIsBoundGuardianActor({ trustClass: "guardian", guardianExternalUserId: "new-guardian-2", requesterExternalUserId: "old-guardian-1", }); expect(result).toBe(false); }); }); // =========================================================================== // f. Timeout/stale flow: guardian decision after prompt timeout // =========================================================================== describe("(f) timeout/stale flow: stale guardian decision after inline wait timeout", () => { const _handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 }, }); beforeEach(() => { resetTables(); emittedSignals.length = 0; deliveredReplies.length = 0; mockGuardianBinding = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; }); test("inline wait timeout clears followupState so later approval sends retry notification", async () => { // Test via waitForInlineGrant directly: timeout clears followupState so // a later guardian approval sends the retry notification. const req = sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-1", requesterExternalUserId: "requester-1", requesterChatId: "requester-chat-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:timeout-stale", expiresAt: Date.now() + 60_000, }); // Stamp inline_wait_active (as checkPreExecutionGates would do) await sim.module.updateGuardianRequest(req.id, { followupState: "inline_wait_active:" + Date.now(), }); // Let the inline wait time out (short 100ms budget) const waitResult = await waitForInlineGrant( req.id, { toolName: "bash", inputDigest: "sha256:timeout-stale", consumingRequestId: "consume-timeout", conversationId: "conv-1", requesterExternalUserId: "requester-1", executionChannel: "telegram", }, { maxWaitMs: 100, intervalMs: 20 }, ); expect(waitResult.outcome).toBe("timeout"); // waitForInlineGrant does NOT clear followupState — the caller (checkPreExecutionGates) does. // For this test, manually clear it to simulate what checkPreExecutionGates does after timeout. await sim.module.updateGuardianRequest(req.id, { followupState: null }); // After followupState is cleared, later guardian approval sends retry notification const freshReq = sim.getRequest(req.id); expect(freshReq?.followupState).toBeNull(); const approvalResult = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/reply", guardianChatId: "guardian-chat-1", assistantId: "self", }, }); expect(approvalResult.applied).toBe(true); // The resolver should have sent the retry notification because // followupState was cleared (not inline_wait_active) const retryNotifications = deliveredReplies.filter( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("approved"), ); expect(retryNotifications.length).toBeGreaterThan(0); }); test("inline_wait_active staleness guard: expired marker allows retry notification", async () => { // Create a guardian request with a stale inline_wait_active marker // that simulates a daemon crash during the wait. // Age the marker past the real wait budget, which the resolver's // staleness threshold tracks. Deriving it from the same helper the // waiter uses keeps this case meaningful if the budget changes. const staleTimestamp = Date.now() - resolveInlineGrantWaitMs() - 60_000; const req = sim.seedRequest({ id: `req-stale-${Date.now()}`, kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-stale-1", requesterExternalUserId: "requester-1", requesterChatId: "requester-chat-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:stale", expiresAt: Date.now() + 60_000, }); // Set a stale inline_wait_active marker await sim.module.updateGuardianRequest(req.id, { followupState: `inline_wait_active:${staleTimestamp}`, }); // Verify marker is stale const freshReq = sim.getRequest(req.id); expect(freshReq?.followupState).toContain("inline_wait_active:"); // Guardian approves — the resolver should detect the stale marker // and send the retry notification instead of suppressing it. const approvalResult = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/reply", guardianChatId: "guardian-chat-1", assistantId: "self", }, }); expect(approvalResult.applied).toBe(true); // The retry notification should have been sent (stale marker treated as cleared) const retryNotifications = deliveredReplies.filter( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("approved"), ); expect(retryNotifications.length).toBeGreaterThan(0); }); test("inline_wait_active marker older than the fallback constant but inside the real budget still suppresses retry", async () => { // The staleness threshold must track the wait budget, not the fallback // constant. A marker aged past TC_GRANT_WAIT_MAX_MS + buffer but still // well inside the configured budget belongs to a waiter that is very // much alive: telling the requester to retry would race a call that is // about to resume, and the retry then fails against the one-time grant // the live waiter consumes. const priorTimeouts = getConfig().timeouts; // Seed the budget rather than leaning on the ambient default, so the // window this case probes exists no matter what the suite's config holds. setConfig("timeouts", { ...priorTimeouts, permissionTimeoutSec: 300 }); try { const budgetMs = resolveInlineGrantWaitMs(); // Comfortably past the old 90s threshold, comfortably short of the budget. const markerAgeMs = TC_GRANT_WAIT_MAX_MS + 45_000; const liveTimestamp = Date.now() - markerAgeMs; expect(markerAgeMs).toBeLessThan(budgetMs); const req = sim.seedRequest({ id: `req-live-${Date.now()}`, kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-live-1", requesterExternalUserId: "requester-1", requesterChatId: "requester-chat-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:livewait", expiresAt: Date.now() + 60_000, }); await sim.module.updateGuardianRequest(req.id, { followupState: `inline_wait_active:${liveTimestamp}`, }); deliveredReplies.length = 0; const approvalResult = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/reply", guardianChatId: "guardian-chat-1", assistantId: "self", }, }); expect(approvalResult.applied).toBe(true); const retryNotifications = deliveredReplies.filter( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("Please retry"), ); expect(retryNotifications.length).toBe(0); } finally { setConfig("timeouts", priorTimeouts); } }); test("fresh inline_wait_active marker suppresses retry notification", async () => { // Create a request with a FRESH inline_wait_active marker const freshTimestamp = Date.now(); const req = sim.seedRequest({ id: `req-fresh-${Date.now()}`, kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-fresh-1", requesterExternalUserId: "requester-1", requesterChatId: "requester-chat-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:fresh", expiresAt: Date.now() + 60_000, }); await sim.module.updateGuardianRequest(req.id, { followupState: `inline_wait_active:${freshTimestamp}`, }); // Guardian approves while an active inline waiter is running deliveredReplies.length = 0; const approvalResult = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/reply", guardianChatId: "guardian-chat-1", assistantId: "self", }, }); expect(approvalResult.applied).toBe(true); // The retry notification should NOT have been sent — the inline waiter // is still active and will consume the grant directly. const retryNotifications = deliveredReplies.filter( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("Please retry"), ); expect(retryNotifications.length).toBe(0); }); test("denied inline wait produces explicit denial (no false success)", async () => { // Test via waitForInlineGrant directly: rejection produces "denied" outcome. const req = sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:denied-f", expiresAt: Date.now() + 60_000, }); // Schedule rejection after 80ms const rejectionPromise = (async () => { await new Promise((r) => setTimeout(r, 80)); await applyGuardianDecision({ requestId: req.id, action: "reject", actorContext: guardianActor(), }); })(); const waitResult = await waitForInlineGrant( req.id, { toolName: "bash", inputDigest: "sha256:denied-f", consumingRequestId: "consume-denied-f", conversationId: "conv-1", requesterExternalUserId: "requester-1", executionChannel: "telegram", }, { maxWaitMs: 2_000, intervalMs: 20 }, ); await rejectionPromise; expect(waitResult.outcome).toBe("denied"); }); test("timeout produces explicit timeout outcome (no false success)", async () => { // Test via waitForInlineGrant directly: timeout produces "timeout" outcome. const req = sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:timeout-f", expiresAt: Date.now() + 60_000, }); const waitResult = await waitForInlineGrant( req.id, { toolName: "bash", inputDigest: "sha256:timeout-f", consumingRequestId: "consume-timeout-f", conversationId: "conv-1", requesterExternalUserId: "requester-1", executionChannel: "telegram", }, { maxWaitMs: 100, intervalMs: 20 }, ); expect(waitResult.outcome).toBe("timeout"); }); }); // =========================================================================== // Cross-milestone integration checks // =========================================================================== describe("cross-milestone integration checks", () => { beforeEach(() => { resetTables(); emittedSignals.length = 0; deliveredReplies.length = 0; mockGuardianBinding = { id: "binding-1", assistantId: "self", channel: "telegram", guardianExternalUserId: "guardian-1", guardianDeliveryChatId: "guardian-chat-1", guardianPrincipalId: "test-principal-id", status: "active", }; }); test("M1+M4: routing state interactivity drives inline wait eligibility", async () => { // With guardian binding: interactive + inline wait allowed const withBinding: TrustContext = { sourceChannel: "telegram", trustClass: "trusted_contact", guardianExternalUserId: "guardian-1", }; expect(resolveRoutingState(withBinding).promptWaitingAllowed).toBe(true); // Without guardian binding: not interactive + inline wait should not enter dead-end const withoutBinding: TrustContext = { sourceChannel: "telegram", trustClass: "trusted_contact", }; expect(resolveRoutingState(withoutBinding).promptWaitingAllowed).toBe( false, ); }); test("M2+M4: bridge and tool_grant_request target the same guardian identity", async () => { // Both the confirmation_request bridge (M2) and tool grant request escalation (M4) // use the guardian binding's guardianExternalUserId to route notifications. // Verify this consistency: const guardianRequest = sim.seedRequest({ id: `req-consistency-${Date.now()}`, kind: "tool_approval", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-consistency", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", status: "pending", expiresAt: Date.now() + 5 * 60_000, }); const trustContext = makeTrustedContactTrustContext(); const bridgeResult = await bridgeConfirmationRequestToGuardian({ guardianRequest, trustContext, conversationId: "conv-consistency", toolName: "bash", }); expect("bridged" in bridgeResult && bridgeResult.bridged).toBe(true); // Both the bridge signal and the tool_grant_request signal would target // the same guardian binding (guardian-1) if (emittedSignals.length > 0) { const payload = emittedSignals[0].contextPayload as Record< string, unknown >; expect(payload.requesterExternalUserId).toBe("requester-1"); } }); test("M4: tool_grant_request resolver is correctly registered", () => { const resolver = getResolver("tool_grant_request"); expect(resolver).toBeDefined(); expect(resolver!.kind).toBe("tool_grant_request"); }); test("M1: guardian actors bypass inline wait entirely (self-approve path)", async () => { const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 }, }); const toolName = "bash"; const input = { command: "ls" }; const context = makeToolContext({ trustClass: "guardian", executionChannel: "telegram", requesterExternalUserId: "guardian-1", }); // Guardian actors resolve through the standard permission prompt path, // not the grant escalation path. The tool should be allowed without // going through grant consumption. const result = await handler.checkPreExecutionGates( toolName, input, context, "high", Date.now(), ); // Guardian + no grant check = allowed without grantConsumed // (guardians use the interactive prompt, not the grant system) expect(result.allowed).toBe(true); if (!result.allowed) { return; } expect(result.grantConsumed).toBeUndefined(); }); test("M4: abort signal during inline wait produces aborted outcome", async () => { // Test via waitForInlineGrant directly: abort signal produces "aborted" outcome. const req = sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-1", requesterExternalUserId: "requester-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: "test-principal-id", toolName: "bash", inputDigest: "sha256:abort-m4", expiresAt: Date.now() + 60_000, }); // Stamp inline_wait_active const waitMarker = "inline_wait_active:" + Date.now(); await sim.module.updateGuardianRequest(req.id, { followupState: waitMarker, }); const controller = new AbortController(); // Abort after 100ms setTimeout(() => controller.abort(), 100); const start = Date.now(); const waitResult = await waitForInlineGrant( req.id, { toolName: "bash", inputDigest: "sha256:abort-m4", consumingRequestId: "consume-abort-m4", conversationId: "conv-1", requesterExternalUserId: "requester-1", executionChannel: "telegram", }, { maxWaitMs: 5_000, intervalMs: 20, signal: controller.signal }, ); const elapsed = Date.now() - start; expect(waitResult.outcome).toBe("aborted"); // Should exit promptly after the abort signal expect(elapsed).toBeLessThan(1_000); // Simulate what checkPreExecutionGates does after abort: clear followupState await sim.module.updateGuardianRequest(req.id, { followupState: null }); // After followupState is cleared, a later guardian approval should send retry notification const freshReq = sim.getRequest(req.id); expect(freshReq?.followupState).toBeNull(); }); }); // =========================================================================== // (g) access_request resolver: requester verification-code delivery // // On approval the requester must receive the 6-digit code so the guardian // never has to relay it by hand. On Slack the code is DM'd straight to the // requester (a private path is guaranteed via their user ID); other channels // keep the courier message because a private path to the requester is not // guaranteed there (e.g. a group chat would leak the secret). // =========================================================================== describe("(g) access_request resolver: requester code delivery", () => { const REQUESTER_UID = "U_REQUESTER"; const GUARDIAN_UID = "U_GUARDIAN"; function createAccessRequest(overrides: Record = {}) { return sim.seedRequest({ id: `access-req-${Date.now()}-${Math.random().toString(36).slice(2)}`, kind: "access_request", sourceType: "channel", sourceChannel: "slack", sourceConversationId: "conv-access-slack", requesterExternalUserId: REQUESTER_UID, requesterChatId: "C_SHARED_CHANNEL", guardianExternalUserId: GUARDIAN_UID, guardianPrincipalId: "test-principal-id", toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, ...overrides, }); } beforeEach(() => { resetTables(); deliveredReplies.length = 0; emittedSignals.length = 0; failDeliveryWhen = null; }); test("on-channel Slack approval DMs the verification code to the requester", async () => { const req = createAccessRequest(); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ channel: "slack", actorExternalUserId: GUARDIAN_UID, }), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/deliver/slack?threadTs=111.222", guardianChatId: "C_SHARED_CHANNEL", assistantId: "self", }, }); expect(result.applied).toBe(true); // The requester receives the actual code in their DM (chatId = user ID), // not a "ask the guardian" courier message. const requesterCodeReply = deliveredReplies.find( (r) => r.payload.chatId === REQUESTER_UID && typeof r.payload.text === "string" && (r.payload.text as string).includes("123456"), ); expect(requesterCodeReply).toBeDefined(); expect(requesterCodeReply!.payload.text).toContain( "your access request was approved", ); // The code DM is durable, so it carries no audience: restricting it // would cost durability without hiding it from anyone. expect(requesterCodeReply!.payload.audience).toBeUndefined(); // threadTs (the guardian's channel thread) is stripped for the DM. expect(requesterCodeReply!.url).not.toContain("threadTs"); // No courier "receive from the guardian" message goes to the requester. const courier = deliveredReplies.find( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("receive from the guardian"), ); expect(courier).toBeUndefined(); }); test("desktop-decided approval DMs the code to the Slack requester via the deliver path", async () => { const req = createAccessRequest(); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", // Desktop decision: no channelDeliveryContext, actor on the vellum channel. actorContext: guardianActor({ channel: "vellum", actorExternalUserId: undefined, }), }); expect(result.applied).toBe(true); const requesterCodeReply = deliveredReplies.find( (r) => r.payload.chatId === REQUESTER_UID && typeof r.payload.text === "string" && (r.payload.text as string).includes("123456"), ); expect(requesterCodeReply).toBeDefined(); expect(requesterCodeReply!.url).toContain("/deliver/slack"); expect(requesterCodeReply!.payload.text).toContain( "your access request was approved", ); // The off-channel approve path records the verification_sent lifecycle // signal too — parity with the on-channel path. const verificationSent = emittedSignals.filter( (s) => s.sourceEventName === "ingress.trusted_contact.verification_sent", ); expect(verificationSent.length).toBe(1); }); test("off-channel approval still records verification_sent when the requester DM fails", async () => { const req = createAccessRequest(); // Fail the direct DM and the courier fallback (both target the requester). failDeliveryWhen = (payload) => payload.chatId === REQUESTER_UID; const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ channel: "vellum", actorExternalUserId: undefined, }), }); expect(result.applied).toBe(true); // The guardian still receives the code via the inline reply, and the // lifecycle signal is recorded even though the requester DM failed — the // session was minted and the request was approved. const replyText = result.applied ? result.resolverReplyText : undefined; expect(replyText).toContain("123456"); const verificationSent = emittedSignals.filter( (s) => s.sourceEventName === "ingress.trusted_contact.verification_sent", ); expect(verificationSent.length).toBe(1); }); test("off-channel approval on a channel with no deliverable callback (e.g. email) still records verification_sent", async () => { // `email` has no deliver URL (resolveDeliverCallbackUrlForChannel returns // null), so the requester cannot be auto-notified here. The guardian still // receives the code inline, so the lifecycle transition must be recorded — // the emit must not be gated on requester deliverability. const req = createAccessRequest({ sourceChannel: "email", requesterChatId: "requester@example.com", sourceConversationId: "conv-access-email", }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ channel: "vellum", actorExternalUserId: undefined, }), }); expect(result.applied).toBe(true); // Guardian gets the code inline; no requester delivery is attempted because // there is no deliver callback for the channel. const replyText = result.applied ? result.resolverReplyText : undefined; expect(replyText).toContain("123456"); const requesterDelivery = deliveredReplies.find( (r) => r.payload.chatId === "requester@example.com", ); expect(requesterDelivery).toBeUndefined(); // The audit/lifecycle signal is still recorded for this off-channel approve. const verificationSent = emittedSignals.filter( (s) => s.sourceEventName === "ingress.trusted_contact.verification_sent", ); expect(verificationSent.length).toBe(1); }); test("non-Slack channel keeps the courier message and never delivers the code to the requester chat", async () => { const req = createAccessRequest({ sourceChannel: "telegram", requesterChatId: "requester-chat-1", sourceConversationId: "conv-access-telegram", }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ channel: "telegram", actorExternalUserId: GUARDIAN_UID, }), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/deliver/telegram", guardianChatId: "guardian-chat-1", assistantId: "self", }, }); expect(result.applied).toBe(true); // The requester is told to expect the code from the guardian; the secret is // never delivered to the requester's (possibly group) chat. const requesterReply = deliveredReplies.find( (r) => r.payload.chatId === "requester-chat-1", ); expect(requesterReply).toBeDefined(); expect(requesterReply!.payload.text).toContain("receive from the guardian"); expect(requesterReply!.payload.text).not.toContain("123456"); }); test("Slack shared-channel fallback posts an ephemeral notice to the channel when the DM fails", async () => { const req = createAccessRequest(); // Make the direct DM (to the U... user ID) fail so the courier fallback runs. failDeliveryWhen = (payload) => payload.chatId === REQUESTER_UID; const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ channel: "slack", actorExternalUserId: GUARDIAN_UID, }), channelDeliveryContext: { replyCallbackUrl: "http://localhost:3000/deliver/slack?threadTs=111.222", guardianChatId: "C_SHARED_CHANNEL", assistantId: "self", }, }); expect(result.applied).toBe(true); // The courier notice falls back to an ephemeral message targeting the // originating channel (C...), since chat.postEphemeral needs a channel ID — // not the requester's user ID. const courier = deliveredReplies.find( (r) => typeof r.payload.text === "string" && (r.payload.text as string).includes("receive from the guardian"), ); expect(courier).toBeDefined(); expect(courier!.payload.chatId).toBe("C_SHARED_CHANNEL"); expect(courier!.payload.audience).toEqual({ kind: "oneReader", userId: REQUESTER_UID, }); }); test("guardian-facing reply uses the requester's display name, not the raw ID", async () => { // Seed a contact so the resolver can resolve a display name. seedContactChannel({ sourceChannel: "slack", externalUserId: REQUESTER_UID, displayName: "Alice", status: "unverified", }); const req = createAccessRequest(); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", // Desktop decision → resolver returns guardianReplyText for assertion. actorContext: guardianActor({ channel: "vellum", actorExternalUserId: undefined, }), }); expect(result.applied).toBe(true); const replyText = result.applied ? result.resolverReplyText : undefined; expect(replyText).toContain("Alice"); expect(replyText).not.toContain(REQUESTER_UID); }); });