Sign In

@iris-eval/mcp-server

Package Overview
Dependencies
Maintainers
1
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@iris-eval/mcp-server - npm Package Compare versions

Comparing version
0.4.5
to
0.4.6
dist/dashboard/assets/index-ChcHJDDJ.js

Sorry, the diff of this file is too big to display

+5
/**
* Returns a human-readable reason when `source` shows superlinear
* backtracking, or null when it looks safe to deploy.
*/
export declare function regexBacktrackingBudgetExceeded(source: string, flags?: string): string | null;
/*
* Empirical backtracking probe for user-supplied regex patterns.
*
* safe-regex2 is a STATIC heuristic built on star height — it catches
* EXPONENTIAL blowup like `(a+)+$` and nothing else. Polynomial patterns
* sail through it: `a*a*a*a*a*b` is judged safe, and takes 156ms on 40
* characters, 237ms on 60, and effectively forever on a realistic agent
* output. Deployed rules are re-registered into the engine at every
* startup, so a pattern like that keeps wedging the server after a
* restart — a permanent, self-inflicted denial of service.
*
* Static analysis of backtracking is hard; actually running the pattern is
* not. This measures it against short adversarial payloads and rejects
* anything already slow at trivial sizes.
*
* The catch-22 — running an untrusted regex to find out whether it hangs —
* is handled by escalating from a tiny payload upward and bailing the
* moment the budget is exceeded. A superlinear pattern blows past it at 16
* or 32 characters, which is cheap; a linear one stays near zero even at
* 128. Nothing here ever runs a pattern against a large input.
*/
/** Total wall-clock a candidate pattern may spend across all probes. */
const BUDGET_MS = 50;
const PROBE_SIZES = [16, 32, 64, 128];
/**
* Characters that tend to maximise backtracking pressure for a given
* pattern: the literals it mentions, plus generic filler. Feeding a
* pattern its own alphabet is what makes the engine explore alternatives
* rather than fail at the first character.
*/
function probeAlphabets(source) {
const literals = source.replace(/[^A-Za-z0-9 ._@-]/g, '');
const fromPattern = [...new Set(literals)].join('').slice(0, 4);
const alphabets = ['a', ' ', 'a.', 'ab'];
if (fromPattern.length > 0)
alphabets.unshift(fromPattern);
return alphabets;
}
/**
* Returns a human-readable reason when `source` shows superlinear
* backtracking, or null when it looks safe to deploy.
*/
export function regexBacktrackingBudgetExceeded(source, flags = '') {
let compiled;
try {
compiled = new RegExp(source, flags);
}
catch {
// Syntax is validated separately and reported with a better message.
return null;
}
const started = Date.now();
for (const size of PROBE_SIZES) {
for (const alphabet of probeAlphabets(source)) {
const payload = alphabet.repeat(Math.ceil(size / alphabet.length)).slice(0, size) + '';
compiled.test(payload);
const elapsed = Date.now() - started;
if (elapsed > BUDGET_MS) {
return (`Regex pattern rejected: superlinear backtracking (still running after ${elapsed}ms ` +
`on a ${size}-character input). safe-regex2 only catches exponential blowup, so ` +
`polynomial patterns like a*a*a*a*a*b pass it while still hanging the server. ` +
`Avoid adjacent unbounded quantifiers over overlapping character classes; bound them ` +
`instead, e.g. \\s{0,8} rather than \\s*.`);
}
}
}
return null;
}
import type { RequestHandler } from 'express';
export declare function isLoopbackHost(host: string): boolean;
/** Concrete origins/hosts this server answers to on `port`. */
export declare function loopbackOriginsFor(port: number): string[];
export declare function loopbackHostsFor(port: number): string[];
export interface RebindingGuardOptions {
/**
* Port the server is actually bound to. Accepts a resolver because the
* middleware is registered BEFORE listen() — and the configured port is
* 0 whenever the caller wants an ephemeral one (tests and embedders do
* this). Baking 0 into the allowlist would produce `http://localhost:0`
* and reject every real request with a 403 that looks exactly like an
* attack. Same trap the MCP transport documents at transport/http.ts.
*/
port: number | (() => number);
/** Bind address, used to decide whether Host validation applies. */
host: string;
/** Operator's configured origins; glob entries are ignored (see above). */
allowedOrigins?: string[];
}
export declare function createRebindingGuard(options: RebindingGuardOptions): RequestHandler;
/*
* DNS-rebinding protection for the dashboard HTTP API.
*
* v0.4.5 closed this hole on the MCP transport (/mcp) by handing
* allowedOrigins + allowedHosts to the SDK. The dashboard — same data,
* plus every mutating endpoint — never got the equivalent, and it starts
* implicitly alongside `--transport http`. So a browser on any page could
* POST a rule deployment to http://localhost:6920 and the server would
* execute it.
*
* CORS does not substitute, for the reason already written down in
* transport/http.ts: the browser withholds the RESPONSE, but the write has
* already happened. The request has to be REJECTED.
*
* Two checks, mirroring the SDK's semantics:
*
* Origin — enforced whenever the header is present. Absent means a
* non-browser client (curl, an MCP client, a health probe), which is not
* the threat model here; browsers always send it on cross-origin
* requests. Exact match only — glob patterns from the CORS allowlist are
* meaningless against a single concrete Origin and are dropped rather
* than left in the list looking effective.
*
* Host — enforced only when bound to loopback. A non-loopback bind is a
* deliberate network deployment, usually behind a proxy that rewrites
* Host, and an exact-match list would break it.
*/
export function isLoopbackHost(host) {
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
}
/** Concrete origins/hosts this server answers to on `port`. */
export function loopbackOriginsFor(port) {
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`];
}
export function loopbackHostsFor(port) {
return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
}
export function createRebindingGuard(options) {
const { port, host, allowedOrigins = [] } = options;
const exactConfigured = allowedOrigins.filter((o) => !o.includes('*'));
const enforceHost = isLoopbackHost(host);
let cache;
function listsFor(resolvedPort) {
if (cache?.port !== resolvedPort) {
cache = {
port: resolvedPort,
origins: new Set([...loopbackOriginsFor(resolvedPort), ...exactConfigured]),
/*
* `[::1]:port` is the form Node actually puts in the Host header
* for an IPv6 loopback request — brackets included. A guard
* written against the bare '::1' would be inert, which is exactly
* how the citation-fetch SSRF guard was silently dead before
* v0.4.5 (URL.hostname returns '[::1]', never '::1').
*/
hosts: new Set(loopbackHostsFor(resolvedPort)),
};
}
return cache;
}
return (req, res, next) => {
const resolvedPort = typeof port === 'function' ? port() : port;
const { origins, hosts } = listsFor(resolvedPort);
const origin = req.headers.origin;
if (origin && !origins.has(origin)) {
res.status(403).json({ error: 'Forbidden: invalid Origin header' });
return;
}
if (enforceHost) {
const hostHeader = req.headers.host;
if (hostHeader && !hosts.has(hostHeader)) {
res.status(403).json({ error: 'Forbidden: invalid Host header' });
return;
}
}
next();
};
}
import type Database from 'better-sqlite3';
export declare const id = "005-normalize-created-at";
export declare function up(db: Database.Database): void;
export const id = '005-normalize-created-at';
/*
* Normalize created_at to ISO-8601 UTC.
*
* The column's DEFAULT is `datetime('now')`, which SQLite renders as
* "2026-08-09 15:00:00" — space separator, no milliseconds, no Z. Nothing
* ever wrote the column explicitly, so every row carried that shape. But
* every query compares it against a JS `toISOString()` value
* ("2026-08-09T15:00:00.000Z") using plain string comparison.
*
* ' ' is 0x20 and 'T' is 0x54, so the stored value sorts BEFORE any
* same-date boundary. Result: every eval whose calendar date equalled the
* window boundary's date was silently dropped from the window. A 20-hour-old
* eval vanished from "last 24h"; at 01:00 UTC the 24h view showed only what
* had happened since midnight. Traces were unaffected — log-trace writes a
* real ISO string — which is why this presented as "my evals are missing but
* my traces aren't".
*
* Fix in two halves: the adapter now writes ISO explicitly (so the DEFAULT
* never fires), and this migration rewrites the rows already on disk.
* strftime with %f gives milliseconds; SQLite stores UTC, so the literal Z
* is accurate. Rows already in ISO form are left alone — the LIKE guard
* matches only the space-separated shape, which keeps this idempotent and
* safe to run against a partially-migrated DB.
*/
export function up(db) {
for (const table of ['traces', 'eval_results']) {
db.exec(`
UPDATE ${table}
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at LIKE '____-__-__ __:__:__%'
`);
}
}
export declare function irisHome(): string;
import { join } from 'node:path';
import { homedir } from 'node:os';
/*
* Single resolver for the iris home directory (default: ~/.iris).
*
* Every per-user file iris touches lives under this directory — the
* SQLite DB default, config.json, custom-rules.json, audit.log,
* preferences.json. Before this helper each module joined
* homedir() + '.iris' itself, which meant there was no way to point a
* spawned server at a scratch directory: the E2E suite isolated the DB
* via IRIS_DB_PATH but still wiped the real audit.log, deployed test
* rules into the real custom-rules.json, and overwrote the real
* preferences.json on every run.
*
* IRIS_HOME redirects all of them at once. Read at call time — not
* module load — so a test harness that sets the env var before
* spawning (or between in-process calls) always wins.
*/
export function irisHome() {
return process.env.IRIS_HOME ?? join(homedir(), '.iris');
}
export declare function writeAtomic(targetPath: string, contents: string): void;
import { mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
/*
* Atomic file write: write a temp file, then rename it over the target.
*
* This lives in one place because it was duplicated verbatim in
* preferences.ts and custom-rule-store.ts, and both copies carried the same
* two Windows bugs.
*
* 1. The temp path was `${targetPath}.tmp.${process.pid}` — keyed on the
* PROCESS, not the call. Two concurrent writes to the same target inside
* one process (which is exactly what a vitest file does) therefore raced
* on a single temp path: one call renamed it away while the other was
* still writing, and the loser got
* EPERM: operation not permitted, rename '...preferences.json.tmp.38468'
* Observed twice in one session, on different suites. A random suffix
* makes each call's temp file its own.
*
* 2. Even with unique names, Windows can briefly deny a rename while a
* virus scanner or indexer holds the file. POSIX rename() has no such
* behaviour, so this never reproduces on CI. A few short retries turn a
* transient lock into a small delay instead of a lost write.
*
* The retry is deliberately narrow: only the error codes Windows raises for
* transient sharing violations. Anything else (ENOSPC, EROFS, a bad path)
* still throws immediately rather than being retried into a slow failure.
*/
const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
const MAX_ATTEMPTS = 5;
function sleepSync(ms) {
// Synchronous by necessity — writeAtomic is sync, and making it async
// would ripple through every caller for a Windows-only edge case.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
export function writeAtomic(targetPath, contents) {
mkdirSync(dirname(targetPath), { recursive: true });
const tmp = `${targetPath}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
writeFileSync(tmp, contents, 'utf-8');
let lastError;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
renameSync(tmp, targetPath);
return;
}
catch (err) {
lastError = err;
const code = err?.code;
if (!code || !TRANSIENT_RENAME_ERRORS.has(code))
break;
sleepSync(10 * (attempt + 1));
}
}
// Don't leave the temp file behind on a genuine failure — a stray
// `preferences.json.tmp.1234.ab12cd` next to the real file is confusing
// and never cleaned up otherwise.
try {
unlinkSync(tmp);
}
catch {
// Best effort; the original error is the one worth reporting.
}
throw lastError;
}
+2
-2

