@kindgi/guardrails 0.0.0-bootstrap.0 → 0.1.1
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/LICENSE +201 -0
- package/README.md +78 -1
- package/dist/action-handler.d.ts +82 -0
- package/dist/action-handler.d.ts.map +1 -0
- package/dist/action-handler.js +120 -0
- package/dist/action-handler.js.map +1 -0
- package/dist/checks.d.ts +9 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +235 -0
- package/dist/checks.js.map +1 -0
- package/dist/define-check.d.ts +93 -0
- package/dist/define-check.d.ts.map +1 -0
- package/dist/define-check.js +110 -0
- package/dist/define-check.js.map +1 -0
- package/dist/define.d.ts +27 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +126 -0
- package/dist/define.js.map +1 -0
- package/dist/engine.d.ts +49 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +198 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +91 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/execution-strategy.d.ts +80 -0
- package/dist/execution-strategy.d.ts.map +1 -0
- package/dist/execution-strategy.js +96 -0
- package/dist/execution-strategy.js.map +1 -0
- package/dist/guardrail.schema.json +261 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/judge.d.ts +31 -0
- package/dist/judge.d.ts.map +1 -0
- package/dist/judge.js +171 -0
- package/dist/judge.js.map +1 -0
- package/dist/types.d.ts +407 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -4
- package/src/action-handler.ts +179 -0
- package/src/checks.ts +236 -0
- package/src/define-check.ts +207 -0
- package/src/define.ts +146 -0
- package/src/engine.ts +271 -0
- package/src/errors.ts +107 -0
- package/src/execution-strategy.ts +184 -0
- package/src/guardrail.schema.json +261 -0
- package/src/index.ts +79 -0
- package/src/judge.ts +221 -0
- package/src/types.ts +455 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { EvaluationResult, Guardrail, RunTrace } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A handler that knows how to APPLY one action (`halt`, `retry`,
|
|
8
|
+
* `escalate`, `log-only`, `compensate`, or custom). Registered once at
|
|
9
|
+
* boot. When `EvaluationBindings.actions` is set, the engine calls the
|
|
10
|
+
* handler for a violation's action after evaluating the guardrail;
|
|
11
|
+
* without it, the engine only reports the action and the caller applies
|
|
12
|
+
* it.
|
|
13
|
+
*
|
|
14
|
+
* Handlers don't mutate the trace; they emit side effects (schedule
|
|
15
|
+
* retry, enqueue HITL review, run compensating tool). The engine
|
|
16
|
+
* computes `action` from the guardrail's declaration and hands off to
|
|
17
|
+
* the registered handler.
|
|
18
|
+
*
|
|
19
|
+
* Adapter packages register their own action handlers (e.g. escalate →
|
|
20
|
+
* a HITL queue) without touching the engine.
|
|
21
|
+
*/
|
|
22
|
+
export interface ActionHandler {
|
|
23
|
+
/** Discriminant matching `guardrail.action['on-violation']`. */
|
|
24
|
+
readonly action: string;
|
|
25
|
+
/** Human-friendly name for diagnostics. */
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Apply the action. Called after the engine identifies a violation.
|
|
29
|
+
* An `err` result is attached to the evaluation as
|
|
30
|
+
* `result.attributes.actionHandlerError`; it doesn't roll back the
|
|
31
|
+
* evaluation (violation still recorded, compliance evidence still
|
|
32
|
+
* emitted).
|
|
33
|
+
*/
|
|
34
|
+
apply(context: ActionContext): Promise<ActionResult>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ActionContext {
|
|
38
|
+
readonly guardrail: Guardrail;
|
|
39
|
+
readonly evaluation: EvaluationResult;
|
|
40
|
+
readonly trace: RunTrace;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ActionResult =
|
|
44
|
+
| { readonly kind: 'ok' }
|
|
45
|
+
| { readonly kind: 'err'; readonly error: { readonly code: string; readonly message: string } };
|
|
46
|
+
|
|
47
|
+
/** Registry the engine consults when a violation surfaces. */
|
|
48
|
+
export interface ActionHandlerRegistry {
|
|
49
|
+
register(handler: ActionHandler): void;
|
|
50
|
+
get(action: string): ActionHandler | undefined;
|
|
51
|
+
list(): readonly ActionHandler[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createActionHandlerRegistry(
|
|
55
|
+
seed: readonly ActionHandler[] = [],
|
|
56
|
+
): ActionHandlerRegistry {
|
|
57
|
+
const handlers = new Map<string, ActionHandler>();
|
|
58
|
+
for (const h of seed) handlers.set(h.action, h);
|
|
59
|
+
return {
|
|
60
|
+
register(handler): void {
|
|
61
|
+
handlers.set(handler.action, handler);
|
|
62
|
+
},
|
|
63
|
+
get(action): ActionHandler | undefined {
|
|
64
|
+
return handlers.get(action);
|
|
65
|
+
},
|
|
66
|
+
list(): readonly ActionHandler[] {
|
|
67
|
+
return [...handlers.values()];
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============ built-in action handlers (record-only defaults) ============
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `halt` — the caller (e.g. the agent runtime) is expected to stop the
|
|
76
|
+
* run. This handler is a no-op record; the enforcement happens in the
|
|
77
|
+
* caller, which sees the `halt` action in the EvaluationResult.
|
|
78
|
+
*/
|
|
79
|
+
export const haltHandler: ActionHandler = {
|
|
80
|
+
action: 'halt',
|
|
81
|
+
name: 'Halt run',
|
|
82
|
+
async apply(): Promise<ActionResult> {
|
|
83
|
+
return { kind: 'ok' };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** `log-only` — record + carry on. */
|
|
88
|
+
export const logOnlyHandler: ActionHandler = {
|
|
89
|
+
action: 'log-only',
|
|
90
|
+
name: 'Log only',
|
|
91
|
+
async apply(): Promise<ActionResult> {
|
|
92
|
+
return { kind: 'ok' };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** `noop` — action synthesized when the check passes; nothing to do. */
|
|
97
|
+
export const noopHandler: ActionHandler = {
|
|
98
|
+
action: 'noop',
|
|
99
|
+
name: 'No-op (check passed)',
|
|
100
|
+
async apply(): Promise<ActionResult> {
|
|
101
|
+
return { kind: 'ok' };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `retry` — records the retry intent. Actual retry scheduling happens
|
|
107
|
+
* in the caller. Handler validates the guardrail's action.retry policy
|
|
108
|
+
* is well-formed.
|
|
109
|
+
*/
|
|
110
|
+
export const retryHandler: ActionHandler = {
|
|
111
|
+
action: 'retry',
|
|
112
|
+
name: 'Retry (caller-scheduled)',
|
|
113
|
+
async apply(ctx): Promise<ActionResult> {
|
|
114
|
+
const retry = ctx.guardrail.action.retry;
|
|
115
|
+
if (retry === undefined || retry.maxAttempts <= 0) {
|
|
116
|
+
return {
|
|
117
|
+
kind: 'err',
|
|
118
|
+
error: {
|
|
119
|
+
code: 'invalid-action-config',
|
|
120
|
+
message: `retry action on guardrail "${ctx.guardrail.id}" needs action.retry.maxAttempts > 0`,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { kind: 'ok' };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `escalate` — records the escalate intent. Actual routing (e.g. to a
|
|
130
|
+
* HITL review queue) happens in the caller or an adapter handler.
|
|
131
|
+
* Handler validates `guardrail.action.escalateTo` is set.
|
|
132
|
+
*/
|
|
133
|
+
export const escalateHandler: ActionHandler = {
|
|
134
|
+
action: 'escalate',
|
|
135
|
+
name: 'Escalate (caller-routed)',
|
|
136
|
+
async apply(ctx): Promise<ActionResult> {
|
|
137
|
+
if (ctx.guardrail.action.escalateTo === undefined) {
|
|
138
|
+
return {
|
|
139
|
+
kind: 'err',
|
|
140
|
+
error: {
|
|
141
|
+
code: 'invalid-action-config',
|
|
142
|
+
message: `escalate action on guardrail "${ctx.guardrail.id}" needs action.escalateTo`,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { kind: 'ok' };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `compensate` — records the compensation intent. The caller invokes
|
|
152
|
+
* the compensating tool via `guardrail.action.compensateWith`.
|
|
153
|
+
*/
|
|
154
|
+
export const compensateHandler: ActionHandler = {
|
|
155
|
+
action: 'compensate',
|
|
156
|
+
name: 'Compensate (caller-invoked)',
|
|
157
|
+
async apply(ctx): Promise<ActionResult> {
|
|
158
|
+
if (ctx.guardrail.action.compensateWith === undefined) {
|
|
159
|
+
return {
|
|
160
|
+
kind: 'err',
|
|
161
|
+
error: {
|
|
162
|
+
code: 'invalid-action-config',
|
|
163
|
+
message: `compensate action on guardrail "${ctx.guardrail.id}" needs action.compensateWith`,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { kind: 'ok' };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Convenience seed with all built-in handlers. */
|
|
172
|
+
export const BUILT_IN_ACTION_HANDLERS: readonly ActionHandler[] = [
|
|
173
|
+
haltHandler,
|
|
174
|
+
logOnlyHandler,
|
|
175
|
+
noopHandler,
|
|
176
|
+
retryHandler,
|
|
177
|
+
escalateHandler,
|
|
178
|
+
compensateHandler,
|
|
179
|
+
];
|
package/src/checks.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { CheckFunction, CheckRegistry, RegisteredCheck } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Built-in check library. Every entry is `kind: 'zero-llm'` — pure
|
|
8
|
+
* functions over the run trace, no external calls, deterministic. See
|
|
9
|
+
* `judge.ts` for LLM-judge evaluation.
|
|
10
|
+
*
|
|
11
|
+
* Adding a new built-in check: implement the function, add it to
|
|
12
|
+
* `BUILT_IN_CHECKS`, document its `config` schema in the JSDoc block.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `must-cite`: assistant output must include at least N matches of the
|
|
17
|
+
* citation pattern. Config:
|
|
18
|
+
* `{ minCitations?: number /* default 1 *\/, sourcePattern?: string /* regex *\/ }`
|
|
19
|
+
*
|
|
20
|
+
* Default `sourcePattern` matches `[<name>]`-style citations; override for
|
|
21
|
+
* verticals with different citation conventions.
|
|
22
|
+
*/
|
|
23
|
+
const mustCite: CheckFunction = async (config, trace) => {
|
|
24
|
+
const minCitations = typeof config.minCitations === 'number' ? config.minCitations : 1;
|
|
25
|
+
const sourcePattern =
|
|
26
|
+
typeof config.sourcePattern === 'string' ? config.sourcePattern : '\\[[^\\]]+\\]';
|
|
27
|
+
if (trace.output === undefined || trace.output.length === 0) {
|
|
28
|
+
return { passed: false, reason: 'output empty; nothing to cite' };
|
|
29
|
+
}
|
|
30
|
+
const re = new RegExp(sourcePattern, 'g');
|
|
31
|
+
const matches = trace.output.match(re) ?? [];
|
|
32
|
+
if (matches.length >= minCitations) {
|
|
33
|
+
return { passed: true, attributes: { citationCount: matches.length } };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
passed: false,
|
|
37
|
+
reason: `expected >= ${minCitations} citations, found ${matches.length}`,
|
|
38
|
+
attributes: { citationCount: matches.length },
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `never-call-tool`: fail if any of the listed tools were invoked.
|
|
44
|
+
* Config: `{ tools: string[] }` — tool ids or names to forbid.
|
|
45
|
+
*/
|
|
46
|
+
const neverCallTool: CheckFunction = async (config, trace) => {
|
|
47
|
+
// Accept both tool id / name strings and ToolRef objects
|
|
48
|
+
// ({ id, version }) — pack config authors may use either.
|
|
49
|
+
const rawTools = Array.isArray(config.tools) ? (config.tools as unknown[]) : [];
|
|
50
|
+
const forbidden = new Set<string>();
|
|
51
|
+
for (const entry of rawTools) {
|
|
52
|
+
if (typeof entry === 'string') {
|
|
53
|
+
forbidden.add(entry);
|
|
54
|
+
} else if (
|
|
55
|
+
entry !== null &&
|
|
56
|
+
typeof entry === 'object' &&
|
|
57
|
+
'id' in entry &&
|
|
58
|
+
typeof (entry as { id: unknown }).id === 'string'
|
|
59
|
+
) {
|
|
60
|
+
forbidden.add((entry as { id: string }).id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const violations: string[] = [];
|
|
64
|
+
for (const call of trace.toolCalls) {
|
|
65
|
+
if (forbidden.has(call.toolId) || forbidden.has(call.toolName)) {
|
|
66
|
+
violations.push(call.toolName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (violations.length === 0) return { passed: true };
|
|
70
|
+
return {
|
|
71
|
+
passed: false,
|
|
72
|
+
reason: `forbidden tool(s) invoked: ${[...new Set(violations)].join(', ')}`,
|
|
73
|
+
attributes: { violatingTools: [...new Set(violations)] },
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `max-tool-calls`: cap total tool invocations. Config: `{ max?: number }` (default 10).
|
|
79
|
+
* Guards against runaway agents.
|
|
80
|
+
*/
|
|
81
|
+
const maxToolCalls: CheckFunction = async (config, trace) => {
|
|
82
|
+
const max = typeof config.max === 'number' ? config.max : 10;
|
|
83
|
+
if (trace.toolCalls.length <= max) {
|
|
84
|
+
return { passed: true, attributes: { toolCallCount: trace.toolCalls.length } };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
passed: false,
|
|
88
|
+
reason: `expected <= ${max} tool calls, saw ${trace.toolCalls.length}`,
|
|
89
|
+
attributes: { toolCallCount: trace.toolCalls.length },
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `output-matches`: final assistant output must match a regex (or fail
|
|
95
|
+
* if `negate: true`). Config: `{ pattern: string, flags?: string, negate?: boolean }`.
|
|
96
|
+
*/
|
|
97
|
+
const outputMatches: CheckFunction = async (config, trace) => {
|
|
98
|
+
const pattern = typeof config.pattern === 'string' ? config.pattern : '';
|
|
99
|
+
const flags = typeof config.flags === 'string' ? config.flags : '';
|
|
100
|
+
const negate = config.negate === true;
|
|
101
|
+
if (pattern.length === 0) {
|
|
102
|
+
return { passed: false, reason: 'pattern config is required' };
|
|
103
|
+
}
|
|
104
|
+
const re = new RegExp(pattern, flags);
|
|
105
|
+
const output = trace.output ?? '';
|
|
106
|
+
const matched = re.test(output);
|
|
107
|
+
const passed = negate ? !matched : matched;
|
|
108
|
+
return passed
|
|
109
|
+
? { passed: true }
|
|
110
|
+
: {
|
|
111
|
+
passed: false,
|
|
112
|
+
reason: negate
|
|
113
|
+
? `output matched forbidden pattern /${pattern}/`
|
|
114
|
+
: `output did not match /${pattern}/`,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `tool-order`: tool calls must appear in a specified order. Config:
|
|
120
|
+
* `{ sequence: string[] }` — tool names in expected order (subsequence).
|
|
121
|
+
* Non-listed tools are allowed to appear anywhere; the sequence must exist
|
|
122
|
+
* as a subsequence of the actual call order.
|
|
123
|
+
*/
|
|
124
|
+
const toolOrder: CheckFunction = async (config, trace) => {
|
|
125
|
+
const sequence = Array.isArray(config.sequence) ? (config.sequence as string[]) : [];
|
|
126
|
+
if (sequence.length === 0) return { passed: true };
|
|
127
|
+
let seqIndex = 0;
|
|
128
|
+
for (const call of trace.toolCalls) {
|
|
129
|
+
if (call.toolName === sequence[seqIndex]) seqIndex += 1;
|
|
130
|
+
if (seqIndex === sequence.length) return { passed: true };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
passed: false,
|
|
134
|
+
reason: `expected tool-call subsequence [${sequence.join(', ')}] not observed`,
|
|
135
|
+
attributes: { matchedPrefix: sequence.slice(0, seqIndex) },
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `required-substring`: `trace.output` must contain EVERY listed pattern.
|
|
141
|
+
* Config: `{ patterns: string[], caseSensitive?: boolean }`.
|
|
142
|
+
*
|
|
143
|
+
* Mirror of `forbidden-substring` — enables "output must include the
|
|
144
|
+
* standard legal disclaimer / required attribution / policy footer"
|
|
145
|
+
* without needing an llm-judge.
|
|
146
|
+
*
|
|
147
|
+
* Empty `patterns` array passes vacuously. Missing `output` on the
|
|
148
|
+
* trace fails (nothing can contain the required text).
|
|
149
|
+
*/
|
|
150
|
+
const requiredSubstring: CheckFunction = async (config, trace) => {
|
|
151
|
+
const patternsRaw = Array.isArray(config.patterns) ? (config.patterns as unknown[]) : [];
|
|
152
|
+
const patterns = patternsRaw.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
|
153
|
+
if (patterns.length === 0) return { passed: true };
|
|
154
|
+
if (trace.output === undefined || trace.output.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
passed: false,
|
|
157
|
+
reason: `output empty; ${patterns.length} required pattern(s) not satisfied`,
|
|
158
|
+
attributes: { missing: patterns },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const caseSensitive = config.caseSensitive === true;
|
|
162
|
+
const haystack = caseSensitive ? trace.output : trace.output.toLowerCase();
|
|
163
|
+
const missing: string[] = [];
|
|
164
|
+
for (const p of patterns) {
|
|
165
|
+
const needle = caseSensitive ? p : p.toLowerCase();
|
|
166
|
+
if (!haystack.includes(needle)) missing.push(p);
|
|
167
|
+
}
|
|
168
|
+
if (missing.length === 0) return { passed: true };
|
|
169
|
+
return {
|
|
170
|
+
passed: false,
|
|
171
|
+
reason: `output missing required pattern(s): ${missing.join(', ')}`,
|
|
172
|
+
attributes: { missing },
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `forbidden-substring`: `trace.output` must NOT contain any listed pattern.
|
|
178
|
+
* Config: `{ patterns: string[], caseSensitive?: boolean }`.
|
|
179
|
+
*
|
|
180
|
+
* Symmetric to `required-substring`. Enables "output must not contain
|
|
181
|
+
* PII markers / off-limits phrases / policy trigger words" without an
|
|
182
|
+
* llm-judge.
|
|
183
|
+
*/
|
|
184
|
+
const forbiddenSubstring: CheckFunction = async (config, trace) => {
|
|
185
|
+
const patternsRaw = Array.isArray(config.patterns) ? (config.patterns as unknown[]) : [];
|
|
186
|
+
const patterns = patternsRaw.filter((p): p is string => typeof p === 'string' && p.length > 0);
|
|
187
|
+
if (patterns.length === 0) return { passed: true };
|
|
188
|
+
if (trace.output === undefined || trace.output.length === 0) return { passed: true };
|
|
189
|
+
const caseSensitive = config.caseSensitive === true;
|
|
190
|
+
const haystack = caseSensitive ? trace.output : trace.output.toLowerCase();
|
|
191
|
+
const violated: string[] = [];
|
|
192
|
+
for (const p of patterns) {
|
|
193
|
+
const needle = caseSensitive ? p : p.toLowerCase();
|
|
194
|
+
if (haystack.includes(needle)) violated.push(p);
|
|
195
|
+
}
|
|
196
|
+
if (violated.length === 0) return { passed: true };
|
|
197
|
+
return {
|
|
198
|
+
passed: false,
|
|
199
|
+
reason: `output contains forbidden pattern(s): ${violated.join(', ')}`,
|
|
200
|
+
attributes: { violated },
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const BUILT_IN_CHECKS: readonly RegisteredCheck[] = [
|
|
205
|
+
{ id: 'must-cite', kind: 'zero-llm', evaluate: mustCite },
|
|
206
|
+
{ id: 'never-call-tool', kind: 'zero-llm', evaluate: neverCallTool },
|
|
207
|
+
{ id: 'max-tool-calls', kind: 'zero-llm', evaluate: maxToolCalls },
|
|
208
|
+
{ id: 'output-matches', kind: 'zero-llm', evaluate: outputMatches },
|
|
209
|
+
{ id: 'tool-order', kind: 'zero-llm', evaluate: toolOrder },
|
|
210
|
+
{ id: 'required-substring', kind: 'zero-llm', evaluate: requiredSubstring },
|
|
211
|
+
{ id: 'forbidden-substring', kind: 'zero-llm', evaluate: forbiddenSubstring },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a fresh CheckRegistry pre-populated with built-in checks.
|
|
216
|
+
* Consumers register custom checks at pack init.
|
|
217
|
+
*/
|
|
218
|
+
export function createCheckRegistry(seed: readonly RegisteredCheck[] = []): CheckRegistry {
|
|
219
|
+
const checks = new Map<string, RegisteredCheck>();
|
|
220
|
+
for (const c of BUILT_IN_CHECKS) checks.set(c.id, c);
|
|
221
|
+
for (const c of seed) checks.set(c.id, c);
|
|
222
|
+
return {
|
|
223
|
+
register(check): void {
|
|
224
|
+
checks.set(check.id, check);
|
|
225
|
+
},
|
|
226
|
+
get(id): RegisteredCheck | undefined {
|
|
227
|
+
return checks.get(id);
|
|
228
|
+
},
|
|
229
|
+
list(): readonly RegisteredCheck[] {
|
|
230
|
+
return [...checks.values()];
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Exported for tests + downstream introspection. */
|
|
236
|
+
export const BUILT_IN_CHECK_IDS = BUILT_IN_CHECKS.map((c) => c.id);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { ValidateFunction } from 'ajv';
|
|
5
|
+
import * as addFormatsModule from 'ajv-formats';
|
|
6
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
7
|
+
|
|
8
|
+
import { isZodSchema, loadZodConverterSync, toJSONSchemaSync } from '@kindgi/schema';
|
|
9
|
+
import type { AnySchema, ZodLikeSchema } from '@kindgi/schema';
|
|
10
|
+
|
|
11
|
+
import type { InvalidCheckDefinitionError } from './errors.js';
|
|
12
|
+
import type { CheckFunction, GuardrailKind, RegisteredCheck } from './types.js';
|
|
13
|
+
|
|
14
|
+
type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
|
|
15
|
+
const addFormatsRaw = addFormatsModule as unknown;
|
|
16
|
+
const addFormats: AddFormatsFn =
|
|
17
|
+
typeof addFormatsRaw === 'function'
|
|
18
|
+
? (addFormatsRaw as AddFormatsFn)
|
|
19
|
+
: (addFormatsRaw as { default: AddFormatsFn }).default;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Author-time inference from a Zod schema's `_zod.output`. Non-Zod
|
|
23
|
+
* schemas fall through to `Readonly<Record<string, unknown>>` — the
|
|
24
|
+
* same type that `Guardrail.config` already carries.
|
|
25
|
+
*/
|
|
26
|
+
export type InferCheckConfig<T> = T extends { readonly _zod: { readonly output: infer O } }
|
|
27
|
+
? O
|
|
28
|
+
: Readonly<Record<string, unknown>>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The author-facing spec accepted by `defineCheck`. Extends
|
|
32
|
+
* `RegisteredCheck` with an optional `configSchema` slot that can be
|
|
33
|
+
* either a JSON Schema object or a Zod v4 schema. When set,
|
|
34
|
+
* `defineCheck` derives `validateConfig` from the schema — authors
|
|
35
|
+
* don't have to write it separately.
|
|
36
|
+
*
|
|
37
|
+
* `evaluate` is typed against the schema-inferred config for Zod-
|
|
38
|
+
* authored checks; JSON-Schema-authored checks fall back to
|
|
39
|
+
* `Readonly<Record<string, unknown>>`.
|
|
40
|
+
*/
|
|
41
|
+
export interface DefineCheckSpec<TConfigSchema extends AnySchema> {
|
|
42
|
+
/**
|
|
43
|
+
* Check identifier. Referenced by `Guardrail.check` on the wire.
|
|
44
|
+
* Convention: dot-namespaced for pack-authored checks (e.g.
|
|
45
|
+
* `'acme.max-citations'`); framework-shipped checks use stable ids
|
|
46
|
+
* like `'must-cite'` / `'never-call-tool'`.
|
|
47
|
+
*/
|
|
48
|
+
readonly id: string;
|
|
49
|
+
/**
|
|
50
|
+
* `'zero-llm'` — pure function over the trace (fast, deterministic,
|
|
51
|
+
* default). `'llm-judge'` — uses a model (opt-in, costs money;
|
|
52
|
+
* needs `bindings.providerRegistry` or `bindings.judgeProvider`).
|
|
53
|
+
* `'external'` — evaluated by a caller-registered strategy.
|
|
54
|
+
*/
|
|
55
|
+
readonly kind: GuardrailKind;
|
|
56
|
+
/**
|
|
57
|
+
* Config schema — Zod v4 or JSON Schema. When present, `defineCheck`
|
|
58
|
+
* derives `RegisteredCheck.validateConfig` from it, and
|
|
59
|
+
* `defineGuardrail` runs that against each `Guardrail.config`.
|
|
60
|
+
* Zod authors get schema-inferred config types on `evaluate`'s first
|
|
61
|
+
* parameter.
|
|
62
|
+
*/
|
|
63
|
+
readonly configSchema?: TConfigSchema;
|
|
64
|
+
/**
|
|
65
|
+
* The check function. Signature: `(config, trace, bindings) => Promise<CheckResult>`.
|
|
66
|
+
* `config` is the guardrail's declared config (typed via `configSchema`
|
|
67
|
+
* when Zod-authored). `trace` is the accumulated `RunTrace` from the
|
|
68
|
+
* agent turn. `bindings` is the `EvaluationBindings` the caller
|
|
69
|
+
* passed to the engine (`{}` when none).
|
|
70
|
+
* Return `{passed: true}` or `{passed: false, reason: string, ...}`.
|
|
71
|
+
*/
|
|
72
|
+
readonly evaluate: (
|
|
73
|
+
config: InferCheckConfig<TConfigSchema>,
|
|
74
|
+
trace: Parameters<CheckFunction>[1],
|
|
75
|
+
bindings: Parameters<CheckFunction>[2],
|
|
76
|
+
) => ReturnType<CheckFunction>;
|
|
77
|
+
/**
|
|
78
|
+
* Optional custom config validator. When `configSchema` is also set,
|
|
79
|
+
* both run — `configSchema` first, then this. `undefined` return means
|
|
80
|
+
* "config is valid"; non-undefined string is the error message.
|
|
81
|
+
*/
|
|
82
|
+
readonly validateConfig?: (config: unknown) => string | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The concrete RegisteredCheck returned by `defineCheck`. When the
|
|
87
|
+
* author provided a Zod schema at `configSchema`, `configZod` is
|
|
88
|
+
* present so TS callers can `z.infer<typeof check.configZod>` for
|
|
89
|
+
* static types.
|
|
90
|
+
*/
|
|
91
|
+
export type DefinedCheck<TConfigSchema extends AnySchema> = RegisteredCheck & {
|
|
92
|
+
readonly configZod: TConfigSchema extends ZodLikeSchema ? TConfigSchema : undefined;
|
|
93
|
+
readonly configJsonSchema?: Readonly<Record<string, unknown>>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build a `RegisteredCheck` with optional schema-derived config
|
|
98
|
+
* validation. Two authoring surfaces coexist:
|
|
99
|
+
* 1. JSON Schema Draft 2020-12 objects — compiled at author time
|
|
100
|
+
* via Ajv; the check's `validateConfig` is derived from the
|
|
101
|
+
* compiled validator.
|
|
102
|
+
* 2. Zod v4 schemas — converted via `z.toJSONSchema()` (peer dep),
|
|
103
|
+
* then compiled the same way. TS callers get `z.infer<typeof
|
|
104
|
+
* check.configZod>` for static config typing.
|
|
105
|
+
*
|
|
106
|
+
* When `configSchema` is omitted, this behaves as a passthrough:
|
|
107
|
+
* returns the check with the caller's `validateConfig` (or undefined).
|
|
108
|
+
*
|
|
109
|
+
* Failures at author time (Zod conversion or Ajv compile) surface via
|
|
110
|
+
* throw — same failure mode as construction of a bad
|
|
111
|
+
* `RegisteredCheck` object literal. Consumers catch at pack-init;
|
|
112
|
+
* downstream `defineGuardrail` runs the derived `validateConfig` at
|
|
113
|
+
* spec-registration time and reports via `Result`.
|
|
114
|
+
*/
|
|
115
|
+
export function defineCheck<TConfigSchema extends AnySchema = AnySchema>(
|
|
116
|
+
spec: DefineCheckSpec<TConfigSchema>,
|
|
117
|
+
): DefinedCheck<TConfigSchema> {
|
|
118
|
+
const converter =
|
|
119
|
+
spec.configSchema !== undefined && isZodSchema(spec.configSchema)
|
|
120
|
+
? loadZodConverterSync()
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
123
|
+
let derivedValidator: ((config: unknown) => string | undefined) | undefined;
|
|
124
|
+
let jsonSchema: Readonly<Record<string, unknown>> | undefined;
|
|
125
|
+
let zodSchema: ZodLikeSchema | undefined;
|
|
126
|
+
|
|
127
|
+
if (spec.configSchema !== undefined) {
|
|
128
|
+
if (isZodSchema(spec.configSchema)) {
|
|
129
|
+
zodSchema = spec.configSchema;
|
|
130
|
+
const converted = toJSONSchemaSync(spec.configSchema, converter, 'input');
|
|
131
|
+
if (converted.kind === 'err') {
|
|
132
|
+
const err: InvalidCheckDefinitionError = {
|
|
133
|
+
code: 'invalid-check-definition',
|
|
134
|
+
message: `Check "${spec.id}" configSchema Zod conversion failed: ${converted.error.message}`,
|
|
135
|
+
checkId: spec.id,
|
|
136
|
+
cause: converted.error.cause,
|
|
137
|
+
};
|
|
138
|
+
throw errorFromDefinition(err);
|
|
139
|
+
}
|
|
140
|
+
jsonSchema = converted.value;
|
|
141
|
+
} else {
|
|
142
|
+
jsonSchema = spec.configSchema as Readonly<Record<string, unknown>>;
|
|
143
|
+
}
|
|
144
|
+
const validator = compileValidator(jsonSchema, spec.id);
|
|
145
|
+
derivedValidator = (config: unknown) => {
|
|
146
|
+
if (!validator(config)) {
|
|
147
|
+
const errors = validator.errors ?? [];
|
|
148
|
+
const first = errors[0] as { instancePath?: string; message?: string } | undefined;
|
|
149
|
+
const path = first?.instancePath ?? '';
|
|
150
|
+
const message = first?.message ?? 'invalid config';
|
|
151
|
+
return `${path} ${message}`.trim();
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const chainValidator = (
|
|
158
|
+
a?: (config: unknown) => string | undefined,
|
|
159
|
+
b?: (config: unknown) => string | undefined,
|
|
160
|
+
): ((config: unknown) => string | undefined) | undefined => {
|
|
161
|
+
if (a === undefined) return b;
|
|
162
|
+
if (b === undefined) return a;
|
|
163
|
+
return (config) => {
|
|
164
|
+
const first = a(config);
|
|
165
|
+
if (first !== undefined) return first;
|
|
166
|
+
return b(config);
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const validate = chainValidator(derivedValidator, spec.validateConfig);
|
|
171
|
+
|
|
172
|
+
const check = {
|
|
173
|
+
id: spec.id,
|
|
174
|
+
kind: spec.kind,
|
|
175
|
+
evaluate: spec.evaluate as CheckFunction,
|
|
176
|
+
...(validate !== undefined && { validateConfig: validate }),
|
|
177
|
+
...(zodSchema !== undefined && { configZod: zodSchema }),
|
|
178
|
+
...(jsonSchema !== undefined && { configJsonSchema: jsonSchema }),
|
|
179
|
+
} as unknown as DefinedCheck<TConfigSchema>;
|
|
180
|
+
|
|
181
|
+
return check;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compileValidator(
|
|
185
|
+
schema: Readonly<Record<string, unknown>>,
|
|
186
|
+
checkId: string,
|
|
187
|
+
): ValidateFunction {
|
|
188
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true, allowUnionTypes: false });
|
|
189
|
+
addFormats(ajv);
|
|
190
|
+
try {
|
|
191
|
+
return ajv.compile(schema as object);
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
const err: InvalidCheckDefinitionError = {
|
|
194
|
+
code: 'invalid-check-definition',
|
|
195
|
+
message: `Check "${checkId}" configSchema does not compile as JSON Schema Draft 2020-12: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
196
|
+
checkId,
|
|
197
|
+
cause,
|
|
198
|
+
};
|
|
199
|
+
throw errorFromDefinition(err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function errorFromDefinition(err: InvalidCheckDefinitionError): Error {
|
|
204
|
+
const wrapped = new Error(err.message);
|
|
205
|
+
Object.assign(wrapped, err);
|
|
206
|
+
return wrapped;
|
|
207
|
+
}
|