@iris-eval/mcp-server 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -44
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +5 -5
- package/dist/config/defaults.js +3 -3
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +32 -6
- package/dist/custom-rule-store.js +100 -38
- package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
- package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/failures.d.ts +3 -0
- package/dist/dashboard/routes/failures.js +76 -0
- package/dist/dashboard/routes/index.d.ts +1 -0
- package/dist/dashboard/routes/index.js +1 -0
- package/dist/dashboard/routes/preferences.js +7 -2
- package/dist/dashboard/routes/rules.js +32 -14
- package/dist/dashboard/routes/traces.d.ts +12 -1
- package/dist/dashboard/routes/traces.js +90 -2
- package/dist/dashboard/seed-demo-data.d.ts +49 -0
- package/dist/dashboard/seed-demo-data.js +1080 -0
- package/dist/dashboard/server.js +124 -16
- package/dist/dashboard/validation.d.ts +74 -0
- package/dist/dashboard/validation.js +31 -2
- package/dist/eval/citation-verify/resolve.js +29 -0
- package/dist/eval/citation-verify/verifier.d.ts +1 -0
- package/dist/eval/citation-verify/verifier.js +12 -4
- package/dist/eval/engine.d.ts +15 -1
- package/dist/eval/engine.js +99 -15
- package/dist/eval/failure-rank.d.ts +14 -0
- package/dist/eval/failure-rank.js +44 -0
- package/dist/eval/rules/custom.d.ts +29 -1
- package/dist/eval/rules/custom.js +155 -19
- package/dist/eval/rules/regex-budget.d.ts +5 -0
- package/dist/eval/rules/regex-budget.js +100 -0
- package/dist/eval/rules/regex-sandbox.d.ts +26 -0
- package/dist/eval/rules/regex-sandbox.js +131 -0
- package/dist/eval/rules/relevance.d.ts +0 -2
- package/dist/eval/rules/relevance.js +6 -68
- package/dist/eval/rules/safety.d.ts +10 -0
- package/dist/eval/rules/safety.js +1377 -30
- package/dist/index.js +206 -19
- package/dist/middleware/rebinding-guard.d.ts +21 -0
- package/dist/middleware/rebinding-guard.js +77 -0
- package/dist/preferences.js +5 -10
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -0
- package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
- package/dist/storage/migrations/005-normalize-created-at.js +34 -0
- package/dist/storage/migrations/index.js +8 -1
- package/dist/storage/sqlite-adapter.d.ts +2 -0
- package/dist/storage/sqlite-adapter.js +93 -13
- package/dist/tools/delete-rule.d.ts +2 -1
- package/dist/tools/delete-rule.js +13 -4
- package/dist/tools/delete-trace.js +2 -1
- package/dist/tools/deploy-rule.d.ts +2 -1
- package/dist/tools/deploy-rule.js +29 -7
- package/dist/tools/evaluate-output.js +36 -9
- package/dist/tools/evaluate-with-llm-judge.js +2 -1
- package/dist/tools/get-traces.js +6 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/list-rules.js +2 -1
- package/dist/tools/log-trace.d.ts +51 -0
- package/dist/tools/log-trace.js +14 -2
- package/dist/tools/strict-input.d.ts +2 -0
- package/dist/tools/strict-input.js +35 -0
- package/dist/tools/verify-citations.js +7 -5
- package/dist/transport/http.js +24 -2
- package/dist/types/config.d.ts +7 -0
- package/dist/types/decision-moment.d.ts +12 -0
- package/dist/types/eval.d.ts +32 -0
- package/dist/types/query.d.ts +1 -1
- package/dist/utils/iris-home.d.ts +1 -0
- package/dist/utils/iris-home.js +21 -0
- package/dist/utils/write-atomic.d.ts +3 -0
- package/dist/utils/write-atomic.js +96 -0
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- package/dist/dashboard/assets/index-CIKsbEhq.js +0 -10
package/dist/index.js
CHANGED
|
@@ -12,8 +12,11 @@ import { loadOrInitPreferences, shouldAutoLaunchDashboard, createPreferenceStore
|
|
|
12
12
|
import { openBrowser } from './utils/open-browser.js';
|
|
13
13
|
import { createCustomRuleStore } from './custom-rule-store.js';
|
|
14
14
|
import { createCustomRule } from './eval/rules/custom.js';
|
|
15
|
+
import { EvalEngine } from './eval/engine.js';
|
|
15
16
|
import { LOCAL_TENANT } from './types/tenant.js';
|
|
16
17
|
import { validatePortConfig } from './utils/validate-port-config.js';
|
|
18
|
+
import { irisHome } from './utils/iris-home.js';
|
|
19
|
+
import { seedDemoData, clearDemoData, demoDbPath, demoPreferencesPath, demoCustomRulesPath, demoAuditLogPath, } from './dashboard/seed-demo-data.js';
|
|
17
20
|
const PortSchema = z
|
|
18
21
|
.string()
|
|
19
22
|
.regex(/^\d+$/, 'must be a positive integer')
|
|
@@ -28,6 +31,10 @@ const CliSchema = z
|
|
|
28
31
|
'api-key': z.string().min(1).optional(),
|
|
29
32
|
dashboard: z.boolean().optional(),
|
|
30
33
|
'dashboard-port': PortSchema.optional(),
|
|
34
|
+
'dashboard-host': z.string().min(1).optional(),
|
|
35
|
+
demo: z.boolean().optional(),
|
|
36
|
+
'demo-clear': z.boolean().optional(),
|
|
37
|
+
'self-test': z.boolean().optional(),
|
|
31
38
|
help: z.boolean().optional(),
|
|
32
39
|
})
|
|
33
40
|
.strict();
|
|
@@ -40,8 +47,24 @@ try {
|
|
|
40
47
|
config: { type: 'string' },
|
|
41
48
|
'db-path': { type: 'string' },
|
|
42
49
|
'api-key': { type: 'string' },
|
|
43
|
-
|
|
50
|
+
/*
|
|
51
|
+
* No `default: false` here, unlike the other booleans. The value
|
|
52
|
+
* flows into loadConfig, and cliArgsToConfig writes the CLI layer
|
|
53
|
+
* whenever `dashboard !== undefined` — so a default made the flag's
|
|
54
|
+
* ABSENCE indistinguishable from an explicitly disabled dashboard and stamped
|
|
55
|
+
* `enabled: false` over the env and config-file layers, which merge
|
|
56
|
+
* before it. IRIS_DASHBOARD=true and `dashboard.enabled` in
|
|
57
|
+
* config.json therefore did nothing at all. Left undefined, the
|
|
58
|
+
* lower layers survive and `--dashboard` still wins when passed.
|
|
59
|
+
* The other boolean flags are read for truthiness only and never
|
|
60
|
+
* reach the merge, so their defaults are harmless.
|
|
61
|
+
*/
|
|
62
|
+
dashboard: { type: 'boolean' },
|
|
44
63
|
'dashboard-port': { type: 'string' },
|
|
64
|
+
'dashboard-host': { type: 'string' },
|
|
65
|
+
demo: { type: 'boolean', default: false },
|
|
66
|
+
'demo-clear': { type: 'boolean', default: false },
|
|
67
|
+
'self-test': { type: 'boolean', default: false },
|
|
45
68
|
help: { type: 'boolean', short: 'h', default: false },
|
|
46
69
|
},
|
|
47
70
|
strict: true,
|
|
@@ -74,16 +97,32 @@ Options:
|
|
|
74
97
|
--api-key <key> API key for HTTP authentication
|
|
75
98
|
--dashboard Enable web dashboard
|
|
76
99
|
--dashboard-port <port> Dashboard port 1-65535 (default: 6920)
|
|
100
|
+
--dashboard-host <host> Dashboard bind address (default: 127.0.0.1). The dashboard is
|
|
101
|
+
unauthenticated unless --api-key is set — binding it beyond
|
|
102
|
+
loopback exposes your full trace history to the network.
|
|
103
|
+
--demo Seed a demo database and serve the dashboard against it —
|
|
104
|
+
see the dashboard working before wiring up your agent.
|
|
105
|
+
Demo data lives in its own files (demo.db) and never mixes
|
|
106
|
+
with your real traces. Serves the dashboard only (no MCP
|
|
107
|
+
transport). Idempotent: re-running reuses the seeded data.
|
|
108
|
+
--demo-clear Delete the demo database (and its sidecar files), then exit.
|
|
109
|
+
Your real traces are not touched.
|
|
110
|
+
--self-test Run the offline install diagnostic and exit: storage round-trip,
|
|
111
|
+
deterministic evals, dashboard + rebinding guard — all inside an
|
|
112
|
+
isolated temp home. Exit code 0 = healthy, 1 = a check failed.
|
|
77
113
|
-h, --help Show this help message
|
|
78
114
|
|
|
79
115
|
Environment variables (CLI flags take precedence):
|
|
80
116
|
IRIS_TRANSPORT stdio | http
|
|
81
117
|
IRIS_HOST Bind address for HTTP transport (default: 127.0.0.1)
|
|
82
118
|
IRIS_PORT HTTP transport port (1-65535)
|
|
83
|
-
|
|
119
|
+
IRIS_HOME Directory for all per-user files: config.json, iris.db, custom-rules.json,
|
|
120
|
+
audit.log, preferences.json (default: ~/.iris)
|
|
121
|
+
IRIS_DB_PATH SQLite database path (overrides IRIS_HOME for the DB only)
|
|
84
122
|
IRIS_LOG_LEVEL debug | info | warn | error
|
|
85
|
-
IRIS_DASHBOARD true
|
|
123
|
+
IRIS_DASHBOARD true/1/yes/on enables the web dashboard; false/0/no/off disables it (overrides config.json)
|
|
86
124
|
IRIS_DASHBOARD_PORT Dashboard port (1-65535, default: 6920)
|
|
125
|
+
IRIS_DASHBOARD_HOST Dashboard bind address (default: 127.0.0.1)
|
|
87
126
|
IRIS_API_KEY API key for HTTP authentication
|
|
88
127
|
IRIS_ALLOWED_ORIGINS Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. http://localhost:*).
|
|
89
128
|
HTTP transport: exact-match Origin allowlist for DNS-rebinding protection (globs ignored;
|
|
@@ -105,14 +144,53 @@ Dashboard preferences (~/.iris/preferences.json):
|
|
|
105
144
|
`);
|
|
106
145
|
process.exit(0);
|
|
107
146
|
}
|
|
147
|
+
/*
|
|
148
|
+
* --self-test exits BEFORE loadConfig() runs at module scope below —
|
|
149
|
+
* deliberately. The diagnostic builds its own isolated IRIS_HOME and
|
|
150
|
+
* scrubs the IRIS_* env layer (src/self-test.ts), so the normal boot
|
|
151
|
+
* path's config (and the user's real ~/.iris) must never load first.
|
|
152
|
+
*/
|
|
153
|
+
if (values['self-test']) {
|
|
154
|
+
const { runSelfTest } = await import('./self-test.js');
|
|
155
|
+
process.exit(await runSelfTest());
|
|
156
|
+
}
|
|
157
|
+
/*
|
|
158
|
+
* Demo-mode flag validation happens before loadConfig so a refused
|
|
159
|
+
* combination exits without touching the filesystem.
|
|
160
|
+
*/
|
|
161
|
+
if (values.demo && values['demo-clear']) {
|
|
162
|
+
process.stderr.write('iris-mcp: --demo and --demo-clear cannot be combined.\nRun `iris-mcp --help` for usage.\n');
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
if (values.demo && values['db-path']) {
|
|
166
|
+
process.stderr.write('iris-mcp: --demo always serves its own database (demo.db under your iris home) and cannot be combined with --db-path.\n' +
|
|
167
|
+
'Run `iris-mcp --demo` alone, or drop --demo to use your own database.\n');
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
if (values['demo-clear']) {
|
|
171
|
+
const { removed } = clearDemoData();
|
|
172
|
+
if (removed.length === 0) {
|
|
173
|
+
process.stderr.write(`iris-mcp: no demo data found under "${irisHome()}" — nothing to remove.\n`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
for (const path of removed) {
|
|
177
|
+
process.stderr.write(`iris-mcp: removed "${path}"\n`);
|
|
178
|
+
}
|
|
179
|
+
process.stderr.write('iris-mcp: demo data cleared. Your real traces were not touched.\n');
|
|
180
|
+
}
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
108
183
|
const config = loadConfig({
|
|
109
184
|
transport: values.transport,
|
|
110
185
|
port: values.port,
|
|
111
186
|
config: values.config,
|
|
112
|
-
|
|
187
|
+
// Demo mode serves the dashboard against the dedicated demo database —
|
|
188
|
+
// never the real store — and always with the dashboard enabled.
|
|
189
|
+
dbPath: values.demo ? demoDbPath() : values['db-path'],
|
|
113
190
|
apiKey: values['api-key'],
|
|
114
|
-
dashboard: values.dashboard,
|
|
191
|
+
dashboard: values.demo ? true : values.dashboard,
|
|
115
192
|
dashboardPort: values['dashboard-port'],
|
|
193
|
+
dashboardHost: values['dashboard-host'],
|
|
116
194
|
});
|
|
117
195
|
const logger = createLogger(config);
|
|
118
196
|
async function main() {
|
|
@@ -136,7 +214,9 @@ async function main() {
|
|
|
136
214
|
// and would need per-tenant rule registration).
|
|
137
215
|
const enabled = customRuleStore.enabledRules(LOCAL_TENANT);
|
|
138
216
|
for (const rule of enabled) {
|
|
139
|
-
|
|
217
|
+
// Severity rides along: high/critical deployed rules hard-fail the
|
|
218
|
+
// evals they lose (createCustomRule sets EvalRule.critical from it).
|
|
219
|
+
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
|
|
140
220
|
}
|
|
141
221
|
if (enabled.length > 0) {
|
|
142
222
|
logger.info(`Loaded ${enabled.length} deployed custom rule(s) from ${customRuleStore.pathFor(LOCAL_TENANT)}`);
|
|
@@ -176,7 +256,18 @@ async function main() {
|
|
|
176
256
|
logger.info(`Tip: run with --dashboard to open the web dashboard on port ${config.dashboard.port}`);
|
|
177
257
|
}
|
|
178
258
|
}
|
|
179
|
-
|
|
259
|
+
/*
|
|
260
|
+
* The dashboard starts ONLY when explicitly enabled (--dashboard,
|
|
261
|
+
* IRIS_DASHBOARD=true, or dashboard.enabled in config.json). It used to
|
|
262
|
+
* also start implicitly whenever `--transport http` was chosen — which
|
|
263
|
+
* contradicted the README ("off by default"), and worse: if its default
|
|
264
|
+
* port 6920 was busy on a shared CI runner, the WHOLE process exited 1
|
|
265
|
+
* after the transport had already bound successfully. An unrequested
|
|
266
|
+
* server must never be able to kill the requested one. Users who relied
|
|
267
|
+
* on the ingest endpoint riding along get a loud pointer below instead
|
|
268
|
+
* of a silent 404.
|
|
269
|
+
*/
|
|
270
|
+
if (config.dashboard.enabled) {
|
|
180
271
|
const preferenceStore = createPreferenceStore();
|
|
181
272
|
const dashboardServer = createDashboardServer(storage, config, logger, {
|
|
182
273
|
customRuleStore,
|
|
@@ -189,19 +280,25 @@ async function main() {
|
|
|
189
280
|
// dashboard in the user's default browser. Skipped in CI, when the
|
|
190
281
|
// user has previously set autoLaunch=false in ~/.iris/preferences.json,
|
|
191
282
|
// or when IRIS_NO_AUTO_LAUNCH=1 is set.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
logger.info(`First run detected — skipping auto-launch (CI/IRIS_NO_AUTO_LAUNCH set). Dashboard at http://localhost:${config.dashboard.port}`);
|
|
202
|
-
}
|
|
283
|
+
const prefState = loadOrInitPreferences();
|
|
284
|
+
if (prefState.isFirstRun && shouldAutoLaunchDashboard(prefState)) {
|
|
285
|
+
const url = `http://localhost:${config.dashboard.port}`;
|
|
286
|
+
logger.info(`First run detected — opening dashboard at ${url}`);
|
|
287
|
+
logger.info(`(To disable auto-launch: set IRIS_NO_AUTO_LAUNCH=1 or edit ${prefState.path})`);
|
|
288
|
+
openBrowser(url);
|
|
289
|
+
}
|
|
290
|
+
else if (prefState.isFirstRun) {
|
|
291
|
+
logger.info(`First run detected — skipping auto-launch (CI/IRIS_NO_AUTO_LAUNCH set). Dashboard at http://localhost:${config.dashboard.port}`);
|
|
203
292
|
}
|
|
204
293
|
}
|
|
294
|
+
else if (config.transport.type === 'http') {
|
|
295
|
+
// Loud, because this used to start implicitly: anyone who relied on the
|
|
296
|
+
// ingest endpoint riding along with --transport http must learn how to
|
|
297
|
+
// get it back from this line, not from a connection refused.
|
|
298
|
+
logger.info(`Dashboard not started (off by default). The dashboard — and the HTTP ingest endpoint ` +
|
|
299
|
+
`POST /api/v1/traces — start on port ${config.dashboard.port} when you pass --dashboard, ` +
|
|
300
|
+
`set IRIS_DASHBOARD=true, or set dashboard.enabled=true in config.json.`);
|
|
301
|
+
}
|
|
205
302
|
if (config.security.apiKey) {
|
|
206
303
|
logger.info('API key authentication enabled');
|
|
207
304
|
}
|
|
@@ -222,7 +319,97 @@ async function main() {
|
|
|
222
319
|
process.on('SIGINT', shutdown);
|
|
223
320
|
process.on('SIGTERM', shutdown);
|
|
224
321
|
}
|
|
225
|
-
|
|
322
|
+
function printDemoBanner(summary, url) {
|
|
323
|
+
const line = '='.repeat(60);
|
|
324
|
+
const counts = summary.alreadySeeded
|
|
325
|
+
? ` Reusing the existing demo database (${summary.traceCount} traces, ${summary.evalCount} evaluations).`
|
|
326
|
+
: ` Seeded ${summary.traceCount} traces / ${summary.evalCount} evaluations across the last 7 days.`;
|
|
327
|
+
process.stderr.write(`
|
|
328
|
+
${line}
|
|
329
|
+
IRIS DEMO MODE — everything on screen is demo data
|
|
330
|
+
${line}
|
|
331
|
+
|
|
332
|
+
${counts}
|
|
333
|
+
Demo database: "${summary.dbPath}"
|
|
334
|
+
Your real trace database is untouched — demo data never mixes with it.
|
|
335
|
+
|
|
336
|
+
Worth clicking into:
|
|
337
|
+
- a PII leak (a synthetic SSN in an agent reply) caught by the safety rules
|
|
338
|
+
- a prompt-injection attempt flagged in summarized forum posts
|
|
339
|
+
- a failed LLM-judge score, with the judge's rationale
|
|
340
|
+
|
|
341
|
+
Dashboard: ${url}
|
|
342
|
+
|
|
343
|
+
Remove the demo data with one command:
|
|
344
|
+
npx @iris-eval/mcp-server --demo-clear
|
|
345
|
+
|
|
346
|
+
Press Ctrl+C to stop.
|
|
347
|
+
|
|
348
|
+
`);
|
|
349
|
+
}
|
|
350
|
+
/*
|
|
351
|
+
* Demo mode (--demo): seed the dedicated demo database (idempotent) and
|
|
352
|
+
* serve the dashboard against it. No MCP transport is started — demo mode
|
|
353
|
+
* exists to put something real on screen before an agent is wired up.
|
|
354
|
+
*
|
|
355
|
+
* Isolation: everything demo mode writes lives in demo-scoped files under
|
|
356
|
+
* irisHome() (demo.db, demo-preferences.json, demo-custom-rules.json,
|
|
357
|
+
* demo-audit.log). A rule deployed from the demo dashboard lands in the
|
|
358
|
+
* demo rule store, and --demo-clear removes all of it. The real iris.db,
|
|
359
|
+
* custom-rules.json, audit.log and preferences.json are never touched.
|
|
360
|
+
*/
|
|
361
|
+
async function runDemo() {
|
|
362
|
+
logger.info(`Starting Iris demo mode v${config.server.version}`);
|
|
363
|
+
const seedSummary = await seedDemoData();
|
|
364
|
+
if (seedSummary.alreadySeeded) {
|
|
365
|
+
logger.info(`Demo database already seeded (${seedSummary.traceCount} traces) — reusing it`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
logger.info(`Seeded demo database with ${seedSummary.traceCount} traces at ${seedSummary.dbPath}`);
|
|
369
|
+
}
|
|
370
|
+
const storage = createStorage(config);
|
|
371
|
+
await storage.initialize();
|
|
372
|
+
const customRuleStore = createCustomRuleStore({
|
|
373
|
+
pathFor: () => demoCustomRulesPath(),
|
|
374
|
+
auditPath: demoAuditLogPath(),
|
|
375
|
+
});
|
|
376
|
+
const evalEngine = new EvalEngine(config.eval.defaultThreshold, config.eval.ruleThresholds);
|
|
377
|
+
for (const rule of customRuleStore.enabledRules(LOCAL_TENANT)) {
|
|
378
|
+
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
|
|
379
|
+
}
|
|
380
|
+
const preferenceStore = createPreferenceStore(demoPreferencesPath());
|
|
381
|
+
const dashboardServer = createDashboardServer(storage, config, logger, {
|
|
382
|
+
customRuleStore,
|
|
383
|
+
evalEngine,
|
|
384
|
+
preferenceStore,
|
|
385
|
+
});
|
|
386
|
+
const server = dashboardServer.start();
|
|
387
|
+
server.on('listening', () => {
|
|
388
|
+
// Use the port actually bound (supports --dashboard-port 0 in tests).
|
|
389
|
+
const addr = server.address();
|
|
390
|
+
const port = typeof addr === 'object' && addr ? addr.port : config.dashboard.port;
|
|
391
|
+
const url = `http://localhost:${port}`;
|
|
392
|
+
printDemoBanner(seedSummary, url);
|
|
393
|
+
const prefState = loadOrInitPreferences(demoPreferencesPath());
|
|
394
|
+
if (shouldAutoLaunchDashboard(prefState)) {
|
|
395
|
+
openBrowser(url);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
const shutdown = async () => {
|
|
399
|
+
logger.info('Shutting down gracefully...');
|
|
400
|
+
await Promise.race([
|
|
401
|
+
new Promise((resolve) => server.close(() => resolve())),
|
|
402
|
+
new Promise((resolve) => setTimeout(resolve, 10_000)),
|
|
403
|
+
]);
|
|
404
|
+
await storage.close();
|
|
405
|
+
logger.info('Shutdown complete');
|
|
406
|
+
process.exit(0);
|
|
407
|
+
};
|
|
408
|
+
process.on('SIGINT', shutdown);
|
|
409
|
+
process.on('SIGTERM', shutdown);
|
|
410
|
+
}
|
|
411
|
+
const run = values.demo ? runDemo : main;
|
|
412
|
+
run().catch((err) => {
|
|
226
413
|
logger.error(`Fatal error: ${err instanceof Error ? err.message : err}`, {
|
|
227
414
|
stack: err instanceof Error ? err.stack : undefined,
|
|
228
415
|
});
|
|
@@ -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,18 @@
|
|
|
1
|
+
export declare const SELF_TEST_STEPS: {
|
|
2
|
+
readonly tempHome: "create isolated temp home";
|
|
3
|
+
readonly storage: "initialize storage";
|
|
4
|
+
readonly trace: "log a trace";
|
|
5
|
+
readonly piiEval: "eval: PII positive (planted SSN)";
|
|
6
|
+
readonly injectionEval: "eval: injection positive (planted override text)";
|
|
7
|
+
readonly cleanEval: "eval: clean output passes";
|
|
8
|
+
readonly readBack: "read back persisted results";
|
|
9
|
+
readonly dashboard: "start dashboard on ephemeral loopback port";
|
|
10
|
+
readonly health: "health endpoint answers";
|
|
11
|
+
readonly stats: "stats endpoint answers";
|
|
12
|
+
readonly rebindingGuard: "rebinding guard rejects hostile Origin";
|
|
13
|
+
readonly cleanup: "clean up temp home";
|
|
14
|
+
};
|
|
15
|
+
export declare const SELF_TEST_PASS_VERDICT = "\u2713 PASS \u2014 this install works";
|
|
16
|
+
export declare const SELF_TEST_FAIL_VERDICT = "\u2717 FAIL";
|
|
17
|
+
export type WriteLine = (line: string) => void;
|
|
18
|
+
export declare function runSelfTest(write?: WriteLine): Promise<number>;
|