Skip to content

Commit ef694b2

Browse files
committed
feat(evaluators): add ends_with mode to list evaluator
1 parent 567f25f commit ef694b2

6 files changed

Lines changed: 219 additions & 7 deletions

File tree

‎evaluators/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ Pattern matching for text (PII, keywords, SQL injection)
2121
### List
2222
Match against value lists (blocked users, restricted cities, allowed prefixes).
2323
Supports `match_mode: "exact"` for full-string membership, `match_mode: "contains"`
24-
for keyword-style matching, and `match_mode: "starts_with"` for prefix matching.
24+
for keyword-style matching, `match_mode: "starts_with"` for prefix matching, and
25+
`match_mode: "ends_with"` for suffix matching.
2526
```python
2627
{"name": "list", "config": {"values": ["admin", "root"], "case_sensitive": False}}
2728
{"name": "list", "config": {"values": ["/home/lev/agent-control"], "match_mode": "starts_with"}}
29+
{"name": "list", "config": {"values": [".md"], "match_mode": "ends_with"}}
2830
```
2931

3032
### SQL

‎evaluators/builtin/src/agent_control_evaluators/list/config.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ class ListEvaluatorConfig(EvaluatorConfig):
1919
match_on: Literal["match", "no_match"] = Field(
2020
"match", description="Trigger rule on match or no match"
2121
)
22-
match_mode: Literal["exact", "contains", "starts_with"] = Field(
22+
match_mode: Literal["exact", "contains", "starts_with", "ends_with"] = Field(
2323
"exact",
2424
description=(
2525
"'exact' for full string match, 'contains' for keyword matching, "
26-
"'starts_with' for prefix matching"
26+
"'starts_with' for prefix matching, and 'ends_with' for suffix matching"
2727
),
2828
)
2929
case_sensitive: bool = Field(False, description="Whether matching is case sensitive")

‎evaluators/builtin/src/agent_control_evaluators/list/evaluator.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class ListEvaluator(Evaluator[ListEvaluatorConfig]):
1818
Checks if data matches values in a list. Supports:
1919
- any/all logic (match any value vs match all values)
2020
- match/no_match trigger (trigger on match or no match)
21-
- exact/contains/starts_with mode (full match vs keyword vs prefix)
21+
- exact/contains/starts_with/ends_with mode (full match vs keyword vs prefix/suffix)
2222
- case sensitivity toggle
2323
2424
Example configs:
@@ -53,6 +53,9 @@ def _build_regex(self) -> Any:
5353
elif self.config.match_mode == "starts_with":
5454
# Prefix matching using anchors
5555
pattern = f"^({'|'.join(escaped)})"
56+
elif self.config.match_mode == "ends_with":
57+
# Suffix matching using anchors
58+
pattern = f"({'|'.join(escaped)})$"
5659
else:
5760
# Exact match using anchors
5861
pattern = f"^({'|'.join(escaped)})$"

‎evaluators/builtin/tests/list/test_list.py‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,212 @@ async def test_starts_with_supports_list_input_with_all_logic(self) -> None:
260260
assert matching_result.matched is True
261261
assert partial_result.matched is False
262262

263+
@pytest.mark.asyncio
264+
async def test_ends_with_matches_suffix(self) -> None:
265+
"""Test that ends_with mode triggers on suffix matches."""
266+
# Given: an ends_with evaluator config
267+
evaluator = ListEvaluator(
268+
ListEvaluatorConfig(
269+
values=["/SOUL.md", ".py"],
270+
logic="any",
271+
match_on="match",
272+
match_mode="ends_with",
273+
case_sensitive=True,
274+
)
275+
)
276+
277+
# When: evaluating a path with an allowed suffix
278+
result = await evaluator.evaluate("/home/lev/agent-control/SOUL.md")
279+
280+
# Then: the suffix match triggers
281+
assert result.matched is True
282+
assert result.metadata["matches"] == ["/home/lev/agent-control/SOUL.md"]
283+
284+
@pytest.mark.asyncio
285+
async def test_ends_with_matches_exact_path_value(self) -> None:
286+
"""Test that ends_with mode matches the configured path value exactly."""
287+
# Given: an ends_with evaluator config
288+
evaluator = ListEvaluator(
289+
ListEvaluatorConfig(
290+
values=["/home/lev/agent-control/SOUL.md"],
291+
logic="any",
292+
match_on="match",
293+
match_mode="ends_with",
294+
case_sensitive=True,
295+
)
296+
)
297+
298+
# When: evaluating the exact configured path
299+
result = await evaluator.evaluate("/home/lev/agent-control/SOUL.md")
300+
301+
# Then: the exact path matches
302+
assert result.matched is True
303+
assert result.metadata["matches"] == ["/home/lev/agent-control/SOUL.md"]
304+
305+
@pytest.mark.asyncio
306+
async def test_ends_with_no_match_when_suffix_absent(self) -> None:
307+
"""Test that ends_with mode does not trigger when no suffix matches."""
308+
# Given: an ends_with evaluator config
309+
evaluator = ListEvaluator(
310+
ListEvaluatorConfig(
311+
values=["/SOUL.md", ".py"],
312+
logic="any",
313+
match_on="match",
314+
match_mode="ends_with",
315+
case_sensitive=True,
316+
)
317+
)
318+
319+
# When: evaluating a path with no configured suffix
320+
result = await evaluator.evaluate("/var/log/system.log")
321+
322+
# Then: the evaluator does not trigger
323+
assert result.matched is False
324+
325+
@pytest.mark.asyncio
326+
async def test_ends_with_honors_case_sensitivity(self) -> None:
327+
"""Test that ends_with mode respects case sensitivity settings."""
328+
# Given: two ends_with evaluators that differ only by case sensitivity
329+
insensitive = ListEvaluator(
330+
ListEvaluatorConfig(
331+
values=["/SOUL.MD"],
332+
logic="any",
333+
match_on="match",
334+
match_mode="ends_with",
335+
case_sensitive=False,
336+
)
337+
)
338+
sensitive = ListEvaluator(
339+
ListEvaluatorConfig(
340+
values=["/SOUL.MD"],
341+
logic="any",
342+
match_on="match",
343+
match_mode="ends_with",
344+
case_sensitive=True,
345+
)
346+
)
347+
348+
# When: evaluating the same lower-case path against both
349+
insensitive_result = await insensitive.evaluate("/home/lev/agent-control/SOUL.md")
350+
sensitive_result = await sensitive.evaluate("/home/lev/agent-control/SOUL.md")
351+
352+
# Then: only the case-insensitive evaluator matches
353+
assert insensitive_result.matched is True
354+
assert sensitive_result.matched is False
355+
356+
@pytest.mark.asyncio
357+
async def test_ends_with_supports_no_match_allowlists(self) -> None:
358+
"""Test that ends_with works with no_match for allowlist-style controls."""
359+
# Given: an ends_with evaluator configured as an allowlist
360+
evaluator = ListEvaluator(
361+
ListEvaluatorConfig(
362+
values=[".md", ".txt"],
363+
logic="any",
364+
match_on="no_match",
365+
match_mode="ends_with",
366+
case_sensitive=True,
367+
)
368+
)
369+
370+
# When: evaluating one allowed and one disallowed path
371+
allowed_result = await evaluator.evaluate("/home/lev/agent-control/SOUL.md")
372+
denied_result = await evaluator.evaluate("/var/log/system.log")
373+
374+
# Then: only the disallowed path triggers the control
375+
assert allowed_result.matched is False
376+
assert denied_result.matched is True
377+
378+
@pytest.mark.asyncio
379+
async def test_ends_with_matches_plain_text_suffix(self) -> None:
380+
"""Test that ends_with mode works for ordinary non-path strings."""
381+
# Given: an ends_with evaluator config for ordinary strings
382+
evaluator = ListEvaluator(
383+
ListEvaluatorConfig(
384+
values=["-control", ":done"],
385+
logic="any",
386+
match_on="match",
387+
match_mode="ends_with",
388+
case_sensitive=True,
389+
)
390+
)
391+
392+
# When: evaluating values with and without the configured suffixes
393+
matched_result = await evaluator.evaluate("agent-control")
394+
unmatched_result = await evaluator.evaluate("done: please")
395+
396+
# Then: only the true suffix match triggers
397+
assert matched_result.matched is True
398+
assert unmatched_result.matched is False
399+
400+
@pytest.mark.asyncio
401+
async def test_ends_with_escapes_regex_metacharacters(self) -> None:
402+
"""Test that ends_with treats configured values as literals, not regex."""
403+
# Given: suffixes containing regex metacharacters
404+
evaluator = ListEvaluator(
405+
ListEvaluatorConfig(
406+
values=[".tar.gz+", "[beta]"],
407+
logic="any",
408+
match_on="match",
409+
match_mode="ends_with",
410+
case_sensitive=True,
411+
)
412+
)
413+
414+
# When: evaluating values that end with those literal suffixes
415+
archive_result = await evaluator.evaluate("release-1.tar.gz+")
416+
beta_result = await evaluator.evaluate("feature-[beta]")
417+
418+
# Then: both values match literally
419+
assert archive_result.matched is True
420+
assert beta_result.matched is True
421+
422+
@pytest.mark.asyncio
423+
async def test_ends_with_supports_list_input_with_any_logic(self) -> None:
424+
"""Test that ends_with works on list inputs when any item matches."""
425+
# Given: an ends_with evaluator with any-item semantics
426+
evaluator = ListEvaluator(
427+
ListEvaluatorConfig(
428+
values=[".md", "-control"],
429+
logic="any",
430+
match_on="match",
431+
match_mode="ends_with",
432+
case_sensitive=True,
433+
)
434+
)
435+
436+
# When: evaluating a list where only one element matches
437+
result = await evaluator.evaluate(["/var/log/system.log", "agent-control"])
438+
439+
# Then: the evaluator triggers and reports the matching entry
440+
assert result.matched is True
441+
assert result.metadata["matches"] == ["agent-control"]
442+
443+
@pytest.mark.asyncio
444+
async def test_ends_with_supports_list_input_with_all_logic(self) -> None:
445+
"""Test that ends_with respects all-item semantics for list inputs."""
446+
# Given: an ends_with evaluator with all-item semantics
447+
evaluator = ListEvaluator(
448+
ListEvaluatorConfig(
449+
values=[".md", ".txt"],
450+
logic="all",
451+
match_on="match",
452+
match_mode="ends_with",
453+
case_sensitive=True,
454+
)
455+
)
456+
457+
# When: evaluating one fully matching list and one partially matching list
458+
matching_result = await evaluator.evaluate(
459+
["/home/lev/agent-control/SOUL.md", "/tmp/cache/notes.txt"]
460+
)
461+
partial_result = await evaluator.evaluate(
462+
["/home/lev/agent-control/SOUL.md", "/var/log/system.log"]
463+
)
464+
465+
# Then: only the fully matching list triggers
466+
assert matching_result.matched is True
467+
assert partial_result.matched is False
468+
263469
@pytest.mark.asyncio
264470
async def test_legacy_empty_string_value_is_ignored_defensively(self) -> None:
265471
"""Test that legacy invalid configs do not compile into a match-all regex."""

‎ui/src/core/evaluators/list/form.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,15 @@ export const ListForm = ({ form }: EvaluatorFormProps<ListFormValues>) => {
7575
label={
7676
<LabelWithTooltip
7777
label="Match mode"
78-
tooltip="Exact string match, keyword contains, or string prefix matching"
78+
tooltip="Exact string match, keyword contains, string prefix matching, or string suffix matching"
7979
/>
8080
}
8181
labelProps={labelPropsInline}
8282
data={[
8383
{ value: 'exact', label: 'Exact (full string match)' },
8484
{ value: 'contains', label: 'Contains (keyword match)' },
8585
{ value: 'starts_with', label: 'Starts with (prefix match)' },
86+
{ value: 'ends_with', label: 'Ends with (suffix match)' },
8687
]}
8788
size="sm"
8889
{...form.getInputProps('match_mode')}

‎ui/src/core/evaluators/list/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export type ListFormValues = {
99
logic: 'any' | 'all';
1010
/** When to trigger: on match or no match */
1111
match_on: 'match' | 'no_match';
12-
/** Match mode: exact, contains, or starts_with */
13-
match_mode: 'exact' | 'contains' | 'starts_with';
12+
/** Match mode: exact, contains, starts_with, or ends_with */
13+
match_mode: 'exact' | 'contains' | 'starts_with' | 'ends_with';
1414
/** Whether matching is case sensitive */
1515
case_sensitive: boolean;
1616
};

0 commit comments

Comments
 (0)