@vellumai/assistant 0.3.18 → 0.3.19

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 (42) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/docs/architecture/security.md +80 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  5. package/src/__tests__/call-controller.test.ts +170 -0
  6. package/src/__tests__/checker.test.ts +60 -0
  7. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  8. package/src/__tests__/guardian-dispatch.test.ts +61 -1
  9. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  10. package/src/__tests__/ipc-snapshot.test.ts +1 -0
  11. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  12. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  13. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  14. package/src/__tests__/trust-store.test.ts +2 -0
  15. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  16. package/src/calls/call-controller.ts +27 -6
  17. package/src/calls/call-domain.ts +12 -0
  18. package/src/calls/guardian-dispatch.ts +8 -0
  19. package/src/calls/relay-server.ts +13 -0
  20. package/src/calls/voice-session-bridge.ts +42 -3
  21. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  22. package/src/config/schema.ts +6 -0
  23. package/src/config/skills-schema.ts +27 -0
  24. package/src/daemon/handlers/config-channels.ts +18 -0
  25. package/src/daemon/handlers/skills.ts +45 -2
  26. package/src/daemon/ipc-contract/skills.ts +1 -0
  27. package/src/daemon/session-process.ts +12 -0
  28. package/src/memory/db-init.ts +9 -1
  29. package/src/memory/embedding-local.ts +16 -7
  30. package/src/memory/guardian-action-store.ts +8 -0
  31. package/src/memory/guardian-verification.ts +1 -1
  32. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  33. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  34. package/src/memory/migrations/index.ts +2 -0
  35. package/src/memory/schema.ts +30 -0
  36. package/src/memory/scoped-approval-grants.ts +509 -0
  37. package/src/permissions/checker.ts +27 -0
  38. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  39. package/src/runtime/routes/guardian-approval-interception.ts +116 -0
  40. package/src/runtime/routes/inbound-message-handler.ts +94 -27
  41. package/src/security/tool-approval-digest.ts +67 -0
  42. package/src/skills/remote-skill-policy.ts +131 -0
@@ -27,6 +27,10 @@ export interface GuardianDispatchParams {
27
27
  conversationId: string;
28
28
  assistantId: string;
29
29
  pendingQuestion: CallPendingQuestion;
30
+ /** Tool identity for tool-approval requests (absent for informational ASK_GUARDIAN). */
31
+ toolName?: string;
32
+ /** Canonical SHA-256 digest of tool input for tool-approval requests. */
33
+ inputDigest?: string;
30
34
  }
31
35
 
32
36
  function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
@@ -48,6 +52,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
48
52
  conversationId,
49
53
  assistantId,
50
54
  pendingQuestion,
55
+ toolName,
56
+ inputDigest,
51
57
  } = params;
52
58
 
53
59
  try {
@@ -63,6 +69,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
63
69
  pendingQuestionId: pendingQuestion.id,
64
70
  questionText: pendingQuestion.questionText,
65
71
  expiresAt,
72
+ toolName,
73
+ inputDigest,
66
74
  });
67
75
 
