@iris-eval/mcp-server 0.4.5 → 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 (94) 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 +5 -5
  4. package/dist/config/defaults.js +3 -3
  5. package/dist/config/index.d.ts +1 -0
  6. package/dist/config/index.js +32 -6
  7. package/dist/custom-rule-store.js +100 -38
  8. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  9. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  14. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  15. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  20. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  21. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  23. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  24. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  25. package/dist/dashboard/index.html +2 -2
  26. package/dist/dashboard/routes/failures.d.ts +3 -0
  27. package/dist/dashboard/routes/failures.js +76 -0
  28. package/dist/dashboard/routes/index.d.ts +1 -0
  29. package/dist/dashboard/routes/index.js +1 -0
  30. package/dist/dashboard/routes/preferences.js +7 -2
  31. package/dist/dashboard/routes/rules.js +32 -14
  32. package/dist/dashboard/routes/traces.d.ts +12 -1
  33. package/dist/dashboard/routes/traces.js +90 -2
  34. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  35. package/dist/dashboard/seed-demo-data.js +1080 -0
  36. package/dist/dashboard/server.js +124 -16
  37. package/dist/dashboard/validation.d.ts +74 -0
  38. package/dist/dashboard/validation.js +31 -2
  39. package/dist/eval/citation-verify/resolve.js +29 -0
  40. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  41. package/dist/eval/citation-verify/verifier.js +12 -4
  42. package/dist/eval/engine.d.ts +15 -1
  43. package/dist/eval/engine.js +99 -15
  44. package/dist/eval/failure-rank.d.ts +14 -0
  45. package/dist/eval/failure-rank.js +44 -0
  46. package/dist/eval/rules/custom.d.ts +29 -1
  47. package/dist/eval/rules/custom.js +155 -19
  48. package/dist/eval/rules/regex-budget.d.ts +5 -0
  49. package/dist/eval/rules/regex-budget.js +100 -0
  50. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  51. package/dist/eval/rules/regex-sandbox.js +131 -0
  52. package/dist/eval/rules/relevance.d.ts +0 -2
  53. package/dist/eval/rules/relevance.js +6 -68
  54. package/dist/eval/rules/safety.d.ts +10 -0
  55. package/dist/eval/rules/safety.js +1377 -30
  56. package/dist/index.js +206 -19
  57. package/dist/middleware/rebinding-guard.d.ts +21 -0
  58. package/dist/middleware/rebinding-guard.js +77 -0
  59. package/dist/preferences.js +5 -10
  60. package/dist/self-test.d.ts +18 -0
  61. package/dist/self-test.js +329 -0
  62. package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
  63. package/dist/storage/migrations/005-normalize-created-at.js +34 -0
  64. package/dist/storage/migrations/index.js +8 -1
  65. package/dist/storage/sqlite-adapter.d.ts +2 -0
  66. package/dist/storage/sqlite-adapter.js +93 -13
  67. package/dist/tools/delete-rule.d.ts +2 -1
  68. package/dist/tools/delete-rule.js +13 -4
  69. package/dist/tools/delete-trace.js +2 -1
  70. package/dist/tools/deploy-rule.d.ts +2 -1
  71. package/dist/tools/deploy-rule.js +29 -7
  72. package/dist/tools/evaluate-output.js +36 -9
  73. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  74. package/dist/tools/get-traces.js +6 -2
  75. package/dist/tools/index.js +2 -2
  76. package/dist/tools/list-rules.js +2 -1
  77. package/dist/tools/log-trace.d.ts +51 -0
  78. package/dist/tools/log-trace.js +14 -2
  79. package/dist/tools/strict-input.d.ts +2 -0
  80. package/dist/tools/strict-input.js +35 -0
  81. package/dist/tools/verify-citations.js +7 -5
  82. package/dist/transport/http.js +24 -2
  83. package/dist/types/config.d.ts +7 -0
  84. package/dist/types/decision-moment.d.ts +12 -0
  85. package/dist/types/eval.d.ts +32 -0
  86. package/dist/types/query.d.ts +1 -1
  87. package/dist/utils/iris-home.d.ts +1 -0
  88. package/dist/utils/iris-home.js +21 -0
  89. package/dist/utils/write-atomic.d.ts +3 -0
  90. package/dist/utils/write-atomic.js +96 -0
  91. package/package.json +3 -2
  92. package/server.json +3 -3
  93. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  94. package/dist/dashboard/assets/index-CIKsbEhq.js +0 -10
@@ -2,12 +2,14 @@ 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';
9
10
  import { createApiRateLimiter } from '../middleware/rate-limit.js';
10
11
  import { createTenantMiddleware } from '../middleware/tenant.js';
12
+ import { createRebindingGuard, isLoopbackHost } from '../middleware/rebinding-guard.js';
11
13
  import { registerTraceRoutes } from './routes/traces.js';
12
14
  import { registerSummaryRoutes } from './routes/summary.js';
13
15
  import { registerEvaluationRoutes } from './routes/evaluations.js';
