@iris-eval/mcp-server 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { homedir } from 'node:os';
15
+ import { irisHome } from './utils/iris-home.js';
16
16
  import { z } from 'zod';
17
17
  const AUDIT_ACTIONS = ['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update'];
18
18
  const EntrySchema = z.object({
@@ -31,7 +31,7 @@ const EntrySchema = z.object({
31
31
  details: z.record(z.string(), z.unknown()).optional(),
32
32
  });
33
33
  function defaultAuditPath() {
34
- return join(homedir(), '.iris', 'audit.log');
34
+ return join(irisHome(), 'audit.log');
35
35
  }
36
36
  export function readAuditLog(opts) {
37
37
  const filePath = opts?.filePath ?? defaultAuditPath();
@@ -1,7 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- const irisHome = join(homedir(), '.iris');
3
+ import { irisHome } from '../utils/iris-home.js';
5
4
  // Read version from package.json to avoid hardcoded drift
6
5
  let pkgVersion = '0.1.8';
7
6
  try {
@@ -17,7 +16,7 @@ export const PKG_VERSION = pkgVersion;
17
16
  export const defaultConfig = {
18
17
  storage: {
19
18
  type: 'sqlite',
20
- path: join(irisHome, 'iris.db'),
19
+ path: join(irisHome(), 'iris.db'),
21
20
  },
22
21
  server: {
23
22
  name: 'iris-eval',
@@ -31,6 +30,7 @@ export const defaultConfig = {
31
30
  dashboard: {
32
31
  enabled: false,
33
32
  port: 6920,
33
+ host: '127.0.0.1',
34
34
  },
35
35
  eval: {
36
36
  defaultThreshold: 0.7,
@@ -6,6 +6,7 @@ export interface CliArgs {
6
6
  dbPath?: string;
7
7
  dashboard?: boolean;
8
8
  dashboardPort?: number;
9
+ dashboardHost?: string;
9
10
  apiKey?: string;
10
11
  }
11
12
  export declare function loadConfig(cliArgs?: CliArgs): IrisConfig;
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, mkdirSync, existsSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { defaultConfig } from './defaults.js';
4
+ import { irisHome } from '../utils/iris-home.js';
5
5
  function deepMerge(target, source) {
6
6
  const result = { ...target };
7
7
  for (const key of Object.keys(source)) {
@@ -71,6 +71,12 @@ function loadEnvVars() {
71
71
  port: parsePortEnv(process.env.IRIS_DASHBOARD_PORT, 'IRIS_DASHBOARD_PORT'),
72
72
  };
73
73
  }
74
+ if (process.env.IRIS_DASHBOARD_HOST) {
75
+ config.dashboard = {
76
+ ...config.dashboard,
77
+ host: process.env.IRIS_DASHBOARD_HOST,
78
+ };
79
+ }
74
80
  if (process.env.IRIS_API_KEY) {
75
81
  config.security = { ...config.security, apiKey: process.env.IRIS_API_KEY };
76
82
  }
@@ -99,17 +105,20 @@ function cliArgsToConfig(args) {
99
105
  if (args.dashboardPort) {
100
106
  config.dashboard = { ...config.dashboard, port: args.dashboardPort };
101
107
  }
108
+ if (args.dashboardHost) {
109
+ config.dashboard = { ...config.dashboard, host: args.dashboardHost };
110
+ }
102
111
  if (args.apiKey) {
103
112
  config.security = { ...config.security, apiKey: args.apiKey };
104
113
  }
105
114
  return config;
106
115
  }
107
116
  export function loadConfig(cliArgs) {
108
- const irisHome = join(homedir(), '.iris');
109
- if (!existsSync(irisHome)) {
110
- mkdirSync(irisHome, { recursive: true });
117
+ const home = irisHome();
118
+ if (!existsSync(home)) {
119
+ mkdirSync(home, { recursive: true });
111
120
  }
112
- const configPath = cliArgs?.config ?? join(irisHome, 'config.json');
121
+ const configPath = cliArgs?.config ?? join(home, 'config.json');
113
122
  const fileConfig = loadConfigFile(configPath);
114
123
  const envConfig = loadEnvVars();
115
124
  const argsConfig = cliArgs ? cliArgsToConfig(cliArgs) : {};
@@ -23,12 +23,14 @@
23
23
  * For now we use atomic write-via-rename so a crashed write doesn't
24
24
  * leave a half-file.
25
25
  */
26
- import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, appendFileSync } from 'node:fs';
26
+ import { mkdirSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
27
+ import { writeAtomic } from './utils/write-atomic.js';
28
+ import { irisHome } from './utils/iris-home.js';
27
29
  import { join, dirname } from 'node:path';
28
- import { homedir } from 'node:os';
29
30
  import { randomBytes } from 'node:crypto';
30
31
  import { z } from 'zod';
31
32
  import isSafeRegex from 'safe-regex2';
33
+ import { regexBacktrackingBudgetExceeded } from './eval/rules/regex-budget.js';
32
34
  import { CUSTOM_RULE_CONFIG_KEYS, readNumericConfig, describeKeys } from './eval/rules/config-keys.js';
33
35
  import { LOCAL_TENANT } from './types/tenant.js';
34
36
  const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
@@ -121,6 +123,21 @@ const DefinitionSchema = z
121
123
  path: ['config', 'pattern'],
122
124
  message: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
123
125
  });
126
+ break;
127
+ }
128
+ // safe-regex2 is a star-height heuristic — it catches EXPONENTIAL
129
+ // blowup only. Polynomial patterns pass it: a*a*a*a*a*b is judged
130
+ // safe and takes 156ms on 40 characters. Measure what the static
131
+ // check cannot see.
132
+ {
133
+ const budgetIssue = regexBacktrackingBudgetExceeded(stripped, typeof config.flags === 'string' ? config.flags : '');
134
+ if (budgetIssue) {
135
+ ctx.addIssue({
136
+ code: z.ZodIssueCode.custom,
137
+ path: ['config', 'pattern'],
138
+ message: budgetIssue,
139
+ });
140
+ }
124
141
  }
125
142
  break;
126
143
  }
@@ -167,26 +184,22 @@ const DeployedRuleSchema = z.object({
167
184
  sourceMomentId: z.string().optional(),
168
185
  version: z.number().int().positive(),
169
186
  });
170
- const FileSchema = z.object({
171
- version: z.literal(1),
172
- rules: z.array(DeployedRuleSchema),
173
- });
174
187
  /**
175
188
  * Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path
176
189
  * (zero migration); others get a per-tenant suffix.
177
190
  */
178
191
  function defaultPathFor(tenantId) {
179
192
  if (tenantId === LOCAL_TENANT) {
180
- return join(homedir(), '.iris', 'custom-rules.json');
193
+ return join(irisHome(), 'custom-rules.json');
181
194
  }
182
195
  // Sanitize tenant id for filesystem safety. TenantId is branded but
183
196
  // could in principle contain odd chars on Cloud — limit to a known-safe
184
197
  // alphabet so we never write outside the .iris directory.
185
198
  const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
186
- return join(homedir(), '.iris', `custom-rules-${safe}.json`);
199
+ return join(irisHome(), `custom-rules-${safe}.json`);
187
200
  }
188
201
  function defaultAuditPath() {
189
- return join(homedir(), '.iris', 'audit.log');
202
+ return join(irisHome(), 'audit.log');
190
203
  }
191
204
  function generateRuleId() {
192
205
  return `rule-${randomBytes(4).toString('hex')}`;
@@ -201,45 +214,80 @@ function appendAudit(auditPath, entry) {
201
214
  // still succeeds; the operator just loses the audit trail.
202
215
  }
203
216
  }
204
- function writeAtomic(targetPath, contents) {
205
- mkdirSync(dirname(targetPath), { recursive: true });
206
- const tmp = `${targetPath}.tmp.${process.pid}`;
207
- writeFileSync(tmp, contents, 'utf-8');
208
- renameSync(tmp, targetPath);
209
- }
217
+ /*
218
+ * Read leniently, one rule at a time.
219
+ *
220
+ * This used to validate the whole array with a single safeParse and return
221
+ * [] if ANY element failed. The empty result was then cached, and the next
222
+ * deploy/delete/toggle called persist(), which wrote {version:1, rules:[]}
223
+ * over the file — permanently destroying every valid rule alongside the
224
+ * bad one. The old comment ("do NOT overwrite the file") described an
225
+ * intent the write path did not honour.
226
+ *
227
+ * It was reachable, not theoretical: DefinitionSchema's superRefine now
228
+ * runs on READ as well as WRITE, and eval/rules/custom.ts notes that rules
229
+ * predating that validation — e.g. {type:'min_length', config:{}} — are
230
+ * already sitting in users' files.
231
+ */
210
232
  function loadRulesFromDisk(rulesPath) {
211
233
  if (!existsSync(rulesPath))
212
- return [];
234
+ return { rules: [], quarantined: [], readable: true };
235
+ let parsedJson;
213
236
  try {
214
- const raw = readFileSync(rulesPath, 'utf-8');
215
- const parsed = FileSchema.safeParse(JSON.parse(raw));
216
- if (parsed.success)
217
- return parsed.data.rules;
218
- // Malformed: leave rules empty; do NOT overwrite the file.
219
- return [];
237
+ parsedJson = JSON.parse(readFileSync(rulesPath, 'utf-8'));
220
238
  }
221
239
  catch {
222
- // Unreadable: leave rules empty.
223
- return [];
240
+ return { rules: [], quarantined: [], readable: false };
241
+ }
242
+ const envelope = z.object({ rules: z.array(z.unknown()).optional() }).safeParse(parsedJson);
243
+ if (!envelope.success)
244
+ return { rules: [], quarantined: [], readable: false };
245
+ const rules = [];
246
+ const quarantined = [];
247
+ for (const entry of envelope.data.rules ?? []) {
248
+ const rule = DeployedRuleSchema.safeParse(entry);
249
+ if (rule.success)
250
+ rules.push(rule.data);
251
+ else
252
+ quarantined.push(entry);
224
253
  }
254
+ return { rules, quarantined, readable: true };
225
255
  }
226
256
  export function createCustomRuleStore(opts) {
227
257
  const pathFor = opts?.pathFor ?? defaultPathFor;
228
258
  const auditPath = opts?.auditPath ?? defaultAuditPath();
229
259
  // In-memory cache keyed by tenant. Lazy-loaded on first access per
230
260
  // tenant; subsequent calls hit the cache.
231
- const tenantRules = new Map();
232
- function load(tenantId) {
233
- let rules = tenantRules.get(tenantId);
234
- if (rules === undefined) {
235
- rules = loadRulesFromDisk(pathFor(tenantId));
236
- tenantRules.set(tenantId, rules);
261
+ const tenantState = new Map();
262
+ function state(tenantId) {
263
+ let loaded = tenantState.get(tenantId);
264
+ if (loaded === undefined) {
265
+ loaded = loadRulesFromDisk(pathFor(tenantId));
266
+ tenantState.set(tenantId, loaded);
237
267
  }
238
- return rules;
268
+ return loaded;
269
+ }
270
+ function load(tenantId) {
271
+ return state(tenantId).rules;
239
272
  }
240
273
  function persist(tenantId) {
241
- const rules = tenantRules.get(tenantId) ?? [];
242
- const file = { version: 1, rules };
274
+ const loaded = state(tenantId);
275
+ if (!loaded.readable) {
276
+ /*
277
+ * The file exists but never parsed. Overwriting it would replace
278
+ * content we could not read — exactly the data loss this store used
279
+ * to cause silently. Fail loudly so the caller surfaces a 500 and
280
+ * the operator can fix or move the file.
281
+ */
282
+ throw new Error(`Refusing to write ${pathFor(tenantId)}: the existing file could not be parsed. ` +
283
+ `Fix or move it, then retry — writing now would destroy its contents.`);
284
+ }
285
+ // Quarantined entries ride along untouched so a deploy never deletes
286
+ // rules this version could not validate.
287
+ const file = {
288
+ version: 1,
289
+ rules: [...loaded.rules, ...loaded.quarantined],
290
+ };
243
291
  writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));
244
292
  }
245
293
  return {