@vellumai/assistant 0.4.29 → 0.4.30

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 (174) hide show
  1. package/ARCHITECTURE.md +39 -37
  2. package/README.md +5 -6
  3. package/docs/runbook-trusted-contacts.md +79 -43
  4. package/package.json +1 -1
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -3
  6. package/scripts/test.sh +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -37
  8. package/src/__tests__/actor-token-service.test.ts +4 -3
  9. package/src/__tests__/app-executors.test.ts +7 -17
  10. package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -10
  11. package/src/__tests__/browser-skill-endstate.test.ts +10 -1
  12. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -0
  13. package/src/__tests__/channel-approval-routes.test.ts +44 -44
  14. package/src/__tests__/channel-approval.test.ts +8 -0
  15. package/src/__tests__/channel-approvals.test.ts +39 -1
  16. package/src/__tests__/channel-guardian.test.ts +15 -5
  17. package/src/__tests__/channel-reply-delivery.test.ts +31 -0
  18. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -0
  19. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +9 -0
  20. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  21. package/src/__tests__/gemini-image-service.test.ts +2 -2
  22. package/src/__tests__/guardian-grant-minting.test.ts +6 -6
  23. package/src/__tests__/guardian-routing-invariants.test.ts +34 -11
  24. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +4 -6
  25. package/src/__tests__/inbound-invite-redemption.test.ts +1 -1
  26. package/src/__tests__/integrations-cli.test.ts +3 -27
  27. package/src/__tests__/intent-routing.test.ts +3 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +1 -1
  29. package/src/__tests__/{ingress-routes-http.test.ts → invite-routes-http.test.ts} +40 -320
  30. package/src/__tests__/ipc-snapshot.test.ts +4 -31
  31. package/src/__tests__/nl-approval-parser.test.ts +305 -0
  32. package/src/__tests__/oauth-provider-profiles.test.ts +34 -0
  33. package/src/__tests__/provider-error-scenarios.test.ts +68 -0
  34. package/src/__tests__/relay-server.test.ts +1 -1
  35. package/src/__tests__/retry-after-extraction.test.ts +111 -0
  36. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +127 -0
  37. package/src/__tests__/session-media-retry.test.ts +147 -0
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +9 -5
  39. package/src/__tests__/skill-feature-flags.test.ts +18 -12
  40. package/src/__tests__/skill-load-feature-flag.test.ts +4 -3
  41. package/src/__tests__/slack-block-formatting.test.ts +100 -0
  42. package/src/__tests__/slack-inbound-verification.test.ts +346 -0
  43. package/src/__tests__/slack-reaction-approvals.test.ts +77 -0
  44. package/src/__tests__/slack-skill.test.ts +3 -2
  45. package/src/__tests__/starter-task-flow.test.ts +0 -1
  46. package/src/__tests__/trusted-contact-verification.test.ts +3 -1
  47. package/src/__tests__/voice-invite-redemption.test.ts +1 -1
  48. package/src/amazon/client.ts +7 -24
  49. package/src/calls/relay-server.ts +39 -11
  50. package/src/channels/config.ts +1 -1
  51. package/src/cli/integrations.ts +10 -66
  52. package/src/config/bundled-skills/app-builder/SKILL.md +193 -1500
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +70 -18
  54. package/src/config/bundled-skills/browser/TOOLS.json +59 -2
  55. package/src/config/bundled-skills/chatgpt-import/TOOLS.json +4 -0
  56. package/src/config/bundled-skills/computer-use/TOOLS.json +50 -2
  57. package/src/config/bundled-skills/contacts/SKILL.md +42 -35
  58. package/src/config/bundled-skills/contacts/TOOLS.json +22 -2
  59. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +38 -58
  60. package/src/config/bundled-skills/contacts/tools/contact-search.ts +11 -31
  61. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +19 -37
  62. package/src/config/bundled-skills/document/TOOLS.json +8 -0
  63. package/src/config/bundled-skills/email-setup/SKILL.md +10 -7
  64. package/src/config/bundled-skills/followups/TOOLS.json +12 -0
  65. package/src/config/bundled-skills/google-calendar/TOOLS.json +124 -26
  66. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +54 -21
  67. package/src/config/bundled-skills/image-studio/TOOLS.json +12 -2
  68. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +14 -8
  69. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +13 -3
  70. package/src/config/bundled-skills/media-processing/SKILL.md +1 -1
  71. package/src/config/bundled-skills/media-processing/TOOLS.json +28 -0
  72. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +26 -6
  73. package/src/config/bundled-skills/messaging/TOOLS.json +228 -182
  74. package/src/config/bundled-skills/notifications/SKILL.md +3 -2
  75. package/src/config/bundled-skills/notifications/TOOLS.json +7 -13
  76. package/src/config/bundled-skills/phone-calls/TOOLS.json +13 -1
  77. package/src/config/bundled-skills/playbooks/TOOLS.json +16 -0
  78. package/src/config/bundled-skills/reminder/TOOLS.json +15 -2
  79. package/src/config/bundled-skills/schedule/SKILL.md +33 -15
  80. package/src/config/bundled-skills/schedule/TOOLS.json +17 -1
  81. package/src/config/bundled-skills/slack/SKILL.md +30 -1
  82. package/src/config/bundled-skills/slack/TOOLS.json +89 -2
  83. package/src/config/bundled-skills/slack/tools/slack-channel-permissions.ts +146 -0
  84. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +120 -0
  85. package/src/config/bundled-skills/slack-app-setup/SKILL.md +200 -0
  86. package/src/config/bundled-skills/subagent/TOOLS.json +22 -2
  87. package/src/config/bundled-skills/tasks/TOOLS.json +86 -14
  88. package/src/config/bundled-skills/transcribe/TOOLS.json +4 -0
  89. package/src/config/bundled-skills/watcher/TOOLS.json +20 -0
  90. package/src/config/bundled-skills/weather/TOOLS.json +4 -0
  91. package/src/config/bundled-tool-registry.ts +2 -0
  92. package/src/config/channel-permission-profiles.ts +155 -0
  93. package/src/config/env.ts +4 -1
  94. package/src/contacts/contact-store.ts +195 -4
  95. package/src/contacts/types.ts +26 -0
  96. package/src/daemon/assistant-attachments.ts +23 -3
  97. package/src/daemon/guardian-verification-intent.ts +7 -4
  98. package/src/daemon/handlers/apps.ts +1 -2
  99. package/src/daemon/handlers/config-inbox.ts +16 -134
  100. package/src/daemon/handlers/guardian-actions.ts +20 -87
  101. package/src/daemon/handlers/sessions.ts +0 -1
  102. package/src/daemon/ipc-contract/apps.ts +0 -1
  103. package/src/daemon/ipc-contract/inbox.ts +7 -66
  104. package/src/daemon/ipc-contract/sessions.ts +1 -0
  105. package/src/daemon/ipc-contract/surfaces.ts +0 -1
  106. package/src/daemon/ipc-contract-inventory.json +2 -4
  107. package/src/daemon/lifecycle.ts +14 -2
  108. package/src/daemon/session-agent-loop-handlers.ts +9 -0
  109. package/src/daemon/session-agent-loop.ts +1 -0
  110. package/src/daemon/session-attachments.ts +5 -1
  111. package/src/daemon/session-error.ts +18 -0
  112. package/src/daemon/session-lifecycle.ts +4 -5
  113. package/src/daemon/session-media-retry.ts +15 -1
  114. package/src/daemon/session-surfaces.ts +0 -1
  115. package/src/daemon/session-tool-setup.ts +7 -4
  116. package/src/events/domain-events.ts +2 -1
  117. package/src/home-base/prebuilt/seed.ts +0 -1
  118. package/src/influencer/client.ts +7 -24
  119. package/src/media/gemini-image-service.ts +48 -3
  120. package/src/memory/app-store.ts +0 -4
  121. package/src/memory/conversation-attention-store.ts +3 -1
  122. package/src/memory/db-init.ts +4 -0
  123. package/src/memory/migrations/133-assistant-contact-metadata.ts +21 -0
  124. package/src/memory/migrations/index.ts +1 -0
  125. package/src/memory/schema.ts +12 -0
  126. package/src/memory/slack-thread-store.ts +187 -0
  127. package/src/messaging/providers/slack/client.ts +84 -26
  128. package/src/messaging/providers/slack/types.ts +4 -0
  129. package/src/notifications/adapters/slack.ts +90 -0
  130. package/src/notifications/destination-resolver.ts +42 -1
  131. package/src/notifications/emit-signal.ts +17 -1
  132. package/src/oauth/provider-profiles.ts +22 -0
  133. package/src/providers/anthropic/client.ts +3 -0
  134. package/src/providers/openai/client.ts +3 -0
  135. package/src/providers/retry.ts +9 -1
  136. package/src/runtime/actor-trust-resolver.ts +8 -0
  137. package/src/runtime/auth/require-bound-guardian.ts +44 -0
  138. package/src/runtime/auth/route-policy.ts +4 -8
  139. package/src/runtime/channel-approval-types.ts +18 -0
  140. package/src/runtime/channel-approvals.ts +8 -0
  141. package/src/runtime/channel-invite-transport.ts +1 -1
  142. package/src/runtime/channel-reply-delivery.ts +62 -3
  143. package/src/runtime/gateway-client.ts +36 -2
  144. package/src/runtime/gateway-internal-client.ts +86 -0
  145. package/src/runtime/guardian-action-service.ts +127 -0
  146. package/src/runtime/guardian-verification-templates.ts +16 -1
  147. package/src/runtime/http-server.ts +20 -49
  148. package/src/runtime/invite-redemption-service.ts +1 -1
  149. package/src/runtime/{ingress-service.ts → invite-service.ts} +5 -157
  150. package/src/runtime/nl-approval-parser.ts +138 -0
  151. package/src/runtime/routes/approval-routes.ts +1 -40
  152. package/src/runtime/routes/channel-route-shared.ts +35 -1
  153. package/src/runtime/routes/contact-routes.ts +196 -28
  154. package/src/runtime/routes/guardian-action-routes.ts +19 -111
  155. package/src/runtime/routes/guardian-approval-interception.ts +76 -0
  156. package/src/runtime/routes/inbound-message-handler.ts +40 -12
  157. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +222 -0
  158. package/src/runtime/routes/inbound-stages/background-dispatch.ts +108 -0
  159. package/src/runtime/routes/{ingress-routes.ts → invite-routes.ts} +10 -110
  160. package/src/runtime/slack-block-formatting.ts +176 -0
  161. package/src/schedule/scheduler.ts +11 -2
  162. package/src/tools/apps/executors.ts +16 -15
  163. package/src/tools/calls/call-end.ts +1 -1
  164. package/src/tools/computer-use/definitions.ts +16 -0
  165. package/src/tools/credentials/vault.ts +86 -2
  166. package/src/tools/network/script-proxy/session-manager.ts +28 -3
  167. package/src/tools/permission-checker.ts +18 -0
  168. package/src/tools/terminal/shell.ts +15 -5
  169. package/src/tools/tool-approval-handler.ts +48 -4
  170. package/src/tools/types.ts +38 -1
  171. package/src/util/errors.ts +5 -1
  172. package/src/util/retry.ts +21 -0
  173. package/src/watcher/providers/slack.ts +33 -3
  174. /package/src/memory/{ingress-invite-store.ts → invite-store.ts} +0 -0