@@ -15,6 +17,7 @@ import { registerFilterRoutes } from './routes/filters.js';
15
17
  import { registerEvalStatsRoutes } from './routes/eval-stats.js';
16
18
  import { registerHealthRoutes } from './routes/health.js';
17
19
  import { registerMomentRoutes } from './routes/moments.js';
20
+ import { registerFailureRoutes } from './routes/failures.js';
18
21
  import { registerRuleRoutes } from './routes/rules.js';
19
22
  import { registerPreferencesRoutes } from './routes/preferences.js';
20
23
  import { registerAuditRoutes } from './routes/audit.js';
@@ -26,21 +29,31 @@ export function createDashboardServer(storage, config, logger, options) {
26
29
  directives: {
27
30
  defaultSrc: ["'self'"],
28
31
  scriptSrc: ["'self'"],
29
- // 'self' covers our bundled CSS. fonts.googleapis.com hosts the
30
- // brand fonts (Space Grotesk + Manrope + JetBrains Mono) loaded
31
- // via @import in tokens.css. Without this, the @import gets
32
- // blocked and the entire stylesheet is dropped by the browser.
33
- // v0.4.1 will self-host these fonts and let us tighten this back
34
- // to 'self' only.
35
- styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
36
- // The fontFaces in those stylesheets resolve to fonts.gstatic.com.
37
- 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:"],
38
39
  connectSrc: ["'self'"],
39
40
  },
40
41
  },
41
42
  }));
42
43
  // Body parser with size limit
43
44
  app.use(express.json({ limit: config.security.requestSizeLimit }));
45
+ /*
46
+ * DNS-rebinding guard BEFORE anything that reads or writes state. CORS
47
+ * runs after it and only decorates responses the guard already allowed —
48
+ * on its own CORS cannot stop a rebound page, because the write executes
49
+ * before the browser withholds the reply.
50
+ */
51
+ let boundPort;
52
+ app.use(createRebindingGuard({
53
+ port: () => boundPort ?? config.dashboard.port,
54
+ host: config.dashboard.host,
55
+ allowedOrigins: config.security.allowedOrigins,
56
+ }));
44
57
  // CORS
45
58
  app.use(createCorsMiddleware(config.security.allowedOrigins));
46
59
  // Authentication
