@iris-eval/mcp-server 0.4.6 → 0.5.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 (82) hide show
  1. package/README.md +94 -44
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +3 -3
  4. package/dist/config/index.js +18 -1
  5. package/dist/custom-rule-store.js +22 -8
  6. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  7. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  8. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  9. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  14. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  15. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  20. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  21. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  23. package/dist/dashboard/index.html +2 -2
  24. package/dist/dashboard/routes/failures.d.ts +3 -0
  25. package/dist/dashboard/routes/failures.js +76 -0
  26. package/dist/dashboard/routes/index.d.ts +1 -0
  27. package/dist/dashboard/routes/index.js +1 -0
  28. package/dist/dashboard/routes/preferences.js +7 -2
  29. package/dist/dashboard/routes/rules.js +32 -14
  30. package/dist/dashboard/routes/traces.d.ts +12 -1
  31. package/dist/dashboard/routes/traces.js +90 -2
  32. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  33. package/dist/dashboard/seed-demo-data.js +1080 -0
  34. package/dist/dashboard/server.js +81 -15
  35. package/dist/dashboard/validation.d.ts +74 -0
  36. package/dist/dashboard/validation.js +31 -2
  37. package/dist/eval/citation-verify/resolve.js +29 -0
  38. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  39. package/dist/eval/citation-verify/verifier.js +12 -4
  40. package/dist/eval/engine.d.ts +15 -1
  41. package/dist/eval/engine.js +74 -5
  42. package/dist/eval/failure-rank.d.ts +14 -0
  43. package/dist/eval/failure-rank.js +44 -0
  44. package/dist/eval/rules/custom.d.ts +29 -1
  45. package/dist/eval/rules/custom.js +155 -19
  46. package/dist/eval/rules/regex-budget.js +0 -0
  47. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  48. package/dist/eval/rules/regex-sandbox.js +131 -0
  49. package/dist/eval/rules/relevance.d.ts +0 -2
  50. package/dist/eval/rules/relevance.js +6 -68
  51. package/dist/eval/rules/safety.d.ts +10 -0
  52. package/dist/eval/rules/safety.js +1337 -26
  53. package/dist/index.js +196 -18
  54. package/dist/self-test.d.ts +18 -0
  55. package/dist/self-test.js +329 -0
  56. package/dist/storage/sqlite-adapter.d.ts +2 -0
  57. package/dist/storage/sqlite-adapter.js +65 -9
  58. package/dist/tools/delete-rule.d.ts +2 -1
  59. package/dist/tools/delete-rule.js +13 -4
  60. package/dist/tools/delete-trace.js +2 -1
  61. package/dist/tools/deploy-rule.d.ts +2 -1
  62. package/dist/tools/deploy-rule.js +29 -7
  63. package/dist/tools/evaluate-output.js +36 -9
  64. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  65. package/dist/tools/get-traces.js +6 -2
  66. package/dist/tools/index.js +2 -2
  67. package/dist/tools/list-rules.js +2 -1
  68. package/dist/tools/log-trace.d.ts +51 -0
  69. package/dist/tools/log-trace.js +14 -2
  70. package/dist/tools/strict-input.d.ts +2 -0
  71. package/dist/tools/strict-input.js +35 -0
  72. package/dist/tools/verify-citations.js +7 -5
  73. package/dist/transport/http.js +24 -2
  74. package/dist/types/decision-moment.d.ts +12 -0
  75. package/dist/types/eval.d.ts +32 -0
  76. package/dist/types/query.d.ts +1 -1
  77. package/dist/utils/write-atomic.d.ts +2 -0
  78. package/dist/utils/write-atomic.js +34 -2
  79. package/package.json +3 -2
  80. package/server.json +3 -3
  81. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  82. package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
@@ -2,7 +2,8 @@ import express from 'express';
2
2
  import helmet from 'helmet';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