@@ -9,22 +9,34 @@
9
9
  */
10
10
 
11
11
  import {
12
+ getAssistantContactMetadata,
13
+ getChannelById,
12
14
  getContact,
13
15
  listContacts,
14
16
  mergeContacts,
15
17
  searchContacts,
16
18
  updateChannelStatus,
19
+ upsertAssistantContactMetadata,
17
20
  upsertContact,
21
+ validateSpeciesMetadata,
18
22
  } from "../../contacts/contact-store.js";
19
23
  import type {
24
+ AssistantSpecies,
20
25
  ChannelPolicy,
21
26
  ChannelStatus,
22
27
  ContactRole,
28
+ ContactType,
23
29
  } from "../../contacts/types.js";
24
30
  import { httpError } from "../http-errors.js";
25
31
 
32
+ const VALID_CONTACT_TYPES: readonly ContactType[] = ["human", "assistant"];
33
+ const VALID_ASSISTANT_SPECIES: readonly AssistantSpecies[] = [
34
+ "vellum",
35
+ "openclaw",
36
+ ];
37
+
26
38
  /**
27
- * GET /v1/contacts?limit=50&role=guardian
39
+ * GET /v1/contacts?limit=50&role=guardian&contactType=human
28
40
  *
29
41
  * Also supports search query params: query, channelAddress, channelType, relationship.
30
42
  * When any search param is provided, delegates to searchContacts() instead of listContacts().
@@ -32,12 +44,26 @@ import { httpError } from "../http-errors.js";
32
44
  export function handleListContacts(url: URL, assistantId: string): Response {
33
45
  const limit = Number(url.searchParams.get("limit") ?? 50);
34
46
  const role = url.searchParams.get("role") as ContactRole | null;
47
+ const contactTypeParam = url.searchParams.get("contactType");
35
48
  const query = url.searchParams.get("query");
36
49
  const channelAddress = url.searchParams.get("channelAddress");
37
50
  const channelType = url.searchParams.get("channelType");
38
51
  const relationship = url.searchParams.get("relationship");
39
52
 
40
- const hasSearchParams = query || channelAddress || relationship;
53
+ if (contactTypeParam && !isContactType(contactTypeParam)) {
54
+ return httpError(
55
+ "BAD_REQUEST",
56
+ `Invalid contactType "${contactTypeParam}". Must be one of: ${VALID_CONTACT_TYPES.join(", ")}`,
57
+ 400,
58
+ );
59
+ }
60
+
61
+ const hasSearchParams =
62
+ query || channelAddress || channelType || relationship;
63
+
64
+ const contactType = contactTypeParam
65
+ ? (contactTypeParam as ContactType)
66
+ : undefined;
41
67
 
42
68
  if (hasSearchParams) {
43
69
  const contacts = searchContacts({
@@ -46,12 +72,19 @@ export function handleListContacts(url: URL, assistantId: string): Response {
46
72
  channelAddress: channelAddress ?? undefined,
47
73
  channelType: channelType ?? undefined,
48
74
  relationship: relationship ?? undefined,
75
+ role: role ?? undefined,
76
+ contactType,
49
77
  limit,
50
78
  });
51
79
  return Response.json({ ok: true, contacts });
52
80
  }
53
81
 
54
- const contacts = listContacts(assistantId, limit, role ?? undefined);
82
+ const contacts = listContacts(
83
+ assistantId,
84
+ limit,
85
+ role ?? undefined,
86
+ contactType,
87
+ );
55
88
  return Response.json({ ok: true, contacts });
56
89
  }
57
90
 
@@ -66,7 +99,15 @@ export function handleGetContact(
66
99
  if (!contact) {
67
100
  return httpError("NOT_FOUND", `Contact "${contactId}" not found`, 404);
68
101
  }
69
- return Response.json({ ok: true, contact });
102
+ const assistantMeta =
103
+ contact.contactType === "assistant"
104
+ ? getAssistantContactMetadata(contact.id)
105
+ : undefined;
106
+ return Response.json({
107
+ ok: true,
108
+ contact,
109
+ assistantMetadata: assistantMeta ?? undefined,
110
+ });
70
111
  }
71
112
 
72
113
  /**
@@ -91,8 +132,37 @@ export async function handleMergeContacts(
91
132
  }
92
133
  }
93
134
 
135
+ const VALID_CHANNEL_STATUSES: readonly ChannelStatus[] = [
136
+ "active",
137
+ "pending",
138
+ "revoked",
139
+ "blocked",
140
+ "unverified",
141
+ ];
142
+ const VALID_CHANNEL_POLICIES: readonly ChannelPolicy[] = [
143
+ "allow",
144
+ "deny",
145
+ "escalate",
146
+ ];
147
+
148
+ function isContactType(value: string): value is ContactType {
149
+ return (VALID_CONTACT_TYPES as readonly string[]).includes(value);
150
+ }
151
+
152
+ function isAssistantSpecies(value: string): value is AssistantSpecies {
153
+ return (VALID_ASSISTANT_SPECIES as readonly string[]).includes(value);
154
+ }
155
+
156
+ function isChannelStatus(value: string): value is ChannelStatus {
157
+ return (VALID_CHANNEL_STATUSES as readonly string[]).includes(value);
158
+ }
159
+
160
+ function isChannelPolicy(value: string): value is ChannelPolicy {
161
+ return (VALID_CHANNEL_POLICIES as readonly string[]).includes(value);
162
+ }
163
+
94
164
  /**
95
- * POST /v1/contacts { displayName, id?, relationship?, importance?, ... }
165
+ * POST /v1/contacts { displayName, id?, relationship?, importance?, contactType?, assistantMetadata?, ... }
96
166
  */