@@ -52,13 +65,14 @@ export function createDashboardServer(storage, config, logger, options) {
52
65
  // API routes with rate limiting
53
66
  const router = express.Router();
54
67
  router.use(createApiRateLimiter(config));
55
- registerTraceRoutes(router, storage);
68
+ registerTraceRoutes(router, storage, { evalEngine: options?.evalEngine });
56
69
  registerSummaryRoutes(router, storage);
57
70
  registerEvaluationRoutes(router, storage);
58
71
  registerEvalStatsRoutes(router, storage);
59
72
  registerFilterRoutes(router, storage);
60
73
  registerHealthRoutes(router, storage, config.server.version);
61
74
  registerMomentRoutes(router, storage);
75
+ registerFailureRoutes(router, storage);
62
76
  if (options?.customRuleStore && options?.evalEngine) {
63
77
  registerRuleRoutes(router, storage, {
64
78
  customRuleStore: options.customRuleStore,
@@ -88,6 +102,24 @@ export function createDashboardServer(storage, config, logger, options) {
88
102
  const currentDir = dirname(fileURLToPath(import.meta.url));
89
103
  const staticDir = join(currentDir, '..', '..', 'dist', 'dashboard');
90
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
+ });
91
123
  if (existsSync(indexHtml)) {
92
124
  app.use(createApiRateLimiter(config));
93
125
  app.use(express.static(staticDir));
@@ -95,13 +127,76 @@ export function createDashboardServer(storage, config, logger, options) {
95
127
  res.sendFile(indexHtml);
96
128
  });
97
129
  }
130
+ else {
131
+ // Without this warning the server logs "Dashboard available at ..."
132
+ // while every page request 404s — an npm install always ships the
133
+ // bundle, so this only bites from-source runs, but when it bites the
134
+ // failure is opaque (before this line existed, a UI-less checkout
135
+ // failed the entire E2E suite with nothing but element-not-found
136
+ // timeouts).
137
+ logger.warn(`Dashboard UI bundle not found at ${indexHtml} — serving API only. ` +
138
+ `Build it with: cd dashboard && npm run build`);
139
+ }
98
140
  // Error handler (must be last)
99
141
  app.use(createErrorHandler(logger));
100
142
  return {
101
143
  app,
102
144
  start() {
103
- const server = app.listen(config.dashboard.port, () => {
104
- logger.info(`Dashboard available at http://localhost:${config.dashboard.port}`);
145
+ /*
146
+ * Bind to config.dashboard.host (loopback by default). Omitting the
147
+ * host argument makes Node listen on 0.0.0.0 AND [::], which put an
148
+ * unauthenticated API — full trace history plus rule deploy/delete —
149
+ * on every interface. That happened silently whenever `--transport
150
+ * http` started the dashboard implicitly, so binding the MCP
151
+ * transport to loopback still left a wide-open second server.
152
+ */
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;
168
+ // Record the port actually bound so the rebinding guard builds its
169
+ // allowlist from it rather than from a configured 0.
170
+ const addr = server.address();
171
+ if (typeof addr === 'object' && addr)
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
+ }
193
+ const shown = isLoopbackHost(config.dashboard.host) ? 'localhost' : config.dashboard.host;
194
+ logger.info(`Dashboard available at http://${shown}:${boundPort ?? config.dashboard.port}`);
195
+ if (!isLoopbackHost(config.dashboard.host) && !config.security.apiKey) {
196
+ logger.warn(`Dashboard is bound to ${config.dashboard.host} with NO api key — the full trace ` +
197
+ `history and rule management are reachable by anyone who can route to this host. ` +
198
+ `Set --api-key / IRIS_API_KEY, or bind to 127.0.0.1.`);
199
+ }
105
200
  });
106
201
  /*
107
202
  * F-006: surface listen() errors instead of swallowing them.
@@ -110,14 +205,27 @@ export function createDashboardServer(storage, config, logger, options) {
110
205
  * handler which emits a warning but doesn't crash — so the process
111
206
  * keeps running in a broken state. We log the specific cause then
112
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.
113
214
  */
114
215
  server.on('error', (err) => {
115
216
  if (err.code === 'EADDRINUSE') {
116
- logger.error(`Dashboard failed to start: port ${config.dashboard.port} is already in use. ` +
117
- `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}`);
118
224
  }
119
225
  else {
120
- 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}`);
121
229
  }
122
230
  process.exit(1);
123
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)
@@ -21,16 +50,31 @@ export class EvalEngine {
21
50
  customConfig: { ...this.ruleThresholds, ...context.customConfig },
22
51
  };
23
52
  }
24
- let rules;
25
- if (evalType === 'custom' && customRules) {
26
- rules = customRules.map((def) => createCustomRule(def));
27
- }
28
- else {
29
- rules = [
30
- ...getRulesForType(evalType),
31
- ...(this.additionalRules.get(evalType) ?? []),
32
- ];
33
- }
53
+ /*
54
+ * Inline custom_rules are ADDITIVE, which is what evaluate_output's
55
+ * description promises in two places: "fires REGARDLESS of eval_type"
56
+ * and "otherwise both your rules AND the eval_type bundle run together".
57
+ *
58
+ * The old branch did neither. `evalType === 'custom' && customRules`
59
+ * meant:
60
+ * - evaluate('safety', ctx, [myRule]) silently DISCARDED myRule and
61
+ * returned a plausible score that never applied it. An agent
62
+ * following the tool description got a wrong answer with no warning.
63
+ * - evaluate('custom', ctx, [myRule]) replaced the rule list entirely,
64
+ * EVICTING every rule the user had deployed and which the server
65
+ * registers at boot. Passing one ad-hoc rule disabled their whole
66
+ * library for that call.
67
+ *
68
+ * getRulesForType('custom') is [] (rules/index.ts), so eval_type="custom"
69
+ * still runs no built-in bundle — the documented "ONLY these" behaviour
70
+ * holds. What it now also includes is the caller's own deployed rules,
71
+ * which is the least surprising reading of having deployed them.
72
+ */
73
+ const rules = [
74
+ ...getRulesForType(evalType),
75
+ ...(this.additionalRules.get(evalType) ?? []),
76
+ ...(customRules ?? []).map((def) => createCustomRule(def)),
77
+ ];
34
78
  if (rules.length === 0) {
35
79
  return {
36
80
  id: generateEvalId(),
@@ -46,7 +90,16 @@ export class EvalEngine {
46
90
  insufficient_data: true,
47
91
  };
48
92
  }
49
- 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));
50
103
  // Partition into evaluated vs skipped
51
104
  const evaluatedIndices = [];
52
105
  const skippedIndices = [];
@@ -90,16 +143,46 @@ export class EvalEngine {
90
143
  }, 0);
91
144
  const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
92
145
  const score = Number.isFinite(rawScore) ? rawScore : 0;
93
- 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;
94
164
  const suggestions = [];
95
165
  for (const result of ruleResults) {
96
166
  if (!result.passed && !result.skipped) {
97
167
  suggestions.push(`[${result.ruleName}] ${result.message}`);
98
168
  }
99
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
+ }
100
173
  if (rulesSkipped > 0) {
101
- const skippedNames = ruleResults.filter((r) => r.skipped).map((r) => r.ruleName);
102
- 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('; ')}`);
103
186
  }
104
187
  return {
105
188
  id: generateEvalId(),
@@ -113,6 +196,7 @@ export class EvalEngine {
113
196
  rules_evaluated: rulesEvaluated,
114
197
  rules_skipped: rulesSkipped,
115
198
  insufficient_data: false,
199
+ ...(criticalFailures.length > 0 ? { critical_failures: criticalFailures } : {}),
116
200
  };
117
201
  }
118
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;