@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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Route policy enforcement for the runtime HTTP server.
3
+ *
4
+ * Each protected endpoint declares the scopes and principal types it
5
+ * requires. `enforcePolicy` checks the AuthContext against these
6
+ * requirements and returns an error Response when access is denied.
7
+ *
8
+ * When auth is bypassed in dev mode, policies are still evaluated for
9
+ * type safety but always allow the request through.
10
+ */
11
+
12
+ import { isHttpAuthDisabled } from '../../config/env.js';
13
+ import { getLogger } from '../../util/logger.js';
14
+ import type { AuthContext, PrincipalType, Scope } from './types.js';
15
+
16
+ const log = getLogger('route-policy');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Policy definition
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface RoutePolicy {
23
+ requiredScopes: Scope[];
24
+ allowedPrincipalTypes: PrincipalType[];
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Policy registry
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const policyRegistry = new Map<string, RoutePolicy>();
32
+
33
+ /**
34
+ * Register a route policy. Called at module load time to populate the
35
+ * registry with all protected endpoint policies.
36
+ */
37
+ export function registerPolicy(endpoint: string, policy: RoutePolicy): void {
38
+ policyRegistry.set(endpoint, policy);
39
+ }
40
+
41
+ /**
42
+ * Look up the policy for an endpoint. Returns undefined for unregistered
43
+ * (unprotected) endpoints.
44
+ */
45
+ export function getPolicy(endpoint: string): RoutePolicy | undefined {
46
+ return policyRegistry.get(endpoint);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Enforcement
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Enforce the route policy for the given endpoint against the AuthContext.
55
+ *
56
+ * Returns an error Response if the request should be denied, or null if
57
+ * the request is allowed to proceed.
58
+ *
59
+ * When auth is bypassed (dev mode), the policy is still checked against
60
+ * the synthetic context for type safety but always returns null (allowed).
61
+ */
62
+ export function enforcePolicy(
63
+ endpoint: string,
64
+ authCtx: AuthContext,
65
+ ): Response | null {
66
+ const policy = policyRegistry.get(endpoint);
67
+ if (!policy) {
68
+ // No policy registered — unprotected endpoint (e.g. health, debug)
69
+ return null;
70
+ }
71
+
72
+ // Dev bypass: log but allow everything through
73
+ if (isHttpAuthDisabled()) {
74
+ return null;
75
+ }
76
+
77
+ // Check principal type
78
+ if (!policy.allowedPrincipalTypes.includes(authCtx.principalType)) {
79
+ log.warn(
80
+ { endpoint, principalType: authCtx.principalType, allowed: policy.allowedPrincipalTypes },
81
+ 'Route policy denied: principal type not allowed',
82
+ );
83
+ return Response.json(
84
+ { error: { code: 'FORBIDDEN', message: 'Principal type not permitted for this endpoint' } },
85
+ { status: 403 },
86
+ );
87
+ }
88
+
89
+ // Check required scopes
90
+ for (const scope of policy.requiredScopes) {
91
+ if (!authCtx.scopes.has(scope)) {
92
+ log.warn(
93
+ { endpoint, missingScope: scope, principalType: authCtx.principalType },
94
+ 'Route policy denied: missing required scope',
95
+ );
96
+ return Response.json(
97
+ { error: { code: 'FORBIDDEN', message: `Missing required scope: ${scope}` } },
98
+ { status: 403 },
99
+ );
100
+ }
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Policy registrations for all protected routes
108
+ // ---------------------------------------------------------------------------
109
+
110
+ // Standard actor endpoints — chat, approvals, settings, etc.
111
+ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
112
+ // Conversation / messaging
113
+ { endpoint: 'messages:GET', scopes: ['chat.read'] },
114
+ { endpoint: 'messages:POST', scopes: ['chat.write'] },
115
+ { endpoint: 'conversations', scopes: ['chat.read'] },
116
+ { endpoint: 'conversations/attention', scopes: ['chat.read'] },
117
+ { endpoint: 'conversations/seen', scopes: ['chat.write'] },
118
+ { endpoint: 'search', scopes: ['chat.read'] },
119
+ { endpoint: 'suggestion', scopes: ['chat.read'] },
120
+
121
+ // Approvals
122
+ { endpoint: 'confirm', scopes: ['approval.write'] },
123
+ { endpoint: 'secret', scopes: ['approval.write'] },
124
+ { endpoint: 'trust-rules', scopes: ['approval.write'] },
125
+ { endpoint: 'pending-interactions', scopes: ['approval.read'] },
126
+
127
+ // Guardian actions
128
+ { endpoint: 'guardian-actions/pending', scopes: ['approval.read'] },
129
+ { endpoint: 'guardian-actions/decision', scopes: ['approval.write'] },
130
+
131
+ // Events (SSE)
132
+ { endpoint: 'events', scopes: ['chat.read'] },
133
+
134
+ // Attachments
135
+ { endpoint: 'attachments:POST', scopes: ['attachments.write'] },
136
+ { endpoint: 'attachments:DELETE', scopes: ['attachments.write'] },
137
+ { endpoint: 'attachments:GET', scopes: ['attachments.read'] },
138
+ { endpoint: 'attachments/content:GET', scopes: ['attachments.read'] },
139
+
140
+ // Calls
141
+ { endpoint: 'calls/start', scopes: ['calls.write'] },
142
+ { endpoint: 'calls:GET', scopes: ['calls.read'] },
143
+ { endpoint: 'calls/cancel', scopes: ['calls.write'] },
144
+ { endpoint: 'calls/answer', scopes: ['calls.write'] },
145
+ { endpoint: 'calls/instruction', scopes: ['calls.write'] },
146
+
147
+ // Settings / integrations / identity
148
+ { endpoint: 'identity', scopes: ['settings.read'] },
149
+ { endpoint: 'brain-graph', scopes: ['settings.read'] },
150
+ { endpoint: 'brain-graph-ui', scopes: ['settings.read'] },
151
+ { endpoint: 'home-base-ui', scopes: ['settings.read'] },
152
+ { endpoint: 'contacts', scopes: ['settings.read'] },
153
+ { endpoint: 'contacts/merge', scopes: ['settings.write'] },
154
+ { endpoint: 'contacts:GET', scopes: ['settings.read'] },
155
+ { endpoint: 'ingress/members', scopes: ['settings.read'] },
156
+ { endpoint: 'ingress/members:POST', scopes: ['settings.write'] },
157
+ { endpoint: 'ingress/members/block', scopes: ['settings.write'] },
158
+ { endpoint: 'ingress/members:DELETE', scopes: ['settings.write'] },
159
+ { endpoint: 'ingress/invites', scopes: ['settings.read'] },
160
+ { endpoint: 'ingress/invites:POST', scopes: ['settings.write'] },
161
+ { endpoint: 'ingress/invites/redeem', scopes: ['settings.write'] },
162
+ { endpoint: 'ingress/invites:DELETE', scopes: ['settings.write'] },
163
+ { endpoint: 'integrations/telegram/config', scopes: ['settings.read'] },
164
+ { endpoint: 'integrations/telegram/config:POST', scopes: ['settings.write'] },
165
+ { endpoint: 'integrations/telegram/config:DELETE', scopes: ['settings.write'] },
166
+ { endpoint: 'integrations/telegram/commands', scopes: ['settings.write'] },
167
+ { endpoint: 'integrations/telegram/setup', scopes: ['settings.write'] },
168
+ { endpoint: 'integrations/slack/channel/config', scopes: ['settings.read'] },
169
+ { endpoint: 'integrations/slack/channel/config:POST', scopes: ['settings.write'] },
170
+ { endpoint: 'integrations/slack/channel/config:DELETE', scopes: ['settings.write'] },
171
+ { endpoint: 'integrations/guardian/challenge', scopes: ['settings.write'] },
172
+ { endpoint: 'integrations/guardian/status', scopes: ['settings.read'] },
173
+ { endpoint: 'integrations/guardian/outbound/start', scopes: ['settings.write'] },
174
+ { endpoint: 'integrations/guardian/outbound/resend', scopes: ['settings.write'] },
175
+ { endpoint: 'integrations/guardian/outbound/cancel', scopes: ['settings.write'] },
176
+ { endpoint: 'integrations/twilio/config', scopes: ['settings.read'] },
177
+ { endpoint: 'integrations/twilio/credentials:POST', scopes: ['settings.write'] },
178
+ { endpoint: 'integrations/twilio/credentials:DELETE', scopes: ['settings.write'] },
179
+ { endpoint: 'integrations/twilio/numbers', scopes: ['settings.read'] },
180
+ { endpoint: 'integrations/twilio/numbers/provision', scopes: ['settings.write'] },
181
+ { endpoint: 'integrations/twilio/numbers/assign', scopes: ['settings.write'] },
182
+ { endpoint: 'integrations/twilio/numbers/release', scopes: ['settings.write'] },
183
+ { endpoint: 'integrations/twilio/sms/compliance', scopes: ['settings.read'] },
184
+ { endpoint: 'integrations/twilio/sms/compliance/tollfree', scopes: ['settings.write'] },
185
+ { endpoint: 'integrations/twilio/sms/compliance/tollfree:PATCH', scopes: ['settings.write'] },
186
+ { endpoint: 'integrations/twilio/sms/compliance/tollfree:DELETE', scopes: ['settings.write'] },
187
+ { endpoint: 'integrations/twilio/sms/test', scopes: ['settings.write'] },
188
+ { endpoint: 'integrations/twilio/sms/doctor', scopes: ['settings.write'] },
189
+
190
+ // Channel readiness
191
+ { endpoint: 'channels/readiness', scopes: ['settings.read'] },
192
+ { endpoint: 'channels/readiness/refresh', scopes: ['settings.write'] },
193
+
194
+ // Dead letters
195
+ { endpoint: 'channels/dead-letters', scopes: ['settings.read'] },
196
+ { endpoint: 'channels/replay', scopes: ['settings.write'] },
197
+
198
+ // Secrets
199
+ { endpoint: 'secrets', scopes: ['settings.write'] },
200
+
201
+ // Pairing (authenticated)
202
+ { endpoint: 'pairing/register', scopes: ['settings.write'] },
203
+
204
+ // Apps
205
+ { endpoint: 'apps/share', scopes: ['settings.write'] },
206
+ { endpoint: 'apps/shared:GET', scopes: ['settings.read'] },
207
+ { endpoint: 'apps/shared:DELETE', scopes: ['settings.write'] },
208
+ { endpoint: 'apps/shared/metadata', scopes: ['settings.read'] },
209
+
210
+ // Debug
211
+ { endpoint: 'debug', scopes: ['settings.read'] },
212
+
213
+ // Browser relay
214
+ { endpoint: 'browser-relay/status', scopes: ['settings.read'] },
215
+ { endpoint: 'browser-relay/command', scopes: ['settings.write'] },
216
+
217
+ // Interfaces
218
+ { endpoint: 'interfaces', scopes: ['settings.read'] },
219
+
220
+ // Trust rule CRUD management
221
+ { endpoint: 'trust-rules/manage:GET', scopes: ['settings.read'] },
222
+ { endpoint: 'trust-rules/manage:POST', scopes: ['settings.write'] },
223
+ { endpoint: 'trust-rules/manage:DELETE', scopes: ['settings.write'] },
224
+ { endpoint: 'trust-rules/manage:PATCH', scopes: ['settings.write'] },
225
+
226
+ // Surface actions
227
+ { endpoint: 'surface-actions', scopes: ['chat.write'] },
228
+
229
+ // Conversation deletion (channel-facing)
230
+ { endpoint: 'channels/conversation:DELETE', scopes: ['chat.write'] },
231
+
232
+ // Delivery ack
233
+ { endpoint: 'channels/delivery-ack', scopes: ['internal.write'] },
234
+ ];
235
+
236
+ for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
237
+ registerPolicy(endpoint, {
238
+ requiredScopes: scopes,
239
+ allowedPrincipalTypes: ['actor', 'svc_gateway', 'ipc'],
240
+ });
241
+ }
242
+
243
+ // Channel inbound: gateway-only
244
+ registerPolicy('channels/inbound', {
245
+ requiredScopes: ['ingress.write'],
246
+ allowedPrincipalTypes: ['svc_gateway'],
247
+ });
248
+
249
+ // Internal forwarding endpoints: gateway-only
250
+ const INTERNAL_ENDPOINTS = [
251
+ 'internal/twilio/voice-webhook',
252
+ 'internal/twilio/status',
253
+ 'internal/twilio/connect-action',
254
+ 'internal/oauth/callback',
255
+ ];
256
+ for (const endpoint of INTERNAL_ENDPOINTS) {
257
+ registerPolicy(endpoint, {
258
+ requiredScopes: ['internal.write'],
259
+ allowedPrincipalTypes: ['svc_gateway'],
260
+ });
261
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Scope profile resolver and scope-check utilities.
3
+ *
4
+ * Each scope profile maps to a fixed set of permission scopes. The
5
+ * mapping is intentionally hard-coded — profile definitions are a
6
+ * policy decision, not a runtime configuration.
7
+ */
8
+
9
+ import type { AuthContext, Scope, ScopeProfile } from './types.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Profile -> scope mapping
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
16
+ actor_client_v1: new Set<Scope>([
17
+ 'chat.read',
18
+ 'chat.write',
19
+ 'approval.read',
20
+ 'approval.write',
21
+ 'settings.read',
22
+ 'settings.write',
23
+ 'attachments.read',
24
+ 'attachments.write',
25
+ 'calls.read',
26
+ 'calls.write',
27
+ 'feature_flags.read',
28
+ 'feature_flags.write',
29
+ ]),
30
+ gateway_ingress_v1: new Set<Scope>([
31
+ 'ingress.write',
32
+ 'internal.write',
33
+ ]),
34
+ gateway_service_v1: new Set<Scope>([
35
+ 'chat.write',
36
+ 'settings.read',
37
+ 'settings.write',
38
+ 'attachments.read',
39
+ 'attachments.write',
40
+ 'internal.write',
41
+ ]),
42
+ ipc_v1: new Set<Scope>([
43
+ 'ipc.all',
44
+ ]),
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public API
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Resolve a scope profile name to its set of granted scopes. */
52
+ export function resolveScopeProfile(profile: ScopeProfile): ReadonlySet<Scope> {
53
+ return PROFILE_SCOPES[profile];
54
+ }
55
+
56
+ /** Check whether the auth context includes a specific scope. */
57
+ export function hasScope(ctx: AuthContext, scope: Scope): boolean {
58
+ return ctx.scopes.has(scope);
59
+ }
60
+
61
+ /** Check whether the auth context includes all of the given scopes. */
62
+ export function hasAllScopes(ctx: AuthContext, ...scopes: Scope[]): boolean {
63
+ return scopes.every((s) => ctx.scopes.has(s));
64
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sub (subject) pattern parser for JWT tokens.
3
+ *
4
+ * The sub claim encodes principal type, assistant scope, and optional
5
+ * actor/session identifiers in a colon-delimited string.
6
+ */
7
+
8
+ import type { PrincipalType } from './types.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Result types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export type ParseSubResult =
15
+ | {
16
+ ok: true;
17
+ principalType: PrincipalType;
18
+ assistantId: string;
19
+ actorPrincipalId?: string;
20
+ sessionId?: string;
21
+ }
22
+ | { ok: false; reason: string };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Parser
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Parse a JWT sub claim into its constituent parts.
30
+ *
31
+ * Supported patterns:
32
+ * actor:<assistantId>:<actorPrincipalId>
33
+ * svc:gateway:<assistantId>
34
+ * ipc:<assistantId>:<sessionId>
35
+ */
36
+ export function parseSub(sub: string): ParseSubResult {
37
+ if (!sub || typeof sub !== 'string') {
38
+ return { ok: false, reason: 'sub is empty or not a string' };
39
+ }
40
+
41
+ const parts = sub.split(':');
42
+
43
+ if (parts[0] === 'actor' && parts.length === 3) {
44
+ const [, assistantId, actorPrincipalId] = parts;
45
+ if (!assistantId || !actorPrincipalId) {
46
+ return { ok: false, reason: 'actor sub has empty assistantId or actorPrincipalId' };
47
+ }
48
+ return { ok: true, principalType: 'actor', assistantId, actorPrincipalId };
49
+ }
50
+
51
+ if (parts[0] === 'svc' && parts[1] === 'gateway' && parts.length === 3) {
52
+ const assistantId = parts[2];
53
+ if (!assistantId) {
54
+ return { ok: false, reason: 'svc:gateway sub has empty assistantId' };
55
+ }
56
+ return { ok: true, principalType: 'svc_gateway', assistantId };
57
+ }
58
+
59
+ if (parts[0] === 'ipc' && parts.length === 3) {
60
+ const [, assistantId, sessionId] = parts;
61
+ if (!assistantId || !sessionId) {
62
+ return { ok: false, reason: 'ipc sub has empty assistantId or sessionId' };
63
+ }
64
+ return { ok: true, principalType: 'ipc', assistantId, sessionId };
65
+ }
66
+
67
+ return { ok: false, reason: `unrecognized sub pattern: ${sub}` };
68
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * JWT token service for the single-header auth system.
3
+ *
4
+ * Mints and verifies standard JWTs (header.payload.signature) using
5
+ * HMAC-SHA256. Owns the signing key lifecycle (load/create/persist).
6
+ */
7
+
8
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+
12
+ import { getLogger } from '../../util/logger.js';
13
+ import { getRootDir } from '../../util/platform.js';
14
+ import { CURRENT_POLICY_EPOCH, isStaleEpoch } from './policy.js';
15
+ import type { ScopeProfile, TokenAudience, TokenClaims } from './types.js';
16
+
17
+ const log = getLogger('token-service');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Signing key management
21
+ // ---------------------------------------------------------------------------
22
+
23
+ let _authSigningKey: Buffer | undefined;
24
+
25
+ /**
26
+ * Path to the persisted signing key file.
27
+ * Stored in the protected directory alongside other sensitive material.
28
+ */
29
+ function getSigningKeyPath(): string {
30
+ return join(getRootDir(), 'protected', 'actor-token-signing-key');
31
+ }
32
+
33
+ /**
34
+ * Load a signing key from disk or generate and persist a new one.
35
+ * Uses atomic-write + chmod 0o600 for safe persistence.
36
+ */
37
+ export function loadOrCreateSigningKey(): Buffer {
38
+ const keyPath = getSigningKeyPath();
39
+
40
+ // Try to load existing key
41
+ if (existsSync(keyPath)) {
42
+ try {
43
+ const raw = readFileSync(keyPath);
44
+ if (raw.length === 32) {
45
+ log.info('Auth signing key loaded from disk');
46
+ return raw;
47
+ }
48
+ log.warn('Signing key file has unexpected length, regenerating');
49
+ } catch (err) {
50
+ log.warn({ err }, 'Failed to read signing key file, regenerating');
51
+ }
52
+ }
53
+
54
+ // Generate and persist a new key
55
+ const newKey = randomBytes(32);
56
+ const dir = dirname(keyPath);
57
+ if (!existsSync(dir)) {
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ const tmpPath = keyPath + '.tmp.' + process.pid;
61
+ writeFileSync(tmpPath, newKey, { mode: 0o600 });
62
+ renameSync(tmpPath, keyPath);
63
+ chmodSync(keyPath, 0o600);
64
+
65
+ log.info('Auth signing key generated and persisted');
66
+ return newKey;
67
+ }
68
+
69
+ function getSigningKey(): Buffer {
70
+ if (!_authSigningKey) {
71
+ throw new Error('Auth signing key not initialized — call initAuthSigningKey() during startup');
72
+ }
73
+ return _authSigningKey;
74
+ }
75
+
76
+ /**
77
+ * Initialize the auth signing key. Called at daemon startup with a key
78
+ * loaded from disk via loadOrCreateSigningKey(), or by tests with a
79
+ * deterministic key.
80
+ */
81
+ export function initAuthSigningKey(key: Buffer): void {
82
+ _authSigningKey = key;
83
+ }
84
+
85
+ /**
86
+ * Check whether the auth signing key has been initialized.
87
+ *
88
+ * Useful for out-of-process contexts (CLI) that may run without
89
+ * daemon startup, where callers need to decide whether they can
90
+ * mint JWTs or must fall back to the legacy shared-secret token.
91
+ */
92
+ export function isSigningKeyInitialized(): boolean {
93
+ return _authSigningKey !== undefined;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Base64url helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function base64urlEncode(data: Buffer | string): string {
101
+ const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
102
+ return buf.toString('base64url');
103
+ }
104
+
105
+ function base64urlDecode(str: string): Buffer {
106
+ return Buffer.from(str, 'base64url');
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Result types
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export type VerifyResult =
114
+ | { ok: true; claims: TokenClaims }
115
+ | { ok: false; reason: string };
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // JWT header — static for HMAC-SHA256
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const JWT_HEADER = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Mint
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Mint a new JWT token with the given parameters.
129
+ *
130
+ * Returns the complete JWT string (header.payload.signature).
131
+ */
132
+ export function mintToken(params: {
133
+ aud: TokenAudience;
134
+ sub: string;
135
+ scope_profile: ScopeProfile;
136
+ policy_epoch: number;
137
+ ttlSeconds: number;
138
+ }): string {
139
+ const now = Math.floor(Date.now() / 1000);
140
+ const claims: TokenClaims = {
141
+ iss: 'vellum-auth',
142
+ aud: params.aud,
143
+ sub: params.sub,
144
+ scope_profile: params.scope_profile,
145
+ exp: now + params.ttlSeconds,
146
+ policy_epoch: params.policy_epoch,
147
+ iat: now,
148
+ jti: randomBytes(16).toString('hex'),
149
+ };
150
+
151
+ const payload = base64urlEncode(JSON.stringify(claims));
152
+ const sigInput = JWT_HEADER + '.' + payload;
153
+ const sig = createHmac('sha256', getSigningKey())
154
+ .update(sigInput)
155
+ .digest();
156
+
157
+ return sigInput + '.' + base64urlEncode(sig);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Verify
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Verify a JWT token's structural integrity, signature, expiration,
166
+ * audience, and policy epoch.
167
+ *
168
+ * Does NOT check revocation — callers must additionally verify the
169
+ * token hash against a revocation store if needed.
170
+ */
171
+ export function verifyToken(token: string, expectedAud: TokenAudience): VerifyResult {
172
+ const parts = token.split('.');
173
+ if (parts.length !== 3) {
174
+ return { ok: false, reason: 'malformed_token: expected 3 dot-separated parts' };
175
+ }
176
+
177
+ const [headerPart, payloadPart, sigPart] = parts;
178
+
179
+ // Recompute HMAC over header.payload
180
+ const sigInput = headerPart + '.' + payloadPart;
181
+ const expectedSig = createHmac('sha256', getSigningKey())
182
+ .update(sigInput)
183
+ .digest();
184
+ const actualSig = base64urlDecode(sigPart);
185
+
186
+ if (expectedSig.length !== actualSig.length) {
187
+ return { ok: false, reason: 'invalid_signature' };
188
+ }
189
+
190
+ if (!timingSafeEqual(expectedSig, actualSig)) {
191
+ return { ok: false, reason: 'invalid_signature' };
192
+ }
193
+
194
+ // Decode and parse claims
195
+ let claims: TokenClaims;
196
+ try {
197
+ const decoded = base64urlDecode(payloadPart).toString('utf-8');
198
+ claims = JSON.parse(decoded) as TokenClaims;
199
+ } catch {
200
+ return { ok: false, reason: 'malformed_claims' };
201
+ }
202
+
203
+ // Audience check
204
+ if (claims.aud !== expectedAud) {
205
+ return { ok: false, reason: `audience_mismatch: expected ${expectedAud}, got ${claims.aud}` };
206
+ }
207
+
208
+ // Expiration check (claims.exp is in seconds)
209
+ const nowSeconds = Math.floor(Date.now() / 1000);
210
+ if (claims.exp <= nowSeconds) {
211
+ return { ok: false, reason: 'token_expired' };
212
+ }
213
+
214
+ // Policy epoch check
215
+ if (isStaleEpoch(claims.policy_epoch)) {
216
+ return { ok: false, reason: 'stale_policy_epoch' };
217
+ }
218
+
219
+ return { ok: true, claims };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Daemon delivery token
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Mint a short-lived JWT for daemon-to-gateway delivery callbacks.
228
+ *
229
+ * Used when the daemon needs to call gateway /deliver/* endpoints. The
230
+ * gateway's deliver-auth middleware validates aud=vellum-daemon, so both
231
+ * sides share the same signing key and audience convention.
232
+ *
233
+ * sub=svc:daemon:self, scope_profile=gateway_service_v1
234
+ */
235
+ export function mintDaemonDeliveryToken(): string {
236
+ return mintToken({
237
+ aud: 'vellum-daemon',
238
+ sub: 'svc:daemon:self',
239
+ scope_profile: 'gateway_service_v1',
240
+ policy_epoch: CURRENT_POLICY_EPOCH,
241
+ ttlSeconds: 60,
242
+ });
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Edge relay token
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Mint a short-lived JWT for relay WebSocket connections through the gateway.
251
+ *
252
+ * The gateway's relay WS handler validates tokens with validateEdgeToken(),
253
+ * which expects aud=vellum-gateway. This is distinct from daemon delivery
254
+ * tokens (aud=vellum-daemon) used for gateway /deliver/* endpoints.
255
+ *
256
+ * sub=svc:daemon:self, scope_profile=gateway_service_v1
257
+ */
258
+ export function mintEdgeRelayToken(): string {
259
+ return mintToken({
260
+ aud: 'vellum-gateway',
261
+ sub: 'svc:daemon:self',
262
+ scope_profile: 'gateway_service_v1',
263
+ policy_epoch: CURRENT_POLICY_EPOCH,
264
+ ttlSeconds: 60,
265
+ });
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Hash
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** SHA-256 hex digest of a raw token string (for revocation store lookups). */
273
+ export function hashToken(token: string): string {
274
+ return createHash('sha256').update(token).digest('hex');
275
+ }