@iris-eval/mcp-server 0.4.5 → 0.4.6

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Iris — Agent Eval & Observability</title>
8
- <script type="module" crossorigin src="/assets/index-CIKsbEhq.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-ChcHJDDJ.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-B4Aw6ozt.css">
10
10
  </head>
11
11
  <body>
@@ -8,6 +8,7 @@ import { createCorsMiddleware } from '../middleware/cors.js';
8
8
  import { createErrorHandler } from '../middleware/error-handler.js';
9
9
  import { createApiRateLimiter } from '../middleware/rate-limit.js';
10
10
  import { createTenantMiddleware } from '../middleware/tenant.js';
11
+ import { createRebindingGuard, isLoopbackHost } from '../middleware/rebinding-guard.js';
11
12
  import { registerTraceRoutes } from './routes/traces.js';
12
13
  import { registerSummaryRoutes } from './routes/summary.js';
13
14
  import { registerEvaluationRoutes } from './routes/evaluations.js';
@@ -41,6 +42,18 @@ export function createDashboardServer(storage, config, logger, options) {
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
@@ -95,13 +108,42 @@ export function createDashboardServer(storage, config, logger, options) {
95
108
  res.sendFile(indexHtml);
96
109
  });
97
110
  }
111
+ else {
112
+ // Without this warning the server logs "Dashboard available at ..."
113
+ // while every page request 404s — an npm install always ships the
114
+ // bundle, so this only bites from-source runs, but when it bites the
115
+ // failure is opaque (before this line existed, a UI-less checkout
116
+ // failed the entire E2E suite with nothing but element-not-found
117
+ // timeouts).
118
+ logger.warn(`Dashboard UI bundle not found at ${indexHtml} — serving API only. ` +
119
+ `Build it with: cd dashboard && npm run build`);
120
+ }
98
121
  // Error handler (must be last)
99
122
  app.use(createErrorHandler(logger));
