@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -12,6 +12,11 @@
12
12
  * 5. Guardian approval record update
13
13
  * 6. Scoped grant minting on approve
14
14
  *
15
+ * The canonical path (`applyCanonicalGuardianDecision`) adds:
16
+ * 7. Canonical request lookup and status validation
17
+ * 8. CAS resolution via `resolveCanonicalGuardianRequest`
18
+ * 9. Kind-specific resolver dispatch via the resolver registry
19
+ *
15
20
  * Security invariants enforced here:
16
21
  * - Decision application is identity-bound to expected guardian identity
17
22
  * - Decisions are first-response-wins (CAS-like stale protection)
@@ -20,11 +25,18 @@
20
25
  */
21
26
 
22
27
  import type { ChannelId } from '../channels/types.js';
28
+ import {
29
+ type CanonicalGuardianRequest,
30
+ type CanonicalRequestStatus,
31
+ getCanonicalGuardianRequest,
32
+ resolveCanonicalGuardianRequest,
33
+ } from '../memory/canonical-guardian-store.js';
23
34
  import {
24
35
  type GuardianApprovalRequest,
25
36
  updateApprovalDecision,
26
37
  } from '../memory/channel-guardian-store.js';
27
38
  import type {
39
+ ApprovalAction,
28
40
  ApprovalDecisionResult,
29
41
  } from '../runtime/channel-approval-types.js';
30
42
  import {
@@ -36,6 +48,11 @@ import type { ApplyGuardianDecisionResult } from '../runtime/guardian-decision-t
36
48
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
37
49
  import { getLogger } from '../util/logger.js';
38
50
  import { mintGrantFromDecision } from './approval-primitive.js';
51
+ import {
52
+ type ActorContext,
53
+ type ChannelDeliveryContext,
54
+ getResolver,
55
+ } from './guardian-request-resolvers.js';
39
56
 
40
57
  const log = getLogger('guardian-decision-primitive');
41
58
 
@@ -189,3 +206,271 @@ export function applyGuardianDecision(params: ApplyGuardianDecisionParams): Appl
189
206
  requestId: result.requestId,
190
207
  };
