@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.
- package/README.md +94 -44
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +5 -5
- package/dist/config/defaults.js +3 -3
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +32 -6
- package/dist/custom-rule-store.js +100 -38
- package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
- package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/failures.d.ts +3 -0
- package/dist/dashboard/routes/failures.js +76 -0
- package/dist/dashboard/routes/index.d.ts +1 -0
- package/dist/dashboard/routes/index.js +1 -0
- package/dist/dashboard/routes/preferences.js +7 -2
- package/dist/dashboard/routes/rules.js +32 -14
- package/dist/dashboard/routes/traces.d.ts +12 -1
- package/dist/dashboard/routes/traces.js +90 -2
- package/dist/dashboard/seed-demo-data.d.ts +49 -0
- package/dist/dashboard/seed-demo-data.js +1080 -0
- package/dist/dashboard/server.js +124 -16
- package/dist/dashboard/validation.d.ts +74 -0
- package/dist/dashboard/validation.js +31 -2
- package/dist/eval/citation-verify/resolve.js +29 -0
- package/dist/eval/citation-verify/verifier.d.ts +1 -0
- package/dist/eval/citation-verify/verifier.js +12 -4
- package/dist/eval/engine.d.ts +15 -1
- package/dist/eval/engine.js +99 -15
- package/dist/eval/failure-rank.d.ts +14 -0
- package/dist/eval/failure-rank.js +44 -0
- package/dist/eval/rules/custom.d.ts +29 -1
- package/dist/eval/rules/custom.js +155 -19
- package/dist/eval/rules/regex-budget.d.ts +5 -0
- package/dist/eval/rules/regex-budget.js +100 -0
- package/dist/eval/rules/regex-sandbox.d.ts +26 -0
- package/dist/eval/rules/regex-sandbox.js +131 -0
- package/dist/eval/rules/relevance.d.ts +0 -2
- package/dist/eval/rules/relevance.js +6 -68
- package/dist/eval/rules/safety.d.ts +10 -0
- package/dist/eval/rules/safety.js +1377 -30
- package/dist/index.js +206 -19
- package/dist/middleware/rebinding-guard.d.ts +21 -0
- package/dist/middleware/rebinding-guard.js +77 -0
- package/dist/preferences.js +5 -10
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -0
- package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
- package/dist/storage/migrations/005-normalize-created-at.js +34 -0
- package/dist/storage/migrations/index.js +8 -1
- package/dist/storage/sqlite-adapter.d.ts +2 -0
- package/dist/storage/sqlite-adapter.js +93 -13
- package/dist/tools/delete-rule.d.ts +2 -1
- package/dist/tools/delete-rule.js +13 -4
- package/dist/tools/delete-trace.js +2 -1
- package/dist/tools/deploy-rule.d.ts +2 -1
- package/dist/tools/deploy-rule.js +29 -7
- package/dist/tools/evaluate-output.js +36 -9
- package/dist/tools/evaluate-with-llm-judge.js +2 -1
- package/dist/tools/get-traces.js +6 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/list-rules.js +2 -1
- package/dist/tools/log-trace.d.ts +51 -0
- package/dist/tools/log-trace.js +14 -2
- package/dist/tools/strict-input.d.ts +2 -0
- package/dist/tools/strict-input.js +35 -0
- package/dist/tools/verify-citations.js +7 -5
- package/dist/transport/http.js +24 -2
- package/dist/types/config.d.ts +7 -0
- package/dist/types/decision-moment.d.ts +12 -0
- package/dist/types/eval.d.ts +32 -0
- package/dist/types/query.d.ts +1 -1
- package/dist/utils/iris-home.d.ts +1 -0
- package/dist/utils/iris-home.js +21 -0
- package/dist/utils/write-atomic.d.ts +3 -0
- package/dist/utils/write-atomic.js +96 -0
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- package/dist/dashboard/assets/index-CIKsbEhq.js +0 -10
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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 verified — counting 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
|
}
|
package/dist/eval/engine.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/eval/engine.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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;
|