@iris-eval/mcp-server 0.5.1 → 0.7.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.
Files changed (56) hide show
  1. package/README.md +100 -34
  2. package/dist/config/defaults.js +3 -1
  3. package/dist/config/index.d.ts +10 -0
  4. package/dist/config/index.js +33 -7
  5. package/dist/dashboard/assets/index-CKs2Wbd_.js +10 -0
  6. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  7. package/dist/dashboard/index.html +4 -3
  8. package/dist/dashboard/routes/health.js +10 -3
  9. package/dist/dashboard/routes/moments.js +1 -1
  10. package/dist/dashboard/routes/preferences.d.ts +1 -0
  11. package/dist/dashboard/routes/preferences.js +31 -3
  12. package/dist/dashboard/routes/rules.d.ts +18 -0
  13. package/dist/dashboard/routes/rules.js +160 -6
  14. package/dist/dashboard/routes/traces.js +27 -3
  15. package/dist/dashboard/seed-demo-data.js +11 -0
  16. package/dist/dashboard/server.js +13 -3
  17. package/dist/dashboard/session-auth.d.ts +8 -0
  18. package/dist/dashboard/session-auth.js +237 -0
  19. package/dist/dashboard/validation.d.ts +10 -4
  20. package/dist/dashboard/validation.js +73 -11
  21. package/dist/eval/engine.d.ts +79 -1
  22. package/dist/eval/engine.js +216 -82
  23. package/dist/eval/rules/relevance.d.ts +13 -0
  24. package/dist/eval/rules/relevance.js +185 -21
  25. package/dist/eval/rules/safety.d.ts +19 -0
  26. package/dist/eval/rules/safety.js +236 -24
  27. package/dist/index.js +102 -16
  28. package/dist/middleware/rate-limit.d.ts +25 -0
  29. package/dist/middleware/rate-limit.js +54 -2
  30. package/dist/self-test.d.ts +14 -0
  31. package/dist/self-test.js +97 -13
  32. package/dist/storage/demo-guard.d.ts +8 -0
  33. package/dist/storage/demo-guard.js +53 -0
  34. package/dist/storage/sqlite-adapter.d.ts +6 -0
  35. package/dist/storage/sqlite-adapter.js +72 -1
  36. package/dist/tools/delete-rule.js +49 -11
  37. package/dist/tools/deploy-rule.d.ts +33 -0
  38. package/dist/tools/deploy-rule.js +130 -27
  39. package/dist/tools/evaluate-output.js +54 -33
  40. package/dist/tools/evaluate-with-llm-judge.js +10 -3
  41. package/dist/tools/get-traces.d.ts +27 -0
  42. package/dist/tools/get-traces.js +60 -8
  43. package/dist/tools/list-rules.js +2 -2
  44. package/dist/tools/log-trace.js +4 -3
  45. package/dist/tools/strict-input.d.ts +1 -0
  46. package/dist/tools/strict-input.js +27 -2
  47. package/dist/tools/trace-link.d.ts +7 -0
  48. package/dist/tools/trace-link.js +39 -0
  49. package/dist/tools/verify-citations.d.ts +19 -0
  50. package/dist/tools/verify-citations.js +41 -4
  51. package/dist/types/eval.d.ts +52 -1
  52. package/dist/types/index.d.ts +1 -1
  53. package/dist/types/query.d.ts +25 -0
  54. package/package.json +8 -1
  55. package/server.json +2 -2
  56. 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=&lt;api key&gt;</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 &lt;api key&gt;</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,12 +1,16 @@
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
- eval_type: z.ZodDefault<z.ZodEnum<{
7
+ eval_type: z.ZodOptional<z.ZodEnum<{
5
8
  completeness: "completeness";
6
9
  relevance: "relevance";
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.$strip>;
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,69 @@
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. `trace_id` is deliberately absent: the server mints it, and
8
- * zod's default unknown-key stripping discards any client-supplied one.
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 = z
11
- .object({
49
+ export const ingestTraceSchema = strictBody({
12
50
  ...logTraceInputShape,
13
51
  evaluate: z.boolean().default(false),
14
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness'),
15
- })
16
- .superRefine((body, ctx) => {
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. Optional rather than defaulted HERE so
56
+ // the route can tell "chose all" from "never chose": the effective
57
+ // default is every bundle (DEFAULT_EVAL_TYPE in eval/engine.ts, the
58
+ // same constant evaluate_output reads), and an omitted eval_type gets a
59
+ // note in the response saying the default ran.
60
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).optional(),
61
+ }, {
62
+ reserved: {
63
+ trace_id: 'trace_id is minted by the server on every ingest and cannot be supplied by the client — ' +
64
+ 'read it from the 201 response instead.',
65
+ },
66
+ }).superRefine((body, ctx) => {
17
67
  if (body.evaluate && body.output === undefined) {
18
68
  ctx.addIssue({
19
69
  code: z.ZodIssueCode.custom,
@@ -22,16 +72,28 @@ export const ingestTraceSchema = z
22
72
  });
23
73
  }
24
74
  });
25
- export const traceQuerySchema = z.object({
75
+ /*
76
+ * GET /api/v1/traces — the HTTP twin of the get_traces tool's query. The
77
+ * bounds are validated by the SAME helpers the tool uses (isoTimestamp,
78
+ * addTraceRangeIssues), so a `since` of "yesterday", a `since` later than
79
+ * `until`, a score outside 0..1 or a `min_score` above `max_score` is
80
+ * refused here with both values named — exactly as the tool refuses it —
81
+ * instead of returning an empty page that reads as "no such traces".
82
+ */
83
+ export const traceQuerySchema = z
84
+ .object({
26
85
  agent_name: z.string().optional(),
27
86
  framework: z.string().optional(),
28
- since: z.string().optional(),
29
- until: z.string().optional(),
87
+ since: isoTimestamp.optional(),
88
+ until: isoTimestamp.optional(),
89
+ min_score: z.coerce.number().min(0).max(1).optional(),
90
+ max_score: z.coerce.number().min(0).max(1).optional(),
30
91
  limit: z.coerce.number().int().min(1).max(1000).default(50),
31
92
  offset: z.coerce.number().int().min(0).default(0),
32
93
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp'),
33
94
  sort_order: z.enum(['asc', 'desc']).default('desc'),
34
- });
95
+ })
96
+ .superRefine(addTraceRangeIssues);
35
97
  export const evalQuerySchema = z.object({
36
98
  eval_type: z.string().optional(),
37
99
  passed: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
@@ -1,4 +1,27 @@
1
- import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition } from '../types/eval.js';
1
+ import type { EvalRule, EvalContext, EvalResult, EvalResultType, 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[];
9
+ /**
10
+ * What runs when a caller never chose a bundle. It used to be
11
+ * 'completeness', so a CI gate keyed on `passed` skipped PII and injection
12
+ * unless the caller knew to set eval_type — six of seven UAT personas read
13
+ * passed:true on PII-laden text with nothing in the payload saying the
14
+ * safety bundle had not run. Every bundle is the only default under which
15
+ * an omitted argument cannot silently narrow the verdict. The MCP tool and
16
+ * the HTTP ingest route both read this constant, so the two surfaces
17
+ * cannot default differently.
18
+ */
19
+ export declare const DEFAULT_EVAL_TYPE: EvalResultType;
20
+ /**
21
+ * The one-line note both surfaces attach when the default ran, so a reader
22
+ * of the response knows the bundle was chosen for them and how to narrow it.
23
+ */
24
+ export declare const DEFAULT_EVAL_TYPE_NOTE = "eval_type was omitted, so the default ran every bundle \u2014 completeness, relevance, safety, cost and any custom rules \u2014 the same as eval_type=\"all\"; pass a single bundle name to narrow the run.";
2
25
  export declare class EvalEngine {
3
26
  private additionalRules;
4
27
  /**
@@ -8,9 +31,21 @@ export declare class EvalEngine {
8
31
  * share a name with different definitions.
9
32
  */
10
33
  private rulesById;
34
+ /**
35
+ * Reverse index, so each rule's result can carry its deployed id
36
+ * (EvalRuleResult.ruleId) without mutating the rule object itself.
37
+ */
38
+ private idByRule;
11
39
  private threshold;
12
40
  private ruleThresholds?;
13
41
  constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
42
+ /**
43
+ * Register a rule under a bundle. When `ruleId` is given the registration
44
+ * is IDEMPOTENT by id: registering an id that is already live replaces the
45
+ * earlier instance instead of adding a second one. That is what re-enable
46
+ * (delete_rule enabled:true) and a reload after edit need — without it,
47
+ * every toggle stacked another copy that fired alongside the first.
48
+ */
14
49
  registerRule(evalType: EvalType, rule: EvalRule, ruleId?: string): void;
15
50
  /**
16
51
  * Hot-remove a rule registered under `ruleId` so it stops firing on the
@@ -19,5 +54,48 @@ export declare class EvalEngine {
19
54
  * without an id); callers treat that as a no-op, not an error.
20
55
  */
21
56
  unregisterRule(ruleId: string): boolean;
57
+ /** Whether a deployed rule id is currently registered (and therefore firing). */
58
+ hasRule(ruleId: string): boolean;
22
59
  evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
60
+ /**
61
+ * eval_type="all" (#370): every built-in bundle, each with the deployed
62
+ * rules registered under it, plus the rules deployed under "custom" and
63
+ * the call's inline custom_rules — in ONE pass, sharing one regex budget,
64
+ * so the whole call is bounded exactly like a single bundle. The overall
65
+ * verdict is the same arithmetic as a single bundle applied to every rule
66
+ * that ran (weighted score against the threshold, critical veto across
67
+ * all bundles); `categories` carries the same arithmetic per bundle.
68
+ */
69
+ evaluateAll(context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
70
+ private run;
71
+ /**
72
+ * Weighted average over the rules that ran, plus the critical veto.
73
+ *
74
+ * Critical rules hard-fail. Before this existed, the weighted average
75
+ * routinely outvoted a genuine violation: an output containing a real
76
+ * SSN failed no_pii while the other safety rules passed, landing at
77
+ * ~0.765 — over the 0.7 threshold — so `passed`, the one field every
78
+ * automated gate keys on, said true about the product's flagship
79
+ * failure scenario. A detection that reports an all-clear is worse
80
+ * than no detection.
81
+ *
82
+ * Only EVALUATED failures count: a critical rule that skipped (missing
83
+ * context, broken config) has not judged the output and must not veto
84
+ * it. The score is left as-is — it stays a quality gradient; `passed`
85
+ * is the verdict, and the two answer different questions.
86
+ *
87
+ * A critical rule that SKIPPED is the fail-open seam between the
88
+ * release's two headline features: an adversary who knows a deployed
89
+ * critical regex can craft output that stalls it past the sandbox
90
+ * budget, and the rule then neither judges nor vetoes — so the eval
91
+ * returns passed=true with an EMPTY critical_failures on output that
92
+ * nobody actually cleared. The trade-off is deliberate (failing closed
93
+ * would let the same adversary force false violations on benign
94
+ * output), but before `criticalSkipped` the only trace of it was a
95
+ * suggestions line — prose. A gate that must fail closed should not
96
+ * have to walk rule_results[].budgetExceeded to discover it was defeated.
97
+ */
98
+ private summarize;
99
+ /** The per-bundle breakdown for eval_type="all": summarize() over each bundle's slice. */
100
+ private categorize;
23
101
  }