fix: harden input validation

This commit is contained in:
Brad Groux 2026-08-24 07:41:05 -05:00
parent 1cdcd6ec60
commit c45f6f2917
14 changed files with 719 additions and 163 deletions

View file

@ -163,7 +163,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
### 🛡️ Agent Governance
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Sandbox Policy Presets** — Assign reusable filesystem, network, environment, and credential rules to agents, workflow agents, or one-off runs; unsupported required controls fail closed before launch with redacted audit traces. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted criteria profiles (regex, keyword, numeric range, custom expressions).
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Sandbox Policy Presets** — Assign reusable filesystem, network, environment, and credential rules to agents, workflow agents, or one-off runs; unsupported required controls fail closed before launch with redacted audit traces. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted bounded criteria profiles (regex, keyword, numeric range, occurrence ratio).
### 🤖 Agent Orchestration

View file

@ -5799,6 +5799,13 @@ POST /api/scoring/profiles
**Response:** `201` with created profile.
Scoring profiles accept `KeywordContains`, `NumericRange`, bounded `RegexMatch`, and declarative
`OccurrenceRatio` scorers. Regex patterns are limited to 256 characters and execute through a
globally bounded four-worker pool and wait queue with a 100 ms limit. Valid JavaScript regex flags
supported by the active Node runtime remain accepted. `OccurrenceRatio` counts literal `needles`
and normalizes them with a fixed `denominator` or numeric `denominatorPath`; it does not execute
expression strings. Persisted legacy custom-expression profiles must be migrated before evaluation.
#### Get Scoring Profile
```

View file

@ -2523,7 +2523,9 @@ Define scoring profiles with weighted criteria and evaluate agent outputs agains
**Key capabilities:**
- Four scorer types: `RegexMatch`, `KeywordContains`, `NumericRange`, `CustomExpression`
- Four bounded scorer types: `RegexMatch`, `KeywordContains`, `NumericRange`, `OccurrenceRatio`
- Regex evaluation runs outside the server event loop with input, pattern, and time limits
- Occurrence ratios use literal values and optional numeric normalization; arbitrary code is never evaluated
- Weighted scorers with optional `target`: `action`, `output`, or `combined`
- Composite scoring methods: `weightedAvg`, `minimum`, `geometricMean`
- Per-evaluation history with scorer-level breakdowns

View file

@ -10,12 +10,26 @@ The Scoring Framework lets you define profiles with weighted criteria that evalu
**Scorer types:**
| Type | What it checks |
| ------------------- | ------------------------------------------------------------ |
| `RegexMatch` | Whether the output matches a regular expression |
| `KeywordContains` | Whether the output contains required keywords |
| `NumericRange` | Whether a numeric field in the output falls within a range |
| `CustomExpression` | A custom evaluation expression |
| Type | What it checks |
| ------------------ | --------------------------------------------------------------------- |
| `RegexMatch` | Whether bounded worker-isolated regex evaluation matches |
| `KeywordContains` | Whether the output contains required keywords |
| `NumericRange` | Whether a numeric field in the output falls within a range |
| `OccurrenceRatio` | Literal occurrence density with optional numeric-path normalization |
`RegexMatch` accepts patterns up to 256 characters and any valid JavaScript regex flag set supported
by the active Node runtime. Evaluation uses a globally bounded four-worker pool outside the server
event loop, a bounded wait queue, and a 100 ms limit. Output is limited to 100,000 characters,
action text to 10,000 characters, and their combined scoring target to 110,001 characters.
`OccurrenceRatio` is the declarative replacement for legacy custom expressions. It accepts one to
32 literal `needles` and divides their occurrence count by either a fixed `denominator` or a numeric
`denominatorPath`, optionally scaled with `denominatorScale`. `wholeWord`, `caseSensitive`,
`minimumDenominator`, and `invert` provide bounded transformations without executing code.
Persisted profiles containing the removed `CustomExpression` scorer fail closed during evaluation.
Replace those scorers through the profile API before retrying; the server never evaluates or
silently converts the stored expression.
**Composite methods:**

View file

@ -1,8 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { ScoringService } from '../services/scoring-service.js';
import {
boundedRegexTest,
getScoringRegexRuntimeSnapshot,
} from '../services/scoring-runtime.js';
import { SCORING_REGEX_MAX_CONCURRENCY } from '../config/scoring.js';
describe('ScoringService', () => {
const originalCwd = process.cwd();
@ -27,6 +32,37 @@ describe('ScoringService', () => {
);
});
it('migrates legacy built-in profiles to bounded scorers', async () => {
const profilesDir = join(tempDir, 'storage', 'scoring');
await mkdir(profilesDir, { recursive: true });
await writeFile(
join(profilesDir, 'task-efficiency.json'),
JSON.stringify({
id: 'task-efficiency',
name: 'Task Efficiency',
builtIn: true,
compositeMethod: 'weightedAvg',
created: '2026-01-01T00:00:00.000Z',
updated: '2026-01-01T00:00:00.000Z',
scorers: [
{
id: 'legacy',
name: 'Legacy',
type: 'CustomExpression',
weight: 1,
expression: 'process.env',
},
],
})
);
const profiles = await service.listProfiles();
const migrated = profiles.find((profile) => profile.id === 'task-efficiency');
expect(migrated?.created).toBe('2026-01-01T00:00:00.000Z');
expect(migrated?.scorers.some((scorer) => scorer.type === 'OccurrenceRatio')).toBe(true);
expect(migrated?.scorers.some((scorer) => scorer.type === 'CustomExpression')).toBe(false);
});
it('evaluates profiles and stores history', async () => {
const profile = await service.createProfile({
name: 'Weighted score',
@ -77,16 +113,18 @@ describe('ScoringService', () => {
{
id: 'pass',
name: 'Pass',
type: 'CustomExpression',
type: 'OccurrenceRatio',
weight: 1,
expression: '1',
needles: ['anything'],
denominator: 1,
},
{
id: 'fail',
name: 'Fail',
type: 'CustomExpression',
type: 'OccurrenceRatio',
weight: 1,
expression: '0',
needles: ['never-present'],
denominator: 1,
},
],
});
@ -98,4 +136,120 @@ describe('ScoringService', () => {
expect(result.compositeScore).toBe(0);
});
it.each([
'typeof process === "object"',
'({}).constructor.constructor("return process")()',
'globalThis.process',
])('rejects legacy executable scorer state: %s', async (expression) => {
const profilesDir = join(tempDir, 'storage', 'scoring');
await service.listProfiles();
await writeFile(
join(profilesDir, 'legacy-profile.json'),
JSON.stringify({
id: 'legacy-profile',
name: 'Legacy profile',
compositeMethod: 'weightedAvg',
created: new Date().toISOString(),
updated: new Date().toISOString(),
scorers: [
{
id: 'legacy',
name: 'Legacy',
type: 'CustomExpression',
weight: 1,
expression,
},
],
})
);
await expect(
service.evaluate({ profileId: 'legacy-profile', output: 'ordinary output' })
).rejects.toThrow('legacy custom expression');
});
it('terminates regex evaluation that exceeds the bounded runtime', async () => {
const profile = await service.createProfile({
name: 'Bounded regex',
compositeMethod: 'weightedAvg',
scorers: [
{
id: 'regex',
name: 'Regex',
type: 'RegexMatch',
weight: 1,
pattern: '^(a+)+$',
},
],
});
await expect(
service.evaluate({ profileId: profile.id, output: `${'a'.repeat(50_000)}!` })
).rejects.toThrow('100ms limit');
});
it('bounds regex workers across concurrent evaluations', async () => {
const evaluations = Array.from({ length: SCORING_REGEX_MAX_CONCURRENCY + 1 }, () =>
boundedRegexTest('^(a+)+$', undefined, `${'a'.repeat(50_000)}!`).catch((error) => error)
);
await vi.waitFor(() => {
const snapshot = getScoringRegexRuntimeSnapshot();
expect(snapshot.activeWorkers).toBe(SCORING_REGEX_MAX_CONCURRENCY);
expect(snapshot.queuedEvaluations).toBe(1);
});
await Promise.all(evaluations);
expect(getScoringRegexRuntimeSnapshot()).toEqual({ activeWorkers: 0, queuedEvaluations: 0 });
});
it('preserves ordinary regex and bounded occurrence scoring', async () => {
const profile = await service.createProfile({
name: 'Safe scoring',
compositeMethod: 'weightedAvg',
scorers: [
{
id: 'regex',
name: 'Regex',
type: 'RegexMatch',
weight: 1,
pattern: '\\bverified\\b',
flags: 'gi',
},
{
id: 'ratio',
name: 'Ratio',
type: 'OccurrenceRatio',
weight: 1,
needles: ['verified'],
wholeWord: true,
denominator: 1,
},
],
});
const result = await service.evaluate({ profileId: profile.id, output: 'Verified result' });
expect(result.scores.map((score) => score.score)).toEqual([1, 1]);
});
it('does not traverse reserved metadata path segments', async () => {
const profile = await service.createProfile({
name: 'Contained metadata path',
compositeMethod: 'weightedAvg',
scorers: [
{
id: 'ratio',
name: 'Ratio',
type: 'OccurrenceRatio',
weight: 1,
needles: ['verified'],
denominatorPath: 'metadata.constructor.prototype.polluted',
},
],
});
const result = await service.evaluate({ profileId: profile.id, output: 'verified' });
expect(result.scores[0]?.score).toBe(1);
});
});

View file

@ -278,6 +278,40 @@ describe('SQLite governance repositories', () => {
result.id,
]);
const now = new Date().toISOString();
const legacyProfile = {
id: 'legacy-sqlite-profile',
name: 'Legacy SQLite profile',
builtIn: false,
compositeMethod: 'weightedAvg',
created: now,
updated: now,
scorers: [
{
id: 'legacy',
name: 'Legacy',
type: 'CustomExpression',
weight: 1,
expression: 'globalThis.process',
},
],
};
fixture.database
.getConnection()
.prepare(
`
INSERT INTO scoring_profiles (
id, workspace_id, name, built_in, profile_json, created_at, updated_at
)
VALUES (?, 'local', ?, 0, ?, ?, ?)
`
)
.run(legacyProfile.id, legacyProfile.name, JSON.stringify(legacyProfile), now, now);
await expect(
service.evaluate({ profileId: legacyProfile.id, output: 'ordinary output' })
).rejects.toThrow('legacy custom expression');
await expect(fs.access(profilesDir)).rejects.toThrow();
await expect(fs.access(evaluationsDir)).rejects.toThrow();
});

View file

@ -0,0 +1,8 @@
export const SCORING_MAX_ACTION_LENGTH = 10_000;
export const SCORING_MAX_OUTPUT_LENGTH = 100_000;
export const SCORING_MAX_COMBINED_LENGTH =
SCORING_MAX_ACTION_LENGTH + SCORING_MAX_OUTPUT_LENGTH + 1;
export const SCORING_MAX_PATTERN_LENGTH = 256;
export const SCORING_REGEX_TIMEOUT_MS = 100;
export const SCORING_REGEX_MAX_CONCURRENCY = 4;
export const SCORING_REGEX_MAX_QUEUE = 64;

View file

@ -4,76 +4,14 @@ import { asyncHandler } from '../middleware/async-handler.js';
import { BadRequestError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { scoringService } from '../services/scoring-service.js';
import { paramStr, qNum, qStr } from '../lib/query-helpers.js';
import {
createScoringProfileSchema,
evaluateScoringSchema,
updateScoringProfileSchema,
} from '../schemas/scoring-schemas.js';
const router: RouterType = Router();
const scorerSchema = z.discriminatedUnion('type', [
z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
weight: z.number().min(0),
target: z.enum(['action', 'output', 'combined']).optional(),
type: z.literal('RegexMatch'),
pattern: z.string().min(1),
flags: z.string().optional(),
scoreOnMatch: z.number().min(0).max(1).optional(),
scoreOnMiss: z.number().min(0).max(1).optional(),
invert: z.boolean().optional(),
}),
z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
weight: z.number().min(0),
target: z.enum(['action', 'output', 'combined']).optional(),
type: z.literal('KeywordContains'),
keywords: z.array(z.string().min(1)).min(1),
matchMode: z.enum(['all', 'any']).optional(),
caseSensitive: z.boolean().optional(),
partialCredit: z.boolean().optional(),
}),
z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
weight: z.number().min(0),
target: z.enum(['action', 'output', 'combined']).optional(),
type: z.literal('NumericRange'),
valuePath: z.string().min(1),
min: z.number().optional(),
max: z.number().optional(),
scoreOnMiss: z.number().min(0).max(1).optional(),
}),
z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
weight: z.number().min(0),
target: z.enum(['action', 'output', 'combined']).optional(),
type: z.literal('CustomExpression'),
expression: z.string().min(1),
}),
]);
const createProfileSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
scorers: z.array(scorerSchema).min(1),
compositeMethod: z.enum(['weightedAvg', 'minimum', 'geometricMean']),
});
const updateProfileSchema = createProfileSchema.partial();
const evaluateSchema = z.object({
profileId: z.string().min(1),
action: z.string().optional(),
output: z.string().min(1),
agent: z.string().optional(),
taskId: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const parseOrThrow = <T>(schema: z.ZodType<T>, value: unknown): T => {
try {
return schema.parse(value);
@ -107,7 +45,7 @@ router.get(
router.post(
'/profiles',
asyncHandler(async (req, res) => {
const input = parseOrThrow(createProfileSchema, req.body);
const input = parseOrThrow(createScoringProfileSchema, req.body);
const profile = await scoringService.createProfile(input);
res.status(201).json(profile);
})
@ -116,7 +54,7 @@ router.post(
router.put(
'/profiles/:id',
asyncHandler(async (req, res) => {
const input = parseOrThrow(updateProfileSchema, req.body);
const input = parseOrThrow(updateScoringProfileSchema, req.body);
let profile;
try {
profile = await scoringService.updateProfile(paramStr(req.params.id), input);
@ -154,7 +92,7 @@ router.delete(
router.post(
'/evaluate',
asyncHandler(async (req, res) => {
const input = parseOrThrow(evaluateSchema, req.body);
const input = parseOrThrow(evaluateScoringSchema, req.body);
const result = await scoringService.evaluate(input);
res.status(201).json(result);
})

View file

@ -0,0 +1,105 @@
import { z } from 'zod';
import type {
CreateScoringProfileInput,
EvaluationRequest,
Scorer,
UpdateScoringProfileInput,
} from '@veritas-kanban/shared';
import {
SCORING_MAX_ACTION_LENGTH,
SCORING_MAX_OUTPUT_LENGTH,
SCORING_MAX_PATTERN_LENGTH,
} from '../config/scoring.js';
const regexFlagsSchema = z
.string()
.max(8)
.refine((flags) => {
try {
void new RegExp('', flags);
return true;
} catch {
return false;
}
}, 'Regex flags must be a valid, non-duplicated JavaScript flag set');
const metadataFitsLimit = (value: Record<string, unknown>): boolean => {
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength <= 64_000;
} catch {
return false;
}
};
const baseScorerShape = {
id: z.string().min(1).max(128),
name: z.string().min(1).max(256),
description: z.string().max(2_000).optional(),
weight: z.number().min(0).max(1_000_000),
target: z.enum(['action', 'output', 'combined']).optional(),
};
export const scorerSchema: z.ZodType<Scorer> = z.discriminatedUnion('type', [
z.object({
...baseScorerShape,
type: z.literal('RegexMatch'),
pattern: z.string().min(1).max(SCORING_MAX_PATTERN_LENGTH),
flags: regexFlagsSchema.optional(),
scoreOnMatch: z.number().min(0).max(1).optional(),
scoreOnMiss: z.number().min(0).max(1).optional(),
invert: z.boolean().optional(),
}),
z.object({
...baseScorerShape,
type: z.literal('KeywordContains'),
keywords: z.array(z.string().min(1).max(256)).min(1).max(64),
matchMode: z.enum(['all', 'any']).optional(),
caseSensitive: z.boolean().optional(),
partialCredit: z.boolean().optional(),
}),
z.object({
...baseScorerShape,
type: z.literal('NumericRange'),
valuePath: z.string().min(1).max(256),
min: z.number().finite().optional(),
max: z.number().finite().optional(),
scoreOnMiss: z.number().min(0).max(1).optional(),
}),
z.object({
...baseScorerShape,
type: z.literal('OccurrenceRatio'),
needles: z.array(z.string().min(1).max(64)).min(1).max(32),
caseSensitive: z.boolean().optional(),
wholeWord: z.boolean().optional(),
denominator: z.number().positive().max(1_000_000).optional(),
denominatorPath: z.string().min(1).max(256).optional(),
denominatorScale: z.number().positive().max(1_000_000).optional(),
minimumDenominator: z.number().positive().max(1_000_000).optional(),
invert: z.boolean().optional(),
}),
]);
const createScoringProfileObjectSchema = z.object({
name: z.string().min(1).max(256),
description: z.string().max(4_000).optional(),
scorers: z.array(scorerSchema).min(1).max(64),
compositeMethod: z.enum(['weightedAvg', 'minimum', 'geometricMean']),
});
export const createScoringProfileSchema: z.ZodType<CreateScoringProfileInput> =
createScoringProfileObjectSchema;
export const updateScoringProfileSchema: z.ZodType<UpdateScoringProfileInput> =
createScoringProfileObjectSchema.partial();
export const evaluateScoringSchema: z.ZodType<EvaluationRequest> = z.object({
profileId: z.string().min(1).max(128),
action: z.string().max(SCORING_MAX_ACTION_LENGTH).optional(),
output: z.string().min(1).max(SCORING_MAX_OUTPUT_LENGTH),
agent: z.string().optional(),
taskId: z.string().max(128).optional(),
metadata: z
.record(z.string(), z.unknown())
.refine(metadataFitsLimit, 'Metadata must be JSON serializable and cannot exceed 64,000 bytes')
.optional(),
});

View file

@ -0,0 +1,198 @@
import { Worker } from 'node:worker_threads';
import type { OccurrenceRatioScorer, ScoringTarget } from '@veritas-kanban/shared';
import { ValidationError } from '../middleware/error-handler.js';
import {
SCORING_MAX_COMBINED_LENGTH,
SCORING_MAX_PATTERN_LENGTH,
SCORING_REGEX_MAX_CONCURRENCY,
SCORING_REGEX_MAX_QUEUE,
SCORING_REGEX_TIMEOUT_MS,
} from '../config/scoring.js';
let activeRegexWorkers = 0;
const regexWorkerWaiters: Array<() => void> = [];
async function acquireRegexWorkerSlot(): Promise<void> {
if (activeRegexWorkers < SCORING_REGEX_MAX_CONCURRENCY) {
activeRegexWorkers += 1;
return;
}
if (regexWorkerWaiters.length >= SCORING_REGEX_MAX_QUEUE) {
throw new ValidationError('Regex evaluation capacity is temporarily exhausted');
}
await new Promise<void>((resolve) => regexWorkerWaiters.push(resolve));
}
function releaseRegexWorkerSlot(): void {
const next = regexWorkerWaiters.shift();
if (next) {
next();
return;
}
activeRegexWorkers = Math.max(0, activeRegexWorkers - 1);
}
export function getScoringRegexRuntimeSnapshot(): {
activeWorkers: number;
queuedEvaluations: number;
} {
return {
activeWorkers: activeRegexWorkers,
queuedEvaluations: regexWorkerWaiters.length,
};
}
const REGEX_WORKER_SOURCE = String.raw`
const { parentPort, workerData } = require('node:worker_threads');
try {
const regex = new RegExp(workerData.pattern, workerData.flags);
parentPort.postMessage({ matched: regex.test(workerData.text) });
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : 'Invalid pattern' });
}
`;
export interface ScoringRuntimeContext {
action: string;
output: string;
combined: string;
metadata: Record<string, unknown>;
}
function targetText(target: ScoringTarget | undefined, context: ScoringRuntimeContext): string {
switch (target) {
case 'action':
return context.action;
case 'combined':
return context.combined;
case 'output':
default:
return context.output;
}
}
function valueAtPath(root: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (
!current ||
typeof current !== 'object' ||
segment === '__proto__' ||
segment === 'prototype' ||
segment === 'constructor' ||
!Object.prototype.hasOwnProperty.call(current, segment)
) {
return undefined;
}
return (current as Record<string, unknown>)[segment];
}, root);
}
function isWordCharacter(value: string | undefined): boolean {
return value !== undefined && /[A-Za-z0-9_]/.test(value);
}
function countOccurrences(text: string, needle: string, wholeWord: boolean): number {
let count = 0;
let offset = 0;
while (offset <= text.length - needle.length) {
const index = text.indexOf(needle, offset);
if (index < 0) break;
const before = index > 0 ? text[index - 1] : undefined;
const after = text[index + needle.length];
if (!wholeWord || (!isWordCharacter(before) && !isWordCharacter(after))) count += 1;
offset = index + Math.max(needle.length, 1);
}
return count;
}
export function evaluateOccurrenceRatio(
scorer: OccurrenceRatioScorer,
context: ScoringRuntimeContext
): number {
const rawText = targetText(scorer.target, context);
const text = scorer.caseSensitive ? rawText : rawText.toLowerCase();
const needles = scorer.caseSensitive
? scorer.needles
: scorer.needles.map((needle) => needle.toLowerCase());
const occurrences = needles.reduce(
(sum, needle) => sum + countOccurrences(text, needle, scorer.wholeWord === true),
0
);
const pathValue = scorer.denominatorPath
? valueAtPath(
{ action: context.action, output: context.output, metadata: context.metadata },
scorer.denominatorPath
)
: undefined;
const numericPathValue = typeof pathValue === 'number' ? pathValue : Number(pathValue);
const scaledPathValue = Number.isFinite(numericPathValue)
? numericPathValue / (scorer.denominatorScale ?? 1)
: undefined;
const denominator = Math.max(
scorer.minimumDenominator ?? 1,
scaledPathValue ?? scorer.denominator ?? 1
);
const ratio = occurrences / denominator;
return scorer.invert ? 1 - ratio : ratio;
}
export async function boundedRegexTest(
pattern: string,
flags: string | undefined,
text: string
): Promise<boolean> {
if (pattern.length > SCORING_MAX_PATTERN_LENGTH) {
throw new ValidationError(`Regex patterns cannot exceed ${SCORING_MAX_PATTERN_LENGTH} characters`);
}
if (text.length > SCORING_MAX_COMBINED_LENGTH) {
throw new ValidationError(
`Combined scoring text cannot exceed ${SCORING_MAX_COMBINED_LENGTH} characters`
);
}
await acquireRegexWorkerSlot();
try {
return await new Promise<boolean>((resolve, reject) => {
const worker = new Worker(REGEX_WORKER_SOURCE, {
eval: true,
workerData: { pattern, flags: flags ?? '', text },
});
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const finish = (callback: () => void, terminate = true) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (!terminate) {
callback();
return;
}
void worker.terminate().then(callback, callback);
};
timer = setTimeout(() => {
finish(() => reject(new ValidationError('Regex evaluation exceeded the 100ms limit')));
}, SCORING_REGEX_TIMEOUT_MS);
worker.once('message', (message: { matched?: boolean; error?: string }) => {
if (message.error) {
finish(() => reject(new ValidationError(`Invalid regex pattern: ${message.error}`)));
return;
}
const matched = message.matched;
if (typeof matched !== 'boolean') {
finish(() => reject(new ValidationError('Regex worker returned an invalid response')));
return;
}
finish(() => resolve(matched));
});
worker.once('error', (error) => finish(() => reject(error)));
worker.once('exit', () => {
finish(
() => reject(new ValidationError('Regex evaluation stopped unexpectedly')),
false
);
});
});
} finally {
releaseRegexWorkerSlot();
}
}

View file

@ -2,13 +2,13 @@ import { join } from 'path';
import { nanoid } from 'nanoid';
import type {
CreateScoringProfileInput,
CustomExpressionScorer,
EvaluationDimensionScore,
EvaluationHistoryQuery,
EvaluationRequest,
EvaluationResult,
KeywordContainsScorer,
NumericRangeScorer,
OccurrenceRatioScorer,
RegexMatchScorer,
Scorer,
ScoringProfile,
@ -17,9 +17,15 @@ import { fileExists, mkdir, readdir, readFile, unlink, writeFile } from '../stor
import { withFileLock } from './file-lock.js';
import { createLogger } from '../lib/logger.js';
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
import { NotFoundError } from '../middleware/error-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
import { SqliteScoringRepository } from '../storage/sqlite/governance-repositories.js';
import {
createScoringProfileSchema,
evaluateScoringSchema,
updateScoringProfileSchema,
} from '../schemas/scoring-schemas.js';
import { boundedRegexTest, evaluateOccurrenceRatio } from './scoring-runtime.js';
const log = createLogger('scoring-service');
@ -55,13 +61,34 @@ const getTargetText = (scorer: Scorer, context: EvaluationContext): string => {
const getValueAtPath = (root: Record<string, unknown>, path: string): unknown => {
return path.split('.').reduce<unknown>((current, segment) => {
if (current && typeof current === 'object' && segment in current) {
if (
current &&
typeof current === 'object' &&
segment !== '__proto__' &&
segment !== 'prototype' &&
segment !== 'constructor' &&
Object.prototype.hasOwnProperty.call(current, segment)
) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, root);
};
const hasLegacyCustomExpression = (profile: unknown): boolean => {
if (!profile || typeof profile !== 'object') return false;
const scorers = (profile as { scorers?: unknown }).scorers;
return (
Array.isArray(scorers) &&
scorers.some(
(scorer) =>
scorer &&
typeof scorer === 'object' &&
(scorer as { type?: unknown }).type === 'CustomExpression'
)
);
};
const BUILT_IN_PROFILES: Array<Omit<ScoringProfile, 'created' | 'updated'>> = [
{
id: 'code-quality',
@ -129,10 +156,15 @@ const BUILT_IN_PROFILES: Array<Omit<ScoringProfile, 'created' | 'updated'>> = [
{
id: 'limited-filler',
name: 'Limited filler',
type: 'CustomExpression',
type: 'OccurrenceRatio',
weight: 0.25,
expression:
'Math.max(0, 1 - (((output.match(/\\b(?:just|really|very|basically|simply)\\b/gi) || []).length) / Math.max(1, metadata.outputWordCount / 50)))',
target: 'output',
needles: ['just', 'really', 'very', 'basically', 'simply'],
wholeWord: true,
denominatorPath: 'metadata.outputWordCount',
denominatorScale: 50,
minimumDenominator: 1,
invert: true,
},
],
},
@ -164,11 +196,11 @@ const BUILT_IN_PROFILES: Array<Omit<ScoringProfile, 'created' | 'updated'>> = [
{
id: 'has-structure',
name: 'Has concise structure',
type: 'CustomExpression',
type: 'OccurrenceRatio',
target: 'output',
weight: 0.33,
expression:
'Math.min(1, ((output.match(/\\n/g) || []).length + (output.match(/\\./g) || []).length) / 4)',
needles: ['\n', '.'],
denominator: 4,
},
],
},
@ -213,19 +245,49 @@ export class ScoringService {
await this.seedBuiltIns();
}
private parseProfileInput(input: unknown): CreateScoringProfileInput {
const parsed = createScoringProfileSchema.safeParse(input);
if (parsed.success) return parsed.data;
if (hasLegacyCustomExpression(input)) {
throw new ValidationError(
'Scoring profile contains a legacy custom expression. Replace it with a bounded OccurrenceRatio, KeywordContains, RegexMatch, or NumericRange scorer before evaluation.'
);
}
throw new ValidationError('Invalid scoring profile', parsed.error.issues);
}
private parseProfileUpdate(input: unknown): Partial<CreateScoringProfileInput> {
const parsed = updateScoringProfileSchema.safeParse(input);
if (parsed.success) return parsed.data;
if (hasLegacyCustomExpression(input)) {
throw new ValidationError(
'Scoring profile contains a legacy custom expression. Replace it with a bounded OccurrenceRatio, KeywordContains, RegexMatch, or NumericRange scorer before evaluation.'
);
}
throw new ValidationError('Invalid scoring profile update', parsed.error.issues);
}
private parseEvaluationInput(input: unknown): EvaluationRequest {
const parsed = evaluateScoringSchema.safeParse(input);
if (parsed.success) return parsed.data;
throw new ValidationError('Invalid scoring evaluation request', parsed.error.issues);
}
private async seedBuiltIns(): Promise<void> {
if (this.builtInsSeeded) return;
for (const profile of BUILT_IN_PROFILES) {
const existing = this.repository
? await this.repository.getProfile(profile.id)
: await fileExists(this.profilePath(profile.id));
if (existing) continue;
: (await fileExists(this.profilePath(profile.id)))
? await this.readProfileFile(this.profilePath(profile.id))
: null;
if (existing && !(existing.builtIn && hasLegacyCustomExpression(existing))) continue;
const now = new Date().toISOString();
const seededProfile: ScoringProfile = {
...profile,
created: now,
created: existing?.created ?? now,
updated: now,
};
@ -311,15 +373,16 @@ export class ScoringService {
async createProfile(input: CreateScoringProfileInput): Promise<ScoringProfile> {
await this.ensureDirs();
const parsed = this.parseProfileInput(input);
const now = new Date().toISOString();
const id = `${slugify(input.name)}-${nanoid(6)}`;
const id = `${slugify(parsed.name)}-${nanoid(6)}`;
const profile: ScoringProfile = {
id,
name: input.name,
description: input.description,
scorers: input.scorers,
compositeMethod: input.compositeMethod,
name: parsed.name,
description: parsed.description,
scorers: parsed.scorers,
compositeMethod: parsed.compositeMethod,
builtIn: false,
created: now,
updated: now,
@ -348,10 +411,11 @@ export class ScoringService {
if (existing.builtIn) {
throw new Error('Built-in scoring profiles cannot be modified');
}
const parsed = this.parseProfileUpdate(input);
const updated: ScoringProfile = {
...existing,
...input,
...parsed,
id: existing.id,
builtIn: existing.builtIn,
updated: new Date().toISOString(),
@ -386,13 +450,12 @@ export class ScoringService {
return true;
}
private evaluateRegex(
private async evaluateRegex(
scorer: RegexMatchScorer,
context: EvaluationContext
): EvaluationDimensionScore {
): Promise<EvaluationDimensionScore> {
const text = getTargetText(scorer, context);
const regex = new RegExp(scorer.pattern, scorer.flags);
const didMatch = regex.test(text);
const didMatch = await boundedRegexTest(scorer.pattern, scorer.flags, text);
const matched = scorer.invert ? !didMatch : didMatch;
const score = matched ? (scorer.scoreOnMatch ?? 1) : (scorer.scoreOnMiss ?? 0);
@ -472,51 +535,27 @@ export class ScoringService {
};
}
private evaluateCustom(
scorer: CustomExpressionScorer,
private evaluateOccurrence(
scorer: OccurrenceRatioScorer,
context: EvaluationContext
): EvaluationDimensionScore {
const evaluator = new Function(
'action',
'output',
'combined',
'metadata',
`return (${scorer.expression});`
) as (
action: string,
output: string,
combined: string,
metadata: Record<string, unknown>
) => unknown;
let rawResult: unknown = 0;
try {
rawResult = evaluator(context.action, context.output, context.combined, context.metadata);
} catch (error) {
log.warn({ err: error, scorerId: scorer.id }, 'Custom scorer expression failed');
}
const score =
typeof rawResult === 'boolean'
? rawResult
? 1
: 0
: typeof rawResult === 'number'
? rawResult
: 0;
const score = clampScore(evaluateOccurrenceRatio(scorer, context));
return {
scorerId: scorer.id,
scorerName: scorer.name,
scorerType: scorer.type,
weight: scorer.weight,
score: clampScore(score),
matched: clampScore(score) > 0,
explanation: `Custom expression returned ${String(rawResult)}`,
score,
matched: score > 0,
explanation: `Observed occurrence ratio ${score.toFixed(3)}`,
};
}
private evaluateScorer(scorer: Scorer, context: EvaluationContext): EvaluationDimensionScore {
private async evaluateScorer(
scorer: Scorer,
context: EvaluationContext
): Promise<EvaluationDimensionScore> {
switch (scorer.type) {
case 'RegexMatch':
return this.evaluateRegex(scorer, context);
@ -524,8 +563,12 @@ export class ScoringService {
return this.evaluateKeywords(scorer, context);
case 'NumericRange':
return this.evaluateNumeric(scorer, context);
case 'CustomExpression':
return this.evaluateCustom(scorer, context);
case 'OccurrenceRatio':
return this.evaluateOccurrence(scorer, context);
default:
throw new ValidationError(
'Scoring profile uses an unsupported legacy scorer. Replace it with KeywordContains, RegexMatch, NumericRange, or OccurrenceRatio.'
);
}
}
@ -575,23 +618,35 @@ export class ScoringService {
async evaluate(input: EvaluationRequest): Promise<EvaluationResult> {
await this.ensureDirs();
const parsedInput = this.parseEvaluationInput(input);
const profile = await this.getProfile(input.profileId);
const profile = await this.getProfile(parsedInput.profileId);
if (!profile) {
throw new NotFoundError('Scoring profile not found');
}
const context = this.buildContext(input);
const scores = profile.scorers.map((scorer) => this.evaluateScorer(scorer, context));
const validatedProfile = this.parseProfileInput({
name: profile.name,
description: profile.description,
scorers: profile.scorers,
compositeMethod: profile.compositeMethod,
});
const context = this.buildContext(parsedInput);
const scores: EvaluationDimensionScore[] = [];
for (const scorer of validatedProfile.scorers) {
// Regex scorers execute in worker threads. Keep evaluation sequential so
// one request cannot fan out into dozens of concurrent workers.
scores.push(await this.evaluateScorer(scorer, context));
}
const compositeScore = this.computeComposite(profile, scores);
const result: EvaluationResult = {
id: `evaluation_${Date.now()}_${nanoid(6)}`,
profileId: profile.id,
profileName: profile.name,
action: input.action,
output: input.output,
agent: input.agent,
taskId: input.taskId,
action: parsedInput.action,
output: parsedInput.output,
agent: parsedInput.agent,
taskId: parsedInput.taskId,
metadata: context.metadata,
scores,
compositeScore,

View file

@ -1,6 +1,6 @@
import type { AgentType } from './task.types.js';
export type ScorerType = 'RegexMatch' | 'KeywordContains' | 'NumericRange' | 'CustomExpression';
export type ScorerType = 'RegexMatch' | 'KeywordContains' | 'NumericRange' | 'OccurrenceRatio';
export type ScoringCompositeMethod = 'weightedAvg' | 'minimum' | 'geometricMean';
@ -39,16 +39,23 @@ export interface NumericRangeScorer extends BaseScorer {
scoreOnMiss?: number;
}
export interface CustomExpressionScorer extends BaseScorer {
type: 'CustomExpression';
expression: string;
export interface OccurrenceRatioScorer extends BaseScorer {
type: 'OccurrenceRatio';
needles: string[];
caseSensitive?: boolean;
wholeWord?: boolean;
denominator?: number;
denominatorPath?: string;
denominatorScale?: number;
minimumDenominator?: number;
invert?: boolean;
}
export type Scorer =
| RegexMatchScorer
| KeywordContainsScorer
| NumericRangeScorer
| CustomExpressionScorer;
| OccurrenceRatioScorer;
export interface ScoringProfile {
id: string;

View file

@ -44,7 +44,7 @@ export type {
RegexMatchScorer,
KeywordContainsScorer,
NumericRangeScorer,
CustomExpressionScorer,
OccurrenceRatioScorer,
Scorer,
ScoringProfile,
CreateScoringProfileInput,

View file

@ -51,8 +51,8 @@ const createScorer = (type: ScorerType = 'KeywordContains'): Scorer => {
return { ...base, type, pattern: '', flags: '', invert: false };
case 'NumericRange':
return { ...base, type, valuePath: 'metadata.outputWordCount', min: 1, max: 500 };
case 'CustomExpression':
return { ...base, type, expression: 'output.length > 0 ? 1 : 0' };
case 'OccurrenceRatio':
return { ...base, type, needles: ['verified'], denominator: 1 };
case 'KeywordContains':
default:
return {
@ -83,7 +83,7 @@ const scorerTypeOptions: ScorerType[] = [
'KeywordContains',
'RegexMatch',
'NumericRange',
'CustomExpression',
'OccurrenceRatio',
];
const scorerTypeSelectData = scorerTypeOptions.map((type) => ({ value: type, label: type }));
@ -805,23 +805,57 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
</>
)}
{'expression' in scorer && (
{scorer.type === 'OccurrenceRatio' && (
<div className="space-y-2 lg:col-span-2">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Expression
Literal values, one per line
</label>
<Textarea
aria-label={`Scorer ${index + 1} expression`}
aria-label={`Scorer ${index + 1} literal values`}
rows={3}
value={scorer.expression}
value={scorer.needles.join('\n')}
onChange={(event) =>
updateScorer(index, (current) => ({
...current,
expression: event.target.value,
needles: event.target.value
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
}))
}
disabled={draftReadOnly}
/>
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
aria-label={`Scorer ${index + 1} denominator`}
type="number"
min={1}
placeholder="Denominator"
value={scorer.denominator ?? ''}
onChange={(event) =>
updateScorer(index, (current) => ({
...current,
denominator:
event.target.value === ''
? undefined
: Number(event.target.value),
}))
}
disabled={draftReadOnly}
/>
<TextInput
aria-label={`Scorer ${index + 1} denominator value path`}
placeholder="metadata.outputWordCount"
value={scorer.denominatorPath ?? ''}
onChange={(event) =>
updateScorer(index, (current) => ({
...current,
denominatorPath: event.target.value || undefined,
}))
}
disabled={draftReadOnly}
/>
</div>
</div>
)}
</div>