@vellumai/assistant 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -31,6 +31,10 @@ function runMcpAdd(name: string, args: string[]) {
|
|
|
31
31
|
return runMcp('add', [name, ...args]);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function runMcpRemove(name: string) {
|
|
35
|
+
return runMcp('remove', [name]);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
function writeConfig(config: Record<string, unknown>): void {
|
|
35
39
|
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
|
36
40
|
}
|
|
@@ -256,3 +260,76 @@ describe('vellum mcp add', () => {
|
|
|
256
260
|
expect(server.defaultRiskLevel).toBe('high');
|
|
257
261
|
});
|
|
258
262
|
});
|
|
263
|
+
|
|
264
|
+
describe('vellum mcp remove', () => {
|
|
265
|
+
beforeAll(() => {
|
|
266
|
+
testDataDir = join(tmpdir(), `vellum-mcp-remove-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
267
|
+
const workspaceDir = join(testDataDir, '.vellum', 'workspace');
|
|
268
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
269
|
+
configPath = join(workspaceDir, 'config.json');
|
|
270
|
+
writeConfig({});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
afterAll(() => {
|
|
274
|
+
rmSync(testDataDir, { recursive: true, force: true });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
writeConfig({});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('removes an existing server', () => {
|
|
282
|
+
writeConfig({
|
|
283
|
+
mcp: {
|
|
284
|
+
servers: {
|
|
285
|
+
'my-server': {
|
|
286
|
+
transport: { type: 'sse', url: 'https://example.com/sse' },
|
|
287
|
+
enabled: true,
|
|
288
|
+
defaultRiskLevel: 'high',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const { stdout, exitCode } = runMcpRemove('my-server');
|
|
295
|
+
expect(exitCode).toBe(0);
|
|
296
|
+
expect(stdout).toContain('Removed MCP server "my-server"');
|
|
297
|
+
|
|
298
|
+
const updated = readConfig();
|
|
299
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
300
|
+
expect(servers?.['my-server']).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('errors when server does not exist', () => {
|
|
304
|
+
const { stderr, exitCode } = runMcpRemove('nonexistent');
|
|
305
|
+
expect(exitCode).toBe(1);
|
|
306
|
+
expect(stderr).toContain('not found');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('preserves other servers when removing one', () => {
|
|
310
|
+
writeConfig({
|
|
311
|
+
mcp: {
|
|
312
|
+
servers: {
|
|
313
|
+
'keep-me': {
|
|
314
|
+
transport: { type: 'streamable-http', url: 'https://example.com/keep' },
|
|
315
|
+
enabled: true,
|
|
316
|
+
defaultRiskLevel: 'low',
|
|
317
|
+
},
|
|
318
|
+
'remove-me': {
|
|
319
|
+
transport: { type: 'sse', url: 'https://example.com/remove' },
|
|
320
|
+
enabled: true,
|
|
321
|
+
defaultRiskLevel: 'high',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const { exitCode } = runMcpRemove('remove-me');
|
|
328
|
+
expect(exitCode).toBe(0);
|
|
329
|
+
|
|
330
|
+
const updated = readConfig();
|
|
331
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
332
|
+
expect(servers?.['remove-me']).toBeUndefined();
|
|
333
|
+
expect(servers?.['keep-me']).toBeDefined();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -80,9 +80,9 @@ mock.module('../runtime/gateway-client.js', () => ({
|
|
|
80
80
|
},
|
|
81
81
|
}));
|
|
82
82
|
|
|
83
|
+
import { listCanonicalGuardianRequests } from '../memory/canonical-guardian-store.js';
|
|
83
84
|
import {
|
|
84
85
|
createBinding,
|
|
85
|
-
findPendingAccessRequestForRequester,
|
|
86
86
|
} from '../memory/channel-guardian-store.js';
|
|
87
87
|
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
88
88
|
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
@@ -107,6 +107,8 @@ function resetState(): void {
|
|
|
107
107
|
db.run('DELETE FROM channel_inbound_events');
|
|
108
108
|
db.run('DELETE FROM conversations');
|
|
109
109
|
db.run('DELETE FROM notification_events');
|
|
110
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
111
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
110
112
|
emitSignalCalls.length = 0;
|
|
111
113
|
deliverReplyCalls.length = 0;
|
|
112
114
|
}
|
|
@@ -185,18 +187,18 @@ describe('non-member access request notification', () => {
|
|
|
185
187
|
expect(payload.senderExternalUserId).toBe('user-unknown-456');
|
|
186
188
|
expect(payload.senderName).toBe('Alice Unknown');
|
|
187
189
|
|
|
188
|
-
//
|
|
189
|
-
const pending =
|
|
190
|
-
'
|
|
191
|
-
'
|
|
192
|
-
'
|
|
193
|
-
'
|
|
194
|
-
);
|
|
195
|
-
expect(pending).
|
|
196
|
-
expect(pending
|
|
197
|
-
expect(pending
|
|
198
|
-
expect(pending
|
|
199
|
-
expect(pending
|
|
190
|
+
// A canonical access request was created
|
|
191
|
+
const pending = listCanonicalGuardianRequests({
|
|
192
|
+
status: 'pending',
|
|
193
|
+
requesterExternalUserId: 'user-unknown-456',
|
|
194
|
+
sourceChannel: 'telegram',
|
|
195
|
+
kind: 'access_request',
|
|
196
|
+
});
|
|
197
|
+
expect(pending.length).toBe(1);
|
|
198
|
+
expect(pending[0].status).toBe('pending');
|
|
199
|
+
expect(pending[0].requesterExternalUserId).toBe('user-unknown-456');
|
|
200
|
+
expect(pending[0].guardianExternalUserId).toBe('guardian-user-789');
|
|
201
|
+
expect(pending[0].toolName).toBe('ingress_access_request');
|
|
200
202
|
});
|
|
201
203
|
|
|
202
204
|
test('no duplicate approval requests for repeated messages from same non-member', async () => {
|
|
@@ -224,14 +226,14 @@ describe('non-member access request notification', () => {
|
|
|
224
226
|
// Only one notification signal should be emitted (second is deduplicated)
|
|
225
227
|
expect(emitSignalCalls.length).toBe(1);
|
|
226
228
|
|
|
227
|
-
// Only one
|
|
228
|
-
const pending =
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
);
|
|
234
|
-
expect(pending).
|
|
229
|
+
// Only one canonical request should exist
|
|
230
|
+
const pending = listCanonicalGuardianRequests({
|
|
231
|
+
status: 'pending',
|
|
232
|
+
requesterExternalUserId: 'user-unknown-456',
|
|
233
|
+
sourceChannel: 'telegram',
|
|
234
|
+
kind: 'access_request',
|
|
235
|
+
});
|
|
236
|
+
expect(pending.length).toBe(1);
|
|
235
237
|
});
|
|
236
238
|
|
|
237
239
|
test('deny works without error when no guardian binding exists', async () => {
|
|
@@ -249,14 +251,14 @@ describe('non-member access request notification', () => {
|
|
|
249
251
|
// No notification signal was emitted
|
|
250
252
|
expect(emitSignalCalls.length).toBe(0);
|
|
251
253
|
|
|
252
|
-
// No
|
|
253
|
-
const pending =
|
|
254
|
-
'
|
|
255
|
-
'
|
|
256
|
-
'
|
|
257
|
-
'
|
|
258
|
-
);
|
|
259
|
-
expect(pending).
|
|
254
|
+
// No canonical request was created
|
|
255
|
+
const pending = listCanonicalGuardianRequests({
|
|
256
|
+
status: 'pending',
|
|
257
|
+
requesterExternalUserId: 'user-unknown-456',
|
|
258
|
+
sourceChannel: 'telegram',
|
|
259
|
+
kind: 'access_request',
|
|
260
|
+
});
|
|
261
|
+
expect(pending.length).toBe(0);
|
|
260
262
|
});
|
|
261
263
|
|
|
262
264
|
test('no notification when senderExternalUserId is absent', async () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* decision-model call is unavailable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { describe, expect, mock, test } from 'bun:test';
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
9
9
|
|
|
10
10
|
mock.module('../channels/config.js', () => ({
|
|
11
11
|
getDeliverableChannels: () => ['vellum', 'telegram', 'sms'],
|
|
@@ -32,13 +32,16 @@ mock.module('../notifications/thread-candidates.js', () => ({
|
|
|
32
32
|
serializeCandidatesForPrompt: () => undefined,
|
|
33
33
|
}));
|
|
34
34
|
|
|
35
|
+
let configuredProvider: { sendMessage: () => Promise<unknown> } | null = null;
|
|
36
|
+
let extractedToolUse: unknown = null;
|
|
37
|
+
|
|
35
38
|
mock.module('../providers/provider-send-message.js', () => ({
|
|
36
|
-
getConfiguredProvider: () =>
|
|
39
|
+
getConfiguredProvider: () => configuredProvider,
|
|
37
40
|
createTimeout: () => ({
|
|
38
41
|
signal: new AbortController().signal,
|
|
39
42
|
cleanup: () => {},
|
|
40
43
|
}),
|
|
41
|
-
extractToolUse: () =>
|
|
44
|
+
extractToolUse: () => extractedToolUse,
|
|
42
45
|
userMessage: (text: string) => ({ role: 'user', content: text }),
|
|
43
46
|
}));
|
|
44
47
|
|
|
@@ -75,6 +78,11 @@ function makeSignal(overrides?: Partial<NotificationSignal>): NotificationSignal
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
describe('notification decision fallback copy', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
configuredProvider = null;
|
|
83
|
+
extractedToolUse = null;
|
|
84
|
+
});
|
|
85
|
+
|
|
78
86
|
test('uses human-friendly template copy for guardian.question', async () => {
|
|
79
87
|
const signal = makeSignal();
|
|
80
88
|
const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
|
|
@@ -85,4 +93,54 @@ describe('notification decision fallback copy', () => {
|
|
|
85
93
|
expect(decision.renderedCopy.vellum?.title).not.toBe('guardian.question');
|
|
86
94
|
expect(decision.renderedCopy.vellum?.body).not.toContain('Action required: guardian.question');
|
|
87
95
|
});
|
|
96
|
+
|
|
97
|
+
test('enforces request-code instructions for guardian.question when requestCode exists', async () => {
|
|
98
|
+
const signal = makeSignal({
|
|
99
|
+
contextPayload: {
|
|
100
|
+
questionText: 'What is the gate code?',
|
|
101
|
+
requestCode: 'A1B2C3',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
|
|
105
|
+
|
|
106
|
+
expect(decision.fallbackUsed).toBe(true);
|
|
107
|
+
expect(decision.renderedCopy.vellum?.body).toContain('A1B2C3');
|
|
108
|
+
expect(decision.renderedCopy.vellum?.body).toContain('approve');
|
|
109
|
+
expect(decision.renderedCopy.vellum?.body).toContain('reject');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('enforcement appends explicit approve/reject instructions when LLM copy only mentions request code', async () => {
|
|
113
|
+
configuredProvider = {
|
|
114
|
+
sendMessage: async () => ({ content: [] }),
|
|
115
|
+
};
|
|
116
|
+
extractedToolUse = {
|
|
117
|
+
name: 'record_notification_decision',
|
|
118
|
+
input: {
|
|
119
|
+
shouldNotify: true,
|
|
120
|
+
selectedChannels: ['vellum'],
|
|
121
|
+
reasoningSummary: 'LLM decision',
|
|
122
|
+
renderedCopy: {
|
|
123
|
+
vellum: {
|
|
124
|
+
title: 'Guardian Question',
|
|
125
|
+
body: 'Use reference code A1B2C3 for this request.',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
dedupeKey: 'guardian-question-test',
|
|
129
|
+
confidence: 0.9,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const signal = makeSignal({
|
|
134
|
+
contextPayload: {
|
|
135
|
+
questionText: 'What is the gate code?',
|
|
136
|
+
requestCode: 'A1B2C3',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
|
|
141
|
+
|
|
142
|
+
expect(decision.fallbackUsed).toBe(false);
|
|
143
|
+
expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
|
|
144
|
+
expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
|
|
145
|
+
});
|
|
88
146
|
});
|
|
@@ -55,6 +55,23 @@ describe('notification decision strategy', () => {
|
|
|
55
55
|
expect(copy.vellum!.body).toContain('What is the gate code?');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test('guardian.question template includes request-code instructions when present', () => {
|
|
59
|
+
const signal = makeSignal({
|
|
60
|
+
sourceEventName: 'guardian.question',
|
|
61
|
+
contextPayload: {
|
|
62
|
+
questionText: 'What is the gate code?',
|
|
63
|
+
requestCode: 'A1B2C3',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
68
|
+
expect(copy.vellum).toBeDefined();
|
|
69
|
+
expect(copy.vellum!.body).toContain('A1B2C3');
|
|
70
|
+
expect(copy.vellum!.body).toContain('approve');
|
|
71
|
+
expect(copy.vellum!.body).toContain('reject');
|
|
72
|
+
expect(copy.telegram!.deliveryText).toContain('A1B2C3');
|
|
73
|
+
});
|
|
74
|
+
|
|
58
75
|
test('reminder.fired template uses message from payload', () => {
|
|
59
76
|
const signal = makeSignal({
|
|
60
77
|
sourceEventName: 'reminder.fired',
|
|
@@ -108,6 +108,8 @@ function ensureConversation(id: string): void {
|
|
|
108
108
|
|
|
109
109
|
function resetTables(): void {
|
|
110
110
|
const db = getDb();
|
|
111
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
112
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
111
113
|
db.run('DELETE FROM guardian_action_deliveries');
|
|
112
114
|
db.run('DELETE FROM guardian_action_requests');
|
|
113
115
|
db.run('DELETE FROM call_pending_questions');
|
|
@@ -268,16 +270,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
268
270
|
|
|
269
271
|
const db = getDb();
|
|
270
272
|
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
271
|
-
const request = raw.query('SELECT * FROM
|
|
273
|
+
const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
|
|
272
274
|
| { id: string }
|
|
273
275
|
| undefined;
|
|
274
276
|
const deliveries = raw.query(
|
|
275
|
-
'SELECT destination_channel, destination_conversation_id, destination_chat_id,
|
|
277
|
+
'SELECT destination_channel, destination_conversation_id, destination_chat_id, status FROM canonical_guardian_deliveries WHERE request_id = ? ORDER BY destination_channel ASC',
|
|
276
278
|
).all(request!.id) as Array<{
|
|
277
279
|
destination_channel: string;
|
|
278
280
|
destination_conversation_id: string | null;
|
|
279
281
|
destination_chat_id: string | null;
|
|
280
|
-
destination_external_user_id: string | null;
|
|
281
282
|
status: string;
|
|
282
283
|
}>;
|
|
283
284
|
|
|
@@ -286,7 +287,6 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
286
287
|
const vellum = deliveries.find((d) => d.destination_channel === 'vellum');
|
|
287
288
|
expect(telegram).toBeDefined();
|
|
288
289
|
expect(telegram!.destination_chat_id).toBe('tg-chat-abc');
|
|
289
|
-
expect(telegram!.destination_external_user_id).toBe('tg-user-xyz');
|
|
290
290
|
expect(telegram!.status).toBe('sent');
|
|
291
291
|
expect(vellum).toBeDefined();
|
|
292
292
|
expect(vellum!.destination_conversation_id).toBe('conv-guardian-vellum');
|
|
@@ -322,16 +322,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
322
322
|
|
|
323
323
|
const db = getDb();
|
|
324
324
|
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
325
|
-
const request = raw.query('SELECT * FROM
|
|
325
|
+
const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
|
|
326
326
|
| { id: string }
|
|
327
327
|
| undefined;
|
|
328
328
|
const vellumDelivery = raw.query(
|
|
329
|
-
'SELECT status
|
|
330
|
-
).get(request!.id, 'vellum') as { status: string
|
|
329
|
+
'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
|
|
330
|
+
).get(request!.id, 'vellum') as { status: string } | undefined;
|
|
331
331
|
|
|
332
332
|
expect(vellumDelivery).toBeDefined();
|
|
333
333
|
expect(vellumDelivery!.status).toBe('failed');
|
|
334
|
-
expect(vellumDelivery!.last_error).toContain('No vellum delivery result');
|
|
335
334
|
});
|
|
336
335
|
|
|
337
336
|
test('context payload includes callSessionId and activeGuardianRequestCount for candidate-affinity', async () => {
|
|
@@ -426,11 +425,11 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
426
425
|
pendingQuestion: pq2,
|
|
427
426
|
});
|
|
428
427
|
|
|
429
|
-
// Verify: two distinct
|
|
428
|
+
// Verify: two distinct canonical_guardian_requests exist
|
|
430
429
|
const db = getDb();
|
|
431
430
|
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
432
431
|
const requests = raw.query(
|
|
433
|
-
'SELECT id, question_text FROM
|
|
432
|
+
'SELECT id, question_text FROM canonical_guardian_requests WHERE call_session_id = ? ORDER BY created_at ASC',
|
|
434
433
|
).all(session.id) as Array<{ id: string; question_text: string }>;
|
|
435
434
|
expect(requests).toHaveLength(2);
|
|
436
435
|
expect(requests[0].question_text).toBe('Can they enter through the side gate?');
|
|
@@ -438,7 +437,7 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
438
437
|
|
|
439
438
|
// Verify: each request has its own delivery row pointing to the shared conversation
|
|
440
439
|
const deliveries = raw.query(
|
|
441
|
-
'SELECT request_id, destination_conversation_id, status FROM
|
|
440
|
+
'SELECT request_id, destination_conversation_id, status FROM canonical_guardian_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
|
|
442
441
|
).all(sharedConvId) as Array<{ request_id: string; destination_conversation_id: string; status: string }>;
|
|
443
442
|
expect(deliveries).toHaveLength(2);
|
|
444
443
|
expect(deliveries[0].request_id).toBe(requests[0].id);
|
|
@@ -478,16 +477,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
|
|
|
478
477
|
// The dispatch should still create a failed fallback delivery row
|
|
479
478
|
const db = getDb();
|
|
480
479
|
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
481
|
-
const request = raw.query('SELECT id FROM
|
|
480
|
+
const request = raw.query('SELECT id FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
|
|
482
481
|
| { id: string }
|
|
483
482
|
| undefined;
|
|
484
483
|
expect(request).toBeDefined();
|
|
485
484
|
|
|
486
485
|
const delivery = raw.query(
|
|
487
|
-
'SELECT status
|
|
488
|
-
).get(request!.id, 'vellum') as { status: string
|
|
486
|
+
'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
|
|
487
|
+
).get(request!.id, 'vellum') as { status: string } | undefined;
|
|
489
488
|
expect(delivery).toBeDefined();
|
|
490
489
|
expect(delivery!.status).toBe('failed');
|
|
491
|
-
expect(delivery!.last_error).toContain('No vellum delivery result');
|
|
492
490
|
});
|
|
493
491
|
});
|
|
@@ -6,19 +6,21 @@ import { describe, expect,test } from 'bun:test';
|
|
|
6
6
|
const templatesDir = join(import.meta.dirname, '..', 'config', 'templates');
|
|
7
7
|
const bootstrap = readFileSync(join(templatesDir, 'BOOTSTRAP.md'), 'utf-8');
|
|
8
8
|
const identity = readFileSync(join(templatesDir, 'IDENTITY.md'), 'utf-8');
|
|
9
|
+
const user = readFileSync(join(templatesDir, 'USER.md'), 'utf-8');
|
|
9
10
|
|
|
10
11
|
describe('onboarding template contracts', () => {
|
|
11
12
|
describe('BOOTSTRAP.md', () => {
|
|
12
13
|
test('contains identity question prompts', () => {
|
|
13
14
|
const lower = bootstrap.toLowerCase();
|
|
14
15
|
expect(lower).toContain('who am i');
|
|
15
|
-
expect(lower).toContain('who are you');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test('
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
expect(
|
|
18
|
+
test('infers personality indirectly instead of asking directly', () => {
|
|
19
|
+
const lower = bootstrap.toLowerCase();
|
|
20
|
+
// Personality step must instruct indirect/organic discovery
|
|
21
|
+
expect(lower).toContain('personality');
|
|
22
|
+
expect(lower).toContain('indirectly');
|
|
23
|
+
expect(lower).toContain('vibe');
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
test('contains emoji auto-selection with change-later instruction', () => {
|
|
@@ -27,30 +29,106 @@ describe('onboarding template contracts', () => {
|
|
|
27
29
|
expect(lower).toContain('change it later');
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
test('
|
|
31
|
-
expect(bootstrap).toMatch(/came up with X ideas/i);
|
|
32
|
-
expect(bootstrap).toMatch(/check this out/i);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('mentions avatar evolution instruction', () => {
|
|
32
|
+
test('creates Home Base silently in the background', () => {
|
|
36
33
|
const lower = bootstrap.toLowerCase();
|
|
37
|
-
expect(lower).toContain('
|
|
38
|
-
expect(lower).toContain('
|
|
34
|
+
expect(lower).toContain('app_create');
|
|
35
|
+
expect(lower).toContain('set_as_home_base');
|
|
36
|
+
// Must NOT open or announce it
|
|
37
|
+
expect(lower).toContain('do not open it with `app_open`');
|
|
38
|
+
expect(lower).toContain('do not announce it');
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test('contains naming intent markers so the first reply includes naming cues', () => {
|
|
42
42
|
const lower = bootstrap.toLowerCase();
|
|
43
43
|
// The template must prompt the assistant to ask about names.
|
|
44
|
-
// These keywords align with the client-side naming intent heuristic
|
|
45
|
-
// (ChatViewModel.replyContainsNamingIntent) so that the first reply
|
|
46
|
-
// naturally passes the quality check without triggering a corrective nudge.
|
|
47
44
|
expect(lower).toContain('name');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// The conversation sequence must include identity/naming as the first step
|
|
45
|
+
// The first step should be about locking in the assistant's name
|
|
46
|
+
expect(lower).toContain('lock in your name');
|
|
47
|
+
// The conversation sequence must include identity/naming
|
|
52
48
|
expect(lower).toContain('who am i');
|
|
53
|
-
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('asks user name AFTER assistant identity is established', () => {
|
|
52
|
+
// Step 1 is locking in the assistant's name, step 3 is asking the user's name
|
|
53
|
+
const assistantNameIdx = bootstrap.indexOf('Lock in your name.');
|
|
54
|
+
const userNameIdx = bootstrap.indexOf('who am I talking to?');
|
|
55
|
+
expect(assistantNameIdx).toBeGreaterThan(-1);
|
|
56
|
+
expect(userNameIdx).toBeGreaterThan(-1);
|
|
57
|
+
expect(assistantNameIdx).toBeLessThan(userNameIdx);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('gathers user context: work role, hobbies, daily tools', () => {
|
|
61
|
+
const lower = bootstrap.toLowerCase();
|
|
62
|
+
expect(lower).toContain('work');
|
|
63
|
+
expect(lower).toContain('hobbies');
|
|
64
|
+
expect(lower).toContain('tools');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('shows exactly 2 suggestions via ui_show card with relay_prompt actions', () => {
|
|
68
|
+
expect(bootstrap).toContain('ui_show');
|
|
69
|
+
expect(bootstrap).toContain('exactly 2');
|
|
70
|
+
// Must use card surface with relay_prompt action buttons
|
|
71
|
+
expect(bootstrap).toContain('surface_type: "card"');
|
|
72
|
+
expect(bootstrap).toContain('relay_prompt');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('contains completion gate with all required conditions', () => {
|
|
76
|
+
const lower = bootstrap.toLowerCase();
|
|
77
|
+
expect(lower).toContain('completion gate');
|
|
78
|
+
expect(lower).toContain('do not delete this file');
|
|
79
|
+
// Assistant name is hard-required
|
|
80
|
+
expect(lower).toContain('you have a name');
|
|
81
|
+
expect(lower).toContain('hard requirement');
|
|
82
|
+
expect(lower).toContain('vibe');
|
|
83
|
+
// User detail fields must be resolved (provided, inferred, or declined)
|
|
84
|
+
expect(lower).toContain('resolved');
|
|
85
|
+
expect(lower).toContain('work role');
|
|
86
|
+
expect(lower).toContain('2 suggestions shown');
|
|
87
|
+
expect(lower).toContain('selected one, deferred both');
|
|
88
|
+
expect(lower).toContain('home base');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('contains privacy/refusal policy', () => {
|
|
92
|
+
const lower = bootstrap.toLowerCase();
|
|
93
|
+
// Must have a privacy section
|
|
94
|
+
expect(lower).toContain('privacy');
|
|
95
|
+
// Assistant name is hard-required, user details are best-effort
|
|
96
|
+
expect(lower).toContain('hard-required');
|
|
97
|
+
expect(lower).toContain('best-effort');
|
|
98
|
+
// Refusal is a valid resolution
|
|
99
|
+
expect(lower).toContain('declined');
|
|
100
|
+
expect(lower).toContain('do not push');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('defines resolved as provided, inferred, or declined', () => {
|
|
104
|
+
const lower = bootstrap.toLowerCase();
|
|
105
|
+
// The template must define what "resolved" means
|
|
106
|
+
expect(lower).toContain('resolved');
|
|
107
|
+
expect(lower).toContain('inferred');
|
|
108
|
+
expect(lower).toContain('declined');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('preserves no em dashes instruction', () => {
|
|
112
|
+
const lower = bootstrap.toLowerCase();
|
|
113
|
+
expect(lower).toContain('em dashes');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('preserves no technical jargon instruction', () => {
|
|
117
|
+
const lower = bootstrap.toLowerCase();
|
|
118
|
+
expect(lower).toContain('technical jargon');
|
|
119
|
+
expect(lower).toContain('system internals');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('preserves comment line format instruction', () => {
|
|
123
|
+
// The template must start with the comment format explanation
|
|
124
|
+
expect(bootstrap).toMatch(/^_ Lines starting with _/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('instructs saving to IDENTITY.md, USER.md, and SOUL.md via file_edit', () => {
|
|
128
|
+
expect(bootstrap).toContain('IDENTITY.md');
|
|
129
|
+
expect(bootstrap).toContain('USER.md');
|
|
130
|
+
expect(bootstrap).toContain('SOUL.md');
|
|
131
|
+
expect(bootstrap).toContain('file_edit');
|
|
54
132
|
});
|
|
55
133
|
});
|
|
56
134
|
|
|
@@ -71,4 +149,21 @@ describe('onboarding template contracts', () => {
|
|
|
71
149
|
expect(identity).toContain('**Style tendency:**');
|
|
72
150
|
});
|
|
73
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('USER.md', () => {
|
|
154
|
+
test('contains onboarding snapshot with all required fields', () => {
|
|
155
|
+
expect(user).toContain('Preferred name/reference:');
|
|
156
|
+
expect(user).toContain('Goals:');
|
|
157
|
+
expect(user).toContain('Locale:');
|
|
158
|
+
expect(user).toContain('Work role:');
|
|
159
|
+
expect(user).toContain('Hobbies/fun:');
|
|
160
|
+
expect(user).toContain('Daily tools:');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('documents resolved-field status conventions', () => {
|
|
164
|
+
const lower = user.toLowerCase();
|
|
165
|
+
expect(lower).toContain('declined_by_user');
|
|
166
|
+
expect(lower).toContain('resolved');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
74
169
|
});
|
|
@@ -346,4 +346,63 @@ describe('Secret scanner executor integration', () => {
|
|
|
346
346
|
expect(types).toContain('AWS Access Key');
|
|
347
347
|
expect(types).toContain('GitHub Token');
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
// -----------------------------------------------------------------------
|
|
351
|
+
// sensitive output directive extraction runs before secret detection
|
|
352
|
+
// -----------------------------------------------------------------------
|
|
353
|
+
test('sensitive output directives are stripped and replaced with placeholders before secret scanning', async () => {
|
|
354
|
+
mockConfig.secretDetection.action = 'redact';
|
|
355
|
+
const rawToken = 'xK9mP2vL4nR7wQ3j';
|
|
356
|
+
fakeToolResult = {
|
|
357
|
+
content: `<vellum-sensitive-output kind="invite_code" value="${rawToken}" />\nhttps://t.me/bot?start=iv_${rawToken}`,
|
|
358
|
+
isError: false,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const lifecycleEvents: ToolLifecycleEvent[] = [];
|
|
362
|
+
const ctx = makeContext({
|
|
363
|
+
onToolLifecycleEvent: (event) => {
|
|
364
|
+
lifecycleEvents.push(event);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = await executor.execute('bash', {}, ctx);
|
|
369
|
+
|
|
370
|
+
// The raw token should NOT appear in the result content
|
|
371
|
+
expect(result.content).not.toContain(rawToken);
|
|
372
|
+
// The directive tag should be fully stripped
|
|
373
|
+
expect(result.content).not.toContain('<vellum-sensitive-output');
|
|
374
|
+
// A placeholder should be present instead
|
|
375
|
+
expect(result.content).toMatch(/VELLUM_ASSISTANT_INVITE_CODE_[A-Z0-9]{8}/);
|
|
376
|
+
// Sensitive bindings should be attached for downstream substitution
|
|
377
|
+
expect(result.sensitiveBindings).toBeDefined();
|
|
378
|
+
expect(result.sensitiveBindings).toHaveLength(1);
|
|
379
|
+
expect(result.sensitiveBindings![0].value).toBe(rawToken);
|
|
380
|
+
expect(result.sensitiveBindings![0].kind).toBe('invite_code');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('sensitive output bindings are NOT present in lifecycle event result', async () => {
|
|
384
|
+
mockConfig.secretDetection.action = 'warn';
|
|
385
|
+
const rawToken = 'testToken999';
|
|
386
|
+
fakeToolResult = {
|
|
387
|
+
content: `<vellum-sensitive-output kind="invite_code" value="${rawToken}" />\nhttps://t.me/bot?start=iv_${rawToken}`,
|
|
388
|
+
isError: false,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const lifecycleEvents: ToolLifecycleEvent[] = [];
|
|
392
|
+
const ctx = makeContext({
|
|
393
|
+
onToolLifecycleEvent: (event) => {
|
|
394
|
+
lifecycleEvents.push(event);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await executor.execute('bash', {}, ctx);
|
|
399
|
+
|
|
400
|
+
// Find the 'executed' lifecycle event
|
|
401
|
+
const executedEvents = lifecycleEvents.filter(
|
|
402
|
+
(e): e is Extract<ToolLifecycleEvent, { type: 'executed' }> => e.type === 'executed',
|
|
403
|
+
);
|
|
404
|
+
expect(executedEvents).toHaveLength(1);
|
|
405
|
+
// The emitted result must NOT contain sensitiveBindings
|
|
406
|
+
expect((executedEvents[0].result as unknown as Record<string, unknown>).sensitiveBindings).toBeUndefined();
|
|
407
|
+
});
|
|
349
408
|
});
|
|
@@ -675,6 +675,14 @@ describe('entropy-based detection', () => {
|
|
|
675
675
|
expect(entropyMatches.length).toBeGreaterThanOrEqual(1);
|
|
676
676
|
}
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test('does not redact Telegram invite deep links', () => {
|
|
680
|
+
const invite = 'https://t.me/credence_the_bot?start=iv_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_ABCDE';
|
|
681
|
+
const input = `Here is your invite link: ${invite}`;
|
|
682
|
+
const matches = scanText(input);
|
|
683
|
+
expect(matches).toHaveLength(0);
|
|
684
|
+
expect(redactSecrets(input)).toBe(input);
|
|
685
|
+
});
|
|
678
686
|
});
|
|
679
687
|
|
|
680
688
|
// ---------------------------------------------------------------------------
|