@vellumai/assistant 0.4.22 → 0.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bun.lock +3 -0
  2. package/package.json +2 -1
  3. package/scripts/ipc/check-swift-decoder-drift.ts +55 -44
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -90
  5. package/src/__tests__/assistant-events-sse-hardening.test.ts +9 -3
  6. package/src/__tests__/config-schema.test.ts +38 -178
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +4 -1
  8. package/src/__tests__/credential-security-invariants.test.ts +0 -2
  9. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +2 -2
  10. package/src/__tests__/headless-browser-interactions.test.ts +0 -4
  11. package/src/__tests__/ipc-snapshot.test.ts +0 -63
  12. package/src/__tests__/onboarding-template-contract.test.ts +10 -20
  13. package/src/__tests__/relay-server.test.ts +3 -3
  14. package/src/__tests__/resolve-guardian-trust-class.test.ts +61 -0
  15. package/src/__tests__/runtime-events-sse-parity.test.ts +10 -0
  16. package/src/__tests__/runtime-events-sse.test.ts +7 -0
  17. package/src/__tests__/session-init.benchmark.test.ts +0 -4
  18. package/src/__tests__/session-runtime-assembly.test.ts +34 -8
  19. package/src/__tests__/system-prompt.test.ts +7 -1
  20. package/src/__tests__/trusted-contact-approval-notifier.test.ts +12 -8
  21. package/src/__tests__/twilio-routes-twiml.test.ts +2 -2
  22. package/src/__tests__/twilio-routes.test.ts +2 -3
  23. package/src/__tests__/voice-quality.test.ts +21 -132
  24. package/src/calls/relay-server.ts +11 -5
  25. package/src/calls/twilio-routes.ts +4 -38
  26. package/src/calls/voice-quality.ts +7 -63
  27. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +7 -10
  28. package/src/config/bundled-skills/messaging/SKILL.md +3 -5
  29. package/src/config/bundled-skills/phone-calls/SKILL.md +143 -82
  30. package/src/config/bundled-skills/sms-setup/SKILL.md +0 -20
  31. package/src/config/bundled-skills/twilio-setup/SKILL.md +9 -17
  32. package/src/config/bundled-skills/voice-setup/SKILL.md +36 -1
  33. package/src/config/bundled-skills/voice-setup/icon.svg +20 -0
  34. package/src/config/calls-schema.ts +3 -53
  35. package/src/config/elevenlabs-schema.ts +33 -0
  36. package/src/config/schema.ts +183 -137
  37. package/src/config/types.ts +0 -1
  38. package/src/daemon/daemon-control.ts +3 -0
  39. package/src/daemon/handlers/browser.ts +2 -53
  40. package/src/daemon/ipc-contract/browser.ts +5 -84
  41. package/src/daemon/ipc-contract/surfaces.ts +51 -48
  42. package/src/daemon/ipc-contract-inventory.json +0 -9
  43. package/src/daemon/session-agent-loop-handlers.ts +3 -0
  44. package/src/daemon/session-agent-loop.ts +2 -1
  45. package/src/daemon/session-runtime-assembly.ts +9 -7
  46. package/src/daemon/session-tool-setup.ts +27 -13
  47. package/src/mcp/client.ts +2 -1
  48. package/src/memory/conversation-crud.ts +339 -166
  49. package/src/memory/migrations/102-alter-table-columns.ts +254 -37
  50. package/src/memory/schema.ts +1227 -1035
  51. package/src/runtime/routes/events-routes.ts +7 -0
  52. package/src/runtime/routes/inbound-message-handler.ts +3 -4
  53. package/src/schedule/scheduler.ts +159 -45
  54. package/src/security/secure-keys.ts +3 -3
  55. package/src/tools/browser/browser-execution.ts +314 -331
  56. package/src/tools/browser/browser-handoff.ts +11 -37
  57. package/src/tools/browser/browser-manager.ts +203 -352
  58. package/src/tools/browser/browser-screencast.ts +15 -76
  59. package/src/tools/network/script-proxy/certs.ts +7 -237
  60. package/src/tools/network/script-proxy/connect-tunnel.ts +1 -82
  61. package/src/tools/network/script-proxy/http-forwarder.ts +2 -151
  62. package/src/tools/network/script-proxy/logging.ts +12 -196
  63. package/src/tools/network/script-proxy/mitm-handler.ts +2 -270
  64. package/src/tools/network/script-proxy/policy.ts +4 -152
  65. package/src/tools/network/script-proxy/router.ts +2 -60
  66. package/src/tools/network/script-proxy/server.ts +5 -137
  67. package/src/tools/network/script-proxy/types.ts +19 -125
  68. package/src/tools/system/voice-config.ts +23 -1
  69. package/src/util/logger.ts +4 -1
  70. package/src/__tests__/elevenlabs-config.test.ts +0 -95
  71. package/src/__tests__/twilio-routes-elevenlabs.test.ts +0 -407
  72. package/src/calls/elevenlabs-config.ts +0 -32
