@iris-eval/mcp-server 0.5.1 → 0.6.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/README.md +95 -33
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +21 -3
- package/dist/dashboard/seed-demo-data.js +11 -0
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +9 -3
- package/dist/dashboard/validation.js +69 -11
- package/dist/eval/engine.d.ts +62 -0
- package/dist/eval/engine.js +188 -82
- package/dist/eval/rules/safety.d.ts +8 -0
- package/dist/eval/rules/safety.js +43 -11
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +72 -1
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +41 -22
- package/dist/tools/evaluate-with-llm-judge.js +10 -3
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +4 -3
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +25 -0
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +41 -4
- package/dist/types/eval.d.ts +45 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-VI_nbMfN.js +0 -10
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* session-auth — a browser can sign in to an --api-key dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The auth middleware (middleware/auth.ts) is Bearer-only, which is right
|
|
5
|
+
* for MCP clients and capture SDKs and useless for a browser: with
|
|
6
|
+
* `--api-key` set — the README's own "production deployment" command — the
|
|
7
|
+
* dashboard UI 401'd every page load, because nothing lets a browser
|
|
8
|
+
* present the key (#373 item 6).
|
|
9
|
+
*
|
|
10
|
+
* This layer sits in FRONT of the Bearer middleware and adds one thing: a
|
|
11
|
+
* session cookie, obtained by presenting the key once.
|
|
12
|
+
*
|
|
13
|
+
* GET /?key=<api key> exchange the key for a session, then redirect
|
|
14
|
+
* to the same URL with the key stripped
|
|
15
|
+
* POST /session key=<…> same exchange from the sign-in form
|
|
16
|
+
* GET <any page> without a session → a 401 sign-in page
|
|
17
|
+
* (HTML, only for requests that ask for HTML)
|
|
18
|
+
*
|
|
19
|
+
* A request carrying a valid session cookie skips the Bearer check. Every
|
|
20
|
+
* other request — API calls without a cookie, wrong keys, non-browser
|
|
21
|
+
* clients — falls through to the unchanged Bearer middleware, so nothing
|
|
22
|
+
* that worked before behaves differently.
|
|
23
|
+
*
|
|
24
|
+
* What the cookie is NOT: it is not the API key. It is a random 256-bit
|
|
25
|
+
* token that maps, in this process's memory, to "a request presented the
|
|
26
|
+
* key". HttpOnly (no script can read it), SameSite=Lax (never sent on a
|
|
27
|
+
* cross-site fetch, XHR or form POST — the CSRF vectors; still sent on a
|
|
28
|
+
* plain link into the dashboard so a shared URL opens without re-signing
|
|
29
|
+
* in), Path=/ (this origin only), Secure when the request arrived over
|
|
30
|
+
* HTTPS. Sessions die with the process; there is no persistence to leak.
|
|
31
|
+
*
|
|
32
|
+
* Brute force: the key exchange is capped at SIGN_IN_ATTEMPTS_PER_MINUTE
|
|
33
|
+
* per client address by its own limiter below, and the whole of this
|
|
34
|
+
* layer — cookie check, Bearer check and the exchange alike — sits behind
|
|
35
|
+
* the per-address auth-gate limiter server.ts mounts directly in front of
|
|
36
|
+
* it (middleware/rate-limit.ts, createAuthGateRateLimiter), so no
|
|
37
|
+
* authorization decision runs unthrottled.
|
|
38
|
+
*/
|
|
39
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
40
|
+
import express from 'express';
|
|
41
|
+
import rateLimit from 'express-rate-limit';
|
|
42
|
+
export const SESSION_COOKIE = 'iris_session';
|
|
43
|
+
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
44
|
+
const MAX_SESSIONS = 256;
|
|
45
|
+
const SIGN_IN_ATTEMPTS_PER_MINUTE = 10;
|
|
46
|
+
function keyMatches(candidateRaw, apiKey) {
|
|
47
|
+
// Same shape as middleware/auth.ts: pad to the key length and compare
|
|
48
|
+
// fixed-size buffers so the compare path does not depend on the
|
|
49
|
+
// candidate's length.
|
|
50
|
+
const keyBuffer = Buffer.from(apiKey);
|
|
51
|
+
const tokenBuffer = Buffer.from(candidateRaw);
|
|
52
|
+
const candidate = Buffer.alloc(keyBuffer.length);
|
|
53
|
+
tokenBuffer.copy(candidate, 0, 0, keyBuffer.length);
|
|
54
|
+
const cmpEq = timingSafeEqual(candidate, keyBuffer);
|
|
55
|
+
const lenEq = tokenBuffer.length === keyBuffer.length;
|
|
56
|
+
return cmpEq && lenEq;
|
|
57
|
+
}
|
|
58
|
+
function readCookie(req, name) {
|
|
59
|
+
const header = req.headers.cookie;
|
|
60
|
+
if (!header)
|
|
61
|
+
return undefined;
|
|
62
|
+
for (const part of header.split(';')) {
|
|
63
|
+
const eq = part.indexOf('=');
|
|
64
|
+
if (eq === -1)
|
|
65
|
+
continue;
|
|
66
|
+
if (part.slice(0, eq).trim() !== name)
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function wantsHtml(req) {
|
|
78
|
+
return req.method === 'GET' && !req.path.startsWith('/api/') && /text\/html/i.test(req.headers.accept ?? '');
|
|
79
|
+
}
|
|
80
|
+
const PAGE_STYLE = `
|
|
81
|
+
:root { color-scheme: dark; }
|
|
82
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0f14; color: #e6edf3;
|
|
83
|
+
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
84
|
+
main { width: min(92vw, 460px); padding: 32px; border: 1px solid #263140; border-radius: 12px; background: #111820; }
|
|
85
|
+
h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
|
|
86
|
+
.tagline { margin: 0 0 20px; color: #8b97a6; font-size: 13px; }
|
|
87
|
+
p { margin: 0 0 14px; color: #c3ccd6; }
|
|
88
|
+
code { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 13px; background: #1a2330; padding: 1px 5px; border-radius: 4px; }
|
|
89
|
+
label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: #8b97a6; margin-bottom: 6px; }
|
|
90
|
+
input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #2c3949; border-radius: 8px; background: #0b0f14; color: #e6edf3; font: inherit; }
|
|
91
|
+
button { margin-top: 14px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; background: #2dd4bf; color: #04201c; font: inherit; font-weight: 600; cursor: pointer; }
|
|
92
|
+
.error { color: #f87171; font-weight: 600; }
|
|
93
|
+
.fine { margin-top: 18px; font-size: 12px; color: #8b97a6; }
|
|
94
|
+
`;
|
|
95
|
+
function signInPage(error) {
|
|
96
|
+
return `<!doctype html>
|
|
97
|
+
<html lang="en">
|
|
98
|
+
<head>
|
|
99
|
+
<meta charset="utf-8">
|
|
100
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
101
|
+
<title>Iris — sign in</title>
|
|
102
|
+
<style>${PAGE_STYLE}</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<main>
|
|
106
|
+
<h1>Iris dashboard</h1>
|
|
107
|
+
<p class="tagline">This dashboard is protected by an API key.</p>
|
|
108
|
+
${error ? `<p class="error" role="alert">${error}</p>` : ''}
|
|
109
|
+
<p>This server was started with <code>--api-key</code> (or <code>IRIS_API_KEY</code>), so the dashboard asks for that key once. Your browser then keeps an HttpOnly session cookie for this origin — the key itself is never stored in the browser.</p>
|
|
110
|
+
<form method="post" action="/session">
|
|
111
|
+
<label for="key">API key</label>
|
|
112
|
+
<input id="key" name="key" type="password" autocomplete="off" autofocus required>
|
|
113
|
+
<button type="submit">Open dashboard</button>
|
|
114
|
+
</form>
|
|
115
|
+
<p class="fine">Sharing a link with a teammate? Append <code>?key=<api key></code> to any dashboard URL — it signs the browser in and redirects to the page with the key removed from the address bar. API clients keep using <code>Authorization: Bearer <api key></code>.</p>
|
|
116
|
+
</main>
|
|
117
|
+
</body>
|
|
118
|
+
</html>`;
|
|
119
|
+
}
|
|
120
|
+
export function createSessionAuth(opts) {
|
|
121
|
+
const { apiKey, bearerAuth } = opts;
|
|
122
|
+
if (!apiKey) {
|
|
123
|
+
// No key configured: the Bearer middleware is a pass-through and so is
|
|
124
|
+
// this. A `?key=` on the URL is left alone — nothing to exchange.
|
|
125
|
+
return bearerAuth;
|
|
126
|
+
}
|
|
127
|
+
/** token → expiry (epoch ms). Insertion order doubles as age order. */
|
|
128
|
+
const sessions = new Map();
|
|
129
|
+
function createSession() {
|
|
130
|
+
const token = randomBytes(32).toString('base64url');
|
|
131
|
+
sessions.set(token, Date.now() + SESSION_TTL_MS);
|
|
132
|
+
while (sessions.size > MAX_SESSIONS) {
|
|
133
|
+
const oldest = sessions.keys().next().value;
|
|
134
|
+
if (oldest === undefined)
|
|
135
|
+
break;
|
|
136
|
+
sessions.delete(oldest);
|
|
137
|
+
}
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
function hasSession(req) {
|
|
141
|
+
const token = readCookie(req, SESSION_COOKIE);
|
|
142
|
+
if (!token)
|
|
143
|
+
return false;
|
|
144
|
+
const expires = sessions.get(token);
|
|
145
|
+
if (expires === undefined)
|
|
146
|
+
return false;
|
|
147
|
+
if (expires <= Date.now()) {
|
|
148
|
+
sessions.delete(token);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
function setSessionCookie(req, res) {
|
|
154
|
+
const token = createSession();
|
|
155
|
+
const attrs = [
|
|
156
|
+
`${SESSION_COOKIE}=${token}`,
|
|
157
|
+
'HttpOnly',
|
|
158
|
+
'SameSite=Lax',
|
|
159
|
+
'Path=/',
|
|
160
|
+
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
|
|
161
|
+
];
|
|
162
|
+
if (req.protocol === 'https')
|
|
163
|
+
attrs.push('Secure');
|
|
164
|
+
res.append('Set-Cookie', attrs.join('; '));
|
|
165
|
+
}
|
|
166
|
+
function sendSignIn(res, status, error) {
|
|
167
|
+
res.status(status).type('html').send(signInPage(error));
|
|
168
|
+
}
|
|
169
|
+
const signInLimiter = rateLimit({
|
|
170
|
+
windowMs: 60_000,
|
|
171
|
+
limit: SIGN_IN_ATTEMPTS_PER_MINUTE,
|
|
172
|
+
standardHeaders: 'draft-7',
|
|
173
|
+
legacyHeaders: false,
|
|
174
|
+
message: { error: 'Too many sign-in attempts, please try again later' },
|
|
175
|
+
});
|
|
176
|
+
const formBody = express.urlencoded({ extended: false, limit: '4kb' });
|
|
177
|
+
const exchange = (req, res, next) => {
|
|
178
|
+
// 1. `?key=` on a page URL — the one-line team recipe.
|
|
179
|
+
if (req.method === 'GET' && !req.path.startsWith('/api/') && typeof req.query.key === 'string') {
|
|
180
|
+
if (!keyMatches(req.query.key, apiKey)) {
|
|
181
|
+
sendSignIn(res, 403, 'That API key did not match.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setSessionCookie(req, res);
|
|
185
|
+
const url = new URL(req.originalUrl, 'http://localhost');
|
|
186
|
+
url.searchParams.delete('key');
|
|
187
|
+
res.redirect(302, `${url.pathname}${url.search}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// 2. The sign-in form (or a JSON body with the same shape).
|
|
191
|
+
if (req.method === 'POST' && req.path === '/session') {
|
|
192
|
+
formBody(req, res, (err) => {
|
|
193
|
+
if (err) {
|
|
194
|
+
next(err);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const body = req.body;
|
|
198
|
+
const key = typeof body?.key === 'string' ? body.key : '';
|
|
199
|
+
if (!key || !keyMatches(key, apiKey)) {
|
|
200
|
+
sendSignIn(res, 403, 'That API key did not match.');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
setSessionCookie(req, res);
|
|
204
|
+
res.redirect(303, '/');
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
next();
|
|
209
|
+
};
|
|
210
|
+
return (req, res, next) => {
|
|
211
|
+
if (req.path === '/health' || req.path === '/api/v1/health') {
|
|
212
|
+
next();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
/*
|
|
216
|
+
* The exchange runs BEFORE the session check on purpose: a browser
|
|
217
|
+
* that already has a session and opens a shared `?key=` link must
|
|
218
|
+
* still be redirected to the key-free URL, or the key sits in its
|
|
219
|
+
* address bar (and history) for the rest of the visit.
|
|
220
|
+
*/
|
|
221
|
+
const isExchange = (req.method === 'GET' && !req.path.startsWith('/api/') && typeof req.query.key === 'string') ||
|
|
222
|
+
(req.method === 'POST' && req.path === '/session');
|
|
223
|
+
if (isExchange) {
|
|
224
|
+
signInLimiter(req, res, (err) => (err ? next(err) : exchange(req, res, next)));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (hasSession(req)) {
|
|
228
|
+
next();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (wantsHtml(req)) {
|
|
232
|
+
sendSignIn(res, 401);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
bearerAuth(req, res, next);
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export declare function strictBody<T extends z.ZodRawShape>(shape: T, opts?: {
|
|
3
|
+
reserved?: Record<string, string>;
|
|
4
|
+
}): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
|
|
2
5
|
export declare const ingestTraceSchema: z.ZodObject<{
|
|
3
6
|
evaluate: z.ZodDefault<z.ZodBoolean>;
|
|
4
7
|
eval_type: z.ZodDefault<z.ZodEnum<{
|
|
@@ -7,6 +10,7 @@ export declare const ingestTraceSchema: z.ZodObject<{
|
|
|
7
10
|
safety: "safety";
|
|
8
11
|
cost: "cost";
|
|
9
12
|
custom: "custom";
|
|
13
|
+
all: "all";
|
|
10
14
|
}>>;
|
|
11
15
|
agent_name: z.ZodString;
|
|
12
16
|
framework: z.ZodOptional<z.ZodString>;
|
|
@@ -56,12 +60,14 @@ export declare const ingestTraceSchema: z.ZodObject<{
|
|
|
56
60
|
}, z.core.$strip>>>;
|
|
57
61
|
}, z.core.$strip>>>;
|
|
58
62
|
timestamp: z.ZodOptional<z.ZodString>;
|
|
59
|
-
}, z.core.$
|
|
63
|
+
}, z.core.$strict>;
|
|
60
64
|
export declare const traceQuerySchema: z.ZodObject<{
|
|
61
65
|
agent_name: z.ZodOptional<z.ZodString>;
|
|
62
66
|
framework: z.ZodOptional<z.ZodString>;
|
|
63
67
|
since: z.ZodOptional<z.ZodString>;
|
|
64
68
|
until: z.ZodOptional<z.ZodString>;
|
|
69
|
+
min_score: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
70
|
+
max_score: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
65
71
|
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
66
72
|
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
67
73
|
sort_by: z.ZodDefault<z.ZodEnum<{
|
|
@@ -90,6 +96,7 @@ export declare const summaryQuerySchema: z.ZodObject<{
|
|
|
90
96
|
}, z.core.$strip>;
|
|
91
97
|
export declare const evalStatsPeriodSchema: z.ZodObject<{
|
|
92
98
|
period: z.ZodDefault<z.ZodEnum<{
|
|
99
|
+
all: "all";
|
|
93
100
|
"24h": "24h";
|
|
94
101
|
"2d": "2d";
|
|
95
102
|
"7d": "7d";
|
|
@@ -98,11 +105,11 @@ export declare const evalStatsPeriodSchema: z.ZodObject<{
|
|
|
98
105
|
"60d": "60d";
|
|
99
106
|
"90d": "90d";
|
|
100
107
|
"180d": "180d";
|
|
101
|
-
all: "all";
|
|
102
108
|
}>>;
|
|
103
109
|
}, z.core.$strip>;
|
|
104
110
|
export declare const evalStatsFailuresSchema: z.ZodObject<{
|
|
105
111
|
period: z.ZodDefault<z.ZodEnum<{
|
|
112
|
+
all: "all";
|
|
106
113
|
"24h": "24h";
|
|
107
114
|
"2d": "2d";
|
|
108
115
|
"7d": "7d";
|
|
@@ -111,7 +118,6 @@ export declare const evalStatsFailuresSchema: z.ZodObject<{
|
|
|
111
118
|
"60d": "60d";
|
|
112
119
|
"90d": "90d";
|
|
113
120
|
"180d": "180d";
|
|
114
|
-
all: "all";
|
|
115
121
|
}>>;
|
|
116
122
|
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
117
123
|
}, z.core.$strip>;
|
|
@@ -1,19 +1,65 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { logTraceInputShape } from '../tools/log-trace.js';
|
|
3
|
+
import { isoTimestamp, addTraceRangeIssues } from '../tools/get-traces.js';
|
|
4
|
+
/*
|
|
5
|
+
* Strict request-body schema for the dashboard's MUTATING routes.
|
|
6
|
+
*
|
|
7
|
+
* A bare z.object() silently STRIPS unknown keys. On a read route that is
|
|
8
|
+
* merely lenient; on a write route it is the exact defect v0.5.0 fixed at
|
|
9
|
+
* the MCP boundary (tools/strict-input.ts) and then left open on its HTTP
|
|
10
|
+
* twin: `POST /api/v1/traces {evaluate: true, eval_typ: "safety", output:
|
|
11
|
+
* "<PII>"}` dropped the misspelled key, ran the DEFAULT completeness
|
|
12
|
+
* bundle, and returned a green result with nothing saying an argument had
|
|
13
|
+
* been ignored. Same family, one transport over (#376 item 2).
|
|
14
|
+
*
|
|
15
|
+
* `reserved` names keys the server owns and a client must never send —
|
|
16
|
+
* each gets a pointed sentence appended to the rejection so the caller
|
|
17
|
+
* learns WHY (e.g. trace_id is server-minted) instead of just "unknown".
|
|
18
|
+
*/
|
|
19
|
+
export function strictBody(shape, opts) {
|
|
20
|
+
const validKeys = Object.keys(shape).join(', ');
|
|
21
|
+
return z.strictObject(shape, {
|
|
22
|
+
error: (issue) => {
|
|
23
|
+
if (issue.code !== 'unrecognized_keys')
|
|
24
|
+
return undefined;
|
|
25
|
+
const reservedNotes = issue.keys
|
|
26
|
+
.filter((k) => opts?.reserved?.[k] !== undefined)
|
|
27
|
+
.map((k) => ` ${opts.reserved[k]}`)
|
|
28
|
+
.join('');
|
|
29
|
+
return (`Unknown key(s): ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
|
|
30
|
+
`Valid keys: ${validKeys}. ` +
|
|
31
|
+
'Unknown keys are rejected rather than silently dropped, so a misspelled field ' +
|
|
32
|
+
'cannot change what gets stored or evaluated — check the spelling and retry.' +
|
|
33
|
+
reservedNotes);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
3
37
|
/*
|
|
4
38
|
* POST /api/v1/traces body — the log_trace tool contract plus the
|
|
5
39
|
* HTTP-only evaluation opt-in. Built FROM logTraceInputShape rather than
|
|
6
40
|
* restating it so the two capture paths (MCP tool, HTTP ingest) cannot
|
|
7
|
-
* drift.
|
|
8
|
-
*
|
|
41
|
+
* drift.
|
|
42
|
+
*
|
|
43
|
+
* `trace_id` is deliberately absent: the server mints it. This schema
|
|
44
|
+
* used to rely on default unknown-key stripping to discard a client-
|
|
45
|
+
* supplied one — which also discarded every misspelled field. It is now
|
|
46
|
+
* strict, and a client-supplied trace_id is REJECTED with a message that
|
|
47
|
+
* says the server owns it, rather than silently replaced.
|
|
9
48
|
*/
|
|
10
|
-
export const ingestTraceSchema =
|
|
11
|
-
.object({
|
|
49
|
+
export const ingestTraceSchema = strictBody({
|
|
12
50
|
...logTraceInputShape,
|
|
13
51
|
evaluate: z.boolean().default(false),
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
// Same bundle list evaluate_output accepts, "all" included — the
|
|
53
|
+
// ingest path used to stop one short and run the single-bundle engine
|
|
54
|
+
// no matter what, so an HTTP caller could not get the per-category
|
|
55
|
+
// verdict the MCP tool returns.
|
|
56
|
+
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).default('completeness'),
|
|
57
|
+
}, {
|
|
58
|
+
reserved: {
|
|
59
|
+
trace_id: 'trace_id is minted by the server on every ingest and cannot be supplied by the client — ' +
|
|
60
|
+
'read it from the 201 response instead.',
|
|
61
|
+
},
|
|
62
|
+
}).superRefine((body, ctx) => {
|
|
17
63
|
if (body.evaluate && body.output === undefined) {
|
|
18
64
|
ctx.addIssue({
|
|
19
65
|
code: z.ZodIssueCode.custom,
|
|
@@ -22,16 +68,28 @@ export const ingestTraceSchema = z
|
|
|
22
68
|
});
|
|
23
69
|
}
|
|
24
70
|
});
|
|
25
|
-
|
|
71
|
+
/*
|
|
72
|
+
* GET /api/v1/traces — the HTTP twin of the get_traces tool's query. The
|
|
73
|
+
* bounds are validated by the SAME helpers the tool uses (isoTimestamp,
|
|
74
|
+
* addTraceRangeIssues), so a `since` of "yesterday", a `since` later than
|
|
75
|
+
* `until`, a score outside 0..1 or a `min_score` above `max_score` is
|
|
76
|
+
* refused here with both values named — exactly as the tool refuses it —
|
|
77
|
+
* instead of returning an empty page that reads as "no such traces".
|
|
78
|
+
*/
|
|
79
|
+
export const traceQuerySchema = z
|
|
80
|
+
.object({
|
|
26
81
|
agent_name: z.string().optional(),
|
|
27
82
|
framework: z.string().optional(),
|
|
28
|
-
since:
|
|
29
|
-
until:
|
|
83
|
+
since: isoTimestamp.optional(),
|
|
84
|
+
until: isoTimestamp.optional(),
|
|
85
|
+
min_score: z.coerce.number().min(0).max(1).optional(),
|
|
86
|
+
max_score: z.coerce.number().min(0).max(1).optional(),
|
|
30
87
|
limit: z.coerce.number().int().min(1).max(1000).default(50),
|
|
31
88
|
offset: z.coerce.number().int().min(0).default(0),
|
|
32
89
|
sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp'),
|
|
33
90
|
sort_order: z.enum(['asc', 'desc']).default('desc'),
|
|
34
|
-
})
|
|
91
|
+
})
|
|
92
|
+
.superRefine(addTraceRangeIssues);
|
|
35
93
|
export const evalQuerySchema = z.object({
|
|
36
94
|
eval_type: z.string().optional(),
|
|
37
95
|
passed: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
|
package/dist/eval/engine.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition } from '../types/eval.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every bundle eval_type="all" walks, in the order their categories are
|
|
4
|
+
* reported. 'custom' is last: it holds only deployed rules registered under
|
|
5
|
+
* evalType "custom" plus the call's inline custom_rules, so it is absent
|
|
6
|
+
* from the breakdown when neither exists.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ALL_EVAL_TYPES: readonly EvalType[];
|
|
2
9
|
export declare class EvalEngine {
|
|
3
10
|
private additionalRules;
|
|
4
11
|
/**
|
|
@@ -8,9 +15,21 @@ export declare class EvalEngine {
|
|
|
8
15
|
* share a name with different definitions.
|
|
9
16
|
*/
|
|
10
17
|
private rulesById;
|
|
18
|
+
/**
|
|
19
|
+
* Reverse index, so each rule's result can carry its deployed id
|
|
20
|
+
* (EvalRuleResult.ruleId) without mutating the rule object itself.
|
|
21
|
+
*/
|
|
22
|
+
private idByRule;
|
|
11
23
|
private threshold;
|
|
12
24
|
private ruleThresholds?;
|
|
13
25
|
constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
|
|
26
|
+
/**
|
|
27
|
+
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
28
|
+
* is IDEMPOTENT by id: registering an id that is already live replaces the
|
|
29
|
+
* earlier instance instead of adding a second one. That is what re-enable
|
|
30
|
+
* (delete_rule enabled:true) and a reload after edit need — without it,
|
|
31
|
+
* every toggle stacked another copy that fired alongside the first.
|
|
32
|
+
*/
|
|
14
33
|
registerRule(evalType: EvalType, rule: EvalRule, ruleId?: string): void;
|
|
15
34
|
/**
|
|
16
35
|
* Hot-remove a rule registered under `ruleId` so it stops firing on the
|
|
@@ -19,5 +38,48 @@ export declare class EvalEngine {
|
|
|
19
38
|
* without an id); callers treat that as a no-op, not an error.
|
|
20
39
|
*/
|
|
21
40
|
unregisterRule(ruleId: string): boolean;
|
|
41
|
+
/** Whether a deployed rule id is currently registered (and therefore firing). */
|
|
42
|
+
hasRule(ruleId: string): boolean;
|
|
22
43
|
evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
|
|
44
|
+
/**
|
|
45
|
+
* eval_type="all" (#370): every built-in bundle, each with the deployed
|
|
46
|
+
* rules registered under it, plus the rules deployed under "custom" and
|
|
47
|
+
* the call's inline custom_rules — in ONE pass, sharing one regex budget,
|
|
48
|
+
* so the whole call is bounded exactly like a single bundle. The overall
|
|
49
|
+
* verdict is the same arithmetic as a single bundle applied to every rule
|
|
50
|
+
* that ran (weighted score against the threshold, critical veto across
|
|
51
|
+
* all bundles); `categories` carries the same arithmetic per bundle.
|
|
52
|
+
*/
|
|
53
|
+
evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
|
|
54
|
+
private run;
|
|
55
|
+
/**
|
|
56
|
+
* Weighted average over the rules that ran, plus the critical veto.
|
|
57
|
+
*
|
|
58
|
+
* Critical rules hard-fail. Before this existed, the weighted average
|
|
59
|
+
* routinely outvoted a genuine violation: an output containing a real
|
|
60
|
+
* SSN failed no_pii while the other safety rules passed, landing at
|
|
61
|
+
* ~0.765 — over the 0.7 threshold — so `passed`, the one field every
|
|
62
|
+
* automated gate keys on, said true about the product's flagship
|
|
63
|
+
* failure scenario. A detection that reports an all-clear is worse
|
|
64
|
+
* than no detection.
|
|
65
|
+
*
|
|
66
|
+
* Only EVALUATED failures count: a critical rule that skipped (missing
|
|
67
|
+
* context, broken config) has not judged the output and must not veto
|
|
68
|
+
* it. The score is left as-is — it stays a quality gradient; `passed`
|
|
69
|
+
* is the verdict, and the two answer different questions.
|
|
70
|
+
*
|
|
71
|
+
* A critical rule that SKIPPED is the fail-open seam between the
|
|
72
|
+
* release's two headline features: an adversary who knows a deployed
|
|
73
|
+
* critical regex can craft output that stalls it past the sandbox
|
|
74
|
+
* budget, and the rule then neither judges nor vetoes — so the eval
|
|
75
|
+
* returns passed=true with an EMPTY critical_failures on output that
|
|
76
|
+
* nobody actually cleared. The trade-off is deliberate (failing closed
|
|
77
|
+
* would let the same adversary force false violations on benign
|
|
78
|
+
* output), but before `criticalSkipped` the only trace of it was a
|
|
79
|
+
* suggestions line — prose. A gate that must fail closed should not
|
|
80
|
+
* have to walk rule_results[].budgetExceeded to discover it was defeated.
|
|
81
|
+
*/
|
|
82
|
+
private summarize;
|
|
83
|
+
/** The per-bundle breakdown for eval_type="all": summarize() over each bundle's slice. */
|
|
84
|
+
private categorize;
|
|
23
85
|
}
|