@iris-eval/mcp-server 0.5.0 → 0.6.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 (65) hide show
  1. package/README.md +99 -36
  2. package/dist/config/index.d.ts +10 -0
  3. package/dist/config/index.js +33 -7
  4. package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
  5. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  6. package/dist/dashboard/index.html +4 -3
  7. package/dist/dashboard/routes/health.js +10 -3
  8. package/dist/dashboard/routes/moments.js +1 -1
  9. package/dist/dashboard/routes/preferences.d.ts +1 -0
  10. package/dist/dashboard/routes/preferences.js +31 -3
  11. package/dist/dashboard/routes/rules.d.ts +18 -0
  12. package/dist/dashboard/routes/rules.js +160 -6
  13. package/dist/dashboard/routes/traces.js +30 -3
  14. package/dist/dashboard/seed-demo-data.js +14 -3
  15. package/dist/dashboard/server.js +13 -3
  16. package/dist/dashboard/session-auth.d.ts +8 -0
  17. package/dist/dashboard/session-auth.js +237 -0
  18. package/dist/dashboard/validation.d.ts +9 -3
  19. package/dist/dashboard/validation.js +69 -11
  20. package/dist/eval/citation-verify/verifier.d.ts +17 -0
  21. package/dist/eval/citation-verify/verifier.js +68 -15
  22. package/dist/eval/decision-moment.js +17 -9
  23. package/dist/eval/engine.d.ts +62 -0
  24. package/dist/eval/engine.js +196 -58
  25. package/dist/eval/llm-judge/evaluator.js +50 -33
  26. package/dist/eval/llm-judge/templates/index.d.ts +4 -0
  27. package/dist/eval/llm-judge/templates/index.js +10 -4
  28. package/dist/eval/rules/custom.js +59 -6
  29. package/dist/eval/rules/relevance.js +1 -1
  30. package/dist/eval/rules/safety.d.ts +8 -0
  31. package/dist/eval/rules/safety.js +63 -18
  32. package/dist/index.js +102 -16
  33. package/dist/middleware/rate-limit.d.ts +25 -0
  34. package/dist/middleware/rate-limit.js +54 -2
  35. package/dist/self-test.d.ts +14 -0
  36. package/dist/self-test.js +97 -13
  37. package/dist/storage/demo-guard.d.ts +8 -0
  38. package/dist/storage/demo-guard.js +53 -0
  39. package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
  40. package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
  41. package/dist/storage/migrations/index.js +2 -0
  42. package/dist/storage/sqlite-adapter.d.ts +6 -0
  43. package/dist/storage/sqlite-adapter.js +91 -4
  44. package/dist/tools/delete-rule.js +49 -11
  45. package/dist/tools/deploy-rule.d.ts +33 -0
  46. package/dist/tools/deploy-rule.js +130 -27
  47. package/dist/tools/evaluate-output.js +50 -24
  48. package/dist/tools/evaluate-with-llm-judge.js +11 -4
  49. package/dist/tools/get-traces.d.ts +27 -0
  50. package/dist/tools/get-traces.js +60 -8
  51. package/dist/tools/list-rules.js +2 -2
  52. package/dist/tools/log-trace.js +5 -4
  53. package/dist/tools/strict-input.d.ts +1 -0
  54. package/dist/tools/strict-input.js +25 -0
  55. package/dist/tools/trace-link.d.ts +7 -0
  56. package/dist/tools/trace-link.js +39 -0
  57. package/dist/tools/verify-citations.d.ts +19 -0
  58. package/dist/tools/verify-citations.js +42 -5
  59. package/dist/types/decision-moment.d.ts +8 -0
  60. package/dist/types/eval.d.ts +60 -1
  61. package/dist/types/index.d.ts +1 -1
  62. package/dist/types/query.d.ts +25 -0
  63. package/package.json +1 -1
  64. package/server.json +2 -2
  65. package/dist/dashboard/assets/index-BZZt8bVh.js +0 -10
package/dist/self-test.js CHANGED
@@ -11,10 +11,20 @@
11
11
  *
12
12
  * Isolation is the load-bearing property. The diagnostic creates its own
13
13
  * scratch IRIS_HOME and scrubs every IRIS_* env var that feeds