191
208
  }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Consolidated canonical grant minting
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Mint a scoped approval grant from a canonical guardian request.
216
+ *
217
+ * Works for all request kinds that carry tool metadata (toolName + inputDigest).
218
+ * Requests without tool metadata are silently skipped — grant minting only
219
+ * applies to tool-approval flows.
220
+ *
221
+ * Fails silently on error — grant minting is best-effort and must never
222
+ * block the approval flow.
223
+ */
224
+ export function mintCanonicalRequestGrant(params: {
225
+ request: CanonicalGuardianRequest;
226
+ actorChannel: string;
227
+ guardianExternalUserId?: string;
228
+ }): { minted: boolean } {
229
+ const { request, actorChannel, guardianExternalUserId } = params;
230
+
231
+ if (!request.toolName || !request.inputDigest) {
232
+ return { minted: false };
233
+ }
234
+
235
+ const result = mintGrantFromDecision({
236
+ assistantId: 'self',
237
+ scopeMode: 'tool_signature',
238
+ toolName: request.toolName,
239
+ inputDigest: request.inputDigest,
240
+ requestChannel: request.sourceChannel ?? 'unknown',
241
+ decisionChannel: actorChannel,
242
+ executionChannel: null,
243
+ conversationId: request.conversationId ?? null,
244
+ callSessionId: request.callSessionId ?? null,
245
+ guardianExternalUserId: guardianExternalUserId ?? null,
246
+ requesterExternalUserId: request.requesterExternalUserId ?? null,
247
+ expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
248
+ });
249
+
250
+ if (result.ok) {
251
+ log.info(
252
+ {
253
+ event: 'canonical_grant_minted',
254
+ requestId: request.id,
255
+ toolName: request.toolName,
256
+ conversationId: request.conversationId,
257
+ },
258
+ 'Minted scoped approval grant for canonical guardian request',
259
+ );
260
+ return { minted: true };
261
+ }
262
+
263
+ log.error(
264
+ {
265
+ event: 'canonical_grant_mint_failed',
266
+ reason: result.reason,
267
+ requestId: request.id,
268
+ toolName: request.toolName,
269
+ },
270
+ 'Failed to mint scoped approval grant for canonical request (non-fatal)',
271
+ );
272
+ return { minted: false };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Canonical guardian decision primitive
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /** Valid actions for canonical guardian decisions. */
280
+ const VALID_CANONICAL_ACTIONS: ReadonlySet<ApprovalAction> = new Set([
281
+ 'approve_once',
282
+ 'approve_always',
283
+ 'reject',
284
+ ]);
285
+
286
+ export interface ApplyCanonicalGuardianDecisionParams {
287
+ /** The canonical request ID to resolve. */
288
+ requestId: string;
289
+ /** The decision action. */
290
+ action: ApprovalAction;
291
+ /** Actor context for the entity making the decision. */
292
+ actorContext: ActorContext;
293
+ /** Optional user-supplied text (e.g. answer text for pending questions). */
294
+ userText?: string;
295
+ /** Optional channel delivery context — present when the decision arrived via a channel message. */
296
+ channelDeliveryContext?: ChannelDeliveryContext;
297
+ }
298
+
299
+ export type CanonicalDecisionResult =
300
+ | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string }
301
+ | { applied: false; reason: 'not_found' | 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired'; detail?: string };
302
+
303
+ /**
304
+ * Apply a guardian decision through the canonical request primitive.
305
+ *
306
+ * This is the future single write path for all guardian decisions. It
307
+ * operates on the canonical_guardian_requests table and dispatches to
308
+ * kind-specific resolvers via the resolver registry.
309
+ *
310
+ * Steps:
311
+ * 1. Look up the canonical request by ID
312
+ * 2. Validate: exists, pending status, identity match, valid action
313
+ * 3. Downgrade approve_always to approve_once (guardian-on-behalf invariant)
314
+ * 4. CAS resolve the canonical request atomically
315
+ * 5. Dispatch to kind-specific resolver
316
+ * 6. Mint grant if applicable
317
+ */
318
+ export async function applyCanonicalGuardianDecision(
319
+ params: ApplyCanonicalGuardianDecisionParams,
320
+ ): Promise<CanonicalDecisionResult> {
321
+ const { requestId, action, actorContext, userText, channelDeliveryContext } = params;
322
+
323
+ // 1. Look up the canonical request
324
+ const request = getCanonicalGuardianRequest(requestId);
325
+ if (!request) {
326
+ log.warn(
327
+ { event: 'canonical_decision_not_found', requestId },
328
+ 'Canonical request not found',
329
+ );
330
+ return { applied: false, reason: 'not_found' };
331
+ }
332
+
333
+ // 2a. Validate status is pending
334
+ if (request.status !== 'pending') {
335
+ log.info(
336
+ { event: 'canonical_decision_already_resolved', requestId, currentStatus: request.status },
337
+ 'Canonical request already resolved',
338
+ );
339
+ return { applied: false, reason: 'already_resolved' };
340
+ }
341
+
342
+ // 2b. Validate action is valid
343
+ if (!VALID_CANONICAL_ACTIONS.has(action)) {
344
+ log.warn(
345
+ { event: 'canonical_decision_invalid_action', requestId, action },
346
+ 'Invalid action for canonical decision',
347
+ );
348
+ return { applied: false, reason: 'invalid_action', detail: `invalid action: ${action}` };
349
+ }
350
+
351
+ // 2c. Validate identity: actor must match guardian_external_user_id
352
+ // unless the actor is trusted (desktop) or the request has no guardian binding.
353
+ if (
354
+ request.guardianExternalUserId &&
355
+ !actorContext.isTrusted &&
356
+ actorContext.externalUserId !== request.guardianExternalUserId
357
+ ) {
358
+ log.warn(
359
+ {
360
+ event: 'canonical_decision_identity_mismatch',
361
+ requestId,
362
+ expectedGuardian: request.guardianExternalUserId,
363
+ actualActor: actorContext.externalUserId,
364
+ },
365
+ 'Actor identity does not match expected guardian',
366
+ );
367
+ return { applied: false, reason: 'identity_mismatch' };
368
+ }
369
+
370
+ // 2d. Check expiry
371
+ if (request.expiresAt && new Date(request.expiresAt).getTime() < Date.now()) {
372
+ log.info(
373
+ { event: 'canonical_decision_expired', requestId, expiresAt: request.expiresAt },
374
+ 'Canonical request has expired',
375
+ );
376
+ return { applied: false, reason: 'expired' };
377
+ }
378
+
379
+ // 3. Downgrade approve_always to approve_once for guardian-on-behalf requests.
380
+ // Guardians cannot permanently allowlist tools on behalf of requesters.
381
+ const effectiveAction: ApprovalAction = action === 'approve_always'
382
+ ? 'approve_once'
383
+ : action;
384
+
385
+ // 4. CAS resolve: atomically transition from 'pending' to terminal status
386
+ const targetStatus: CanonicalRequestStatus = effectiveAction === 'reject'
387
+ ? 'denied'
388
+ : 'approved';
389
+
390
+ const resolved = resolveCanonicalGuardianRequest(requestId, 'pending', {
391
+ status: targetStatus,
392
+ answerText: userText,
393
+ decidedByExternalUserId: actorContext.externalUserId,
394
+ });
395
+
396
+ if (!resolved) {
397
+ // CAS failed — someone else resolved it first
398
+ log.info(
399
+ { event: 'canonical_decision_cas_failed', requestId },
400
+ 'CAS resolution failed (race condition — first writer wins)',
401
+ );
402
+ return { applied: false, reason: 'already_resolved' };
403
+ }
404
+
405
+ // 5. Dispatch to kind-specific resolver
406
+ let resolverFailed = false;
407
+ let resolverFailureReason: string | undefined;
408
+ const resolver = getResolver(request.kind);
409
+ if (resolver) {
410
+ const resolverResult = await resolver.resolve({
411
+ request: resolved,
412
+ decision: { action: effectiveAction, userText },
413
+ actor: actorContext,
414
+ channelDeliveryContext,
415
+ });
416
+
417
+ if (!resolverResult.ok) {
418
+ log.warn(
419
+ {
420
+ event: 'canonical_decision_resolver_failed',
421
+ requestId,
422
+ kind: request.kind,
423
+ reason: resolverResult.reason,
424
+ },
425
+ `Resolver for kind '${request.kind}' failed: ${resolverResult.reason}`,
426
+ );
427
+ // The canonical request is already resolved (CAS succeeded), so we don't
428
+ // roll back. Flag the failure and fall through to grant minting so that
429
+ // callers see applied: true (reflecting the committed DB state) while
430
+ // still being informed that the resolver had an issue.
431
+ resolverFailed = true;
432
+ resolverFailureReason = resolverResult.reason;
433
+ }
434
+ } else {
435
+ log.info(
436
+ { event: 'canonical_decision_no_resolver', requestId, kind: request.kind },
437
+ `No resolver registered for kind '${request.kind}' — CAS resolution only`,
438
+ );
439
+ }
440
+
441
+ // 6. Mint grant if the decision is an approval with tool metadata.
442
+ // Skip when the resolver failed — minting a grant on a failed side effect
443
+ // would allow the tool to execute without the intended resolver action
444
+ // (e.g. answerCall) having succeeded.
445
+ let grantMinted = false;
446
+ if (effectiveAction !== 'reject' && !resolverFailed) {
447
+ const grantResult = mintCanonicalRequestGrant({
448
+ request: resolved,
449
+ actorChannel: actorContext.channel,
450
+ guardianExternalUserId: actorContext.externalUserId ?? resolved.guardianExternalUserId ?? undefined,
451
+ });
452
+ grantMinted = grantResult.minted;
453
+ }
454
+
455
+ log.info(
456
+ {
457
+ event: 'canonical_decision_applied',
458
+ requestId,
459
+ kind: request.kind,
460
+ action: effectiveAction,
461
+ targetStatus,
462
+ grantMinted,
463
+ resolverFailed,
464
+ },
465
+ resolverFailed
466
+ ? 'Canonical guardian decision applied (CAS committed) but resolver failed'
467
+ : 'Canonical guardian decision applied successfully',
468
+ );
469
+
470
+ return {
471
+ applied: true,
472
+ requestId,
473
+ grantMinted,
474
+ ...(resolverFailed ? { resolverFailed, resolverFailureReason } : {}),
475
+ };
476
+ }