Skip to content

Commit cd76bf8

Browse files
authored
feat(core): optional governance metadata on EvalMetadata and EvalTest (#1165)
Adds an optional `governance` block (OWASP LLM Top 10 / OWASP Agentic / MITRE ATLAS / cross-framework controls / EU AI Act risk tier / owner) to suite-level EvalMetadata and case-level EvalTest.metadata. The shape is permissive: every field is optional, custom prefixes are first-class, and value validation is a soft warning, never an error. Existing evals without the block validate and run unchanged. Case-level blocks merge with suite-level (arrays concat with dedupe, scalars override). Result metadata is surfaced into the JSONL artifact so reports and `jq` pipelines can aggregate by control. Closes #1161
1 parent 8116897 commit cd76bf8

9 files changed

Lines changed: 526 additions & 8 deletions

File tree

apps/cli/src/commands/eval/artifact-writer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ export interface IndexArtifactEntry {
160160
readonly output_path?: string;
161161
readonly input_path?: string;
162162
readonly response_path?: string;
163+
/** Case-level metadata pass-through (governance taxonomies, skill tags, etc.). */
164+
readonly metadata?: Record<string, unknown>;
163165
}
164166

165167
export type ResultIndexArtifact = IndexArtifactEntry;
@@ -573,6 +575,7 @@ export function buildIndexArtifactEntry(
573575
input_path: options.inputPath
574576
? toRelativeArtifactPath(options.outputDir, options.inputPath)
575577
: undefined,
578+
metadata: result.metadata,
576579
};
577580
}
578581

@@ -606,6 +609,7 @@ export function buildResultIndexArtifact(result: EvaluationResult): ResultIndexA
606609
response_path: hasResponse
607610
? path.posix.join(artifactSubdir, 'outputs', 'response.md')
608611
: undefined,
612+
metadata: result.metadata,
609613
};
610614
}
611615

packages/core/src/evaluation/metadata.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,56 @@
11
import { z } from 'zod';
22
import type { JsonObject } from './types.js';
33

4+
/**
5+
* Optional governance block on suite-level `EvalMetadata` and case-level `EvalTest.metadata`.
6+
*
7+
* The schema is intentionally permissive: every field is optional, unknown fields pass through,
8+
* and value validation is delegated to a soft-warning lint in `eval-validator.ts`. The block
9+
* captures convergence on public AI-governance taxonomies (NIST AI RMF, ISO/IEC 42001, EU AI Act,
10+
* OWASP LLM Top 10, MITRE ATLAS) without prescribing a workflow or hard-coding ID lists.
11+
*
12+
* Versioning lives in field names (`owasp_llm_top_10_2025`) so that when a standard revises and
13+
* redefines IDs (OWASP LLM Top 10 v2025 vs v1.1), agentv ships a new field rather than
14+
* silently changing the meaning of existing tags.
15+
*
16+
* To extend with a new versioned taxonomy: add an optional `string[]` field here, document it in
17+
* the README under examples/red-team/, and propagate through the `agentv eval` JSONL output.
18+
*/
19+
const GovernanceMetadataSchema = z
20+
.object({
21+
/** Schema version of this governance block itself (lets the block evolve). */
22+
schema_version: z.string().optional(),
23+
/** OWASP LLM Top 10 v2025 IDs (LLM01..LLM10). */
24+
owasp_llm_top_10_2025: z.array(z.string()).optional(),
25+
/** OWASP Top 10 for Agentic Applications v2025 (T1..T10). */
26+
owasp_agentic_top_10_2025: z.array(z.string()).optional(),
27+
/** MITRE ATLAS technique IDs (e.g. AML.T0051, AML.T0075). */
28+
mitre_atlas: z.array(z.string()).optional(),
29+
/**
30+
* Cross-framework controls. String format: `<FRAMEWORK>-<VERSION>:<ID>`.
31+
* Custom prefixes are first-class (e.g. `INTERNAL-AI-POLICY-3.2:CTRL-7`).
32+
*/
33+
controls: z.array(z.string()).optional(),
34+
/**
35+
* Risk vocabulary anchored to EU AI Act terminology by default.
36+
* Allowed values: `prohibited | high | limited | minimal`.
37+
* Other strings (e.g. NIST 800-30 `low | moderate | high`) are accepted with a soft warning.
38+
*/
39+
risk_tier: z.string().optional(),
40+
/** Human-readable owner (team name, group). */
41+
owner: z.string().optional(),
42+
})
43+
.passthrough();
44+
45+
export type GovernanceMetadata = z.infer<typeof GovernanceMetadataSchema>;
46+
447
const MetadataSchema = z.object({
548
name: z
649
.string()
750
.min(1)
851
.max(64)
9-
.regex(/^[a-z0-9-]+$/),
52+
.regex(/^[a-z0-9-]+$/)
53+
.optional(),
1054
description: z.string().min(1).max(1024).optional(),
1155
version: z.string().optional(),
1256
author: z.string().optional(),
@@ -17,17 +61,35 @@ const MetadataSchema = z.object({
1761
agentv: z.string().optional(),
1862
})
1963
.optional(),
64+
governance: GovernanceMetadataSchema.optional(),
2065
});
2166