14
- * loadConfig(), so it NEVER opens (or migrates!) the user's real iris.db,
15
- * never reads their config.json, and never honours an IRIS_API_KEY that
16
- * would 401 its own probes. The scratch home is removed and the env
17
- * restored before returning — pass or fail.
14
+ * loadConfig(), so it never MIGRATES or writes rows into the user's real
15
+ * iris.db, never reads their config.json, and never honours an
16
+ * IRIS_API_KEY that would 401 its own probes. The scratch home is removed
17
+ * and the env restored before returning — pass or fail.
18
+ *
19
+ * Isolation is not the same as ignorance, though. The first check runs
20
+ * BEFORE the scrub, against the CONFIGURED home: it creates the directory
21
+ * the server would create, proves it can write there, and — when the real
22
+ * database already exists — opens it and takes (then releases) a write
23
+ * lock without changing a byte. #371: the diagnostic used to print PASS
24
+ * against an IRIS_HOME the server could not write, because every check
25
+ * ran in the temp home; the real server then died on startup with a raw
26
+ * EPERM stack. A diagnostic that cannot fail the way the product fails is
27
+ * not a diagnostic.
18
28
  *
19
29
  * Budget: everything is in-process or loopback. No LLM calls, no network
20
30
  * beyond 127.0.0.1, and the whole sequence completes in well under the
@@ -23,11 +33,13 @@
23
33
  * Exit contract: 0 = every check passed, 1 = any check failed. index.ts
24
34
  * runs this BEFORE loadConfig() so the normal boot path never executes.
25
35
  */
26
- import { mkdtempSync, rmSync } from 'node:fs';
36
+ import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
27
37
  import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
38
+ import { dirname, join } from 'node:path';
39
+ import { randomBytes } from 'node:crypto';
29
40
  import { request as httpRequest } from 'node:http';
30
- import { loadConfig } from './config/index.js';
41
+ import Database from 'better-sqlite3';
42
+ import { ensureIrisDirectory, loadConfig } from './config/index.js';
31
43
  import { PKG_VERSION } from './config/defaults.js';
32
44
  import { createStorage } from './storage/index.js';
33
45
  import { createDashboardServer } from './dashboard/server.js';
@@ -44,6 +56,7 @@ const CROSS = '✗';
44
56
  * files, per the usual drift rule.
45
57
  */
46
58
  export const SELF_TEST_STEPS = {
59
+ configuredHome: 'configured IRIS_HOME is writable',
47
60
  tempHome: 'create isolated temp home',
48
61
  storage: 'initialize storage',
49
62
  trace: 'log a trace',
@@ -114,16 +127,77 @@ function probe(port, path, headers = {}) {
114
127
  });
115
128
  }
116
129
  const stdoutLine = (line) => process.stdout.write(`${line}\n`);