68
76
  log.info(
@@ -12,6 +12,7 @@ import type { ServerWebSocket } from 'bun';
12
12
 
13
13
  import { getConfig } from '../config/loader.js';
14
14
  import * as conversationStore from '../memory/conversation-store.js';
15
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
15
16
  import {
16
17
  getPendingChallenge,
17
18
  validateAndConsumeChallenge,
@@ -351,6 +352,18 @@ export class RelayConnection {
351
352
  }
352
353
 
353
354
  expirePendingQuestions(this.callSessionId);
355
+
356
+ // Revoke any scoped approval grants bound to this call session.
357
+ // Revoke by both callSessionId and conversationId because the
358
+ // guardian-approval-interception minting path sets callSessionId: null
359
+ // but always sets conversationId.
360
+ try {
361
+ revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
362
+ revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
363
+ } catch (err) {
364
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on transport close');
365
+ }
366
+
354
367
  persistCallCompletionMessage(session.conversationId, this.callSessionId).catch((err) => {
355
368
  log.error({ err, conversationId: session.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
356
369
  });
@@ -18,7 +18,9 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
18
18
  import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
19
19
  import { buildAssistantEvent } from '../runtime/assistant-event.js';
20
20
  import { assistantEventHub } from '../runtime/assistant-event-hub.js';
21
+ import { consumeScopedApprovalGrantByToolSignature } from '../memory/scoped-approval-grants.js';
21
22
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
23
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
22
24
  import { IngressBlockedError } from '../util/errors.js';
23
25
  import { getLogger } from '../util/logger.js';
24
26
 
@@ -81,6 +83,8 @@ export interface VoiceRunEventSink {
81
83
  export interface VoiceTurnOptions {
82
84
  /** The conversation ID for this voice call's session. */
83
85
  conversationId: string;
86
+ /** The call session ID for scoped grant matching. */
87
+ callSessionId?: string;
84
88
  /** The transcribed caller utterance or synthetic marker. */
85
89
  content: string;
86
90
  /** Assistant scope for multi-assistant channels. */
@@ -147,7 +151,12 @@ function buildVoiceCallControlPrompt(opts: {
147
151
  '1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.',
148
152
  ...(opts.isCallerGuardian
149
153
  ? ['2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.']
150
- : ['2. You can consult your guardian at any time by including [ASK_GUARDIAN: your question here] in your response. When you do, add a natural hold message like "Let me check on that for you."']
154
+ : [[
155
+ '2. You can consult your guardian in two ways:',
156
+ ' - For general questions or information: [ASK_GUARDIAN: your question here]',
157
+ ' - For tool/action permission requests: [ASK_GUARDIAN_APPROVAL: {"question":"Describe what you need permission for","toolName":"the_tool_name","input":{...tool input object...}}]',
158
+ ' Use ASK_GUARDIAN_APPROVAL when you need permission to execute a specific tool or action. Use ASK_GUARDIAN for everything else (general questions, advice, information). When you use either marker, add a natural hold message like "Let me check on that for you."',
159
+ ].join('\n')]
151
160
  ),
152
161
  );
153
162
 
@@ -194,7 +203,7 @@ function buildVoiceCallControlPrompt(opts: {
194
203
 
195
204
  lines.push(
196
205
  '9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.',
197
- '10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian.',
206
+ '10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
198
207
  '</voice_call_control>',
199
208
  );
200
209
 
@@ -339,9 +348,39 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
339
348
  session.updateClient((msg: ServerMessage) => {
340
349
  if (msg.type === 'confirmation_request') {
341
350
  if (autoDeny) {
351
+ // Before auto-denying, check if a guardian from another channel
352
+ // has pre-approved this exact tool invocation via a scoped grant.
353
+ const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
354
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
355
+ toolName: msg.toolName,
356
+ inputDigest,
357
+ consumingRequestId: msg.requestId,
358
+ assistantId: opts.assistantId,
359
+ executionChannel: 'voice',
360
+ conversationId: opts.conversationId,
361
+ callSessionId: opts.callSessionId,
362
+ requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
363
+ });
364
+
365
+ if (consumeResult.ok) {
366
+ log.info(
367
+ { turnId, toolName: msg.toolName, grantId: consumeResult.grant?.id },
368
+ 'Consumed scoped grant — allowing non-guardian voice confirmation',
369
+ );
370
+ session.handleConfirmationResponse(
371
+ msg.requestId,
372
+ 'allow',
373
+ undefined,
374
+ undefined,
375
+ `Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
376
+ );
377
+ publishToHub(msg);
378
+ return;
379
+ }
380
+
342
381
  log.info(
343
382
  { turnId, toolName: msg.toolName },
344
- 'Auto-denying confirmation request for voice turn (forceStrictSideEffects)',
383
+ 'Auto-denying confirmation request for voice turn (no matching scoped grant)',
345
384
  );
346
385
  session.handleConfirmationResponse(
347
386
  msg.requestId,
@@ -12,6 +12,24 @@ Use `send_notification` for user-facing alerts and notifications. This tool rout
12
12
  - `preferred_channels` are **routing hints**, not hard channel forcing. The notification router makes the final delivery decision based on user preferences, channel availability, and urgency.
13
13
  - Channel selection and delivery are handled entirely by the notification router -- do not attempt to control delivery manually.
14
14
 
15
+ ## Deduplication (`dedupe_key`)
16
+
17
+ - `dedupe_key` suppresses duplicate signals. A second notification with the same key is **dropped entirely** within a **1-hour window**. After the window expires, the same key is accepted again.
18
+ - Never reuse a `dedupe_key` across logically distinct notifications, even if they are related. The key means "this exact event already fired," not "these events are in the same category."
19
+ - If you omit `dedupe_key`, the LLM decision engine may generate one automatically based on signal context. This means even keyless signals can be deduplicated if the engine considers them duplicates of a recent event.
20
+
21
+ ## Threading
22
+
23
+ Thread grouping is handled by the LLM-powered decision engine, not by any parameter you pass. There is no explicit "post to thread X" parameter — thread reuse is inferred, not commanded.
24
+
25
+ **How it works:** The engine evaluates recent notification thread candidates and decides whether a new signal is a continuation of an existing thread based on `source_event_name`, provenance metadata, and message content. Use natural, descriptive titles and bodies — the engine groups by semantic relatedness, not string matching.
26
+
27
+ **`source_event_name` is the primary grouping signal.** Use a stable event name for notifications that belong to the same logical stream (e.g. `dog.news.thread.reply` for all replies in a thread). Use a distinct event name when the notification represents a genuinely different kind of event.
28
+
29
+ **Practical constraints:**
30
+ - Thread candidates are scoped to the **last 24 hours** (max 5 per channel). You cannot reuse an old thread from days ago.
31
+ - The engine will only reuse conversations originally created by the notification system (`source === 'notification'`). It will never append to a user-initiated conversation, even if it looks related.
32
+
15
33
  ## Important
16
34
 
17
35
  - Do **NOT** use AppleScript `display notification` or other OS-level notification commands for assistant-managed alerts. Always use `send_notification`.
@@ -117,12 +117,18 @@ export {
117
117
  SandboxConfigSchema,
118
118
  } from './sandbox-schema.js';
119
119
  export type {
120
+ RemotePolicyConfig,
121
+ RemoteProviderConfig,
122
+ RemoteProvidersConfig,
120
123
  SkillEntryConfig,
121
124
  SkillsConfig,
122
125
  SkillsInstallConfig,
123
126
  SkillsLoadConfig,
124
127
  } from './skills-schema.js';
125
128
  export {
129
+ RemotePolicyConfigSchema,
130
+ RemoteProviderConfigSchema,
131
+ RemoteProvidersConfigSchema,
126
132
  SkillEntryConfigSchema,
127
133
  SkillsConfigSchema,
128
134
  SkillsInstallConfigSchema,
@@ -19,14 +19,41 @@ export const SkillsInstallConfigSchema = z.object({
19
19
  }).default('npm'),
20
20
  });
21
21
 
22
+ export const RemoteProviderConfigSchema = z.object({
23
+ enabled: z.boolean({ error: 'skills.remoteProviders.<provider>.enabled must be a boolean' }).default(true),
24
+ });
25
+
26
+ export const RemoteProvidersConfigSchema = z.object({
27
+ skillssh: RemoteProviderConfigSchema.default({} as any),
28
+ clawhub: RemoteProviderConfigSchema.default({} as any),
29
+ });
30
+
31
+ const VALID_SKILLS_SH_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical', 'unknown'] as const;
32
+ // 'unknown' is valid as a risk label on a skill but not as a threshold — setting the threshold
33
+ // to 'unknown' would silently disable fail-closed behavior since nothing can exceed it.
34
+ const VALID_MAX_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical'] as const;
35
+
36
+ export const RemotePolicyConfigSchema = z.object({
37
+ blockSuspicious: z.boolean({ error: 'skills.remotePolicy.blockSuspicious must be a boolean' }).default(true),
38
+ blockMalware: z.boolean({ error: 'skills.remotePolicy.blockMalware must be a boolean' }).default(true),
39
+ maxSkillsShRisk: z.enum(VALID_MAX_RISK_LEVELS, {
40
+ error: `skills.remotePolicy.maxSkillsShRisk must be one of: ${VALID_MAX_RISK_LEVELS.join(', ')}`,
41
+ }).default('medium'),
42
+ });
43
+
22
44
  export const SkillsConfigSchema = z.object({
23
45
  entries: z.record(z.string(), SkillEntryConfigSchema).default({} as any),
24
46
  load: SkillsLoadConfigSchema.default({} as any),
25
47
  install: SkillsInstallConfigSchema.default({} as any),
26
48
  allowBundled: z.array(z.string()).nullable().default(null),
49
+ remoteProviders: RemoteProvidersConfigSchema.default({} as any),
50
+ remotePolicy: RemotePolicyConfigSchema.default({} as any),
27
51
  });
28
52
 
29
53
  export type SkillEntryConfig = z.infer<typeof SkillEntryConfigSchema>;
30
54
  export type SkillsLoadConfig = z.infer<typeof SkillsLoadConfigSchema>;
31
55
  export type SkillsInstallConfig = z.infer<typeof SkillsInstallConfigSchema>;
56
+ export type RemoteProviderConfig = z.infer<typeof RemoteProviderConfigSchema>;
57
+ export type RemoteProvidersConfig = z.infer<typeof RemoteProvidersConfigSchema>;
58
+ export type RemotePolicyConfig = z.infer<typeof RemotePolicyConfigSchema>;
32
59
  export type SkillsConfig = z.infer<typeof SkillsConfigSchema>;
@@ -2,6 +2,7 @@ import * as net from 'node:net';
2
2
 
3
3
  import type { ChannelId } from '../../channels/types.js';
4
4
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
5
+ import { findMember, revokeMember } from '../../memory/ingress-member-store.js';
5
6
  import {
6
7
  createVerificationChallenge,
7
8
  findActiveSession,
@@ -173,8 +174,25 @@ export function handleGuardianVerification(
173
174
  const result = getGuardianStatus(channel, assistantId);
174
175
  ctx.send(socket, { type: 'guardian_verification_response', ...result });
175
176
  } else if (msg.action === 'revoke') {
177
+ // Capture binding before revoking so we can revoke the guardian's
178
+ // ingress member record — without this, the guardian would still pass
179
+ // the ACL check after unbinding.
180
+ const bindingBeforeRevoke = getGuardianBinding(assistantId, channel);
176
181
  revokeGuardianBinding(assistantId, channel);
177
182
  revokePendingChallenges(assistantId, channel);
183
+
184
+ if (bindingBeforeRevoke) {
185
+ const member = findMember({
186
+ assistantId,
187
+ sourceChannel: channel,
188
+ externalUserId: bindingBeforeRevoke.guardianExternalUserId,
189
+ externalChatId: bindingBeforeRevoke.guardianDeliveryChatId,
190
+ });
191
+ if (member) {
192
+ revokeMember(member.id, 'guardian_binding_revoked');
193
+ }
194
+ }
195
+
178
196
  ctx.send(socket, {
179
197
  type: 'guardian_verification_response',
180
198
  success: true,
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
 
5
5
  import { getConfig, invalidateConfigCache,loadRawConfig, saveRawConfig } from '../../config/loader.js';
6
6
  import { resolveSkillStates } from '../../config/skill-state.js';
7
- import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog } from '../../config/skills.js';
7
+ import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog, type SkillSummary } from '../../config/skills.js';
8
8
  import { createTimeout,extractText, getConfiguredProvider, userMessage } from '../../providers/provider-send-message.js';
9
9
  import { clawhubCheckUpdates, clawhubInspect, clawhubInstall, clawhubSearch, type ClawhubSearchResultItem,clawhubUpdate } from '../../skills/clawhub.js';
10
10
  import { createManagedSkill,deleteManagedSkill, removeSkillsIndexEntry, validateManagedSkillId } from '../../skills/managed-store.js';
@@ -26,6 +26,48 @@ import type {
26
26
  } from '../ipc-protocol.js';
27
27
  import { CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, ensureSkillEntry, type HandlerContext,log } from './shared.js';
28
28
 
29
+ // ─── Provenance resolution ──────────────────────────────────────────────────
30
+
31
+ interface SkillProvenance {
32
+ kind: 'first-party' | 'third-party' | 'local';
33
+ provider?: string;
34
+ originId?: string;
35
+ sourceUrl?: string;
36
+ }
37
+
38
+ const CLAWHUB_BASE_URL = 'https://skills.sh';
39
+
40
+ function resolveProvenance(summary: SkillSummary): SkillProvenance {
41
+ // Bundled skills are always first-party (shipped with Vellum)
42
+ if (summary.source === 'bundled') {
43
+ return { kind: 'first-party', provider: 'Vellum' };
44
+ }
45
+
46
+ // Managed skills could be either first-party (installed from Vellum catalog)
47
+ // or third-party (installed from clawhub). The homepage field serves as a
48
+ // heuristic: Vellum catalog skills don't typically have a clawhub homepage.
49
+ if (summary.source === 'managed') {
50
+ if (summary.homepage?.includes('skills.sh') || summary.homepage?.includes('clawhub')) {
51
+ return {
52
+ kind: 'third-party',
53
+ provider: 'skills.sh',
54
+ originId: summary.id,
55
+ sourceUrl: summary.homepage ?? `${CLAWHUB_BASE_URL}/skills/${encodeURIComponent(summary.id)}`,
56
+ };
57
+ }
58
+ // No positive evidence of origin -- could be user-authored or from Vellum catalog.
59
+ // Default to "local" to avoid mislabeling user-created skills as first-party.
60
+ return { kind: 'local' };
61
+ }
62
+
63
+ // Workspace and extra skills are user-provided
64
+ if (summary.source === 'workspace' || summary.source === 'extra') {
65
+ return { kind: 'local' };
66
+ }
67
+
68
+ return { kind: 'local' };
69
+ }
70
+
29
71
  export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void {
30
72
  const config = getConfig();
31
73
  const catalog = loadSkillCatalog();
@@ -37,12 +79,13 @@ export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void
37
79
  description: r.summary.description,
38
80
  emoji: r.summary.emoji,
39
81
  homepage: r.summary.homepage,
40
- source: r.summary.source as 'bundled' | 'managed' | 'workspace' | 'clawhub' | 'extra',
82
+ source: r.summary.source,
41
83
  state: (r.state === 'degraded' ? 'enabled' : r.state) as 'enabled' | 'disabled' | 'available',
42
84
  degraded: r.degraded,
43
85
  missingRequirements: r.missingRequirements,
44
86
  updateAvailable: false,
45
87
  userInvocable: r.summary.userInvocable,
88
+ provenance: resolveProvenance(r.summary),
46
89
  }));
47
90
 
48
91
  ctx.send(socket, { type: 'skills_list_response', skills });
@@ -95,6 +95,7 @@ export interface SkillsListResponse {
95
95
  updateAvailable: boolean;
96
96
  userInvocable: boolean;
97
97
  clawhub?: { author: string; stars: number; installs: number; reports: number; publishedAt: string };
98
+ provenance?: { kind: 'first-party' | 'third-party' | 'local'; provider?: string; originId?: string; sourceUrl?: string };
98
99
  }>;
99
100
  }
100
101
 
@@ -28,6 +28,7 @@ import { createPreference } from '../notifications/preferences-store.js';
28
28
  import type { Message } from '../providers/types.js';
29
29
  import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
30
30
  import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
31
+ import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
31
32
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
32
33
  import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
33
34
  import { getLogger } from '../util/logger.js';
@@ -491,6 +492,17 @@ export async function processMessage(
491
492
 
492
493
  if ('ok' in answerResult && answerResult.ok) {
493
494
  const resolved = resolveGuardianActionRequest(guardianRequest.id, answerText, 'vellum');
495
+
496
+ // Mint a scoped grant so the voice call can consume it
497
+ // for subsequent tool confirmations.
498
+ if (resolved) {
499
+ tryMintGuardianActionGrant({
500
+ resolvedRequest: resolved,
501
+ answerText,
502
+ decisionChannel: 'vellum',
503
+ });
504
+ }
505
+
494
506
  const replyText = resolved
495
507
  ? 'Your answer has been relayed to the call.'
496
508
  : await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
@@ -8,6 +8,7 @@ import {
8
8
  createConversationAttentionTables,
9
9
  createCoreIndexes,
10
10
  createCoreTables,
11
+ createScopedApprovalGrantsTable,
11
12
  createExternalConversationBindingsTables,
12
13
  createFollowupsTables,
13
14
  createMediaAssetsTables,
@@ -21,6 +22,7 @@ import {
21
22
  migrateConversationsThreadTypeIndex,
22
23
  migrateFkCascadeRebuilds,
23
24
  migrateGuardianActionFollowup,
25
+ migrateGuardianActionToolMetadata,
24
26
  migrateGuardianBootstrapToken,
25
27
  migrateGuardianDeliveryConversationIndex,
26
28
  migrateGuardianVerificationPurpose,
@@ -102,6 +104,9 @@ export function initializeDb(): void {
102
104
  // 14c. Guardian action follow-up lifecycle columns (timeout reason, late answers)
103
105
  migrateGuardianActionFollowup(database);
104
106
 
107
+ // 14c2. Guardian action tool-approval metadata columns (tool_name, input_digest)
108
+ migrateGuardianActionToolMetadata(database);
109
+
105
110
  // 14d. Index on conversations.thread_type for frequent WHERE filters
106
111
  migrateConversationsThreadTypeIndex(database);
107
112
 
@@ -130,7 +135,10 @@ export function initializeDb(): void {
130
135
  // 21. Rebuild tables to add ON DELETE CASCADE to FK constraints
131
136
  migrateFkCascadeRebuilds(database);
132
137
 
133
- // 22. Thread decision audit columns on notification_deliveries
138
+ // 22. Scoped approval grants (channel-agnostic one-time-use grants)
139
+ createScopedApprovalGrantsTable(database);
140
+
141
+ // 23. Thread decision audit columns on notification_deliveries
134
142
  migrateNotificationDeliveryThreadDecision(database);
135
143
 
136
144
  validateMigrationState(database);
@@ -1,3 +1,5 @@
1
+ import { dirname, join } from 'node:path';
2
+
1
3
  import { getLogger } from '../util/logger.js';
2
4
  import { PromiseGuard } from '../util/promise-guard.js';
3
5
  import type { EmbeddingBackend, EmbeddingRequestOptions } from './embedding-backend.js';
@@ -59,13 +61,20 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
59
61
  let transformers: typeof import('@huggingface/transformers');
60
62
  try {
61
63
  transformers = await import('@huggingface/transformers');
62
- } catch (err) {
63
- // onnxruntime-node is not bundled in compiled binaries, so the import
64
- // fails at runtime. Surface a clear error so callers can fall back to
65
- // another embedding backend.
66
- throw new Error(
67
- `Local embedding backend unavailable: failed to load @huggingface/transformers (${err instanceof Error ? err.message : String(err)})`,
68
- );
64
+ } catch {
65
+ // In compiled Bun binaries, bare specifier resolution starts from the
66
+ // virtual /$bunfs/root/ filesystem and can't find externalized packages.
67
+ // Fall back to resolving from the executable's real disk location where
68
+ // node_modules/ is co-located.
69
+ try {
70
+ const execDir = dirname(process.execPath);
71
+ const modulePath = join(execDir, 'node_modules', '@huggingface', 'transformers');
72
+ transformers = await import(modulePath);
73
+ } catch (err) {
74
+ throw new Error(
75
+ `Local embedding backend unavailable: failed to load @huggingface/transformers (${err instanceof Error ? err.message : String(err)})`,
76
+ );
77
+ }
69
78
  }
70
79
  this.extractor = await transformers.pipeline('feature-extraction', this.model, {
71
80
  dtype: 'fp32',
@@ -51,6 +51,8 @@ export interface GuardianActionRequest {
51
51
  lateAnsweredAt: number | null;
52
52
  followupAction: FollowupAction | null;
53
53
  followupCompletedAt: number | null;
54
+ toolName: string | null;
55
+ inputDigest: string | null;
54
56
  createdAt: number;
55
57
  updatedAt: number;
56
58
  }
@@ -97,6 +99,8 @@ function rowToRequest(row: typeof guardianActionRequests.$inferSelect): Guardian
97
99
  lateAnsweredAt: row.lateAnsweredAt ?? null,
98
100
  followupAction: (row.followupAction as FollowupAction) ?? null,
99
101
  followupCompletedAt: row.followupCompletedAt ?? null,
102
+ toolName: row.toolName ?? null,
103
+ inputDigest: row.inputDigest ?? null,
100
104
  createdAt: row.createdAt,
101
105
  updatedAt: row.updatedAt,
102
106
  };
@@ -137,6 +141,8 @@ export function createGuardianActionRequest(params: {
137
141
  pendingQuestionId: string;
138
142
  questionText: string;
139
143
  expiresAt: number;
144
+ toolName?: string;
145
+ inputDigest?: string;
140
146
  }): GuardianActionRequest {
141
147
  const db = getDb();
142
148
  const now = Date.now();
@@ -164,6 +170,8 @@ export function createGuardianActionRequest(params: {
164
170
  lateAnsweredAt: null,
165
171
  followupAction: null,
166
172
  followupCompletedAt: null,
173
+ toolName: params.toolName ?? null,
174
+ inputDigest: params.inputDigest ?? null,
167
175
  createdAt: now,
168
176
  updatedAt: now,
169
177
  };
@@ -131,8 +131,8 @@ export function createChallenge(params: {
131
131
  nextResendAt: null,
132
132
  codeDigits: 6,
133
133
  maxAttempts: 3,
134
- bootstrapTokenHash: null,
135
134
  verificationPurpose: 'guardian' as const,
135
+ bootstrapTokenHash: null,
136
136
  createdAt: now,
137
137
  updatedAt: now,
138
138
  };
@@ -0,0 +1,51 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Create the scoped_approval_grants table for channel-agnostic scoped
5
+ * approval grants. Supports two scope modes:
6
+ * - request_id: grant is scoped to a specific request
7
+ * - tool_signature: grant is scoped to a tool name + input digest
8
+ *
9
+ * Grants are one-time-use (active -> consumed via CAS) and carry a
10
+ * mandatory TTL (expires_at).
11
+ */
12
+ export function createScopedApprovalGrantsTable(database: DrizzleDb): void {
13
+ database.run(/*sql*/ `
14
+ CREATE TABLE IF NOT EXISTS scoped_approval_grants (
15
+ id TEXT PRIMARY KEY,
16
+ assistant_id TEXT NOT NULL,
17
+ scope_mode TEXT NOT NULL,
18
+ request_id TEXT,
19
+ tool_name TEXT,
20
+ input_digest TEXT,
21
+ request_channel TEXT NOT NULL,
22
+ decision_channel TEXT NOT NULL,
23
+ execution_channel TEXT,
24
+ conversation_id TEXT,
25
+ call_session_id TEXT,
26
+ requester_external_user_id TEXT,
27
+ guardian_external_user_id TEXT,
28
+ status TEXT NOT NULL,
29
+ expires_at TEXT NOT NULL,
30
+ consumed_at TEXT,
31
+ consumed_by_request_id TEXT,
32
+ created_at TEXT NOT NULL,
33
+ updated_at TEXT NOT NULL
34
+ )
35
+ `);
36
+
37
+ // Index for request_id-based lookups (scope_mode = 'request_id')
38
+ database.run(
39
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_scoped_grants_request_id ON scoped_approval_grants(request_id) WHERE request_id IS NOT NULL`,
40
+ );
41
+
42
+ // Index for tool_signature-based lookups (scope_mode = 'tool_signature')
43
+ database.run(
44
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_scoped_grants_tool_sig ON scoped_approval_grants(tool_name, input_digest) WHERE tool_name IS NOT NULL`,
45
+ );
46
+
47
+ // Index for expiry sweeps
48
+ database.run(
49
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_scoped_grants_status_expires ON scoped_approval_grants(status, expires_at)`,
50
+ );
51
+ }
@@ -0,0 +1,12 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add tool_name and input_digest columns to guardian_action_requests for
5
+ * structured tool-approval tracking. These are nullable — informational
6
+ * ASK_GUARDIAN requests leave them NULL while tool-approval requests
7
+ * carry the tool identity and canonical input digest.
8
+ */
9
+ export function migrateGuardianActionToolMetadata(database: DrizzleDb): void {
10
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN tool_name TEXT`); } catch { /* already exists */ }
11
+ try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN input_digest TEXT`); } catch { /* already exists */ }
12
+ }
@@ -33,6 +33,8 @@ export { migrateGuardianActionFollowup } from './030-guardian-action-followup.js
33
33
  export { migrateGuardianVerificationPurpose } from './030-guardian-verification-purpose.js';
34
34
  export { migrateConversationsThreadTypeIndex } from './031-conversations-thread-type-index.js';
35
35
  export { migrateGuardianDeliveryConversationIndex } from './032-guardian-delivery-conversation-index.js';
36
+ export { createScopedApprovalGrantsTable } from './033-scoped-approval-grants.js';
37
+ export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-metadata.js';
36
38
  export { migrateNotificationDeliveryThreadDecision } from './032-notification-delivery-thread-decision.js';
37
39
  export { createCoreTables } from './100-core-tables.js';
38
40
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
@@ -843,6 +843,8 @@ export const guardianActionRequests = sqliteTable('guardian_action_requests', {
843
843
  lateAnsweredAt: integer('late_answered_at'),
844
844
  followupAction: text('followup_action'), // call_back | message_back | decline
845
845
  followupCompletedAt: integer('followup_completed_at'),
846
+ toolName: text('tool_name'), // tool identity for tool-approval requests
847
+ inputDigest: text('input_digest'), // canonical SHA-256 digest of tool input
846
848
  createdAt: integer('created_at').notNull(),
847
849
  updatedAt: integer('updated_at').notNull(),
848
850
  });
@@ -1075,3 +1077,31 @@ export const conversationAssistantAttentionState = sqliteTable('conversation_ass
1075
1077
  index('idx_conv_attn_state_assistant_latest_msg').on(table.assistantId, table.latestAssistantMessageAt),
1076
1078
  index('idx_conv_attn_state_assistant_last_seen').on(table.assistantId, table.lastSeenAssistantMessageAt),
1077
1079
  ]);
1080
+
1081
+ // ── Scoped Approval Grants ──────────────────────────────────────────
1082
+
1083
+ export const scopedApprovalGrants = sqliteTable('scoped_approval_grants', {
1084
+ id: text('id').primaryKey(),
1085
+ assistantId: text('assistant_id').notNull(),
1086
+ scopeMode: text('scope_mode').notNull(), // 'request_id' | 'tool_signature'
1087
+ requestId: text('request_id'),
1088
+ toolName: text('tool_name'),
1089
+ inputDigest: text('input_digest'),
1090
+ requestChannel: text('request_channel').notNull(),
1091
+ decisionChannel: text('decision_channel').notNull(),
1092
+ executionChannel: text('execution_channel'), // null = any channel
1093
+ conversationId: text('conversation_id'),
1094
+ callSessionId: text('call_session_id'),
1095
+ requesterExternalUserId: text('requester_external_user_id'),
1096
+ guardianExternalUserId: text('guardian_external_user_id'),
1097
+ status: text('status').notNull(), // 'active' | 'consumed' | 'expired' | 'revoked'
1098
+ expiresAt: text('expires_at').notNull(),
1099
+ consumedAt: text('consumed_at'),
1100
+ consumedByRequestId: text('consumed_by_request_id'),
1101
+ createdAt: text('created_at').notNull(),
1102
+ updatedAt: text('updated_at').notNull(),
1103
+ }, (table) => [
1104
+ index('idx_scoped_grants_request_id').on(table.requestId),
1105
+ index('idx_scoped_grants_tool_sig').on(table.toolName, table.inputDigest),
1106
+ index('idx_scoped_grants_status_expires').on(table.status, table.expiresAt),
1107
+ ]);