@@ -15,3 +15,3 @@ /*

import { join } from 'node:path';
import { homedir } from 'node:os';
import { irisHome } from './utils/iris-home.js';
import { z } from 'zod';

@@ -35,3 +35,3 @@ const AUDIT_ACTIONS = ['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update'];

function defaultAuditPath() {
return join(homedir(), '.iris', 'audit.log');
return join(irisHome(), 'audit.log');
}

@@ -38,0 +38,0 @@ export function readAuditLog(opts) {

import { join } from 'node:path';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
const irisHome = join(homedir(), '.iris');
import { irisHome } from '../utils/iris-home.js';
// Read version from package.json to avoid hardcoded drift

@@ -20,3 +19,3 @@ let pkgVersion = '0.1.8';

type: 'sqlite',
path: join(irisHome, 'iris.db'),
path: join(irisHome(), 'iris.db'),
},

@@ -35,2 +34,3 @@ server: {

port: 6920,
host: '127.0.0.1',
},

@@ -37,0 +37,0 @@ eval: {

@@ -9,4 +9,5 @@ import type { IrisConfig } from '../types/index.js';

dashboardPort?: number;
dashboardHost?: string;
apiKey?: string;
}
export declare function loadConfig(cliArgs?: CliArgs): IrisConfig;
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { defaultConfig } from './defaults.js';
import { irisHome } from '../utils/iris-home.js';
function deepMerge(target, source) {

@@ -74,2 +74,8 @@ const result = { ...target };

}
if (process.env.IRIS_DASHBOARD_HOST) {
config.dashboard = {
...config.dashboard,
host: process.env.IRIS_DASHBOARD_HOST,
};
}
if (process.env.IRIS_API_KEY) {

@@ -103,2 +109,5 @@ config.security = { ...config.security, apiKey: process.env.IRIS_API_KEY };

}
if (args.dashboardHost) {
config.dashboard = { ...config.dashboard, host: args.dashboardHost };
}
if (args.apiKey) {

@@ -110,7 +119,7 @@ config.security = { ...config.security, apiKey: args.apiKey };

export function loadConfig(cliArgs) {
const irisHome = join(homedir(), '.iris');
if (!existsSync(irisHome)) {
mkdirSync(irisHome, { recursive: true });
const home = irisHome();
if (!existsSync(home)) {
mkdirSync(home, { recursive: true });
}
const configPath = cliArgs?.config ?? join(irisHome, 'config.json');
const configPath = cliArgs?.config ?? join(home, 'config.json');
const fileConfig = loadConfigFile(configPath);

@@ -117,0 +126,0 @@ const envConfig = loadEnvVars();

@@ -26,8 +26,10 @@ /*

*/
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, appendFileSync } from 'node:fs';
import { mkdirSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
import { writeAtomic } from './utils/write-atomic.js';
import { irisHome } from './utils/iris-home.js';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { randomBytes } from 'node:crypto';
import { z } from 'zod';
import isSafeRegex from 'safe-regex2';
import { regexBacktrackingBudgetExceeded } from './eval/rules/regex-budget.js';
import { CUSTOM_RULE_CONFIG_KEYS, readNumericConfig, describeKeys } from './eval/rules/config-keys.js';

@@ -125,3 +127,18 @@ import { LOCAL_TENANT } from './types/tenant.js';

});
break;
}
// safe-regex2 is a star-height heuristic — it catches EXPONENTIAL
// blowup only. Polynomial patterns pass it: a*a*a*a*a*b is judged
// safe and takes 156ms on 40 characters. Measure what the static
// check cannot see.
{
const budgetIssue = regexBacktrackingBudgetExceeded(stripped, typeof config.flags === 'string' ? config.flags : '');
if (budgetIssue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['config', 'pattern'],
message: budgetIssue,
});
}
}
break;