130
+ function errorCode(err) {
131
+ const code = err?.code;
132
+ return typeof code === 'string' ? code : err instanceof Error ? err.message : String(err);
133
+ }
134
+ /**
135
+ * The configured-home probe (#371). Exercises the exact calls the real
136
+ * server makes at startup, in order: create IRIS_HOME (same helper and
137
+ * mode as loadConfig), create the database directory when IRIS_DB_PATH
138
+ * points elsewhere, write-and-unlink a probe file in each, and — only if
139
+ * the real database already exists — open it and take a write lock
140
+ * (BEGIN IMMEDIATE … ROLLBACK), which fails on a read-only file or a
141
+ * non-database exactly as the first INSERT would, without migrating or
142
+ * changing anything. A missing database is not created: the server
143
+ * creates it on first run, and the writable-directory probe is what
144
+ * proves that it can.
145
+ */
146
+ export function probeConfiguredHome(home, dbPath) {
147
+ ensureIrisDirectory(home, 'IRIS_HOME');
148
+ probeWritable(home, 'IRIS_HOME');
149
+ const dbDir = dirname(dbPath);
150
+ if (dbDir !== home) {
151
+ ensureIrisDirectory(dbDir, 'the database directory (IRIS_DB_PATH / --db-path)');
152
+ probeWritable(dbDir, 'the database directory');
153
+ }
154
+ if (!existsSync(dbPath)) {
155
+ return `${home} (database ${dbPath} will be created on first run)`;
156
+ }
157
+ let db;
158
+ try {
159
+ db = new Database(dbPath, { fileMustExist: true });
160
+ db.exec('BEGIN IMMEDIATE');
161
+ db.exec('ROLLBACK');
162
+ }
163
+ catch (err) {
164
+ throw new Error(`database "${dbPath}" exists but cannot be opened for writing (${errorCode(err)}) — the server would fail ` +
165
+ 'at startup with the same error. Fix the file permissions, or point IRIS_DB_PATH / --db-path at a writable location.');
166
+ }
167
+ finally {
168
+ db?.close();
169
+ }
170
+ return `${home} (database ${dbPath} opens for writing)`;
171
+ }
172
+ function probeWritable(dir, what) {
173
+ const probeFile = join(dir, `.iris-self-test-${randomBytes(4).toString('hex')}`);
174
+ try {
175
+ writeFileSync(probeFile, 'iris self-test write probe\n', { mode: 0o600 });
176
+ }
177
+ catch (err) {
178
+ throw new Error(`${what} "${dir}" is not writable (${errorCode(err)}) — the server would fail at startup with the same error. ` +
179
+ 'Point IRIS_HOME at a directory this user can write, or fix the permissions on that path.');
180
+ }
181
+ try {
182
+ unlinkSync(probeFile);
183
+ }
184
+ catch {
185
+ // Written but not removable: unusual (sticky bit, AV lock). Not a
186
+ // startup blocker, so not a failure; the file is tiny and named for
187
+ // what it is.
188
+ }
189
+ }
117
190
  export async function runSelfTest(write = stdoutLine) {
118
191
  write(`Iris self-test v${PKG_VERSION}`);
119
192
  write('');
120
193
  /*
121
194
  * Resolved BEFORE the env scrub: this is where a normal (non-self-test)
122
195
  * run of this install would keep its data, which is the line the user
123
- * actually wants from a diagnostic. The self-test itself never touches
124
- * this path.
196
+ * actually wants from a diagnostic and the target of the configured-
197
+ * home probe below. The isolated checks never touch these paths.
125
198
  */
126
- const userStoragePath = process.env.IRIS_DB_PATH ?? join(irisHome(), 'iris.db');
199
+ const userHome = irisHome();
200
+ const userStoragePath = process.env.IRIS_DB_PATH ?? join(userHome, 'iris.db');
127
201
  const savedEnv = {};
128
202
  for (const key of SCRUBBED_ENV_VARS) {
129
203
  savedEnv[key] = process.env[key];
@@ -137,14 +211,18 @@ export async function runSelfTest(write = stdoutLine) {
137
211
  let traceId = '';
138
212
  const insertedIds = [];
139
213
  const failedSteps = [];
214
+ let halted = false;
140
215
  /*
141
216
  * Steps run strictly in order and stop at the first failure — each one
142
217
  * depends on the state the previous one built, so a cascade of
143
218
  * follow-on crosses would only bury the real cause. Cleanup runs
144
- * unconditionally afterwards.
219
+ * unconditionally afterwards. A step marked `independent` still fails
220
+ * the run but does not halt it: the configured-home probe has no
221
+ * successors that depend on it, and the user is better served by ALSO
222
+ * learning whether the install itself works.
145
223
  */
146
- const step = async (label, fn) => {
147
- if (failedSteps.length > 0)
224
+ const step = async (label, fn, opts) => {
225
+ if (halted)
148
226
  return;
149
227
  try {
150
228
  const detail = await fn();
@@ -152,9 +230,14 @@ export async function runSelfTest(write = stdoutLine) {
152
230
  }
153
231
  catch (err) {
154
232
  failedSteps.push(label);
233
+ if (!opts?.independent)
234
+ halted = true;
155
235
  write(`${CROSS} ${label} — ${err instanceof Error ? err.message : String(err)}`);
156
236
  }
157
237
  };
238
+ await step(SELF_TEST_STEPS.configuredHome, () => probeConfiguredHome(userHome, userStoragePath), {
239
+ independent: true,
240
+ });
158
241
  await step(SELF_TEST_STEPS.tempHome, () => {
159
242
  tempHome = mkdtempSync(join(tmpdir(), 'iris-self-test-'));
160
243
  for (const key of SCRUBBED_ENV_VARS) {
@@ -321,6 +404,7 @@ export async function runSelfTest(write = stdoutLine) {
321
404
  }
322
405
  write('');
323
406
  write(`version ${PKG_VERSION}`);
407
+ write(`home ${userHome}`);
324
408
  write(`storage ${userStoragePath}`);
325
409
  write(failedSteps.length === 0
326
410
  ? SELF_TEST_PASS_VERDICT
@@ -0,0 +1,8 @@
1
+ import type { IStorageAdapter } from '../types/query.js';
2
+ export declare const DEMO_INGEST_REFUSED_MESSAGE: string;
3
+ export declare class DemoIngestRefusedError extends Error {
4
+ /** Read by the dashboard's error handler: a client fault, not a server one. */
5
+ readonly status = 403;
6
+ constructor();
7
+ }
8
+ export declare function withDemoIngestGuard(storage: IStorageAdapter): IStorageAdapter;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Demo mode must not quietly become someone's production store.
3
+ *
4
+ * `--demo` serves the dashboard — and with it POST /api/v1/traces — against
5
+ * demo.db, a seeded, DISPOSABLE database that `--demo-clear` deletes
6
+ * outright. A reader following the README top to bottom could start the
7
+ * demo, point their capture client at the port it printed, watch real
8
+ * traces land beside the fake ones, and later lose all of them to the
9
+ * cleanup command the banner recommends. Nothing warned at any step.
10
+ *
11
+ * The guard wraps the demo store so every WRITE of trace or eval data is
12
+ * refused with a message that says what demo mode is and where real
13
+ * traces go. Reads and the demo's own seeded content are untouched — the
14
+ * dashboard keeps working exactly as before. Rule deploys are not storage
15
+ * writes (they go to the demo-scoped rule store) and stay allowed; they
16
+ * are part of what the demo exists to show.
17
+ *
18
+ * Implemented as a Proxy over the adapter rather than a subclass or a
19
+ * hand-written delegate: any method added to IStorageAdapter later
20
+ * delegates automatically instead of silently bypassing the guard.
21
+ */
22
+ export const DEMO_INGEST_REFUSED_MESSAGE = 'Demo mode does not accept trace ingest. `--demo` serves a seeded, disposable database (demo.db) — ' +
23
+ '`--demo-clear` deletes everything in it, so real traces stored here would be lost. ' +
24
+ 'Start the real server to store traces: `iris-mcp --dashboard` for HTTP ingest on POST /api/v1/traces, ' +
25
+ 'or the MCP transport for log_trace.';
26
+ const REFUSED_METHODS = new Set([
27
+ 'insertTrace',
28
+ 'insertSpan',
29
+ 'insertEvalResult',
30
+ ]);
31
+ export class DemoIngestRefusedError extends Error {
32
+ /** Read by the dashboard's error handler: a client fault, not a server one. */
33
+ status = 403;
34
+ constructor() {
35
+ super(DEMO_INGEST_REFUSED_MESSAGE);
36
+ this.name = 'DemoIngestRefusedError';
37
+ }
38
+ }
39
+ export function withDemoIngestGuard(storage) {
40
+ return new Proxy(storage, {
41
+ get(target, prop, receiver) {
42
+ if (typeof prop === 'string' && REFUSED_METHODS.has(prop)) {
43
+ return async () => {
44
+ throw new DemoIngestRefusedError();
45
+ };
46
+ }
47
+ const value = Reflect.get(target, prop, receiver);
48
+ // Class methods live on the prototype and read private fields off
49
+ // `this`; bind them to the real adapter so `this.db` resolves.
50
+ return typeof value === 'function' ? value.bind(target) : value;
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const id = "006-eval-critical-failures";
3
+ export declare function up(db: Database.Database): void;
@@ -0,0 +1,23 @@
1
+ export const id = '006-eval-critical-failures';
2
+ /*
3
+ * v0.5.0's headline feature — the critical-rule veto — was response-only.
4
+ * `critical_failures` was returned to the caller and then dropped on the
5
+ * floor: `insertEvalResult` never wrote it, so once an evaluation was
6
+ * stored, a vetoed eval was indistinguishable from one that simply scored
7
+ * below the threshold. Nothing downstream could filter, count or badge the
8
+ * flagship behaviour, and the dashboard showed "safety · fail score 0.92"
9
+ * with no way to say WHY it failed.
10
+ *
11
+ * JSON text rather than a join table: it mirrors how rule_results and
12
+ * suggestions are already stored, keeps the read path a single row, and the
13
+ * array is small and read-only after write.
14
+ *
15
+ * NULL for every row written before this migration, which is honest — those
16
+ * evaluations predate the veto, so "no recorded veto" is the truth rather
17
+ * than an empty array asserting there was none.
18
+ */
19
+ export function up(db) {
20
+ db.exec(`
21
+ ALTER TABLE eval_results ADD COLUMN critical_failures TEXT;
22
+ `);
23
+ }
@@ -3,12 +3,14 @@ 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
+ import * as migration006 from './006-eval-critical-failures.js';
6
7
  const migrations = [
7
8
  migration001,
8
9
  migration002,
9
10
  migration003,
10
11
  migration004,
11
12
  migration005,
13
+ migration006,
12
14
  ];
13
15
  export function runMigrations(db) {
14
16
  db.exec(`
@@ -34,6 +34,12 @@ export declare class SqliteAdapter implements IStorageAdapter {
34
34
  getEvalStatsRules(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStatsRuleBreakdown[]>;
35
35
  getEvalStatsFailures(tenantId: TenantId, period: EvalStatsPeriod, limit: number): Promise<EvalStatsFailure[]>;
36
36
  deleteTracesOlderThan(tenantId: TenantId, days: number): Promise<number>;
37
+ deleteEvalResultsOlderThan(tenantId: TenantId, days: number): Promise<number>;
38
+ purge(tenantId: TenantId): Promise<{
39
+ traces: number;
40
+ evalResults: number;
41
+ }>;
42
+ checkpoint(): Promise<void>;
37
43
  deleteTrace(tenantId: TenantId, traceId: string): Promise<boolean>;
38
44
  getDistinctValues(tenantId: TenantId, column: string): Promise<string[]>;
39
45
  private rowToTrace;
@@ -45,6 +45,16 @@ export class SqliteAdapter {
45
45
  this.db.pragma('journal_mode = WAL');
46
46
  this.db.pragma('busy_timeout = 5000');
47
47
  this.db.pragma('foreign_keys = ON');
48
+ /*
49
+ * secure_delete overwrites freed content with zeros instead of leaving
50
+ * it in place until the page is reused. Without it, a DELETE — the
51
+ * retention sweep, delete_trace, --purge — removed the row from every
52
+ * query while the text stayed byte-for-byte readable in the file with
53
+ * `strings iris.db`. Deletes are rare here (startup sweep, explicit
54
+ * deletes), so the write cost is negligible; the privacy cost of the
55
+ * alternative is the whole point of #372.
56
+ */
57
+ this.db.pragma('secure_delete = ON');
48
58
  runMigrations(this.db);
49
59
  /*
50
60
  * iris.db holds agent inputs and outputs verbatim, and a tool that
@@ -185,10 +195,18 @@ export class SqliteAdapter {
185
195
  * calendar date matched the boundary's date was dropped from the
186
196
  * window. Migration 005 rewrites rows written before this line existed.
187
197
  */
198
+ /*
199
+ * critical_failures is PERSISTED (migration 006) because the veto is a
200
+ * verdict, not a presentation detail. It used to live only in the live
201
+ * tool response, so the moment an evaluation was stored a vetoed eval
202
+ * became indistinguishable from one that merely scored below threshold —
203
+ * no surface could filter, count, or explain the release's flagship
204
+ * behaviour. NULL when nothing vetoed.
205
+ */
188
206
  this.db.prepare(`
189
- 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)
190
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
- `).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());
207
+ 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, critical_failures, created_at)
208
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
+ `).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, result.critical_failures?.length ? JSON.stringify(result.critical_failures) : null, new Date().toISOString());
192
210
  }
193
211
  async getEvalsByTraceId(tenantId, traceId) {
194
212
  assertTenant(tenantId);
@@ -344,11 +362,18 @@ export class SqliteAdapter {
344
362
  * skips rules that passed, so scanning every safety eval in the window
345
363
  * is both correct and sufficient.
346
364
  */
365
+ /*
366
+ * eval_type IN ('safety', 'all'): an eval_type="all" run carries the
367
+ * whole safety bundle inside its rule_results, and a PII leak caught
368
+ * there is exactly as real as one caught by a safety-only run. The
369
+ * per-rule loop below keys on rule NAMES, so the wider filter cannot
370
+ * over-count.
371
+ */
347
372
  const safetyRows = this.db.prepare(`
348
373
  SELECT rule_results
349
374
  FROM eval_results
350
375
  WHERE tenant_id = ? AND created_at >= ?
351
- AND eval_type = 'safety'
376
+ AND eval_type IN ('safety', 'all')
352
377
  `).all(tenantId, since);
353
378
  const violations = { pii: 0, injection: 0, hallucination: 0 };
354
379
  for (const row of safetyRows) {
@@ -484,6 +509,55 @@ export class SqliteAdapter {
484
509
  .run(tenantId, cutoff);
485
510
  return result.changes;
486
511
  }
512
+ async deleteEvalResultsOlderThan(tenantId, days) {
513
+ assertTenant(tenantId);
514
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
515
+ /*
516
+ * created_at, not the linked trace's timestamp: an unlinked eval has
517
+ * no trace, and a linked one whose trace was already swept has a NULL
518
+ * trace_id — either way the eval's own age is the only age it has.
519
+ * Rows are ISO-8601 here (write path + migration 005), so the string
520
+ * comparison against an ISO cutoff is exact.
521
+ */
522
+ const result = this.db
523
+ .prepare('DELETE FROM eval_results WHERE tenant_id = ? AND created_at < ?')
524
+ .run(tenantId, cutoff);
525
+ return result.changes;
526
+ }
527
+ async purge(tenantId) {
528
+ assertTenant(tenantId);
529
+ const deleteAll = this.db.transaction(() => {
530
+ const evalResults = this.db.prepare('DELETE FROM eval_results WHERE tenant_id = ?').run(tenantId).changes;
531
+ // spans cascade (FK ON DELETE CASCADE).
532
+ const traces = this.db.prepare('DELETE FROM traces WHERE tenant_id = ?').run(tenantId).changes;
533
+ return { traces, evalResults };
534
+ });
535
+ const counts = deleteAll();
536
+ /*
537
+ * VACUUM rebuilds the file from the live rows only — the freed pages
538
+ * (already zeroed by secure_delete) are dropped rather than kept as
539
+ * free-list slack — and the TRUNCATE checkpoint then folds the WAL into
540
+ * the main file and cuts it to zero bytes, so neither iris.db nor
541
+ * iris.db-wal keeps a copy of what was just deleted. Skipped for
542
+ * :memory: (nothing on disk to clean).
543
+ */
544
+ if (this.dbPath !== ':memory:') {
545
+ this.db.exec('VACUUM');
546
+ }
547
+ await this.checkpoint();
548
+ return counts;
549
+ }
550
+ async checkpoint() {
551
+ if (this.dbPath === ':memory:')
552
+ return;
553
+ try {
554
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
555
+ }
556
+ catch {
557
+ // Best effort: a checkpoint can be refused while another connection
558
+ // holds a read transaction. The next one will pick the pages up.
559
+ }
560
+ }
487
561
  async deleteTrace(tenantId, traceId) {
488
562
  assertTenant(tenantId);
489
563
  // Tenant-scoped: a trace id owned by a different tenant is
@@ -543,6 +617,11 @@ export class SqliteAdapter {
543
617
  id: row.id,
544
618
  trace_id: row.trace_id,
545
619
  eval_type: row.eval_type,
620
+ /*
621
+ * `categories` is not a column: an eval_type="all" row carries a
622
+ * `category` on every rule_results entry instead, so a reader can
623
+ * regroup the per-bundle breakdown from what IS stored.
624
+ */
546
625
  output_text: row.output_text,
547
626
  expected_text: row.expected_text,
548
627
  score: row.score,
@@ -553,6 +632,14 @@ export class SqliteAdapter {
553
632
  rules_evaluated: row.rules_evaluated,
554
633
  rules_skipped: row.rules_skipped,
555
634
  insufficient_data: row.insufficient_data != null ? row.insufficient_data === 1 : undefined,
635
+ /*
636
+ * Absent, not [], when NULL. Rows written before migration 006 never
637
+ * captured the field, and returning an empty array would assert "no
638
+ * critical rule failed" about an evaluation that never recorded one.
639
+ */
640
+ ...(row.critical_failures != null
641
+ ? { critical_failures: JSON.parse(row.critical_failures) }
642
+ : {}),
556
643
  };
557
644
  }
558
645
  }
@@ -1,43 +1,56 @@
1
1
  /*
2
- * delete_rule MCP tool — remove a deployed custom rule.
2
+ * delete_rule MCP tool — remove a deployed custom rule, or disable /
3
+ * re-enable one without removing it.
3
4
  *
4
5
  * Destructive counterpart to deploy_rule. Removes the rule from
5
6
  * ~/.iris/custom-rules.json AND unregisters it from the live eval
6
7
  * engine, so it stops firing on the very next evaluate_output call —
7
8
  * no restart needed. Appends a `rule.delete` entry to the audit log.
8
9
  *
10
+ * With `enabled` present the call is a TOGGLE instead: the rule stays in
11
+ * the store with its history and provenance, is unregistered from (or
12
+ * re-registered with) the live engine, and a `rule.toggle` audit row is
13
+ * written. The descriptions used to point at "the dashboard's toggle
14
+ * affordance" for this — which did not exist on any surface; the store
15
+ * had setEnabled() and nothing called it. This is the MCP path to it.
16
+ *
9
17
  * Past eval_results that referenced this rule stay intact — the
10
18
  * history is preserved even after the rule is removed. The audit
11
19
  * log row is the permanent record that the rule ever existed.
12
20
  */
13
21
  import { z } from 'zod';
22
+ import { createCustomRule } from '../eval/rules/custom.js';
14
23
  import { LOCAL_TENANT } from '../types/tenant.js';
15
24
  import { strictInput } from './strict-input.js';
16
25
  const inputSchema = {
17
26
  rule_id: z
18
27
  .string()
19
28
  .regex(/^rule-[a-z0-9]+$/)
20
- .describe('Rule id to delete (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
29
+ .describe('Rule id to delete or toggle (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
30
+ enabled: z
31
+ .boolean()
32
+ .optional()
33
+ .describe('When present the rule is NOT deleted: false DISABLES it (kept in the store, stops firing immediately, history and provenance preserved); true RE-ENABLES a disabled rule. Omit to delete'),
21
34
  };
22
35
  export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
23
36
  server.registerTool('delete_rule', {
24
- title: 'Delete Custom Rule',
37
+ title: 'Delete or Disable Custom Rule',
25
38
  description: [
26
- 'Remove a deployed custom evaluation rule. The rule stops firing on future evaluate_output calls; past eval_results that referenced it are preserved.',
39
+ 'Remove a deployed custom evaluation rule — or, with `enabled`, disable / re-enable it without removing it. Either way the change takes effect on the next evaluate_output call; past eval_results that referenced the rule are preserved.',
27
40
  '',
28
- 'Sibling tools — deploy_rule adds custom rules, list_rules enumerates them, evaluate_output runs them. delete_trace handles trace deletion (separate concern); log_trace / get_traces handle trace I/O. delete_rule is the DESTRUCTIVE remove path for the custom-rule store; it does NOT touch traces, eval_results, or built-in (non-custom) rules.',
41
+ 'Sibling tools — deploy_rule adds custom rules, list_rules enumerates them (including disabled ones, with `enabled: false`), evaluate_output runs them. delete_trace handles trace deletion (separate concern); log_trace / get_traces handle trace I/O. delete_rule is the DESTRUCTIVE remove path for the custom-rule store and the only MCP path that toggles a rule; it does NOT touch traces, eval_results, or built-in (non-custom) rules.',
29
42
  '',
30
- 'Behavior. DESTRUCTIVE — rewrites ~/.iris/custom-rules.json without the deleted row and appends a `rule.delete` entry to the audit log (~/.iris/audit.log). Not idempotent: deleting an already-deleted rule returns `deleted: false` rather than re-emitting the audit row. The rule stops firing immediately on the live process. Historical eval_results that reference this rule_id stay in the database — drift analytics + audit trail remain valid. Tenant-scoped in Cloud tier; OSS operates on LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
43
+ 'Behavior. Without `enabled`: DESTRUCTIVE — rewrites ~/.iris/custom-rules.json without the deleted row and appends a `rule.delete` entry to the audit log (~/.iris/audit.log). Not idempotent: deleting an already-deleted rule returns `deleted: false` rather than re-emitting the audit row. The rule stops firing immediately on the live process. With `enabled`: NOT destructive — the rule row stays, its `enabled` flag and `updatedAt` change, a `rule.toggle` audit entry is appended (none if the flag was already in that state), and the live engine unregisters (false) or re-registers (true) the rule so the change is immediate; a disabled rule is not loaded at the next boot either. Historical eval_results that reference this rule_id stay in the database — drift analytics + audit trail remain valid. Tenant-scoped in Cloud tier; OSS operates on LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
31
44
  '',
32
- 'Output shape. Returns JSON: `{ "deleted": boolean, "rule_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no rule with that id existed.',
45
+ 'Output shape. Delete: `{ "deleted": boolean, "rule_id": string }` `deleted=true` if a row was removed; `deleted=false` if no rule with that id existed. Toggle (enabled given): `{ "deleted": false, "toggled": boolean, "rule_id": string, "enabled"?: boolean, "rule"?: { ...the rule } }` — `toggled=true` with the rule\'s current state when the id exists (also when it was already in the requested state), `toggled=false` and no `rule` when it does not.',
33
46
  '',
34
- "Use when a custom rule is obsolete (behavior changed, false positives unacceptable, replaced by a better rule). Typical flow: list_rules → identify the stale one → delete_rule(id). Combine with deploy_rule to replace: delete_rule(oldId) + deploy_rule(newDefinition). To temporarily disable a rule WITHOUT deletion, use the dashboard's toggle affordance instead delete is permanent in intent (rule is gone; re-adding requires a new id).",
47
+ "Use when a custom rule is obsolete (behavior changed, false positives unacceptable, replaced by a better rule). Typical flow: list_rules → identify the stale one → delete_rule(id). Combine with deploy_rule to replace: delete_rule(oldId) + deploy_rule(newDefinition), or deploy_rule with the same name and replace:true. To temporarily PAUSE a rule false positives to investigate, a rollout to stage pass `enabled: false` instead of deleting; it keeps the id, the provenance and the history, and `enabled: true` brings it back with the same id.",
35
48
  '',
36
- "Don't use to pause a rule (toggle in the dashboard preserves history better). Don't use on built-in (non-custom) rules — the rule_id format checks for `rule-<hex>` custom ids; built-ins aren't in the store. Don't use to delete a trace or eval result (use delete_trace for traces; eval_results deletion is not exposed in v0.4 — they fall under data retention).",
49
+ "Don't use on built-in (non-custom) rules — the rule_id format checks for `rule-<hex>` custom ids; built-ins aren't in the store. Don't use to delete a trace or eval result (use delete_trace for traces; eval_results deletion is not exposed per row — they fall under data retention and `--purge`).",
37
50
  '',
38
- 'Parameters. rule_id is the only parameter; must match `rule-<lowercase-hex>` format (Zod regex). Format mismatch fails Zod with 400 BEFORE the store is touched. Cross-tenant rule_ids return `deleted: false` silently — they\'re invisible to the caller\'s tenant rather than producing a not-found error (prevents enumeration attacks). The rule_id you pass is exactly what list_rules returned in `id` or what deploy_rule returned in `rule.id`.',
51
+ 'Parameters. rule_id must match `rule-<lowercase-hex>` format (Zod regex). Format mismatch fails Zod with 400 BEFORE the store is touched. Cross-tenant rule_ids return `deleted: false` / `toggled: false` silently — they\'re invisible to the caller\'s tenant rather than producing a not-found error (prevents enumeration attacks). The rule_id you pass is exactly what list_rules returned in `id` or what deploy_rule returned in `rule.id`. enabled is optional: omit to delete, false to disable, true to re-enable.',
39
52
  '',
40
- "Error modes. Throws 400 on malformed rule_id (wrong prefix). Returns `{deleted: false}` if rule_id doesn't match any deployed rule (not an error — idempotent-ish). Returns 429 on HTTP rate limit. File-write failures propagate as 500.",
53
+ "Error modes. Throws 400 on malformed rule_id (wrong prefix) or an unknown argument. Returns `{deleted: false}` (or `{toggled: false}`) if rule_id doesn't match any deployed rule (not an error — idempotent-ish). Returns 429 on HTTP rate limit. File-write failures propagate as 500.",
41
54
  ].join('\n'),
42
55
  inputSchema: strictInput(inputSchema),
43
56
  annotations: {
@@ -48,6 +61,31 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
48
61
  },
49
62
  }, async (args) => {
50
63
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
64
+ if (args.enabled !== undefined) {
65
+ const rule = customRuleStore.setEnabled(LOCAL_TENANT, args.rule_id, args.enabled, 'mcp');
66
+ if (!rule) {
67
+ return {
68
+ content: [{ type: 'text', text: JSON.stringify({ deleted: false, toggled: false, rule_id: args.rule_id }) }],
69
+ };
70
+ }
71
+ // Mirror the store in the live engine, so the toggle is immediate
72
+ // (registerRule is idempotent by id — re-enabling an already-live
73
+ // rule does not stack a second copy).
74
+ if (rule.enabled) {
75
+ evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
76
+ }
77
+ else {
78
+ evalEngine.unregisterRule(rule.id);
79
+ }
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: JSON.stringify({ deleted: false, toggled: true, rule_id: args.rule_id, enabled: rule.enabled, rule }),
85
+ },
86
+ ],
87
+ };
88
+ }
51
89
  const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
52
90
  if (deleted) {
53
91
  // Hot-remove from the live engine so the rule stops firing on the
@@ -1,4 +1,37 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
3
  import type { EvalEngine } from '../eval/engine.js';
4
+ import type { DeployedCustomRule } from '../types/custom-rule.js';
5
+ import { type TenantId } from '../types/tenant.js';
6
+ /**
7
+ * A rule with this name is already deployed and the caller did not ask to
8
+ * replace it. Carries the existing rule(s) so an HTTP surface can answer
9
+ * 409 with them beside the same message the MCP tool throws.
10
+ */
11
+ export declare class DuplicateRuleNameError extends Error {
12
+ readonly existing: DeployedCustomRule[];
13
+ constructor(name: string, existing: DeployedCustomRule[]);
14
+ }
15
+ /** One rule retired by a `replace: true` deploy. */
16
+ export interface ReplacedRule {
17
+ id: string;
18
+ evalType: string;
19
+ severity: string;
20
+ }
21
+ /**
22
+ * Same-name redeploy (#373). Two rules with one name both fire, and their
23
+ * rule_results used to be indistinguishable — the same ruleName showing
24
+ * PASS and FAIL in one response. Refuse by default; with replace:true,
25
+ * retire the earlier rule(s) first so the name means one thing again. The
26
+ * store keeps the audit trail either way.
27
+ *
28
+ * One function for both deploy surfaces — the `deploy_rule` tool and the
29
+ * dashboard's `POST /api/v1/rules/custom` — so the semantics and the
30
+ * wording cannot drift between them. Returns the rules it retired (empty
31
+ * when the name was free); throws DuplicateRuleNameError when the name is
32
+ * taken and `replace` is false. Nothing is deployed by this function.
33
+ */
34
+ export declare function retireSameNamedRules(store: CustomRuleStore, engine: EvalEngine, tenantId: TenantId, name: string, replace: boolean, user: string): ReplacedRule[];
35
+ /** The `warning` both deploy surfaces attach when a replace retired rules. */
36
+ export declare function replacedRulesWarning(name: string, replaced: ReplacedRule[]): string;
4
37
  export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;