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" ;
78import { detectInjection } from "../injection-detect.js" ;
89import type { InjectionCategory } from "../injection-detect.js" ;
910import { 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 */
3134export 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