503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""Tests for the Policy type hierarchy and PolicyEngine."""
|
|
|
|
import asyncio
|
|
import sys
|
|
import unittest
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from omnigent.inner.datamodel import AgentDef, ExecutorSpec
|
|
from omnigent.inner.executor import MockExecutor
|
|
from omnigent.inner.policies import (
|
|
FunctionPolicy,
|
|
PolicyAction,
|
|
PolicyResult,
|
|
PolicyRuntimeContext,
|
|
PromptPolicy,
|
|
)
|
|
from tests.resources.examples._shared.rate_limit_policy import (
|
|
max_tool_calls_per_turn,
|
|
)
|
|
|
|
|
|
def _run(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
class TestFunctionPolicy(unittest.TestCase):
|
|
def test_allow_by_default(self):
|
|
async def _t():
|
|
r = await FunctionPolicy(name="noop").evaluate("x", "request")
|
|
self.assertEqual(r.action, PolicyAction.ALLOW)
|
|
|
|
_run(_t())
|
|
|
|
def test_sync_callable_block(self):
|
|
def block_bad(event):
|
|
data = event.get("data", "")
|
|
if isinstance(data, str) and "badword" in data:
|
|
return PolicyResult(action=PolicyAction.DENY, reason="Profanity")
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
async def _t():
|
|
p = FunctionPolicy(name="p", on=["request"], callable=block_bad)
|
|
r = await p.evaluate("has badword", "request")
|
|
self.assertEqual(r.action, PolicyAction.DENY)
|
|
self.assertIn("Profanity", r.reason)
|
|
|
|
_run(_t())
|
|
|
|
def test_sync_callable_allow(self):
|
|
async def _t():
|
|
p = FunctionPolicy(
|
|
name="p", callable=lambda event, config: PolicyResult(action=PolicyAction.ALLOW)
|
|
)
|
|
r = await p.evaluate("clean", "request")
|
|
self.assertEqual(r.action, PolicyAction.ALLOW)
|
|
|
|
_run(_t())
|
|
|
|
def test_async_callable(self):
|
|
async def check(c, ph):
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
async def _t():
|
|
r = await FunctionPolicy(name="a", callable=check).evaluate("x", "request")
|
|
self.assertEqual(r.action, PolicyAction.ALLOW)
|
|
|
|
_run(_t())
|
|
|
|
def test_callable_returns_dict(self):
|
|
async def _t():
|
|
p = FunctionPolicy(
|
|
name="d",
|
|
callable=lambda event, config: {
|
|
"result": "deny",
|
|
"reason": "t",
|
|
},
|
|
)
|
|
r = await p.evaluate("x", "request")
|
|
self.assertEqual(r.action, PolicyAction.DENY)
|
|
|
|
_run(_t())
|
|
|
|
def test_deny_action_from_dict(self):
|
|
def redact(event):
|
|
data = event.get("data", "")
|
|
if isinstance(data, str) and "secret" in data:
|
|
return {"result": "deny", "reason": "explicit deny"}
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
async def _t():
|
|
r = await FunctionPolicy(name="r", on=["response"], callable=redact).evaluate(
|
|
"the secret is 42", "response"
|
|
)
|
|
self.assertEqual(r.action, PolicyAction.DENY)
|
|
self.assertEqual(r.reason, "explicit deny")
|
|
|
|
_run(_t())
|
|
|
|
|
|
class TestRateLimitPolicy(unittest.TestCase):
|
|
def test_tool_call_rate_limit(self):
|
|
async def _t():
|
|
p = FunctionPolicy(
|
|
name="rl", on=["tool_call"], callable=max_tool_calls_per_turn(limit=2)
|
|
)
|
|
self.assertEqual(
|
|
(await p.evaluate({"tool": "x"}, "tool_call")).action, PolicyAction.ALLOW
|
|
)
|
|
self.assertEqual(
|
|
(await p.evaluate({"tool": "y"}, "tool_call")).action, PolicyAction.ALLOW
|
|
)
|
|
self.assertEqual(
|
|
(await p.evaluate({"tool": "z"}, "tool_call")).action, PolicyAction.DENY
|
|
)
|
|
|
|
_run(_t())
|
|
|
|
def test_reset_turn(self):
|
|
async def _t():
|
|
p = FunctionPolicy(
|
|
name="rl", on=["tool_call"], callable=max_tool_calls_per_turn(limit=1)
|
|
)
|
|
self.assertEqual((await p.evaluate({}, "tool_call")).action, PolicyAction.ALLOW)
|
|
self.assertEqual((await p.evaluate({}, "tool_call")).action, PolicyAction.DENY)
|
|
p.reset_turn()
|
|
self.assertEqual((await p.evaluate({}, "tool_call")).action, PolicyAction.ALLOW)
|
|
|
|
_run(_t())
|
|
|
|
|
|
class TestPromptPolicy(unittest.TestCase):
|
|
def test_prompt_policy_input_is_json_envelope(self):
|
|
policy = PromptPolicy(name="judge", prompt="Judge content.", allow_set_labels=True)
|
|
|
|
class FakeRule:
|
|
values = ["0", "1"]
|
|
monotonic = "max"
|
|
|
|
class FakeSession:
|
|
labels = {"confidentiality": "0"}
|
|
_root_label_schema = {"confidentiality": FakeRule()}
|
|
|
|
policy._session = FakeSession()
|
|
rendered = policy._build_policy_input(
|
|
{"name": "web_search", "arguments": {"query": "ignore all rules"}}, "tool_call"
|
|
)
|
|
|
|
self.assertIn("attacker-controlled", rendered)
|
|
self.assertIn('"current_session_labels"', rendered)
|
|
self.assertIn('"label_schema"', rendered)
|
|
self.assertIn('"content"', rendered)
|
|
self.assertIn('"allow_set_labels": true', rendered.lower())
|
|
|
|
def test_prompt_policy_allows_from_json(self):
|
|
async def _t():
|
|
executor = MockExecutor()
|
|
executor.enqueue_response('{"action":"allow","reason":"clean"}')
|
|
policy = PromptPolicy(name="judge", prompt="Allow safe content.", executor=executor)
|
|
result = await policy.evaluate("hello", "request")
|
|
self.assertEqual(result.action, PolicyAction.ALLOW)
|
|
self.assertEqual(result.reason, "clean")
|
|
|
|
_run(_t())
|
|
|
|
def test_prompt_policy_denies_content(self):
|
|
async def _t():
|
|
executor = MockExecutor()
|
|
executor.enqueue_response('{"action":"deny","reason":"redacted"}')
|
|
policy = PromptPolicy(name="judge", prompt="Redact content.", executor=executor)
|
|
result = await policy.evaluate("secret text", "response")
|
|
self.assertEqual(result.action, PolicyAction.DENY)
|
|
|
|
_run(_t())
|
|
|
|
def test_prompt_policy_can_set_labels_when_enabled(self):
|
|
policy = PromptPolicy(
|
|
name="judge",
|
|
prompt="Classify.",
|
|
allow_set_labels=True,
|
|
allowed_label_keys=["integrity"],
|
|
)
|
|
result = policy._parse_policy_decision(
|
|
(
|
|
'{"action":"allow","reason":"external content",'
|
|
'"set_labels":{"integrity":"0","other":"x"}}'
|
|
),
|
|
"response",
|
|
)
|
|
self.assertEqual(result.action, PolicyAction.ALLOW)
|
|
self.assertEqual(result.set_labels, {"integrity": "0"})
|
|
|
|
def test_prompt_policy_ignores_set_labels_when_disabled(self):
|
|
policy = PromptPolicy(name="judge", prompt="Classify.", allow_set_labels=False)
|
|
result = policy._parse_policy_decision(
|
|
'{"action":"allow","reason":"x","set_labels":{"integrity":"0"}}',
|
|
"response",
|
|
)
|
|
self.assertEqual(result.set_labels, {})
|
|
|
|
def test_prompt_policy_uses_configured_executor_spec(self):
|
|
captured = {}
|
|
|
|
def make_executor(agent_def: AgentDef) -> MockExecutor:
|
|
captured["executor"] = agent_def.executor
|
|
executor = MockExecutor()
|
|
executor.enqueue_response('{"action":"deny","reason":"nope"}')
|
|
return executor
|
|
|
|
async def _t():
|
|
policy = PromptPolicy(
|
|
name="judge",
|
|
prompt="Block this.",
|
|
executor=ExecutorSpec(model="policy-model"),
|
|
)
|
|
policy.bind_runtime(
|
|
PolicyRuntimeContext(
|
|
default_executor_spec=ExecutorSpec(
|
|
model="base-model",
|
|
harness="open-responses",
|
|
profile="default-profile",
|
|
),
|
|
executor_factory=make_executor,
|
|
)
|
|
)
|
|
result = await policy.evaluate("secret", "request")
|
|
self.assertEqual(result.action, PolicyAction.DENY)
|
|
self.assertEqual(
|
|
captured["executor"],
|
|
ExecutorSpec(
|
|
model="policy-model",
|
|
harness="open-responses",
|
|
profile="default-profile",
|
|
),
|
|
)
|
|
|
|
_run(_t())
|
|
|
|
def test_prompt_policy_loader_fields(self):
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
agent = load_agent_def(
|
|
{
|
|
"name": "t",
|
|
"policies": {
|
|
"judge": {
|
|
"type": "prompt",
|
|
"on": ["request"],
|
|
"prompt": "Judge.",
|
|
"allow_set_labels": True,
|
|
"allowed_label_keys": ["integrity", "confidentiality"],
|
|
}
|
|
},
|
|
}
|
|
)
|
|
policy = agent.policies["judge"]
|
|
self.assertTrue(policy.allow_set_labels)
|
|
self.assertEqual(policy.allowed_label_keys, ["integrity", "confidentiality"])
|
|
|
|
def test_prompt_policy_invalid_json_blocks(self):
|
|
async def _t():
|
|
executor = MockExecutor()
|
|
executor.enqueue_response("not json")
|
|
policy = PromptPolicy(name="judge", prompt="Judge.", executor=executor)
|
|
result = await policy.evaluate("hello", "request")
|
|
self.assertEqual(result.action, PolicyAction.DENY)
|
|
self.assertIn("failed", result.reason.lower())
|
|
|
|
_run(_t())
|
|
|
|
|
|
async def _evaluate_capturing(
|
|
callable_fn: Callable[[list[tuple[Any, ...]]], Callable[..., Any]],
|
|
) -> list[tuple[Any, ...]]:
|
|
"""
|
|
Drive ``FunctionPolicy.evaluate`` once and return the argv list
|
|
that the supplied callable observed.
|
|
|
|
The callable is wrapped to record its positional args into a
|
|
list shared with the caller. After ``evaluate`` returns, the
|
|
test asserts on the captured shape — letting us check whether
|
|
the dispatch passed 1 arg (short form, ``(event,)``) or 2 args
|
|
(long form with ``config``, ``(event, config)``) without
|
|
touching the private helper.
|
|
|
|
:param callable_fn: A factory that takes a ``capture`` list
|
|
and returns the policy callable that appends its received
|
|
positionals into ``capture``. The factory shape lets the
|
|
test build callables with different signatures
|
|
(``*args``, ``(event,)``, ``(event, config)``) while still
|
|
sharing the capture mechanism.
|
|
:returns: The captured positional argv from the single
|
|
``evaluate`` invocation, e.g. ``[({"type": "request", ...},)]``
|
|
or ``[({"type": "request", ...}, {})]``.
|
|
"""
|
|
capture: list[tuple[Any, ...]] = []
|
|
fn = callable_fn(capture)
|
|
policy = FunctionPolicy(name="capture", callable=fn)
|
|
await policy.evaluate("hello", "request")
|
|
return capture
|
|
|
|
|
|
def test_var_positional_callable_receives_config() -> None:
|
|
"""
|
|
A sync policy callable defined as ``def fn(*args)`` accepts
|
|
arbitrary positional arguments — including the optional 2nd
|
|
``config`` arg. Before the fix, the arity check filtered
|
|
parameters to POSITIONAL_ONLY + POSITIONAL_OR_KEYWORD only,
|
|
so ``*args`` (VAR_POSITIONAL) counted as zero, the dispatch
|
|
chose the 1-arg branch and config was never passed.
|
|
|
|
V0 contract: ``*args`` callables receive ``(event, config)``
|
|
where ``event`` is the V0 event dict and ``config`` is the
|
|
runtime configuration dict (empty dict when unset).
|
|
|
|
Failure mode if regressed: variadic policies silently miss
|
|
config — a policy author writing ``def policy(*args)`` sees
|
|
an unexpectedly short argv and never receives the config dict.
|
|
"""
|
|
|
|
def _factory(capture: list[tuple[Any, ...]]):
|
|
def policy(*args):
|
|
capture.append(tuple(args))
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
return policy
|
|
|
|
captured = _run(_evaluate_capturing(_factory))
|
|
|
|
# The test invokes ``evaluate`` exactly once, so exactly one
|
|
# capture row should land. Anything else means the wrapper
|
|
# ran the callable more than once or not at all.
|
|
assert len(captured) == 1, f"expected one invocation; got {captured!r}"
|
|
|
|
# 2 positional args proves dispatch took the long-form branch.
|
|
# If we see 1, ``_accepts_config`` regressed for *args.
|
|
args = captured[0]
|
|
assert len(args) == 2, (
|
|
f"*args callable should have received (event, config); "
|
|
f"got {args!r} (len={len(args)}). If len is 1, the arity check "
|
|
f"filtered out VAR_POSITIONAL and silently dropped config."
|
|
)
|
|
|
|
# Args land in their documented positions. Catches a regression
|
|
# where the dispatch order is reshuffled — the public contract
|
|
# is event first, config second.
|
|
event, config = args
|
|
assert isinstance(event, dict), f"event should be a dict, got {type(event).__name__}"
|
|
assert event.get("type") == "request", f"event['type'] should be 'request'; got {event!r}"
|
|
assert event.get("data") == "hello", f"event['data'] should be 'hello'; got {event!r}"
|
|
# Config shape: a dict (empty when policy.config is None).
|
|
assert isinstance(config, dict), f"config should be a dict, got {type(config).__name__}"
|
|
|
|
|
|
def test_var_positional_async_callable_receives_config() -> None:
|
|
"""
|
|
Async sibling of the *args repro. The async dispatch path
|
|
(``_async_call_policy_callable``) goes through the same
|
|
``_accepts_config`` helper, so the fix must apply equally.
|
|
Pin both paths so a future split that touches one but not
|
|
the other is caught.
|
|
|
|
V0 contract: async ``*args`` callables receive ``(event, config)``
|
|
— 2 positionals — matching the sync path.
|
|
"""
|
|
|
|
def _factory(capture: list[tuple[Any, ...]]):
|
|
async def policy(*args):
|
|
capture.append(tuple(args))
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
return policy
|
|
|
|
captured = _run(_evaluate_capturing(_factory))
|
|
assert len(captured) == 1, f"expected one invocation; got {captured!r}"
|
|
args = captured[0]
|
|
assert len(args) == 2, (
|
|
f"async *args callable should have received 2 positionals "
|
|
f"(event, config); got {args!r} (len={len(args)}). "
|
|
f"If 1, the async dispatch regressed independently of the sync one."
|
|
)
|
|
event, config = args
|
|
assert isinstance(event, dict), f"event should be a dict, got {type(event).__name__}"
|
|
assert event.get("type") == "request"
|
|
assert isinstance(config, dict), f"config should be a dict, got {type(config).__name__}"
|
|
|
|
|
|
def test_two_positional_callable_receives_config() -> None:
|
|
"""
|
|
V0 contract: a callable with exactly 2 positional parameters
|
|
``(event, config)`` receives both args. Under the new interface
|
|
arity >= 2 triggers the long-form dispatch, so 2-arg callables
|
|
now receive config (unlike the old ``(content, phase)`` short-form
|
|
that was mapped to 2 args but skipped context).
|
|
|
|
Pinned so a future "always pass 1" simplification doesn't slip
|
|
through and silently drop config from 2-arg callables.
|
|
"""
|
|
|
|
def _factory(capture: list[tuple[Any, ...]]):
|
|
def policy(event, config):
|
|
capture.append((event, config))
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
return policy
|
|
|
|
captured = _run(_evaluate_capturing(_factory))
|
|
assert len(captured) == 1
|
|
args = captured[0]
|
|
assert len(args) == 2, (
|
|
f"2-positional callable should have received exactly 2 args (event, config); got {args!r}."
|
|
)
|
|
event, config = args
|
|
assert isinstance(event, dict), f"event should be a dict, got {type(event).__name__}"
|
|
assert event.get("type") == "request"
|
|
assert event.get("data") == "hello"
|
|
assert isinstance(config, dict), f"config should be a dict, got {type(config).__name__}"
|
|
|
|
|
|
def test_three_positional_callable_receives_config_via_second_arg() -> None:
|
|
"""
|
|
A callable with 3 explicit positional parameters has arity >= 2,
|
|
so ``_accepts_config`` returns True and it receives ``(event, config)``.
|
|
The 3rd parameter is never filled — the dispatch always passes exactly
|
|
2 args for config-accepting callables.
|
|
|
|
This test pins that the VAR_POSITIONAL short-circuit in ``_accepts_config``
|
|
doesn't break the explicit >= 2 arity count for normal positionals.
|
|
"""
|
|
|
|
def _factory(capture: list[tuple[Any, ...]]):
|
|
# Use a default so the 3rd param is optional — the dispatch only
|
|
# passes 2 args, so the callable would TypeError without a default.
|
|
def policy(event, config, extra=None):
|
|
capture.append((event, config, extra))
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
return policy
|
|
|
|
captured = _run(_evaluate_capturing(_factory))
|
|
assert len(captured) == 1
|
|
args = captured[0]
|
|
# 3 elements because we explicitly appended 3 items; dispatch only
|
|
# passed 2 positionals, so extra=None (the default).
|
|
assert len(args) == 3
|
|
event, config, extra = args
|
|
assert isinstance(event, dict), f"event should be a dict, got {type(event).__name__}"
|
|
assert event.get("type") == "request"
|
|
assert event.get("data") == "hello"
|
|
assert isinstance(config, dict), f"config should be a dict, got {type(config).__name__}"
|
|
# extra was not passed by the dispatcher — it should be the default None.
|
|
assert extra is None, f"3rd param should be None (not passed by dispatch); got {extra!r}"
|
|
|
|
|
|
def test_event_then_var_positional_receives_config() -> None:
|
|
"""
|
|
Hybrid signature ``def fn(event, *args)``: 1 named positional plus
|
|
a variadic tail. ``*args`` flags the callable as config-accepting,
|
|
so the dispatch passes ``(event, config)`` and ``*args`` absorbs
|
|
``config``.
|
|
|
|
Without the VAR_POSITIONAL branch, ``_accepts_config`` would count
|
|
only 1 explicit positional (< 2) and choose the short-form branch,
|
|
leaving ``*args`` always empty — silently masking the author's intent
|
|
to receive config.
|
|
"""
|
|
|
|
def _factory(capture: list[tuple[Any, ...]]):
|
|
def policy(event, *extra):
|
|
capture.append((event, extra))
|
|
return PolicyResult(action=PolicyAction.ALLOW)
|
|
|
|
return policy
|
|
|
|
captured = _run(_evaluate_capturing(_factory))
|
|
assert len(captured) == 1
|
|
event, extra = captured[0]
|
|
assert isinstance(event, dict), f"event should be a dict, got {type(event).__name__}"
|
|
assert event.get("type") == "request"
|
|
assert event.get("data") == "hello"
|
|
# ``extra`` is the *args tuple. With the fix, it should
|
|
# contain exactly the config dict. Without the fix, it would
|
|
# be ``()`` — config was dropped.
|
|
assert len(extra) == 1, (
|
|
f"*args tail should have absorbed the config arg; got "
|
|
f"extra={extra!r}. Empty tuple means dispatch took the "
|
|
f"1-arg branch and config was never passed."
|
|
)
|
|
assert isinstance(extra[0], dict), (
|
|
f"config absorbed into *args should be a dict; got {extra[0]!r}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|