100
123
  return {
101
124
  app,
102
125
  start() {
103
- const server = app.listen(config.dashboard.port, () => {
104
- logger.info(`Dashboard available at http://localhost:${config.dashboard.port}`);
126
+ /*
127
+ * Bind to config.dashboard.host (loopback by default). Omitting the
128
+ * host argument makes Node listen on 0.0.0.0 AND [::], which put an
129
+ * unauthenticated API — full trace history plus rule deploy/delete —
130
+ * on every interface. That happened silently whenever `--transport
131
+ * http` started the dashboard implicitly, so binding the MCP
132
+ * transport to loopback still left a wide-open second server.
133
+ */
134
+ const server = app.listen(config.dashboard.port, config.dashboard.host, () => {
135
+ // Record the port actually bound so the rebinding guard builds its
136
+ // allowlist from it rather than from a configured 0.
137
+ const addr = server.address();
138
+ if (typeof addr === 'object' && addr)
139
+ boundPort = addr.port;
140
+ const shown = isLoopbackHost(config.dashboard.host) ? 'localhost' : config.dashboard.host;
141
+ logger.info(`Dashboard available at http://${shown}:${boundPort ?? config.dashboard.port}`);
142
+ if (!isLoopbackHost(config.dashboard.host) && !config.security.apiKey) {
143
+ logger.warn(`Dashboard is bound to ${config.dashboard.host} with NO api key — the full trace ` +
144
+ `history and rule management are reachable by anyone who can route to this host. ` +
145
+ `Set --api-key / IRIS_API_KEY, or bind to 127.0.0.1.`);
146
+ }
105
147
  });
106
148
  /*
107
149
  * F-006: surface listen() errors instead of swallowing them.
@@ -21,16 +21,31 @@ export class EvalEngine {
21
21
  customConfig: { ...this.ruleThresholds, ...context.customConfig },
22
22
  };
23
23
  }
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
- }
24
+ /*
25
+ * Inline custom_rules are ADDITIVE, which is what evaluate_output's
26
+ * description promises in two places: "fires REGARDLESS of eval_type"
27
+ * and "otherwise both your rules AND the eval_type bundle run together".
28
+ *
29
+ * The old branch did neither. `evalType === 'custom' && customRules`
30
+ * meant:
31
+ * - evaluate('safety', ctx, [myRule]) silently DISCARDED myRule and
32
+ * returned a plausible score that never applied it. An agent
33
+ * following the tool description got a wrong answer with no warning.
34
+ * - evaluate('custom', ctx, [myRule]) replaced the rule list entirely,
35
+ * EVICTING every rule the user had deployed and which the server
36
+ * registers at boot. Passing one ad-hoc rule disabled their whole
37
+ * library for that call.
38
+ *
39
+ * getRulesForType('custom') is [] (rules/index.ts), so eval_type="custom"
40
+ * still runs no built-in bundle — the documented "ONLY these" behaviour
41
+ * holds. What it now also includes is the caller's own deployed rules,
42
+ * which is the least surprising reading of having deployed them.
43
+ */
44
+ const rules = [
45
+ ...getRulesForType(evalType),
46
+ ...(this.additionalRules.get(evalType) ?? []),
47
+ ...(customRules ?? []).map((def) => createCustomRule(def)),
48
+ ];
34
49
  if (rules.length === 0) {
35
50
  return {
36
51
  id: generateEvalId(),
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns a human-readable reason when `source` shows superlinear
3
+ * backtracking, or null when it looks safe to deploy.
4
+ */
5
+ export declare function regexBacktrackingBudgetExceeded(source: string, flags?: string): string | null;
Binary file
@@ -5,23 +5,59 @@
5
5
  * patterns evaluate. Word-boundary anchors avoid matching inside larger
6
6
  * strings where appropriate.
7
7
  */
8
- // Exported so the claims drift test can assert .claims.json counts against
9
- // the runtime truth (tests/claims-eval-rules-counts.test.ts).
8
+ /*
9
+ * Every pattern here runs against ATTACKER-CONTROLLED text — agent output is
10
+ * untrusted by definition (resolve.ts states this outright), and any agent
11
+ * that summarises a web page, reads email, or handles user tickets can be
12
+ * fed a crafted string straight into evaluate_output.
13
+ *
14
+ * So: no ambiguous quantifiers. The rule that bit us was `\s*[:.]?\s*` in
15
+ * DOB and Medical Record Number — two adjacent unbounded whitespace
16
+ * quantifiers give the engine N+1 ways to split a run of N spaces, each of
17
+ * which fails at the trailing character class. Cost was quadratic in the
18
+ * input: 'MRN' + N spaces + '!' measured 31ms at 4k, 118ms at 8k, 468ms at
19
+ * 16k, and did not finish at the 1MB body limit. Node is single-threaded,
20
+ * so one call wedged the whole server.
21
+ *
22
+ * Bounded quantifiers ({0,8}) keep the alternatives constant regardless of
23
+ * input length. When adding a pattern, check for: adjacent quantifiers over
24
+ * overlapping character classes, nested quantifiers, and a character that
25
+ * can match both inside a + and as the following literal.
26
+ *
27
+ * Exported so the claims drift test can assert .claims.json counts against
28
+ * the runtime truth (tests/claims-eval-rules-counts.test.ts).
29
+ */
10
30
  export const PII_PATTERNS = [
11
31
  // Original v0.3.0 patterns
12
32
  { name: 'SSN', pattern: /\b\d{3}-\d{2}-\d{4}\b/ },
13
33
  { name: 'Credit Card', pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/ },
14
34
  { name: 'Phone', pattern: /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/ },
15
- { name: 'Email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i },
35
+ /*
36
+ * Every quantifier is bounded, at the RFC 5321 limits (local part 64,
37
+ * DNS label 63, TLD 24). Unbounded ones made this quadratic on text with
38
+ * no '@' in it: from EVERY starting position the local part consumed the
39
+ * rest of the string before failing, so N start positions each did O(N)
40
+ * work. 'a@' + 'a.'×32000 measured 3.5 seconds. Bounding the local part
41
+ * caps per-position work at a constant, which is what makes the whole
42
+ * scan linear.
43
+ *
44
+ * The domain is also written as explicit dot-separated labels rather than
45
+ * [A-Za-z0-9.-]+\. — that form lets '.' match both inside the + and as
46
+ * the following literal, which is its own source of splits to try.
47
+ */
48
+ {
49
+ name: 'Email',
50
+ pattern: /\b[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Z]{2,24}\b/i,
51
+ },
16
52
  // v0.3.1 additions
17
53
  // IBAN: 2 letters + 2 digits + 1-30 alphanumeric (international bank account number)
18
54
  { name: 'IBAN', pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/ },
19
55
  // US passport: 9 digits, optionally prefixed with letter (modern format C12345678)
20
56
  { name: 'Passport', pattern: /\b[A-Z]?\d{9}\b/ },
21
57
  // Date of birth contextual — DOB or "Born:" / "Birthday:" + date
22
- { name: 'DOB', pattern: /\b(?:DOB|D\.O\.B\.|Date of Birth|Born|Birthday)\s*[:.]?\s*\d{1,2}[\/\-.]\d{1,2}[\/\-.](?:\d{2}|\d{4})\b/i },
58
+ { name: 'DOB', pattern: /\b(?:DOB|D\.O\.B\.|Date of Birth|Born|Birthday)\s{0,8}[:.]?\s{0,8}\d{1,2}[\/\-.]\d{1,2}[\/\-.](?:\d{2}|\d{4})\b/i },
23
59
  // Medical record number — MRN: + alphanumeric (common format)
24
- { name: 'Medical Record Number', pattern: /\b(?:MRN|Medical Record (?:Number|No\.?|#))\s*[:.]?\s*[A-Z0-9]{6,12}\b/i },
60
+ { name: 'Medical Record Number', pattern: /\b(?:MRN|Medical Record (?:Number|No\.?|#))\s{0,8}[:.]?\s{0,8}[A-Z0-9]{6,12}\b/i },
25
61
  // IPv4 address
26
62
  { name: 'IP Address', pattern: /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b/ },
27
63
  // API key heuristic — looks for sk-/pk-/api_/Bearer + long alphanumeric
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ const CliSchema = z
28
28
  'api-key': z.string().min(1).optional(),
29
29
  dashboard: z.boolean().optional(),
30
30
  'dashboard-port': PortSchema.optional(),
31
+ 'dashboard-host': z.string().min(1).optional(),
31
32
  help: z.boolean().optional(),
32
33
  })
33
34
  .strict();
@@ -42,6 +43,7 @@ try {
42
43
  'api-key': { type: 'string' },
43
44
  dashboard: { type: 'boolean', default: false },
44
45
  'dashboard-port': { type: 'string' },
46
+ 'dashboard-host': { type: 'string' },
45
47
  help: { type: 'boolean', short: 'h', default: false },
46
48
  },
47
49
  strict: true,
@@ -74,16 +76,22 @@ Options:
74
76
  --api-key <key> API key for HTTP authentication
75
77
  --dashboard Enable web dashboard
76
78
  --dashboard-port <port> Dashboard port 1-65535 (default: 6920)
79
+ --dashboard-host <host> Dashboard bind address (default: 127.0.0.1). The dashboard is
80
+ unauthenticated unless --api-key is set — binding it beyond
81
+ loopback exposes your full trace history to the network.
77
82
  -h, --help Show this help message
78
83
 
79
84
  Environment variables (CLI flags take precedence):
80
85
  IRIS_TRANSPORT stdio | http
81
86
  IRIS_HOST Bind address for HTTP transport (default: 127.0.0.1)
82
87
  IRIS_PORT HTTP transport port (1-65535)
83
- IRIS_DB_PATH SQLite database path
88
+ IRIS_HOME Directory for all per-user files: config.json, iris.db, custom-rules.json,
89
+ audit.log, preferences.json (default: ~/.iris)
90
+ IRIS_DB_PATH SQLite database path (overrides IRIS_HOME for the DB only)
84
91
  IRIS_LOG_LEVEL debug | info | warn | error
85
92
  IRIS_DASHBOARD true to enable web dashboard
86
93
  IRIS_DASHBOARD_PORT Dashboard port (1-65535, default: 6920)
94
+ IRIS_DASHBOARD_HOST Dashboard bind address (default: 127.0.0.1)
87
95
  IRIS_API_KEY API key for HTTP authentication
88
96
  IRIS_ALLOWED_ORIGINS Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. http://localhost:*).
89
97
  HTTP transport: exact-match Origin allowlist for DNS-rebinding protection (globs ignored;
@@ -113,6 +121,7 @@ const config = loadConfig({
113
121
  apiKey: values['api-key'],
114
122
  dashboard: values.dashboard,
115
123
  dashboardPort: values['dashboard-port'],
124
+ dashboardHost: values['dashboard-host'],
116
125
  });
117
126
  const logger = createLogger(config);
118
127
  async function main() {
@@ -0,0 +1,21 @@
1
+ import type { RequestHandler } from 'express';
2
+ export declare function isLoopbackHost(host: string): boolean;
3
+ /** Concrete origins/hosts this server answers to on `port`. */
4
+ export declare function loopbackOriginsFor(port: number): string[];
5
+ export declare function loopbackHostsFor(port: number): string[];
6
+ export interface RebindingGuardOptions {
7
+ /**
8
+ * Port the server is actually bound to. Accepts a resolver because the
9
+ * middleware is registered BEFORE listen() — and the configured port is
10
+ * 0 whenever the caller wants an ephemeral one (tests and embedders do
11
+ * this). Baking 0 into the allowlist would produce `http://localhost:0`
12
+ * and reject every real request with a 403 that looks exactly like an
13
+ * attack. Same trap the MCP transport documents at transport/http.ts.
14
+ */
15
+ port: number | (() => number);
16
+ /** Bind address, used to decide whether Host validation applies. */
17
+ host: string;
18
+ /** Operator's configured origins; glob entries are ignored (see above). */
19
+ allowedOrigins?: string[];
20
+ }
21
+ export declare function createRebindingGuard(options: RebindingGuardOptions): RequestHandler;
@@ -0,0 +1,77 @@
1
+ /*
2
+ * DNS-rebinding protection for the dashboard HTTP API.
3
+ *
4
+ * v0.4.5 closed this hole on the MCP transport (/mcp) by handing
5
+ * allowedOrigins + allowedHosts to the SDK. The dashboard — same data,
6
+ * plus every mutating endpoint — never got the equivalent, and it starts
7
+ * implicitly alongside `--transport http`. So a browser on any page could
8
+ * POST a rule deployment to http://localhost:6920 and the server would
9
+ * execute it.
10
+ *
11
+ * CORS does not substitute, for the reason already written down in
12
+ * transport/http.ts: the browser withholds the RESPONSE, but the write has
13
+ * already happened. The request has to be REJECTED.
14
+ *
15
+ * Two checks, mirroring the SDK's semantics:
16
+ *
17
+ * Origin — enforced whenever the header is present. Absent means a
18
+ * non-browser client (curl, an MCP client, a health probe), which is not
19
+ * the threat model here; browsers always send it on cross-origin
20
+ * requests. Exact match only — glob patterns from the CORS allowlist are
21
+ * meaningless against a single concrete Origin and are dropped rather
22
+ * than left in the list looking effective.
23
+ *
24
+ * Host — enforced only when bound to loopback. A non-loopback bind is a
25
+ * deliberate network deployment, usually behind a proxy that rewrites
26
+ * Host, and an exact-match list would break it.
27
+ */
28
+ export function isLoopbackHost(host) {
29
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
30
+ }
31
+ /** Concrete origins/hosts this server answers to on `port`. */
32
+ export function loopbackOriginsFor(port) {
33
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`];
34
+ }
35
+ export function loopbackHostsFor(port) {
36
+ return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
37
+ }
38
+ export function createRebindingGuard(options) {
39
+ const { port, host, allowedOrigins = [] } = options;
40
+ const exactConfigured = allowedOrigins.filter((o) => !o.includes('*'));
41
+ const enforceHost = isLoopbackHost(host);
42
+ let cache;
43
+ function listsFor(resolvedPort) {
44
+ if (cache?.port !== resolvedPort) {
45
+ cache = {
46
+ port: resolvedPort,
47
+ origins: new Set([...loopbackOriginsFor(resolvedPort), ...exactConfigured]),
48
+ /*
49
+ * `[::1]:port` is the form Node actually puts in the Host header
50
+ * for an IPv6 loopback request — brackets included. A guard
51
+ * written against the bare '::1' would be inert, which is exactly
52
+ * how the citation-fetch SSRF guard was silently dead before
53
+ * v0.4.5 (URL.hostname returns '[::1]', never '::1').
54
+ */
55
+ hosts: new Set(loopbackHostsFor(resolvedPort)),
56
+ };
57
+ }
58
+ return cache;
59
+ }
60
+ return (req, res, next) => {
61
+ const resolvedPort = typeof port === 'function' ? port() : port;
62
+ const { origins, hosts } = listsFor(resolvedPort);
63
+ const origin = req.headers.origin;
64
+ if (origin && !origins.has(origin)) {
65
+ res.status(403).json({ error: 'Forbidden: invalid Origin header' });
66
+ return;
67
+ }
68
+ if (enforceHost) {
69
+ const hostHeader = req.headers.host;
70
+ if (hostHeader && !hosts.has(hostHeader)) {
71
+ res.status(403).json({ error: 'Forbidden: invalid Host header' });
72
+ return;
73
+ }
74
+ }
75
+ next();
76
+ };
77
+ }
@@ -18,9 +18,10 @@
18
18
  * /moments), dismissedTours, archivedMoments. These let the dashboard
19
19
  * remember the user's last view across reloads + across iris-mcp restarts.
20
20
  */
21
- import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
22
- import { join, dirname } from 'node:path';
23
- import { homedir } from 'node:os';
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { writeAtomic } from './utils/write-atomic.js';
23
+ import { irisHome } from './utils/iris-home.js';
24
+ import { join } from 'node:path';
24
25
  import { z } from 'zod';
25
26
  const MomentFiltersSchema = z
26
27
  .object({
@@ -61,7 +62,7 @@ export const PreferencesSchema = z
61
62
  })
62
63
  .passthrough();
63
64
  function defaultPreferencesPath() {
64
- return join(homedir(), '.iris', 'preferences.json');
65
+ return join(irisHome(), 'preferences.json');
65
66
  }
66
67
  function freshPreferences() {
67
68
  return PreferencesSchema.parse({
@@ -74,12 +75,6 @@ function freshPreferences() {
74
75
  archivedMoments: [],
75
76
  });
76
77
  }
77
- function writeAtomic(targetPath, contents) {
78
- mkdirSync(dirname(targetPath), { recursive: true });
79
- const tmp = `${targetPath}.tmp.${process.pid}`;
80
- writeFileSync(tmp, contents, 'utf-8');
81
- renameSync(tmp, targetPath);
82
- }
83
78
  export function loadOrInitPreferences(customPath) {
84
79
  const path = customPath ?? defaultPreferencesPath();
85
80
  if (!existsSync(path)) {
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const id = "005-normalize-created-at";
3
+ export declare function up(db: Database.Database): void;
@@ -0,0 +1,34 @@
1
+ export const id = '005-normalize-created-at';
2
+ /*
3
+ * Normalize created_at to ISO-8601 UTC.
4
+ *
5
+ * The column's DEFAULT is `datetime('now')`, which SQLite renders as
6
+ * "2026-08-09 15:00:00" — space separator, no milliseconds, no Z. Nothing
7
+ * ever wrote the column explicitly, so every row carried that shape. But
8
+ * every query compares it against a JS `toISOString()` value
9
+ * ("2026-08-09T15:00:00.000Z") using plain string comparison.
10
+ *
11
+ * ' ' is 0x20 and 'T' is 0x54, so the stored value sorts BEFORE any
12
+ * same-date boundary. Result: every eval whose calendar date equalled the
13
+ * window boundary's date was silently dropped from the window. A 20-hour-old
14
+ * eval vanished from "last 24h"; at 01:00 UTC the 24h view showed only what
15
+ * had happened since midnight. Traces were unaffected — log-trace writes a
16
+ * real ISO string — which is why this presented as "my evals are missing but
17
+ * my traces aren't".
18
+ *
19
+ * Fix in two halves: the adapter now writes ISO explicitly (so the DEFAULT
20
+ * never fires), and this migration rewrites the rows already on disk.
21
+ * strftime with %f gives milliseconds; SQLite stores UTC, so the literal Z
22
+ * is accurate. Rows already in ISO form are left alone — the LIKE guard
23
+ * matches only the space-separated shape, which keeps this idempotent and
24
+ * safe to run against a partially-migrated DB.
25
+ */
26
+ export function up(db) {
27
+ for (const table of ['traces', 'eval_results']) {
28
+ db.exec(`
29
+ UPDATE ${table}
30
+ SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
31
+ WHERE created_at LIKE '____-__-__ __:__:__%'
32
+ `);
33
+ }
34
+ }
@@ -2,7 +2,14 @@ import * as migration001 from './001-initial-schema.js';
2
2
  import * as migration002 from './002-eval-skip-fields.js';
3
3
  import * as migration003 from './003-eval-passed-index.js';
4
4
  import * as migration004 from './004-tenant-id.js';
5
- const migrations = [migration001, migration002, migration003, migration004];
5
+ import * as migration005 from './005-normalize-created-at.js';
6
+ const migrations = [
7
+ migration001,
8
+ migration002,
9
+ migration003,
10
+ migration004,
11
+ migration005,
12
+ ];
6
13
  export function runMigrations(db) {
7
14
  db.exec(`
8
15
  CREATE TABLE IF NOT EXISTS _iris_migrations (
@@ -145,10 +145,18 @@ export class SqliteAdapter {
145
145
  }
146
146
  async insertEvalResult(tenantId, result) {
147
147
  assertTenant(tenantId);
148
+ /*
149
+ * created_at is written EXPLICITLY as ISO-8601. Leaving it to the
150
+ * column DEFAULT (datetime('now')) stored "2026-08-09 15:00:00", which
151
+ * every period query then compared as a string against a JS
152
+ * toISOString() boundary — and ' ' sorts before 'T', so any eval whose
153
+ * calendar date matched the boundary's date was dropped from the
154
+ * window. Migration 005 rewrites rows written before this line existed.
155
+ */
148
156
  this.db.prepare(`
149
- INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data)
150
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
151
- `).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0);
157
+ INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data, created_at)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
159
+ `).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0, new Date().toISOString());
152
160
  }
153
161
  async getEvalsByTraceId(tenantId, traceId) {
154
162
  assertTenant(tenantId);
@@ -263,12 +271,28 @@ export class SqliteAdapter {
263
271
  FROM traces
264
272
  WHERE tenant_id = ? AND timestamp >= ?
265
273
  `).get(tenantId, since);
274
+ /*
275
+ * No `AND passed = 0` here — deliberately.
276
+ *
277
+ * A safety eval's score is the average across its rules, so a single
278
+ * violation is routinely outvoted: output containing "Your SSN is
279
+ * 123-45-6789" fails no_pii (score 0) while the three other safety
280
+ * rules pass, giving 0.733 overall — above the 0.7 threshold, so
281
+ * passed = 1. Filtering to failed evals therefore reported
282
+ * {pii: 0, injection: 0, hallucination: 0} for a trace that leaked a
283
+ * social security number.
284
+ *
285
+ * For a product whose job is catching PII, injection and hallucination,
286
+ * that error ran in the direction that HIDES problems. The count is
287
+ * per-VIOLATION, not per-failed-eval; the per-rule loop below already
288
+ * skips rules that passed, so scanning every safety eval in the window
289
+ * is both correct and sufficient.
290
+ */
266
291
  const safetyRows = this.db.prepare(`
267
292
  SELECT rule_results
268
293
  FROM eval_results
269
294
  WHERE tenant_id = ? AND created_at >= ?
270
295
  AND eval_type = 'safety'
271
- AND passed = 0
272
296
  `).all(tenantId, since);
273
297
  const violations = { pii: 0, injection: 0, hallucination: 0 };
274
298
  for (const row of safetyRows) {
@@ -15,6 +15,13 @@ export interface IrisConfig {
15
15
  dashboard: {
16
16
  enabled: boolean;
17
17
  port: number;
18
+ /**
19
+ * Bind address. Defaults to loopback: the dashboard is unauthenticated
20
+ * by default (security.apiKey is undefined) and serves the full trace
21
+ * history, so binding it to every interface exposes agent inputs and
22
+ * outputs to the local network. Set explicitly to share it.
23
+ */
24
+ host: string;
18
25
  };
19
26
  eval: {
20
27
  defaultThreshold: number;
@@ -0,0 +1 @@
1
+ export declare function irisHome(): string;
@@ -0,0 +1,21 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ /*
4
+ * Single resolver for the iris home directory (default: ~/.iris).
5
+ *
6
+ * Every per-user file iris touches lives under this directory — the
7
+ * SQLite DB default, config.json, custom-rules.json, audit.log,
8
+ * preferences.json. Before this helper each module joined
9
+ * homedir() + '.iris' itself, which meant there was no way to point a
10
+ * spawned server at a scratch directory: the E2E suite isolated the DB
11
+ * via IRIS_DB_PATH but still wiped the real audit.log, deployed test
12
+ * rules into the real custom-rules.json, and overwrote the real
13
+ * preferences.json on every run.
14
+ *
15
+ * IRIS_HOME redirects all of them at once. Read at call time — not
16
+ * module load — so a test harness that sets the env var before
17
+ * spawning (or between in-process calls) always wins.
18
+ */
19
+ export function irisHome() {
20
+ return process.env.IRIS_HOME ?? join(homedir(), '.iris');
21
+ }
@@ -0,0 +1 @@
1
+ export declare function writeAtomic(targetPath: string, contents: string): void;
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ /*
5
+ * Atomic file write: write a temp file, then rename it over the target.
6
+ *
7
+ * This lives in one place because it was duplicated verbatim in
8
+ * preferences.ts and custom-rule-store.ts, and both copies carried the same
9
+ * two Windows bugs.
10
+ *
11
+ * 1. The temp path was `${targetPath}.tmp.${process.pid}` — keyed on the
12
+ * PROCESS, not the call. Two concurrent writes to the same target inside
13
+ * one process (which is exactly what a vitest file does) therefore raced
14
+ * on a single temp path: one call renamed it away while the other was
15
+ * still writing, and the loser got
16
+ * EPERM: operation not permitted, rename '...preferences.json.tmp.38468'
17
+ * Observed twice in one session, on different suites. A random suffix
18
+ * makes each call's temp file its own.
19
+ *
20
+ * 2. Even with unique names, Windows can briefly deny a rename while a
21
+ * virus scanner or indexer holds the file. POSIX rename() has no such
22
+ * behaviour, so this never reproduces on CI. A few short retries turn a
23
+ * transient lock into a small delay instead of a lost write.
24
+ *
25
+ * The retry is deliberately narrow: only the error codes Windows raises for
26
+ * transient sharing violations. Anything else (ENOSPC, EROFS, a bad path)
27
+ * still throws immediately rather than being retried into a slow failure.
28
+ */
29
+ const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
30
+ const MAX_ATTEMPTS = 5;
31
+ function sleepSync(ms) {
32
+ // Synchronous by necessity — writeAtomic is sync, and making it async
33
+ // would ripple through every caller for a Windows-only edge case.
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
+ }
36
+ export function writeAtomic(targetPath, contents) {
37
+ mkdirSync(dirname(targetPath), { recursive: true });
38
+ const tmp = `${targetPath}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
39
+ writeFileSync(tmp, contents, 'utf-8');
40
+ let lastError;
41
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
42
+ try {
43
+ renameSync(tmp, targetPath);
44
+ return;
45
+ }
46
+ catch (err) {
47
+ lastError = err;
48
+ const code = err?.code;
49
+ if (!code || !TRANSIENT_RENAME_ERRORS.has(code))
50
+ break;
51
+ sleepSync(10 * (attempt + 1));
52
+ }
53
+ }
54
+ // Don't leave the temp file behind on a genuine failure — a stray
55
+ // `preferences.json.tmp.1234.ab12cd` next to the real file is confusing
56
+ // and never cleaned up otherwise.
57
+ try {
58
+ unlinkSync(tmp);
59
+ }
60
+ catch {
61
+ // Best effort; the original error is the one worth reporting.
62
+ }
63
+ throw lastError;
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iris-eval/mcp-server",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",
5
5
  "mcpName": "io.github.iris-eval/mcp-server",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/iris-eval/mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.4.5",
9
+ "version": "0.4.6",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@iris-eval/mcp-server",
14
- "version": "0.4.4",
14
+ "version": "0.4.6",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },