@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.
Files changed (94) hide show
  1. package/README.md +94 -44
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +5 -5
  4. package/dist/config/defaults.js +3 -3
  5. package/dist/config/index.d.ts +1 -0
  6. package/dist/config/index.js +32 -6
  7. package/dist/custom-rule-store.js +100 -38
  8. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  9. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  14. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  15. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  20. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  21. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  23. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  24. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  25. package/dist/dashboard/index.html +2 -2
  26. package/dist/dashboard/routes/failures.d.ts +3 -0
  27. package/dist/dashboard/routes/failures.js +76 -0
  28. package/dist/dashboard/routes/index.d.ts +1 -0
  29. package/dist/dashboard/routes/index.js +1 -0
  30. package/dist/dashboard/routes/preferences.js +7 -2
  31. package/dist/dashboard/routes/rules.js +32 -14
  32. package/dist/dashboard/routes/traces.d.ts +12 -1
  33. package/dist/dashboard/routes/traces.js +90 -2
  34. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  35. package/dist/dashboard/seed-demo-data.js +1080 -0
  36. package/dist/dashboard/server.js +124 -16
  37. package/dist/dashboard/validation.d.ts +74 -0
  38. package/dist/dashboard/validation.js +31 -2
  39. package/dist/eval/citation-verify/resolve.js +29 -0
  40. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  41. package/dist/eval/citation-verify/verifier.js +12 -4
  42. package/dist/eval/engine.d.ts +15 -1
  43. package/dist/eval/engine.js +99 -15
  44. package/dist/eval/failure-rank.d.ts +14 -0
  45. package/dist/eval/failure-rank.js +44 -0
  46. package/dist/eval/rules/custom.d.ts +29 -1
  47. package/dist/eval/rules/custom.js +155 -19
  48. package/dist/eval/rules/regex-budget.d.ts +5 -0
  49. package/dist/eval/rules/regex-budget.js +100 -0
  50. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  51. package/dist/eval/rules/regex-sandbox.js +131 -0
  52. package/dist/eval/rules/relevance.d.ts +0 -2
  53. package/dist/eval/rules/relevance.js +6 -68
  54. package/dist/eval/rules/safety.d.ts +10 -0
  55. package/dist/eval/rules/safety.js +1377 -30
  56. package/dist/index.js +206 -19
  57. package/dist/middleware/rebinding-guard.d.ts +21 -0
  58. package/dist/middleware/rebinding-guard.js +77 -0
  59. package/dist/preferences.js +5 -10
  60. package/dist/self-test.d.ts +18 -0
  61. package/dist/self-test.js +329 -0
  62. package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
  63. package/dist/storage/migrations/005-normalize-created-at.js +34 -0
  64. package/dist/storage/migrations/index.js +8 -1
  65. package/dist/storage/sqlite-adapter.d.ts +2 -0
  66. package/dist/storage/sqlite-adapter.js +93 -13
  67. package/dist/tools/delete-rule.d.ts +2 -1
  68. package/dist/tools/delete-rule.js +13 -4
  69. package/dist/tools/delete-trace.js +2 -1
  70. package/dist/tools/deploy-rule.d.ts +2 -1
  71. package/dist/tools/deploy-rule.js +29 -7
  72. package/dist/tools/evaluate-output.js +36 -9
  73. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  74. package/dist/tools/get-traces.js +6 -2
  75. package/dist/tools/index.js +2 -2
  76. package/dist/tools/list-rules.js +2 -1
  77. package/dist/tools/log-trace.d.ts +51 -0
  78. package/dist/tools/log-trace.js +14 -2
  79. package/dist/tools/strict-input.d.ts +2 -0
  80. package/dist/tools/strict-input.js +35 -0
  81. package/dist/tools/verify-citations.js +7 -5
  82. package/dist/transport/http.js +24 -2
  83. package/dist/types/config.d.ts +7 -0
  84. package/dist/types/decision-moment.d.ts +12 -0
  85. package/dist/types/eval.d.ts +32 -0
  86. package/dist/types/query.d.ts +1 -1
  87. package/dist/utils/iris-home.d.ts +1 -0
  88. package/dist/utils/iris-home.js +21 -0
  89. package/dist/utils/write-atomic.d.ts +3 -0
  90. package/dist/utils/write-atomic.js +96 -0
  91. package/package.json +3 -2
  92. package/server.json +3 -3
  93. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  94. 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
- dashboard: { type: 'boolean', default: false },
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
- IRIS_DB_PATH SQLite database path
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 to enable web dashboard
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
- dbPath: values['db-path'],
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
- evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
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
- if (config.dashboard.enabled || config.transport.type === 'http') {
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
- if (config.dashboard.enabled) {
193
- const prefState = loadOrInitPreferences();
194
- if (prefState.isFirstRun && shouldAutoLaunchDashboard(prefState)) {
195
- const url = `http://localhost:${config.dashboard.port}`;
196
- logger.info(`First run detected opening dashboard at ${url}`);
197
- logger.info(`(To disable auto-launch: set IRIS_NO_AUTO_LAUNCH=1 or edit ${prefState.path})`);
198
- openBrowser(url);
199
- }
200
- else if (prefState.isFirstRun) {
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
- main().catch((err) => {
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
+ }
@@ -18,9 +18,10 @@
18
18
  * /moments), dismissedTours, archivedMoments. These let the dashboard
19
19
  * remember the user's last view across reloads + across iris-mcp restarts.
20
20
  */
21
- import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
22
- import { join, dirname } from 'node:path';
23
- import { homedir } from 'node:os';
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { writeAtomic } from './utils/write-atomic.js';
23
+ import { irisHome } from './utils/iris-home.js';
24
+ import { join } from 'node:path';
24
25
  import { z } from 'zod';
25
26
  const MomentFiltersSchema = z
26
27
  .object({
@@ -61,7 +62,7 @@ export const PreferencesSchema = z
61
62
  })
62
63
  .passthrough();
63
64
  function defaultPreferencesPath() {
64
- return join(homedir(), '.iris', 'preferences.json');
65
+ return join(irisHome(), 'preferences.json');
65
66
  }
66
67
  function freshPreferences() {
67
68
  return PreferencesSchema.parse({
@@ -74,12 +75,6 @@ function freshPreferences() {
74
75
  archivedMoments: [],
75
76
  });
76
77
  }
77
- function writeAtomic(targetPath, contents) {
78
- mkdirSync(dirname(targetPath), { recursive: true });
79
- const tmp = `${targetPath}.tmp.${process.pid}`;
80
- writeFileSync(tmp, contents, 'utf-8');
81
- renameSync(tmp, targetPath);
82
- }
83
78
  export function loadOrInitPreferences(customPath) {
84
79
  const path = customPath ?? defaultPreferencesPath();
85
80
  if (!existsSync(path)) {
@@ -0,0 +1,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>;