@vellumai/assistant 0.4.13 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -30,39 +30,34 @@ import {
30
30
  import { parseChannelId } from "../channels/types.js";
31
31
  import {
32
32
  getGatewayInternalBaseUrl,
33
- getRuntimeGatewayOriginSecret,
34
33
  hasUngatedHttpAuthDisabled,
35
34
  isHttpAuthDisabled,
36
- } from "../config/env.js";
37
- import type { ServerMessage } from "../daemon/ipc-contract.js";
38
- import { PairingStore } from "../daemon/pairing-store.js";
39
- import {
40
- type Confidence,
41
- getAttentionStateByConversationIds,
42
- recordConversationSeenSignal,
43
- type SignalType,
44
- } from "../memory/conversation-attention-store.js";
45
- import * as conversationStore from "../memory/conversation-store.js";
46
- import * as externalConversationStore from "../memory/external-conversation-store.js";
47
- import {
48
- consumeCallback,
49
- consumeCallbackError,
50
- } from "../security/oauth-callback-registry.js";
51
- import { getLogger } from "../util/logger.js";
52
- import { buildAssistantEvent } from "./assistant-event.js";
53
- import { assistantEventHub } from "./assistant-event-hub.js";
54
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "./assistant-scope.js";
55
- import { sweepFailedEvents } from "./channel-retry-sweep.js";
56
- import { httpError } from "./http-errors.js";
35
+ } from '../config/env.js';
36
+ import type { ServerMessage } from '../daemon/ipc-contract.js';
37
+ import { PairingStore } from '../daemon/pairing-store.js';
38
+ import { type Confidence, getAttentionStateByConversationIds, recordConversationSeenSignal, type SignalType } from '../memory/conversation-attention-store.js';
39
+ import * as conversationStore from '../memory/conversation-store.js';
40
+ import * as externalConversationStore from '../memory/external-conversation-store.js';
41
+ import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
42
+ import { getLogger } from '../util/logger.js';
43
+ import { buildAssistantEvent } from './assistant-event.js';
44
+ import { assistantEventHub } from './assistant-event-hub.js';
45
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
46
+ // Auth
47
+ import { authenticateRequest } from './auth/middleware.js';
48
+ import { enforcePolicy, getPolicy } from './auth/route-policy.js';
49
+ import { verifyToken } from './auth/token-service.js';
50
+ import type { AuthContext } from './auth/types.js';
51
+ import { sweepFailedEvents } from './channel-retry-sweep.js';
52
+ import { httpError } from './http-errors.js';
57
53
  // Middleware
