@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
@@ -11,7 +11,9 @@
11
11
  * eval_type.
12
12
  */
13
13
  import { z } from 'zod';
14
+ import { createCustomRule } from '../eval/rules/custom.js';
14
15
  import { LOCAL_TENANT } from '../types/tenant.js';
16
+ import { strictInput } from './strict-input.js';
15
17
  const CustomRuleDefinitionSchema = z.object({
16
18
  name: z.string(),
17
19
  type: z.enum([
@@ -28,7 +30,11 @@ const CustomRuleDefinitionSchema = z.object({
28
30
  weight: z.number().optional(),
29
31
  });
30
32
  const inputSchema = {
31
- name: z.string().min(1).max(120).describe('Human-readable rule name (used in eval results)'),
33
+ // 80 mirrors the persisted store's cap (custom-rule-store.ts). The tool
34
+ // used to allow 120, so a 100-char name passed the tool schema and then
35
+ // surfaced the store's ZodError as a raw 500 (#332). One limit, enforced
36
+ // at the boundary, fails cleanly as a 400.
37
+ name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results)'),
32
38
  description: z
33
39
  .string()
34
40
  .max(500)
@@ -40,14 +46,14 @@ const inputSchema = {
40
46
  severity: z
41
47
  .enum(['low', 'medium', 'high', 'critical'])
42
48
  .default('medium')
43
- .describe('Severity used for dashboard sort + audit alerts'),
49
+ .describe('What a FAILURE of this rule means. low/medium: informational — contributes to the weighted score only (plus dashboard sort + audit alerts). high/critical: hard-fail — a failing evaluation of this rule forces the overall passed=false regardless of the weighted score'),
44
50
  definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
45
51
  sourceMomentId: z
46
52
  .string()
47
53
  .optional()
48
54
  .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
49
55
  };
50
- export function registerDeployRuleTool(server, customRuleStore) {
56
+ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
51
57
  server.registerTool('deploy_rule', {
52
58
  title: 'Deploy Custom Rule',
53
59
  description: [
@@ -63,11 +69,11 @@ export function registerDeployRuleTool(server, customRuleStore) {
63
69
  '',
64
70
  "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule — this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
65
71
  '',
66
- 'Parameters. name is 1-120 chars (Zod-enforced min/max); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity affects dashboard sort + audit log signal but does NOT affect scoring (scoring uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
72
+ 'Parameters. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
67
73
  '',
68
- "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name`. Throws 400 if the eval category mismatches the definition type. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
74
+ "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name` or `name` over 80 chars. Any evalType/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
69
75
  ].join('\n'),
70
- inputSchema,
76
+ inputSchema: strictInput(inputSchema),
71
77
  annotations: {
72
78
  readOnlyHint: false,
73
79
  destructiveHint: false,
@@ -75,16 +81,32 @@ export function registerDeployRuleTool(server, customRuleStore) {
75
81
  openWorldHint: false,
76
82
  },
77
83
  }, async (args) => {
84
+ // Server overrides the inner definition's `name` so it always matches
85
+ // the user-facing rule name — same normalization the dashboard's
86
+ // deploy route applies. Also keeps the tool's 80-char cap authoritative
87
+ // (an unchecked definition.name used to reach the store and surface its
88
+ // ZodError as a raw 500).
89
+ const definition = {
90
+ ...args.definition,
91
+ name: args.name,
92
+ };
78
93
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
79
94
  const rule = customRuleStore.deploy(LOCAL_TENANT, {
80
95
  name: args.name,
81
96
  description: args.description,
82
97
  evalType: args.evalType,
83
98
  severity: args.severity,
84
- definition: args.definition,
99
+ definition,
85
100
  sourceMomentId: args.sourceMomentId,
86
101
  user: 'mcp',
87
102
  });
103
+ // Register with the live engine so the rule fires on the very next
104
+ // evaluate_output call — the "activates immediately for the running
105
+ // process" this description promises. Previously only the dashboard's
106
+ // deploy route did this; MCP deploys silently waited for a restart.
107
+ // Registered under its rule id so delete_rule can hot-remove it.
108
+ // Severity rides along: high/critical makes the rule hard-failing.
109
+ evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
88
110
  return {
89
111
  content: [
90
112
  {
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
+ import { strictInput } from './strict-input.js';
3
4
  const CustomRuleSchema = z.object({
4
5
  name: z.string(),
5
6
  type: z.enum([
@@ -11,11 +12,20 @@ const CustomRuleSchema = z.object({
11
12
  });
12
13
  const inputSchema = {
13
14
  output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
14
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness').describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire'),
15
+ // .optional() rather than .default('completeness') so the handler can tell
16
+ // "caller chose completeness" apart from "caller never chose" — the second
17
+ // case gets a note in the response saying safety rules did not run. The
18
+ // effective default is still completeness.
19
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
15
20
  expected: z.string().optional().describe('Expected output for comparison — REQUIRED when eval_type="relevance" (used as keyword-overlap target)'),
16
- input: z.string().optional().describe('Original input for context — improves relevance scoring (keyword overlap vs input)'),
21
+ input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — improves relevance scoring and grounds the safety bundle\'s hallucination signals'),
17
22
  trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
18
- custom_rules: z.array(CustomRuleSchema).optional().describe('Custom evaluation rules fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
23
+ // .max(10): inline rules skip the deploy-time probe, and the engine runs
24
+ // rules synchronously — without a cap, one request carrying N sandbox-
25
+ // defeating regex rules stalls the server linearly in N (measured 9.3s at
26
+ // N=50). Ten is ample for per-call rules; persistent sets belong in
27
+ // deploy_rule, where deploy-time validation probes each pattern.
28
+ custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
19
29
  cost_usd: z.number().optional().describe('Cost in USD — only consulted when eval_type="cost" (compared against cost_threshold rules)'),
20
30
  token_usage: z.object({
21
31
  prompt_tokens: z.number().optional(),
@@ -33,17 +43,19 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
33
43
  '',
34
44
  'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
35
45
  '',
36
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data).',
46
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "note?": string }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
37
47
  '',
38
- 'Use when you want a quality score on a specific output typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
48
+ 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing.',
49
+ '',
50
+ 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
39
51
  '',
40
52
  'Don\'t use when the output is empty or has no applicable rules — the eval_type decides which rules apply, and invalid combinations return score=0 + insufficient_data=true (not an error, but not actionable). Don\'t use to VALIDATE JSON schemas directly (use your language\'s JSON Schema validator — Iris\'s `json_schema` custom rule type is for output-shape assertions, not arbitrary validation).',
41
53
  '',
42
- 'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks; ignored otherwise. Defaults: eval_type="completeness".',
54
+ 'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
43
55
  '',
44
- 'Error modes. Throws on malformed custom_rules (Zod rejects). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions.',
56
+ 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped). Throws on malformed custom_rules (Zod rejects) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
45
57
  ].join('\n'),
46
- inputSchema,
58
+ inputSchema: strictInput(inputSchema),
47
59
  annotations: {
48
60
  readOnlyHint: false, // Writes an eval_result row
49
61
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
@@ -51,7 +63,12 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
51
63
  openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
52
64
  },
53
65
  }, async (args) => {
54
- const evalType = args.eval_type;
66
+ // Track omission explicitly: a caller who never chose a bundle gets
67
+ // the completeness default AND a note saying so — six of seven UAT
68
+ // personas read passed:true on PII-laden text with no hint that the
69
+ // safety bundle never ran.
70
+ const evalTypeOmitted = args.eval_type === undefined;
71
+ const evalType = (args.eval_type ?? 'completeness');
55
72
  const result = evalEngine.evaluate(evalType, {
56
73
  output: args.output,
57
74
  expected: args.expected,
@@ -71,13 +88,23 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
71
88
  type: 'text',
72
89
  text: JSON.stringify({
73
90
  id: result.id,
91
+ // Echo which bundle actually ran. Without this, a caller who
92
+ // omitted eval_type could not tell a "safety pass" from a
93
+ // completeness eval that never ran a single safety rule.
94
+ eval_type: result.eval_type,
74
95
  score: result.score,
75
96
  passed: result.passed,
97
+ ...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
76
98
  rule_results: result.rule_results,
77
99
  suggestions: result.suggestions,
78
100
  rules_evaluated: result.rules_evaluated,
79
101
  rules_skipped: result.rules_skipped,
80
102
  insufficient_data: result.insufficient_data,
103
+ ...(evalTypeOmitted
104
+ ? {
105
+ note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them.',
106
+ }
107
+ : {}),
81
108
  }),
82
109
  },
83
110
  ],
@@ -3,6 +3,7 @@ import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
+ import { strictInput } from './strict-input.js';
6
7
  const inputSchema = {
7
8
  output: z.string().min(1).describe('The agent output text to evaluate'),
8
9
  template: z
@@ -73,7 +74,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
73
74
  '',
74
75
  'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
75
76
  ].join('\n'),
76
- inputSchema,
77
+ inputSchema: strictInput(inputSchema),
77
78
  annotations: {
78
79
  readOnlyHint: false, // Writes eval_result; also spends money (external API cost)
79
80
  destructiveHint: false, // Creates data; doesn't overwrite or delete
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
+ import { strictInput } from './strict-input.js';
3
4
  const inputSchema = {
4
5
  agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
5
6
  framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
@@ -7,7 +8,10 @@ const inputSchema = {
7
8
  until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
8
9
  min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
9
10
  max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
10
- limit: z.number().default(50).describe('Results per page (default 50, max 1000 values >1000 return 400)'),
11
+ // Mirrors traceQuerySchema in dashboard/validation.tsboth capture paths
12
+ // (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
13
+ // meant "LIMIT -1" in SQLite, i.e. every row (#332).
14
+ limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
11
15
  offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
12
16
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
13
17
  sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
@@ -33,7 +37,7 @@ export function registerGetTracesTool(server, storage) {
33
37
  '',
34
38
  'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
35
39
  ].join('\n'),
36
- inputSchema,
40
+ inputSchema: strictInput(inputSchema),
37
41
  annotations: {
38
42
  readOnlyHint: true, // Pure query: never writes, never deletes
39
43
  destructiveHint: false, // Inverse of readOnly — trivially false
@@ -12,8 +12,8 @@ export function registerAllTools(server, storage, evalEngine, customRuleStore) {
12
12
  registerEvaluateOutputTool(server, storage, evalEngine);
13
13
  registerGetTracesTool(server, storage);
14
14
  registerListRulesTool(server, customRuleStore);
15
- registerDeployRuleTool(server, customRuleStore);
16
- registerDeleteRuleTool(server, customRuleStore);
15
+ registerDeployRuleTool(server, customRuleStore, evalEngine);
16
+ registerDeleteRuleTool(server, customRuleStore, evalEngine);
17
17
  registerDeleteTraceTool(server, storage);
18
18
  registerEvaluateWithLLMJudgeTool(server, storage);
19
19
  registerVerifyCitationsTool(server, storage);
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { LOCAL_TENANT } from '../types/tenant.js';
15
+ import { strictInput } from './strict-input.js';
15
16
  const inputSchema = {
16
17
  eval_type: z
17
18
  .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
@@ -42,7 +43,7 @@ export function registerListRulesTool(server, customRuleStore) {
42
43
  '',
43
44
  "Error modes. Returns empty list if the rule store file doesn't exist (first run). Returns 429 if HTTP rate limit exceeded. Never throws on valid input.",
44
45
  ].join('\n'),
45
- inputSchema,
46
+ inputSchema: strictInput(inputSchema),
46
47
  annotations: {
47
48
  readOnlyHint: true,
48
49
  destructiveHint: false,
@@ -1,3 +1,54 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { IStorageAdapter } from '../types/query.js';
4
+ export declare const logTraceInputShape: {
5
+ agent_name: z.ZodString;
6
+ framework: z.ZodOptional<z.ZodString>;
7
+ input: z.ZodOptional<z.ZodString>;
8
+ output: z.ZodOptional<z.ZodString>;
9
+ tool_calls: z.ZodOptional<z.ZodArray<z.ZodObject<{
10
+ tool_name: z.ZodString;
11
+ input: z.ZodOptional<z.ZodUnknown>;
12
+ output: z.ZodOptional<z.ZodUnknown>;
13
+ latency_ms: z.ZodOptional<z.ZodNumber>;
14
+ error: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strip>>>;
16
+ latency_ms: z.ZodOptional<z.ZodNumber>;
17
+ token_usage: z.ZodOptional<z.ZodObject<{
18
+ prompt_tokens: z.ZodOptional<z.ZodNumber>;
19
+ completion_tokens: z.ZodOptional<z.ZodNumber>;
20
+ total_tokens: z.ZodOptional<z.ZodNumber>;
21
+ }, z.core.$strip>>;
22
+ cost_usd: z.ZodOptional<z.ZodNumber>;
23
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
24
+ spans: z.ZodOptional<z.ZodArray<z.ZodObject<{
25
+ span_id: z.ZodOptional<z.ZodString>;
26
+ parent_span_id: z.ZodOptional<z.ZodString>;
27
+ name: z.ZodString;
28
+ kind: z.ZodDefault<z.ZodEnum<{
29
+ INTERNAL: "INTERNAL";
30
+ SERVER: "SERVER";
31
+ CLIENT: "CLIENT";
32
+ PRODUCER: "PRODUCER";
33
+ CONSUMER: "CONSUMER";
34
+ LLM: "LLM";
35
+ TOOL: "TOOL";
36
+ }>>;
37
+ status_code: z.ZodDefault<z.ZodEnum<{
38
+ UNSET: "UNSET";
39
+ OK: "OK";
40
+ ERROR: "ERROR";
41
+ }>>;
42
+ status_message: z.ZodOptional<z.ZodString>;
43
+ start_time: z.ZodString;
44
+ end_time: z.ZodOptional<z.ZodString>;
45
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
46
+ events: z.ZodOptional<z.ZodArray<z.ZodObject<{
47
+ name: z.ZodString;
48
+ timestamp: z.ZodString;
49
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
50
+ }, z.core.$strip>>>;
51
+ }, z.core.$strip>>>;
52
+ timestamp: z.ZodOptional<z.ZodString>;
53
+ };
3
54
  export declare function registerLogTraceTool(server: McpServer, storage: IStorageAdapter): void;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
3
  import { LOCAL_TENANT } from '../types/tenant.js';
4
4
  import { bestEffortExport } from '../otel/lazy.js';
5
+ import { strictInput } from './strict-input.js';
5
6
  const ToolCallSchema = z.object({
6
7
  tool_name: z.string(),
7
8
  input: z.unknown().optional(),
@@ -30,7 +31,14 @@ const TokenUsageSchema = z.object({
30
31
  completion_tokens: z.number().optional(),
31
32
  total_tokens: z.number().optional(),
32
33
  });
33
- const inputSchema = {
34
+ /*
35
+ * The log_trace input contract. Exported because POST /api/v1/traces
36
+ * (src/dashboard/routes/traces.ts) accepts the SAME body — one schema,
37
+ * two capture paths. Duplicating it there would let the tool and the
38
+ * HTTP endpoint drift apart silently; importing it means a field added
39
+ * here is accepted (and validated identically) on both.
40
+ */
41
+ export const logTraceInputShape = {
34
42
  agent_name: z.string().describe('Agent name — used for filtering in get_traces (e.g., "customer-support-bot")'),
35
43
  framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
36
44
  input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
@@ -63,7 +71,11 @@ export function registerLogTraceTool(server, storage) {
63
71
  '',
64
72
  'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
65
73
  ].join('\n'),
66
- inputSchema,
74
+ // Strict at the MCP boundary (unknown args rejected, not stripped).
75
+ // The dashboard's HTTP ingest builds its own schema FROM this shape
76
+ // (dashboard/validation.ts) and keeps default stripping there on
77
+ // purpose — it relies on it to discard a client-supplied trace_id.
78
+ inputSchema: strictInput(logTraceInputShape),
67
79
  annotations: {
68
80
  readOnlyHint: false, // Writes a row to storage
69
81
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
@@ -0,0 +1,2 @@
1
+ import { z } from 'zod';
2
+ export declare function strictInput<T extends z.ZodRawShape>(shape: T): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ /*
3
+ * Wraps a tool's input shape in a STRICT object schema so unknown argument
4
+ * names are REJECTED with an error that names the offending key(s) and
5
+ * lists the valid ones.
6
+ *
7
+ * Why this exists: a bare shape (or z.object()) silently STRIPS unknown
8
+ * keys. At the MCP tool boundary that is dangerous, not lenient — an LLM
9
+ * guessing an argument name is the normal case, not an edge case. Before
10
+ * this wrapper, `evaluate_output({ criteria: ["safety"], ... })` (a
11
+ * plausible guess) and `eval_typ: "safety"` (a one-character typo) both
12
+ * "succeeded": the arguments were dropped, the DEFAULT completeness bundle
13
+ * ran instead of the safety rules, and the response said passed:true on
14
+ * text containing real PII — with nothing indicating the arguments were
15
+ * ignored. Meanwhile a missing REQUIRED field produced a precise Zod
16
+ * error, so the failure mode was inconsistent as well as unsafe.
17
+ *
18
+ * The MCP SDK accepts a schema object (not just a raw shape) for
19
+ * inputSchema and validates tool calls through it, so the custom
20
+ * unrecognized-keys message below is exactly what the caller sees.
21
+ * Strictness also reaches tools/list: the generated JSON Schema carries
22
+ * additionalProperties:false, telling well-behaved clients up front.
23
+ */
24
+ export function strictInput(shape) {
25
+ const validKeys = Object.keys(shape).join(', ');
26
+ return z.strictObject(shape, {
27
+ error: (issue) => issue.code === 'unrecognized_keys'
28
+ ? `Unknown argument(s): ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
29
+ `Valid arguments: ${validKeys}. ` +
30
+ 'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
31
+ 'argument name cannot change what gets evaluated — check the spelling against ' +
32
+ "the tool's input schema and retry."
33
+ : undefined,
34
+ });
35
+ }
@@ -3,6 +3,7 @@ import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { verifyCitations } from '../eval/citation-verify/verifier.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
+ import { strictInput } from './strict-input.js';
6
7
  const inputSchema = {
7
8
  output: z.string().min(1).describe('The agent output containing citations to verify'),
8
9
  model: z
@@ -57,7 +58,7 @@ export function registerVerifyCitationsTool(server, storage) {
57
58
  '',
58
59
  'Behavior. Three-phase pipeline: (1) regex extraction of [N] numbered refs, (Author, Year) parentheticals, bare URLs, and DOIs (in-process, no network); (2) SSRF-guarded fetch of URL + DOI citations, with scheme allowlist, private/link-local/cloud-metadata IP blocking, optional domain allowlist (IRIS_CITATION_DOMAINS), 10s timeout, 5MB body cap, manual redirect chase (max 3, re-checked), in-process LRU cache; (3) per-citation LLM judge call asking "does this source support this claim?" with a 256-token verdict. Opt-in via allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — Iris refuses outbound HTTP by default. Cost-capped across the entire call by max_cost_usd_total (default $1.00) — the pipeline stops when the cap would be exceeded. Rate-limited to 20 req/min on HTTP MCP. Writes one eval_result row tagged with per-citation provenance.',
59
60
  '',
60
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported / resolved`; `null` when nothing resolvable was found.',
61
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_judged": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported / judged`; `null` when nothing was judged (no resolvable citations, or every judge call failed). Infrastructure failures (cost cap, judge timeout/error, malformed verdict) leave a citation resolved-but-unjudged — reported per-citation via resolve_error, never scored as unsupported.',
61
62
  '',
62
63
  'Use when the output makes factual claims backed by [1]-style references, DOIs, or URLs and you want to separate "cited correctly" from "cited and wrong" from "cited but unresolvable". Particularly useful for research/legal/medical agents where fabricated citations are the dominant failure mode.',
63
64
  "",
@@ -67,7 +68,7 @@ export function registerVerifyCitationsTool(server, storage) {
67
68
  '',
68
69
  'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
69
70
  ].join('\n'),
70
- inputSchema,
71
+ inputSchema: strictInput(inputSchema),
71
72
  annotations: {
72
73
  readOnlyHint: false, // Writes eval_result + spends money
73
74
  destructiveHint: false, // Creates data; doesn't overwrite/delete
@@ -109,11 +110,11 @@ export function registerVerifyCitationsTool(server, storage) {
109
110
  passed: result.passed,
110
111
  score,
111
112
  message: result.overallScore === null
112
- ? `No resolvable citations (found ${result.totalCitationsFound}, resolved ${result.totalResolved})`
113
- : `${result.totalSupported}/${result.totalResolved} cited sources supported the output`,
113
+ ? `No citations judged (found ${result.totalCitationsFound}, resolved ${result.totalResolved}, judged 0)`
114
+ : `${result.totalSupported}/${result.totalJudged} judged sources supported the output`,
114
115
  },
115
116
  ],
116
- suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.totalResolved} cited sources actually supported the claim.`],
117
+ suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.totalJudged} judged sources actually supported the claim.`],
117
118
  rules_evaluated: 1,
118
119
  rules_skipped: 0,
119
120
  insufficient_data: result.overallScore === null,
@@ -128,6 +129,7 @@ export function registerVerifyCitationsTool(server, storage) {
128
129
  passed: result.passed,
129
130
  total_citations_found: result.totalCitationsFound,
130
131
  total_resolved: result.totalResolved,
132
+ total_judged: result.totalJudged,
131
133
  total_supported: result.totalSupported,
132
134
  total_cost_usd: result.totalCostUsd,
133
135
  citations: result.citations.map((c) => ({
@@ -56,9 +56,31 @@ export async function createHttpTransport(mcpServer, config, logger) {
56
56
  * `127.0.0.1:0` and reject every real request with a 403 that looks
57
57
  * exactly like an attack. Routes are registered immediately after, and
58
58
  * the port is not discoverable by any client until this function returns.
59
+ *
60
+ * The callback MUST inspect its error argument. Express 5 wires the
61
+ * listen callback as `server.once('error', done)` as well as the
62
+ * listening callback — so on EADDRINUSE it is invoked WITH the error.
63
+ * Ignoring that argument resolved this promise on a server that never
64
+ * bound: the caller then logged "HTTP transport listening on <port>"
65
+ * while another process owned the port, and the process idled forever.
66
+ * A CI health poll got 200 from the OTHER instance and shipped
67
+ * evaluations to a stranger's database. A bind failure must reject,
68
+ * name the port, and take the process down nonzero.
59
69
  */
60
- const httpServer = await new Promise((resolve) => {
61
- const server = app.listen(config.transport.port, config.transport.host, () => resolve(server));
70
+ const httpServer = await new Promise((resolve, reject) => {
71
+ const server = app.listen(config.transport.port, config.transport.host, (err) => {
72
+ if (err) {
73
+ const bind = `${config.transport.host}:${config.transport.port}`;
74
+ const code = err.code;
75
+ reject(code === 'EADDRINUSE'
76
+ ? new Error(`HTTP transport failed to start: port ${config.transport.port} is already in use ` +
77
+ `(EADDRINUSE on ${bind}). Another process — possibly another iris instance — owns it. ` +
78
+ `Pass --port <other> (or set IRIS_PORT) or stop the other process.`)
79
+ : new Error(`HTTP transport failed to bind ${bind}: ${err.message}`));
80
+ return;
81
+ }
82
+ resolve(server);
83
+ });
62
84
  });
63
85
  const address = httpServer.address();
64
86
  const port = typeof address === 'object' && address ? address.port : config.transport.port;
@@ -15,6 +15,13 @@ export interface IrisConfig {
15
15
  dashboard: {
16
16
  enabled: boolean;
17
17
  port: number;
18
+ /**
19
+ * Bind address. Defaults to loopback: the dashboard is unauthenticated
20
+ * by default (security.apiKey is undefined) and serves the full trace
21
+ * history, so binding it to every interface exposes agent inputs and
22
+ * outputs to the local network. Set explicitly to share it.
23
+ */
24
+ host: string;
18
25
  };
19
26
  eval: {
20
27
  defaultThreshold: number;
@@ -120,3 +120,15 @@ export interface MomentQueryResult {
120
120
  limit: number;
121
121
  offset: number;
122
122
  }
123
+ export interface RankedFailure extends DecisionMoment {
124
+ /** Severity × recency-decay blend, 0-1. Higher = shown first. */
125
+ rankScore: number;
126
+ }
127
+ export interface FailureQueryResult {
128
+ failures: RankedFailure[];
129
+ /** How many recent traces were scanned to build the list. */
130
+ scanned: number;
131
+ /** Total traces matching the filter (pre-scan-cap). */
132
+ total: number;
133
+ limit: number;
134
+ }
@@ -4,6 +4,18 @@ export interface EvalRule {
4
4
  description: string;
5
5
  evalType: EvalType;
6
6
  weight: number;
7
+ /**
8
+ * Hard-fail marker. When a critical rule FAILS (and was not skipped), the
9
+ * overall eval reports passed=false regardless of the weighted score.
10
+ *
11
+ * Exists because the weighted average routinely outvotes a genuine
12
+ * violation: an output leaking a real SSN failed no_pii while the other
13
+ * safety rules passed, scoring ~0.765 — above the 0.7 threshold — so the
14
+ * one field every CI gate reads said passed:true about the product's
15
+ * flagship failure scenario. The score stays a quality gradient; `passed`
16
+ * is the verdict, and a critical violation must never be averaged away.
17
+ */
18
+ critical?: boolean;
7
19
  evaluate(context: EvalContext): EvalRuleResult;
8
20
  }
9
21
  export interface EvalContext {
@@ -23,6 +35,17 @@ export interface EvalContext {
23
35
  costUsd?: number;
24
36
  metadata?: Record<string, unknown>;
25
37
  customConfig?: Record<string, unknown>;
38
+ /**
39
+ * Per-evaluation regex circuit breaker, initialized by the engine (never
40
+ * by callers). Each sandbox budget breach increments `breaches`; once it
41
+ * reaches the cap, remaining regex rules in the SAME evaluation skip
42
+ * without running. Bounds how long a single hostile output can stall a
43
+ * request: without it, N regex rules × (budget + worker respawn) of
44
+ * main-thread stall scale linearly with N.
45
+ */
46
+ regexBudget?: {
47
+ breaches: number;
48
+ };
26
49
  }
27
50
  export interface EvalRuleResult {
28
51
  ruleName: string;
@@ -31,6 +54,8 @@ export interface EvalRuleResult {
31
54
  message: string;
32
55
  skipped?: boolean;
33
56
  skipReason?: string;
57
+ configInvalid?: boolean;
58
+ budgetExceeded?: boolean;
34
59
  }
35
60
  export interface EvalResult {
36
61
  id: string;
@@ -46,6 +71,13 @@ export interface EvalResult {
46
71
  rules_evaluated?: number;
47
72
  rules_skipped?: number;
48
73
  insufficient_data?: boolean;
74
+ /**
75
+ * Names of critical rules that failed (present only when non-empty).
76
+ * Any entry here forces passed=false regardless of the weighted score —
77
+ * this field is how a caller tells "failed the quality bar" apart from
78
+ * "committed a hard violation".
79
+ */
80
+ critical_failures?: string[];
49
81
  }
50
82
  export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
51
83
  export interface CustomRuleDefinition {
@@ -23,7 +23,7 @@ export interface TraceQueryResult {
23
23
  limit: number;
24
24
  offset: number;
25
25
  }
26
- export type EvalStatsPeriod = '24h' | '7d' | '30d' | 'all';
26
+ export type EvalStatsPeriod = '24h' | '2d' | '7d' | '14d' | '30d' | '60d' | '90d' | '180d' | 'all';
27
27
  export interface EvalStats {
28
28
  passRate: number;
29
29
  avgScore: number;
@@ -0,0 +1 @@
1
+ export declare function irisHome(): string;
@@ -0,0 +1,21 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ /*
4
+ * Single resolver for the iris home directory (default: ~/.iris).
5
+ *
6
+ * Every per-user file iris touches lives under this directory — the
7
+ * SQLite DB default, config.json, custom-rules.json, audit.log,
8
+ * preferences.json. Before this helper each module joined
9
+ * homedir() + '.iris' itself, which meant there was no way to point a
10
+ * spawned server at a scratch directory: the E2E suite isolated the DB
11
+ * via IRIS_DB_PATH but still wiped the real audit.log, deployed test
12
+ * rules into the real custom-rules.json, and overwrote the real
13
+ * preferences.json on every run.
14
+ *
15
+ * IRIS_HOME redirects all of them at once. Read at call time — not
16
+ * module load — so a test harness that sets the env var before
17
+ * spawning (or between in-process calls) always wins.
18
+ */
19
+ export function irisHome() {
20
+ return process.env.IRIS_HOME ?? join(homedir(), '.iris');
21
+ }