@iris-eval/mcp-server 0.4.0 → 0.4.2

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 CHANGED
@@ -126,8 +126,8 @@ Iris registers nine tools that any MCP-compatible agent can invoke — full rule
126
126
  - **`deploy_rule`** — Register a new custom eval rule so it fires on every `evaluate_output` of that category
127
127
  - **`delete_rule`** — Remove a deployed custom rule (destructive, idempotent)
128
128
  - **`delete_trace`** — Remove a single stored trace by ID (destructive, tenant-scoped)
129
- - **`evaluate_with_llm_judge`** — Semantic eval via LLM (Anthropic or OpenAI). Five templates: accuracy, helpfulness, safety, correctness, faithfulness. Cost-capped, per-eval pricing disclosed.
130
- - **`verify_citations`** — Extract citations from output (numbered, author-year, URLs, DOIs), fetch sources behind an SSRF-guarded + domain-allowlisted resolver, and use an LLM judge to check whether each source actually supports the cited claim. Opt-in outbound HTTP.
129
+ - **`evaluate_with_llm_judge`** — Semantic eval via LLM (Anthropic or OpenAI). Five templates: accuracy, helpfulness, safety, correctness, faithfulness. Cost-capped, per-eval pricing disclosed. **Bring your own API key** (`IRIS_ANTHROPIC_API_KEY` or `IRIS_OPENAI_API_KEY`) — Iris doesn't proxy or relay LLM calls.
130
+ - **`verify_citations`** — Extract citations from output (numbered, author-year, URLs, DOIs), fetch sources behind an SSRF-guarded + domain-allowlisted resolver, and use an LLM judge to check whether each source actually supports the cited claim. Opt-in outbound HTTP. Same BYOK requirement as `evaluate_with_llm_judge`.
131
131
 
132
132
  When `IRIS_OTEL_ENDPOINT` is configured, `log_trace` calls also emit a best-effort OTLP/HTTP JSON export to any OpenTelemetry collector (Jaeger, Grafana Tempo, Datadog OTLP, Honeycomb, etc). See [docs/otel-integration.md](docs/otel-integration.md).
133
133
 
@@ -1,15 +1,16 @@
1
1
  import type { DeployedCustomRule, RuleSeverity } from './types/custom-rule.js';
2
2
  import type { CustomRuleDefinition, EvalType } from './types/eval.js';
3
+ import { type TenantId } from './types/tenant.js';
3
4
  export interface CustomRuleStore {
4
- list(): DeployedCustomRule[];
5
- get(id: string): DeployedCustomRule | undefined;
6
- deploy(input: DeployRuleInput): DeployedCustomRule;
7
- delete(id: string, user?: string): boolean;
8
- setEnabled(id: string, enabled: boolean, user?: string): DeployedCustomRule | undefined;
9
- /** All ENABLED rules in deploy order — what the engine should register. */
10
- enabledRules(): DeployedCustomRule[];
11
- /** Path on disk for diagnostics. */
12
- filePath: string;
5
+ list(tenantId: TenantId): DeployedCustomRule[];
6
+ get(tenantId: TenantId, id: string): DeployedCustomRule | undefined;
7
+ deploy(tenantId: TenantId, input: DeployRuleInput): DeployedCustomRule;
8
+ delete(tenantId: TenantId, id: string, user?: string): boolean;
9
+ setEnabled(tenantId: TenantId, id: string, enabled: boolean, user?: string): DeployedCustomRule | undefined;
10
+ /** All ENABLED rules for a tenant in deploy order — what the engine should register. */
11
+ enabledRules(tenantId: TenantId): DeployedCustomRule[];
12
+ /** Path on disk for diagnostics. Different per tenant. */
13
+ pathFor(tenantId: TenantId): string;
13
14
  auditPath: string;
14
15
  }