58
54
  import {
59
55
  extractBearerToken,
60
56
  isLoopbackHost,
61
57
  isPrivateNetworkOrigin,
62
58
  isPrivateNetworkPeer,
63
- verifyBearerToken,
64
- } from "./middleware/auth.js";
65
- import { withErrorHandling } from "./middleware/error-handler.js";
59
+ } from './middleware/auth.js';
60
+ import { withErrorHandling } from './middleware/error-handler.js';
66
61
  import {
67
62
  apiRateLimiter,
68
63
  extractClientIp,
@@ -241,6 +236,68 @@ const DEFAULT_HOSTNAME = "127.0.0.1";
241
236
  /** Global hard cap on request body size (50 MB). */
242
237
  const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
243
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // Parameterized endpoint normalization for policy lookup
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Patterns that map parameterized URLs back to their policy registry keys.
245
+ * Order matters: more specific patterns must come before general ones so
246
+ * that e.g. `attachments/{id}/content` matches before `attachments/{id}`.
247
+ */
248
+ const PARAMETERIZED_ROUTE_PATTERNS: Array<{ re: RegExp; policyBase: string }> = [
249
+ // calls/{id}/cancel, calls/{id}/answer, calls/{id}/instruction
250
+ { re: /^calls\/[^/]+\/(cancel|answer|instruction)$/, policyBase: 'calls/$1' },
251
+ // calls/{id} (GET status)
252
+ { re: /^calls\/[^/]+$/, policyBase: 'calls' },
253
+ // contacts/{id}
254
+ { re: /^contacts\/[^/]+$/, policyBase: 'contacts' },
255
+ // ingress/members/{id}/block
256
+ { re: /^ingress\/members\/[^/]+\/block$/, policyBase: 'ingress/members/block' },
257
+ // ingress/members/{id}
258
+ { re: /^ingress\/members\/[^/]+$/, policyBase: 'ingress/members' },
259
+ // ingress/invites/{id}
260
+ { re: /^ingress\/invites\/[^/]+$/, policyBase: 'ingress/invites' },
261
+ // integrations/twilio/sms/compliance/tollfree/{sid}
262
+ { re: /^integrations\/twilio\/sms\/compliance\/tollfree\/[^/]+$/, policyBase: 'integrations/twilio/sms/compliance/tollfree' },
263
+ // attachments/{id}/content
264
+ { re: /^attachments\/[^/]+\/content$/, policyBase: 'attachments/content' },
265
+ // attachments/{id}
266
+ { re: /^attachments\/[^/]+$/, policyBase: 'attachments' },
267
+ // trust-rules/manage/{id}
268
+ { re: /^trust-rules\/manage\/[^/]+$/, policyBase: 'trust-rules/manage' },
269
+ // interfaces/{path}
270
+ { re: /^interfaces\/.+$/, policyBase: 'interfaces' },
271
+ ];
272
+
273
+ /**
274
+ * Strip parameterized segments from an endpoint string so it matches
275
+ * the base route name used in the policy registry.
276
+ *
277
+ * For example, `calls/abc123` becomes `calls`, and
278
+ * `attachments/abc123/content` becomes `attachments/content`.
279
+ *
280
+ * If the raw endpoint already has a direct policy registered (either
281
+ * bare or with a method qualifier), normalization is skipped. This
282
+ * prevents literal sub-routes like `calls/start` from being
283
+ * incorrectly collapsed to `calls`.
284
+ */
285
+ function normalizeEndpointForPolicy(endpoint: string, method: string): string {
286
+ // If the exact endpoint (with or without method) has a direct policy, don't normalize
287
+ if (getPolicy(`${endpoint}:${method}`) || getPolicy(endpoint)) {
288
+ return endpoint;
289
+ }
290
+
291
+ for (const { re, policyBase } of PARAMETERIZED_ROUTE_PATTERNS) {
292
+ const match = endpoint.match(re);
293
+ if (match) {
294
+ // Support capture-group substitution (e.g. calls/$1 -> calls/cancel)
295
+ return policyBase.replace(/\$(\d+)/g, (_, idx) => match[Number(idx)] ?? '');
296
+ }
297
+ }
298
+ return endpoint;
299
+ }
300
+
244
301
  export class RuntimeHttpServer {
245
302
  private server: ReturnType<typeof Bun.serve> | null = null;
246
303
  private port: number;
@@ -260,9 +317,15 @@ export class RuntimeHttpServer {
260
317
  private pairingStore = new PairingStore();
261
318
  private pairingBroadcast?: (msg: ServerMessage) => void;
262
319
  private sendMessageDeps?: SendMessageDeps;
263
- private findSession?: (
264
- sessionId: string,
265
- ) => import("../daemon/session.js").Session | undefined;
320
+ private findSession?: (sessionId: string) =>
321
+ | {
322
+ handleSurfaceAction(
323
+ surfaceId: string,
324
+ actionId: string,
325
+ data?: Record<string, unknown>,
326
+ ): void;
327
+ }
328
+ | undefined;
266
329
 
267
330
  constructor(options: RuntimeHttpServerOptions = {}) {
268
331
  this.port = options.port ?? DEFAULT_PORT;
@@ -528,13 +591,25 @@ export class RuntimeHttpServer {
528
591
  return handlePairingStatus(url, this.pairingContext);
529
592
  }
530
593
 
531
- // Require bearer token when configured
532
- if (!isHttpAuthDisabled() && this.bearerToken) {
533
- const token = extractBearerToken(req);
534
- if (!token || !verifyBearerToken(token, this.bearerToken)) {
535
- return httpError("UNAUTHORIZED", "Unauthorized", 401);
536
- }
594
+ // Guardian bootstrap and refresh endpoints — before JWT auth because
595
+ // bootstrap is the first endpoint called to obtain a JWT, and refresh
596
+ // needs to work when the access token is expired. Bootstrap has its
597
+ // own loopback IP validation; refresh is secured by the refresh token
598
+ // in the request body (32 random bytes, hash-only storage).
599
+ if (path === '/v1/integrations/guardian/vellum/bootstrap' && req.method === 'POST') {
600
+ return await handleGuardianBootstrap(req, server);
601
+ }
602
+ if (path === '/v1/integrations/guardian/vellum/refresh' && req.method === 'POST') {
603
+ return await handleGuardianRefresh(req);
604
+ }
605
+
606
+ // JWT bearer authentication — replaces the old shared-secret comparison.
607
+ // authenticateRequest handles dev bypass (DISABLE_HTTP_AUTH) internally.
608
+ const authResult = authenticateRequest(req);
609
+ if (!authResult.ok) {
610
+ return authResult.response;
537
611
  }
612
+ const authContext = authResult.context;
538
613
 
539
614
  // Per-client-IP rate limiting for /v1/* endpoints. Authenticated requests
540
615
  // get a higher limit; unauthenticated requests get a lower limit to reduce
@@ -551,12 +626,7 @@ export class RuntimeHttpServer {
551
626
  return rateLimitResponse(result);
552
627
  }
553
628
  // Attach rate limit headers to the eventual response
554
- const originalResponse = await this.handleAuthenticatedRequest(
555
- req,
556
- url,
557
- path,
558
- server,
559
- );
629
+ const originalResponse = await this.handleAuthenticatedRequest(req, url, path, server, authContext);
560
630
  const headers = new Headers(originalResponse.headers);
561
631
  for (const [k, v] of Object.entries(rateLimitHeaders(result))) {
562
632
  headers.set(k, v);
@@ -568,20 +638,17 @@ export class RuntimeHttpServer {
568
638
  });
569
639
  }
570
640
 
571
- return this.handleAuthenticatedRequest(req, url, path, server);
641
+ return this.handleAuthenticatedRequest(req, url, path, server, authContext);
572
642
  }
573
643
 
574
644
  /**
575
645
  * Handle requests that have already passed auth and rate limiting.
576
646
  */
577
- private async handleAuthenticatedRequest(
578
- req: Request,
579
- url: URL,
580
- path: string,
581
- server: ReturnType<typeof Bun.serve>,
582
- ): Promise<Response> {
647
+ private async handleAuthenticatedRequest(req: Request, url: URL, path: string, server: ReturnType<typeof Bun.serve>, authContext: AuthContext): Promise<Response> {
583
648
  // Pairing registration (bearer-authenticated)
584
- if (path === "/v1/pairing/register" && req.method === "POST") {
649
+ if (path === '/v1/pairing/register' && req.method === 'POST') {
650
+ const policyDenied = enforcePolicy('pairing/register', authContext);
651
+ if (policyDenied) return policyDenied;
585
652
  return await handlePairingRegister(req, this.pairingContext);
586
653
  }
587
654
 
@@ -600,79 +667,68 @@ export class RuntimeHttpServer {
600
667
  }
601
668
 
602
669
  // Cloud sharing endpoints
603
- if (path === "/v1/apps/share" && req.method === "POST") {
604
- try {
605
- return await handleShareApp(req);
606
- } catch (err) {
607
- log.error({ err }, "Runtime HTTP handler error sharing app");
608
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
670
+ if (path === '/v1/apps/share' && req.method === 'POST') {
671
+ const policyDenied = enforcePolicy('apps/share', authContext);
672
+ if (policyDenied) return policyDenied;
673
+ try { return await handleShareApp(req); } catch (err) {
674
+ log.error({ err }, 'Runtime HTTP handler error sharing app');
675
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
609
676
  }
610
677
  }
611
678
 
612
679
  const sharedTokenMatch = path.match(/^\/v1\/apps\/shared\/([^/]+)$/);
613
680
  if (sharedTokenMatch) {
614
681
  const shareToken = sharedTokenMatch[1];
615
- if (req.method === "GET") {
616
- try {
617
- return handleDownloadSharedApp(shareToken);
618
- } catch (err) {
619
- log.error(
620
- { err, shareToken },
621
- "Runtime HTTP handler error downloading shared app",
622
- );
623
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
682
+ if (req.method === 'GET') {
683
+ const policyDenied = enforcePolicy('apps/shared:GET', authContext);
684
+ if (policyDenied) return policyDenied;
685
+ try { return handleDownloadSharedApp(shareToken); } catch (err) {
686
+ log.error({ err, shareToken }, 'Runtime HTTP handler error downloading shared app');
687
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
624
688
  }
625
689
  }
626
- if (req.method === "DELETE") {
627
- try {
628
- return handleDeleteSharedApp(shareToken);
629
- } catch (err) {
630
- log.error(
631
- { err, shareToken },
632
- "Runtime HTTP handler error deleting shared app",
633
- );
634
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
690
+ if (req.method === 'DELETE') {
691
+ const policyDenied = enforcePolicy('apps/shared:DELETE', authContext);
692
+ if (policyDenied) return policyDenied;
693
+ try { return handleDeleteSharedApp(shareToken); } catch (err) {
694
+ log.error({ err, shareToken }, 'Runtime HTTP handler error deleting shared app');
695
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
635
696
  }
636
697
  }
637
698
  }
638
699
 
639
- const sharedMetadataMatch = path.match(
640
- /^\/v1\/apps\/shared\/([^/]+)\/metadata$/,
641
- );
642
- if (sharedMetadataMatch && req.method === "GET") {
643
- try {
644
- return handleGetSharedAppMetadata(sharedMetadataMatch[1]);
645
- } catch (err) {
646
- log.error(
647
- { err, shareToken: sharedMetadataMatch[1] },
648
- "Runtime HTTP handler error getting shared app metadata",
649
- );
650
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
700
+ const sharedMetadataMatch = path.match(/^\/v1\/apps\/shared\/([^/]+)\/metadata$/);
701
+ if (sharedMetadataMatch && req.method === 'GET') {
702
+ const policyDenied = enforcePolicy('apps/shared/metadata', authContext);
703
+ if (policyDenied) return policyDenied;
704
+ try { return handleGetSharedAppMetadata(sharedMetadataMatch[1]); } catch (err) {
705
+ log.error({ err, shareToken: sharedMetadataMatch[1] }, 'Runtime HTTP handler error getting shared app metadata');
706
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
651
707
  }
652
708
  }
653
709
 
654
710
  // Secret management endpoint
655
- if (path === "/v1/secrets" && req.method === "POST") {
656
- try {
657
- return await handleAddSecret(req);
658
- } catch (err) {
659
- log.error({ err }, "Runtime HTTP handler error adding secret");
660
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
711
+ if (path === '/v1/secrets' && req.method === 'POST') {
712
+ const policyDenied = enforcePolicy('secrets', authContext);
713
+ if (policyDenied) return policyDenied;
714
+ try { return await handleAddSecret(req); } catch (err) {
715
+ log.error({ err }, 'Runtime HTTP handler error adding secret');
716
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
661
717
  }
662
718
  }
663
- if (path === "/v1/secrets" && req.method === "DELETE") {
664
- try {
665
- return await handleDeleteSecret(req);
666
- } catch (err) {
667
- log.error({ err }, "Runtime HTTP handler error deleting secret");
668
- return httpError("INTERNAL_ERROR", "Internal server error", 500);
719
+ if (path === '/v1/secrets' && req.method === 'DELETE') {
720
+ const policyDenied = enforcePolicy('secrets', authContext);
721
+ if (policyDenied) return policyDenied;
722
+ try { return await handleDeleteSecret(req); } catch (err) {
723
+ log.error({ err }, 'Runtime HTTP handler error deleting secret');
724
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
669
725
  }
670
726
  }
671
727
 
672
728
  // Runtime routes: /v1/<endpoint>
673
729
  const routeMatch = path.match(/^\/v1\/(.+)$/);
674
730
  if (routeMatch) {
675
- return this.dispatchEndpoint(routeMatch[1], req, url, server);
731
+ return this.dispatchEndpoint(routeMatch[1], req, url, server, authContext);
676
732
  }
677
733
 
678
734
  return httpError("NOT_FOUND", "Not found", 404);
@@ -693,11 +749,15 @@ export class RuntimeHttpServer {
693
749
  );
694
750
  }
695
751
 
696
- if (!isHttpAuthDisabled() && this.bearerToken) {
752
+ if (!isHttpAuthDisabled()) {
697
753
  const wsUrl = new URL(req.url);
698
- const token = wsUrl.searchParams.get("token");
699
- if (!token || !verifyBearerToken(token, this.bearerToken)) {
700
- return httpError("UNAUTHORIZED", "Unauthorized", 401);
754
+ const token = wsUrl.searchParams.get('token');
755
+ if (!token) {
756
+ return httpError('UNAUTHORIZED', 'Unauthorized', 401);
757
+ }
758
+ const jwtResult = verifyToken(token, 'vellum-daemon');
759
+ if (!jwtResult.ok) {
760
+ return httpError('UNAUTHORIZED', 'Unauthorized', 401);
701
761
  }
702
762
  }
703
763
 
@@ -788,8 +848,23 @@ export class RuntimeHttpServer {
788
848
  req: Request,
789
849
  url: URL,
790
850
  server: ReturnType<typeof Bun.serve>,
851
+ authContext: AuthContext,
791
852
  ): Promise<Response> {
792
853
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
854
+
855
+ // Normalize parameterized endpoints to their base policy key so that
856
+ // routes like `calls/abc123` match the registered key `calls` and
857
+ // `attachments/abc123/content` matches `attachments/content`.
858
+ const normalizedEndpoint = normalizeEndpointForPolicy(endpoint, req.method);
859
+
860
+ // Enforce route-level scope/principal policy before invoking any handler.
861
+ // Try method-specific key first (e.g. "messages:POST"); fall back to the
862
+ // plain endpoint key only when no method-specific policy is registered.
863
+ const methodKey = `${normalizedEndpoint}:${req.method}`;
864
+ const policyKey = getPolicy(methodKey) ? methodKey : normalizedEndpoint;
865
+ const policyDenied = enforcePolicy(policyKey, authContext);
866
+ if (policyDenied) return policyDenied;
867
+
793
868
  return withErrorHandling(endpoint, async () => {
794
869
  if (endpoint === "health" && req.method === "GET") return handleHealth();
795
870
  if (endpoint === "debug" && req.method === "GET") return handleDebug();
@@ -933,28 +1008,20 @@ export class RuntimeHttpServer {
933
1008
  if (endpoint === "search" && req.method === "GET")
934
1009
  return handleSearchConversations(url);
935
1010
 
936
- if (endpoint === "messages" && req.method === "POST") {
937
- return await handleSendMessage(
938
- req,
939
- {
940
- processMessage: this.processMessage,
941
- persistAndProcessMessage: this.persistAndProcessMessage,
942
- sendMessageDeps: this.sendMessageDeps,
943
- approvalConversationGenerator: this.approvalConversationGenerator,
944
- },
945
- server,
946
- );
1011
+ if (endpoint === 'messages' && req.method === 'POST') {
1012
+ return await handleSendMessage(req, {
1013
+ processMessage: this.processMessage,
1014
+ persistAndProcessMessage: this.persistAndProcessMessage,
1015
+ sendMessageDeps: this.sendMessageDeps,
1016
+ approvalConversationGenerator: this.approvalConversationGenerator,
1017
+ }, authContext);
947
1018
  }
948
1019
 
949
1020
  // Standalone approval endpoints — keyed by requestId, orthogonal to message sending
950
- if (endpoint === "confirm" && req.method === "POST")
951
- return await handleConfirm(req, server);
952
- if (endpoint === "secret" && req.method === "POST")
953
- return await handleSecret(req, server);
954
- if (endpoint === "trust-rules" && req.method === "POST")
955
- return await handleTrustRule(req, server);
956
- if (endpoint === "pending-interactions" && req.method === "GET")
957
- return handleListPendingInteractions(url, req, server);
1021
+ if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req, authContext);
1022
+ if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req, authContext);
1023
+ if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req, authContext);
1024
+ if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url, authContext);
958
1025
 
959
1026
  // Trust rule CRUD — standalone management (not approval-flow)
960
1027
  if (endpoint === "trust-rules/manage" && req.method === "GET")
@@ -982,10 +1049,8 @@ export class RuntimeHttpServer {
982
1049
  }
983
1050
 
984
1051
  // Guardian action endpoints — deterministic button-based decisions
985
- if (endpoint === "guardian-actions/pending" && req.method === "GET")
986
- return handleGuardianActionsPending(req, server);
987
- if (endpoint === "guardian-actions/decision" && req.method === "POST")
988
- return await handleGuardianActionDecision(req, server);
1052
+ if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(url, authContext);
1053
+ if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req, authContext);
989
1054
 
990
1055
  // Contacts
991
1056
  if (endpoint === "contacts" && req.method === "GET")
@@ -1080,17 +1145,8 @@ export class RuntimeHttpServer {
1080
1145
  )
1081
1146
  return await handleCancelOutbound(req);
1082
1147
 
1083
- // Guardian vellum channel bootstrap
1084
- if (
1085
- endpoint === "integrations/guardian/vellum/bootstrap" &&
1086
- req.method === "POST"
1087
- )
1088
- return await handleGuardianBootstrap(req, server);
1089
- if (
1090
- endpoint === "integrations/guardian/vellum/refresh" &&
1091
- req.method === "POST"
1092
- )
1093
- return await handleGuardianRefresh(req);
1148
+ // Guardian vellum channel bootstrap and refresh are handled before
1149
+ // JWT auth in routeRequest() — they are not dispatched here.
1094
1150
 
1095
1151
  // Integrations — Twilio config
1096
1152
  if (endpoint === "integrations/twilio/config" && req.method === "GET")
@@ -1187,19 +1243,9 @@ export class RuntimeHttpServer {
1187
1243
  if (endpoint === "channels/conversation" && req.method === "DELETE")
1188
1244
  return await handleDeleteConversation(req, assistantId);
1189
1245
 
1190
- if (endpoint === "channels/inbound" && req.method === "POST") {
1191
- const gatewayOriginSecret = getRuntimeGatewayOriginSecret();
1192
- return await handleChannelInbound(
1193
- req,
1194
- this.processMessage,
1195
- this.bearerToken,
1196
- assistantId,
1197
- gatewayOriginSecret,
1198
- this.approvalCopyGenerator,
1199
- this.approvalConversationGenerator,
1200
- this.guardianActionCopyGenerator,
1201
- this.guardianFollowUpConversationGenerator,
1202
- );
1246
+ if (endpoint === 'channels/inbound' && req.method === 'POST') {
1247
+ // Route policy enforces svc_gateway principal type.
1248
+ return await handleChannelInbound(req, this.processMessage, assistantId, this.approvalCopyGenerator, this.approvalConversationGenerator, this.guardianActionCopyGenerator, this.guardianFollowUpConversationGenerator);
1203
1249
  }
1204
1250
 
1205
1251
  if (endpoint === "channels/delivery-ack" && req.method === "POST")
@@ -1233,15 +1279,10 @@ export class RuntimeHttpServer {
1233
1279
  }
1234
1280
  }
1235
1281
 
1236
- // Internal Twilio forwarding endpoints (gateway -> runtime)
1237
- if (
1238
- endpoint === "internal/twilio/voice-webhook" &&
1239
- req.method === "POST"
1240
- ) {
1241
- const json = (await req.json()) as {
1242
- params: Record<string, string>;
1243
- originalUrl?: string;
1244
- };
1282
+ // Internal Twilio forwarding endpoints (gateway -> runtime).
1283
+ // Route policy enforces svc_gateway principal type.
1284
+ if (endpoint === 'internal/twilio/voice-webhook' && req.method === 'POST') {
1285
+ const json = await req.json() as { params: Record<string, string>; originalUrl?: string };
1245
1286
  const formBody = new URLSearchParams(json.params).toString();
1246
1287
  const reconstructedUrl = json.originalUrl ?? req.url;
1247
1288
  const fakeReq = new Request(reconstructedUrl, {
@@ -1252,8 +1293,8 @@ export class RuntimeHttpServer {
1252
1293
  return await handleVoiceWebhook(fakeReq);
1253
1294
  }
1254
1295
 
1255
- if (endpoint === "internal/twilio/status" && req.method === "POST") {
1256
- const json = (await req.json()) as { params: Record<string, string> };
1296
+ if (endpoint === 'internal/twilio/status' && req.method === 'POST') {
1297
+ const json = await req.json() as { params: Record<string, string> };
1257
1298
  const formBody = new URLSearchParams(json.params).toString();
1258
1299
  const fakeReq = new Request(req.url, {
1259
1300
  method: "POST",
@@ -1263,11 +1304,8 @@ export class RuntimeHttpServer {
1263
1304
  return await handleStatusCallback(fakeReq);
1264
1305
  }
1265
1306
 
1266
- if (
1267
- endpoint === "internal/twilio/connect-action" &&
1268
- req.method === "POST"
1269
- ) {
1270
- const json = (await req.json()) as { params: Record<string, string> };
1307
+ if (endpoint === 'internal/twilio/connect-action' && req.method === 'POST') {
1308
+ const json = await req.json() as { params: Record<string, string> };
1271
1309
  const formBody = new URLSearchParams(json.params).toString();
1272
1310
  const fakeReq = new Request(req.url, {
1273
1311
  method: "POST",
@@ -1277,26 +1315,16 @@ export class RuntimeHttpServer {
1277
1315
  return await handleConnectAction(fakeReq);
1278
1316
  }
1279
1317
 
1280
- if (endpoint === "identity" && req.method === "GET")
1281
- return handleGetIdentity();
1282
- if (endpoint === "brain-graph" && req.method === "GET")
1283
- return handleGetBrainGraph();
1284
- if (endpoint === "brain-graph-ui" && req.method === "GET")
1285
- return handleServeBrainGraphUI(this.bearerToken);
1286
- if (endpoint === "home-base-ui" && req.method === "GET")
1287
- return handleServeHomeBaseUI(this.bearerToken);
1288
- if (endpoint === "events" && req.method === "GET")
1289
- return handleSubscribeAssistantEvents(req, url, { server });
1318
+ if (endpoint === 'identity' && req.method === 'GET') return handleGetIdentity();
1319
+ if (endpoint === 'brain-graph' && req.method === 'GET') return handleGetBrainGraph();
1320
+ if (endpoint === 'brain-graph-ui' && req.method === 'GET') return handleServeBrainGraphUI(this.bearerToken);
1321
+ if (endpoint === 'home-base-ui' && req.method === 'GET') return handleServeHomeBaseUI(this.bearerToken);
1322
+ if (endpoint === 'events' && req.method === 'GET') return handleSubscribeAssistantEvents(req, url, { authContext });
1290
1323
 
1291
1324
  // Internal OAuth callback endpoint (gateway -> runtime)
1292
- if (endpoint === "internal/oauth/callback" && req.method === "POST") {
1293
- const json = (await req.json()) as {
1294
- state: string;
1295
- code?: string;
1296
- error?: string;
1297
- };
1298
- if (!json.state)
1299
- return httpError("BAD_REQUEST", "Missing state parameter", 400);
1325
+ if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
1326
+ const json = await req.json() as { state: string; code?: string; error?: string };
1327
+ if (!json.state) return httpError('BAD_REQUEST', 'Missing state parameter', 400);
1300
1328
  if (json.error) {
1301
1329
  const consumed = consumeCallbackError(json.state, json.error);
1302
1330
  return consumed
@@ -30,6 +30,8 @@ export type ApprovalCopyGenerator = (
30
30
  export type ApprovalConversationDisposition =
31
31
  | "keep_pending"
32
32
  | "approve_once"
33
+ | "approve_10m"
34
+ | "approve_thread"
33
35
  | "approve_always"
34
36
  | "reject";
35
37
 
@@ -184,7 +186,15 @@ export interface RuntimeHttpServerOptions {
184
186
  /** Dependencies for the POST /v1/messages queue-if-busy handler. */
185
187
  sendMessageDeps?: SendMessageDeps;
186
188
  /** Lookup an active session by ID (for surface actions). Returns undefined if not found. */
187
- findSession?: (sessionId: string) => Session | undefined;
189
+ findSession?: (sessionId: string) =>
190
+ | {
191
+ handleSurfaceAction(
192
+ surfaceId: string,
193
+ actionId: string,
194
+ data?: Record<string, unknown>,
195
+ ): void;
196
+ }
197
+ | undefined;
188
198
  }
189
199
 
190
200
  export interface RuntimeAttachmentMetadata {
@@ -12,10 +12,12 @@
12
12
  */
13
13
 
14
14
  import type { ChannelId } from '../channels/types.js';
15
+ import { buildIpcAuthContext } from '../daemon/ipc-handler.js';
15
16
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
16
17
  import { getActiveBinding } from '../memory/guardian-bindings.js';
17
18
  import { getLogger } from '../util/logger.js';
18
19
  import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
20
+ import type { AuthContext } from './auth/types.js';
19
21
  import { resolveGuardianContext } from './guardian-context-resolver.js';
20
22
  import { ensureVellumGuardianBinding } from './guardian-vellum-migration.js';
21
23
 
@@ -78,3 +80,26 @@ export function resolveLocalIpcGuardianContext(
78
80
  // so downstream consumers see the correct channel provenance.
79
81
  return { ...guardianCtx, sourceChannel };
80
82
  }
83
+
84
+ /**
85
+ * Build an AuthContext for a local IPC connection.
86
+ *
87
+ * Produces the same AuthContext shape that HTTP routes receive from JWT
88
+ * verification, using the `ipc_v1` scope profile. The `actorPrincipalId`
89
+ * is populated from the vellum guardian binding when available, enabling
90
+ * downstream code to resolve guardian context using the same
91
+ * `authContext.actorPrincipalId` path as HTTP sessions.
92
+ */
93
+ export function resolveLocalIpcAuthContext(sessionId: string): AuthContext {
94
+ const authContext = buildIpcAuthContext(sessionId);
95
+
96
+ // Enrich with the guardian principal ID when a vellum binding exists,
97
+ // so downstream guardian resolution can use authContext.actorPrincipalId.
98
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
99
+ const binding = getActiveBinding(assistantId, 'vellum');
100
+ if (binding) {
101
+ return { ...authContext, actorPrincipalId: binding.guardianExternalUserId };
102
+ }
103
+
104
+ return authContext;
105
+ }
@@ -18,6 +18,7 @@ export interface ConfirmationDetails {
18
18
  allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
19
19
  scopeOptions: Array<{ label: string; scope: string }>;
20
20
  persistentDecisionsAllowed?: boolean;
21
+ temporaryOptionsAvailable?: Array<'allow_10m' | 'allow_thread'>;
21
22
  }
22
23
 
23
24
  export interface PendingInteraction {