2267
export type EvalMetadata = z.infer<typeof MetadataSchema>;
2368

69+
/**
70+
* Extract the governance block from a suite-level YAML. Accepts either:
71+
* - top-level `governance:` (consistent with `description`, `tags`, etc.)
72+
* - nested `metadata.governance:` (matches the case-level shape)
73+
* Top-level wins if both are present.
74+
*/
75+
function extractGovernance(suite: JsonObject): unknown {
76+
if (suite.governance !== undefined) {
77+
return suite.governance;
78+
}
79+
const wrapper = suite.metadata;
80+
if (wrapper && typeof wrapper === 'object' && !Array.isArray(wrapper)) {
81+
return (wrapper as Record<string, unknown>).governance;
82+
}
83+
return undefined;
84+
}
85+
2486
export function parseMetadata(suite: JsonObject): EvalMetadata | undefined {
2587
const hasName = typeof suite.name === 'string';
26-
const hasDescription = typeof suite.description === 'string';
88+
const governanceRaw = extractGovernance(suite);
2789

28-
// Only trigger metadata parsing when `name` is present.
29-
// `description` alone doesn't trigger it since it's also used as a regular suite field.
30-
if (!hasName) {
90+
// Trigger metadata parsing when `name` is present, OR when a governance block exists
91+
// (so authors can attach governance to suites that don't have a name).
92+
if (!hasName && governanceRaw === undefined) {
3193
return undefined;
3294
}
3395

@@ -39,5 +101,6 @@ export function parseMetadata(suite: JsonObject): EvalMetadata | undefined {
39101
tags: suite.tags,
40102
license: suite.license,
41103
requires: suite.requires,
104+
governance: governanceRaw,
42105
});
43106
}

packages/core/src/evaluation/orchestrator.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,13 @@ export async function runEvaluation(
13691369
beforeAllOutputAttached = true;
13701370
}
13711371

