Skip to content

Commit 98d4d5e

Browse files
authored
Merge pull request #12 from lua-ai-global/feat/per-policy-modalities
feat(policy): per-rule scanModalities for content-scanning conditions
2 parents a393d40 + 423e372 commit 98d4d5e

4 files changed

Lines changed: 489 additions & 22 deletions

File tree

packages/governance/src/conditions/builtins.ts

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
* All 25 condition types from the original switch statement, now pluggable.
44
*/
55

6-
import type { ConditionEvaluator, EnforcementContext, PolicyCondition } from "../policy.js";
6+
import type { ConditionEvaluator, EnforcementContext, PolicyCondition, PolicyRule } from "../policy.js";
7+
import { getScanText } from "../policy.js";
78
import { detectInjection } from "../injection-detect.js";
89
import type { InjectionCategory } from "../injection-detect.js";
910
import { evaluateBlocklist, evaluateInputLength, evaluateInputPattern } from "./preprocess.js";
@@ -27,9 +28,11 @@ type BuiltinDef = { name: string; description: string; evaluator: ConditionEvalu
2728
/**
2829
* Create the full list of built-in condition definitions.
2930
* Accepts `evalCondition` so combinators (any_of, all_of, not) can recurse.
31+
* The optional 3rd `rule` arg is forwarded to combinators so the parent
32+
* rule's `scanModalities` propagates into nested conditions.
3033
*/
3134
export function getBuiltinConditions(
32-
evalCondition: (condition: PolicyCondition, ctx: EnforcementContext) => boolean,
35+
evalCondition: (condition: PolicyCondition, ctx: EnforcementContext, rule?: PolicyRule) => boolean,
3336
): BuiltinDef[] {
3437
return [
3538
// ─── Access control ────────────────────────────────────────
@@ -165,11 +168,15 @@ export function getBuiltinConditions(
165168
{
166169
name: "injection_guard",
167170
description: "Detect prompt injection attacks (regex detector, synchronous)",
168-
evaluator: (ctx, p) => {
169-
if (!ctx.input) return false;
171+
evaluator: (ctx, p, rule) => {
170172
const skip = (p.skipCategories ?? []) as InjectionCategory[];
171173
const opts = { threshold: p.threshold as number, skipCategories: skip.length > 0 ? skip : undefined };
172-
for (const str of extractStrings(ctx.input)) {
174+
// When `rule.scanModalities` is set, scan only those modalities'
175+
// pre-extracted text from `ctx.textByModality`. Otherwise fall back
176+
// to the legacy walk over `ctx.input` so existing rules without
177+
// modality config behave identically.
178+
const strings = getScanText(ctx, rule) ?? (ctx.input ? extractStrings(ctx.input) : []);
179+
for (const str of strings) {
173180
if (detectInjection(str, opts).detected) return true;
174181
}
175182
return false;
@@ -181,7 +188,11 @@ export function getBuiltinConditions(
181188
"Consume an ML-classifier score pre-computed by the host. " +
182189
"Async ML classifiers cannot run inside the sync policy engine — the " +
183190
"host runs hybridDetect() (or its own integration) and populates " +
184-
"ctx.mlInjectionScore / ctx.mlInjectionCategories before enforce().",
191+
"ctx.mlInjectionScore / ctx.mlInjectionCategories before enforce(). " +
192+
"When the rule has scanModalities set, the host should run the ML " +
193+
"classifier over the union of those modalities' text and put the " +
194+
"resulting score into mlInjectionScore — modality dispatch happens " +
195+
"at the host's hybridDetect call, not here.",
185196
evaluator: (ctx, p) => {
186197
if (typeof ctx.mlInjectionScore !== "number") return false;
187198
const threshold = (p.threshold as number | undefined) ?? 0.5;
@@ -196,7 +207,24 @@ export function getBuiltinConditions(
196207
{
197208
name: "blocklist",
198209
description: "Block input containing specific terms",
199-
evaluator: (ctx, p) => evaluateBlocklist(ctx, p.terms as string[], p.caseSensitive as boolean | undefined),
210+
evaluator: (ctx, p, rule) => {
211+
const terms = p.terms as string[];
212+
const caseSensitive = p.caseSensitive as boolean | undefined;
213+
const scan = getScanText(ctx, rule);
214+
if (scan) {
215+
// Per-modality scan path. Search each contributing modality's
216+
// text for any of the terms.
217+
for (const text of scan) {
218+
const haystack = caseSensitive ? text : text.toLowerCase();
219+
for (const t of terms) {
220+
const needle = caseSensitive ? t : t.toLowerCase();
221+
if (haystack.includes(needle)) return true;
222+
}
223+
}
224+
return false;
225+
}
226+
return evaluateBlocklist(ctx, terms, caseSensitive);
227+
},
200228
},
201229
{
202230
name: "input_length",
@@ -206,7 +234,16 @@ export function getBuiltinConditions(
206234
{
207235
name: "input_pattern",
208236
description: "Block input matching a regex",
209-
evaluator: (ctx, p) => evaluateInputPattern(ctx, p.pattern as string, p.flags as string | undefined),
237+
evaluator: (ctx, p, rule) => {
238+
const pattern = p.pattern as string;
239+
const flags = p.flags as string | undefined;
240+
const scan = getScanText(ctx, rule);
241+
if (scan) {
242+
const regex = new RegExp(pattern, flags);
243+
return scan.some((text) => regex.test(text));
244+
}
245+
return evaluateInputPattern(ctx, pattern, flags);
246+
},
210247
},
211248
// ─── Output safety (postprocess) ───────────────────────────
212249
{
@@ -217,12 +254,35 @@ export function getBuiltinConditions(
217254
{
218255
name: "output_pattern",
219256
description: "Detect patterns in output",
220-
evaluator: (ctx, p) => evaluateOutputPattern(ctx, p.pattern as string, p.flags as string | undefined),
257+
evaluator: (ctx, p, rule) => {
258+
const pattern = p.pattern as string;
259+
const flags = p.flags as string | undefined;
260+
const scan = getScanText(ctx, rule);
261+
if (scan) {
262+
const regex = new RegExp(pattern, flags);
263+
return scan.some((text) => regex.test(text));
264+
}
265+
return evaluateOutputPattern(ctx, pattern, flags);
266+
},
221267
},
222268
{
223269
name: "sensitive_data_filter",
224270
description: "Detect leaked credentials and secrets",
225-
evaluator: (ctx, p) => evaluateSensitiveDataFilter(ctx, p.patterns as string[] | undefined),
271+
evaluator: (ctx, p, rule) => {
272+
const patternIds = p.patterns as string[] | undefined;
273+
const scan = getScanText(ctx, rule);
274+
if (scan) {
275+
// Reuse the postprocess helper by temporarily overriding
276+
// outputText with each modality's text — keeps a single source
277+
// of truth for the sensitive-pattern set.
278+
for (const text of scan) {
279+
const proxy = { ...ctx, outputText: text } as EnforcementContext;
280+
if (evaluateSensitiveDataFilter(proxy, patternIds)) return true;
281+
}
282+
return false;
283+
}
284+
return evaluateSensitiveDataFilter(ctx, patternIds);
285+
},
226286
},
227287
// ─── Identity ─────────────────────────────────────────────
228288
{
@@ -235,28 +295,43 @@ export function getBuiltinConditions(
235295
},
236296
},
237297
// ─── Combinators ───────────────────────────────────────────
298+
// Combinators synthesise a per-child rule view: the parent's
299+
// `scanModalities` is preserved, but `condition` is rebound to the
300+
// nested type. This lets `getScanText()` check the CHILD's eligibility
301+
// (e.g. `input_pattern` supports modalities) while still using the
302+
// PARENT's modality config — so an `any_of` over `injection_guard` +
303+
// `blocklist` with `scanModalities: ["image"]` correctly scopes both
304+
// sub-checks to image-extracted text.
238305
{
239306
name: "any_of",
240307
description: "Match if any sub-condition matches",
241-
evaluator: (ctx, p) => {
308+
evaluator: (ctx, p, rule) => {
242309
const conditions = p.conditions as PolicyCondition[];
243-
return conditions.some((c) => evalCondition(c, ctx));
310+
return conditions.some((c) =>
311+
evalCondition(c, ctx, rule ? { ...rule, condition: c } : undefined),
312+
);
244313
},
245314
},
246315
{
247316
name: "all_of",
248317
description: "Match if all sub-conditions match",
249-
evaluator: (ctx, p) => {
318+
evaluator: (ctx, p, rule) => {
250319
const conditions = p.conditions as PolicyCondition[];
251-
return conditions.every((c) => evalCondition(c, ctx));
320+
return conditions.every((c) =>
321+
evalCondition(c, ctx, rule ? { ...rule, condition: c } : undefined),
322+
);
252323
},
253324
},
254325
{
255326
name: "not",
256327
description: "Invert a condition",
257-
evaluator: (ctx, p) => {
328+
evaluator: (ctx, p, rule) => {
258329
const condition = p.condition as PolicyCondition;
259-
return !evalCondition(condition, ctx);
330+
return !evalCondition(
331+
condition,
332+
ctx,
333+
rule ? { ...rule, condition } : undefined,
334+
);
260335
},
261336
},
262337
];

0 commit comments

Comments
 (0)