@iris-eval/mcp-server 0.4.4 → 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.
- package/README.md +55 -1
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/defaults.d.ts +1 -0
- package/dist/config/defaults.js +6 -3
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +14 -5
- package/dist/custom-rule-store.js +190 -35
- package/dist/dashboard/assets/index-ChcHJDDJ.js +10 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/rules.js +1 -1
- package/dist/dashboard/server.js +60 -5
- package/dist/dashboard/validation.d.ts +36 -62
- package/dist/eval/citation-verify/resolve.js +101 -15
- package/dist/eval/engine.js +25 -10
- package/dist/eval/rules/config-keys.d.ts +14 -0
- package/dist/eval/rules/config-keys.js +43 -0
- package/dist/eval/rules/custom.js +43 -14
- package/dist/eval/rules/regex-budget.d.ts +5 -0
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/relevance.d.ts +1 -0
- package/dist/eval/rules/relevance.js +3 -1
- package/dist/eval/rules/safety.d.ts +5 -0
- package/dist/eval/rules/safety.js +43 -5
- package/dist/index.js +13 -2
- package/dist/middleware/error-handler.js +19 -1
- package/dist/middleware/rebinding-guard.d.ts +21 -0
- package/dist/middleware/rebinding-guard.js +77 -0
- package/dist/otel/mapper.js +2 -1
- package/dist/preferences.d.ts +43 -93
- package/dist/preferences.js +5 -10
- package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
- package/dist/storage/migrations/005-normalize-created-at.js +34 -0
- package/dist/storage/migrations/index.js +8 -1
- package/dist/storage/sqlite-adapter.js +28 -4
- package/dist/tools/deploy-rule.js +2 -2
- package/dist/tools/evaluate-output.js +1 -1
- package/dist/tools/log-trace.js +3 -3
- package/dist/transport/http.js +68 -4
- package/dist/types/config.d.ts +7 -0
- package/dist/utils/iris-home.d.ts +1 -0
- package/dist/utils/iris-home.js +21 -0
- package/dist/utils/write-atomic.d.ts +1 -0
- package/dist/utils/write-atomic.js +64 -0
- package/package.json +10 -5
- package/server.json +2 -2
- package/dist/dashboard/assets/index-DNflCqmJ.js +0 -12
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Iris evaluates all of it.
|
|
|
41
41
|
|
|
42
42
|
## Quickstart
|
|
43
43
|
|
|
44
|
-
Add Iris to your MCP config. Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
|
|
44
|
+
Add Iris to your MCP config. Works with Claude Desktop, Claude Code, Cursor, Windsurf, Continue, VS Code, Cline, Zed, Codex CLI, Gemini CLI — and any other MCP-compatible agent.
|
|
45
45
|
|
|
46
46
|
```json
|
|
47
47
|
{
|
|
@@ -102,6 +102,60 @@ Then restart the session (`/clear` or relaunch) for tools to load.
|
|
|
102
102
|
|
|
103
103
|
Add to your workspace `.cursor/mcp.json` or global MCP settings using the JSON config above.
|
|
104
104
|
|
|
105
|
+
#### VS Code (native MCP)
|
|
106
|
+
|
|
107
|
+
Add to `.vscode/mcp.json` in your workspace (note: VS Code uses `servers`, not `mcpServers`):
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"servers": {
|
|
112
|
+
"iris-eval": {
|
|
113
|
+
"command": "npx",
|
|
114
|
+
"args": ["@iris-eval/mcp-server"]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Cline
|
|
121
|
+
|
|
122
|
+
Open Cline's MCP Servers panel → Configure MCP Servers, and add the `mcpServers` JSON config above to `cline_mcp_settings.json`.
|
|
123
|
+
|
|
124
|
+
#### Zed
|
|
125
|
+
|
|
126
|
+
Add to Zed `settings.json`:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"context_servers": {
|
|
131
|
+
"iris-eval": {
|
|
132
|
+
"command": {
|
|
133
|
+
"path": "npx",
|
|
134
|
+
"args": ["@iris-eval/mcp-server"]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### OpenAI Codex CLI
|
|
142
|
+
|
|
143
|
+
Add to `~/.codex/config.toml`:
|
|
144
|
+
|
|
145
|
+
```toml
|
|
146
|
+
[mcp_servers.iris-eval]
|
|
147
|
+
command = "npx"
|
|
148
|
+
args = ["@iris-eval/mcp-server"]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### Gemini CLI
|
|
152
|
+
|
|
153
|
+
Add the `mcpServers` JSON config above to `~/.gemini/settings.json`.
|
|
154
|
+
|
|
155
|
+
#### Anything else that speaks MCP
|
|
156
|
+
|
|
157
|
+
Iris is a standard stdio MCP server — one `npx @iris-eval/mcp-server` command, no SDK, no code changes. If your client supports MCP, it supports Iris. Client config formats change; when in doubt, check your client's MCP docs and point it at that command.
|
|
158
|
+
|
|
105
159
|
</details>
|
|
106
160
|
|
|
107
161
|
### Other Install Methods
|
package/dist/audit-log-reader.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import {
|
|
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({
|
|
@@ -28,10 +28,10 @@ const EntrySchema = z.object({
|
|
|
28
28
|
user: z.string(),
|
|
29
29
|
ruleId: z.string(),
|
|
30
30
|
ruleName: z.string().optional(),
|
|
31
|
-
details: z.record(z.unknown()).optional(),
|
|
31
|
+
details: z.record(z.string(), z.unknown()).optional(),
|
|
32
32
|
});
|
|
33
33
|
function defaultAuditPath() {
|
|
34
|
-
return join(
|
|
34
|
+
return join(irisHome(), 'audit.log');
|
|
35
35
|
}
|
|
36
36
|
export function readAuditLog(opts) {
|
|
37
37
|
const filePath = opts?.filePath ?? defaultAuditPath();
|
package/dist/config/defaults.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
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 {
|
|
@@ -11,10 +10,13 @@ try {
|
|
|
11
10
|
catch {
|
|
12
11
|
// Fallback if package.json isn't resolvable at runtime
|
|
13
12
|
}
|
|
13
|
+
// The single runtime source for the server's own version — import this
|
|
14
|
+
// instead of hardcoding a literal (OTel resource attrs, health, banners).
|
|
15
|
+
export const PKG_VERSION = pkgVersion;
|
|
14
16
|
export const defaultConfig = {
|
|
15
17
|
storage: {
|
|
16
18
|
type: 'sqlite',
|
|
17
|
-
path: join(irisHome, 'iris.db'),
|
|
19
|
+
path: join(irisHome(), 'iris.db'),
|
|
18
20
|
},
|
|
19
21
|
server: {
|
|
20
22
|
name: 'iris-eval',
|
|
@@ -28,6 +30,7 @@ export const defaultConfig = {
|
|
|
28
30
|
dashboard: {
|
|
29
31
|
enabled: false,
|
|
30
32
|
port: 6920,
|
|
33
|
+
host: '127.0.0.1',
|
|
31
34
|
},
|
|
32
35
|
eval: {
|
|
33
36
|
defaultThreshold: 0.7,
|
package/dist/config/index.d.ts
CHANGED
package/dist/config/index.js
CHANGED
|
@@ -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
|
|
109
|
-
if (!existsSync(
|
|
110
|
-
mkdirSync(
|
|
117
|
+
const home = irisHome();
|
|
118
|
+
if (!existsSync(home)) {
|
|
119
|
+
mkdirSync(home, { recursive: true });
|
|
111
120
|
}
|
|
112
|
-
const configPath = cliArgs?.config ?? join(
|
|
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,11 +23,15 @@
|
|
|
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,
|
|
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';
|
|
32
|
+
import isSafeRegex from 'safe-regex2';
|
|
33
|
+
import { regexBacktrackingBudgetExceeded } from './eval/rules/regex-budget.js';
|
|
34
|
+
import { CUSTOM_RULE_CONFIG_KEYS, readNumericConfig, describeKeys } from './eval/rules/config-keys.js';
|
|
31
35
|
import { LOCAL_TENANT } from './types/tenant.js';
|
|
32
36
|
const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
|
|
33
37
|
const EVAL_TYPE_VALUES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
|
|
@@ -41,11 +45,131 @@ const RULE_TYPE_VALUES = [
|
|
|
41
45
|
'json_schema',
|
|
42
46
|
'cost_threshold',
|
|
43
47
|
];
|
|
44
|
-
|
|
48
|
+
// Per-type config requirements, enforced at DEPLOY time.
|
|
49
|
+
//
|
|
50
|
+
// `config` was previously `z.record(z.unknown())` — any object passed. That
|
|
51
|
+
// let a rule like {type:'min_length', config:{}} deploy successfully and then
|
|
52
|
+
// fail on every single evaluation forever, silently dragging down aggregate
|
|
53
|
+
// scores with no indication the RULE (not the agent) was broken. Validating
|
|
54
|
+
// here means the failure surfaces once, at deploy, with an actionable message
|
|
55
|
+
// — instead of quietly corrupting every eval that follows.
|
|
56
|
+
const MAX_RULE_PATTERN_LENGTH = 1000;
|
|
57
|
+
function requirePositiveNumber(config, type, ctx) {
|
|
58
|
+
const value = readNumericConfig(config, type);
|
|
59
|
+
if (value == null || value <= 0) {
|
|
60
|
+
ctx.addIssue({
|
|
61
|
+
code: z.ZodIssueCode.custom,
|
|
62
|
+
path: ['config', CUSTOM_RULE_CONFIG_KEYS[type][0]],
|
|
63
|
+
message: `${type} rule requires ${describeKeys(type)} (positive number)`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function requireNonEmptyStringArray(config, key, ctx, hint) {
|
|
68
|
+
const value = config[key];
|
|
69
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every((v) => typeof v === 'string')) {
|
|
70
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['config', key], message: hint });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const DefinitionSchema = z
|
|
74
|
+
.object({
|
|
45
75
|
name: z.string().min(1).max(80),
|
|
46
76
|
type: z.enum(RULE_TYPE_VALUES),
|
|
47
|
-
config: z.record(z.unknown()),
|
|
77
|
+
config: z.record(z.string(), z.unknown()),
|
|
48
78
|
weight: z.number().positive().optional(),
|
|
79
|
+
})
|
|
80
|
+
.superRefine((def, ctx) => {
|
|
81
|
+
const config = def.config ?? {};
|
|
82
|
+
switch (def.type) {
|
|
83
|
+
case 'regex_match':
|
|
84
|
+
case 'regex_no_match': {
|
|
85
|
+
const pattern = config.pattern;
|
|
86
|
+
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
87
|
+
ctx.addIssue({
|
|
88
|
+
code: z.ZodIssueCode.custom,
|
|
89
|
+
path: ['config', 'pattern'],
|
|
90
|
+
message: `${def.type} rule requires config.pattern (non-empty string)`,
|
|
91
|
+
});
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (pattern.length > MAX_RULE_PATTERN_LENGTH) {
|
|
95
|
+
ctx.addIssue({
|
|
96
|
+
code: z.ZodIssueCode.custom,
|
|
97
|
+
path: ['config', 'pattern'],
|
|
98
|
+
message: `Regex pattern too long (${pattern.length} > ${MAX_RULE_PATTERN_LENGTH})`,
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
// Strip a leading inline flag group the way the evaluator does, so a
|
|
103
|
+
// pattern that WILL run is not rejected here for syntax it tolerates.
|
|
104
|
+
const stripped = pattern.replace(/^\(\?[imsugy]+\)/, '');
|
|
105
|
+
// Syntax BEFORE safety: safe-regex2 returns false for anything it
|
|
106
|
+
// cannot parse, so checking it first reports a plainly broken pattern
|
|
107
|
+
// like `(` as "catastrophic backtracking" — an error that sends the
|
|
108
|
+
// author looking for a performance problem they do not have.
|
|
109
|
+
try {
|
|
110
|
+
new RegExp(stripped, typeof config.flags === 'string' ? config.flags : '');
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: z.ZodIssueCode.custom,
|
|
115
|
+
path: ['config', 'pattern'],
|
|
116
|
+
message: `Invalid regex syntax: ${e instanceof Error ? e.message : 'unknown error'}`,
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (!isSafeRegex(stripped)) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: z.ZodIssueCode.custom,
|
|
123
|
+
path: ['config', 'pattern'],
|
|
124
|
+
message: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
|
|
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
|
+
}
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'min_length':
|
|
145
|
+
requirePositiveNumber(config, 'min_length', ctx);
|
|
146
|
+
break;
|
|
147
|
+
case 'max_length':
|
|
148
|
+
requirePositiveNumber(config, 'max_length', ctx);
|
|
149
|
+
break;
|
|
150
|
+
case 'contains_keywords':
|
|
151
|
+
requireNonEmptyStringArray(config, 'keywords', ctx, 'contains_keywords rule requires config.keywords (non-empty string array)');
|
|
152
|
+
break;
|
|
153
|
+
case 'excludes_keywords':
|
|
154
|
+
requireNonEmptyStringArray(config, 'keywords', ctx, 'excludes_keywords rule requires config.keywords (non-empty string array)');
|
|
155
|
+
break;
|
|
156
|
+
case 'cost_threshold': {
|
|
157
|
+
// 0 is a legitimate threshold ("must be free"), so only reject
|
|
158
|
+
// missing / non-numeric / negative.
|
|
159
|
+
const max = readNumericConfig(config, 'cost_threshold');
|
|
160
|
+
if (max == null || max < 0) {
|
|
161
|
+
ctx.addIssue({
|
|
162
|
+
code: z.ZodIssueCode.custom,
|
|
163
|
+
path: ['config', CUSTOM_RULE_CONFIG_KEYS.cost_threshold[0]],
|
|
164
|
+
message: `cost_threshold rule requires ${describeKeys('cost_threshold')} (non-negative number)`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'json_schema':
|
|
170
|
+
// No required config — validity is judged against the output itself.
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
49
173
|
});
|
|
50
174
|
const DeployedRuleSchema = z.object({
|
|
51
175
|
id: z.string().min(1),
|
|
@@ -60,26 +184,22 @@ const DeployedRuleSchema = z.object({
|
|
|
60
184
|
sourceMomentId: z.string().optional(),
|
|
61
185
|
version: z.number().int().positive(),
|
|
62
186
|
});
|
|
63
|
-
const FileSchema = z.object({
|
|
64
|
-
version: z.literal(1),
|
|
65
|
-
rules: z.array(DeployedRuleSchema),
|
|
66
|
-
});
|
|
67
187
|
/**
|
|
68
188
|
* Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path
|
|
69
189
|
* (zero migration); others get a per-tenant suffix.
|
|
70
190
|
*/
|
|
71
191
|
function defaultPathFor(tenantId) {
|
|
72
192
|
if (tenantId === LOCAL_TENANT) {
|
|
73
|
-
return join(
|
|
193
|
+
return join(irisHome(), 'custom-rules.json');
|
|
74
194
|
}
|
|
75
195
|
// Sanitize tenant id for filesystem safety. TenantId is branded but
|
|
76
196
|
// could in principle contain odd chars on Cloud — limit to a known-safe
|
|
77
197
|
// alphabet so we never write outside the .iris directory.
|
|
78
198
|
const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
79
|
-
return join(
|
|
199
|
+
return join(irisHome(), `custom-rules-${safe}.json`);
|
|
80
200
|
}
|
|
81
201
|
function defaultAuditPath() {
|
|
82
|
-
return join(
|
|
202
|
+
return join(irisHome(), 'audit.log');
|
|
83
203
|
}
|
|
84
204
|
function generateRuleId() {
|
|
85
205
|
return `rule-${randomBytes(4).toString('hex')}`;
|
|
@@ -94,45 +214,80 @@ function appendAudit(auditPath, entry) {
|
|
|
94
214
|
// still succeeds; the operator just loses the audit trail.
|
|
95
215
|
}
|
|
96
216
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
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
|
+
*/
|
|
103
232
|
function loadRulesFromDisk(rulesPath) {
|
|
104
233
|
if (!existsSync(rulesPath))
|
|
105
|
-
return [];
|
|
234
|
+
return { rules: [], quarantined: [], readable: true };
|
|
235
|
+
let parsedJson;
|
|
106
236
|
try {
|
|
107
|
-
|
|
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 [];
|
|
237
|
+
parsedJson = JSON.parse(readFileSync(rulesPath, 'utf-8'));
|
|
113
238
|
}
|
|
114
239
|
catch {
|
|
115
|
-
|
|
116
|
-
|
|
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);
|
|
117
253
|
}
|
|
254
|
+
return { rules, quarantined, readable: true };
|
|
118
255
|
}
|
|
119
256
|
export function createCustomRuleStore(opts) {
|
|
120
257
|
const pathFor = opts?.pathFor ?? defaultPathFor;
|
|
121
258
|
const auditPath = opts?.auditPath ?? defaultAuditPath();
|
|
122
259
|
// In-memory cache keyed by tenant. Lazy-loaded on first access per
|
|
123
260
|
// tenant; subsequent calls hit the cache.
|
|
124
|
-
const
|
|
125
|
-
function
|
|
126
|
-
let
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
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);
|
|
130
267
|
}
|
|
131
|
-
return
|
|
268
|
+
return loaded;
|
|
269
|
+
}
|
|
270
|
+
function load(tenantId) {
|
|
271
|
+
return state(tenantId).rules;
|
|
132
272
|
}
|
|
133
273
|
function persist(tenantId) {
|
|
134
|
-
const
|
|
135
|
-
|
|
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
|
+
};
|
|
136
291
|
writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));
|
|
137
292
|
}
|
|
138
293
|
return {
|