1372+
// Surface case-level metadata (e.g. governance taxonomies) on the result so
1373+
// it round-trips into the JSONL artifact and downstream consumers (reports,
1374+
// jq pipelines, attestation exports). Already-set metadata wins.
1375+
if (evalCase.metadata && !result.metadata) {
1376+
result = { ...result, metadata: evalCase.metadata };
1377+
}
1378+
13721379
if (onProgress) {
13731380
await onProgress({
13741381
workerId,

packages/core/src/evaluation/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,12 @@ export interface EvaluationResult {
11601160
readonly failureReasonCode?: string;
11611161
/** Structured error detail (only when executionStatus === 'execution_error') */
11621162
readonly executionError?: ExecutionError;
1163+
/**
1164+
* Pass-through of `EvalTest.metadata` so case-level information (e.g. governance taxonomies,
1165+
* skill-name tags) flows into the JSONL artifact and downstream consumers without each
1166+
* surface having to thread the EvalTest separately.
1167+
*/
1168+
readonly metadata?: Record<string, unknown>;
11631169
}
11641170

11651171
export type EvaluationVerdict = 'pass' | 'fail' | 'skip';

packages/core/src/evaluation/validation/eval-validator.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const KNOWN_TOP_LEVEL_FIELDS = new Set([
5151
'evaluators',
5252
'preprocessors',
5353
'workspace',
54+
'metadata',
55+
'governance',
5456
]);
5557

5658
/**
@@ -195,6 +197,10 @@ export async function validateEvalFile(filePath: string): Promise<ValidationResu
195197
// Validate metadata fields
196198
validateMetadata(parsed, absolutePath, errors);
197199

200+
// Soft-warning lint for the optional governance block (suite-level).
201+
// Accepts both top-level `governance:` and nested `metadata.governance:`.
202+
validateGovernance(extractGovernanceBlock(parsed), 'governance', absolutePath, errors);
203+
198204
// Warn on deprecated or unknown top-level fields
199205
for (const key of Object.keys(parsed)) {
200206
const deprecationMessage = DEPRECATED_TOP_LEVEL_FIELDS.get(key);
@@ -457,6 +463,16 @@ export async function validateEvalFile(filePath: string): Promise<ValidationResu
457463
// Cross-field validation for conversation mode
458464
validateConversationMode(evalCase, location, absolutePath, errors);
459465

466+
// Soft-warning lint for case-level governance block.
467+
if (isObject(evalCase.metadata)) {
468+
validateGovernance(
469+
(evalCase.metadata as JsonObject).governance,
470+
`${location}.metadata.governance`,
471+
absolutePath,
472+
errors,
473+
);
474+
}
475+
460476
await validateWorkspaceConfig(
461477
evalCase.workspace,
462478
absolutePath,
@@ -1006,3 +1022,125 @@ function validateConversationMode(
10061022
}
10071023
}
10081024
}
1025+
1026+
/**
1027+
* Recognized fields inside the optional `governance` block. Any other key produces a soft
1028+
* warning so that authors notice typos like `owasp_lm_top_10_2025`. Unknown frameworks (e.g.
1029+
* a future `iso_42001_2027`) require updating this set in the same PR — that is intentional;
1030+
* the alternative (silent acceptance) lets typos rot in production evals.
1031+
*/
1032+
const KNOWN_GOVERNANCE_FIELDS = new Set([
1033+
'schema_version',
1034+
'owasp_llm_top_10_2025',
1035+
'owasp_agentic_top_10_2025',
1036+
'mitre_atlas',
1037+
'controls',
1038+
'risk_tier',
1039+
'owner',
1040+
]);
1041+
1042+
/** EU AI Act risk-tier vocabulary (the default; other strings produce a soft warning). */
1043+
const EU_AI_ACT_RISK_TIERS = new Set(['prohibited', 'high', 'limited', 'minimal']);
1044+
1045+
/**
1046+
* Validates a `<FRAMEWORK>-<VERSION>:<ID>` control string. Custom prefixes are first-class
1047+
* (e.g. `INTERNAL-AI-POLICY-3.2:CTRL-7`) — only the *shape* is checked. Returns true if the
1048+
* string has the required `:` separator AND the framework segment ends with a version-looking
1049+
* token (digit-or-dot suffix, e.g. `1.0`, `2024`, `3.2`). Misses on this heuristic produce
1050+
* a soft warning, never an error.
1051+
*/
1052+
function isWellFormedControlId(value: string): boolean {
1053+
const colonIdx = value.indexOf(':');
1054+
if (colonIdx <= 0 || colonIdx === value.length - 1) {
1055+
return false;
1056+
}
1057+
const prefix = value.slice(0, colonIdx);
1058+
const lastSegment = prefix.split('-').pop() ?? '';
1059+
// Version-looking: starts with a digit or contains a dot.
1060+
return /[0-9]/.test(lastSegment.charAt(0)) || lastSegment.includes('.');
1061+
}
1062+
1063+
/** Top-level `governance:` wins; falls back to nested `metadata.governance:`. */
1064+
function extractGovernanceBlock(parsed: JsonObject): JsonValue | undefined {
1065+
if (parsed.governance !== undefined) {
1066+
return parsed.governance;
1067+
}
1068+
if (isObject(parsed.metadata)) {
1069+
return (parsed.metadata as JsonObject).governance;
1070+
}
1071+
return undefined;
1072+
}
1073+
1074+
function validateGovernance(
1075+
block: JsonValue | undefined,
1076+
location: string,
1077+
filePath: string,
1078+
errors: ValidationError[],
1079+
): void {
1080+
if (block === undefined) return;
1081+
if (!isObject(block)) {
1082+
errors.push({
1083+
severity: 'warning',
1084+
filePath,
1085+
location,
1086+
message: `'${location}' must be an object; got ${Array.isArray(block) ? 'array' : typeof block}.`,
1087+
});
1088+
return;
1089+
}
1090+
1091+
for (const key of Object.keys(block)) {
1092+
if (!KNOWN_GOVERNANCE_FIELDS.has(key)) {
1093+
errors.push({
1094+
severity: 'warning',
1095+
filePath,
1096+
location: `${location}.${key}`,
1097+
message: `Unknown governance field '${key}'. Known fields: ${[...KNOWN_GOVERNANCE_FIELDS].join(', ')}.`,
1098+
});
1099+
}
1100+
}
1101+
1102+
const controls = block.controls;
1103+
if (controls !== undefined) {
1104+
if (!Array.isArray(controls)) {
1105+
errors.push({
1106+
severity: 'warning',
1107+
filePath,
1108+
location: `${location}.controls`,
1109+
message: "'controls' should be an array of '<FRAMEWORK>-<VERSION>:<ID>' strings.",
1110+
});
1111+
} else {
1112+
for (let i = 0; i < controls.length; i++) {
1113+
const entry = controls[i];
1114+
if (typeof entry !== 'string') {
1115+
errors.push({
1116+
severity: 'warning',
1117+
filePath,
1118+
location: `${location}.controls[${i}]`,
1119+
message: 'Control entries must be strings.',
1120+
});
1121+
} else if (!isWellFormedControlId(entry)) {
1122+
errors.push({
1123+
severity: 'warning',
1124+
filePath,
1125+
location: `${location}.controls[${i}]`,
1126+
message: `Malformed control '${entry}'. Expected '<FRAMEWORK>-<VERSION>:<ID>' (e.g. NIST-AI-RMF-1.0:MEASURE-2.7). Custom prefixes are allowed.`,
1127+
});
1128+
}
1129+
}
1130+
}
1131+
}
1132+
1133+
const riskTier = block.risk_tier;
1134+
if (
1135+
riskTier !== undefined &&
1136+
typeof riskTier === 'string' &&
1137+
!EU_AI_ACT_RISK_TIERS.has(riskTier)
1138+
) {
1139+
errors.push({
1140+
severity: 'warning',
1141+
filePath,
1142+
location: `${location}.risk_tier`,
1143+
message: `'risk_tier: ${riskTier}' is outside EU AI Act vocabulary (prohibited | high | limited | minimal). Other vocabularies (e.g. NIST 800-30) are accepted but flagged.`,
1144+
});
1145+
}
1146+
}

0 commit comments

Comments
 (0)