@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.
- package/ARCHITECTURE.md +4 -0
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-dispatch.test.ts +61 -1
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +1 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/calls/call-controller.ts +27 -6
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +42 -3
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/session-process.ts +12 -0
- package/src/memory/db-init.ts +9 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/guardian-action-store.ts +8 -0
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +30 -0
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/routes/guardian-approval-interception.ts +116 -0
- package/src/runtime/routes/inbound-message-handler.ts +94 -27
- package/src/security/tool-approval-digest.ts +67 -0
- 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
|
-
: [
|
|
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 (
|
|
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`.
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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.
|
|
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
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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';
|
package/src/memory/schema.ts
CHANGED
|
@@ -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
|
+
]);
|