@vellumai/assistant 0.3.28 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +19 -19
- 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 +4 -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/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 +2 -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/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 () => {
|
|
@@ -380,7 +380,7 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
380
380
|
test('non-guardian actor denied for guardian endpoint', () => {
|
|
381
381
|
const result = enforceGuardianOnlyPolicy('bash', {
|
|
382
382
|
command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/start',
|
|
383
|
-
}, '
|
|
383
|
+
}, 'trusted_contact');
|
|
384
384
|
expect(result.denied).toBe(true);
|
|
385
385
|
expect(result.reason).toContain('restricted to guardian users');
|
|
386
386
|
});
|
|
@@ -388,7 +388,7 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
388
388
|
test('unverified_channel actor denied for guardian endpoint', () => {
|
|
389
389
|
const result = enforceGuardianOnlyPolicy('network_request', {
|
|
390
390
|
url: 'https://api.example.com/v1/integrations/guardian/challenge',
|
|
391
|
-
}, '
|
|
391
|
+
}, 'unknown');
|
|
392
392
|
expect(result.denied).toBe(true);
|
|
393
393
|
expect(result.reason).toContain('restricted to guardian users');
|
|
394
394
|
});
|
|
@@ -419,14 +419,14 @@ describe('enforceGuardianOnlyPolicy', () => {
|
|
|
419
419
|
test('non-guardian actor is NOT denied for unrelated endpoint', () => {
|
|
420
420
|
const result = enforceGuardianOnlyPolicy('bash', {
|
|
421
421
|
command: 'curl http://localhost:3000/v1/messages',
|
|
422
|
-
}, '
|
|
422
|
+
}, 'trusted_contact');
|
|
423
423
|
expect(result.denied).toBe(false);
|
|
424
424
|
});
|
|
425
425
|
|
|
426
426
|
test('non-guardian actor is NOT denied for unrelated tool', () => {
|
|
427
427
|
const result = enforceGuardianOnlyPolicy('file_read', {
|
|
428
428
|
path: 'README.md',
|
|
429
|
-
}, '
|
|
429
|
+
}, 'trusted_contact');
|
|
430
430
|
expect(result.denied).toBe(false);
|
|
431
431
|
});
|
|
432
432
|
});
|
|
@@ -445,7 +445,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
445
445
|
const result = await executor.execute(
|
|
446
446
|
'bash',
|
|
447
447
|
{ command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
|
|
448
|
-
makeContext({
|
|
448
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
449
449
|
);
|
|
450
450
|
expect(result.isError).toBe(true);
|
|
451
451
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -456,7 +456,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
456
456
|
const result = await executor.execute(
|
|
457
457
|
'network_request',
|
|
458
458
|
{ url: 'https://api.example.com/v1/integrations/guardian/challenge' },
|
|
459
|
-
makeContext({
|
|
459
|
+
makeContext({ guardianTrustClass: 'unknown' }),
|
|
460
460
|
);
|
|
461
461
|
expect(result.isError).toBe(true);
|
|
462
462
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -467,18 +467,18 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
467
467
|
const result = await executor.execute(
|
|
468
468
|
'bash',
|
|
469
469
|
{ command: 'curl -X POST http://localhost:3000/v1/integrations/guardian/outbound/start' },
|
|
470
|
-
makeContext({
|
|
470
|
+
makeContext({ guardianTrustClass: 'guardian' }),
|
|
471
471
|
);
|
|
472
472
|
expect(result.isError).toBe(false);
|
|
473
473
|
expect(result.content).toBe('ok');
|
|
474
474
|
});
|
|
475
475
|
|
|
476
|
-
test('undefined
|
|
476
|
+
test('undefined guardianTrustClass is NOT blocked from guardian endpoint', async () => {
|
|
477
477
|
const executor = new ToolExecutor(makePrompter());
|
|
478
478
|
const result = await executor.execute(
|
|
479
479
|
'bash',
|
|
480
480
|
{ command: 'curl http://localhost:3000/v1/integrations/guardian/status' },
|
|
481
|
-
makeContext(), // no
|
|
481
|
+
makeContext(), // no guardianTrustClass set
|
|
482
482
|
);
|
|
483
483
|
expect(result.isError).toBe(false);
|
|
484
484
|
expect(result.content).toBe('ok');
|
|
@@ -489,7 +489,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
489
489
|
const result = await executor.execute(
|
|
490
490
|
'bash',
|
|
491
491
|
{ command: 'curl http://localhost:3000/v1/messages' },
|
|
492
|
-
makeContext({
|
|
492
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
493
493
|
);
|
|
494
494
|
expect(result.isError).toBe(true);
|
|
495
495
|
expect(result.content).toContain('requires guardian approval');
|
|
@@ -500,7 +500,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
500
500
|
const result = await executor.execute(
|
|
501
501
|
'file_read',
|
|
502
502
|
{ path: 'README.md' },
|
|
503
|
-
makeContext({
|
|
503
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
504
504
|
);
|
|
505
505
|
expect(result.isError).toBe(false);
|
|
506
506
|
expect(result.content).toBe('ok');
|
|
@@ -513,7 +513,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
513
513
|
'bash',
|
|
514
514
|
{ command: 'curl http://localhost:3000/v1/integrations/guardian/outbound/cancel' },
|
|
515
515
|
makeContext({
|
|
516
|
-
|
|
516
|
+
guardianTrustClass: 'trusted_contact',
|
|
517
517
|
onToolLifecycleEvent: (event: ToolLifecycleEvent) => {
|
|
518
518
|
if (event.type === 'permission_denied') {
|
|
519
519
|
capturedEvent = event as ToolPermissionDeniedEvent;
|
|
@@ -531,7 +531,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
531
531
|
const result = await executor.execute(
|
|
532
532
|
'web_fetch',
|
|
533
533
|
{ url: 'http://localhost:3000/v1/integrations/guardian/outbound/resend' },
|
|
534
|
-
makeContext({
|
|
534
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
535
535
|
);
|
|
536
536
|
expect(result.isError).toBe(true);
|
|
537
537
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -542,7 +542,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
542
542
|
const result = await executor.execute(
|
|
543
543
|
'browser_navigate',
|
|
544
544
|
{ url: 'http://localhost:3000/v1/integrations/guardian/status' },
|
|
545
|
-
makeContext({
|
|
545
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
546
546
|
);
|
|
547
547
|
expect(result.isError).toBe(true);
|
|
548
548
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -553,7 +553,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
553
553
|
const result = await executor.execute(
|
|
554
554
|
'host_bash',
|
|
555
555
|
{ command: 'curl -X POST https://internal:8080/v1/integrations/guardian/challenge' },
|
|
556
|
-
makeContext({
|
|
556
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
557
557
|
);
|
|
558
558
|
expect(result.isError).toBe(true);
|
|
559
559
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -573,7 +573,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
573
573
|
const result = await executor.execute(
|
|
574
574
|
'network_request',
|
|
575
575
|
{ url: `https://api.example.com${path}` },
|
|
576
|
-
makeContext({
|
|
576
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
577
577
|
);
|
|
578
578
|
expect(result.isError).toBe(true);
|
|
579
579
|
expect(result.content).toContain('restricted to guardian users');
|
|
@@ -585,7 +585,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
585
585
|
const result = await executor.execute(
|
|
586
586
|
'host_file_read',
|
|
587
587
|
{ path: '/Users/noaflaherty/.ssh/config' },
|
|
588
|
-
makeContext({
|
|
588
|
+
makeContext({ guardianTrustClass: 'trusted_contact' }),
|
|
589
589
|
);
|
|
590
590
|
expect(result.isError).toBe(true);
|
|
591
591
|
expect(result.content).toContain('requires guardian approval');
|
|
@@ -596,7 +596,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
596
596
|
const result = await executor.execute(
|
|
597
597
|
'reminder_create',
|
|
598
598
|
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
599
|
-
makeContext({
|
|
599
|
+
makeContext({ guardianTrustClass: 'unknown' }),
|
|
600
600
|
);
|
|
601
601
|
expect(result.isError).toBe(true);
|
|
602
602
|
expect(result.content).toContain('verified channel identity');
|
|
@@ -607,7 +607,7 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
607
607
|
const result = await executor.execute(
|
|
608
608
|
'reminder_create',
|
|
609
609
|
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
610
|
-
makeContext({
|
|
610
|
+
makeContext({ guardianTrustClass: 'guardian' }),
|
|
611
611
|
);
|
|
612
612
|
expect(result.isError).toBe(false);
|
|
613
613
|
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
|
|