@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.
- package/README.md +94 -44
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/index.js +18 -1
- package/dist/custom-rule-store.js +22 -8
- 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 +81 -15
- 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 +74 -5
- 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.js +0 -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 +1337 -26
- package/dist/index.js +196 -18
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -0
- package/dist/storage/sqlite-adapter.d.ts +2 -0
- package/dist/storage/sqlite-adapter.js +65 -9
- 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/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/write-atomic.d.ts +2 -0
- package/dist/utils/write-atomic.js +34 -2
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -61,7 +90,16 @@ export class EvalEngine {
|
|
|
61
90
|
insufficient_data: true,
|
|
62
91
|
};
|
|
63
92
|
}
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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;
|