@@ -172,6 +189,2 @@ }

});
const FileSchema = z.object({
version: z.literal(1),
rules: z.array(DeployedRuleSchema),
});
/**

@@ -183,3 +196,3 @@ * Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path

if (tenantId === LOCAL_TENANT) {
return join(homedir(), '.iris', 'custom-rules.json');
return join(irisHome(), 'custom-rules.json');
}

@@ -190,6 +203,6 @@ // Sanitize tenant id for filesystem safety. TenantId is branded but

const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
return join(homedir(), '.iris', `custom-rules-${safe}.json`);
return join(irisHome(), `custom-rules-${safe}.json`);
}
function defaultAuditPath() {
return join(homedir(), '.iris', 'audit.log');
return join(irisHome(), 'audit.log');
}

@@ -209,23 +222,40 @@ function generateRuleId() {

}
function writeAtomic(targetPath, contents) {
mkdirSync(dirname(targetPath), { recursive: true });
const tmp = `${targetPath}.tmp.${process.pid}`;
writeFileSync(tmp, contents, 'utf-8');
renameSync(tmp, targetPath);
}
/*
* Read leniently, one rule at a time.
*
* This used to validate the whole array with a single safeParse and return
* [] if ANY element failed. The empty result was then cached, and the next
* deploy/delete/toggle called persist(), which wrote {version:1, rules:[]}
* over the file — permanently destroying every valid rule alongside the
* bad one. The old comment ("do NOT overwrite the file") described an
* intent the write path did not honour.
*
* It was reachable, not theoretical: DefinitionSchema's superRefine now
* runs on READ as well as WRITE, and eval/rules/custom.ts notes that rules
* predating that validation — e.g. {type:'min_length', config:{}} — are
* already sitting in users' files.
*/
function loadRulesFromDisk(rulesPath) {
if (!existsSync(rulesPath))
return [];
return { rules: [], quarantined: [], readable: true };
let parsedJson;
try {
const raw = readFileSync(rulesPath, 'utf-8');
const parsed = FileSchema.safeParse(JSON.parse(raw));
if (parsed.success)
return parsed.data.rules;
// Malformed: leave rules empty; do NOT overwrite the file.
return [];
parsedJson = JSON.parse(readFileSync(rulesPath, 'utf-8'));
}
catch {
// Unreadable: leave rules empty.
return [];
return { rules: [], quarantined: [], readable: false };
}
const envelope = z.object({ rules: z.array(z.unknown()).optional() }).safeParse(parsedJson);
if (!envelope.success)
return { rules: [], quarantined: [], readable: false };
const rules = [];
const quarantined = [];
for (const entry of envelope.data.rules ?? []) {
const rule = DeployedRuleSchema.safeParse(entry);
if (rule.success)
rules.push(rule.data);
else
quarantined.push(entry);
}
return { rules, quarantined, readable: true };
}

