@vellumai/assistant 0.3.28 → 0.4.1
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 +33 -3
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +3 -3
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
- package/src/__tests__/guardian-dispatch.test.ts +2 -0
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +183 -9
- package/src/__tests__/notification-decision-fallback.test.ts +2 -0
- package/src/__tests__/notification-decision-strategy.test.ts +61 -0
- package/src/__tests__/notification-guardian-path.test.ts +2 -0
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/send-endpoint-busy.test.ts +288 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +50 -12
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/relay-server.ts +216 -27
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +19 -0
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/response-tier.ts +6 -5
- package/src/daemon/session-agent-loop.ts +5 -5
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +1 -20
- package/src/daemon/session-runtime-assembly.ts +28 -22
- package/src/daemon/session-tool-setup.ts +2 -2
- package/src/daemon/session.ts +3 -3
- package/src/memory/canonical-guardian-store.ts +63 -1
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema.ts +4 -0
- package/src/notifications/copy-composer.ts +15 -0
- package/src/runtime/access-request-helper.ts +43 -7
- package/src/runtime/actor-trust-resolver.ts +46 -50
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -96
- package/src/runtime/guardian-reply-router.ts +31 -1
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +166 -2
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +41 -10
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/reminder/reminder-store.ts +10 -14
- package/src/tools/tool-approval-handler.ts +11 -11
- package/src/tools/types.ts +2 -2
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -470,7 +470,7 @@ describe('credential_store tool — oauth2_connect error paths', () => {
|
|
|
470
470
|
client_id: 'test-client-id',
|
|
471
471
|
}, { ..._ctx, isInteractive: false });
|
|
472
472
|
expect(result.isError).toBe(true);
|
|
473
|
-
expect(result.content).toContain('interactive
|
|
473
|
+
expect(result.content).toContain('non-interactive session');
|
|
474
474
|
});
|
|
475
475
|
|
|
476
476
|
test('resolves gmail alias to integration:gmail', async () => {
|
|
@@ -672,7 +672,7 @@ describe('credential_store tool — tool definition', () => {
|
|
|
672
672
|
expect(schema.required).toContain('action');
|
|
673
673
|
const props = schema.properties as Record<string, Record<string, unknown>>;
|
|
674
674
|
expect(props.action.enum).toEqual(
|
|
675
|
-
['store', 'list', 'delete', 'prompt', 'oauth2_connect'],
|
|
675
|
+
['store', 'list', 'delete', 'prompt', 'oauth2_connect', 'describe'],
|
|
676
676
|
);
|
|
677
677
|
});
|
|
678
678
|
|
|
@@ -406,18 +406,19 @@ describe('credential_store tool', () => {
|
|
|
406
406
|
expect(entries[0].service).toBe('svc-b');
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
-
test('
|
|
409
|
+
test('recovers from corrupt secure storage by resetting and returning empty list', async () => {
|
|
410
410
|
// Store a credential so metadata exists
|
|
411
411
|
await credentialStoreTool.execute({
|
|
412
412
|
action: 'store', service: 'svc-x', field: 'key', value: 'val-x',
|
|
413
413
|
}, _ctx);
|
|
414
414
|
|
|
415
|
-
// Corrupt the encrypted store file
|
|
415
|
+
// Corrupt the encrypted store file — the store auto-recovers by
|
|
416
|
+
// backing up the corrupt file and creating a fresh store
|
|
416
417
|
writeFileSync(STORE_PATH, 'not-valid-json!!!', 'utf-8');
|
|
417
418
|
|
|
418
419
|
const result = await credentialStoreTool.execute({ action: 'list' }, _ctx);
|
|
419
|
-
|
|
420
|
-
expect(result.
|
|
420
|
+
// Store auto-recovers: list succeeds but the corrupted credentials are lost
|
|
421
|
+
expect(result.isError).toBe(false);
|
|
421
422
|
});
|
|
422
423
|
});
|
|
423
424
|
|
|
@@ -94,6 +94,15 @@ const conversation = {
|
|
|
94
94
|
};
|
|
95
95
|
|
|
96
96
|
mock.module('../memory/conversation-store.js', () => ({
|
|
97
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
98
|
+
updateConversationContextWindow: () => {},
|
|
99
|
+
deleteMessageById: () => {},
|
|
100
|
+
updateConversationTitle: () => {},
|
|
101
|
+
updateConversationUsage: () => {},
|
|
102
|
+
addMessage: () => ({ id: 'mock-msg-id' }),
|
|
103
|
+
provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
|
|
104
|
+
getConversationOriginInterface: () => null,
|
|
105
|
+
getConversationOriginChannel: () => null,
|
|
97
106
|
getLatestConversation: () => conversation,
|
|
98
107
|
createConversation: () => conversation,
|
|
99
108
|
getConversation: (id: string) => (id === conversation.id ? conversation : null),
|
|
@@ -203,6 +203,8 @@ mock.module('../providers/ratelimit.js', () => ({
|
|
|
203
203
|
|
|
204
204
|
mock.module('../config/loader.js', () => ({
|
|
205
205
|
getConfig: () => ({
|
|
206
|
+
ui: {},
|
|
207
|
+
|
|
206
208
|
provider: 'mock-provider',
|
|
207
209
|
providerOrder: ['mock-provider'],
|
|
208
210
|
maxTokens: 4096,
|
|
@@ -243,7 +245,31 @@ mock.module('../memory/external-conversation-store.js', () => ({
|
|
|
243
245
|
getBindingsForConversations: () => new Map(),
|
|
244
246
|
}));
|
|
245
247
|
|
|
248
|
+
mock.module('../memory/conversation-attention-store.js', () => ({
|
|
249
|
+
getAttentionStateByConversationIds: () => new Map(),
|
|
250
|
+
recordAttentionSignal: () => {},
|
|
251
|
+
recordConversationSeenSignal: () => {},
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
mock.module('../memory/canonical-guardian-store.js', () => ({
|
|
255
|
+
generateCanonicalRequestCode: () => 'mock-code-0000',
|
|
256
|
+
createCanonicalGuardianRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
|
|
257
|
+
submitCanonicalRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
|
|
258
|
+
getCanonicalRequest: () => null,
|
|
259
|
+
resolveCanonicalRequest: () => false,
|
|
260
|
+
listPendingCanonicalRequests: () => [],
|
|
261
|
+
}));
|
|
262
|
+
|
|
246
263
|
mock.module('../memory/conversation-store.js', () => ({
|
|
264
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
265
|
+
updateConversationContextWindow: () => {},
|
|
266
|
+
deleteMessageById: () => {},
|
|
267
|
+
updateConversationTitle: () => {},
|
|
268
|
+
updateConversationUsage: () => {},
|
|
269
|
+
addMessage: () => ({ id: 'mock-msg-id' }),
|
|
270
|
+
provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
|
|
271
|
+
getConversationOriginInterface: () => null,
|
|
272
|
+
getConversationOriginChannel: () => null,
|
|
247
273
|
getLatestConversation: () => conversation,
|
|
248
274
|
createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
|
|
249
275
|
lastCreateConversationArgs = titleOrOpts;
|
|
@@ -266,6 +292,7 @@ mock.module('../memory/conversation-store.js', () => ({
|
|
|
266
292
|
getMessages: () => [],
|
|
267
293
|
listConversations: () => [conversation],
|
|
268
294
|
countConversations: () => 1,
|
|
295
|
+
getDisplayMetaForConversations: () => new Map(),
|
|
269
296
|
}));
|
|
270
297
|
|
|
271
298
|
mock.module('../daemon/session.js', () => ({
|
|
@@ -253,24 +253,29 @@ describe('encrypted-store', () => {
|
|
|
253
253
|
expect(getKey('test')).toBe('value');
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
-
test('setKey
|
|
256
|
+
test('setKey recovers from a corrupt store file by backing up and creating fresh store', () => {
|
|
257
257
|
// Write a valid store first
|
|
258
258
|
setKey('existing', 'old-secret');
|
|
259
259
|
// Corrupt the store
|
|
260
260
|
writeFileSync(STORE_PATH, 'corrupted data');
|
|
261
|
-
// setKey should
|
|
261
|
+
// setKey should recover by backing up corrupt file and creating fresh store
|
|
262
262
|
const result = setKey('new-key', 'new-value');
|
|
263
|
-
expect(result).toBe(
|
|
263
|
+
expect(result).toBe(true);
|
|
264
|
+
// Old key is lost but new key works
|
|
265
|
+
expect(getKey('new-key')).toBe('new-value');
|
|
266
|
+
expect(getKey('existing')).toBeUndefined();
|
|
264
267
|
});
|
|
265
268
|
|
|
266
|
-
test('setKey
|
|
269
|
+
test('setKey recovers from a store with invalid version', () => {
|
|
267
270
|
writeFileSync(STORE_PATH, JSON.stringify({
|
|
268
271
|
version: 99,
|
|
269
272
|
salt: 'abc',
|
|
270
273
|
entries: {},
|
|
271
274
|
}));
|
|
275
|
+
// setKey should recover by backing up invalid store and creating fresh store
|
|
272
276
|
const result = setKey('test', 'value');
|
|
273
|
-
expect(result).toBe(
|
|
277
|
+
expect(result).toBe(true);
|
|
278
|
+
expect(getKey('test')).toBe('value');
|
|
274
279
|
});
|
|
275
280
|
|
|
276
281
|
test('writeStore enforces 0600 permissions on existing files', () => {
|
|
@@ -243,9 +243,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
243
243
|
body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
|
|
244
244
|
});
|
|
245
245
|
expect(res.status).toBe(410);
|
|
246
|
-
const body = await res.json() as { error: string;
|
|
247
|
-
expect(body.code).toBe('
|
|
248
|
-
expect(body.error).toContain('Direct webhook access disabled');
|
|
246
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
247
|
+
expect(body.error.code).toBe('GONE');
|
|
248
|
+
expect(body.error.message).toContain('Direct webhook access disabled');
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
test('POST /webhooks/twilio/status returns 410', async () => {
|
|
@@ -255,8 +255,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
255
255
|
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
256
256
|
});
|
|
257
257
|
expect(res.status).toBe(410);
|
|
258
|
-
const body = await res.json() as { error: string;
|
|
259
|
-
expect(body.code).toBe('
|
|
258
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
259
|
+
expect(body.error.code).toBe('GONE');
|
|
260
260
|
});
|
|
261
261
|
|
|
262
262
|
test('POST /webhooks/twilio/connect-action returns 410', async () => {
|
|
@@ -266,8 +266,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
266
266
|
body: makeFormBody({ CallSid: 'CA123' }),
|
|
267
267
|
});
|
|
268
268
|
expect(res.status).toBe(410);
|
|
269
|
-
const body = await res.json() as { error: string;
|
|
270
|
-
expect(body.code).toBe('
|
|
269
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
270
|
+
expect(body.error.code).toBe('GONE');
|
|
271
271
|
});
|
|
272
272
|
|
|
273
273
|
test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
|
|
@@ -277,8 +277,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
277
277
|
body: makeFormBody({ CallSid: 'CA123' }),
|
|
278
278
|
});
|
|
279
279
|
expect(res.status).toBe(410);
|
|
280
|
-
const body = await res.json() as { error: string;
|
|
281
|
-
expect(body.code).toBe('
|
|
280
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
281
|
+
expect(body.error.code).toBe('GONE');
|
|
282
282
|
});
|
|
283
283
|
|
|
284
284
|
test('POST /v1/calls/twilio/status returns 410', async () => {
|
|
@@ -288,8 +288,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
288
288
|
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
289
289
|
});
|
|
290
290
|
expect(res.status).toBe(410);
|
|
291
|
-
const body = await res.json() as { error: string;
|
|
292
|
-
expect(body.code).toBe('
|
|
291
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
292
|
+
expect(body.error.code).toBe('GONE');
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
@@ -304,9 +304,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
304
304
|
body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
|
|
305
305
|
});
|
|
306
306
|
expect(res.status).toBe(410);
|
|
307
|
-
const body = await res.json() as { error: string;
|
|
308
|
-
expect(body.code).toBe('
|
|
309
|
-
expect(body.error).toContain('Direct webhook access disabled');
|
|
307
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
308
|
+
expect(body.error.code).toBe('GONE');
|
|
309
|
+
expect(body.error.message).toContain('Direct webhook access disabled');
|
|
310
310
|
});
|
|
311
311
|
|
|
312
312
|
test('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
|
|
@@ -316,8 +316,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
316
316
|
body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
|
|
317
317
|
});
|
|
318
318
|
expect(res.status).toBe(410);
|
|
319
|
-
const body = await res.json() as { error: string;
|
|
320
|
-
expect(body.code).toBe('
|
|
319
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
320
|
+
expect(body.error.code).toBe('GONE');
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
test('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
|
|
@@ -331,8 +331,8 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
331
331
|
});
|
|
332
332
|
// The gateway-only guard runs before auth for Twilio webhook paths
|
|
333
333
|
expect(res.status).toBe(410);
|
|
334
|
-
const body = await res.json() as { error: string;
|
|
335
|
-
expect(body.code).toBe('
|
|
334
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
335
|
+
expect(body.error.code).toBe('GONE');
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
@@ -407,9 +407,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
407
407
|
},
|
|
408
408
|
});
|
|
409
409
|
expect(res.status).toBe(403);
|
|
410
|
-
const body = await res.json() as { error: string;
|
|
411
|
-
expect(body.code).toBe('
|
|
412
|
-
expect(body.error).toContain('Direct relay access disabled');
|
|
410
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
411
|
+
expect(body.error.code).toBe('FORBIDDEN');
|
|
412
|
+
expect(body.error.message).toContain('Direct relay access disabled');
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
test('allows request with no origin header (private network peer)', async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import type { ToolExecutionResult, ToolLifecycleEvent, ToolPermissionDeniedEvent } from '../tools/types.js';
|
|
4
4
|
|
|
@@ -100,7 +100,11 @@ function makePrompter(): PermissionPrompter {
|
|
|
100
100
|
} as unknown as PermissionPrompter;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
import { resetDb } from '../memory/db.js';
|
|
104
|
+
import { initializeDb } from '../memory/db-init.js';
|
|
105
|
+
|
|
106
|
+
beforeAll(() => { initializeDb(); });
|
|
107
|
+
afterAll(() => { resetDb(); mock.restore(); });
|
|
104
108
|
|
|
105
109
|
// =====================================================================
|
|
106
110
|
// Unit tests: isGuardianControlPlaneInvocation
|
|
@@ -380,7 +384,7 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
380
384
|
test('non-guardian actor denied for guardian endpoint', () => {
|
|
381
385
|
const result = enforceGuardianOnlyPolicy('bash', {
|
|
382
386
|
command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/start',
|
|
383
|
-
}, '
|
|
387
|
+
}, 'trusted_contact');
|
|
384
388
|
expect(result.denied).toBe(true);
|
|
385
389
|
expect(result.reason).toContain('restricted to guardian users');
|
|
386
390
|
});
|
|
@@ -388,7 +392,7 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
388
392
|
test('unverified_channel actor denied for guardian endpoint', () => {
|
|
389
393
|
const result = enforceGuardianOnlyPolicy('network_request', {
|
|
390
394
|
url: 'https://api.example.com/v1/integrations/guardian/challenge',
|
|
391
|
-
}, '
|
|
395
|
+
}, 'unknown');
|
|
392
396
|
expect(result.denied).toBe(true);
|
|
393
397
|
expect(result.reason).toContain('restricted to guardian users');
|
|
394
398
|
});
|
|
@@ -419,14 +423,14 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
419
423
|
test('non-guardian actor is NOT denied for unrelated endpoint', () => {
|
|
420
424
|
const result = enforceGuardianOnlyPolicy('bash', {
|
|
421
425
|
command: 'curl http://localhost:3000/v1/messages',
|
|
422
|
-
}, '
|
|
426
|
+
}, 'trusted_contact');
|
|
423
427
|
expect(result.denied).toBe(false);
|
|
424
428
|
});
|
|
425
429
|
|
|
426
430
|
test('non-guardian actor is NOT denied for unrelated tool', () => {
|
|
427
431
|
const result = enforceGuardianOnlyPolicy('file_read', {
|
|
428
432
|
path: 'README.md',
|
|
429
|
-
}, '
|
|
433
|
+
}, 'trusted_contact');
|
|
430
434
|
expect(result.denied).toBe(false);
|
|
431
435
|
});
|
|
432
436
|
});
|
|
@@ -445,7 +449,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
445
449
|
const result = await executor.execute(
|
|
446
450
|
'bash',
|
|
447
451
|
{ command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
|
|
448
|
-
makeContext({
|
|
452
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
449
453
|
);
|
|
450
454
|
expect(result.isError).toBe(true);
|
|
451
455
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -456,7 +460,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
456
460
|
const result = await executor.execute(
|
|
457
461
|
'network_request',
|
|
458
462
|
{ url: 'https://api.example.com/v1/integrations/guardian/challenge' },
|
|
459
|
-
makeContext({
|
|
463
|
+
makeContext({ guardianTrustClass: 'unknown' }),
|
|
460
464
|
);
|
|
461
465
|
expect(result.isError).toBe(true);
|
|
462
466
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -467,18 +471,18 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
467
471
|
const result = await executor.execute(
|
|
468
472
|
'bash',
|
|
469
473
|
{ command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
|
|
470
|
-
makeContext({
|
|
474
|
+
makeContext({ guardianTrustClass: 'guardian' }),
|
|
471
475
|
);
|
|
472
476
|
expect(result.isError).toBe(false);
|
|
473
477
|
expect(result.content).toBe('ok');
|
|
474
478
|
});
|
|
475
479
|
|
|
476
|
-
test('undefined
|
|
480
|
+
test('undefined guardianTrustClass is NOT blocked from guardian endpoint', async () => {
|
|
477
481
|
const executor = new ToolExecutor(makePrompter());
|
|
478
482
|
const result = await executor.execute(
|
|
479
483
|
'bash',
|
|
480
484
|
{ command: 'curl http://localhost:3000/v1/integrations/guardian/status' },
|
|
481
|
-
makeContext(), // no
|
|
485
|
+
makeContext(), // no guardianTrustClass set
|
|
482
486
|
);
|
|
483
487
|
expect(result.isError).toBe(false);
|
|
484
488
|
expect(result.content).toBe('ok');
|
|
@@ -489,7 +493,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
489
493
|
const result = await executor.execute(
|
|
490
494
|
'bash',
|
|
491
495
|
{ command: 'curl http://localhost:3000/v1/messages' },
|
|
492
|
-
makeContext({
|
|
496
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
493
497
|
);
|
|
494
498
|
expect(result.isError).toBe(true);
|
|
495
499
|
expect(result.content).toContain('requires guardian approval');
|
|
@@ -500,7 +504,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
500
504
|
const result = await executor.execute(
|
|
501
505
|
'file_read',
|
|
502
506
|
{ path: 'README.md' },
|
|
503
|
-
makeContext({
|
|
507
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
504
508
|
);
|
|
505
509
|
expect(result.isError).toBe(false);
|
|
506
510
|
expect(result.content).toBe('ok');
|
|
@@ -513,7 +517,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
513
517
|
'bash',
|
|
514
518
|
{ command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/cancel' },
|
|
515
519
|
makeContext({
|
|
516
|
-
|
|
520
|
+
guardianTrustClass: 'trusted_contact',
|
|
517
521
|
onToolLifecycleEvent: (event: ToolLifecycleEvent) => {
|
|
518
522
|
if (event.type === 'permission_denied') {
|
|
519
523
|
capturedEvent = event as ToolPermissionDeniedEvent;
|
|
@@ -531,7 +535,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
531
535
|
const result = await executor.execute(
|
|
532
536
|
'web_fetch',
|
|
533
537
|
{ url: 'http://localhost:3000/v1/integrations/guardian/outbound/resend' },
|
|
534
|
-
makeContext({
|
|
538
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
535
539
|
);
|
|
536
540
|
expect(result.isError).toBe(true);
|
|
537
541
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -542,7 +546,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
542
546
|
const result = await executor.execute(
|
|
543
547
|
'browser_navigate',
|
|
544
548
|
{ url: 'http://localhost:3000/v1/integrations/guardian/status' },
|
|
545
|
-
makeContext({
|
|
549
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
546
550
|
);
|
|
547
551
|
expect(result.isError).toBe(true);
|
|
548
552
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -553,7 +557,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
553
557
|
const result = await executor.execute(
|
|
554
558
|
'host_bash',
|
|
555
559
|
{ command: 'curl -X POST https://internal:8080/v1/integrations/guardian/challenge' },
|
|
556
|
-
makeContext({
|
|
560
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
557
561
|
);
|
|
558
562
|
expect(result.isError).toBe(true);
|
|
559
563
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -573,7 +577,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
573
577
|
const result = await executor.execute(
|
|
574
578
|
'network_request',
|
|
575
579
|
{ url: `https://api.example.com${path}` },
|
|
576
|
-
makeContext({
|
|
580
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
577
581
|
);
|
|
578
582
|
expect(result.isError).toBe(true);
|
|
579
583
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -585,7 +589,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
585
589
|
const result = await executor.execute(
|
|
586
590
|
'host_file_read',
|
|
587
591
|
{ path: '/Users/noaflaherty/.ssh/config' },
|
|
588
|
-
makeContext({
|
|
592
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
589
593
|
);
|
|
590
594
|
expect(result.isError).toBe(true);
|
|
591
595
|
expect(result.content).toContain('requires guardian approval');
|
|
@@ -596,7 +600,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
596
600
|
const result = await executor.execute(
|
|
597
601
|
'reminder_create',
|
|
598
602
|
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
599
|
-
makeContext({
|
|
603
|
+
makeContext({ guardianTrustClass: 'unknown' }),
|
|
600
604
|
);
|
|
601
605
|
expect(result.isError).toBe(true);
|
|
602
606
|
expect(result.content).toContain('verified channel identity');
|
|
@@ -607,7 +611,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
607
611
|
const result = await executor.execute(
|
|
608
612
|
'reminder_create',
|
|
609
613
|
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
610
|
-
makeContext({
|
|
614
|
+
makeContext({ guardianTrustClass: 'guardian' }),
|
|
611
615
|
);
|
|
612
616
|
expect(result.isError).toBe(false);
|
|
613
617
|
expect(result.content).toBe('ok');
|
|
@@ -137,7 +137,7 @@ function registerPendingInteraction(
|
|
|
137
137
|
|
|
138
138
|
function makeGuardianContext(): GuardianContext {
|
|
139
139
|
return {
|
|
140
|
-
|
|
140
|
+
trustClass: 'guardian',
|
|
141
141
|
denialReason: undefined,
|
|
142
142
|
};
|
|
143
143
|
}
|
|
@@ -530,3 +530,70 @@ describe('guardian grant minting on tool-approval decisions', () => {
|
|
|
530
530
|
composeSpy.mockRestore();
|
|
531
531
|
});
|
|
532
532
|
});
|
|
533
|
+
|
|
534
|
+
describe('approval interception trust-class regression coverage', () => {
|
|
535
|
+
let deliverSpy: ReturnType<typeof spyOn>;
|
|
536
|
+
let composeSpy: ReturnType<typeof spyOn>;
|
|
537
|
+
|
|
538
|
+
beforeEach(() => {
|
|
539
|
+
resetTables();
|
|
540
|
+
deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
541
|
+
composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
|
|
542
|
+
.mockResolvedValue('test message');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('identity-known unknown sender does not auto-deny pending approval', async () => {
|
|
546
|
+
const requestId = 'req-unknown-no-auto-deny-1';
|
|
547
|
+
const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
|
|
548
|
+
createTestGuardianApproval(requestId);
|
|
549
|
+
|
|
550
|
+
const result = await handleApprovalInterception({
|
|
551
|
+
conversationId: CONVERSATION_ID,
|
|
552
|
+
content: 'approve',
|
|
553
|
+
externalChatId: REQUESTER_CHAT,
|
|
554
|
+
sourceChannel: 'telegram',
|
|
555
|
+
senderExternalUserId: 'intruder-user-1',
|
|
556
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
557
|
+
guardianCtx: {
|
|
558
|
+
trustClass: 'unknown',
|
|
559
|
+
},
|
|
560
|
+
assistantId: ASSISTANT_ID,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(result.handled).toBe(true);
|
|
564
|
+
expect(result.type).toBe('assistant_turn');
|
|
565
|
+
expect(sessionMock).not.toHaveBeenCalled();
|
|
566
|
+
|
|
567
|
+
deliverSpy.mockRestore();
|
|
568
|
+
composeSpy.mockRestore();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('legacy unverified sender still auto-denies pending approval', async () => {
|
|
572
|
+
const requestId = 'req-unknown-auto-deny-1';
|
|
573
|
+
const sessionMock = registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
|
|
574
|
+
createTestGuardianApproval(requestId);
|
|
575
|
+
|
|
576
|
+
const result = await handleApprovalInterception({
|
|
577
|
+
conversationId: CONVERSATION_ID,
|
|
578
|
+
content: 'approve',
|
|
579
|
+
externalChatId: REQUESTER_CHAT,
|
|
580
|
+
sourceChannel: 'telegram',
|
|
581
|
+
senderExternalUserId: undefined,
|
|
582
|
+
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
583
|
+
guardianCtx: {
|
|
584
|
+
trustClass: 'unknown',
|
|
585
|
+
denialReason: 'no_identity',
|
|
586
|
+
},
|
|
587
|
+
assistantId: ASSISTANT_ID,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(result.handled).toBe(true);
|
|
591
|
+
expect(result.type).toBe('decision_applied');
|
|
592
|
+
expect(sessionMock).toHaveBeenCalled();
|
|
593
|
+
expect(sessionMock.mock.calls[0]?.[0]).toBe(requestId);
|
|
594
|
+
expect(sessionMock.mock.calls[0]?.[1]).toBe('deny');
|
|
595
|
+
|
|
596
|
+
deliverSpy.mockRestore();
|
|
597
|
+
composeSpy.mockRestore();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
@@ -364,15 +364,16 @@ describe('HTTP route: handleStartOutbound', () => {
|
|
|
364
364
|
const req = jsonRequest({ destination: '+15551234567' });
|
|
365
365
|
const resp = await handleStartOutbound(req);
|
|
366
366
|
expect(resp.status).toBe(400);
|
|
367
|
-
const body = await resp.json() as
|
|
368
|
-
expect(body.error).toBe('
|
|
367
|
+
const body = await resp.json() as { error: { message: string; code: string } };
|
|
368
|
+
expect(body.error.code).toBe('BAD_REQUEST');
|
|
369
|
+
expect(body.error.message).toContain('channel');
|
|
369
370
|
});
|
|
370
371
|
|
|
371
372
|
test('returns 400 for missing destination (SMS)', async () => {
|
|
372
373
|
const req = jsonRequest({ channel: 'sms' });
|
|
373
374
|
const resp = await handleStartOutbound(req);
|
|
374
375
|
expect(resp.status).toBe(400);
|
|
375
|
-
const body = await resp.json() as
|
|
376
|
+
const body = await resp.json() as { error?: string };
|
|
376
377
|
expect(body.error).toBe('missing_destination');
|
|
377
378
|
});
|
|
378
379
|
|
|
@@ -391,15 +392,16 @@ describe('HTTP route: handleResendOutbound', () => {
|
|
|
391
392
|
const req = jsonRequest({});
|
|
392
393
|
const resp = await handleResendOutbound(req);
|
|
393
394
|
expect(resp.status).toBe(400);
|
|
394
|
-
const body = await resp.json() as
|
|
395
|
-
expect(body.error).toBe('
|
|
395
|
+
const body = await resp.json() as { error: { message: string; code: string } };
|
|
396
|
+
expect(body.error.code).toBe('BAD_REQUEST');
|
|
397
|
+
expect(body.error.message).toContain('channel');
|
|
396
398
|
});
|
|
397
399
|
|
|
398
400
|
test('returns 400 for no_active_session', async () => {
|
|
399
401
|
const req = jsonRequest({ channel: 'sms', assistantId: 'resend-no-session' });
|
|
400
402
|
const resp = await handleResendOutbound(req);
|
|
401
403
|
expect(resp.status).toBe(400);
|
|
402
|
-
const body = await resp.json() as
|
|
404
|
+
const body = await resp.json() as { error?: string };
|
|
403
405
|
expect(body.error).toBe('no_active_session');
|
|
404
406
|
});
|
|
405
407
|
|
|
@@ -432,15 +434,16 @@ describe('HTTP route: handleCancelOutbound', () => {
|
|
|
432
434
|
const req = jsonRequest({});
|
|
433
435
|
const resp = await handleCancelOutbound(req);
|
|
434
436
|
expect(resp.status).toBe(400);
|
|
435
|
-
const body = await resp.json() as
|
|
436
|
-
expect(body.error).toBe('
|
|
437
|
+
const body = await resp.json() as { error: { message: string; code: string } };
|
|
438
|
+
expect(body.error.code).toBe('BAD_REQUEST');
|
|
439
|
+
expect(body.error.message).toContain('channel');
|
|
437
440
|
});
|
|
438
441
|
|
|
439
442
|
test('returns 400 for no_active_session', async () => {
|
|
440
443
|
const req = jsonRequest({ channel: 'sms', assistantId: 'cancel-no-session' });
|
|
441
444
|
const resp = await handleCancelOutbound(req);
|
|
442
445
|
expect(resp.status).toBe(400);
|
|
443
|
-
const body = await resp.json() as
|
|
446
|
+
const body = await resp.json() as { error?: string };
|
|
444
447
|
expect(body.error).toBe('no_active_session');
|
|
445
448
|
});
|
|
446
449
|
|