15
16
  export interface DeployRuleInput {
@@ -22,6 +23,13 @@ export interface DeployRuleInput {
22
23
  user?: string;
23
24
  }
24
25
  export declare function createCustomRuleStore(opts?: {
25
- rulesPath?: string;
26
+ /**
27
+ * Returns the file path for a tenant's rules. Defaults to
28
+ * `~/.iris/custom-rules.json` for LOCAL_TENANT (zero migration for OSS)
29
+ * and `~/.iris/custom-rules-<sanitized-tenantId>.json` for others.
30
+ * Cloud orchestrators can inject their own factory to e.g. write into
31
+ * a per-tenant data dir.
32
+ */
33
+ pathFor?: (tenantId: TenantId) => string;
26
34
  auditPath?: string;
27
35
  }): CustomRuleStore;
@@ -1,20 +1,34 @@
1
1
  /*
2
2
  * custom-rule-store — file-based persistence for deployed custom rules.
3
3
  *
4
- * Lives at ~/.iris/custom-rules.json with the schema in
5
- * src/types/custom-rule.ts. Audit log lives at ~/.iris/audit.log
6
- * (append-only JSONL). Both files are created on first write.
4
+ * Per-tenant file partition: each tenant's rules live in their own
5
+ * file. OSS single-tenant installs continue to use
6
+ * ~/.iris/custom-rules.json (the LOCAL_TENANT path) zero migration
7
+ * for existing users. Cloud tenants get
8
+ * ~/.iris/custom-rules-<tenantId>.json (or whatever path the
9
+ * `pathFor` factory returns).
10
+ *
11
+ * Why per-file rather than top-level keys in one file or a tenant
12
+ * column on each rule:
13
+ * - Zero migration: LOCAL_TENANT keeps the v0.4 file path/schema.
14
+ * - Smallest blast radius for a corrupt write: one tenant's data
15
+ * can't poison another's.
16
+ * - Mirrors the existing audit-log per-file convention.
17
+ *
18
+ * Audit log stays SHARED across tenants: every entry already carries
19
+ * `tenantId` so readers can scope at query time.
7
20
  *
8
21
  * The v0.4 cut is single-user local. Concurrent writes from multiple
9
- * iris-mcp instances are not protected that's a v0.5 concern when
10
- * multi-tenancy lands. For now we use atomic write-via-rename so a
11
- * crashed write doesn't leave a half-file.
22
+ * iris-mcp instances against the same tenant file are not protected.
23
+ * For now we use atomic write-via-rename so a crashed write doesn't
24
+ * leave a half-file.
12
25
  */
13
26
  import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, appendFileSync } from 'node:fs';
14
27
  import { join, dirname } from 'node:path';
15
28
  import { homedir } from 'node:os';
16
29
  import { randomBytes } from 'node:crypto';
17
30
  import { z } from 'zod';
31
+ import { LOCAL_TENANT } from './types/tenant.js';
18
32
  const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
19
33
  const EVAL_TYPE_VALUES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