- import { existsSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { irisHome } from '../utils/iris-home.js';
6
7
  import { createAuthMiddleware } from '../middleware/auth.js';
7
8
  import { createCorsMiddleware } from '../middleware/cors.js';
8
9
  import { createErrorHandler } from '../middleware/error-handler.js';
@@ -16,6 +17,7 @@ import { registerFilterRoutes } from './routes/filters.js';
16
17
  import { registerEvalStatsRoutes } from './routes/eval-stats.js';
17
18
  import { registerHealthRoutes } from './routes/health.js';
18
19
  import { registerMomentRoutes } from './routes/moments.js';
20
+ import { registerFailureRoutes } from './routes/failures.js';
19
21
  import { registerRuleRoutes } from './routes/rules.js';
20
22
  import { registerPreferencesRoutes } from './routes/preferences.js';
21
23
  import { registerAuditRoutes } from './routes/audit.js';
@@ -27,15 +29,13 @@ export function createDashboardServer(storage, config, logger, options) {
27
29
  directives: {
28
30
  defaultSrc: ["'self'"],
29
31
  scriptSrc: ["'self'"],
30
- // 'self' covers our bundled CSS. fonts.googleapis.com hosts the
31
- // brand fonts (Space Grotesk + Manrope + JetBrains Mono) loaded
32
- // via @import in tokens.css. Without this, the @import gets
33
- // blocked and the entire stylesheet is dropped by the browser.
34
- // v0.4.1 will self-host these fonts and let us tighten this back
35
- // to 'self' only.
36
- styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
37
- // The fontFaces in those stylesheets resolve to fonts.gstatic.com.
38
- fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
32
+ // 'self' covers our bundled CSS. The brand fonts (Space Grotesk +
33
+ // Manrope + JetBrains Mono) are self-hosted from /fonts as of
34
+ // #334, so no Google Fonts origins are needed. 'unsafe-inline'
35
+ // stays: the React components set style={} inline throughout.
36
+ styleSrc: ["'self'", "'unsafe-inline'"],
37
+ // Self-hosted woff2 under /fonts resolves via 'self'.
38
+ fontSrc: ["'self'", "data:"],
39
39
  connectSrc: ["'self'"],
40
40
  },
41
41
  },
@@ -65,13 +65,14 @@ export function createDashboardServer(storage, config, logger, options) {
65
65
  // API routes with rate limiting
66
66
  const router = express.Router();
67
67
  router.use(createApiRateLimiter(config));
68
- registerTraceRoutes(router, storage);
68
+ registerTraceRoutes(router, storage, { evalEngine: options?.evalEngine });
69
69
  registerSummaryRoutes(router, storage);
70
70
  registerEvaluationRoutes(router, storage);
71
71
  registerEvalStatsRoutes(router, storage);
72
72
  registerFilterRoutes(router, storage);
73
73
  registerHealthRoutes(router, storage, config.server.version);
74
74
  registerMomentRoutes(router, storage);
75
+ registerFailureRoutes(router, storage);
75
76
  if (options?.customRuleStore && options?.evalEngine) {
76
77
  registerRuleRoutes(router, storage, {
77
78
  customRuleStore: options.customRuleStore,
@@ -101,6 +102,24 @@ export function createDashboardServer(storage, config, logger, options) {
101
102
  const currentDir = dirname(fileURLToPath(import.meta.url));
102
103
  const staticDir = join(currentDir, '..', '..', 'dist', 'dashboard');
103
104
  const indexHtml = join(staticDir, 'index.html');
105
+ /*
106
+ * An unmatched /api/ path must answer as an API, not as the app.
107
+ *
108
+ * The SPA fallback below is deliberately a blanket catch-all so deep links
109
+ * like /traces/<id> survive a reload. Without this guard it also swallowed
110
+ * mistyped API routes: `GET /api/v1/tracez` returned 200 with index.html,
111
+ * so a client saw SUCCESS and then threw "Unexpected token '<'" from
112
+ * res.json() — sending the developer to debug their payload instead of
113
+ * their URL. A liveness check asserting only status === 200 would call a
114
+ * nonexistent endpoint healthy. POST to an unknown /api/ route reached
115
+ * Express's HTML error page, which is the same problem in a smaller hat.
116
+ *
117
+ * Mounted before the static handler so it wins regardless of method, and
118
+ * scoped to /api/ so nothing else changes.
119
+ */
120
+ app.use('/api', (_req, res) => {
121
+ res.status(404).json({ error: 'Unknown API route' });
122
+ });
104
123
  if (existsSync(indexHtml)) {
105
124
  app.use(createApiRateLimiter(config));
106
125
  app.use(express.static(staticDir));
@@ -131,12 +150,46 @@ export function createDashboardServer(storage, config, logger, options) {
131
150
  * http` started the dashboard implicitly, so binding the MCP
132
151
  * transport to loopback still left a wide-open second server.
133
152
  */
134
- const server = app.listen(config.dashboard.port, config.dashboard.host, () => {
153
+ // Distinguishes "never bound" from "failed after startup" so the
154
+ // error handler below can say which one actually happened.
155
+ let bound = false;
156
+ const server = app.listen(config.dashboard.port, config.dashboard.host, (err) => {
157
+ /*
158
+ * Express 5 also invokes this callback on a bind ERROR (it wires it
159
+ * via `server.once('error', done)`). Before this guard, a port
160
+ * collision ran the success path anyway: it logged "Dashboard
161
+ * available at http://localhost:<port>" — a URL owned by a DIFFERENT
162
+ * process — and overwrote runtime.json to point capture clients at
163
+ * that stranger. Failures belong to the 'error' handler below.
164
+ */
165
+ if (err)
166
+ return;
167
+ bound = true;
135
168
  // Record the port actually bound so the rebinding guard builds its
136
169
  // allowlist from it rather than from a configured 0.
137
170
  const addr = server.address();
138
171
  if (typeof addr === 'object' && addr)
139
172
  boundPort = addr.port;
173
+ /*
174
+ * Port-discovery handshake for capture clients (the
175
+ * @iris-eval/capture design pins this contract): write the port
176
+ * actually bound to ${IRIS_HOME}/runtime.json so an SDK can find
177
+ * the ingest endpoint without configuration. Best-effort — a
178
+ * failed write must never take the dashboard down. The file may
179
+ * go stale after an unclean exit; clients are expected to verify
180
+ * with GET /api/v1/health before trusting it.
181
+ */
182
+ try {
183
+ mkdirSync(irisHome(), { recursive: true });
184
+ writeFileSync(join(irisHome(), 'runtime.json'), JSON.stringify({
185
+ dashboardPort: boundPort ?? config.dashboard.port,
186
+ pid: process.pid,
187
+ startedAt: new Date().toISOString(),
188
+ }, null, 2));
189
+ }
190
+ catch (err) {
191
+ logger.warn(`Could not write runtime.json: ${err.message}`);
192
+ }
140
193
  const shown = isLoopbackHost(config.dashboard.host) ? 'localhost' : config.dashboard.host;
141
194
  logger.info(`Dashboard available at http://${shown}:${boundPort ?? config.dashboard.port}`);
142
195
  if (!isLoopbackHost(config.dashboard.host) && !config.security.apiKey) {
@@ -152,14 +205,27 @@ export function createDashboardServer(storage, config, logger, options) {
152
205
  * handler which emits a warning but doesn't crash — so the process
153
206
  * keeps running in a broken state. We log the specific cause then
154
207
  * exit(1) so the user sees the actual problem.
208
+ *
209
+ * Exiting nonzero is correct here because the dashboard only starts
210
+ * when EXPLICITLY requested (--dashboard / IRIS_DASHBOARD / --demo —
211
+ * see src/index.ts): the user asked for a surface they will not get,
212
+ * and running on while a health gate reports "ready" would send them
213
+ * to a port owned by a different process.
155
214
  */
156
215
  server.on('error', (err) => {
157
216
  if (err.code === 'EADDRINUSE') {
158
- logger.error(`Dashboard failed to start: port ${config.dashboard.port} is already in use. ` +
159
- `If running HTTP transport on the same port, use --dashboard-port <other>.`);
217
+ logger.error(`Dashboard failed to start: port ${config.dashboard.port} is already in use ` +
218
+ `(EADDRINUSE on ${config.dashboard.host}:${config.dashboard.port}). The dashboard was ` +
219
+ `explicitly requested, so iris is exiting. Pass --dashboard-port <other> (or set ` +
220
+ `IRIS_DASHBOARD_PORT) or stop the process that owns the port.`);
221
+ }
222
+ else if (!bound) {
223
+ logger.error(`Dashboard failed to start on ${config.dashboard.host}:${config.dashboard.port}: ${err.message}`);
160
224
  }
161
225
  else {
162
- logger.error(`Dashboard server error: ${err.message}`);
226
+ // Post-bind failure (e.g. EMFILE on accept) — "failed to start"
227
+ // would misdescribe a server that had been up and serving.
228
+ logger.error(`Dashboard server error after startup: ${err.message}`);
163
229
  }
164
230
  process.exit(1);
165
231
  });
@@ -1,4 +1,62 @@
1
1
  import { z } from 'zod';
2
+ export declare const ingestTraceSchema: z.ZodObject<{
3
+ evaluate: z.ZodDefault<z.ZodBoolean>;
4
+ eval_type: z.ZodDefault<z.ZodEnum<{
5
+ completeness: "completeness";
6
+ relevance: "relevance";
7
+ safety: "safety";
8
+ cost: "cost";
9
+ custom: "custom";
10
+ }>>;
11
+ agent_name: z.ZodString;
12
+ framework: z.ZodOptional<z.ZodString>;
13
+ input: z.ZodOptional<z.ZodString>;
14
+ output: z.ZodOptional<z.ZodString>;
15
+ tool_calls: z.ZodOptional<z.ZodArray<z.ZodObject<{
16
+ tool_name: z.ZodString;
17
+ input: z.ZodOptional<z.ZodUnknown>;
18
+ output: z.ZodOptional<z.ZodUnknown>;
19
+ latency_ms: z.ZodOptional<z.ZodNumber>;
20
+ error: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>>>;
22
+ latency_ms: z.ZodOptional<z.ZodNumber>;
23
+ token_usage: z.ZodOptional<z.ZodObject<{
24
+ prompt_tokens: z.ZodOptional<z.ZodNumber>;
25
+ completion_tokens: z.ZodOptional<z.ZodNumber>;
26
+ total_tokens: z.ZodOptional<z.ZodNumber>;
27
+ }, z.core.$strip>>;
28
+ cost_usd: z.ZodOptional<z.ZodNumber>;
29
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
30
+ spans: z.ZodOptional<z.ZodArray<z.ZodObject<{
31
+ span_id: z.ZodOptional<z.ZodString>;
32
+ parent_span_id: z.ZodOptional<z.ZodString>;
33
+ name: z.ZodString;
34
+ kind: z.ZodDefault<z.ZodEnum<{
35
+ INTERNAL: "INTERNAL";
36
+ SERVER: "SERVER";
37
+ CLIENT: "CLIENT";
38
+ PRODUCER: "PRODUCER";
39
+ CONSUMER: "CONSUMER";
40
+ LLM: "LLM";
41
+ TOOL: "TOOL";
42
+ }>>;
43
+ status_code: z.ZodDefault<z.ZodEnum<{
44
+ UNSET: "UNSET";
45
+ OK: "OK";
46
+ ERROR: "ERROR";
47
+ }>>;
48
+ status_message: z.ZodOptional<z.ZodString>;
49
+ start_time: z.ZodString;
50
+ end_time: z.ZodOptional<z.ZodString>;
51
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
52
+ events: z.ZodOptional<z.ZodArray<z.ZodObject<{
53
+ name: z.ZodString;
54
+ timestamp: z.ZodString;
55
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
56
+ }, z.core.$strip>>>;
57
+ }, z.core.$strip>>>;
58
+ timestamp: z.ZodOptional<z.ZodString>;
59
+ }, z.core.$strip>;
2
60
  export declare const traceQuerySchema: z.ZodObject<{
3
61
  agent_name: z.ZodOptional<z.ZodString>;
4
62
  framework: z.ZodOptional<z.ZodString>;
@@ -33,17 +91,33 @@ export declare const summaryQuerySchema: z.ZodObject<{
33
91
  export declare const evalStatsPeriodSchema: z.ZodObject<{
34
92
  period: z.ZodDefault<z.ZodEnum<{
35
93
  "24h": "24h";
94
+ "2d": "2d";
36
95
  "7d": "7d";
96
+ "14d": "14d";
37
97
  "30d": "30d";
98
+ "60d": "60d";
99
+ "90d": "90d";
100
+ "180d": "180d";
38
101
  all: "all";
39
102
  }>>;
40
103
  }, z.core.$strip>;
41
104
  export declare const evalStatsFailuresSchema: z.ZodObject<{
42
105
  period: z.ZodDefault<z.ZodEnum<{
43
106
  "24h": "24h";
107
+ "2d": "2d";
44
108
  "7d": "7d";
109
+ "14d": "14d";
45
110
  "30d": "30d";
111
+ "60d": "60d";
112
+ "90d": "90d";
113
+ "180d": "180d";
46
114
  all: "all";
47
115
  }>>;
48
116
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
49
117
  }, z.core.$strip>;
118
+ export declare const failuresQuerySchema: z.ZodObject<{
119
+ agent_name: z.ZodOptional<z.ZodString>;
120
+ since: z.ZodOptional<z.ZodString>;
121
+ until: z.ZodOptional<z.ZodString>;
122
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
123
+ }, z.core.$strip>;
@@ -1,4 +1,27 @@
1
1
  import { z } from 'zod';
2
+ import { logTraceInputShape } from '../tools/log-trace.js';
3
+ /*
4
+ * POST /api/v1/traces body — the log_trace tool contract plus the
5
+ * HTTP-only evaluation opt-in. Built FROM logTraceInputShape rather than
6
+ * 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.
9
+ */
10
+ export const ingestTraceSchema = z
11
+ .object({
12
+ ...logTraceInputShape,
13
+ evaluate: z.boolean().default(false),
14
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness'),
15
+ })
16
+ .superRefine((body, ctx) => {
17
+ if (body.evaluate && body.output === undefined) {
18
+ ctx.addIssue({
19
+ code: z.ZodIssueCode.custom,
20
+ path: ['output'],
21
+ message: '"output" is required when "evaluate" is true — the eval engine scores the output text',
22
+ });
23
+ }
24
+ });
2
25
  export const traceQuerySchema = z.object({
3
26
  agent_name: z.string().optional(),
4
27
  framework: z.string().optional(),
@@ -21,9 +44,15 @@ export const summaryQuerySchema = z.object({
21
44
  hours: z.coerce.number().int().min(1).max(8760).default(24),
22
45
  });
23
46
  export const evalStatsPeriodSchema = z.object({
24
- period: z.enum(['24h', '7d', '30d', 'all']).default('24h'),
47
+ period: z.enum(['24h', '2d', '7d', '14d', '30d', '60d', '90d', '180d', 'all']).default('24h'),
25
48
  });
26
49
  export const evalStatsFailuresSchema = z.object({
27
- period: z.enum(['24h', '7d', '30d', 'all']).default('24h'),
50
+ period: z.enum(['24h', '2d', '7d', '14d', '30d', '60d', '90d', '180d', 'all']).default('24h'),
28
51
  limit: z.coerce.number().int().min(1).max(100).default(10),
29
52
  });
53
+ export const failuresQuerySchema = z.object({
54
+ agent_name: z.string().min(1).max(200).optional(),
55
+ since: z.string().datetime({ offset: true }).optional(),
56
+ until: z.string().datetime({ offset: true }).optional(),
57
+ limit: z.coerce.number().int().min(1).max(100).default(50),
58
+ });
@@ -55,6 +55,17 @@ const BLOCKED_IPV4 = [
55
55
  /^255\.255\.255\.255$/,
56
56
  // This-network
57
57
  /^0\./,
58
+ // Carrier-grade NAT (RFC 6598). Routable inside an ISP or a corporate
59
+ // overlay — Tailscale hands out 100.64/10 addresses, so this range reaches
60
+ // real internal hosts on a very common setup.
61
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
62
+ // IETF protocol assignments (RFC 6890) incl. 192.0.0.0/24
63
+ /^192\.0\.0\./,
64
+ // Benchmarking (RFC 2544) — routed to internal test networks in practice
65
+ /^198\.(1[89])\./,
66
+ // Multicast and reserved/future space
67
+ /^(22[4-9]|23\d)\./,
68
+ /^(24\d|25[0-5])\./,
58
69
  ];
59
70
  const BLOCKED_HOST_SUBSTRINGS = ['localhost', 'internal', '.local', 'metadata.google', 'metadata.azure'];
60
71
  function isIpv4(host) {
@@ -144,6 +155,24 @@ function isBlockedIpv6(addr) {
144
155
  // fc00::/7 unique-local (fc.. / fd..)
145
156
  if (first.startsWith('fc') || first.startsWith('fd'))
146
157
  return true;
158
+ /*
159
+ * Transition mechanisms tunnel an IPv4 destination inside an IPv6 literal,
160
+ * so the v4 blocklist has to be applied to the embedded address or the
161
+ * whole v4 ruleset is bypassable by re-encoding the target.
162
+ *
163
+ * 6to4 (2002::/16, RFC 3056): the destination v4 is hextets 1-2, plain.
164
+ * Teredo (2001:0000::/32, RFC 4380): the client v4 is hextets 6-7, stored
165
+ * one's-complemented, so it must be un-obfuscated before classification.
166
+ */
167
+ if (first === '2002') {
168
+ return BLOCKED_IPV4.some((re) => re.test(ipv4FromHextets(g[1], g[2])));
169
+ }
170
+ if (first === '2001' && g[1] === '0000') {
171
+ const deobfuscate = (h) => (parseInt(h, 16) ^ 0xffff).toString(16).padStart(4, '0');
172
+ const client = ipv4FromHextets(deobfuscate(g[6]), deobfuscate(g[7]));
173
+ const server = ipv4FromHextets(g[2], g[3]);
174
+ return BLOCKED_IPV4.some((re) => re.test(client) || re.test(server));
175
+ }
147
176
  // IPv4-mapped ::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d (deprecated)
148
177
  const mapped = g.slice(0, 5).every((h) => h === '0000') && g[5] === 'ffff';
149
178
  const compat = g.slice(0, 6).every((h) => h === '0000') && !(g[6] === '0000' && g[7] === '0000');
@@ -38,6 +38,7 @@ export interface VerifyCitationsResult {
38
38
  totalCostUsd: number;
39
39
  totalCitationsFound: number;
40
40
  totalResolved: number;
41
+ totalJudged: number;
41
42
  totalSupported: number;
42
43
  }
43
44
  export declare function verifyCitations(params: VerifyCitationsParams): Promise<VerifyCitationsResult>;
@@ -56,6 +56,7 @@ export async function verifyCitations(params) {
56
56
  const out = [];
57
57
  let totalCost = 0;
58
58
  let totalResolved = 0;
59
+ let totalJudged = 0;
59
60
  let totalSupported = 0;
60
61
  for (const citation of selected) {
61
62
  // Only URL/DOI can be resolved. Numbered citations without
@@ -163,6 +164,7 @@ export async function verifyCitations(params) {
163
164
  });
164
165
  continue;
165
166
  }
167
+ totalJudged++;
166
168
  if (parsed.supported)
167
169
  totalSupported++;
168
170
  out.push({
@@ -186,10 +188,15 @@ export async function verifyCitations(params) {
186
188
  },
187
189
  });
188
190
  }
189
- const overallScore = totalResolved > 0 ? Math.round((totalSupported / totalResolved) * 100) / 100 : null;
190
- // Fail if >= 50% of resolved sources don't support the claim. When
191
- // no citations or none resolved, we don't fail there's nothing to
192
- // score, we just report that.
191
+ // Denominator = citations the judge actually ruled on. A resolved
192
+ // citation whose judge call hit the cost cap, timed out, errored, or
193
+ // emitted unparseable JSON was never verifiedcounting it as
194
+ // unsupported would make a judge outage on 5 of 10 supported citations
195
+ // score 0.5, indistinguishable from fabrication.
196
+ const overallScore = totalJudged > 0 ? Math.round((totalSupported / totalJudged) * 100) / 100 : null;
197
+ // Fail if >= 50% of judged sources don't support the claim. When no
198
+ // citations, none resolved, or none judged, we don't fail — there's
199
+ // nothing to score, we just report that.
193
200
  const passed = overallScore === null ? true : overallScore >= 0.5;
194
201
  return {
195
202
  overallScore,
@@ -198,6 +205,7 @@ export async function verifyCitations(params) {
198
205
  totalCostUsd: Math.round(totalCost * 1_000_000) / 1_000_000,
199
206
  totalCitationsFound: totalFound,
200
207
  totalResolved,
208
+ totalJudged,
201
209
  totalSupported,
202
210
  };
203
211
  }
@@ -1,9 +1,23 @@
1
1
  import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition } from '../types/eval.js';
2
2
  export declare class EvalEngine {
3
3
  private additionalRules;
4
+ /**
5
+ * Registered-rule handles keyed by deployed rule id, so delete paths can
6
+ * hot-remove exactly the instance they registered. Keyed by id (not name)
7
+ * because deploy_rule doesn't enforce name uniqueness — two rules can
8
+ * share a name with different definitions.
9
+ */
10
+ private rulesById;
4
11
  private threshold;
5
12
  private ruleThresholds?;
6
13
  constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
7
- registerRule(evalType: EvalType, rule: EvalRule): void;
14
+ registerRule(evalType: EvalType, rule: EvalRule, ruleId?: string): void;
15
+ /**
16
+ * Hot-remove a rule registered under `ruleId` so it stops firing on the
17
+ * live process — what delete_rule's description promises (#332). Returns
18
+ * false when the id was never registered (already removed, or registered
19
+ * without an id); callers treat that as a no-op, not an error.
20
+ */
21
+ unregisterRule(ruleId: string): boolean;
8
22
  evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
9
23
  }
@@ -2,16 +2,45 @@ import { getRulesForType, createCustomRule } from './rules/index.js';
2
2
  import { generateEvalId } from '../utils/ids.js';
3
3
  export class EvalEngine {
4
4
  additionalRules = new Map();
5
+ /**
6
+ * Registered-rule handles keyed by deployed rule id, so delete paths can
7
+ * hot-remove exactly the instance they registered. Keyed by id (not name)
8
+ * because deploy_rule doesn't enforce name uniqueness — two rules can
9
+ * share a name with different definitions.
10
+ */
11
+ rulesById = new Map();
5
12
  threshold;
6
13
  ruleThresholds;
7
14
  constructor(threshold = 0.7, ruleThresholds) {
8
15
  this.threshold = threshold;
9
16
  this.ruleThresholds = ruleThresholds;
10
17
  }
11
- registerRule(evalType, rule) {
18
+ registerRule(evalType, rule, ruleId) {
12
19
  const existing = this.additionalRules.get(evalType) ?? [];
13
20
  existing.push(rule);
14
21
  this.additionalRules.set(evalType, existing);
22
+ if (ruleId !== undefined) {
23
+ this.rulesById.set(ruleId, { evalType, rule });
24
+ }
25
+ }
26
+ /**
27
+ * Hot-remove a rule registered under `ruleId` so it stops firing on the
28
+ * live process — what delete_rule's description promises (#332). Returns
29
+ * false when the id was never registered (already removed, or registered
30
+ * without an id); callers treat that as a no-op, not an error.
31
+ */
32
+ unregisterRule(ruleId) {
33
+ const entry = this.rulesById.get(ruleId);
34
+ if (!entry)
35
+ return false;
36
+ this.rulesById.delete(ruleId);
37
+ const rules = this.additionalRules.get(entry.evalType);
38
+ if (rules) {
39
+ const idx = rules.indexOf(entry.rule);
40
+ if (idx !== -1)
41
+ rules.splice(idx, 1);
42
+ }
43
+ return true;
15
44
  }
16
45
  evaluate(evalType, context, customRules) {
17
46
  // Merge system-level thresholds into customConfig (user-provided values take precedence)
@@ -61,7 +90,16 @@ export class EvalEngine {
61
90
  insufficient_data: true,
62
91
  };
63
92
  }
64
- const ruleResults = rules.map((rule) => rule.evaluate(context));
93
+ /*
94
+ * Shallow copy so the regex circuit breaker is scoped to THIS evaluation
95
+ * and never leaks into a caller-held context object. All rules in one
96
+ * evaluation share the breaker: after MAX_REGEX_BREACHES_PER_EVAL sandbox
97
+ * budget breaches (see rules/custom.ts), remaining regex rules skip
98
+ * without running — one hostile output cannot stall the request once per
99
+ * rule it carries.
100
+ */
101
+ const evalContext = { ...context, regexBudget: { breaches: 0 } };
102
+ const ruleResults = rules.map((rule) => rule.evaluate(evalContext));
65
103
  // Partition into evaluated vs skipped
66
104
  const evaluatedIndices = [];
67
105
  const skippedIndices = [];
@@ -105,16 +143,46 @@ export class EvalEngine {
105
143
  }, 0);
106
144
  const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
107
145
  const score = Number.isFinite(rawScore) ? rawScore : 0;
108
- const passed = score >= this.threshold;
146
+ /*
147
+ * Critical rules hard-fail. Before this existed, the weighted average
148
+ * routinely outvoted a genuine violation: an output containing a real
149
+ * SSN failed no_pii while the other safety rules passed, landing at
150
+ * ~0.765 — over the 0.7 threshold — so `passed`, the one field every
151
+ * automated gate keys on, said true about the product's flagship
152
+ * failure scenario. A detection that reports an all-clear is worse
153
+ * than no detection.
154
+ *
155
+ * Only EVALUATED failures count: a critical rule that skipped (missing
156
+ * context, broken config) has not judged the output and must not veto
157
+ * it. The score is left as-is — it stays a quality gradient; `passed`
158
+ * is the verdict, and the two answer different questions.
159
+ */
160
+ const criticalFailures = evaluatedIndices
161
+ .filter((i) => rules[i].critical === true && !ruleResults[i].passed)
162
+ .map((i) => ruleResults[i].ruleName);
163
+ const passed = score >= this.threshold && criticalFailures.length === 0;
109
164
  const suggestions = [];
110
165
  for (const result of ruleResults) {
111
166
  if (!result.passed && !result.skipped) {
112
167
  suggestions.push(`[${result.ruleName}] ${result.message}`);
113
168
  }
114
169
  }
170
+ if (criticalFailures.length > 0 && score >= this.threshold) {
171
+ suggestions.push(`Critical rule(s) failed (${criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
172
+ }
115
173
  if (rulesSkipped > 0) {
116
- const skippedNames = ruleResults.filter((r) => r.skipped).map((r) => r.ruleName);
117
- suggestions.push(`${rulesSkipped} rule(s) skipped (missing context): ${skippedNames.join(', ')}`);
174
+ /*
175
+ * Say WHY each rule skipped. The old line hardcoded "(missing
176
+ * context)" — but a rule whose regex was killed at the sandbox budget
177
+ * did not lack context, it was DEFEATED by this output, and labeling
178
+ * that "missing context" hid the one signal a fail-closed consumer
179
+ * needs. Each rule's own skipReason is the truth; missing context is
180
+ * only the default for rules that skip without stating a reason.
181
+ */
182
+ const skippedParts = ruleResults
183
+ .filter((r) => r.skipped)
184
+ .map((r) => `${r.ruleName} (${r.skipReason ?? 'missing context'})`);
185
+ suggestions.push(`${rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
118
186
  }
119
187
  return {
120
188
  id: generateEvalId(),
@@ -128,6 +196,7 @@ export class EvalEngine {
128
196
  rules_evaluated: rulesEvaluated,
129
197
  rules_skipped: rulesSkipped,
130
198
  insufficient_data: false,
199
+ ...(criticalFailures.length > 0 ? { critical_failures: criticalFailures } : {}),
131
200
  };
132
201
  }
133
202
  }
@@ -0,0 +1,14 @@
1
+ import type { DecisionMoment } from '../types/decision-moment.js';
2
+ /** Recency half-life: a failure loses half its rank weight every 24h. */
3
+ export declare const FAILURE_RANK_HALF_LIFE_MS: number;
4
+ /**
5
+ * Is this moment a failure (verdict fail/partial) or flagged
6
+ * (safety/cost significance regardless of verdict)?
7
+ */
8
+ export declare function isFailureMoment(moment: DecisionMoment): boolean;
9
+ /**
10
+ * Rank score for a failure moment: significance × recency decay.
11
+ * Higher = shown first. Future timestamps (clock skew) clamp to age 0
12
+ * rather than inflating the score.
13
+ */
14
+ export declare function rankFailureScore(moment: DecisionMoment, nowMs: number): number;
@@ -0,0 +1,44 @@
1
+ /*
2
+ * failure-rank — pure ranking logic for the failure-first landing list.
3
+ *
4
+ * The dashboard's default screen is a ranked list of recent failures
5
+ * ("what's new and bad"), not an aggregate. Ranking blends two signals:
6
+ *
7
+ * severity — the significance classifier's 0-1 score (safety-violation
8
+ * 1.0 > cost-spike 0.9 > rule-collision 0.7 > normal-fail
9
+ * 0.5/0.4). See classifySignificance in decision-moment.ts.
10
+ * recency — exponential decay with a 24h half-life. A safety violation
11
+ * from three days ago ranks below a plain fail from an hour
12
+ * ago, which is the right call for a "since you last looked"
13
+ * surface — old severity is history, not news.
14
+ *
15
+ * Kept as a pure module (no storage, no clock reads — `nowMs` is a
16
+ * parameter) so tests can pin time and assert exact orderings.
17
+ */
18
+ /** Recency half-life: a failure loses half its rank weight every 24h. */
19
+ export const FAILURE_RANK_HALF_LIFE_MS = 24 * 60 * 60 * 1000;
20
+ /*
21
+ * Significance kinds that flag a moment for the failure list even when
22
+ * its verdict is not fail/partial. A cost spike on a passing trace is
23
+ * still something the builder should see on the landing screen.
24
+ */
25
+ const FLAGGED_KINDS = new Set(['safety-violation', 'cost-spike']);
26
+ /**
27
+ * Is this moment a failure (verdict fail/partial) or flagged
28
+ * (safety/cost significance regardless of verdict)?
29
+ */
30
+ export function isFailureMoment(moment) {
31
+ if (moment.verdict === 'fail' || moment.verdict === 'partial')
32
+ return true;
33
+ return FLAGGED_KINDS.has(moment.significance.kind);
34
+ }
35
+ /**
36
+ * Rank score for a failure moment: significance × recency decay.
37
+ * Higher = shown first. Future timestamps (clock skew) clamp to age 0
38
+ * rather than inflating the score.
39
+ */
40
+ export function rankFailureScore(moment, nowMs) {
41
+ const ageMs = Math.max(0, nowMs - new Date(moment.timestamp).getTime());
42
+ const recency = Math.pow(0.5, ageMs / FAILURE_RANK_HALF_LIFE_MS);
43
+ return moment.significance.score * recency;
44
+ }
@@ -1,2 +1,30 @@
1
1
  import type { EvalRule, CustomRuleDefinition } from '../../types/eval.js';
2
- export declare function createCustomRule(definition: CustomRuleDefinition): EvalRule;
2
+ import type { RuleSeverity } from '../../types/custom-rule.js';
3
+ /**
4
+ * Converts a leading inline flag group like `(?i)` or `(?im)` into a real
5
+ * flags argument. Node's RegExp engine does not support inline flag groups,
6
+ * and a user pasting `(?i)foo` from a regex tutorial would otherwise hit
7
+ * "Invalid group" with no clear recovery.
8
+ *
9
+ * Exported so deploy-time validation (custom-rule-store) probes the SAME
10
+ * pattern+flags pair the evaluator will actually run — the store used to
11
+ * strip the inline group but not merge its flags, probing `(?i)…` under
12
+ * different flags than evaluation used.
13
+ */
14
+ export declare function normalizeRegexSource(patternStr: string, flags: string): {
15
+ pattern: string;
16
+ flags: string;
17
+ };
18
+ /**
19
+ * Builds a runnable EvalRule from a persisted/inline definition.
20
+ *
21
+ * `severity` comes from the DEPLOYED rule's metadata (deploy_rule / the
22
+ * dashboard composer). high/critical severities make the rule CRITICAL:
23
+ * a failing evaluation forces the overall eval to passed=false regardless
24
+ * of the weighted score. Before this, a rule-author could deploy a
25
+ * severity="critical" policy rule, watch it FAIL on a violating output,
26
+ * and still get passed:true (score 0.895) — severity affected nothing but
27
+ * dashboard sorting. Inline custom_rules (evaluate_output's per-call
28
+ * definitions) carry no severity and stay weight-only.
29
+ */
30
+ export declare function createCustomRule(definition: CustomRuleDefinition, severity?: RuleSeverity): EvalRule;