@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.
- package/README.md +99 -36
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +30 -3
- package/dist/dashboard/seed-demo-data.js +14 -3
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +9 -3
- package/dist/dashboard/validation.js +69 -11
- package/dist/eval/citation-verify/verifier.d.ts +17 -0
- package/dist/eval/citation-verify/verifier.js +68 -15
- package/dist/eval/decision-moment.js +17 -9
- package/dist/eval/engine.d.ts +62 -0
- package/dist/eval/engine.js +196 -58
- package/dist/eval/llm-judge/evaluator.js +50 -33
- package/dist/eval/llm-judge/templates/index.d.ts +4 -0
- package/dist/eval/llm-judge/templates/index.js +10 -4
- package/dist/eval/rules/custom.js +59 -6
- package/dist/eval/rules/relevance.js +1 -1
- package/dist/eval/rules/safety.d.ts +8 -0
- package/dist/eval/rules/safety.js +63 -18
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
- package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
- package/dist/storage/migrations/index.js +2 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +91 -4
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +50 -24
- package/dist/tools/evaluate-with-llm-judge.js +11 -4
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +5 -4
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +25 -0
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +42 -5
- package/dist/types/decision-moment.d.ts +8 -0
- package/dist/types/eval.d.ts +60 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +1 -1
- package/server.json +2 -2
- 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
|
|
15
|
-
* never reads their config.json, and never honours an
|
|
16
|
-
* would 401 its own probes. The scratch home is removed
|
|
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
|
|
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
|
|
124
|
-
*
|
|
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
|
|
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 (
|
|
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,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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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;
|