@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
@@ -1,234 +0,0 @@
1
- /**
2
- * Actor-token mint/verify service.
3
- *
4
- * Mints HMAC-signed actor tokens bound to (assistantId, guardianPrincipalId,
5
- * deviceId, platform). Only the SHA-256 hash of the token is persisted —
6
- * the raw plaintext is returned to the caller once and never stored.
7
- *
8
- * Token format: base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)
9
- */
10
-
11
- import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
12
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
- import { dirname, join } from 'node:path';
14
-
15
- import { getLogger } from '../util/logger.js';
16
- import { getRootDir } from '../util/platform.js';
17
-
18
- const log = getLogger('actor-token-service');
19
-
20
- // ---------------------------------------------------------------------------
21
- // Types
22
- // ---------------------------------------------------------------------------
23
-
24
- export interface ActorTokenClaims {
25
- /** The assistant this token is scoped to. */
26
- assistantId: string;
27
- /** Platform: 'macos' | 'ios' */
28
- platform: string;
29
- /** Opaque device identifier (hashed for storage). */
30
- deviceId: string;
31
- /** The guardian principal this token is bound to. */
32
- guardianPrincipalId: string;
33
- /** Token issuance timestamp (epoch ms). */
34
- iat: number;
35
- /** Token expiration timestamp (epoch ms). Null means non-expiring. */
36
- exp: number | null;
37
- /** Random jti (JWT ID) for uniqueness. */
38
- jti: string;
39
- }
40
-
41
- export interface MintResult {
42
- /** The raw actor token string — returned once, never persisted. */
43
- token: string;
44
- /** SHA-256 hex hash of the token (for storage/lookup). */
45
- tokenHash: string;
46
- /** The decoded claims. */
47
- claims: ActorTokenClaims;
48
- }
49
-
50
- export type VerifyResult =
51
- | { ok: true; claims: ActorTokenClaims }
52
- | { ok: false; reason: string };
53
-
54
- // ---------------------------------------------------------------------------
55
- // Signing key management
56
- // ---------------------------------------------------------------------------
57
-
58
- let signingKey: Buffer | null = null;
59
-
60
- /**
61
- * Path to the persisted signing key file.
62
- * Stored in the protected directory alongside other sensitive material.
63
- */
64
- function getSigningKeyPath(): string {
65
- return join(getRootDir(), 'protected', 'actor-token-signing-key');
66
- }
67
-
68
- /**
69
- * Load a signing key from disk or generate and persist a new one.
70
- * Uses the same atomic-write + chmod 0o600 pattern as approved-devices-store.ts.
71
- */
72
- export function loadOrCreateSigningKey(): Buffer {
73
- const keyPath = getSigningKeyPath();
74
-
75
- // Try to load existing key
76
- if (existsSync(keyPath)) {
77
- try {
78
- const raw = readFileSync(keyPath);
79
- if (raw.length === 32) {
80
- log.info('Actor-token signing key loaded from disk');
81
- return raw;
82
- }
83
- log.warn('Signing key file has unexpected length, regenerating');
84
- } catch (err) {
85
- log.warn({ err }, 'Failed to read signing key file, regenerating');
86
- }
87
- }
88
-
89
- // Generate and persist a new key
90
- const newKey = randomBytes(32);
91
- const dir = dirname(keyPath);
92
- if (!existsSync(dir)) {
93
- mkdirSync(dir, { recursive: true });
94
- }
95
- const tmpPath = keyPath + '.tmp.' + process.pid;
96
- writeFileSync(tmpPath, newKey, { mode: 0o600 });
97
- renameSync(tmpPath, keyPath);
98
- chmodSync(keyPath, 0o600);
99
-
100
- log.info('Actor-token signing key generated and persisted');
101
- return newKey;
102
- }
103
-
104
- /**
105
- * Initialize (or reinitialize) the signing key. Called at daemon startup
106
- * with a key loaded from disk via loadOrCreateSigningKey(), or by tests
107
- * with a deterministic key.
108
- */
109
- export function initSigningKey(key: Buffer): void {
110
- signingKey = key;
111
- }
112
-
113
- function getSigningKey(): Buffer {
114
- if (!signingKey) {
115
- throw new Error('Actor-token signing key not initialized — call initSigningKey() during startup');
116
- }
117
- return signingKey;
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // Base64url helpers
122
- // ---------------------------------------------------------------------------
123
-
124
- function base64urlEncode(data: Buffer | string): string {
125
- const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
126
- return buf.toString('base64url');
127
- }
128
-
129
- function base64urlDecode(str: string): Buffer {
130
- return Buffer.from(str, 'base64url');
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // Hashing
135
- // ---------------------------------------------------------------------------
136
-
137
- /** SHA-256 hex digest of a raw token string. */
138
- export function hashToken(token: string): string {
139
- return createHash('sha256').update(token).digest('hex');
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Mint
144
- // ---------------------------------------------------------------------------
145
-
146
- /** Default TTL for actor tokens: 30 days in milliseconds. */
147
- const DEFAULT_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
148
-
149
- /**
150
- * Mint a new actor token.
151
- *
152
- * @param params Token claims (assistantId, platform, deviceId, guardianPrincipalId).
153
- * @param ttlMs Optional TTL in milliseconds. Defaults to 30 days.
154
- * Pass `null` explicitly for a non-expiring token.
155
- * @returns The raw token, its hash, and the embedded claims.
156
- */
157
- export function mintActorToken(params: {
158
- assistantId: string;
159
- platform: string;
160
- deviceId: string;
161
- guardianPrincipalId: string;
162
- ttlMs?: number | null;
163
- }): MintResult {
164
- const now = Date.now();
165
- const effectiveTtl = params.ttlMs === undefined ? DEFAULT_TOKEN_TTL_MS : params.ttlMs;
166
- const claims: ActorTokenClaims = {
167
- assistantId: params.assistantId,
168
- platform: params.platform,
169
- deviceId: params.deviceId,
170
- guardianPrincipalId: params.guardianPrincipalId,
171
- iat: now,
172
- exp: effectiveTtl != null ? now + effectiveTtl : null,
173
- jti: randomBytes(16).toString('hex'),
174
- };
175
-
176
- const payload = base64urlEncode(JSON.stringify(claims));
177
- const sig = createHmac('sha256', getSigningKey())
178
- .update(payload)
179
- .digest();
180
- const token = payload + '.' + base64urlEncode(sig);
181
- const tokenHash = hashToken(token);
182
-
183
- return { token, tokenHash, claims };
184
- }
185
-
186
- // ---------------------------------------------------------------------------
187
- // Verify
188
- // ---------------------------------------------------------------------------
189
-
190
- /**
191
- * Verify an actor token's structural integrity and signature.
192
- *
193
- * Does NOT check revocation — callers must additionally verify the
194
- * token hash exists in the actor-token store with status='active'.
195
- */
196
- export function verifyActorToken(token: string): VerifyResult {
197
- const dotIndex = token.indexOf('.');
198
- if (dotIndex < 0) {
199
- return { ok: false, reason: 'malformed_token' };
200
- }
201
-
202
- const payload = token.slice(0, dotIndex);
203
- const sigPart = token.slice(dotIndex + 1);
204
-
205
- // Recompute HMAC
206
- const expectedSig = createHmac('sha256', getSigningKey())
207
- .update(payload)
208
- .digest();
209
- const actualSig = base64urlDecode(sigPart);
210
-
211
- if (expectedSig.length !== actualSig.length) {
212
- return { ok: false, reason: 'invalid_signature' };
213
- }
214
-
215
- // Constant-time comparison
216
- if (!timingSafeEqual(expectedSig, actualSig)) {
217
- return { ok: false, reason: 'invalid_signature' };
218
- }
219
-
220
- let claims: ActorTokenClaims;
221
- try {
222
- const decoded = base64urlDecode(payload).toString('utf-8');
223
- claims = JSON.parse(decoded) as ActorTokenClaims;
224
- } catch {
225
- return { ok: false, reason: 'malformed_claims' };
226
- }
227
-
228
- // Expiration check
229
- if (claims.exp != null && Date.now() > claims.exp) {
230
- return { ok: false, reason: 'token_expired' };
231
- }
232
-
233
- return { ok: true, claims };
234
- }
@@ -1,265 +0,0 @@
1
- /**
2
- * Actor-token verification middleware for HTTP routes.
3
- *
4
- * Extracts the X-Actor-Token header, verifies the HMAC signature,
5
- * checks that the token is active in the store, and returns the
6
- * verified claims and resolved guardian runtime context.
7
- *
8
- * Used by vellum-channel HTTP routes (POST /v1/messages, POST /v1/confirm,
9
- * POST /v1/guardian-actions/decision, etc.) to enforce identity-based
10
- * authentication.
11
- *
12
- * For backward compatibility with bearer-authenticated local clients (CLI),
13
- * provides fallback functions that resolve identity through the local IPC
14
- * guardian context pathway when no actor token is present.
15
- */
16
-
17
- import type { GuardianRuntimeContext } from '../../daemon/session-runtime-assembly.js';
18
- import { getActiveBinding } from '../../memory/guardian-bindings.js';
19
- import { getLogger } from '../../util/logger.js';
20
- import { type ActorTokenClaims, hashToken, verifyActorToken } from '../actor-token-service.js';
21
- import { findActiveByTokenHash } from '../actor-token-store.js';
22
- import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
23
- import { resolveGuardianContext } from '../guardian-context-resolver.js';
24
- import { resolveLocalIpcGuardianContext } from '../local-actor-identity.js';
25
-
26
- const log = getLogger('actor-token-middleware');
27
-
28
- // ---------------------------------------------------------------------------
29
- // Types
30
- // ---------------------------------------------------------------------------
31
-
32
- export interface ActorTokenResult {
33
- ok: true;
34
- claims: ActorTokenClaims;
35
- guardianContext: GuardianRuntimeContext;
36
- }
37
-
38
- export interface ActorTokenError {
39
- ok: false;
40
- status: number;
41
- message: string;
42
- }
43
-
44
- export type ActorTokenVerification = ActorTokenResult | ActorTokenError;
45
-
46
- // ---------------------------------------------------------------------------
47
- // Header extraction
48
- // ---------------------------------------------------------------------------
49
-
50
- const ACTOR_TOKEN_HEADER = 'x-actor-token';
51
-
52
- export function extractActorToken(req: Request): string | null {
53
- return req.headers.get(ACTOR_TOKEN_HEADER) || null;
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Full verification pipeline
58
- // ---------------------------------------------------------------------------
59
-
60
- /**
61
- * Verify the X-Actor-Token header and resolve a guardian runtime context.
62
- *
63
- * Steps:
64
- * 1. Extract the header value.
65
- * 2. Verify HMAC signature and expiration.
66
- * 3. Check the token hash is active in the actor-token store.
67
- * 4. Resolve a guardian context through the standard trust pipeline using
68
- * the claims' guardianPrincipalId as the sender identity.
69
- *
70
- * Returns an ok result with claims and guardianContext, or an error with
71
- * the HTTP status code and message to return.
72
- */
73
- export function verifyHttpActorToken(req: Request): ActorTokenVerification {
74
- const rawToken = extractActorToken(req);
75
- if (!rawToken) {
76
- return {
77
- ok: false,
78
- status: 401,
79
- message: 'Missing X-Actor-Token header. Vellum HTTP requests require actor identity.',
80
- };
81
- }
82
-
83
- // Structural + signature verification
84
- const verifyResult = verifyActorToken(rawToken);
85
- if (!verifyResult.ok) {
86
- log.warn({ reason: verifyResult.reason }, 'Actor token verification failed');
87
- return {
88
- ok: false,
89
- status: 401,
90
- message: `Invalid actor token: ${verifyResult.reason}`,
91
- };
92
- }
93
-
94
- // Check the token is active in the store (not revoked)
95
- const tokenHash = hashToken(rawToken);
96
- const record = findActiveByTokenHash(tokenHash);
97
- if (!record) {
98
- log.warn('Actor token not found in active store (possibly revoked)');
99
- return {
100
- ok: false,
101
- status: 401,
102
- message: 'Actor token is no longer active',
103
- };
104
- }
105
-
106
- const { claims } = verifyResult;
107
-
108
- // Resolve guardian context through the shared trust pipeline.
109
- // The guardianPrincipalId from the token is used as the sender identity,
110
- // and 'vellum' is used as the channel for binding lookup.
111
- const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
112
- const guardianCtx = resolveGuardianContext({
113
- assistantId,
114
- sourceChannel: 'vellum',
115
- conversationExternalId: 'local',
116
- actorExternalId: claims.guardianPrincipalId,
117
- });
118
-
119
- return {
120
- ok: true,
121
- claims,
122
- guardianContext: guardianCtx,
123
- };
124
- }
125
-
126
- /**
127
- * Verify that the actor identity from a verified token matches the bound
128
- * guardian for the vellum channel. Used for guardian-decision endpoints
129
- * where only the guardian should be able to approve/reject.
130
- *
131
- * Returns true if the actor is the bound guardian, false otherwise.
132
- */
133
- export function isActorBoundGuardian(claims: ActorTokenClaims): boolean {
134
- const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
135
- const binding = getActiveBinding(assistantId, 'vellum');
136
- if (!binding) return false;
137
- return binding.guardianExternalUserId === claims.guardianPrincipalId;
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // Bearer-auth fallback variants
142
- // ---------------------------------------------------------------------------
143
-
144
- /** Loopback addresses — used to gate the local identity fallback. */
145
- const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
146
-
147
- /** Bun server shape needed for requestIP — avoids importing the full Bun type. */
148
- export type ServerWithRequestIP = {
149
- requestIP(req: Request): { address: string; family: string; port: number } | null;
150
- };
151
-
152
- /**
153
- * Result for the fallback verification path where the actor token is absent
154
- * but the request is bearer-authenticated (local trusted client like CLI).
155
- */
156
- export interface ActorTokenLocalFallbackResult {
157
- ok: true;
158
- claims: null;
159
- guardianContext: GuardianRuntimeContext;
160
- localFallback: true;
161
- }
162
-
163
- export type ActorTokenVerificationWithFallback =
164
- | ActorTokenResult
165
- | ActorTokenLocalFallbackResult
166
- | ActorTokenError;
167
-
168
- /**
169
- * Verify the actor token with fallback to local IPC identity resolution.
170
- *
171
- * When an actor token is present, the full verification pipeline runs.
172
- * When absent AND the request originates from a loopback address, the
173
- * request is treated as a trusted local client (e.g. CLI) and we fall
174
- * back to `resolveLocalIpcGuardianContext()` which produces the same
175
- * guardian context as the IPC pathway.
176
- *
177
- * Two conditions must BOTH be met for the local fallback:
178
- * 1. No X-Forwarded-For header (rules out gateway-proxied requests).
179
- * 2. The peer remote address is a loopback address (rules out LAN/container
180
- * connections when the runtime binds to 0.0.0.0).
181
- *
182
- * The peer address is checked via `server.requestIP(req)`.
183
- *
184
- * --- CLI compatibility note ---
185
- *
186
- * The local fallback is an intentional CLI compatibility path, not a
187
- * security gap. The CLI currently sends only `Authorization: Bearer <token>`
188
- * without `X-Actor-Token`. This fallback allows the CLI to function until
189
- * it is migrated to actor tokens in a future milestone.
190
- *
191
- * The fallback is gated by three conditions that together ensure only
192
- * genuinely local connections receive guardian identity:
193
- * (1) Absence of X-Forwarded-For header — the gateway always injects
194
- * this header when proxying, so its presence indicates a remote client.
195
- * (2) Loopback origin check — verifies the peer IP is 127.0.0.1/::1,
196
- * preventing LAN or container peers.
197
- * (3) Valid bearer authentication — already enforced upstream by the
198
- * HTTP server's auth gate before this function is called.
199
- *
200
- * Once the CLI adopts actor tokens, this fallback can be removed.
201
- */
202
- export function verifyHttpActorTokenWithLocalFallback(
203
- req: Request,
204
- server: ServerWithRequestIP,
205
- ): ActorTokenVerificationWithFallback {
206
- const rawToken = extractActorToken(req);
207
-
208
- // If an actor token is present, use the strict verification pipeline.
209
- if (rawToken) {
210
- return verifyHttpActorToken(req);
211
- }
212
-
213
- // Gate the local fallback on provably-local origin. The gateway runtime
214
- // proxy always injects X-Forwarded-For with the real client IP when
215
- // forwarding requests. Direct local connections (CLI, macOS app) never
216
- // set this header. If X-Forwarded-For is present, the request was
217
- // proxied through the gateway on behalf of a potentially remote client
218
- // and must not receive local guardian identity.
219
- if (req.headers.get('x-forwarded-for')) {
220
- log.warn('Rejecting local identity fallback: request has X-Forwarded-For (proxied through gateway)');
221
- return {
222
- ok: false,
223
- status: 401,
224
- message: 'Missing X-Actor-Token header. Proxied requests require actor identity.',
225
- };
226
- }
227
-
228
- // Verify the peer address is actually loopback. This prevents LAN or
229
- // container peers from receiving local guardian identity when the
230
- // runtime binds to 0.0.0.0.
231
- const peerIp = server.requestIP(req)?.address;
232
- if (!peerIp || !LOOPBACK_ADDRESSES.has(peerIp)) {
233
- log.warn({ peerIp }, 'Rejecting local identity fallback: peer is not loopback');
234
- return {
235
- ok: false,
236
- status: 401,
237
- message: 'Missing X-Actor-Token header. Non-loopback requests require actor identity.',
238
- };
239
- }
240
-
241
- // No actor token, no forwarding header, and the peer is on loopback
242
- // — this is a direct local connection that passed bearer auth at the
243
- // HTTP server layer. Resolve identity the same way as IPC.
244
- log.debug('No actor token present on direct local request; using local IPC identity fallback');
245
- const guardianContext = resolveLocalIpcGuardianContext('vellum');
246
- return {
247
- ok: true,
248
- claims: null,
249
- guardianContext,
250
- localFallback: true,
251
- };
252
- }
253
-
254
- /**
255
- * Check whether the local fallback identity is the bound guardian.
256
- *
257
- * When no actor token is present (local fallback), the local user is
258
- * treated as the guardian of their own machine — equivalent to IPC.
259
- * This returns true when either the resolved trust class is 'guardian'
260
- * or no vellum binding exists yet (pre-bootstrap).
261
- */
262
- export function isLocalFallbackBoundGuardian(): boolean {
263
- const guardianContext = resolveLocalIpcGuardianContext('vellum');
264
- return guardianContext.trustClass === 'guardian';
265
- }