@@ -1,196 +1,12 @@
1
- /**
2
- * Safe diagnostic logging helpers for the proxy subsystem.
3
- *
4
- * All sanitizers and trace builders are designed to NEVER include secret
5
- * values by construction — sanitizers redact sensitive header/query values,
6
- * and trace builders only reference host patterns, decision kinds, and
7
- * candidate counts.
8
- */
9
-
10
- const REDACTED = '[REDACTED]';
11
-
12
- /**
13
- * Replace values of sensitive header keys with a redaction placeholder.
14
- *
15
- * Matching is case-insensitive — "Authorization" and "authorization"
16
- * are both caught. The caller supplies the set of sensitive key names
17
- * (lowercased) because different credential templates inject into
18
- * different headers.
19
- */
20
- export function sanitizeHeaders(
21
- headers: Record<string, string>,
22
- sensitiveKeys: string[],
23
- ): Record<string, string> {
24
- const lower = new Set(sensitiveKeys.map((k) => k.toLowerCase()));
25
- const out: Record<string, string> = {};
26
-
27
- for (const [key, value] of Object.entries(headers)) {
28
- out[key] = lower.has(key.toLowerCase()) ? REDACTED : value;
29
- }
30
-
31
- return out;
32
- }
33
-
34
- /**
35
- * Redact query-parameter values for sensitive param names.
36
- *
37
- * Returns a URL string where the values of `sensitiveParams` are
38
- * replaced with the redaction placeholder. Non-sensitive params and
39
- * the rest of the URL are preserved verbatim.
40
- */
41
- export function sanitizeUrl(
42
- url: string,
43
- sensitiveParams: string[],
44
- ): string {
45
- if (sensitiveParams.length === 0) return url;
46
-
47
- // Guard against malformed input — return the URL unchanged if it
48
- // doesn't contain a query string at all.
49
- const qIdx = url.indexOf('?');
50
- if (qIdx === -1) return url;
51
-
52
- try {
53
- // Build a full URL if given an absolute path, otherwise parse as-is
54
- const parseable = url.startsWith('/') ? `http://placeholder${url}` : url;
55
- const parsed = new URL(parseable);
56
- const lower = new Set(sensitiveParams.map((p) => p.toLowerCase()));
57
-
58
- for (const key of Array.from(parsed.searchParams.keys())) {
59
- if (lower.has(key.toLowerCase())) {
60
- parsed.searchParams.set(key, REDACTED);
61
- }
62
- }
63
-
64
- // Reconstruct the original shape: if the input was a path we strip
65
- // the placeholder origin so the caller gets back a relative path.
66
- if (url.startsWith('/')) {
67
- return parsed.pathname + parsed.search;
68
- }
69
- return parsed.toString();
70
- } catch {
71
- // Fail closed: if we can't parse the URL, strip the query string
72
- // entirely rather than risk leaking secrets in log output.
73
- return url.slice(0, qIdx);
74
- }
75
- }
76
-
77
- /**
78
- * Build a log-safe snapshot of an outbound proxy request.
79
- *
80
- * `sensitiveKeys` should include header names and query param names
81
- * that carry credential values (e.g. "Authorization", "api_key").
82
- */
83
- export function createSafeLogEntry(
84
- req: { method: string; url: string; headers: Record<string, string> },
85
- sensitiveKeys: string[],
86
- ): { method: string; url: string; headers: Record<string, string> } {
87
- return {
88
- method: req.method,
89
- url: sanitizeUrl(req.url, sensitiveKeys),
90
- headers: sanitizeHeaders(req.headers, sensitiveKeys),
91
- };
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Policy/rewrite decision trace
96
- // ---------------------------------------------------------------------------
97
-
98
- import type { PolicyDecision } from './types.js';
99
-
100
- export interface ProxyDecisionTrace {
101
- /** Target hostname. */
102
- host: string;
103
- /** Target port (null = default for scheme). */
104
- port: number | null;
105
- /** Request path. */
106
- path: string;
107
- /** Protocol scheme. */
108
- scheme: 'http' | 'https';
109
- /** The decision kind emitted by the policy engine. */
110
- decisionKind: PolicyDecision['kind'];
111
- /** Number of candidate templates that matched before disambiguation. */
112
- candidateCount: number;
113
- /** The host pattern of the selected template, if any. */
114
- selectedPattern: string | null;
115
- /** The credential ID of the selected credential, if any. */
116
- selectedCredentialId: string | null;
117
- }
118
-
119
- /**
120
- * Strip the query string from a URL path so that secrets passed as
121
- * query parameters (API keys, tokens) are never recorded in traces.
122
- */
123
- export function stripQueryString(p: string): string {
124
- const idx = p.indexOf('?');
125
- return idx === -1 ? p : p.slice(0, idx);
126
- }
127
-
128
- /**
129
- * Build a structured trace record from a policy decision.
130
- *
131
- * Intentionally excludes all secret-bearing fields (header values,
132
- * storage keys, injected tokens) — only patterns, counts, and
133
- * decision metadata are included. Query parameters are stripped from
134
- * the path to prevent leaking secrets (API keys, tokens) into logs.
135
- */
136
- export function buildDecisionTrace(
137
- host: string,
138
- port: number | null,
139
- path: string,
140
- scheme: 'http' | 'https',
141
- decision: PolicyDecision,
142
- ): ProxyDecisionTrace {
143
- let candidateCount = 0;
144
- let selectedPattern: string | null = null;
145
- let selectedCredentialId: string | null = null;
146
-
147
- switch (decision.kind) {
148
- case 'matched':
149
- candidateCount = 1;
150
- selectedPattern = decision.template.hostPattern;
151
- selectedCredentialId = decision.credentialId;
152
- break;
153
- case 'ambiguous':
154
- candidateCount = decision.candidates.length;
155
- break;
156
- case 'ask_missing_credential':
157
- candidateCount = decision.matchingPatterns.length;
158
- break;
159
- // 'missing', 'unauthenticated', 'ask_unauthenticated' — no candidates
160
- }
161
-
162
- return {
163
- host,
164
- port,
165
- path: stripQueryString(path),
166
- scheme,
167
- decisionKind: decision.kind,
168
- candidateCount,
169
- selectedPattern,
170
- selectedCredentialId,
171
- };
172
- }
173
-
174
- // ---------------------------------------------------------------------------
175
- // Credential ref resolution trace
176
- // ---------------------------------------------------------------------------
177
-
178
- export interface CredentialRefTrace {
179
- /** The raw refs provided by the caller. */
180
- rawRefs: string[];
181
- /** The resolved canonical UUIDs. */
182
- resolvedIds: string[];
183
- /** Any refs that could not be resolved. */
184
- unresolvedRefs: string[];
185
- }
186
-
187
- /**
188
- * Build a credential ref resolution trace for diagnostic logging.
189
- */
190
- export function buildCredentialRefTrace(
191
- rawRefs: string[],
192
- resolvedIds: string[],
193
- unresolvedRefs: string[],
194
- ): CredentialRefTrace {
195
- return { rawRefs, resolvedIds, unresolvedRefs };
196
- }
1
+ export type {
2
+ CredentialRefTrace,
3
+ ProxyDecisionTrace,
4
+ } from "@vellumai/proxy-sidecar";
5
+ export {
6
+ buildCredentialRefTrace,
7
+ buildDecisionTrace,
8
+ createSafeLogEntry,
9
+ sanitizeHeaders,
10
+ sanitizeUrl,
11
+ stripQueryString,
12
+ } from "@vellumai/proxy-sidecar";
@@ -1,270 +1,2 @@
1
- /**
2
- * MITM handler intercepts HTTPS CONNECT requests by terminating TLS
3
- * with a dynamically-issued leaf certificate, allowing the proxy to
4
- * read and rewrite the decrypted HTTP request before forwarding it
5
- * upstream over a fresh TLS connection.
6
- *
7
- * Uses a loopback TLS server on an ephemeral port with manual data
8
- * forwarding because Bun does not support in-process TLS termination
9
- * via `new TLSSocket(socket, { isServer })` or `tlsServer.emit('connection')`.
10
- * Additionally, pipe() has timing issues in Bun for this use case,
11
- * so we use explicit data event forwarding instead.
12
- */
13
-
14
- import { connect as netConnect, type Socket } from 'node:net';
15
- import {
16
- connect as tlsConnect,
17
- type ConnectionOptions,
18
- createServer as createTlsServer,
19
- type TLSSocket,
20
- } from 'node:tls';
21
-
22
- import { issueLeafCert } from './certs.js';
23
-
24
- /**
25
- * Hop-by-hop headers stripped during forwarding.
26
- * transfer-encoding is intentionally preserved: we forward body bytes raw,
27
- * so stripping it would cause upstream to misparse chunked bodies.
28
- */
29
- const HOP_BY_HOP = new Set([
30
- 'connection',
31
- 'keep-alive',
32
- 'proxy-authenticate',
33
- 'proxy-authorization',
34
- 'proxy-connection',
35
- 'te',
36
- 'trailer',
37
- 'upgrade',
38
- ]);
39
-
40
- /**
41
- * Callback that receives the parsed request and returns headers to merge.
42
- * Return null to reject the request with 403.
43
- */
44
- export type RewriteCallback = (req: {
45
- method: string;
46
- path: string;
47
- headers: Record<string, string>;
48
- hostname: string;
49
- port: number;
50
- }) => Promise<Record<string, string> | null>;
51
-
52
- interface ParsedRequest {
53
- method: string;
54
- path: string;
55
- httpVersion: string;
56
- headers: Record<string, string>;
57
- bodyPrefix: Buffer;
58
- }
59
-
60
- function parseHttpRequest(buf: Buffer): ParsedRequest | null {
61
- const headerEnd = buf.indexOf('\r\n\r\n');
62
- if (headerEnd === -1) return null;
63
-
64
- const headerBlock = buf.subarray(0, headerEnd).toString('utf-8');
65
- const lines = headerBlock.split('\r\n');
66
- const [method, path, httpVersion] = lines[0].split(' ', 3);
67
-
68
- const headers: Record<string, string> = {};
69
- for (let i = 1; i < lines.length; i++) {
70
- const colonIdx = lines[i].indexOf(':');
71
- if (colonIdx === -1) continue;
72
- const key = lines[i].slice(0, colonIdx).trim().toLowerCase();
73
- const value = lines[i].slice(colonIdx + 1).trim();
74
- headers[key] = value;
75
- }
76
-
77
- return { method, path, httpVersion, headers, bodyPrefix: buf.subarray(headerEnd + 4) };
78
- }
79
-
80
- function serializeRequestHead(
81
- method: string,
82
- path: string,
83
- httpVersion: string,
84
- headers: Record<string, string>,
85
- ): Buffer {
86
- let head = `${method} ${path} ${httpVersion}\r\n`;
87
- for (const [key, value] of Object.entries(headers)) {
88
- head += `${key}: ${value}\r\n`;
89
- }
90
- head += '\r\n';
91
- return Buffer.from(head);
92
- }
93
-
94
- function filterHeaders(raw: Record<string, string>): Record<string, string> {
95
- const out: Record<string, string> = {};
96
- for (const [key, value] of Object.entries(raw)) {
97
- if (!HOP_BY_HOP.has(key.toLowerCase())) {
98
- out[key] = value;
99
- }
100
- }
101
- // Prevent request-smuggling: when Transfer-Encoding is present,
102
- // Content-Length creates ambiguous framing. Drop it per RFC 7230 §3.3.3.
103
- if (out['transfer-encoding']) {
104
- delete out['content-length'];
105
- }
106
- return out;
107
- }
108
-
109
- /**
110
- * Handle a CONNECT request via MITM TLS interception.
111
- */
112
- export async function handleMitm(
113
- clientSocket: Socket,
114
- head: Buffer,
115
- hostname: string,
116
- port: number,
117
- caDir: string,
118
- rewriteCallback: RewriteCallback,
119
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
120
- ): Promise<void> {
121
- const { cert, key } = await issueLeafCert(caDir, hostname);
122
-
123
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
124
-
125
- const tlsServer = createTlsServer({ cert, key }, (tlsSocket: TLSSocket) => {
126
- tlsServer.close();
127
- handleDecryptedConnection(tlsSocket, hostname, port, rewriteCallback, upstreamTlsOptions);
128
- });
129
-
130
- await new Promise<void>((resolve, reject) => {
131
- tlsServer.listen(0, '127.0.0.1', () => resolve());
132
- tlsServer.on('error', reject);
133
- });
134
-
135
- const addr = tlsServer.address();
136
- if (!addr || typeof addr === 'string') {
137
- clientSocket.destroy();
138
- tlsServer.close();
139
- return;
140
- }
141
-
142
- const bridge = netConnect(addr.port, '127.0.0.1', () => {
143
- if (head.length > 0) {
144
- bridge.write(head);
145
- }
146
- });
147
-
148
- // Manual bidirectional forwarding — pipe() has timing issues in Bun
149
- clientSocket.on('data', (chunk) => bridge.write(chunk));
150
- bridge.on('data', (chunk) => clientSocket.write(chunk));
151
-
152
- bridge.on('end', () => clientSocket.end());
153
- clientSocket.on('end', () => bridge.end());
154
-
155
- bridge.on('error', () => { clientSocket.destroy(); tlsServer.close(); });
156
- clientSocket.on('error', () => {
157
- bridge.destroy();
158
- tlsServer.close();
159
- });
160
- }
161
-
162
- function handleDecryptedConnection(
163
- tlsSocket: TLSSocket,
164
- hostname: string,
165
- port: number,
166
- rewriteCallback: RewriteCallback,
167
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
168
- ): void {
169
- const chunks: Buffer[] = [];
170
-
171
- const onData = (chunk: Buffer) => {
172
- chunks.push(chunk);
173
- const combined = Buffer.concat(chunks);
174
- const parsed = parseHttpRequest(combined);
175
- if (!parsed) return;
176
-
177
- tlsSocket.removeListener('data', onData);
178
- tlsSocket.pause();
179
- processRequest(tlsSocket, parsed, hostname, port, rewriteCallback, upstreamTlsOptions);
180
- };
181
-
182
- tlsSocket.on('data', onData);
183
- tlsSocket.on('error', () => tlsSocket.destroy());
184
- }
185
-
186
- async function processRequest(
187
- tlsSocket: TLSSocket,
188
- parsed: ParsedRequest,
189
- hostname: string,
190
- port: number,
191
- rewriteCallback: RewriteCallback,
192
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
193
- ): Promise<void> {
194
- try {
195
- const filteredHeaders = filterHeaders(parsed.headers);
196
- const rewriteResult = await rewriteCallback({
197
- method: parsed.method,
198
- path: parsed.path,
199
- headers: { ...filteredHeaders },
200
- hostname,
201
- port,
202
- });
203
-
204
- if (rewriteResult == null) {
205
- const body = 'Forbidden';
206
- tlsSocket.write(
207
- `HTTP/1.1 403 Forbidden\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
208
- );
209
- tlsSocket.end();
210
- return;
211
- }
212
-
213
- const finalHeaders = { ...filteredHeaders, ...rewriteResult };
214
- if (!finalHeaders['host']) {
215
- finalHeaders['host'] = port === 443 ? hostname : `${hostname}:${port}`;
216
- }
217
- // Force close so each request gets a fresh MITM cycle with rewrite
218
- finalHeaders['connection'] = 'close';
219
-
220
- const upstream = tlsConnect(
221
- {
222
- host: hostname,
223
- port,
224
- servername: hostname,
225
- ...upstreamTlsOptions,
226
- },
227
- () => {
228
- const headBuf = serializeRequestHead(
229
- parsed.method,
230
- parsed.path,
231
- parsed.httpVersion,
232
- finalHeaders,
233
- );
234
- upstream.write(headBuf);
235
-
236
- if (parsed.bodyPrefix.length > 0) {
237
- upstream.write(parsed.bodyPrefix);
238
- }
239
-
240
- // Manual forwarding — no pipe()
241
- tlsSocket.on('data', (chunk) => upstream.write(chunk));
242
- tlsSocket.resume();
243
- upstream.on('data', (chunk) => tlsSocket.write(chunk));
244
-
245
- upstream.on('end', () => tlsSocket.end());
246
- tlsSocket.on('end', () => upstream.end());
247
- },
248
- );
249
-
250
- upstream.on('error', () => {
251
- if (tlsSocket.writable) {
252
- const body = 'Bad Gateway';
253
- tlsSocket.write(
254
- `HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
255
- );
256
- }
257
- tlsSocket.end();
258
- });
259
-
260
- tlsSocket.on('error', () => upstream.destroy());
261
- } catch {
262
- if (tlsSocket.writable) {
263
- const body = 'Internal Server Error';
264
- tlsSocket.write(
265
- `HTTP/1.1 500 Internal Server Error\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
266
- );
267
- }
268
- tlsSocket.end();
269
- }
270
- }
1
+ export type { RewriteCallback } from "@vellumai/proxy-sidecar";
2
+ export { handleMitm } from "@vellumai/proxy-sidecar";
@@ -1,152 +1,4 @@
1
- /**
2
- * Proxy policy engine — matches outbound request targets to credential
3
- * injection templates and emits deterministic policy decisions.
4
- */
5
-
6
- import { compareMatchSpecificity, type HostMatchKind,matchHostPattern } from '../../credentials/host-pattern-match.js';
7
- import type { CredentialInjectionTemplate } from '../../credentials/policy-types.js';
8
- import type { PolicyDecision, RequestTargetContext } from './types.js';
9
-
10
- interface MatchCandidate {
11
- credentialId: string;
12
- template: CredentialInjectionTemplate;
13
- }
14
-
15
- /**
16
- * Evaluate an outbound request against credential injection templates.
17
- *
18
- * @param hostname Target hostname (e.g. "api.fal.ai")
19
- * @param _path Request path — reserved for future path-level matching
20
- * @param credentialIds Credential IDs the session is authorized to use
21
- * @param templates Map from credentialId → injection templates
22
- */
23
- export function evaluateRequest(
24
- hostname: string,
25
- _path: string,
26
- credentialIds: string[],
27
- templates: Map<string, CredentialInjectionTemplate[]>,
28
- ): PolicyDecision {
29
- if (credentialIds.length === 0) {
30
- return { kind: 'unauthenticated' };
31
- }
32
-
33
- // For each credential, find the best matching header template by specificity.
34
- // Query templates are excluded — they're handled via URL rewriting in the
35
- // MITM path and can't be injected by the HTTP forwarder.
36
- const perCredentialBest: MatchCandidate[] = [];
37
-
38
- for (const id of credentialIds) {
39
- const tpls = templates.get(id);
40
- if (!tpls) continue;
41
-
42
- let bestMatch: HostMatchKind = 'none';
43
- let bestCandidates: CredentialInjectionTemplate[] = [];
44
-
45
- for (const tpl of tpls) {
46
- if (tpl.injectionType === 'query') continue;
47
- const match = matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true });
48
- if (match === 'none') continue;
49
-
50
- const cmp = compareMatchSpecificity(match, bestMatch);
51
- if (cmp < 0) {
52
- // Strictly more specific — replace
53
- bestMatch = match;
54
- bestCandidates = [tpl];
55
- } else if (cmp === 0) {
56
- // Same specificity — accumulate (potential intra-credential tie)
57
- bestCandidates.push(tpl);
58
- }
59
- // cmp > 0 means less specific — skip
60
- }
61
-
62
- if (bestCandidates.length === 1) {
63
- perCredentialBest.push({ credentialId: id, template: bestCandidates[0] });
64
- } else if (bestCandidates.length > 1) {
65
- // Same credential has multiple templates at the same specificity — ambiguous
66
- return {
67
- kind: 'ambiguous',
68
- candidates: bestCandidates.map((tpl) => ({ credentialId: id, template: tpl })),
69
- };
70
- }
71
- }
72
-
73
- if (perCredentialBest.length === 0) {
74
- return { kind: 'missing' };
75
- }
76
-
77
- if (perCredentialBest.length === 1) {
78
- return {
79
- kind: 'matched',
80
- credentialId: perCredentialBest[0].credentialId,
81
- template: perCredentialBest[0].template,
82
- };
83
- }
84
-
85
- // Multiple credentials match — cross-credential ambiguity
86
- return { kind: 'ambiguous', candidates: perCredentialBest };
87
- }
88
-
89
- /**
90
- * Evaluate an outbound request with approval-hook awareness.
91
- *
92
- * This wraps `evaluateRequest` and, when the base decision is `missing` or
93
- * `unauthenticated`, consults the full credential template registry to
94
- * determine whether an approval prompt should be surfaced:
95
- *
96
- * - `ask_missing_credential` — the target host matches at least one known
97
- * template pattern in the registry, but the session has no credential
98
- * bound for it.
99
- * - `ask_unauthenticated` — the request doesn't match any known template
100
- * in the full registry and the session has no credentials.
101
- *
102
- * For `matched` and `ambiguous` decisions the result passes through unchanged.
103
- *
104
- * @param hostname Target hostname
105
- * @param port Target port (null when the default for the scheme)
106
- * @param path Request path
107
- * @param credentialIds Credential IDs the session is authorized to use
108
- * @param sessionTemplates Templates for the session's credential IDs
109
- * @param allKnownTemplates All credential injection templates across every
110
- * credential in the system — used to detect whether
111
- * the target host is "known" even if the session
112
- * doesn't have the right credential bound.
113
- */
114
- export function evaluateRequestWithApproval(
115
- hostname: string,
116
- port: number | null,
117
- path: string,
118
- credentialIds: string[],
119
- sessionTemplates: Map<string, CredentialInjectionTemplate[]>,
120
- allKnownTemplates: CredentialInjectionTemplate[],
121
- scheme: 'http' | 'https' = 'https',
122
- ): PolicyDecision {
123
- const base = evaluateRequest(hostname, path, credentialIds, sessionTemplates);
124
-
125
- if (base.kind !== 'missing' && base.kind !== 'unauthenticated') {
126
- return base;
127
- }
128
-
129
- const target: RequestTargetContext = { hostname, port, path, scheme };
130
-
131
- // Check whether any non-query template in the full registry covers this
132
- // host. Query templates are excluded for consistency with evaluateRequest
133
- // — they're handled via URL rewriting in the MITM path and shouldn't
134
- // cause a false ask_missing_credential on the HTTP forwarder path.
135
- const matchingPatterns: string[] = [];
136
- for (const tpl of allKnownTemplates) {
137
- if (tpl.injectionType === 'query') continue;
138
- if (matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true }) !== 'none') {
139
- matchingPatterns.push(tpl.hostPattern);
140
- }
141
- }
142
- // Deduplicate — multiple credentials may share the same host pattern.
143
- const uniquePatterns = [...new Set(matchingPatterns)];
144
-
145
- if (uniquePatterns.length > 0) {
146
- // A known host pattern exists but no credential is bound to this session.
147
- return { kind: 'ask_missing_credential', target, matchingPatterns: uniquePatterns };
148
- }
149
-
150
- // Completely unknown host — prompt for unauthenticated access.
151
- return { kind: 'ask_unauthenticated', target };
152
- }
1
+ export {
2
+ evaluateRequest,
3
+ evaluateRequestWithApproval,
4
+ } from "@vellumai/proxy-sidecar";
@@ -1,60 +1,2 @@
1
- /**
2
- * Hybrid proxy router — decides per-CONNECT request whether to MITM-intercept
3
- * (for credential injection) or use a plain CONNECT tunnel (no rewrite needed).
4
- *
5
- * The router checks whether any credential injection template matches the
6
- * target hostname. Only when a credential rewrite is required does the proxy
7
- * pay the cost of TLS termination, cert issuance, and request rewriting.
8
- */
9
-
10
- import { matchHostPattern } from '../../credentials/host-pattern-match.js';
11
- import type { CredentialInjectionTemplate } from '../../credentials/policy-types.js';
12
-
13
- // ---- Public types ----------------------------------------------------------
14
-
15
- /** Deterministic reason codes for auditing and testing. */
16
- export type RouteReason =
17
- | 'mitm:credential_injection'
18
- | 'tunnel:no_rewrite'
19
- | 'tunnel:no_credentials';
20
-
21
- export interface RouteDecision {
22
- action: 'mitm' | 'tunnel';
23
- reason: RouteReason;
24
- }
25
-
26
- // ---- Router ----------------------------------------------------------------
27
-
28
- /**
29
- * Decide whether a CONNECT target requires MITM interception.
30
- *
31
- * @param hostname Target hostname (e.g. "api.fal.ai")
32
- * @param _port Target port — reserved for future port-level rules
33
- * @param credentialIds Credential IDs the session is authorized to use
34
- * @param templates Map from credentialId to injection templates
35
- */
36
- export function routeConnection(
37
- hostname: string,
38
- _port: number,
39
- credentialIds: string[],
40
- templates: ReadonlyMap<string, readonly CredentialInjectionTemplate[]>,
41
- ): RouteDecision {
42
- // No credentials configured — nothing to inject, tunnel through.
43
- if (credentialIds.length === 0) {
44
- return { action: 'tunnel', reason: 'tunnel:no_credentials' };
45
- }
46
-
47
- for (const id of credentialIds) {
48
- const tpls = templates.get(id);
49
- if (!tpls) continue;
50
-
51
- for (const tpl of tpls) {
52
- if (matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true }) !== 'none') {
53
- return { action: 'mitm', reason: 'mitm:credential_injection' };
54
- }
55
- }
56
- }
57
-
58
- // Credentials exist but none match this host — no rewrite needed.
59
- return { action: 'tunnel', reason: 'tunnel:no_rewrite' };
60
- }
1
+ export type { RouteDecision, RouteReason } from "@vellumai/proxy-sidecar";
2
+ export { routeConnection } from "@vellumai/proxy-sidecar";