97
167
  export async function handleUpsertContact(
98
168
  req: Request,
@@ -106,7 +176,20 @@ export async function handleUpsertContact(
106
176
  responseExpectation?: string;
107
177
  preferredTone?: string;
108
178
  role?: string;
109
- channels?: Array<{ type: string; address: string; isPrimary?: boolean }>;
179
+ contactType?: string;
180
+ assistantMetadata?: {
181
+ species: string;
182
+ metadata?: Record<string, unknown>;
183
+ };
184
+ channels?: Array<{
185
+ type: string;
186
+ address: string;
187
+ isPrimary?: boolean;
188
+ status?: string;
189
+ policy?: string;
190
+ externalUserId?: string;
191
+ externalChatId?: string;
192
+ }>;
110
193
  };
111
194
 
112
195
  if (
@@ -135,6 +218,81 @@ export async function handleUpsertContact(
135
218
  );
136
219
  }
137
220
 
221
+ if (body.contactType !== undefined && !isContactType(body.contactType)) {
222
+ return httpError(
223
+ "BAD_REQUEST",
224
+ `Invalid contactType "${body.contactType}". Must be one of: ${VALID_CONTACT_TYPES.join(", ")}`,
225
+ 400,
226
+ );
227
+ }
228
+
229
+ if (body.contactType === "assistant") {
230
+ if (!body.assistantMetadata) {
231
+ return httpError(
232
+ "BAD_REQUEST",
233
+ 'assistantMetadata is required when contactType is "assistant"',
234
+ 400,
235
+ );
236
+ }
237
+ if (!isAssistantSpecies(body.assistantMetadata.species)) {
238
+ return httpError(
239
+ "BAD_REQUEST",
240
+ `Invalid species "${body.assistantMetadata.species}". Must be one of: ${VALID_ASSISTANT_SPECIES.join(", ")}`,
241
+ 400,
242
+ );
243
+ }
244
+ try {
245
+ validateSpeciesMetadata(
246
+ body.assistantMetadata.species as AssistantSpecies,
247
+ body.assistantMetadata.metadata ?? null,
248
+ );
249
+ } catch (err) {
250
+ return httpError(
251
+ "BAD_REQUEST",
252
+ err instanceof Error ? err.message : String(err),
253
+ 400,
254
+ );
255
+ }
256
+ }
257
+
258
+ if (body.contactType === "human" && body.assistantMetadata) {
259
+ return httpError(
260
+ "BAD_REQUEST",
261
+ 'assistantMetadata must not be provided when contactType is "human"',
262
+ 400,
263
+ );
264
+ }
265
+
266
+ if (body.assistantMetadata && !body.contactType) {
267
+ return httpError(
268
+ "BAD_REQUEST",
269
+ 'contactType must be "assistant" when assistantMetadata is provided',
270
+ 400,
271
+ );
272
+ }
273
+
274
+ if (body.channels) {
275
+ if (!Array.isArray(body.channels)) {
276
+ return httpError("BAD_REQUEST", "channels must be an array", 400);
277
+ }
278
+ for (const ch of body.channels) {
279
+ if (ch.status !== undefined && !isChannelStatus(ch.status)) {
280
+ return httpError(
281
+ "BAD_REQUEST",
282
+ `Invalid channel status "${ch.status}". Must be one of: ${VALID_CHANNEL_STATUSES.join(", ")}`,
283
+ 400,
284
+ );
285
+ }
286
+ if (ch.policy !== undefined && !isChannelPolicy(ch.policy)) {
287
+ return httpError(
288
+ "BAD_REQUEST",
289
+ `Invalid channel policy "${ch.policy}". Must be one of: ${VALID_CHANNEL_POLICIES.join(", ")}`,
290
+ 400,
291
+ );
292
+ }
293
+ }
294
+ }
295
+
138
296
  try {
139
297
  const contact = upsertContact({
140
298
  id: body.id,
@@ -144,9 +302,23 @@ export async function handleUpsertContact(
144
302
  responseExpectation: body.responseExpectation,
145
303
  preferredTone: body.preferredTone,
146
304
  role: body.role as ContactRole | undefined,
305
+ contactType: body.contactType as ContactType | undefined,
147
306
  assistantId,
148
- channels: body.channels,
307
+ channels: body.channels?.map((ch) => ({
308
+ ...ch,
309
+ status: ch.status as ChannelStatus | undefined,
310
+ policy: ch.policy as ChannelPolicy | undefined,
311
+ })),
149
312
  });
313
+
314
+ if (body.assistantMetadata) {
315
+ upsertAssistantContactMetadata({
316
+ contactId: contact.id,
317
+ species: body.assistantMetadata.species as AssistantSpecies,
318
+ metadata: body.assistantMetadata.metadata ?? null,
319
+ });
320
+ }
321
+
150
322
  return Response.json(
151
323
  { ok: true, contact },
152
324
  { status: contact.created ? 201 : 200 },
@@ -157,27 +329,6 @@ export async function handleUpsertContact(
157
329
  }
158
330
  }
159
331
 
160
- const VALID_CHANNEL_STATUSES: readonly ChannelStatus[] = [
161
- "active",
162
- "pending",
163
- "revoked",
164
- "blocked",
165
- "unverified",
166
- ];
167
- const VALID_CHANNEL_POLICIES: readonly ChannelPolicy[] = [
168
- "allow",
169
- "deny",
170
- "escalate",
171
- ];
172
-
173
- function isChannelStatus(value: string): value is ChannelStatus {
174
- return (VALID_CHANNEL_STATUSES as readonly string[]).includes(value);
175
- }
176
-
177
- function isChannelPolicy(value: string): value is ChannelPolicy {
178
- return (VALID_CHANNEL_POLICIES as readonly string[]).includes(value);
179
- }
180
-
181
332
  /**
182
333
  * PATCH /v1/contacts/channels/:channelId { status?, policy?, reason? }
183
334
  */
@@ -212,6 +363,23 @@ export async function handleUpdateContactChannel(
212
363
  );
213
364
  }
214
365
 
366
+ // Blocked-state guard: revoking a blocked channel is not allowed because
367
+ // blocking is a stronger action than revoking. The caller must explicitly
368
+ // unblock (set status to "active") before revoking.
369
+ if (body.status === "revoked") {
370
+ const existing = getChannelById(channelId);
371
+ if (!existing) {
372
+ return httpError("NOT_FOUND", `Channel "${channelId}" not found`, 404);
373
+ }
374
+ if (existing.status === "blocked") {
375
+ return httpError(
376
+ "CONFLICT",
377
+ "Cannot revoke a blocked channel. Unblock it first or leave it blocked.",
378
+ 409,
379
+ );
380
+ }
381
+ }
382
+
215
383
  const updated = updateChannelStatus(channelId, {
216
384
  status: body.status,
217
385
  policy: body.policy,
@@ -11,17 +11,13 @@
11
11
  * Guardian decisions additionally verify the actor is the bound guardian
12
12
  * via the AuthContext's actorPrincipalId.
13
13
  */
14
- import { applyCanonicalGuardianDecision } from "../../approvals/guardian-decision-primitive.js";
15
- import { isHttpAuthDisabled } from "../../config/env.js";
16
- import { findGuardianForChannel } from "../../contacts/contact-store.js";
17
14
  import {
18
15
  type CanonicalGuardianRequest,
19
- getCanonicalGuardianRequest,
20
- isRequestInConversationScope,
21
16
  listPendingRequestsByConversationScope,
22
17
  } from "../../memory/canonical-guardian-store.js";
18
+ import { requireBoundGuardian } from "../auth/require-bound-guardian.js";
23
19
  import type { AuthContext } from "../auth/types.js";
24
- import type { ApprovalAction } from "../channel-approval-types.js";
20
+ import { processGuardianDecision } from "../guardian-action-service.js";
25
21
  import type { GuardianDecisionPrompt } from "../guardian-decision-types.js";
26
22
  import { buildDecisionActions } from "../guardian-decision-types.js";
27
23
  import { httpError } from "../http-errors.js";
@@ -63,44 +59,6 @@ export function handleGuardianActionsPending(
63
59
  // POST /v1/guardian-actions/decision
64
60
  // ---------------------------------------------------------------------------
65
61
 
66
- /**
67
- * Verify that the actor from AuthContext is the bound guardian for the
68
- * vellum channel. Returns an error Response if not, or null if allowed.
69
- */
70
- function requireBoundGuardian(authContext: AuthContext): Response | null {
71
- // Dev bypass: when auth is disabled, skip guardian binding check
72
- // (mirrors enforcePolicy dev bypass in route-policy.ts)
73
- if (isHttpAuthDisabled()) {
74
- return null;
75
- }
76
- if (!authContext.actorPrincipalId) {
77
- return httpError(
78
- "FORBIDDEN",
79
- "Actor is not the bound guardian for this channel",
80
- 403,
81
- );
82
- }
83
- const guardianResult = findGuardianForChannel(
84
- "vellum",
85
- authContext.assistantId,
86
- );
87
- if (!guardianResult) {
88
- // No guardian yet — in pre-bootstrap state, allow through
89
- return null;
90
- }
91
- if (
92
- (guardianResult.channel.externalUserId ??
93
- guardianResult.contact.principalId) !== authContext.actorPrincipalId
94
- ) {
95
- return httpError(
96
- "FORBIDDEN",
97
- "Actor is not the bound guardian for this channel",
98
- 403,
99
- );
100
- }
101
- return null;
102
- }
103
-
104
62
  /**
105
63
  * Submit a guardian action decision.
106
64
  * Requires AuthContext with a bound guardian actor.
@@ -132,77 +90,24 @@ export async function handleGuardianActionDecision(
132
90
  return httpError("BAD_REQUEST", "action is required", 400);
133
91
  }
134
92
 
135
- const VALID_ACTIONS = new Set<string>([
136
- "approve_once",
137
- "approve_10m",
138
- "approve_thread",
139
- "approve_always",
140
- "reject",
141
- ]);
142
- if (!VALID_ACTIONS.has(action)) {
143
- return httpError(
144
- "BAD_REQUEST",
145
- `Invalid action: ${action}. Must be one of: approve_once, approve_10m, approve_thread, approve_always, reject`,
146
- 400,
147
- );
148
- }
149
-
150
- // Verify conversationId scoping before applying the canonical decision.
151
- // The decision is allowed when the conversationId matches the request's
152
- // source conversation OR a recorded delivery destination conversation.
153
- // Channel is scoped to 'vellum' to prevent cross-channel approval when
154
- // conversation ID namespaces overlap.
155
- if (conversationId) {
156
- const canonicalRequest = getCanonicalGuardianRequest(requestId);
157
- if (
158
- canonicalRequest &&
159
- canonicalRequest.conversationId &&
160
- !isRequestInConversationScope(requestId, conversationId, "vellum")
161
- ) {
162
- return httpError(
163
- "NOT_FOUND",
164
- "No pending guardian action found for this requestId",
165
- 404,
166
- );
167
- }
168
- }
169
-
170
- // Resolve actor identity from the AuthContext (set by JWT middleware).
171
- const actorExternalUserId = authContext.actorPrincipalId ?? undefined;
172
- const actorPrincipalId = authContext.actorPrincipalId ?? undefined;
173
-
174
- const canonicalResult = await applyCanonicalGuardianDecision({
93
+ const result = await processGuardianDecision({
175
94
  requestId,
176
- action: action as ApprovalAction,
95
+ action,
96
+ conversationId,
97
+ channel: "vellum",
177
98
  actorContext: {
178
- externalUserId: actorExternalUserId,
179
- channel: "vellum",
180
- guardianPrincipalId: actorPrincipalId,
99
+ externalUserId: authContext.actorPrincipalId ?? undefined,
100
+ guardianPrincipalId: authContext.actorPrincipalId ?? undefined,
181
101
  },
182
- userText: undefined,
183
102
  });
184
103
 
185
- if (canonicalResult.applied) {
186
- // When the CAS committed but the resolver failed, the side effect
187
- // (e.g. minting a verification session) did not happen. From the
188
- // caller's perspective the decision was not truly applied.
189
- if (canonicalResult.resolverFailed) {
190
- return Response.json({
191
- applied: false,
192
- reason: "resolver_failed",
193
- resolverFailureReason: canonicalResult.resolverFailureReason,
194
- requestId: canonicalResult.requestId,
195
- });
196
- }
197
-
198
- return Response.json({
199
- applied: true,
200
- requestId: canonicalResult.requestId,
201
- });
104
+ if (!result.ok) {
105
+ return httpError("BAD_REQUEST", result.message, 400);
202
106
  }
203
-
204
- // Return the reason for failure (stale, expired, not_found, etc.)
205
- return canonicalResult.reason === "not_found"
107
+ if (result.applied) {
108
+ return Response.json({ applied: true, requestId: result.requestId });
109
+ }
110
+ return result.reason === "not_found"
206
111
  ? httpError(
207
112
  "NOT_FOUND",
208
113
  "No pending guardian action found for this requestId",
@@ -210,8 +115,11 @@ export async function handleGuardianActionDecision(
210
115
  )
211
116
  : Response.json({
212
117
  applied: false,
213
- reason: canonicalResult.reason,
214
- requestId,
118
+ reason: result.reason,
119
+ ...(result.resolverFailureReason
120
+ ? { resolverFailureReason: result.resolverFailureReason }
121
+ : {}),
122
+ requestId: result.requestId ?? requestId,
215
123
  });
216
124
  }
217
125
 
@@ -30,12 +30,14 @@ import type {
30
30
  ApprovalConversationGenerator,
31
31
  ApprovalCopyGenerator,
32
32
  } from "../http-types.js";
33
+ import { parseApprovalIntent } from "../nl-approval-parser.js";
33
34
  import { handleGuardianCallbackDecision } from "./approval-strategies/guardian-callback-strategy.js";
34
35
  import { handleGuardianLegacyFallback } from "./approval-strategies/guardian-legacy-fallback-strategy.js";
35
36
  import { handleGuardianTextEngineDecision } from "./approval-strategies/guardian-text-engine-strategy.js";
36
37
  import {
37
38
  buildGuardianDenyContext,
38
39
  parseCallbackData,
40
+ parseReactionCallbackData,
39
41
  } from "./channel-route-shared.js";
40
42
  import { deliverStaleApprovalReply } from "./guardian-approval-reply-helpers.js";
41
43
 
@@ -115,6 +117,33 @@ export async function handleApprovalInterception(
115
117
  }
116
118
  }
117
119
 
120
+ // ── Slack reaction path ──
121
+ // Reactions produce `callbackData` of the form `reaction:<emoji_name>`.
122
+ // Handled before the pendingPrompt guard because guardian reactions arrive
123
+ // on the guardian's chat (guardianChatId), not the requester's conversation,
124
+ // so getChannelApprovalPrompt(conversationId) would return null.
125
+ // Only guardians can approve via reaction — non-guardian reactions are
126
+ // silently ignored to prevent self-approval.
127
+ if (callbackData?.startsWith("reaction:")) {
128
+ if (trustCtx.trustClass !== "guardian") {
129
+ return { handled: true, type: "stale_ignored" };
130
+ }
131
+ const reactionDecision = parseReactionCallbackData(callbackData);
132
+ if (!reactionDecision) {
133
+ // Unknown emoji — ignore silently
134
+ return { handled: true, type: "stale_ignored" };
135
+ }
136
+ const pending = getApprovalInfoByConversation(conversationId);
137
+ if (pending.length === 0) {
138
+ return { handled: true, type: "stale_ignored" };
139
+ }
140
+ const result = handleChannelDecision(conversationId, reactionDecision);
141
+ if (result.applied) {
142
+ return { handled: true, type: "decision_applied" };
143
+ }
144
+ return { handled: true, type: "stale_ignored" };
145
+ }
146
+
118
147
  // ── Standard approval interception (existing flow) ──
119
148
  const pendingPrompt = getChannelApprovalPrompt(conversationId);
120
149
  if (!pendingPrompt) return { handled: false };
@@ -394,6 +423,30 @@ export async function handleApprovalInterception(
394
423
  }
395
424
  }
396
425
 
426
+ // ── Slack reaction path ──
427
+ // Reactions produce `callbackData` of the form `reaction:<emoji_name>`.
428
+ // Only guardians can approve via reaction — non-guardian reactions are
429
+ // silently ignored to prevent self-approval.
430
+ if (callbackData?.startsWith("reaction:")) {
431
+ if (trustCtx.trustClass !== "guardian") {
432
+ return { handled: true, type: "stale_ignored" };
433
+ }
434
+ const reactionDecision = parseReactionCallbackData(callbackData);
435
+ if (!reactionDecision) {
436
+ // Unknown emoji — ignore silently
437
+ return { handled: true, type: "stale_ignored" };
438
+ }
439
+ const pending = getApprovalInfoByConversation(conversationId);
440
+ if (pending.length === 0) {
441
+ return { handled: true, type: "stale_ignored" };
442
+ }
443
+ const result = handleChannelDecision(conversationId, reactionDecision);
444
+ if (result.applied) {
445
+ return { handled: true, type: "decision_applied" };
446
+ }
447
+ return { handled: true, type: "stale_ignored" };
448
+ }
449
+
397
450
  // Try to extract a decision from callback data (button press) first.
398
451
  // Callback/button path remains deterministic and takes priority.
399
452
  if (callbackData) {
@@ -451,6 +504,29 @@ export async function handleApprovalInterception(
451
504
  });
452
505
  }
453
506
 
507
+ // ── Natural language approval intent parser ──
508
+ // Covers a broad set of colloquial approval/rejection phrases, emoji, and
509
+ // timed-approval variants. Runs before the legacy parser to provide wider
510
+ // coverage for channels (like Slack) that rely on plain-text responses.
511
+ if (pending.length > 0 && content) {
512
+ const nlIntent = parseApprovalIntent(content);
513
+ if (nlIntent && nlIntent.confidence >= 0.9) {
514
+ const nlDecision: ApprovalDecisionResult = {
515
+ action:
516
+ nlIntent.decision === "approve"
517
+ ? "approve_once"
518
+ : nlIntent.decision === "approve_10m"
519
+ ? "approve_10m"
520
+ : "reject",
521
+ source: "plain_text",
522
+ };
523
+ const nlResult = handleChannelDecision(conversationId, nlDecision);
524
+ if (nlResult.applied) {
525
+ return { handled: true, type: "decision_applied" };
526
+ }
527
+ }
528
+ }
529
+
454
530
  // ── Legacy deterministic fallback ──
455
531
  // When no conversational engine is available, use the deterministic parser
456
532
  // as a safety net for backward compatibility.