20
34
  const RULE_TYPE_VALUES = [
@@ -50,8 +64,19 @@ const FileSchema = z.object({
50
64
  version: z.literal(1),
51
65
  rules: z.array(DeployedRuleSchema),
52
66
  });
53
- function defaultRulesPath() {
54
- return join(homedir(), '.iris', 'custom-rules.json');
67
+ /**
68
+ * Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path
69
+ * (zero migration); others get a per-tenant suffix.
70
+ */
71
+ function defaultPathFor(tenantId) {
72
+ if (tenantId === LOCAL_TENANT) {
73
+ return join(homedir(), '.iris', 'custom-rules.json');
74
+ }
75
+ // Sanitize tenant id for filesystem safety. TenantId is branded but
76
+ // could in principle contain odd chars on Cloud — limit to a known-safe
77
+ // alphabet so we never write outside the .iris directory.
78
+ const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
79
+ return join(homedir(), '.iris', `custom-rules-${safe}.json`);
55
80
  }
56
81
  function defaultAuditPath() {
57
82
  return join(homedir(), '.iris', 'audit.log');
@@ -75,42 +100,54 @@ function writeAtomic(targetPath, contents) {
75
100
  writeFileSync(tmp, contents, 'utf-8');
76
101
  renameSync(tmp, targetPath);
77
102
  }
103
+ function loadRulesFromDisk(rulesPath) {
104
+ if (!existsSync(rulesPath))
105
+ return [];
106
+ try {
107
+ const raw = readFileSync(rulesPath, 'utf-8');
108
+ const parsed = FileSchema.safeParse(JSON.parse(raw));
109
+ if (parsed.success)
110
+ return parsed.data.rules;
111
+ // Malformed: leave rules empty; do NOT overwrite the file.
112
+ return [];
113
+ }
114
+ catch {
115
+ // Unreadable: leave rules empty.
116
+ return [];
117
+ }
118
+ }
78
119
  export function createCustomRuleStore(opts) {
79
- const rulesPath = opts?.rulesPath ?? defaultRulesPath();
120
+ const pathFor = opts?.pathFor ?? defaultPathFor;
80
121
  const auditPath = opts?.auditPath ?? defaultAuditPath();
81
- // In-memory copy that mirrors the file. Writes update both.
82
- let rules = [];
83
- // Initial load
84
- if (existsSync(rulesPath)) {
85
- try {
86
- const raw = readFileSync(rulesPath, 'utf-8');
87
- const parsed = FileSchema.safeParse(JSON.parse(raw));
88
- if (parsed.success) {
89
- rules = parsed.data.rules;
90
- }
91
- // Malformed: leave rules empty; do NOT overwrite the file.
92
- }
93
- catch {
94
- // Unreadable: leave rules empty.
122
+ // In-memory cache keyed by tenant. Lazy-loaded on first access per
123
+ // tenant; subsequent calls hit the cache.
124
+ const tenantRules = new Map();
125
+ function load(tenantId) {
126
+ let rules = tenantRules.get(tenantId);
127
+ if (rules === undefined) {
128
+ rules = loadRulesFromDisk(pathFor(tenantId));
129
+ tenantRules.set(tenantId, rules);
95
130
  }
131
+ return rules;
96
132
  }
97
- function persist() {
133
+ function persist(tenantId) {
134
+ const rules = tenantRules.get(tenantId) ?? [];
98
135
  const file = { version: 1, rules };
99
- writeAtomic(rulesPath, JSON.stringify(file, null, 2));
136
+ writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));
100
137
  }
101
138
  return {
102
- filePath: rulesPath,
103
139
  auditPath,
104
- list() {
105
- return [...rules];
140
+ pathFor,
141
+ list(tenantId) {
142
+ return [...load(tenantId)];
106
143
  },
107
- get(id) {
108
- return rules.find((r) => r.id === id);
144
+ get(tenantId, id) {
145
+ return load(tenantId).find((r) => r.id === id);
109
146
  },
110
- enabledRules() {
111
- return rules.filter((r) => r.enabled);
147
+ enabledRules(tenantId) {
148
+ return load(tenantId).filter((r) => r.enabled);
112
149
  },
113
- deploy(input) {
150
+ deploy(tenantId, input) {
114
151
  const now = new Date().toISOString();
115
152
  const id = generateRuleId();
116
153
  const rule = {
@@ -128,15 +165,12 @@ export function createCustomRuleStore(opts) {
128
165
  };
129
166
  // Validate before persisting.
130
167
  const validated = DeployedRuleSchema.parse(rule);
168
+ const rules = load(tenantId);
131
169
  rules.push(validated);
132
- persist();
133
- /* Tenant scoping: OSS single-tenant emits 'local'. Cloud replaces
134
- * the entire custom-rule-store with a DB-backed service that reads
135
- * the tenant from the authenticated session; that service will
136
- * compute tenantId per-call. Hard-coded here for OSS. */
170
+ persist(tenantId);
137
171
  appendAudit(auditPath, {
138
172
  ts: now,
139
- tenantId: 'local',
173
+ tenantId,
140
174
  action: 'rule.deploy',
141
175
  user: input.user ?? 'local',
142
176
  ruleId: id,
@@ -147,16 +181,17 @@ export function createCustomRuleStore(opts) {
147
181
  });
148
182
  return validated;
149
183
  },
150
- delete(id, user = 'local') {
184
+ delete(tenantId, id, user = 'local') {
185
+ const rules = load(tenantId);
151
186
  const idx = rules.findIndex((r) => r.id === id);
152
187
  if (idx === -1)
153
188
  return false;
154
189
  const removed = rules[idx];
155
190
  rules.splice(idx, 1);
156
- persist();
191
+ persist(tenantId);
157
192
  appendAudit(auditPath, {
158
193
  ts: new Date().toISOString(),
159
- tenantId: 'local',
194
+ tenantId,
160
195
  action: 'rule.delete',
161
196
  user,
162
197
  ruleId: id,
@@ -164,7 +199,8 @@ export function createCustomRuleStore(opts) {
164
199
  });
165
200
  return true;
166
201
  },
167
- setEnabled(id, enabled, user = 'local') {
202
+ setEnabled(tenantId, id, enabled, user = 'local') {
203
+ const rules = load(tenantId);
168
204
  const rule = rules.find((r) => r.id === id);
169
205
  if (!rule)
170
206
  return undefined;
@@ -172,10 +208,10 @@ export function createCustomRuleStore(opts) {
172
208
  return rule;
173
209
  rule.enabled = enabled;
174
210
  rule.updatedAt = new Date().toISOString();
175
- persist();
211
+ persist(tenantId);
176
212
  appendAudit(auditPath, {
177
213
  ts: rule.updatedAt,
178
- tenantId: 'local',
214
+ tenantId,
179
215
  action: 'rule.toggle',
180
216
  user,
181
217
  ruleId: id,
@@ -1 +1 @@
1
- @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500..700&family=Manrope:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap";:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01"}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}
1
+ @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500..700&family=Manrope:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap";:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01"}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}.iris-sr-reveal{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.iris-sr-reveal:focus-within{width:auto;height:auto;margin:var(--space-3) 0 0 0;padding:var(--space-3) var(--space-4);clip:auto;white-space:normal;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--iris-500);border-radius:var(--radius-sm);position:static;overflow:visible}.iris-sr-reveal:focus-within>li{padding:var(--space-1) 0;list-style:none}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}