diff --git a/server/src/agent_control_server/bootstrap/out_of_box_controls.py b/server/src/agent_control_server/bootstrap/out_of_box_controls.py index 3ba30b1d..96ca5d65 100644 --- a/server/src/agent_control_server/bootstrap/out_of_box_controls.py +++ b/server/src/agent_control_server/bootstrap/out_of_box_controls.py @@ -1,9 +1,5 @@ """Startup bootstrap for out-of-box controls. -Phase 1 provides the tooling needed to seed controls safely, but does not -register the static out-of-box control catalog yet. Phase 2 should add those -definitions to ``OUT_OF_BOX_CONTROL_TEMPLATES``. - Namespace rule: - Standalone Agent Control seeds into ``DEFAULT_NAMESPACE_KEY``. - Galileo-integrated Agent Control should call the same helper with @@ -35,6 +31,7 @@ ) _INITIAL_VERSION_NOTE = "Out-of-box control seed" _SLUG_NAME_ADAPTER = TypeAdapter(SlugName) +_OUT_OF_BOX_TAGS = ["out-of-box"] @dataclass(frozen=True, slots=True) @@ -104,7 +101,250 @@ def skipped_count(self) -> int: ) -OUT_OF_BOX_CONTROL_TEMPLATES: tuple[OutOfBoxControlTemplate, ...] = () +def _leaf_control_payload( + *, + description: str, + selector_path: str, + evaluator_name: str, + evaluator_config: Mapping[str, object], + step_types: list[str], + stages: list[str], + decision: str, + tags: list[str], + steering_message: str | None = None, +) -> dict[str, object]: + action: dict[str, object] = {"decision": decision} + if steering_message is not None: + action["steering_context"] = {"message": steering_message} + + return { + "description": description, + "enabled": True, + "execution": "server", + "scope": {"step_types": step_types, "stages": stages}, + "condition": { + "selector": {"path": selector_path}, + "evaluator": { + "name": evaluator_name, + "config": dict(evaluator_config), + }, + }, + "action": action, + "tags": [*_OUT_OF_BOX_TAGS, *tags], + } + + +OUT_OF_BOX_CONTROL_TEMPLATES: tuple[OutOfBoxControlTemplate, ...] = ( + OutOfBoxControlTemplate.from_payload( + name="oob-ssn-match", + data=_leaf_control_payload( + description="Block LLM output containing US Social Security Numbers.", + selector_path="output", + evaluator_name="regex", + evaluator_config={"pattern": r"\b\d{3}-\d{2}-\d{4}\b"}, + step_types=["llm"], + stages=["post"], + decision="deny", + tags=["pii", "regex"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-credit-card-number-match", + data=_leaf_control_payload( + description="Block LLM output containing common credit-card-like numbers.", + selector_path="output", + evaluator_name="regex", + evaluator_config={"pattern": r"\b(?:\d[ -]?){13,19}\b"}, + step_types=["llm"], + stages=["post"], + decision="deny", + tags=["pii", "payment", "regex"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-phone-number-match", + data=_leaf_control_payload( + description="Block LLM output containing common US phone number formats.", + selector_path="output", + evaluator_name="regex", + evaluator_config={ + "pattern": ( + r"\b(?:\+?1[-.\s]?)?(?:\(?[2-9]\d{2}\)?[-.\s]?)?" + r"[2-9]\d{2}[-.\s]?\d{4}\b" + ) + }, + step_types=["llm"], + stages=["post"], + decision="deny", + tags=["pii", "regex"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-dangerous-shell-command-match", + data=_leaf_control_payload( + description="Block tool commands matching common destructive shell operations.", + selector_path="input.command", + evaluator_name="regex", + evaluator_config={ + "pattern": ( + r"\b(?:rm\s+-rf\s+(?:/|~|\$HOME)|sudo\s+rm\s+-rf|" + r"mkfs(?:\.[a-z0-9]+)?|dd\s+if=[^\s]+\s+of=/dev/[^\s]+|" + r"chmod\s+-R\s+777\s+/|chown\s+-R\s+[^|;&]*\s+/|" + r"shutdown\s+(?:-h\s+)?now|reboot)\b" + ), + "flags": ["IGNORECASE"], + }, + step_types=["tool"], + stages=["pre"], + decision="deny", + tags=["tool", "shell", "regex"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-high-value-action-requires-approval", + data=_leaf_control_payload( + description=( + "Steer tool calls over the default amount threshold to collect approval." + ), + selector_path="input", + evaluator_name="json", + evaluator_config={ + "json_schema": { + "type": "object", + "anyOf": [ + {"not": {"required": ["amount"]}}, + { + "required": ["amount"], + "properties": { + "amount": {"type": "number", "maximum": 10000} + }, + }, + { + "required": ["amount"], + "properties": { + "amount": {"type": "number", "exclusiveMinimum": 10000} + }, + "anyOf": [ + { + "required": ["approved"], + "properties": {"approved": {"const": True}}, + }, + { + "required": ["approval"], + "properties": { + "approval": { + "type": "object", + "required": ["approved"], + "properties": {"approved": {"const": True}}, + } + }, + }, + ], + }, + ], + } + }, + step_types=["tool"], + stages=["pre"], + decision="steer", + steering_message=( + "This high-value action requires approval. Ask for approval, record it " + "in the tool input, then retry." + ), + tags=["tool", "approval", "json"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-outbound-communication-requires-approval", + data=_leaf_control_payload( + description=( + "Steer outbound communication tool calls to collect approval before sending." + ), + selector_path="input", + evaluator_name="json", + evaluator_config={ + "json_schema": { + "type": "object", + "anyOf": [ + { + "not": { + "anyOf": [ + {"required": ["to"]}, + {"required": ["recipient"]}, + {"required": ["recipients"]}, + {"required": ["email"]}, + {"required": ["phone_number"]}, + {"required": ["channel"]}, + {"required": ["destination"]}, + ] + } + }, + { + "required": ["approved"], + "properties": {"approved": {"const": True}}, + }, + { + "required": ["approval"], + "properties": { + "approval": { + "type": "object", + "required": ["approved"], + "properties": {"approved": {"const": True}}, + } + }, + }, + ], + } + }, + step_types=["tool"], + stages=["pre"], + decision="steer", + steering_message=( + "Outbound communication requires approval. Ask the user to approve the " + "recipient and message before sending." + ), + tags=["tool", "approval", "exfiltration", "json"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-sensitive-tool-requires-approved-role", + data=_leaf_control_payload( + description="Deny sensitive tool use when runtime context has an unapproved role.", + selector_path="context.user.role", + evaluator_name="list", + evaluator_config={ + "values": ["admin", "security", "compliance", "manager"], + "logic": "any", + "match_on": "no_match", + "match_mode": "exact", + "case_sensitive": False, + }, + step_types=["tool"], + stages=["pre"], + decision="deny", + tags=["tool", "rbac", "list"], + ), + ), + OutOfBoxControlTemplate.from_payload( + name="oob-only-approved-tools-may-run", + data=_leaf_control_payload( + description="Deny tool calls whose step name is not in the approved tool list.", + selector_path="name", + evaluator_name="list", + evaluator_config={ + "values": ["search", "web_search", "retrieve", "calculator"], + "logic": "any", + "match_on": "no_match", + "match_mode": "exact", + "case_sensitive": False, + }, + step_types=["tool"], + stages=["pre"], + decision="deny", + tags=["tool", "allowlist", "list"], + ), + ), +) def default_out_of_box_namespace_key() -> str: diff --git a/server/src/agent_control_server/config.py b/server/src/agent_control_server/config.py index fe481881..598a4bde 100644 --- a/server/src/agent_control_server/config.py +++ b/server/src/agent_control_server/config.py @@ -207,7 +207,6 @@ class Settings(BaseSettings): "AGENT_CONTROL_ALLOW_HEADERS", "ALLOW_HEADERS", ) - def get_cors_origins(self) -> list[str]: """Parse CORS origins from string or list.""" return self._parse_list_setting(self.cors_origins) diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index d328c7f9..39be7f72 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -43,7 +43,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..auth_framework import Operation, Principal, get_authorizer, require_operation -from ..db import get_async_db +from ..bootstrap.out_of_box_controls import ( + default_out_of_box_namespace_key, + seed_out_of_box_controls, +) +from ..db import AsyncSessionLocal, get_async_db from ..errors import ( APIError, APIValidationError, @@ -54,7 +58,7 @@ NotFoundError, ) from ..logging_utils import get_logger -from ..models import Agent, AgentData +from ..models import Agent, AgentData, Control from ..services.condition_traversal import iter_condition_leaves_with_paths from ..services.control_bindings import ControlBindingsService from ..services.control_definitions import parse_control_definition_or_api_error @@ -257,6 +261,78 @@ def _validate_attachment_filters( ) +async def _seed_out_of_box_controls_for_namespace( + db: AsyncSession, + *, + namespace_key: str, +) -> None: + """Best-effort namespace seeding for browse/list surfaces.""" + try: + if namespace_key == default_out_of_box_namespace_key(): + return + if await _namespace_has_active_controls(db, namespace_key=namespace_key): + return + await seed_out_of_box_controls( + session_factory=AsyncSessionLocal, + namespace_key=namespace_key, + available_evaluators=set(list_evaluators().keys()), + ) + except Exception: + await db.rollback() + _logger.warning( + "Out-of-box control seed failed for namespace '%s'; continuing request", + namespace_key, + exc_info=True, + ) + + +def _should_seed_out_of_box_controls_on_list( + *, + cursor: int | None, + name: str | None, + enabled: bool | None, + template_backed: bool | None, + cloned: bool | None, + step_type: str | None, + stage: str | None, + execution: str | None, + tag: str | None, + include_attachments: bool, + attachment_target_type: str | None, + attachment_target_id: str | None, +) -> bool: + return ( + cursor is None + and name is None + and enabled is None + and template_backed is None + and cloned is not True + and step_type is None + and stage is None + and execution is None + and tag is None + and not include_attachments + and attachment_target_type is None + and attachment_target_id is None + ) + + +async def _namespace_has_active_controls( + db: AsyncSession, + *, + namespace_key: str, +) -> bool: + result = await db.execute( + select(Control.id) + .where( + Control.namespace_key == namespace_key, + Control.deleted_at.is_(None), + ) + .limit(1) + ) + return result.first() is not None + + def _serialize_control_data( control_data: ControlDefinition | UnrenderedTemplateControl, ) -> dict[str, object]: @@ -1234,6 +1310,21 @@ async def list_controls( control_service = ControlService(db) namespace_key = principal.namespace_key + if _should_seed_out_of_box_controls_on_list( + cursor=cursor, + name=name, + enabled=enabled, + template_backed=template_backed, + cloned=cloned, + step_type=step_type, + stage=stage, + execution=execution, + tag=tag, + include_attachments=include_attachments, + attachment_target_type=attachment_target_type, + attachment_target_id=attachment_target_id, + ): + await _seed_out_of_box_controls_for_namespace(db, namespace_key=namespace_key) filter_by_attachment = target_principal is not None and ( attachment_target_type is not None or attachment_target_id is not None ) diff --git a/server/tests/test_out_of_box_controls_bootstrap.py b/server/tests/test_out_of_box_controls_bootstrap.py index 5ba30577..99845e9b 100644 --- a/server/tests/test_out_of_box_controls_bootstrap.py +++ b/server/tests/test_out_of_box_controls_bootstrap.py @@ -5,7 +5,15 @@ from typing import cast import pytest +from agent_control_evaluators.json.config import JSONEvaluatorConfig +from agent_control_evaluators.json.evaluator import JSONEvaluator +from agent_control_evaluators.list.config import ListEvaluatorConfig +from agent_control_evaluators.list.evaluator import ListEvaluator +from agent_control_evaluators.regex.config import RegexEvaluatorConfig +from agent_control_evaluators.regex.evaluator import RegexEvaluator +from agent_control_models import EvaluatorSpec from agent_control_server.bootstrap.out_of_box_controls import ( + OUT_OF_BOX_CONTROL_TEMPLATES, OutOfBoxControlTemplate, default_out_of_box_namespace_key, missing_required_evaluators, @@ -26,6 +34,18 @@ from .conftest import AsyncSessionTest, engine +_EXPECTED_OOB_CONTROL_NAMES = ( + "oob-ssn-match", + "oob-credit-card-number-match", + "oob-phone-number-match", + "oob-dangerous-shell-command-match", + "oob-high-value-action-requires-approval", + "oob-outbound-communication-requires-approval", + "oob-sensitive-tool-requires-approved-role", + "oob-only-approved-tools-may-run", +) +_AVAILABLE_PHASE_2_EVALUATORS = {"regex", "json", "list"} + def _control_payload(*, evaluator_name: str = "regex") -> dict[str, object]: return { @@ -71,10 +91,31 @@ def _count_table_rows(table: Table) -> int: return cast(int, session.scalar(select(func.count()).select_from(table))) +def _oob_evaluator_spec(name: str) -> EvaluatorSpec: + template = next(template for template in OUT_OF_BOX_CONTROL_TEMPLATES if template.name == name) + leaf = template.control.primary_leaf() + assert leaf is not None + leaf_parts = leaf.leaf_parts() + assert leaf_parts is not None + _, evaluator = leaf_parts + return evaluator + + def test_default_namespace_key_uses_standalone_namespace() -> None: assert default_out_of_box_namespace_key() == DEFAULT_NAMESPACE_KEY +def test_out_of_box_catalog_contains_phase_2_templates() -> None: + assert tuple(template.name for template in OUT_OF_BOX_CONTROL_TEMPLATES) == ( + _EXPECTED_OOB_CONTROL_NAMES + ) + assert { + evaluator + for template in OUT_OF_BOX_CONTROL_TEMPLATES + for evaluator in template.required_evaluators + } == _AVAILABLE_PHASE_2_EVALUATORS + + def test_missing_required_evaluators_returns_sorted_names() -> None: missing = missing_required_evaluators( {"galileo.luna", "regex", "json"}, @@ -148,6 +189,48 @@ async def test_seed_creates_control_version_in_namespace_without_bindings() -> N assert _count_table_rows(ControlBinding.__table__) == 0 +@pytest.mark.asyncio +async def test_seed_default_catalog_creates_all_controls_without_bindings() -> None: + result = await seed_out_of_box_controls( + session_factory=AsyncSessionTest, + namespace_key=DEFAULT_NAMESPACE_KEY, + available_evaluators=_AVAILABLE_PHASE_2_EVALUATORS, + ) + + assert result.created == _EXPECTED_OOB_CONTROL_NAMES + assert result.skipped_existing == () + assert result.skipped_missing_evaluator == () + assert result.skipped_conflict == () + + controls = _fetch_controls() + assert tuple(control.name for control in controls) == _EXPECTED_OOB_CONTROL_NAMES + assert {control.namespace_key for control in controls} == {DEFAULT_NAMESPACE_KEY} + assert len(_fetch_versions()) == len(_EXPECTED_OOB_CONTROL_NAMES) + assert _count_table_rows(policy_controls) == 0 + assert _count_table_rows(agent_controls) == 0 + assert _count_table_rows(ControlBinding.__table__) == 0 + + +@pytest.mark.asyncio +async def test_seed_default_catalog_is_idempotent() -> None: + await seed_out_of_box_controls( + session_factory=AsyncSessionTest, + namespace_key=DEFAULT_NAMESPACE_KEY, + available_evaluators=_AVAILABLE_PHASE_2_EVALUATORS, + ) + + result = await seed_out_of_box_controls( + session_factory=AsyncSessionTest, + namespace_key=DEFAULT_NAMESPACE_KEY, + available_evaluators=_AVAILABLE_PHASE_2_EVALUATORS, + ) + + assert result.created == () + assert result.skipped_existing == _EXPECTED_OOB_CONTROL_NAMES + assert len(_fetch_controls()) == len(_EXPECTED_OOB_CONTROL_NAMES) + assert len(_fetch_versions()) == len(_EXPECTED_OOB_CONTROL_NAMES) + + @pytest.mark.asyncio async def test_seed_is_idempotent_for_existing_active_control_names() -> None: template = _template(name="oob-idempotent-control") @@ -208,3 +291,69 @@ async def active_control_name_exists( assert len(_fetch_controls()) == 1 assert len(_fetch_versions()) == 1 + +@pytest.mark.asyncio +async def test_regex_out_of_box_controls_match_representative_payloads() -> None: + ssn_spec = _oob_evaluator_spec("oob-ssn-match") + ssn_evaluator = RegexEvaluator(RegexEvaluatorConfig.model_validate(ssn_spec.config)) + ssn_result = await ssn_evaluator.evaluate("Customer SSN is 123-45-6789.") + assert ssn_result.matched is True + + shell_spec = _oob_evaluator_spec("oob-dangerous-shell-command-match") + shell_evaluator = RegexEvaluator(RegexEvaluatorConfig.model_validate(shell_spec.config)) + shell_result = await shell_evaluator.evaluate("sudo rm -rf /") + assert shell_result.matched is True + + +@pytest.mark.asyncio +async def test_json_out_of_box_controls_match_missing_approval_only() -> None: + high_value_spec = _oob_evaluator_spec("oob-high-value-action-requires-approval") + high_value_evaluator = JSONEvaluator( + JSONEvaluatorConfig.model_validate(high_value_spec.config) + ) + + high_value_result = await high_value_evaluator.evaluate({"amount": 25000}) + low_value_result = await high_value_evaluator.evaluate({"amount": 250}) + approved_result = await high_value_evaluator.evaluate( + {"amount": 25000, "approval": {"approved": True}} + ) + + assert high_value_result.matched is True + assert low_value_result.matched is False + assert approved_result.matched is False + + outbound_spec = _oob_evaluator_spec("oob-outbound-communication-requires-approval") + outbound_evaluator = JSONEvaluator(JSONEvaluatorConfig.model_validate(outbound_spec.config)) + + outbound_result = await outbound_evaluator.evaluate( + {"to": "customer@example.com", "message": "Hello"} + ) + internal_result = await outbound_evaluator.evaluate({"query": "customer history"}) + approved_outbound_result = await outbound_evaluator.evaluate( + {"to": "customer@example.com", "message": "Hello", "approved": True} + ) + + assert outbound_result.matched is True + assert internal_result.matched is False + assert approved_outbound_result.matched is False + + +@pytest.mark.asyncio +async def test_list_out_of_box_controls_match_unapproved_values() -> None: + role_spec = _oob_evaluator_spec("oob-sensitive-tool-requires-approved-role") + role_evaluator = ListEvaluator(ListEvaluatorConfig.model_validate(role_spec.config)) + + viewer_result = await role_evaluator.evaluate("viewer") + admin_result = await role_evaluator.evaluate("admin") + + assert viewer_result.matched is True + assert admin_result.matched is False + + tool_spec = _oob_evaluator_spec("oob-only-approved-tools-may-run") + tool_evaluator = ListEvaluator(ListEvaluatorConfig.model_validate(tool_spec.config)) + + delete_result = await tool_evaluator.evaluate("delete_user") + search_result = await tool_evaluator.evaluate("web_search") + + assert delete_result.matched is True + assert search_result.matched is False diff --git a/server/tests/test_principal_namespace_flow.py b/server/tests/test_principal_namespace_flow.py index 8f16a795..af8b0dfd 100644 --- a/server/tests/test_principal_namespace_flow.py +++ b/server/tests/test_principal_namespace_flow.py @@ -11,6 +11,7 @@ Principal, set_authorizer, ) +from agent_control_server.bootstrap.out_of_box_controls import OUT_OF_BOX_CONTROL_TEMPLATES from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -73,6 +74,24 @@ def _evaluation_payload(agent_name: str) -> dict[str, Any]: } +def test_controls_list_seeds_out_of_box_controls_for_principal_namespace( + app: FastAPI, +) -> None: + set_authorizer(HeaderNamespaceAuthorizer()) + + namespace_client = _client(app, "org-oob-controls") + filtered = namespace_client.get("/api/v1/controls", params={"name": "oob"}) + assert filtered.status_code == 200, filtered.text + assert filtered.json()["controls"] == [] + + resp = namespace_client.get("/api/v1/controls", params={"limit": 10, "cloned": "false"}) + assert resp.status_code == 200, resp.text + + expected_names = {template.name for template in OUT_OF_BOX_CONTROL_TEMPLATES} + returned_names = {control["name"] for control in resp.json()["controls"]} + assert expected_names.issubset(returned_names) + + def test_principal_namespace_scopes_management_and_runtime(app: FastAPI) -> None: set_authorizer(HeaderNamespaceAuthorizer())