@iris-eval/mcp-server 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/audit-log-reader.d.ts +24 -0
- package/dist/audit-log-reader.js +87 -0
- package/dist/config/defaults.js +7 -1
- package/dist/custom-rule-store.d.ts +27 -0
- package/dist/custom-rule-store.js +188 -0
- package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
- package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/audit.d.ts +3 -0
- package/dist/dashboard/routes/audit.js +36 -0
- package/dist/dashboard/routes/eval-stats.js +9 -4
- package/dist/dashboard/routes/evaluations.js +3 -1
- package/dist/dashboard/routes/filters.js +5 -3
- package/dist/dashboard/routes/health.js +8 -1
- package/dist/dashboard/routes/index.d.ts +4 -0
- package/dist/dashboard/routes/index.js +4 -0
- package/dist/dashboard/routes/moments.d.ts +3 -0
- package/dist/dashboard/routes/moments.js +115 -0
- package/dist/dashboard/routes/preferences.d.ts +3 -0
- package/dist/dashboard/routes/preferences.js +52 -0
- package/dist/dashboard/routes/rules.d.ts +10 -0
- package/dist/dashboard/routes/rules.js +169 -0
- package/dist/dashboard/routes/summary.js +3 -1
- package/dist/dashboard/routes/traces.js +7 -4
- package/dist/dashboard/server.d.ts +9 -1
- package/dist/dashboard/server.js +52 -3
- package/dist/eval/citation-verify/extract.d.ts +11 -0
- package/dist/eval/citation-verify/extract.js +102 -0
- package/dist/eval/citation-verify/resolve.d.ts +26 -0
- package/dist/eval/citation-verify/resolve.js +237 -0
- package/dist/eval/citation-verify/verifier.d.ts +43 -0
- package/dist/eval/citation-verify/verifier.js +203 -0
- package/dist/eval/decision-moment.d.ts +12 -0
- package/dist/eval/decision-moment.js +181 -0
- package/dist/eval/llm-judge/client.d.ts +28 -0
- package/dist/eval/llm-judge/client.js +183 -0
- package/dist/eval/llm-judge/evaluator.d.ts +32 -0
- package/dist/eval/llm-judge/evaluator.js +138 -0
- package/dist/eval/llm-judge/pricing.d.ts +9 -0
- package/dist/eval/llm-judge/pricing.js +31 -0
- package/dist/eval/llm-judge/templates/index.d.ts +20 -0
- package/dist/eval/llm-judge/templates/index.js +170 -0
- package/dist/eval/rules/custom.js +13 -2
- package/dist/index.js +77 -14
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/tenant.d.ts +17 -0
- package/dist/middleware/tenant.js +26 -0
- package/dist/otel/exporter.d.ts +24 -0
- package/dist/otel/exporter.js +116 -0
- package/dist/otel/lazy.d.ts +5 -0
- package/dist/otel/lazy.js +31 -0
- package/dist/otel/mapper.d.ts +24 -0
- package/dist/otel/mapper.js +208 -0
- package/dist/preferences.d.ts +129 -0
- package/dist/preferences.js +152 -0
- package/dist/resources/dashboard-summary.js +3 -1
- package/dist/resources/trace-detail.js +5 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +9 -3
- package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
- package/dist/storage/migrations/004-tenant-id.js +40 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.d.ts +17 -15
- package/dist/storage/sqlite-adapter.js +130 -79
- package/dist/tools/delete-rule.d.ts +3 -0
- package/dist/tools/delete-rule.js +53 -0
- package/dist/tools/delete-trace.d.ts +3 -0
- package/dist/tools/delete-trace.js +54 -0
- package/dist/tools/deploy-rule.d.ts +3 -0
- package/dist/tools/deploy-rule.js +91 -0
- package/dist/tools/evaluate-output.js +23 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +147 -0
- package/dist/tools/get-traces.js +23 -3
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/list-rules.d.ts +3 -0
- package/dist/tools/list-rules.js +66 -0
- package/dist/tools/log-trace.js +30 -2
- package/dist/tools/verify-citations.d.ts +3 -0
- package/dist/tools/verify-citations.js +157 -0
- package/dist/types/custom-rule.d.ts +70 -0
- package/dist/types/custom-rule.js +1 -0
- package/dist/types/decision-moment.d.ts +122 -0
- package/dist/types/decision-moment.js +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -1
- package/dist/types/query.d.ts +25 -15
- package/dist/types/tenant.d.ts +26 -0
- package/dist/types/tenant.js +58 -0
- package/dist/utils/open-browser.d.ts +1 -0
- package/dist/utils/open-browser.js +45 -0
- package/dist/utils/validate-port-config.d.ts +2 -0
- package/dist/utils/validate-port-config.js +9 -0
- package/package.json +4 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
- package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
package/dist/dashboard/server.js
CHANGED
|
@@ -7,13 +7,18 @@ import { createAuthMiddleware } from '../middleware/auth.js';
|
|
|
7
7
|
import { createCorsMiddleware } from '../middleware/cors.js';
|
|
8
8
|
import { createErrorHandler } from '../middleware/error-handler.js';
|
|
9
9
|
import { createApiRateLimiter } from '../middleware/rate-limit.js';
|
|
10
|
+
import { createTenantMiddleware } from '../middleware/tenant.js';
|
|
10
11
|
import { registerTraceRoutes } from './routes/traces.js';
|
|
11
12
|
import { registerSummaryRoutes } from './routes/summary.js';
|
|
12
13
|
import { registerEvaluationRoutes } from './routes/evaluations.js';
|
|
13
14
|
import { registerFilterRoutes } from './routes/filters.js';
|
|
14
15
|
import { registerEvalStatsRoutes } from './routes/eval-stats.js';
|
|
15
16
|
import { registerHealthRoutes } from './routes/health.js';
|
|
16
|
-
|
|
17
|
+
import { registerMomentRoutes } from './routes/moments.js';
|
|
18
|
+
import { registerRuleRoutes } from './routes/rules.js';
|
|
19
|
+
import { registerPreferencesRoutes } from './routes/preferences.js';
|
|
20
|
+
import { registerAuditRoutes } from './routes/audit.js';
|
|
21
|
+
export function createDashboardServer(storage, config, logger, options) {
|
|
17
22
|
const app = express();
|
|
18
23
|
// Security headers
|
|
19
24
|
app.use(helmet({
|
|
@@ -21,7 +26,15 @@ export function createDashboardServer(storage, config, logger) {
|
|
|
21
26
|
directives: {
|
|
22
27
|
defaultSrc: ["'self'"],
|
|
23
28
|
scriptSrc: ["'self'"],
|
|
24
|
-
|
|
29
|
+
// 'self' covers our bundled CSS. fonts.googleapis.com hosts the
|
|
30
|
+
// brand fonts (Space Grotesk + Manrope + JetBrains Mono) loaded
|
|
31
|
+
// via @import in tokens.css. Without this, the @import gets
|
|
32
|
+
// blocked and the entire stylesheet is dropped by the browser.
|
|
33
|
+
// v0.4.1 will self-host these fonts and let us tighten this back
|
|
34
|
+
// to 'self' only.
|
|
35
|
+
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
|
36
|
+
// The fontFaces in those stylesheets resolve to fonts.gstatic.com.
|
|
37
|
+
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
25
38
|
connectSrc: ["'self'"],
|
|
26
39
|
},
|
|
27
40
|
},
|
|
@@ -32,6 +45,10 @@ export function createDashboardServer(storage, config, logger) {
|
|
|
32
45
|
app.use(createCorsMiddleware(config.security.allowedOrigins));
|
|
33
46
|
// Authentication
|
|
34
47
|
app.use(createAuthMiddleware(config));
|
|
48
|
+
// Tenant resolution — attaches req.tenantId to every request.
|
|
49
|
+
// OSS: always resolves to LOCAL_TENANT. Cloud: swaps for an auth-aware
|
|
50
|
+
// resolver that reads the authenticated session. See middleware/tenant.ts.
|
|
51
|
+
app.use(createTenantMiddleware());
|
|
35
52
|
// API routes with rate limiting
|
|
36
53
|
const router = express.Router();
|
|
37
54
|
router.use(createApiRateLimiter(config));
|
|
@@ -41,6 +58,19 @@ export function createDashboardServer(storage, config, logger) {
|
|
|
41
58
|
registerEvalStatsRoutes(router, storage);
|
|
42
59
|
registerFilterRoutes(router, storage);
|
|
43
60
|
registerHealthRoutes(router, storage, config.server.version);
|
|
61
|
+
registerMomentRoutes(router, storage);
|
|
62
|
+
if (options?.customRuleStore && options?.evalEngine) {
|
|
63
|
+
registerRuleRoutes(router, storage, {
|
|
64
|
+
customRuleStore: options.customRuleStore,
|
|
65
|
+
evalEngine: options.evalEngine,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (options?.preferenceStore) {
|
|
69
|
+
registerPreferencesRoutes(router, options.preferenceStore);
|
|
70
|
+
}
|
|
71
|
+
// Audit always available — falls back to default ~/.iris/audit.log path
|
|
72
|
+
// when no custom rule store is provided (read-only access).
|
|
73
|
+
registerAuditRoutes(router, options?.customRuleStore);
|
|
44
74
|
app.use('/api/v1', router);
|
|
45
75
|
// Serve static dashboard files if built (rate limited)
|
|
46
76
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -57,9 +87,28 @@ export function createDashboardServer(storage, config, logger) {
|
|
|
57
87
|
return {
|
|
58
88
|
app,
|
|
59
89
|
start() {
|
|
60
|
-
|
|
90
|
+
const server = app.listen(config.dashboard.port, () => {
|
|
61
91
|
logger.info(`Dashboard available at http://localhost:${config.dashboard.port}`);
|
|
62
92
|
});
|
|
93
|
+
/*
|
|
94
|
+
* F-006: surface listen() errors instead of swallowing them.
|
|
95
|
+
* Without this handler, EADDRINUSE (port already bound, typically
|
|
96
|
+
* by the MCP HTTP transport) goes to the default Node 'error'
|
|
97
|
+
* handler which emits a warning but doesn't crash — so the process
|
|
98
|
+
* keeps running in a broken state. We log the specific cause then
|
|
99
|
+
* exit(1) so the user sees the actual problem.
|
|
100
|
+
*/
|
|
101
|
+
server.on('error', (err) => {
|
|
102
|
+
if (err.code === 'EADDRINUSE') {
|
|
103
|
+
logger.error(`Dashboard failed to start: port ${config.dashboard.port} is already in use. ` +
|
|
104
|
+
`If running HTTP transport on the same port, use --dashboard-port <other>.`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
logger.error(`Dashboard server error: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
return server;
|
|
63
112
|
},
|
|
64
113
|
};
|
|
65
114
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ExtractedCitation {
|
|
2
|
+
raw: string;
|
|
3
|
+
kind: 'numbered' | 'author_year' | 'url' | 'doi';
|
|
4
|
+
offsetStart: number;
|
|
5
|
+
offsetEnd: number;
|
|
6
|
+
contextWindow: string;
|
|
7
|
+
identifier: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function extractCitations(output: string): ExtractedCitation[];
|
|
10
|
+
export declare function hasAnyCitations(output: string): boolean;
|
|
11
|
+
export declare function countCitationsByKind(output: string): Record<ExtractedCitation['kind'], number>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Citation extraction — finds references inside an AI-generated output.
|
|
2
|
+
// No network, no judgment on validity; the resolver + verifier do that.
|
|
3
|
+
// This file's only job is "what did the model claim to cite, and what's
|
|
4
|
+
// the textual context around each citation?"
|
|
5
|
+
//
|
|
6
|
+
// Supported citation forms:
|
|
7
|
+
// [1], [2], [3] — numbered citations (bare)
|
|
8
|
+
// [1][2] — consecutive numbered citations
|
|
9
|
+
// (Author, 2021) — author-year parenthetical
|
|
10
|
+
// (Author et al., 2021) — multi-author parenthetical
|
|
11
|
+
// https://example.com/... — bare URLs
|
|
12
|
+
// 10.1234/foo.bar — DOIs (bare or with https://doi.org/)
|
|
13
|
+
//
|
|
14
|
+
// We intentionally do NOT try to parse footnote DEFINITIONS here — the
|
|
15
|
+
// extractor pairs each INLINE citation with a local sentence/paragraph
|
|
16
|
+
// context window. The verifier will match (citation, context) pairs
|
|
17
|
+
// against resolved sources.
|
|
18
|
+
const URL_PATTERN = /https?:\/\/[^\s)\]]+/g;
|
|
19
|
+
// Basic DOI — 10. prefix + registrant + slash + suffix
|
|
20
|
+
const DOI_PATTERN = /\b10\.\d{4,9}\/[^\s)\];,"'<>]+/g;
|
|
21
|
+
// [1], [2], [12]. Avoid matching [INSERT], [FIXME] etc. by requiring digits only.
|
|
22
|
+
const NUMBERED_PATTERN = /\[(\d{1,3})\]/g;
|
|
23
|
+
// (Author, 2021), (Smith et al., 2019), (van der Berg, 2023)
|
|
24
|
+
// Restrictive enough to avoid matching any parenthetical aside:
|
|
25
|
+
// - 1-40 chars inside parens
|
|
26
|
+
// - ends with ", YYYY" where YYYY is 1800-2099
|
|
27
|
+
const AUTHOR_YEAR_PATTERN = /\(([^()]{1,50}?,\s*(?:18|19|20)\d{2})\)/g;
|
|
28
|
+
function makeContext(text, start, end) {
|
|
29
|
+
const radius = 200;
|
|
30
|
+
const contextStart = Math.max(0, start - radius);
|
|
31
|
+
const contextEnd = Math.min(text.length, end + radius);
|
|
32
|
+
let ctx = text.slice(contextStart, contextEnd);
|
|
33
|
+
if (contextStart > 0)
|
|
34
|
+
ctx = '…' + ctx;
|
|
35
|
+
if (contextEnd < text.length)
|
|
36
|
+
ctx = ctx + '…';
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
39
|
+
export function extractCitations(output) {
|
|
40
|
+
if (!output)
|
|
41
|
+
return [];
|
|
42
|
+
const results = [];
|
|
43
|
+
for (const match of output.matchAll(NUMBERED_PATTERN)) {
|
|
44
|
+
results.push({
|
|
45
|
+
raw: match[0],
|
|
46
|
+
kind: 'numbered',
|
|
47
|
+
offsetStart: match.index ?? 0,
|
|
48
|
+
offsetEnd: (match.index ?? 0) + match[0].length,
|
|
49
|
+
contextWindow: makeContext(output, match.index ?? 0, (match.index ?? 0) + match[0].length),
|
|
50
|
+
identifier: match[1],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const match of output.matchAll(URL_PATTERN)) {
|
|
54
|
+
// Strip trailing punctuation that shouldn't be part of the URL.
|
|
55
|
+
let cleaned = match[0].replace(/[.,;:!?)"'>]+$/, '');
|
|
56
|
+
results.push({
|
|
57
|
+
raw: match[0],
|
|
58
|
+
kind: 'url',
|
|
59
|
+
offsetStart: match.index ?? 0,
|
|
60
|
+
offsetEnd: (match.index ?? 0) + cleaned.length,
|
|
61
|
+
contextWindow: makeContext(output, match.index ?? 0, (match.index ?? 0) + cleaned.length),
|
|
62
|
+
identifier: cleaned,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
for (const match of output.matchAll(DOI_PATTERN)) {
|
|
66
|
+
// Skip DOIs that are inside a URL (already captured as a URL).
|
|
67
|
+
const inUrl = /https?:\/\/[^\s]*$/.test(output.slice(0, match.index ?? 0));
|
|
68
|
+
if (inUrl)
|
|
69
|
+
continue;
|
|
70
|
+
const cleaned = match[0].replace(/[.,;:!?)"'>]+$/, '');
|
|
71
|
+
results.push({
|
|
72
|
+
raw: match[0],
|
|
73
|
+
kind: 'doi',
|
|
74
|
+
offsetStart: match.index ?? 0,
|
|
75
|
+
offsetEnd: (match.index ?? 0) + cleaned.length,
|
|
76
|
+
contextWindow: makeContext(output, match.index ?? 0, (match.index ?? 0) + cleaned.length),
|
|
77
|
+
identifier: cleaned,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
for (const match of output.matchAll(AUTHOR_YEAR_PATTERN)) {
|
|
81
|
+
results.push({
|
|
82
|
+
raw: match[0],
|
|
83
|
+
kind: 'author_year',
|
|
84
|
+
offsetStart: match.index ?? 0,
|
|
85
|
+
offsetEnd: (match.index ?? 0) + match[0].length,
|
|
86
|
+
contextWindow: makeContext(output, match.index ?? 0, (match.index ?? 0) + match[0].length),
|
|
87
|
+
identifier: match[1].trim(),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Sort by position — dashboards expect reading order.
|
|
91
|
+
return results.sort((a, b) => a.offsetStart - b.offsetStart);
|
|
92
|
+
}
|
|
93
|
+
export function hasAnyCitations(output) {
|
|
94
|
+
return extractCitations(output).length > 0;
|
|
95
|
+
}
|
|
96
|
+
export function countCitationsByKind(output) {
|
|
97
|
+
const counts = { numbered: 0, author_year: 0, url: 0, doi: 0 };
|
|
98
|
+
for (const c of extractCitations(output)) {
|
|
99
|
+
counts[c.kind]++;
|
|
100
|
+
}
|
|
101
|
+
return counts;
|
|
102
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface ResolveOptions {
|
|
2
|
+
allowFetch: boolean;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
maxBytes?: number;
|
|
5
|
+
domainAllowlist?: readonly string[];
|
|
6
|
+
maxRedirects?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedSource {
|
|
9
|
+
url: string;
|
|
10
|
+
requestedUrl: string;
|
|
11
|
+
status: number;
|
|
12
|
+
contentType: string;
|
|
13
|
+
text: string;
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
fetchedAt: string;
|
|
16
|
+
bytesFetched: number;
|
|
17
|
+
fromCache: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare class CitationResolveError extends Error {
|
|
20
|
+
readonly kind: 'bad_scheme' | 'ssrf' | 'not_allowed_domain' | 'timeout' | 'too_large' | 'bad_status' | 'redirect_loop' | 'not_text' | 'fetch_disabled';
|
|
21
|
+
readonly details?: string | undefined;
|
|
22
|
+
constructor(message: string, kind: 'bad_scheme' | 'ssrf' | 'not_allowed_domain' | 'timeout' | 'too_large' | 'bad_status' | 'redirect_loop' | 'not_text' | 'fetch_disabled', details?: string | undefined);
|
|
23
|
+
}
|
|
24
|
+
export declare function isSafeHost(host: string): boolean;
|
|
25
|
+
export declare function __clearCitationCacheForTests(): void;
|
|
26
|
+
export declare function resolveSource(identifier: string, opts: ResolveOptions): Promise<ResolvedSource>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Citation source resolver — fetches URLs and DOIs so the verifier can
|
|
2
|
+
// feed them to the LLM judge. This is the security-sensitive piece: we
|
|
3
|
+
// are fetching URLs that appeared in model-generated output, which is
|
|
4
|
+
// effectively user-controlled input.
|
|
5
|
+
//
|
|
6
|
+
// Defense layers (in order):
|
|
7
|
+
// 1. Scheme allowlist — http/https only; refuse file:/javascript:/etc.
|
|
8
|
+
// 2. SSRF host check — refuse localhost, link-local, private ranges,
|
|
9
|
+
// and cloud metadata (AWS/GCP/Azure/DigitalOcean) IP literals.
|
|
10
|
+
// 3. Optional domain allowlist — IRIS_CITATION_DOMAINS=doi.org,arxiv.org
|
|
11
|
+
// restricts to a curated set; empty/unset = open web (still SSRF-guarded).
|
|
12
|
+
// 4. Timeout + size cap — 10s default, 5MB cap on response body.
|
|
13
|
+
// 5. Redirect chase cap — follow max 3 redirects, each re-checked.
|
|
14
|
+
// 6. Cache — in-process LRU (100 entries) so retries don't re-fetch.
|
|
15
|
+
//
|
|
16
|
+
// This is opt-in: calls require passing {allowFetch: true} so an agent
|
|
17
|
+
// can't trick Iris into fetching random URLs without operator consent
|
|
18
|
+
// (consent granted via tool param or env IRIS_CITATION_ALLOW_FETCH=1).
|
|
19
|
+
export class CitationResolveError extends Error {
|
|
20
|
+
kind;
|
|
21
|
+
details;
|
|
22
|
+
constructor(message, kind, details) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
this.details = details;
|
|
26
|
+
this.name = 'CitationResolveError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Private IP ranges + localhost + link-local + cloud metadata.
|
|
30
|
+
const BLOCKED_IPV4 = [
|
|
31
|
+
// Localhost
|
|
32
|
+
/^127\./,
|
|
33
|
+
// Link-local
|
|
34
|
+
/^169\.254\./,
|
|
35
|
+
// Private RFC 1918
|
|
36
|
+
/^10\./,
|
|
37
|
+
/^192\.168\./,
|
|
38
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
39
|
+
// Cloud metadata
|
|
40
|
+
/^169\.254\.169\.254$/,
|
|
41
|
+
// Broadcast
|
|
42
|
+
/^255\.255\.255\.255$/,
|
|
43
|
+
// This-network
|
|
44
|
+
/^0\./,
|
|
45
|
+
];
|
|
46
|
+
const BLOCKED_IPV6 = [
|
|
47
|
+
/^::1$/, // localhost
|
|
48
|
+
/^fc|^fd/i, // unique local
|
|
49
|
+
/^fe80/i, // link-local
|
|
50
|
+
/^::ffff:127\./i, // IPv4-mapped localhost
|
|
51
|
+
];
|
|
52
|
+
const BLOCKED_HOST_SUBSTRINGS = ['localhost', 'internal', '.local', 'metadata.google', 'metadata.azure'];
|
|
53
|
+
function isIpv4(host) {
|
|
54
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
55
|
+
}
|
|
56
|
+
function isIpv6(host) {
|
|
57
|
+
return host.includes(':');
|
|
58
|
+
}
|
|
59
|
+
export function isSafeHost(host) {
|
|
60
|
+
const hostLower = host.toLowerCase();
|
|
61
|
+
for (const sub of BLOCKED_HOST_SUBSTRINGS) {
|
|
62
|
+
if (hostLower === sub || hostLower.endsWith(sub))
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (isIpv4(host)) {
|
|
66
|
+
for (const re of BLOCKED_IPV4) {
|
|
67
|
+
if (re.test(host))
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (isIpv6(host)) {
|
|
72
|
+
for (const re of BLOCKED_IPV6) {
|
|
73
|
+
if (re.test(host))
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
function matchesAllowlist(host, allowlist) {
|
|
80
|
+
if (!allowlist || allowlist.length === 0)
|
|
81
|
+
return true;
|
|
82
|
+
const hostLower = host.toLowerCase();
|
|
83
|
+
for (const allowed of allowlist) {
|
|
84
|
+
const a = allowed.toLowerCase();
|
|
85
|
+
if (hostLower === a || hostLower.endsWith('.' + a))
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// Tiny LRU — short-circuits duplicate fetches in a single batch of
|
|
91
|
+
// citations (e.g. 5 citations in one output all pointing to the same
|
|
92
|
+
// source). Not durable; every process start is fresh.
|
|
93
|
+
const CACHE_MAX = 100;
|
|
94
|
+
const cache = new Map();
|
|
95
|
+
function cacheGet(key) {
|
|
96
|
+
const hit = cache.get(key);
|
|
97
|
+
if (!hit)
|
|
98
|
+
return undefined;
|
|
99
|
+
// Re-insert to bump LRU order
|
|
100
|
+
cache.delete(key);
|
|
101
|
+
cache.set(key, hit);
|
|
102
|
+
return { ...hit, fromCache: true };
|
|
103
|
+
}
|
|
104
|
+
function cacheSet(key, value) {
|
|
105
|
+
if (cache.size >= CACHE_MAX) {
|
|
106
|
+
const firstKey = cache.keys().next().value;
|
|
107
|
+
if (firstKey !== undefined)
|
|
108
|
+
cache.delete(firstKey);
|
|
109
|
+
}
|
|
110
|
+
cache.set(key, value);
|
|
111
|
+
}
|
|
112
|
+
export function __clearCitationCacheForTests() {
|
|
113
|
+
cache.clear();
|
|
114
|
+
}
|
|
115
|
+
function normalizeDoiToUrl(doiOrUrl) {
|
|
116
|
+
const trimmed = doiOrUrl.trim();
|
|
117
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
118
|
+
return trimmed;
|
|
119
|
+
if (/^10\.\d{4,9}\//.test(trimmed))
|
|
120
|
+
return `https://doi.org/${trimmed}`;
|
|
121
|
+
return trimmed;
|
|
122
|
+
}
|
|
123
|
+
async function doFetch(url, opts, redirectsLeft) {
|
|
124
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
125
|
+
const maxBytes = opts.maxBytes ?? 5 * 1024 * 1024;
|
|
126
|
+
const parsed = new URL(url);
|
|
127
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
128
|
+
throw new CitationResolveError(`Refusing non-http(s) scheme: ${parsed.protocol}`, 'bad_scheme', parsed.protocol);
|
|
129
|
+
}
|
|
130
|
+
if (!isSafeHost(parsed.hostname)) {
|
|
131
|
+
throw new CitationResolveError(`Refusing SSRF-blocked host: ${parsed.hostname}`, 'ssrf', parsed.hostname);
|
|
132
|
+
}
|
|
133
|
+
if (!matchesAllowlist(parsed.hostname, opts.domainAllowlist)) {
|
|
134
|
+
throw new CitationResolveError(`Host ${parsed.hostname} not in IRIS_CITATION_DOMAINS allowlist`, 'not_allowed_domain', parsed.hostname);
|
|
135
|
+
}
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetch(url, {
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
redirect: 'manual',
|
|
143
|
+
headers: {
|
|
144
|
+
'user-agent': 'iris-mcp-citation-verifier/0.4 (+https://iris-eval.com)',
|
|
145
|
+
accept: 'text/html, text/plain, application/pdf, application/xhtml+xml, */*;q=0.1',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
151
|
+
throw new CitationResolveError(`Fetch timed out after ${timeoutMs}ms`, 'timeout');
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
}
|
|
158
|
+
// Manual redirect chase — we re-check each target against SSRF rules.
|
|
159
|
+
if (res.status >= 300 && res.status < 400) {
|
|
160
|
+
const loc = res.headers.get('location');
|
|
161
|
+
if (!loc) {
|
|
162
|
+
throw new CitationResolveError(`Redirect with no Location header (${res.status})`, 'bad_status');
|
|
163
|
+
}
|
|
164
|
+
if (redirectsLeft <= 0) {
|
|
165
|
+
throw new CitationResolveError(`Exceeded max redirects (${opts.maxRedirects ?? 3})`, 'redirect_loop');
|
|
166
|
+
}
|
|
167
|
+
const next = new URL(loc, url).toString();
|
|
168
|
+
return doFetch(next, opts, redirectsLeft - 1);
|
|
169
|
+
}
|
|
170
|
+
if (res.status < 200 || res.status >= 300) {
|
|
171
|
+
throw new CitationResolveError(`Bad status ${res.status}`, 'bad_status', String(res.status));
|
|
172
|
+
}
|
|
173
|
+
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
174
|
+
// We only extract text. PDFs could be supported later with pdf-parse
|
|
175
|
+
// but that's an opt-in + heavy dep. For now we refuse non-text.
|
|
176
|
+
const textLike = contentType.includes('text/') ||
|
|
177
|
+
contentType.includes('xml') ||
|
|
178
|
+
contentType.includes('json') ||
|
|
179
|
+
contentType === '';
|
|
180
|
+
if (!textLike) {
|
|
181
|
+
throw new CitationResolveError(`Refusing non-text content-type: ${contentType}`, 'not_text', contentType);
|
|
182
|
+
}
|
|
183
|
+
// Stream with a byte cap so we don't DoS ourselves on a huge body.
|
|
184
|
+
if (!res.body) {
|
|
185
|
+
throw new CitationResolveError('Response had no body stream', 'bad_status');
|
|
186
|
+
}
|
|
187
|
+
const reader = res.body.getReader();
|
|
188
|
+
const chunks = [];
|
|
189
|
+
let total = 0;
|
|
190
|
+
let truncated = false;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { value, done } = await reader.read();
|
|
193
|
+
if (done)
|
|
194
|
+
break;
|
|
195
|
+
total += value.byteLength;
|
|
196
|
+
if (total > maxBytes) {
|
|
197
|
+
truncated = true;
|
|
198
|
+
chunks.push(value.slice(0, Math.max(0, maxBytes - (total - value.byteLength))));
|
|
199
|
+
await reader.cancel();
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
chunks.push(value);
|
|
203
|
+
}
|
|
204
|
+
// Concat + decode. UTF-8 is good enough for this use case — any weird
|
|
205
|
+
// encoding will surface as mojibake for the LLM judge, still safe.
|
|
206
|
+
const buf = new Uint8Array(total > maxBytes ? maxBytes : total);
|
|
207
|
+
let off = 0;
|
|
208
|
+
for (const c of chunks) {
|
|
209
|
+
buf.set(c, off);
|
|
210
|
+
off += c.byteLength;
|
|
211
|
+
}
|
|
212
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
|
213
|
+
return {
|
|
214
|
+
url: res.url || url,
|
|
215
|
+
requestedUrl: url,
|
|
216
|
+
status: res.status,
|
|
217
|
+
contentType,
|
|
218
|
+
text,
|
|
219
|
+
truncated,
|
|
220
|
+
fetchedAt: new Date().toISOString(),
|
|
221
|
+
bytesFetched: Math.min(total, maxBytes),
|
|
222
|
+
fromCache: false,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export async function resolveSource(identifier, opts) {
|
|
226
|
+
if (!opts.allowFetch) {
|
|
227
|
+
throw new CitationResolveError('Citation fetch is disabled. Pass allowFetch:true or set IRIS_CITATION_ALLOW_FETCH=1.', 'fetch_disabled');
|
|
228
|
+
}
|
|
229
|
+
const url = normalizeDoiToUrl(identifier);
|
|
230
|
+
const cacheKey = url;
|
|
231
|
+
const cached = cacheGet(cacheKey);
|
|
232
|
+
if (cached)
|
|
233
|
+
return cached;
|
|
234
|
+
const fetched = await doFetch(url, opts, opts.maxRedirects ?? 3);
|
|
235
|
+
cacheSet(cacheKey, fetched);
|
|
236
|
+
return fetched;
|
|
237
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type LLMProvider } from '../llm-judge/client.js';
|
|
2
|
+
import { type ExtractedCitation } from './extract.js';
|
|
3
|
+
import { type ResolvedSource } from './resolve.js';
|
|
4
|
+
export interface VerifyCitationsParams {
|
|
5
|
+
output: string;
|
|
6
|
+
provider: LLMProvider;
|
|
7
|
+
model: string;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
allowFetch: boolean;
|
|
10
|
+
domainAllowlist?: readonly string[];
|
|
11
|
+
maxCostUsdTotal?: number;
|
|
12
|
+
perSourceTimeoutMs?: number;
|
|
13
|
+
perSourceMaxBytes?: number;
|
|
14
|
+
maxCitations?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface VerifiedCitation {
|
|
17
|
+
citation: ExtractedCitation;
|
|
18
|
+
resolveStatus: 'ok' | 'skipped' | 'error';
|
|
19
|
+
resolveError?: {
|
|
20
|
+
kind: string;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
source?: Pick<ResolvedSource, 'url' | 'status' | 'contentType' | 'bytesFetched' | 'truncated'>;
|
|
24
|
+
judge?: {
|
|
25
|
+
supported: boolean;
|
|
26
|
+
confidence: number;
|
|
27
|
+
rationale: string;
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
costUsd: number | null;
|
|
31
|
+
latencyMs: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface VerifyCitationsResult {
|
|
35
|
+
overallScore: number | null;
|
|
36
|
+
passed: boolean;
|
|
37
|
+
citations: VerifiedCitation[];
|
|
38
|
+
totalCostUsd: number;
|
|
39
|
+
totalCitationsFound: number;
|
|
40
|
+
totalResolved: number;
|
|
41
|
+
totalSupported: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function verifyCitations(params: VerifyCitationsParams): Promise<VerifyCitationsResult>;
|