@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -8,10 +8,10 @@
8
8
  * POST /v1/ingress/members/:id/block — block a member
9
9
  *
10
10
  * Invites:
11
- * GET /v1/ingress/invites — list invites
12
- * POST /v1/ingress/invites — create an invite
13
- * DELETE /v1/ingress/invites/:id — revoke an invite
14
- * POST /v1/ingress/invites/redeem — redeem an invite
11
+ * GET /v1/ingress/invites — list invites
12
+ * POST /v1/ingress/invites — create an invite (supports voice)
13
+ * DELETE /v1/ingress/invites/:id — revoke an invite
14
+ * POST /v1/ingress/invites/redeem — redeem an invite (token or voice code)
15
15
  */
16
16
 
17
17
  import {
@@ -20,6 +20,7 @@ import {
20
20
  listIngressInvites,
21
21
  listIngressMembers,
22
22
  redeemIngressInvite,
23
+ redeemVoiceInviteCode,
23
24
  revokeIngressInvite,
24
25
  revokeIngressMember,
25
26
  upsertIngressMember,
@@ -130,6 +131,11 @@ export function handleListInvites(url: URL): Response {
130
131
 
131
132
  /**
132
133
  * POST /v1/ingress/invites
134
+ *
135
+ * For voice invites, pass `sourceChannel: "voice"` with required
136
+ * `expectedExternalUserId` (E.164 phone). Voice codes are always 6 digits.
137
+ * The response will include a one-time `voiceCode` field that must be
138
+ * communicated to the invited user out-of-band.
133
139
  */
134
140
  export async function handleCreateInvite(req: Request): Promise<Response> {
135
141
  const body = (await req.json()) as Record<string, unknown>;
@@ -139,6 +145,8 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
139
145
  note: body.note as string | undefined,
140
146
  maxUses: body.maxUses as number | undefined,
141
147
  expiresInMs: body.expiresInMs as number | undefined,
148
+ expectedExternalUserId: body.expectedExternalUserId as string | undefined,
149
+ voiceCodeDigits: body.voiceCodeDigits as number | undefined,
142
150
  });
143
151
 
144
152
  if (!result.ok) {
@@ -161,10 +169,50 @@ export function handleRevokeInvite(inviteId: string): Response {
161
169
 
162
170
  /**
163
171
  * POST /v1/ingress/invites/redeem
172
+ *
173
+ * Unified invite redemption endpoint. Supports two modes:
174
+ *
175
+ * 1. **Token-based** (existing): pass `token`, `sourceChannel`, `externalUserId`, etc.
176
+ * 2. **Voice code** (new): pass `code` and `callerExternalUserId` (E.164 phone).
177
+ * Optionally pass `assistantId`.
178
+ *
179
+ * The presence of `code` in the body selects voice-code redemption.
164
180
  */
165
181
  export async function handleRedeemInvite(req: Request): Promise<Response> {
166
182
  const body = (await req.json()) as Record<string, unknown>;
167
183
 
184
+ // Voice-code redemption path: triggered when `code` is present
185
+ if (body.code != null) {
186
+ const callerExternalUserId = body.callerExternalUserId as string | undefined;
187
+ const code = body.code as string | undefined;
188
+
189
+ if (!callerExternalUserId || !code) {
190
+ return Response.json(
191
+ { ok: false, error: 'callerExternalUserId and code are required' },
192
+ { status: 400 },
193
+ );
194
+ }
195
+
196
+ const result = redeemVoiceInviteCode({
197
+ assistantId: body.assistantId as string | undefined,
198
+ callerExternalUserId,
199
+ sourceChannel: 'voice',
200
+ code,
201
+ });
202
+
203
+ if (!result.ok) {
204
+ return Response.json({ ok: false, error: result.reason }, { status: 400 });
205
+ }
206
+
207
+ return Response.json({
208
+ ok: true,
209
+ type: result.type,
210
+ memberId: result.memberId,
211
+ ...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
212
+ });
213
+ }
214
+
215
+ // Token-based redemption path (default)
168
216
  const result = redeemIngressInvite({
169
217
  token: body.token as string | undefined,
170
218
  externalUserId: body.externalUserId as string | undefined,
@@ -75,6 +75,9 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
75
75
 
76
76
  const result = ctx.pairingStore.beginRequest({ pairingRequestId, pairingSecret, deviceId, deviceName });
77
77
  if (!result.ok) {
78
+ if (result.reason === 'already_paired') {
79
+ return httpError('CONFLICT', 'This pairing request is already bound to another device', 409);
80
+ }
78
81
  const statusCode = result.reason === 'invalid_secret' ? 403 : result.reason === 'not_found' ? 403 : 410;
79
82
  return httpError('FORBIDDEN', 'Forbidden', statusCode);
80
83
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tool grant request creation and guardian notification helper.
3
+ *
4
+ * Encapsulates the "create/dedupe canonical tool_grant_request + emit notification"
5
+ * logic so non-guardian channel actors can escalate tool invocations that require
6
+ * guardian approval. Modeled after the access-request-helper pattern.
7
+ *
8
+ * Invariants preserved:
9
+ * - Unverified actors are fail-closed (caller must gate before calling).
10
+ * - Guardians cannot self-approve (grant minting uses guardian identity).
11
+ * - Notification routing goes through emitNotificationSignal().
12
+ */
13
+
14
+ import type { ChannelId } from '../channels/types.js';
15
+ import {
16
+ createCanonicalGuardianDelivery,
17
+ createCanonicalGuardianRequest,
18
+ listCanonicalGuardianRequests,
19
+ } from '../memory/canonical-guardian-store.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
21
+ import { getLogger } from '../util/logger.js';
22
+ import { getGuardianBinding } from './channel-guardian-service.js';
23
+ import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
24
+
25
+ const log = getLogger('tool-grant-request-helper');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface ToolGrantRequestParams {
32
+ assistantId: string;
33
+ sourceChannel: ChannelId;
34
+ conversationId: string;
35
+ requesterExternalUserId: string;
36
+ requesterChatId?: string;
37
+ requesterIdentifier?: string;
38
+ toolName: string;
39
+ inputDigest: string;
40
+ questionText: string;
41
+ }
42
+
43
+ export type ToolGrantRequestResult =
44
+ | { created: true; requestId: string; requestCode: string | null }
45
+ | { deduped: true; requestId: string; requestCode: string | null }
46
+ | { failed: true; reason: 'no_guardian_binding' | 'missing_identity' };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helper
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Create/dedupe a canonical tool_grant_request and emit a notification signal
54
+ * so the guardian can approve or deny the tool invocation.
55
+ *
56
+ * Returns a result indicating whether a new request was created, an existing
57
+ * one was deduped, or the escalation failed (no binding, missing identity).
58
+ */
59
+ export function createOrReuseToolGrantRequest(
60
+ params: ToolGrantRequestParams,
61
+ ): ToolGrantRequestResult {
62
+ const {
63
+ assistantId,
64
+ sourceChannel,
65
+ conversationId,
66
+ requesterExternalUserId,
67
+ requesterChatId,
68
+ requesterIdentifier,
69
+ toolName,
70
+ inputDigest,
71
+ questionText,
72
+ } = params;
73
+
74
+ if (!requesterExternalUserId) {
75
+ return { failed: true, reason: 'missing_identity' };
76
+ }
77
+
78
+ const binding = getGuardianBinding(assistantId, sourceChannel);
79
+ if (!binding) {
80
+ log.debug(
81
+ { sourceChannel, assistantId },
82
+ 'No guardian binding for tool grant request escalation',
83
+ );
84
+ return { failed: true, reason: 'no_guardian_binding' };
85
+ }
86
+
87
+ // Deduplicate: skip creation if there is already a pending canonical request
88
+ // for the same requester + conversation + tool + input digest + guardian.
89
+ // Guardian identity is included so that after a guardian rebind, old requests
90
+ // tied to the previous guardian don't block creation of a new approvable request.
91
+ const existing = listCanonicalGuardianRequests({
92
+ status: 'pending',
93
+ requesterExternalUserId,
94
+ conversationId,
95
+ kind: 'tool_grant_request',
96
+ toolName,
97
+ });
98
+ const dedupeMatch = existing.find(
99
+ (r) => r.inputDigest === inputDigest && r.guardianExternalUserId === binding.guardianExternalUserId,
100
+ );
101
+ if (dedupeMatch) {
102
+ log.debug(
103
+ {
104
+ sourceChannel,
105
+ requesterExternalUserId,
106
+ toolName,
107
+ existingId: dedupeMatch.id,
108
+ },
109
+ 'Skipping duplicate tool grant request notification',
110
+ );
111
+ return { deduped: true, requestId: dedupeMatch.id, requestCode: dedupeMatch.requestCode };
112
+ }
113
+
114
+ const senderLabel = requesterIdentifier || requesterExternalUserId;
115
+ const requestId = `tool-grant-${assistantId}-${sourceChannel}-${requesterExternalUserId}-${Date.now()}`;
116
+
117
+ const canonicalRequest = createCanonicalGuardianRequest({
118
+ id: requestId,
119
+ kind: 'tool_grant_request',
120
+ sourceType: 'channel',
121
+ sourceChannel,
122
+ conversationId,
123
+ requesterExternalUserId,
124
+ requesterChatId: requesterChatId ?? undefined,
125
+ guardianExternalUserId: binding.guardianExternalUserId,
126
+ toolName,
127
+ inputDigest,
128
+ questionText,
129
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
130
+ });
131
+
132
+ // Emit notification so guardian is alerted. Uses 'guardian.question' as
133
+ // sourceEventName so that existing request-code guidance in the notification
134
+ // pipeline is preserved.
135
+ const signalPromise = emitNotificationSignal({
136
+ sourceEventName: 'guardian.question',
137
+ sourceChannel,
138
+ sourceSessionId: conversationId,
139
+ assistantId,
140
+ attentionHints: {
141
+ requiresAction: true,
142
+ urgency: 'high',
143
+ isAsyncBackground: false,
144
+ visibleInSourceNow: false,
145
+ },
146
+ contextPayload: {
147
+ requestId: canonicalRequest.id,
148
+ requestCode: canonicalRequest.requestCode,
149
+ sourceChannel,
150
+ requesterExternalUserId,
151
+ requesterChatId: requesterChatId ?? null,
152
+ requesterIdentifier: senderLabel,
153
+ toolName,
154
+ questionText,
155
+ },
156
+ dedupeKey: `tool-grant-request:${canonicalRequest.id}`,
157
+ onThreadCreated: (info) => {
158
+ createCanonicalGuardianDelivery({
159
+ requestId: canonicalRequest.id,
160
+ destinationChannel: 'vellum',
161
+ destinationConversationId: info.conversationId,
162
+ });
163
+ },
164
+ });
165
+
166
+ // Record deliveries from the notification pipeline results (fire-and-forget).
167
+ void signalPromise.then((signalResult) => {
168
+ for (const result of signalResult.deliveryResults) {
169
+ if (result.channel === 'vellum') continue; // handled in onThreadCreated
170
+ if (result.channel !== 'telegram' && result.channel !== 'sms') continue;
171
+ createCanonicalGuardianDelivery({
172
+ requestId: canonicalRequest.id,
173
+ destinationChannel: result.channel,
174
+ destinationChatId: result.destination.length > 0 ? result.destination : undefined,
175
+ });
176
+ }
177
+ });
178
+
179
+ log.info(
180
+ {
181
+ sourceChannel,
182
+ requesterExternalUserId,
183
+ toolName,
184
+ requestId: canonicalRequest.id,
185
+ requestCode: canonicalRequest.requestCode,
186
+ },
187
+ 'Guardian notified of tool grant request',
188
+ );
189
+
190
+ return {
191
+ created: true,
192
+ requestId: canonicalRequest.id,
193
+ requestCode: canonicalRequest.requestCode,
194
+ };
195
+ }
@@ -13,6 +13,7 @@ import { resolveExecutionTarget } from './execution-target.js';
13
13
  import { executeWithTimeout,safeTimeoutMs } from './execution-timeout.js';
14
14
  import { PermissionChecker } from './permission-checker.js';
15
15
  import { SecretDetectionHandler } from './secret-detection-handler.js';
16
+ import { extractAndSanitize } from './sensitive-output-placeholders.js';
16
17
  import { applyEdit } from './shared/filesystem/edit-engine.js';
17
18
  import { sandboxPolicy } from './shared/filesystem/path-policy.js';
18
19
  import { MAX_FILE_SIZE_BYTES } from './shared/filesystem/size-guard.js';
@@ -182,6 +183,15 @@ export class ToolExecutor {
182
183
  );
183
184
  }
184
185
 
186
+ // Sensitive output extraction: strip directives, replace raw values
187
+ // with placeholders, and attach bindings for agent-loop substitution.
188
+ // Runs before secret detection so that raw sensitive values are already
189
+ // replaced and won't trigger entropy-based redaction.
190
+ const { sanitizedContent, bindings } = extractAndSanitize(execResult.content);
191
+ if (bindings.length > 0) {
192
+ execResult = { ...execResult, content: sanitizedContent, sensitiveBindings: bindings };
193
+ }
194
+
185
195
  // Secret detection on tool output
186
196
  const secretResult = await this.secretDetectionHandler.handle(
187
197
  execResult, name, input, context, executionTarget,
@@ -193,6 +203,8 @@ export class ToolExecutor {
193
203
  execResult = secretResult.result;
194
204
 
195
205
  const durationMs = Date.now() - startTime;
206
+ // Strip sensitiveBindings from lifecycle event to prevent raw values leaking
207
+ const { sensitiveBindings: _sb, ...safeResult } = execResult;
196
208
  emitLifecycleEvent(context, {
197
209
  type: 'executed',
198
210
  toolName: name,
@@ -205,7 +217,7 @@ export class ToolExecutor {
205
217
  riskLevel,
206
218
  decision,
207
219
  durationMs,
208
- result: execResult,
220
+ result: safeResult,
209
221
  });
210
222
 
211
223
  void getHookManager().trigger('post-tool-execute', {
@@ -124,13 +124,13 @@ export function isGuardianControlPlaneInvocation(
124
124
  export function enforceGuardianOnlyPolicy(
125
125
  toolName: string,
126
126
  input: Record<string, unknown>,
127
- actorRole: string | undefined,
127
+ trustClass: string | undefined,
128
128
  ): { denied: boolean; reason?: string } {
129
129
  if (!isGuardianControlPlaneInvocation(toolName, input)) {
130
130
  return { denied: false };
131
131
  }
132
132
 
133
- if (actorRole === 'guardian' || actorRole === undefined) {
133
+ if (trustClass === 'guardian' || trustClass === undefined) {
134
134
  return { denied: false };
135
135
  }
136
136
 
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Sensitive output placeholder extraction and substitution.
3
+ *
4
+ * Tool outputs may contain `<vellum-sensitive-output kind="..." value="..." />`
5
+ * directives. This module:
6
+ * 1. Parses and strips those directives from tool output.
7
+ * 2. Replaces any raw sensitive values remaining in the output with stable,
8
+ * high-uniqueness placeholders so the LLM never sees the real values.
9
+ * 3. Returns bindings (placeholder -> real value) for deterministic
10
+ * post-generation substitution in the agent loop.
11
+ *
12
+ * Raw sensitive values MUST NOT be logged or emitted in lifecycle events.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type SensitiveOutputKind = 'invite_code';
20
+
21
+ export interface SensitiveOutputBinding {
22
+ kind: SensitiveOutputKind;
23
+ placeholder: string;
24
+ value: string;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Directive regex
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const DIRECTIVE_RE =
32
+ /<vellum-sensitive-output\s+kind="([^"]+)"\s+value="([^"]+)"\s*\/>/g;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Placeholder generation
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const KIND_PREFIX: Record<SensitiveOutputKind, string> = {
39
+ invite_code: 'VELLUM_ASSISTANT_INVITE_CODE_',
40
+ };
41
+
42
+ const VALID_KINDS = new Set<string>(Object.keys(KIND_PREFIX));
43
+
44
+ /**
45
+ * Generate an 8-char uppercase base-36 short ID.
46
+ * Provides ~41 bits of entropy — sufficient for intra-request uniqueness.
47
+ */
48
+ function generateShortId(): string {
49
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
50
+ let id = '';
51
+ for (let i = 0; i < 8; i++) {
52
+ id += chars[Math.floor(Math.random() * chars.length)];
53
+ }
54
+ return id;
55
+ }
56
+
57
+ function makePlaceholder(kind: SensitiveOutputKind): string {
58
+ return `${KIND_PREFIX[kind]}${generateShortId()}`;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public API
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface SanitizeResult {
66
+ sanitizedContent: string;
67
+ bindings: SensitiveOutputBinding[];
68
+ }
69
+
70
+ /**
71
+ * Extract `<vellum-sensitive-output>` directives from tool output content,
72
+ * strip them, replace any remaining occurrences of the raw sensitive values
73
+ * with placeholders, and return the bindings for downstream substitution.
74
+ *
75
+ * Guarantees:
76
+ * - Directives are fully removed from the returned content.
77
+ * - Empty values are silently dropped.
78
+ * - Duplicate values produce a single binding (same placeholder).
79
+ * - Unknown kinds are silently ignored.
80
+ */
81
+ export function extractAndSanitize(content: string): SanitizeResult {
82
+ const bindings: SensitiveOutputBinding[] = [];
83
+ const seenValues = new Map<string, SensitiveOutputBinding>();
84
+
85
+ // Step 1: parse directives
86
+ // Reset lastIndex for safety since the regex is global
87
+ DIRECTIVE_RE.lastIndex = 0;
88
+ let match: RegExpExecArray | undefined;
89
+ while ((match = DIRECTIVE_RE.exec(content) ?? undefined) !== undefined) {
90
+ const kind = match[1];
91
+ const value = match[2];
92
+
93
+ if (!value || value.trim().length === 0) continue;
94
+ if (!VALID_KINDS.has(kind)) continue;
95
+
96
+ const typedKind = kind as SensitiveOutputKind;
97
+ if (!seenValues.has(value)) {
98
+ const binding: SensitiveOutputBinding = {
99
+ kind: typedKind,
100
+ placeholder: makePlaceholder(typedKind),
101
+ value,
102
+ };
103
+ bindings.push(binding);
104
+ seenValues.set(value, binding);
105
+ }
106
+ }
107
+
108
+ if (bindings.length === 0) {
109
+ return { sanitizedContent: content, bindings: [] };
110
+ }
111
+
112
+ // Step 2: strip directive tags
113
+ let sanitized = content.replace(DIRECTIVE_RE, '');
114
+
115
+ // Step 3: replace raw values with placeholders throughout remaining content
116
+ for (const binding of bindings) {
117
+ sanitized = sanitized.split(binding.value).join(binding.placeholder);
118
+ }
119
+
120
+ return { sanitizedContent: sanitized, bindings };
121
+ }
122
+
123
+ /**
124
+ * Apply placeholder->value substitution to a text string.
125
+ * Used by the agent loop to resolve placeholders in streamed deltas
126
+ * and final message content.
127
+ */
128
+ export function applySubstitutions(
129
+ text: string,
130
+ substitutionMap: ReadonlyMap<string, string>,
131
+ ): string {
132
+ if (substitutionMap.size === 0) return text;
133
+
134
+ let result = text;
135
+ for (const [placeholder, value] of substitutionMap) {
136
+ result = result.split(placeholder).join(value);
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Chunk-safe substitution for streaming text deltas.
143
+ *
144
+ * Because a placeholder like `VELLUM_ASSISTANT_INVITE_CODE_AB12CD34` may be
145
+ * split across consecutive streamed chunks, this function buffers a trailing
146
+ * segment that could be the start of an incomplete placeholder and returns it
147
+ * as `pending`. The caller must prepend `pending` to the next chunk.
148
+ *
149
+ * Returns `{ emit, pending }`:
150
+ * - `emit`: text safe to send to the client (all complete placeholders resolved).
151
+ * - `pending`: trailing text that might be an incomplete placeholder prefix.
152
+ */
153
+ export function applyStreamingSubstitution(
154
+ text: string,
155
+ substitutionMap: ReadonlyMap<string, string>,
156
+ ): { emit: string; pending: string } {
157
+ if (substitutionMap.size === 0) {
158
+ return { emit: text, pending: '' };
159
+ }
160
+
161
+ // First, resolve any complete placeholders
162
+ let resolved = text;
163
+ for (const [placeholder, value] of substitutionMap) {
164
+ resolved = resolved.split(placeholder).join(value);
165
+ }
166
+
167
+ // Check if the tail of resolved text could be an incomplete placeholder prefix.
168
+ // All current placeholders start with "VELLUM_ASSISTANT_".
169
+ const PREFIX = 'VELLUM_ASSISTANT_';
170
+ const minSuffixLen = 1; // At minimum, one char of the prefix
171
+
172
+ // Walk backwards from the end to find a trailing partial match of any placeholder prefix
173
+ let pendingStart = resolved.length;
174
+ for (let i = Math.max(0, resolved.length - getMaxPlaceholderLength(substitutionMap)); i < resolved.length; i++) {
175
+ const tail = resolved.slice(i);
176
+ // Check if any placeholder starts with this tail
177
+ if (tail.length >= minSuffixLen && PREFIX.startsWith(tail)) {
178
+ pendingStart = i;
179
+ break;
180
+ }
181
+ // Also check if any full placeholder key starts with this tail
182
+ for (const placeholder of substitutionMap.keys()) {
183
+ if (placeholder.startsWith(tail) && tail.length < placeholder.length) {
184
+ pendingStart = i;
185
+ break;
186
+ }
187
+ }
188
+ if (pendingStart !== resolved.length) break;
189
+ }
190
+
191
+ return {
192
+ emit: resolved.slice(0, pendingStart),
193
+ pending: resolved.slice(pendingStart),
194
+ };
195
+ }
196
+
197
+ function getMaxPlaceholderLength(map: ReadonlyMap<string, string>): number {
198
+ let max = 0;
199
+ for (const key of map.keys()) {
200
+ if (key.length > max) max = key.length;
201
+ }
202
+ return max;
203
+ }
@@ -1,4 +1,5 @@
1
1
  import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
2
+ import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
2
3
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
3
4
  import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
4
5
  import { getLogger } from '../util/logger.js';
@@ -9,8 +10,8 @@ import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifec
9
10
 
10
11
  const log = getLogger('tool-approval-handler');
11
12
 
12
- function isUntrustedGuardianActorRole(role: ToolContext['guardianActorRole']): boolean {
13
- return role === 'non-guardian' || role === 'unverified_channel';
13
+ function isUntrustedGuardianTrustClass(role: ToolContext['guardianTrustClass']): boolean {
14
+ return role === 'trusted_contact' || role === 'unknown';
14
15
  }
15
16
 
16
17
  function requiresGuardianApprovalForActor(
@@ -25,10 +26,10 @@ function requiresGuardianApprovalForActor(
25
26
  }
26
27
 
27
28
  function guardianApprovalDeniedMessage(
28
- actorRole: ToolContext['guardianActorRole'],
29
+ trustClass: ToolContext['guardianTrustClass'],
29
30
  toolName: string,
30
31
  ): string {
31
- if (actorRole === 'unverified_channel') {
32
+ if (trustClass === 'unknown') {
32
33
  return `Permission denied for "${toolName}": this action requires guardian approval from a verified channel identity.`;
33
34
  }
34
35
  return `Permission denied for "${toolName}": this action requires guardian approval and the current actor is not the guardian.`;
@@ -81,13 +82,13 @@ export class ToolApprovalHandler {
81
82
  }
82
83
 
83
84
  // Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
84
- const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianActorRole);
85
+ const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianTrustClass);
85
86
  if (guardianCheck.denied) {
86
87
  log.warn({
87
88
  toolName: name,
88
89
  sessionId: context.sessionId,
89
90
  conversationId: context.conversationId,
90
- actorRole: context.guardianActorRole,
91
+ trustClass: context.guardianTrustClass,
91
92
  reason: 'guardian_only_policy',
92
93
  }, 'Guardian-only policy blocked tool invocation');
93
94
  const durationMs = Date.now() - startTime;
@@ -117,7 +118,7 @@ export class ToolApprovalHandler {
117
118
  let deferredConsumeParams: Parameters<typeof consumeGrantForInvocation>[0] | null = null;
118
119
 
119
120
  if (
120
- isUntrustedGuardianActorRole(context.guardianActorRole)
121
+ isUntrustedGuardianTrustClass(context.guardianTrustClass)
121
122
  && requiresGuardianApprovalForActor(name, input, executionTarget)
122
123
  ) {
123
124
  const inputDigest = computeToolApprovalDigest(name, input);
@@ -232,7 +233,7 @@ export class ToolApprovalHandler {
232
233
  toolName: name,
233
234
  sessionId: context.sessionId,
234
235
  conversationId: context.conversationId,
235
- actorRole: context.guardianActorRole,
236
+ trustClass: context.guardianTrustClass,
236
237
  executionTarget,
237
238
  grantId: grantResult.grant.id,
238
239
  }, 'Scoped grant consumed — allowing untrusted actor tool invocation');
@@ -266,15 +267,57 @@ export class ToolApprovalHandler {
266
267
  }
267
268
 
268
269
  // No matching grant or race condition — deny.
269
- const reason = guardianApprovalDeniedMessage(context.guardianActorRole, name);
270
+ //
271
+ // For verified non-guardian actors with sufficient context, escalate to
272
+ // the guardian by creating a canonical tool_grant_request. Unverified
273
+ // actors remain fail-closed with no escalation.
274
+ let escalationMessage: string | undefined;
275
+ if (
276
+ context.guardianTrustClass === 'trusted_contact'
277
+ && context.assistantId
278
+ && context.executionChannel
279
+ && context.requesterExternalUserId
280
+ ) {
281
+ const inputDigest = deferredConsumeParams?.inputDigest
282
+ ?? computeToolApprovalDigest(name, input);
283
+ const escalation = createOrReuseToolGrantRequest({
284
+ assistantId: context.assistantId,
285
+ sourceChannel: context.executionChannel as import('../channels/types.js').ChannelId,
286
+ conversationId: context.conversationId,
287
+ requesterExternalUserId: context.requesterExternalUserId,
288
+ requesterChatId: context.requesterChatId,
289
+ toolName: name,
290
+ inputDigest,
291
+ questionText: `Trusted contact is requesting permission to use "${name}"`,
292
+ });
293
+
294
+ if ('created' in escalation) {
295
+ const codeSuffix = escalation.requestCode
296
+ ? ` (request code: ${escalation.requestCode})`
297
+ : '';
298
+ escalationMessage = `Permission denied for "${name}": this action requires guardian approval. `
299
+ + `A request has been sent to the guardian${codeSuffix}. `
300
+ + `Please retry after the guardian approves.`;
301
+ } else if ('deduped' in escalation) {
302
+ const codeSuffix = escalation.requestCode
303
+ ? ` (request code: ${escalation.requestCode})`
304
+ : '';
305
+ escalationMessage = `Permission denied for "${name}": guardian approval is already pending${codeSuffix}. `
306
+ + `Please retry after the guardian approves.`;
307
+ }
308
+ // If escalation.failed, fall through to generic denial message.
309
+ }
310
+
311
+ const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianTrustClass, name);
270
312
  log.warn({
271
313
  toolName: name,
272
314
  sessionId: context.sessionId,
273
315
  conversationId: context.conversationId,
274
- actorRole: context.guardianActorRole,
316
+ trustClass: context.guardianTrustClass,
275
317
  executionTarget,
276
318
  reason: 'guardian_approval_required',
277
319
  grantMissReason: grantResult.reason,
320
+ escalated: !!escalationMessage,
278
321
  }, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
279
322
  const durationMs = Date.now() - startTime;
280
323
  emitLifecycleEvent({