@@ -237,14 +267,32 @@ export function createCustomRuleStore(opts) {

// tenant; subsequent calls hit the cache.
const tenantRules = new Map();
function load(tenantId) {
let rules = tenantRules.get(tenantId);
if (rules === undefined) {
rules = loadRulesFromDisk(pathFor(tenantId));
tenantRules.set(tenantId, rules);
const tenantState = new Map();
function state(tenantId) {
let loaded = tenantState.get(tenantId);
if (loaded === undefined) {
loaded = loadRulesFromDisk(pathFor(tenantId));
tenantState.set(tenantId, loaded);
}
return rules;
return loaded;
}
function load(tenantId) {
return state(tenantId).rules;
}
function persist(tenantId) {
const rules = tenantRules.get(tenantId) ?? [];
const file = { version: 1, rules };
const loaded = state(tenantId);
if (!loaded.readable) {
/*
* The file exists but never parsed. Overwriting it would replace
* content we could not read — exactly the data loss this store used
* to cause silently. Fail loudly so the caller surfaces a 500 and
* the operator can fix or move the file.
*/
throw new Error(`Refusing to write ${pathFor(tenantId)}: the existing file could not be parsed. ` +
`Fix or move it, then retry — writing now would destroy its contents.`);
}
// Quarantined entries ride along untouched so a deploy never deletes
// rules this version could not validate.
const file = {
version: 1,
rules: [...loaded.rules, ...loaded.quarantined],
};
writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));

@@ -251,0 +299,0 @@ }

@@ -8,3 +8,3 @@ <!DOCTYPE html>

<title>Iris — Agent Eval & Observability</title>
<script type="module" crossorigin src="/assets/index-CIKsbEhq.js"></script>
<script type="module" crossorigin src="/assets/index-ChcHJDDJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4Aw6ozt.css">

@@ -11,0 +11,0 @@ </head>

@@ -11,2 +11,3 @@ import express from 'express';

import { createTenantMiddleware } from '../middleware/tenant.js';
import { createRebindingGuard, isLoopbackHost } from '../middleware/rebinding-guard.js';
import { registerTraceRoutes } from './routes/traces.js';

@@ -45,2 +46,14 @@ import { registerSummaryRoutes } from './routes/summary.js';

app.use(express.json({ limit: config.security.requestSizeLimit }));
/*
* DNS-rebinding guard BEFORE anything that reads or writes state. CORS
* runs after it and only decorates responses the guard already allowed —
* on its own CORS cannot stop a rebound page, because the write executes
* before the browser withholds the reply.
*/
let boundPort;
app.use(createRebindingGuard({
port: () => boundPort ?? config.dashboard.port,
host: config.dashboard.host,
allowedOrigins: config.security.allowedOrigins,
}));
// CORS

@@ -100,2 +113,12 @@ app.use(createCorsMiddleware(config.security.allowedOrigins));

}
else {
// Without this warning the server logs "Dashboard available at ..."
// while every page request 404s — an npm install always ships the
// bundle, so this only bites from-source runs, but when it bites the
// failure is opaque (before this line existed, a UI-less checkout
// failed the entire E2E suite with nothing but element-not-found
// timeouts).
logger.warn(`Dashboard UI bundle not found at ${indexHtml} — serving API only. ` +
`Build it with: cd dashboard && npm run build`);
}
// Error handler (must be last)

