@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.
- package/dist/audit-log-reader.js +2 -2
- package/dist/config/defaults.js +3 -3
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +14 -5
- package/dist/custom-rule-store.js +81 -33
- package/dist/dashboard/assets/{index-CIKsbEhq.js → index-ChcHJDDJ.js} +1 -1
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/server.js +44 -2
- package/dist/eval/engine.js +25 -10
- package/dist/eval/rules/regex-budget.d.ts +5 -0
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/safety.js +41 -5
- package/dist/index.js +10 -1
- package/dist/middleware/rebinding-guard.d.ts +21 -0
- package/dist/middleware/rebinding-guard.js +77 -0
- package/dist/preferences.js +5 -10
- package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
- package/dist/storage/migrations/005-normalize-created-at.js +34 -0
- package/dist/storage/migrations/index.js +8 -1
- package/dist/storage/sqlite-adapter.js +28 -4
- package/dist/types/config.d.ts +7 -0
- package/dist/utils/iris-home.d.ts +1 -0
- package/dist/utils/iris-home.js +21 -0
- package/dist/utils/write-atomic.d.ts +1 -0
- package/dist/utils/write-atomic.js +64 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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-
|
|
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>
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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.
|
package/dist/eval/engine.js
CHANGED
|
@@ -21,16 +21,31 @@ export class EvalEngine {
|
|
|
21
21
|
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(),
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/preferences.js
CHANGED
|
@@ -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 {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
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(
|
|
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,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
|
-
|
|
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) {
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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
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.
|
|
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.
|
|
14
|
+
"version": "0.4.6",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|