/** * Guard tests for guardian request routing invariants. * * These tests verify that the guardian request system maintains * its key architectural invariants: * * 1. All decision paths route through `applyGuardianDecision` * 2. Principal-based authorization is enforced before decisions are applied * 3. Stale/expired/already-resolved decisions are rejected * 4. Code-only messages return clarification (not auto-approve) * 5. Disambiguation with multiple pending requests stays fail-closed * * The tests combine import-verification (ensuring callers reference the * canonical primitive) and unit tests of the router and primitive functions. */ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { beforeEach, describe, expect, mock, test } from "bun:test"; mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true })); // Gateway IPC mock — session lifecycle goes through the gateway session // client; delegate verification_sessions_* methods to the local-service sim // so these tests keep reading/writing the local test DB. mock.module("../ipc/gateway-client.js", () => ({ ipcCallPersistent: async ( method: string, params?: Record, ) => { const { handleVerificationSessionsIpc, isVerificationSessionsIpcMethod } = await import("./helpers/verification-sessions-ipc-sim.js"); if (isVerificationSessionsIpcMethod(method)) { return handleVerificationSessionsIpc(method, params); } return { ok: true }; }, ipcCall: async () => null, })); const _conversationMocks = new Map(); mock.module("../daemon/conversation-registry.js", () => ({ findConversation: (id: string) => _conversationMocks.get(id), })); import { createGuardianGatewaySim } from "./guardian-gateway-sim.js"; const sim = createGuardianGatewaySim(); 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 { getRegisteredKinds, getResolver, } from "../approvals/guardian-request-resolvers.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { processGuardianDecision } from "../runtime/guardian-action-service.js"; import { GUARDIAN_DECISION_ACTIONS } from "../runtime/guardian-decision-types.js"; import { type GuardianReplyContext, routeGuardianReply, } from "../runtime/guardian-reply-router.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; import { parseCallbackData } from "../runtime/routes/channel-route-shared.js"; import { listGuardianDecisionPrompts } from "../runtime/routes/guardian-action-routes.js"; await initializeDb(); function resetTables(): void { getDb().run("DELETE FROM scoped_approval_grants"); sim.reset(); pendingInteractions.clear(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Consistent test principal used across all test actors and requests. */ const TEST_PRINCIPAL_ID = "test-principal-id"; function guardianActor(overrides: Partial = {}): ActorContext { return { actorPrincipalId: TEST_PRINCIPAL_ID, actorExternalUserId: "guardian-1", channel: "telegram", guardianPrincipalId: TEST_PRINCIPAL_ID, ...overrides, }; } function trustedActor(overrides: Partial = {}): ActorContext { return { actorPrincipalId: TEST_PRINCIPAL_ID, actorExternalUserId: undefined, channel: "desktop", guardianPrincipalId: TEST_PRINCIPAL_ID, ...overrides, }; } function replyCtx( overrides: Partial = {}, ): GuardianReplyContext { return { messageText: "", channel: "telegram", actor: guardianActor(), conversationId: "conv-test", ...overrides, }; } function registerPendingToolApprovalInteraction( requestId: string, conversationId: string, toolName: string = "shell", ): ReturnType { const handleConfirmationResponse = mock(() => {}); const _mockSession = { handleConfirmationResponse, ensureActorScopedHistory: async () => {}, } as unknown as import("../daemon/conversation.js").Conversation; _conversationMocks.set(conversationId, _mockSession); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", confirmationDetails: { toolName, input: { command: "echo hello" }, riskLevel: "medium", allowlistOptions: [ { label: "echo hello", description: "echo hello", pattern: "echo hello", }, ], scopeOptions: [ { label: "everywhere", scope: "everywhere", }, ], }, }); return handleConfirmationResponse; } // =========================================================================== // SECTION 1: Import-verification guard tests // // These verify that all known decision entrypoints import from and call // `applyGuardianDecision` rather than inlining decision logic. // =========================================================================== describe("routing invariant: all decision paths reference applyGuardianDecision", () => { const srcRoot = resolve(__dirname, ".."); // The files that constitute decision entrypoints. Each must reference // `applyGuardianDecision` (directly) or `processGuardianDecision` // (shared wrapper that calls applyGuardianDecision internally). const DECISION_ENTRYPOINTS: Array<{ path: string; symbols: string[]; }> = [ // Inbound channel router (Telegram/WhatsApp) { path: "runtime/guardian-reply-router.ts", symbols: ["applyGuardianDecision"], }, // HTTP API route handler (desktop and API clients) — uses processGuardianDecision // which is a shared wrapper around applyGuardianDecision { path: "runtime/routes/guardian-action-routes.ts", symbols: ["processGuardianDecision"], }, // Surface action route handler (Vellum in-app card buttons) — intercepts // apr:* action IDs and routes through processGuardianDecision { path: "runtime/routes/surface-action-routes.ts", symbols: ["processGuardianDecision"], }, // Shared service where processGuardianDecision is defined — must route // through the canonical primitive to complete the chain: // entrypoint → processGuardianDecision → applyGuardianDecision { path: "runtime/guardian-action-service.ts", symbols: ["applyGuardianDecision"], }, ]; for (const { path: relPath, symbols } of DECISION_ENTRYPOINTS) { test(`${relPath} imports ${symbols.join(" or ")}`, () => { const fullPath = join(srcRoot, relPath); const source = readFileSync(fullPath, "utf-8"); const found = symbols.some((s) => source.includes(s)); expect(found).toBe(true); }); } // The inbound message handler and session-process both use routeGuardianReply // which itself calls applyGuardianDecision. Verify they reference // the shared router rather than inlining decision logic. const ROUTER_CONSUMERS = [ "runtime/routes/inbound-message-handler.ts", "daemon/conversation-process.ts", ]; for (const relPath of ROUTER_CONSUMERS) { test(`${relPath} uses routeGuardianReply (shared router)`, () => { const fullPath = join(srcRoot, relPath); const source = readFileSync(fullPath, "utf-8"); expect(source).toContain("routeGuardianReply"); }); } test("daemon/conversation-process.ts no longer references legacy guardian-action interception", () => { const fullPath = join(srcRoot, "daemon/conversation-process.ts"); const source = readFileSync(fullPath, "utf-8"); expect(source).not.toContain("../memory/guardian-action-store.js"); expect(source).not.toContain("getPendingDeliveriesByConversation"); }); test("daemon/conversation-process.ts seeds router hints via the gateway scope read", () => { const fullPath = join(srcRoot, "daemon/conversation-process.ts"); const source = readFileSync(fullPath, "utf-8"); expect(source).toContain("listPendingRequestsByScopeOrEmpty"); }); test("guardian-reply-router routes all decisions through applyGuardianDecision", () => { const fullPath = join(srcRoot, "runtime/guardian-reply-router.ts"); const source = readFileSync(fullPath, "utf-8"); // The router must import and call the canonical decision primitive. expect(source).toContain("applyGuardianDecision"); }); }); // =========================================================================== // SECTION 2: Principal-based authorization invariants // =========================================================================== describe("routing invariant: principal-based authorization enforced before decisions", () => { beforeEach(() => resetTables()); test("mismatching actor principal is rejected by canonical primitive", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ guardianPrincipalId: "wrong-principal" }), }); expect(result.applied).toBe(false); if (result.applied) { return; } expect(result.reason).toBe("identity_mismatch"); // Request must remain pending (no state change) const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("matching principal authorizes desktop actor", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "desktop", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: trustedActor(), }); expect(result.applied).toBe(true); }); test("actor without guardianPrincipalId is rejected", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor({ guardianPrincipalId: undefined }), }); expect(result.applied).toBe(false); if (result.applied) { return; } expect(result.reason).toBe("identity_mismatch"); }); test("principal mismatch on code-only message blocks detail leakage", async () => { sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "ABC123", toolName: "shell", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "ABC123", actor: guardianActor({ guardianPrincipalId: "wrong-principal" }), conversationId: "conv-1", }), ); // Code-only clarification should be returned but must NOT reveal tool details expect(result.consumed).toBe(true); expect(result.type).toBe("code_only_clarification"); expect(result.replyText).toBe("Request not found."); expect(result.decisionApplied).toBe(false); }); }); // =========================================================================== // SECTION 3: Stale / expired / already-resolved rejection // =========================================================================== describe("routing invariant: stale/expired/already-resolved decisions rejected", () => { beforeEach(() => resetTables()); test("expired request is rejected by canonical primitive", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() - 10_000, // already expired }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), }); expect(result.applied).toBe(false); if (result.applied) { return; } expect(result.reason).toBe("expired"); }); test("already-resolved request is rejected (first-writer-wins)", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); // First decision succeeds const first = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), }); expect(first.applied).toBe(true); // Second decision fails — request is no longer pending const second = await applyGuardianDecision({ requestId: req.id, action: "reject", actorContext: guardianActor(), }); expect(second.applied).toBe(false); if (second.applied) { return; } expect(second.reason).toBe("already_resolved"); // First decision stuck const final = sim.getRequest(req.id); expect(final!.status).toBe("approved"); }); test("nonexistent request returns not_found", async () => { const result = await applyGuardianDecision({ requestId: "nonexistent-id", action: "approve_once", actorContext: guardianActor(), }); expect(result.applied).toBe(false); if (result.applied) { return; } expect(result.reason).toBe("not_found"); }); test("already-resolved request via router returns not_consumed (code lookup filters pending only)", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "ABC123", expiresAt: Date.now() + 60_000, }); // Resolve the request first await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), }); // Attempt to resolve again via router with code prefix. // Since getGuardianRequestByCodeOrNull only returns pending requests, // the resolved request won't be found and the code won't match. const result = await routeGuardianReply( replyCtx({ messageText: "ABC123 approve", conversationId: "conv-1", }), ); // Code lookup filters by status='pending', so the resolved request is invisible. // The router does not match the code and returns not_consumed. expect(result.consumed).toBe(false); }); test("expired request via callback returns stale type", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() - 10_000, // already expired }); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:approve_once`, conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_stale"); expect(result.decisionApplied).toBe(false); }); }); // =========================================================================== // SECTION 4: Code-only messages return clarification, not auto-approve // =========================================================================== describe("routing invariant: code-only messages return clarification", () => { beforeEach(() => resetTables()); test("code-only message returns clarification with request details", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", questionText: "Run shell command: ls -la", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "A1B2C3", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("code_only_clarification"); expect(result.decisionApplied).toBe(false); // Must provide actionable instructions expect(result.replyText).toContain("A1B2C3"); expect(result.replyText).toContain("approve"); expect(result.replyText).toContain("reject"); // The request must remain pending — NOT auto-approved const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("code-only pending_question asks for free-text answer (not approve/reject)", async () => { const req = sim.seedRequest({ kind: "pending_question", sourceType: "voice", sourceChannel: "phone", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, callSessionId: "call-1", pendingQuestionId: "pq-1", requestCode: "A2B3C4", questionText: "What time works best?", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "A2B3C4", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("code_only_clarification"); expect(result.decisionApplied).toBe(false); expect(result.replyText).toContain("A2B3C4"); expect(result.replyText).toContain(""); expect(result.replyText).not.toContain("approve"); expect(result.replyText).not.toContain("reject"); const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("code-only tool-backed pending_question asks for approve/reject decision", async () => { const req = sim.seedRequest({ kind: "pending_question", sourceType: "voice", sourceChannel: "phone", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, callSessionId: "call-2", pendingQuestionId: "pq-2", requestCode: "B2C3D4", questionText: "Allow send_email to bob@example.com?", toolName: "send_email", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "B2C3D4", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("code_only_clarification"); expect(result.decisionApplied).toBe(false); expect(result.replyText).toContain("B2C3D4"); expect(result.replyText).toContain("approve"); expect(result.replyText).toContain("reject"); expect(result.replyText).not.toContain(""); const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("code with decision text does apply the decision", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "A1B2C3 approve", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("code with reject text denies the request", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "D4E5F6", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "D4E5F6 reject", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("denied"); }); }); // =========================================================================== // SECTION 4b: Channel formatting delimiters stripped from code parser // =========================================================================== describe("routing invariant: channel formatting delimiters stripped from code parser", () => { beforeEach(() => resetTables()); test("backtick-wrapped code + approve is parsed correctly", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "`A1B2C3 approve`", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("bold+backtick code + reject is parsed correctly", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "D4E5F6", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "*`D4E5F6 reject`*", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("denied"); }); test("backtick-wrapped code only returns clarification", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", questionText: "Run shell command: ls -la", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "`A1B2C3`", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("code_only_clarification"); expect(result.decisionApplied).toBe(false); const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("asterisk-wrapped code + approve is parsed correctly", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "*A1B2C3 approve*", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("*CODE* action — formatting wraps only the code portion", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A1B2C3", toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "*A1B2C3* approve", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("**CODE** action — double-asterisk formatting wraps only the code portion", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "D4E5F6", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "**D4E5F6** reject", conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("denied"); }); }); // =========================================================================== // SECTION 5: Disambiguation with multiple pending requests stays fail-closed // =========================================================================== describe("routing invariant: disambiguation stays fail-closed", () => { beforeEach(() => resetTables()); test("single hinted pending request accepts explicit plain-text approve without NL generator", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "DDD444", toolName: "shell", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("single hinted pending request does not auto-approve broad acknowledgment text", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "GGG777", toolName: "shell", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "ok, what is this for?", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); expect(result.decisionApplied).toBe(false); const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("explicit blocked scope stays fail-closed for desktop actors", async () => { sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-other", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "HHH888", toolName: "shell", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "approve", actor: trustedActor(), conversationId: "conv-unrelated", pendingScope: { mode: "blocked" }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); expect(result.decisionApplied).toBe(false); }); test("explicit request code still resolves under a blocked scope (cross-chat carve-out)", async () => { // The Slack cross-chat guard blocks identity fallback, but an explicit // request code carries its own target and must still resolve — otherwise a // guardian could not approve-by-code from a chat where no card was delivered. const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-other", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "ABC123", toolName: "shell", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-other", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "ABC123 approve", actor: trustedActor(), conversationId: "conv-unrelated", pendingScope: { mode: "blocked" }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.requestId).toBe(req.id); expect(result.decisionApplied).toBe(true); expect(sim.getRequest(req.id)!.status).toBe("approved"); }); test("multiple hinted pending requests with plain-text approve returns disambiguation", async () => { const req1 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "EEE555", toolName: "shell", expiresAt: Date.now() + 60_000, }); const req2 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "FFF666", toolName: "file_write", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req1.id, req2.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("disambiguation_needed"); expect(result.decisionApplied).toBe(false); expect(result.replyText).toContain("EEE555"); expect(result.replyText).toContain("FFF666"); const r1 = sim.getRequest(req1.id); const r2 = sim.getRequest(req2.id); expect(r1!.status).toBe("pending"); expect(r2!.status).toBe("pending"); }); test("multiple pending requests without target return disambiguation (not auto-resolve)", async () => { // Create two pending requests for the same guardian const req1 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "AAA111", toolName: "shell", expiresAt: Date.now() + 60_000, }); const req2 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "BBB222", toolName: "file_write", expiresAt: Date.now() + 60_000, }); // The NL engine mock: returns a decision but no specific target. // This simulates a guardian saying "yes" without specifying which request. const mockGenerator = async () => ({ disposition: "approve_once" as const, replyText: "Approved!", targetRequestId: undefined, }); const result = await routeGuardianReply( replyCtx({ messageText: "yes approve it", conversationId: "conv-1", pendingScope: { mode: "scoped", requestIds: [req1.id, req2.id] }, approvalConversationGenerator: mockGenerator as any, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("disambiguation_needed"); expect(result.decisionApplied).toBe(false); // Both requests must remain pending — fail-closed const r1 = sim.getRequest(req1.id); const r2 = sim.getRequest(req2.id); expect(r1!.status).toBe("pending"); expect(r2!.status).toBe("pending"); // Disambiguation reply should list request codes expect(result.replyText).toContain("AAA111"); expect(result.replyText).toContain("BBB222"); }); test("disambiguation treats tool-backed pending_question as approval request", async () => { const answerRequest = sim.seedRequest({ kind: "pending_question", sourceType: "voice", sourceChannel: "phone", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, callSessionId: "call-answer", pendingQuestionId: "pq-answer", requestCode: "ABC123", questionText: "What time works best?", expiresAt: Date.now() + 60_000, }); const approvalRequest = sim.seedRequest({ kind: "pending_question", sourceType: "voice", sourceChannel: "phone", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, callSessionId: "call-approval", pendingQuestionId: "pq-approval", requestCode: "DEF456", questionText: "Allow send_email to bob@example.com?", toolName: "send_email", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [answerRequest.id, approvalRequest.id], }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("disambiguation_needed"); expect(result.decisionApplied).toBe(false); expect(result.replyText).toContain("ABC123"); expect(result.replyText).toContain("DEF456"); expect(result.replyText).toContain("send_email"); expect(result.replyText).toContain( 'For questions: reply "ABC123 ".', ); expect(result.replyText).toContain( 'For approvals: reply "DEF456 approve" or "DEF456 reject".', ); }); test("single pending request does not need disambiguation", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "CCC333", toolName: "shell", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); // NL engine returns a decision without specifying target — but only one // request is pending, so it should be resolved without disambiguation. const mockGenerator = async () => ({ disposition: "approve_once" as const, replyText: "Approved!", targetRequestId: undefined, }); const result = await routeGuardianReply( replyCtx({ messageText: "yes", conversationId: "conv-1", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: mockGenerator as any, }), ); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test('single pending request accepts "go for it" as deterministic approval', async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "shell", requestCode: "GO1234", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "go for it", conversationId: "conv-1", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); expect(result.type).toBe("canonical_decision_applied"); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("code-based routing is constrained to caller-provided scope", async () => { const inScope = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "111AAA", toolName: "shell", expiresAt: Date.now() + 60_000, }); const outOfScope = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-2", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "222BBB", toolName: "shell", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(inScope.id, "conv-1", "shell"); registerPendingToolApprovalInteraction(outOfScope.id, "conv-2", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "222BBB approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [inScope.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); expect(result.decisionApplied).toBe(false); const inScopeAfter = sim.getRequest(inScope.id); const outOfScopeAfter = sim.getRequest(outOfScope.id); expect(inScopeAfter!.status).toBe("pending"); expect(outOfScopeAfter!.status).toBe("pending"); }); }); // =========================================================================== // SECTION 6: Resolver registry integrity // =========================================================================== describe("routing invariant: resolver registry covers all built-in kinds", () => { test("tool_approval resolver is registered", () => { const resolver = getResolver("tool_approval"); expect(resolver).toBeDefined(); expect(resolver!.kind).toBe("tool_approval"); }); test("pending_question resolver is registered", () => { const resolver = getResolver("pending_question"); expect(resolver).toBeDefined(); expect(resolver!.kind).toBe("pending_question"); }); test("unknown kind returns undefined (no default fallback)", () => { expect(getResolver("nonexistent_kind")).toBeUndefined(); }); test("registered kinds include at least tool_approval and pending_question", () => { const kinds = getRegisteredKinds(); expect(kinds).toContain("tool_approval"); expect(kinds).toContain("pending_question"); }); }); // =========================================================================== // SECTION 7: valid action invariant // =========================================================================== describe("routing invariant: only approve_once and reject are valid actions", () => { beforeEach(() => resetTables()); test("approve_once is accepted by canonical primitive", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); const result = await applyGuardianDecision({ requestId: req.id, action: "approve_once", actorContext: guardianActor(), }); expect(result.applied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("approve_always is rejected as an invalid action", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); const result = await applyGuardianDecision({ requestId: req.id, // @ts-expect-error - approve_always is no longer a valid action action: "approve_always", actorContext: guardianActor(), }); expect(result.applied).toBe(false); if (!result.applied) { expect(result.reason).toBe("invalid_action"); } }); }); // =========================================================================== // SECTION 8: Callback routing uses applyGuardianDecision // =========================================================================== describe("routing invariant: callback buttons route through canonical primitive", () => { beforeEach(() => resetTables()); test("valid callback data applies decision via canonical primitive", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "shell", inputDigest: "sha256:abc", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:approve_once`, conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("callback with reject action denies the request", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:reject`, conversationId: "conv-1", }), ); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("denied"); }); test("callback targeting different conversation is still processed (conversationId scoping removed for cross-channel)", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-other", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-other", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:approve_once`, conversationId: "conv-1", // different conversation — no longer rejected }), ); // Should be consumed — conversationId scoping was removed because in // cross-channel flows the guardian's conversation differs from the // requester's. Principal validation in the canonical decision primitive // is the correct security boundary. expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); // Request should be approved const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); }); // =========================================================================== // SECTION 8b: route-owned (directResolve) confirmations resolve via the // channel/guardian-action path too. // // The ACP spawn/steer approval gate (acp-routes.ts, ATL-822) registers // pending confirmations with a `directResolve` callback rather than a // Conversation.prompter. The tool_approval resolver must fire that callback // directly; otherwise a guardian approval submitted over a channel never // reaches the waiting route and the spawn/resume hangs until timeout. // =========================================================================== describe("routing invariant: directResolve interactions resolve via guardian decisions", () => { beforeEach(() => resetTables()); function registerDirectResolveInteraction( requestId: string, conversationId: string, ): string[] { const decisions: string[] = []; pendingInteractions.register(requestId, { conversationId, kind: "confirmation", confirmationDetails: { toolName: "acp_spawn", input: { agent: "claude", task: "t", cwd: "/work" }, riskLevel: "high", executionTarget: "host", allowlistOptions: [], scopeOptions: [], persistentDecisionsAllowed: false, }, directResolve: (d) => decisions.push(d), }); return decisions; } test("channel approval fires directResolve('allow') with no live Conversation", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-acp", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "acp_spawn", expiresAt: Date.now() + 60_000, }); // Deliberately no _conversationMocks entry: the prompter path would have // failed with conversation_not_found, proving directResolve is what runs. const decisions = registerDirectResolveInteraction(req.id, "conv-acp"); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:approve_once`, conversationId: "conv-acp", }), ); expect(result.decisionApplied).toBe(true); expect(decisions).toEqual(["allow"]); expect(pendingInteractions.get(req.id)).toBeUndefined(); expect(sim.getRequest(req.id)!.status).toBe("approved"); }); test("channel rejection fires directResolve('deny')", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-acp", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "acp_spawn", expiresAt: Date.now() + 60_000, }); const decisions = registerDirectResolveInteraction(req.id, "conv-acp"); const result = await routeGuardianReply( replyCtx({ messageText: "", callbackData: `apr:${req.id}:reject`, conversationId: "conv-acp", }), ); expect(result.decisionApplied).toBe(true); expect(decisions).toEqual(["deny"]); expect(pendingInteractions.get(req.id)).toBeUndefined(); expect(sim.getRequest(req.id)!.status).toBe("denied"); }); }); // =========================================================================== // SECTION 9: Destination hints do not bypass principal binding for tool_approval // =========================================================================== describe("routing invariant: destination hints do not bypass tool_approval principal binding", () => { beforeEach(() => resetTables()); test("explicit scope still fails closed when guardianPrincipalId does not match", async () => { // Voice-originated tool approval with a different principal than the actor. const req = sim.seedRequest({ kind: "tool_approval", sourceType: "voice", sourceChannel: "twilio", sourceConversationId: "conv-voice-1", toolName: "shell", requestCode: "NL1234", guardianPrincipalId: "request-principal", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-voice-1", "shell"); // The channel inbound router would compute the pending scope from // delivery-scoped lookup and pass it here. Simulate that. const result = await routeGuardianReply( replyCtx({ messageText: "approve", channel: "telegram", actor: guardianActor({ guardianPrincipalId: "different-principal" }), conversationId: "conv-guardian-chat", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_stale"); expect(result.decisionApplied).toBe(false); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("pending"); }); test("without destination hints, unbound principal means no pending requests found", async () => { // Voice-originated request: different principal const req = sim.seedRequest({ kind: "tool_approval", sourceType: "voice", sourceChannel: "twilio", sourceConversationId: "conv-voice-2", toolName: "shell", requestCode: "NL5678", guardianPrincipalId: "voice-principal", expiresAt: Date.now() + 60_000, }); // No pendingScope passed — identity-based fallback uses // actor.actorExternalUserId which does not match any request's // guardianExternalUserId (since it's null). const result = await routeGuardianReply( replyCtx({ messageText: "approve", channel: "telegram", actor: guardianActor({ actorExternalUserId: "guardian-tg-user" }), conversationId: "conv-guardian-chat", // pendingScope omitted — no delivery hints approvalConversationGenerator: undefined, }), ); // Identity-based lookup finds nothing because the request has no // guardianExternalUserId, so the router returns not_consumed. expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); }); // =========================================================================== // SECTION 10: Invite handoff bypass for access requests // =========================================================================== describe("routing invariant: invite handoff bypass for access requests", () => { beforeEach(() => resetTables()); test('pending access_request + message "open invite flow" returns not_consumed with skipApprovalInterception', async () => { const req = sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-access-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "INV001", toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "open invite flow", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); expect(result.decisionApplied).toBe(false); expect(result.skipApprovalInterception).toBe(true); // Request remains pending — not resolved by the handoff const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("invite handoff is case-insensitive and punctuation-trimmed", async () => { const req = sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, expiresAt: Date.now() + 60_000, }); for (const phrase of [ "Open Invite Flow", "OPEN INVITE FLOW", "open invite flow.", "Open invite flow!", ]) { const result = await routeGuardianReply( replyCtx({ messageText: phrase, conversationId: "conv-test", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); } }); test("invite handoff does NOT bypass for non-access-request kinds", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "TAP001", toolName: "shell", expiresAt: Date.now() + 60_000, }); await routeGuardianReply( replyCtx({ messageText: "open invite flow", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); // Should NOT return not_consumed via the invite handoff path. // Without NL generator and no explicit approve/reject, it falls through // to not_consumed anyway, but the key invariant is the request remains pending. const unchanged = sim.getRequest(req.id); expect(unchanged!.status).toBe("pending"); }); test("explicit approve/reject messages still consume with pending access_request", async () => { const req = sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-access-2", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "A00B01", toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); // Code-based approve should still work (request code must be valid hex: [A-F0-9]{6}) const result = await routeGuardianReply( replyCtx({ messageText: "A00B01 approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("desktop access-request approval returns a verification code reply", async () => { const req = sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-access-desktop", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "C0D3A5", toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const result = await routeGuardianReply({ messageText: "C0D3A5 approve", channel: "vellum", actor: trustedActor({ channel: "vellum" }), conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); expect(result.replyText).toContain("verification code"); expect(result.replyText).toMatch(/\b\d{6}\b/); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("NL decision path preserves resolver verification code reply text", async () => { const req = sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: "conv-access-desktop-nl", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requesterExternalUserId: "requester-1", requesterChatId: "chat-1", requestCode: "A1B2C3", toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const approvalConversationGenerator = async () => ({ disposition: "approve_once" as const, replyText: "Access approved.", targetRequestId: req.id, }); const result = await routeGuardianReply({ messageText: "please approve this request", channel: "vellum", actor: trustedActor({ channel: "vellum" }), conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: approvalConversationGenerator as any, }); expect(result.consumed).toBe(true); expect(result.decisionApplied).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.replyText).toContain("verification code"); expect(result.replyText).toMatch(/\b\d{6}\b/); expect(result.replyText).not.toBe("Access approved."); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); }); // =========================================================================== // SECTION 11: Expired requests are excluded from routing // =========================================================================== describe("routing invariant: expired requests are excluded from pending discovery", () => { beforeEach(() => resetTables()); test("expired request with hinted IDs is excluded from disambiguation", async () => { const expired = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "EXP001", toolName: "shell", expiresAt: Date.now() - 10_000, }); const active = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "ACT001", toolName: "file_write", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(active.id, "conv-1", "file_write"); // Both IDs are hinted but only the active one should be considered const result = await routeGuardianReply( replyCtx({ messageText: "approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [expired.id, active.id] }, approvalConversationGenerator: undefined, }), ); // Single active request — should apply directly, no disambiguation expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolvedActive = sim.getRequest(active.id); expect(resolvedActive!.status).toBe("approved"); // Expired request untouched const resolvedExpired = sim.getRequest(expired.id); expect(resolvedExpired!.status).toBe("pending"); }); test("backtick-wrapped plain-text approve is normalized and applied", async () => { const req = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "FMT001", toolName: "shell", expiresAt: Date.now() + 60_000, }); registerPendingToolApprovalInteraction(req.id, "conv-1", "shell"); const result = await routeGuardianReply( replyCtx({ messageText: "`approve`", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [req.id] }, approvalConversationGenerator: undefined, }), ); expect(result.consumed).toBe(true); expect(result.type).toBe("canonical_decision_applied"); expect(result.decisionApplied).toBe(true); const resolved = sim.getRequest(req.id); expect(resolved!.status).toBe("approved"); }); test("all expired hinted requests means no pending found — not consumed", async () => { const expired1 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "EXP002", toolName: "shell", expiresAt: Date.now() - 10_000, }); const expired2 = sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: "conv-1", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, requestCode: "EXP003", toolName: "file_write", expiresAt: Date.now() - 5_000, }); const result = await routeGuardianReply( replyCtx({ messageText: "approve", conversationId: "conv-guardian-conversation", pendingScope: { mode: "scoped", requestIds: [expired1.id, expired2.id], }, approvalConversationGenerator: undefined, }), ); // No active pending requests — falls through expect(result.consumed).toBe(false); expect(result.type).toBe("not_consumed"); expect(result.decisionApplied).toBe(false); }); }); // =========================================================================== // SECTION 12: Kind-specific action sets in prompt mapping // =========================================================================== describe("routing invariant: kind-specific action sets in prompt mapping", () => { beforeEach(() => { resetTables(); }); test("non-tool-approval action set is approve_once + reject only", () => { const actions = [ GUARDIAN_DECISION_ACTIONS.approve_once, GUARDIAN_DECISION_ACTIONS.reject, ]; expect(actions).toHaveLength(2); expect(actions[0].action).toBe("approve_once"); expect(actions[1].action).toBe("reject"); }); test("source-code invariant: guardian-action-routes.ts contains kind guard", () => { const srcRoot = resolve(__dirname, ".."); const fullPath = join(srcRoot, "runtime/routes/guardian-action-routes.ts"); const source = readFileSync(fullPath, "utf-8"); expect(source).toContain('req.kind === "access_request"'); }); // Integration tests: verify listGuardianDecisionPrompts returns correct // action sets for each guardian request kind. test("tool_approval prompt uses approve_once + reject only (one-time decision pattern)", async () => { const convId = "conv-kind-tool-approval"; sim.seedRequest({ kind: "tool_approval", sourceType: "channel", sourceConversationId: convId, guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "shell", expiresAt: Date.now() + 60_000, }); const prompts = await listGuardianDecisionPrompts({ conversationId: convId, }); expect(prompts).toHaveLength(1); expect(prompts[0].actions.map((a) => a.action)).toEqual([ "approve_once", "reject", ]); }); test("pending_question prompt has approve_once + reject only (no temporal actions)", async () => { const convId = "conv-kind-pending-question"; sim.seedRequest({ kind: "pending_question", sourceType: "voice", sourceChannel: "phone", sourceConversationId: convId, guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, callSessionId: "call-pq", pendingQuestionId: "pq-kind-test", questionText: "What time works best?", expiresAt: Date.now() + 60_000, }); const prompts = await listGuardianDecisionPrompts({ conversationId: convId, }); expect(prompts).toHaveLength(1); const actionIds = prompts[0].actions.map((a) => a.action); expect(actionIds).toEqual(["approve_once", "reject"]); expect(actionIds).not.toContain("approve_10m"); expect(actionIds).not.toContain("approve_conversation"); }); test("access_request prompt has approve_once + reject only (no temporal actions)", async () => { const convId = "conv-kind-access-request"; sim.seedRequest({ kind: "access_request", sourceType: "channel", sourceChannel: "telegram", sourceConversationId: convId, guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const prompts = await listGuardianDecisionPrompts({ conversationId: convId, }); expect(prompts).toHaveLength(1); const actionIds = prompts[0].actions.map((a) => a.action); expect(actionIds).toEqual(["approve_once", "reject"]); expect(actionIds).not.toContain("approve_10m"); expect(actionIds).not.toContain("approve_conversation"); }); test("tool_grant_request prompt has approve_once + reject only (no temporal actions)", async () => { const convId = "conv-kind-tool-grant-request"; sim.seedRequest({ kind: "tool_grant_request", sourceType: "channel", sourceConversationId: convId, guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "file_write", expiresAt: Date.now() + 60_000, }); const prompts = await listGuardianDecisionPrompts({ conversationId: convId, }); expect(prompts).toHaveLength(1); const actionIds = prompts[0].actions.map((a) => a.action); expect(actionIds).toEqual(["approve_once", "reject"]); expect(actionIds).not.toContain("approve_10m"); expect(actionIds).not.toContain("approve_conversation"); }); }); // =========================================================================== // SECTION 11: Surface action → access request approval routing // =========================================================================== describe("routing invariant: surface action apr:* buttons route through canonical primitive", () => { beforeEach(() => resetTables()); test("parseCallbackData extracts requestId and action from apr: action IDs", () => { const result = parseCallbackData( "apr:access-req-test-slack-U123-1700000000:approve_once", "vellum", ); expect(result).not.toBeNull(); expect(result!.requestId).toBe("access-req-test-slack-U123-1700000000"); expect(result!.action).toBe("approve_once"); expect(result!.source).toBe("vellum_surface"); }); test("parseCallbackData returns vellum_surface source for vellum channel", () => { const result = parseCallbackData("apr:req-1:reject", "vellum"); expect(result).not.toBeNull(); expect(result!.source).toBe("vellum_surface"); }); test("processGuardianDecision approves access_request from vellum surface", async () => { const requestId = `access-req-test-slack-U123-${Date.now()}`; sim.seedRequest({ id: requestId, kind: "access_request", sourceType: "channel", sourceChannel: "slack", sourceConversationId: "conv-surface-test", requesterExternalUserId: "U123", requesterChatId: "D456", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const result = await processGuardianDecision({ requestId, action: "approve_once", channel: "vellum", actorContext: { actorPrincipalId: TEST_PRINCIPAL_ID, guardianPrincipalId: TEST_PRINCIPAL_ID, }, }); expect(result.ok).toBe(true); if (result.ok && "applied" in result) { expect(result.applied).toBe(true); } const resolved = sim.getRequest(requestId); expect(resolved!.status).toBe("approved"); }); test("processGuardianDecision denies access_request from vellum surface", async () => { const requestId = `access-req-test-slack-U789-${Date.now()}`; sim.seedRequest({ id: requestId, kind: "access_request", sourceType: "channel", sourceChannel: "slack", sourceConversationId: "conv-surface-deny", requesterExternalUserId: "U789", requesterChatId: "D012", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const result = await processGuardianDecision({ requestId, action: "reject", channel: "vellum", actorContext: { actorPrincipalId: TEST_PRINCIPAL_ID, guardianPrincipalId: TEST_PRINCIPAL_ID, }, }); expect(result.ok).toBe(true); if (result.ok && "applied" in result) { expect(result.applied).toBe(true); } const resolved = sim.getRequest(requestId); expect(resolved!.status).toBe("denied"); }); test("principal mismatch rejects surface action decision", async () => { const requestId = `access-req-test-slack-U999-${Date.now()}`; sim.seedRequest({ id: requestId, kind: "access_request", sourceType: "channel", sourceChannel: "slack", sourceConversationId: "conv-surface-mismatch", requesterExternalUserId: "U999", requesterChatId: "D345", guardianExternalUserId: "guardian-1", guardianPrincipalId: TEST_PRINCIPAL_ID, toolName: "ingress_access_request", expiresAt: Date.now() + 60_000, }); const result = await processGuardianDecision({ requestId, action: "approve_once", channel: "vellum", actorContext: { actorPrincipalId: "wrong-principal", guardianPrincipalId: "wrong-principal", }, }); expect(result.ok).toBe(true); if (result.ok && "applied" in result) { expect(result.applied).toBe(false); } const req = sim.getRequest(requestId); expect(req!.status).toBe("pending"); }); });