@@ -106,4 +129,23 @@ app.use(createErrorHandler(logger));

start() {
const server = app.listen(config.dashboard.port, () => {
logger.info(`Dashboard available at http://localhost:${config.dashboard.port}`);
/*
* Bind to config.dashboard.host (loopback by default). Omitting the
* host argument makes Node listen on 0.0.0.0 AND [::], which put an
* unauthenticated API — full trace history plus rule deploy/delete —
* on every interface. That happened silently whenever `--transport
* http` started the dashboard implicitly, so binding the MCP
* transport to loopback still left a wide-open second server.
*/
const server = app.listen(config.dashboard.port, config.dashboard.host, () => {
// Record the port actually bound so the rebinding guard builds its
// allowlist from it rather than from a configured 0.
const addr = server.address();
if (typeof addr === 'object' && addr)
boundPort = addr.port;
const shown = isLoopbackHost(config.dashboard.host) ? 'localhost' : config.dashboard.host;
logger.info(`Dashboard available at http://${shown}:${boundPort ?? config.dashboard.port}`);
if (!isLoopbackHost(config.dashboard.host) && !config.security.apiKey) {
logger.warn(`Dashboard is bound to ${config.dashboard.host} with NO api key — the full trace ` +
`history and rule management are reachable by anyone who can route to this host. ` +
`Set --api-key / IRIS_API_KEY, or bind to 127.0.0.1.`);
}
});

@@ -110,0 +152,0 @@ /*

@@ -24,12 +24,27 @@ import { getRulesForType, createCustomRule } from './rules/index.js';

}
let rules;
if (evalType === 'custom' && customRules) {
rules = customRules.map((def) => createCustomRule(def));
}
else {
rules = [
...getRulesForType(evalType),
...(this.additionalRules.get(evalType) ?? []),
];
}
/*
* Inline custom_rules are ADDITIVE, which is what evaluate_output's
* description promises in two places: "fires REGARDLESS of eval_type"
* and "otherwise both your rules AND the eval_type bundle run together".
*
* The old branch did neither. `evalType === 'custom' && customRules`
* meant:
* - evaluate('safety', ctx, [myRule]) silently DISCARDED myRule and
* returned a plausible score that never applied it. An agent
* following the tool description got a wrong answer with no warning.
* - evaluate('custom', ctx, [myRule]) replaced the rule list entirely,
* EVICTING every rule the user had deployed and which the server
* registers at boot. Passing one ad-hoc rule disabled their whole
* library for that call.
*
* getRulesForType('custom') is [] (rules/index.ts), so eval_type="custom"
* still runs no built-in bundle — the documented "ONLY these" behaviour
* holds. What it now also includes is the caller's own deployed rules,
* which is the least surprising reading of having deployed them.
*/
const rules = [
...getRulesForType(evalType),
...(this.additionalRules.get(evalType) ?? []),
...(customRules ?? []).map((def) => createCustomRule(def)),
];
if (rules.length === 0) {

@@ -36,0 +51,0 @@ return {

@@ -8,4 +8,24 @@ /*

*/
// Exported so the claims drift test can assert .claims.json counts against
// the runtime truth (tests/claims-eval-rules-counts.test.ts).
/*
* Every pattern here runs against ATTACKER-CONTROLLED text — agent output is
* untrusted by definition (resolve.ts states this outright), and any agent
* that summarises a web page, reads email, or handles user tickets can be
* fed a crafted string straight into evaluate_output.
*
* So: no ambiguous quantifiers. The rule that bit us was `\s*[:.]?\s*` in
* DOB and Medical Record Number — two adjacent unbounded whitespace
* quantifiers give the engine N+1 ways to split a run of N spaces, each of
* which fails at the trailing character class. Cost was quadratic in the
* input: 'MRN' + N spaces + '!' measured 31ms at 4k, 118ms at 8k, 468ms at
* 16k, and did not finish at the 1MB body limit. Node is single-threaded,
* so one call wedged the whole server.
*
* Bounded quantifiers ({0,8}) keep the alternatives constant regardless of
* input length. When adding a pattern, check for: adjacent quantifiers over
* overlapping character classes, nested quantifiers, and a character that
* can match both inside a + and as the following literal.
*
* Exported so the claims drift test can assert .claims.json counts against
* the runtime truth (tests/claims-eval-rules-counts.test.ts).
*/
export const PII_PATTERNS = [

@@ -16,3 +36,19 @@ // Original v0.3.0 patterns

{ name: 'Phone', pattern: /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/ },
{ name: 'Email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i },
/*
* Every quantifier is bounded, at the RFC 5321 limits (local part 64,
* DNS label 63, TLD 24). Unbounded ones made this quadratic on text with
* no '@' in it: from EVERY starting position the local part consumed the
* rest of the string before failing, so N start positions each did O(N)
* work. 'a@' + 'a.'×32000 measured 3.5 seconds. Bounding the local part
* caps per-position work at a constant, which is what makes the whole
* scan linear.
*
* The domain is also written as explicit dot-separated labels rather than
* [A-Za-z0-9.-]+\. — that form lets '.' match both inside the + and as
* the following literal, which is its own source of splits to try.
*/
{
name: 'Email',
pattern: /\b[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Z]{2,24}\b/i,
},
// v0.3.1 additions

@@ -24,5 +60,5 @@ // IBAN: 2 letters + 2 digits + 1-30 alphanumeric (international bank account number)

// Date of birth contextual — DOB or "Born:" / "Birthday:" + date
{ 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 },
{ 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 },
// Medical record number — MRN: + alphanumeric (common format)
{ name: 'Medical Record Number', pattern: /\b(?:MRN|Medical Record (?:Number|No\.?|#))\s*[:.]?\s*[A-Z0-9]{6,12}\b/i },
{ name: 'Medical Record Number', pattern: /\b(?:MRN|Medical Record (?:Number|No\.?|#))\s{0,8}[:.]?\s{0,8}[A-Z0-9]{6,12}\b/i },
// IPv4 address

@@ -29,0 +65,0 @@ { 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/ },

@@ -31,2 +31,3 @@ #!/usr/bin/env node

'dashboard-port': PortSchema.optional(),
'dashboard-host': z.string().min(1).optional(),
help: z.boolean().optional(),

@@ -46,2 +47,3 @@ })

'dashboard-port': { type: 'string' },
'dashboard-host': { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },

@@ -79,2 +81,5 @@ },

--dashboard-port <port> Dashboard port 1-65535 (default: 6920)
--dashboard-host <host> Dashboard bind address (default: 127.0.0.1). The dashboard is
unauthenticated unless --api-key is set — binding it beyond
loopback exposes your full trace history to the network.
-h, --help Show this help message

@@ -86,6 +91,9 @@

IRIS_PORT HTTP transport port (1-65535)
IRIS_DB_PATH SQLite database path
IRIS_HOME Directory for all per-user files: config.json, iris.db, custom-rules.json,
audit.log, preferences.json (default: ~/.iris)
IRIS_DB_PATH SQLite database path (overrides IRIS_HOME for the DB only)
IRIS_LOG_LEVEL debug | info | warn | error
IRIS_DASHBOARD true to enable web dashboard
IRIS_DASHBOARD_PORT Dashboard port (1-65535, default: 6920)
IRIS_DASHBOARD_HOST Dashboard bind address (default: 127.0.0.1)
IRIS_API_KEY API key for HTTP authentication

@@ -120,2 +128,3 @@ IRIS_ALLOWED_ORIGINS Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. http://localhost:*).

dashboardPort: values['dashboard-port'],
dashboardHost: values['dashboard-host'],
});

@@ -122,0 +131,0 @@ const logger = createLogger(config);

@@ -21,5 +21,6 @@ /*

*/
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { readFileSync, existsSync } from 'node:fs';
import { writeAtomic } from './utils/write-atomic.js';
import { irisHome } from './utils/iris-home.js';
import { join } from 'node:path';
import { z } from 'zod';

@@ -65,3 +66,3 @@ const MomentFiltersSchema = z

function defaultPreferencesPath() {
return join(homedir(), '.iris', 'preferences.json');
return join(irisHome(), 'preferences.json');
}

@@ -79,8 +80,2 @@ function freshPreferences() {

}
function writeAtomic(targetPath, contents) {
mkdirSync(dirname(targetPath), { recursive: true });
const tmp = `${targetPath}.tmp.${process.pid}`;
writeFileSync(tmp, contents, 'utf-8');
renameSync(tmp, targetPath);
}
export function loadOrInitPreferences(customPath) {

@@ -87,0 +82,0 @@ const path = customPath ?? defaultPreferencesPath();

@@ -5,3 +5,10 @@ import * as migration001 from './001-initial-schema.js';

import * as migration004 from './004-tenant-id.js';
const migrations = [migration001, migration002, migration003, migration004];
import * as migration005 from './005-normalize-created-at.js';
const migrations = [
migration001,
migration002,
migration003,
migration004,
migration005,
];
export function runMigrations(db) {

@@ -8,0 +15,0 @@ db.exec(`

@@ -148,6 +148,14 @@ /*

assertTenant(tenantId);
/*
* created_at is written EXPLICITLY as ISO-8601. Leaving it to the
* column DEFAULT (datetime('now')) stored "2026-08-09 15:00:00", which
* every period query then compared as a string against a JS
* toISOString() boundary — and ' ' sorts before 'T', so any eval whose
* calendar date matched the boundary's date was dropped from the
* window. Migration 005 rewrites rows written before this line existed.
*/
this.db.prepare(`
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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);
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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());
}

@@ -267,2 +275,19 @@ async getEvalsByTraceId(tenantId, traceId) {

`).get(tenantId, since);
/*
* No `AND passed = 0` here — deliberately.
*
* A safety eval's score is the average across its rules, so a single
* violation is routinely outvoted: output containing "Your SSN is
* 123-45-6789" fails no_pii (score 0) while the three other safety
* rules pass, giving 0.733 overall — above the 0.7 threshold, so
* passed = 1. Filtering to failed evals therefore reported
* {pii: 0, injection: 0, hallucination: 0} for a trace that leaked a
* social security number.
*
* For a product whose job is catching PII, injection and hallucination,
* that error ran in the direction that HIDES problems. The count is
* per-VIOLATION, not per-failed-eval; the per-rule loop below already
* skips rules that passed, so scanning every safety eval in the window
* is both correct and sufficient.
*/
const safetyRows = this.db.prepare(`

@@ -273,3 +298,2 @@ SELECT rule_results

AND eval_type = 'safety'
AND passed = 0
`).all(tenantId, since);

@@ -276,0 +300,0 @@ const violations = { pii: 0, injection: 0, hallucination: 0 };

@@ -18,2 +18,9 @@ export interface IrisConfig {

port: number;
/**
* Bind address. Defaults to loopback: the dashboard is unauthenticated
* by default (security.apiKey is undefined) and serves the full trace
* history, so binding it to every interface exposes agent inputs and
* outputs to the local network. Set explicitly to share it.
*/
host: string;
};

@@ -20,0 +27,0 @@ eval: {

{
"name": "@iris-eval/mcp-server",
"version": "0.4.5",
"version": "0.4.6",
"description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",

@@ -5,0 +5,0 @@ "mcpName": "io.github.iris-eval/mcp-server",

@@ -9,3 +9,3 @@ {

},
"version": "0.4.5",
"version": "0.4.6",
"packages": [

@@ -15,3 +15,3 @@ {

"identifier": "@iris-eval/mcp-server",
"version": "0.4.4",
"version": "0.4.6",
"transport": {

@@ -18,0 +18,0 @@ "type": "stdio"

Sorry, the diff of this file is too big to display