From 298fddded70d380ad1cb2c8eaadaa05d9308370d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 5 Feb 2026 13:35:16 -0800 Subject: [PATCH 1/4] fix(server): enforce public-safe API error propagation --- engine/src/agent_control_engine/core.py | 10 +- .../agent_control_server/endpoints/agents.py | 8 +- .../endpoints/controls.py | 32 ++- .../endpoints/evaluation.py | 61 ++++- .../endpoints/evaluator_configs.py | 10 +- .../endpoints/policies.py | 9 +- server/src/agent_control_server/errors.py | 252 +++++++++++++++--- .../observability/ingest/direct.py | 4 +- server/tests/test_auth.py | 4 +- server/tests/test_controls_additional.py | 7 + server/tests/test_errors_handlers.py | 106 +++++++- .../tests/test_evaluation_error_handling.py | 10 +- server/tests/test_evaluator_configs.py | 5 + server/tests/test_init_agent.py | 4 + 14 files changed, 454 insertions(+), 68 deletions(-) diff --git a/engine/src/agent_control_engine/core.py b/engine/src/agent_control_engine/core.py index f3c2ca23..158c7d80 100644 --- a/engine/src/agent_control_engine/core.py +++ b/engine/src/agent_control_engine/core.py @@ -205,12 +205,13 @@ async def evaluate_control(eval_task: _EvalTask) -> None: except asyncio.CancelledError: # Task was cancelled due to another deny - that's OK raise - except TimeoutError: + except TimeoutError as exc: # Evaluator timed out error_msg = f"TimeoutError: Evaluator exceeded {timeout}s timeout" logger.warning( f"Evaluator timeout for control '{eval_task.item.name}' " - f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}" + f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}", + exc_info=(type(exc), exc, exc.__traceback__), ) eval_task.result = EvaluatorResult( matched=False, @@ -222,9 +223,10 @@ async def evaluate_control(eval_task: _EvalTask) -> None: # Evaluation error - fail open but mark as error # The error field signals to callers that this was not a real evaluation error_msg = f"{type(e).__name__}: {e}" - logger.warning( + logger.error( f"Evaluator error for control '{eval_task.item.name}' " - f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}" + f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}", + exc_info=(type(e), e, e.__traceback__), ) eval_task.result = EvaluatorResult( matched=False, diff --git a/server/src/agent_control_server/endpoints/agents.py b/server/src/agent_control_server/endpoints/agents.py index 5ba569d2..97886860 100644 --- a/server/src/agent_control_server/endpoints/agents.py +++ b/server/src/agent_control_server/endpoints/agents.py @@ -63,6 +63,7 @@ _DEFAULT_PAGINATION_OFFSET = 0 _DEFAULT_PAGINATION_LIMIT = 20 _MAX_PAGINATION_LIMIT = 100 +_CORRUPTED_AGENT_DATA_MESSAGE = "Stored agent data is corrupted and cannot be parsed." type StepKeyTuple = tuple[str, str] @@ -441,7 +442,7 @@ async def init_agent( # Parse existing data via AgentData Pydantic model try: data_model = AgentData.model_validate(existing.data) - except ValidationError as e: + except ValidationError: if not request.force_replace: _logger.error( f"Failed to parse existing agent data for '{request.agent.agent_name}'", @@ -459,14 +460,15 @@ async def init_agent( resource="Agent", field="data", code="corrupted_data", - message=str(e), + message=_CORRUPTED_AGENT_DATA_MESSAGE, ) ], ) # User explicitly requested replacement _logger.warning( f"Force-replacing corrupted data for agent '{request.agent.agent_name}' " - f"due to force_replace=true. Original error: {e}" + "due to force_replace=true.", + exc_info=True, ) data_model = AgentData(agent_metadata={}, steps=[], evaluators=[]) diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index e887a6c7..561d88e1 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -39,6 +39,9 @@ # Pagination constants _DEFAULT_PAGINATION_LIMIT = 20 _MAX_PAGINATION_LIMIT = 100 +_INVALID_PARAMETERS_MESSAGE = "Invalid config parameters for evaluator." +_CORRUPTED_CONTROL_DATA_MESSAGE = "Stored control data is corrupted and cannot be parsed." +_SCHEMA_VALIDATION_FAILED_MESSAGE = "Config does not satisfy the evaluator schema." router = APIRouter(prefix="/controls", tags=["controls"]) @@ -139,13 +142,13 @@ async def get_control( if control.data: try: control_data = ControlDefinition.model_validate(control.data) - except ValidationError as e: + except ValidationError: # Data exists but is corrupted - log and return None _logger.warning( - "Control '%s' (id=%s) has corrupted data that failed validation: %s", + "Control '%s' (id=%s) has corrupted data that failed validation", control.name, control_id, - str(e), + exc_info=True, ) control_data = None @@ -330,7 +333,7 @@ async def set_control_data( validate_config_against_schema( request.data.evaluator.config, evaluator.config_schema ) - except JSONSchemaValidationError as e: + except JSONSchemaValidationError: raise APIValidationError( error_code=ErrorCode.INVALID_CONFIG, detail=f"Config validation failed for evaluator '{evaluator_ref}'", @@ -341,7 +344,7 @@ async def set_control_data( resource="Control", field="data.evaluator.config", code="schema_validation_error", - message=e.message, + message=_SCHEMA_VALIDATION_FAILED_MESSAGE, ) ], ) @@ -370,7 +373,12 @@ async def set_control_data( for err in e.errors() ], ) - except TypeError as e: + except TypeError: + _logger.warning( + "Config validation raised TypeError for evaluator '%s'", + parsed.name, + exc_info=True, + ) raise APIValidationError( error_code=ErrorCode.INVALID_CONFIG, detail=f"Invalid config parameters for evaluator '{parsed.name}'", @@ -381,7 +389,7 @@ async def set_control_data( resource="Control", field="data.evaluator.config", code="invalid_parameters", - message=str(e), + message=_INVALID_PARAMETERS_MESSAGE, ) ], ) @@ -819,7 +827,13 @@ async def patch_control( control.data = new_data updated = True current_enabled = request.enabled if updated else ctrl_def.enabled - except ValidationError as e: + except ValidationError: + _logger.error( + "Control '%s' (%s) has corrupted data in patch request", + control.name, + control_id, + exc_info=True, + ) raise APIValidationError( error_code=ErrorCode.CORRUPTED_DATA, detail=f"Control '{control.name}' has corrupted data", @@ -830,7 +844,7 @@ async def patch_control( resource="Control", field="data", code="corrupted_data", - message=str(e), + message=_CORRUPTED_CONTROL_DATA_MESSAGE, ) ], ) diff --git a/server/src/agent_control_server/endpoints/evaluation.py b/server/src/agent_control_server/endpoints/evaluation.py index 3ad28582..a79fad01 100644 --- a/server/src/agent_control_server/endpoints/evaluation.py +++ b/server/src/agent_control_server/endpoints/evaluation.py @@ -8,6 +8,7 @@ from agent_control_models import ( ControlDefinition, ControlExecutionEvent, + ControlMatch, EvaluationRequest, EvaluationResponse, ) @@ -33,6 +34,10 @@ # These are immediately recognizable as "not traced" and can be filtered in queries. INVALID_TRACE_ID = "0" * 32 # 128-bit, 32 hex chars INVALID_SPAN_ID = "0" * 16 # 64-bit, 16 hex chars +SAFE_EVALUATOR_ERROR = "Evaluation failed due to an internal evaluator error." +SAFE_EVALUATOR_TIMEOUT_ERROR = "Evaluation timed out before completion." +SAFE_INVALID_STEP_REGEX_ERROR = "Control configuration error: invalid step name regex." +SAFE_ENGINE_VALIDATION_MESSAGE = "Invalid evaluation request or control configuration." class ControlAdapter: @@ -44,6 +49,54 @@ def __init__(self, id: int, name: str, control: ControlDefinition): self.control = control +def _sanitize_evaluator_error(error_message: str) -> str: + """Convert evaluator runtime errors into safe client-facing text.""" + if "invalid step_name_regex" in error_message.lower(): + return SAFE_INVALID_STEP_REGEX_ERROR + if "timeout" in error_message.lower(): + return SAFE_EVALUATOR_TIMEOUT_ERROR + return SAFE_EVALUATOR_ERROR + + +def _sanitize_control_match(match: ControlMatch) -> ControlMatch: + """Redact internal evaluator error strings from a control match.""" + if match.result.error is None: + return match + + safe_error = _sanitize_evaluator_error(match.result.error) + safe_message = safe_error + sanitized_result = match.result.model_copy( + update={ + "error": safe_error, + "message": safe_message, + } + ) + return match.model_copy(update={"result": sanitized_result}) + + +def _sanitize_evaluation_response(response: EvaluationResponse) -> EvaluationResponse: + """Return a copy of the evaluation response with safe public error text.""" + return response.model_copy( + update={ + "matches": ( + [_sanitize_control_match(match) for match in response.matches] + if response.matches + else None + ), + "errors": ( + [_sanitize_control_match(match) for match in response.errors] + if response.errors + else None + ), + "non_matches": ( + [_sanitize_control_match(match) for match in response.non_matches] + if response.non_matches + else None + ), + } + ) + + @router.post( "", response_model=EvaluationResponse, @@ -118,8 +171,8 @@ async def evaluate( engine = ControlEngine(engine_controls) try: response = await engine.process(request) - except ValueError as e: - _logger.error(f"Evaluation failed: {e}") + except ValueError: + _logger.exception("Evaluation failed due to invalid configuration or input") raise APIValidationError( error_code=ErrorCode.EVALUATION_FAILED, detail="Evaluation failed due to invalid configuration or input", @@ -130,11 +183,13 @@ async def evaluate( resource="Evaluation", field=None, code="evaluation_error", - message=str(e), + message=SAFE_ENGINE_VALIDATION_MESSAGE, ) ], ) + response = _sanitize_evaluation_response(response) + # Calculate total execution time total_duration_ms = (time.perf_counter() - start_time) * 1000 diff --git a/server/src/agent_control_server/endpoints/evaluator_configs.py b/server/src/agent_control_server/endpoints/evaluator_configs.py index b3aed487..4abbfa88 100644 --- a/server/src/agent_control_server/endpoints/evaluator_configs.py +++ b/server/src/agent_control_server/endpoints/evaluator_configs.py @@ -29,6 +29,7 @@ # Pagination constants _DEFAULT_PAGINATION_LIMIT = 20 _MAX_PAGINATION_LIMIT = 100 +_INVALID_PARAMETERS_MESSAGE = "Invalid config parameters for evaluator." router = APIRouter(prefix="/evaluator-configs", tags=["evaluator-configs"]) @@ -100,14 +101,19 @@ def _validate_known_evaluator_config(evaluator: str, config: dict[str, Any]) -> detail=f"Config validation failed for evaluator '{evaluator}'", hint="Check the evaluator's config schema for required fields and types.", ) - except TypeError as e: + except TypeError: + _logger.warning( + "Evaluator config parameter validation raised TypeError for '%s'", + evaluator, + exc_info=True, + ) _raise_invalid_config( [ ValidationErrorItem( resource="EvaluatorConfig", field="config", code="invalid_parameters", - message=str(e), + message=_INVALID_PARAMETERS_MESSAGE, ) ], detail=f"Invalid config parameters for evaluator '{evaluator}'", diff --git a/server/src/agent_control_server/endpoints/policies.py b/server/src/agent_control_server/endpoints/policies.py index e55641a3..120952f9 100644 --- a/server/src/agent_control_server/endpoints/policies.py +++ b/server/src/agent_control_server/endpoints/policies.py @@ -136,11 +136,14 @@ async def add_control_to_policy( ) await db.execute(stmt) await db.commit() - except Exception as e: + except Exception: await db.rollback() _logger.error( - f"Failed to add control '{control.name}' ({control_id}) " - f"to policy '{policy.name}' ({policy_id}): {e}", + "Failed to add control '%s' (%s) to policy '%s' (%s)", + control.name, + control_id, + policy.name, + policy_id, exc_info=True, ) raise DatabaseError( diff --git a/server/src/agent_control_server/errors.py b/server/src/agent_control_server/errors.py index 4403ff82..083cff13 100644 --- a/server/src/agent_control_server/errors.py +++ b/server/src/agent_control_server/errors.py @@ -31,7 +31,7 @@ """ import logging -import os +import re import traceback import uuid from typing import Any @@ -49,7 +49,199 @@ from fastapi import HTTPException, Request from fastapi.responses import JSONResponse -from .config import settings +_logger = logging.getLogger(__name__) + +_MAX_PUBLIC_TEXT_LENGTH = 500 +_REDACTED_VALUE = "[REDACTED]" +_GENERIC_INTERNAL_DETAIL = "An unexpected error occurred. Please try again or contact support." +_GENERIC_DATABASE_DETAIL = "A database error occurred while processing the request." +_GENERIC_AUTH_MISCONFIGURED_DETAIL = ( + "Server authentication is misconfigured. Contact administrator." +) +_GENERIC_BAD_REQUEST_DETAIL = "Request validation failed." +_GENERIC_UNAUTHORIZED_DETAIL = "Authentication failed." +_GENERIC_FORBIDDEN_DETAIL = "Permission denied." +_GENERIC_NOT_FOUND_DETAIL = "Requested resource was not found." +_GENERIC_CONFLICT_DETAIL = "Request conflicts with existing state." + +_EXCEPTION_PREFIX_RE = re.compile(r"^[A-Za-z_][\w.]{0,80}(Error|Exception):\s+") +_TRACEBACK_RE = re.compile(r"traceback \(most recent call last\):", re.IGNORECASE) +_STACK_FRAME_RE = re.compile(r'File ".*?\.py", line \d+', re.IGNORECASE) +_PYDANTIC_INTERNAL_RE = re.compile(r"\bvalidation error for\b", re.IGNORECASE) +_SECRET_KV_RE = re.compile( + r"(?i)\b(password|passwd|secret|token|api[-_]?key|authorization)\b\s*[:=]\s*\S+" +) +_BEARER_TOKEN_RE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-/+=]+") +_CONNECTION_STRING_RE = re.compile(r"(?i)\b(postgresql|postgres|mysql|sqlite)://\S+") +_ABSOLUTE_PATH_RE = re.compile(r"(?i)(/Users/|/home/|/var/|[A-Z]:\\)") +_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_.:\-\[\]/]+$") +_SENSITIVE_PATTERNS = ( + _TRACEBACK_RE, + _STACK_FRAME_RE, + _EXCEPTION_PREFIX_RE, + _PYDANTIC_INTERNAL_RE, + _SECRET_KV_RE, + _BEARER_TOKEN_RE, + _CONNECTION_STRING_RE, + _ABSOLUTE_PATH_RE, +) + + +def _default_safe_detail(status_code: int, error_code: ErrorCode | None) -> str: + """Return a fallback safe detail for the given status and error code.""" + if error_code == ErrorCode.DATABASE_ERROR: + return _GENERIC_DATABASE_DETAIL + if error_code == ErrorCode.AUTH_MISCONFIGURED: + return _GENERIC_AUTH_MISCONFIGURED_DETAIL + if status_code >= 500: + return _GENERIC_INTERNAL_DETAIL + if status_code in (400, 422): + return _GENERIC_BAD_REQUEST_DETAIL + if status_code == 401: + return _GENERIC_UNAUTHORIZED_DETAIL + if status_code == 403: + return _GENERIC_FORBIDDEN_DETAIL + if status_code == 404: + return _GENERIC_NOT_FOUND_DETAIL + if status_code == 409: + return _GENERIC_CONFLICT_DETAIL + return _GENERIC_BAD_REQUEST_DETAIL + + +def _normalize_public_text(text: str) -> str: + """Collapse repeated whitespace and trim to keep response payloads concise.""" + normalized = " ".join(text.split()) + if len(normalized) > _MAX_PUBLIC_TEXT_LENGTH: + return normalized[: _MAX_PUBLIC_TEXT_LENGTH - 3] + "..." + return normalized + + +def _contains_internal_or_sensitive_data(text: str) -> bool: + """Detect content that should never be returned to API callers.""" + return any(pattern.search(text) for pattern in _SENSITIVE_PATTERNS) + + +def sanitize_public_error_text( + text: str, + *, + status_code: int, + fallback: str | None = None, + error_code: ErrorCode | None = None, +) -> str: + """ + Sanitize a string for client-facing error responses. + + This is intentionally strict: + - All 5xx content is replaced with a safe template. + - 4xx content is preserved only if it does not look internal/sensitive. + """ + safe_fallback = ( + fallback if fallback is not None else _default_safe_detail(status_code, error_code) + ) + if status_code >= 500: + return safe_fallback + + normalized = _normalize_public_text(text) + if not normalized: + return safe_fallback + if _contains_internal_or_sensitive_data(normalized): + return safe_fallback + return normalized + + +def _sanitize_optional_public_text(text: str | None) -> str | None: + """Sanitize optional advisory text; returns None when unsafe.""" + if text is None: + return None + + normalized = _normalize_public_text(text) + if not normalized: + return None + if _contains_internal_or_sensitive_data(normalized): + return None + return normalized + + +def _sanitize_identifier(value: str, *, fallback: str) -> str: + """Sanitize resource/field/code identifiers in validation payloads.""" + normalized = _normalize_public_text(value) + if not normalized: + return fallback + if len(normalized) > 120: + normalized = normalized[:120] + if not _SAFE_IDENTIFIER_RE.fullmatch(normalized): + return fallback + return normalized + + +def _sanitize_validation_error_value(value: Any) -> Any | None: + """ + Redact potentially sensitive invalid values before returning them to clients. + + Preserve primitive scalar values when safe. + """ + if value is None: + return None + if isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return _REDACTED_VALUE + return _REDACTED_VALUE + + +def _sanitize_validation_errors( + items: list[ValidationErrorItem] | None, + *, + status_code: int, +) -> list[ValidationErrorItem] | None: + """Sanitize validation error arrays for safe client output.""" + if items is None: + return None + + sanitized: list[ValidationErrorItem] = [] + for item in items: + sanitized.append( + item.model_copy( + update={ + "resource": _sanitize_identifier(item.resource, fallback="Request"), + "field": ( + _sanitize_identifier(item.field, fallback="field") + if item.field is not None + else None + ), + "code": _sanitize_identifier(item.code, fallback="validation_error"), + "message": sanitize_public_error_text( + item.message, + status_code=status_code, + fallback="Invalid value.", + ), + "value": _sanitize_validation_error_value(item.value), + } + ) + ) + return sanitized + + +def _sanitize_problem_detail(problem: ProblemDetail) -> ProblemDetail: + """Apply public-safe sanitization rules to a ProblemDetail payload.""" + problem.detail = sanitize_public_error_text( + problem.detail, + status_code=problem.status, + error_code=problem.error_code, + ) + + if problem.hint is not None: + problem.hint = _sanitize_optional_public_text(problem.hint) + + problem.errors = _sanitize_validation_errors(problem.errors, status_code=problem.status) + + if problem.details is not None and problem.details.causes is not None: + problem.details.causes = _sanitize_validation_errors( + problem.details.causes, + status_code=problem.status, + ) + + return problem class APIError(HTTPException): @@ -341,7 +533,7 @@ async def api_error_handler(request: Request, exc: APIError) -> JSONResponse: Converts APIError exceptions to RFC 7807 JSON responses. """ - problem = exc.to_problem_detail(instance=str(request.url.path)) + problem = _sanitize_problem_detail(exc.to_problem_detail(instance=str(request.url.path))) # Add headers for auth errors headers: dict[str, str] | None = None @@ -380,7 +572,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe # Extract detail - handle both string and dict details if isinstance(exc.detail, dict): - detail_str = exc.detail.get("message", str(exc.detail)) + detail_value = exc.detail.get("message") + detail_str = str(detail_value) if detail_value is not None else str(exc.detail) else: detail_str = str(exc.detail) @@ -394,6 +587,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe reason=reason, metadata=ErrorMetadata(), ) + problem = _sanitize_problem_detail(problem) headers: dict[str, str] | None = None if exc.status_code == 401: @@ -415,46 +609,39 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes SECURITY NOTE: Stack traces are NEVER exposed to users, even in debug mode. Debug information is only logged server-side. """ - # Always log the full exception server-side for debugging - logging.error(f"Unhandled exception: {exc}", exc_info=True) - - # SECURITY: Never expose internal details to users - # Stack traces and exception messages may contain: - # - File paths revealing server structure - # - Database queries or credentials - # - Internal logic details useful to attackers - detail = "An unexpected error occurred. Please try again or contact support." - # Generate a correlation ID for support to look up the full error in logs # In production, you'd want to use a proper request ID from middleware error_id = str(uuid.uuid4())[:8] - logging.error(f"Error ID {error_id}: {traceback.format_exc()}") + if exc.__traceback__ is not None: + trace_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + else: + stack_text = "".join(traceback.format_stack()) + trace_text = ( + f"Traceback unavailable for propagated exception: {type(exc).__name__}: {exc}\n" + "Stack (most recent call last):\n" + f"{stack_text}" + ) + + _logger.error( + "Unhandled exception (error_id=%s, path=%s, method=%s)\n%s", + error_id, + request.url.path, + request.method, + trace_text, + ) problem = ProblemDetail( type=make_error_type(ErrorCode.INTERNAL_ERROR), title="Internal Server Error", status=500, - detail=detail, + detail=_GENERIC_INTERNAL_DETAIL, instance=str(request.url.path), error_code=ErrorCode.INTERNAL_ERROR, reason=ErrorReason.INTERNAL_ERROR, metadata=ErrorMetadata(request_id=error_id), hint=f"Reference error ID '{error_id}' when contacting support.", ) - - # SECURITY: Only in explicitly local development, allow exception type - # This requires BOTH debug=true AND running on localhost - is_local_dev = ( - settings.debug - and os.environ.get("AGENT_CONTROL_EXPOSE_ERRORS", "").lower() == "true" - ) - if is_local_dev: - logging.warning( - "SECURITY: Exposing error details. " - "Ensure AGENT_CONTROL_EXPOSE_ERRORS is not set in production!" - ) - # Only expose exception type and message, never full traceback - problem.detail = f"{type(exc).__name__}: {exc}" + problem = _sanitize_problem_detail(problem) return JSONResponse( status_code=500, @@ -498,8 +685,8 @@ async def validation_exception_handler( ValidationErrorItem( resource=resource, field=field, - code=error.get("type", "validation_error"), - message=error.get("msg", "Validation failed"), + code=str(error.get("type", "validation_error")), + message=str(error.get("msg", "Validation failed")), value=error.get("input"), ) ) @@ -516,6 +703,7 @@ async def validation_exception_handler( errors=errors, hint="Check the 'errors' array for field-level validation details.", ) + problem = _sanitize_problem_detail(problem) return JSONResponse( status_code=422, diff --git a/server/src/agent_control_server/observability/ingest/direct.py b/server/src/agent_control_server/observability/ingest/direct.py index df9b5cc3..081cd4be 100644 --- a/server/src/agent_control_server/observability/ingest/direct.py +++ b/server/src/agent_control_server/observability/ingest/direct.py @@ -66,8 +66,8 @@ async def ingest(self, events: list[ControlExecutionEvent]) -> IngestResult: if self.log_to_stdout: self._log_events(events) - except Exception as e: - logger.error(f"Failed to store events: {e}", exc_info=True) + except Exception: + logger.error("Failed to store events", exc_info=True) dropped = received return IngestResult( diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py index c1dda588..2fa3c5ea 100644 --- a/server/tests/test_auth.py +++ b/server/tests/test_auth.py @@ -218,7 +218,9 @@ def test_misconfigured_returns_500(self, unauthenticated_client: TestClient) -> # Then: assert response.status_code == 500 - assert "misconfigured" in response.json()["detail"] + body = response.json() + assert body["error_code"] == "AUTH_MISCONFIGURED" + assert body["detail"] == "Server authentication is misconfigured. Contact administrator." class TestOptionalApiKey: diff --git a/server/tests/test_controls_additional.py b/server/tests/test_controls_additional.py index 271f04b1..8d63bf9f 100644 --- a/server/tests/test_controls_additional.py +++ b/server/tests/test_controls_additional.py @@ -443,6 +443,8 @@ def test_patch_control_enabled_with_corrupted_data(client: TestClient) -> None: assert resp.status_code == 422 body = resp.json() assert body["error_code"] == "CORRUPTED_DATA" + assert body["errors"][0]["message"] == "Stored control data is corrupted and cannot be parsed." + assert "ValidationError" not in body["errors"][0]["message"] def test_set_control_data_agent_scoped_agent_not_found(client: TestClient) -> None: @@ -679,6 +681,11 @@ def config_model(**_kwargs): # type: ignore[no-untyped-def] body = resp.json() assert body["error_code"] == "INVALID_CONFIG" assert any(err.get("code") == "invalid_parameters" for err in body.get("errors", [])) + assert any( + err.get("message") == "Invalid config parameters for evaluator." + for err in body.get("errors", []) + ) + assert "unexpected parameter" not in resp.text @pytest.mark.asyncio diff --git a/server/tests/test_errors_handlers.py b/server/tests/test_errors_handlers.py index d03c0e4d..1a458768 100644 --- a/server/tests/test_errors_handlers.py +++ b/server/tests/test_errors_handlers.py @@ -1,13 +1,19 @@ """Tests for error handlers.""" import json +import logging import pytest +from fastapi.exceptions import RequestValidationError from fastapi import HTTPException from starlette.requests import Request -from agent_control_server.config import settings -from agent_control_server.errors import InternalError, generic_exception_handler, http_exception_handler +from agent_control_server.errors import ( + InternalError, + generic_exception_handler, + http_exception_handler, + validation_exception_handler, +) @pytest.mark.asyncio @@ -27,19 +33,37 @@ async def test_http_exception_handler_sets_www_authenticate() -> None: @pytest.mark.asyncio -async def test_generic_exception_handler_exposes_details_in_local_dev(monkeypatch) -> None: - # Given: local dev settings enabled for error exposure - monkeypatch.setattr(settings, "debug", True) +async def test_generic_exception_handler_never_exposes_exception_details(monkeypatch) -> None: + # Given: local debug flags are enabled monkeypatch.setenv("AGENT_CONTROL_EXPOSE_ERRORS", "true") request = Request({"type": "http", "method": "GET", "path": "/boom", "headers": []}) # When: handling an unexpected exception response = await generic_exception_handler(request, ValueError("boom")) - # Then: response includes exception type and message + # Then: response remains public-safe and does not include internals assert response.status_code == 500 body = json.loads(response.body.decode("utf-8")) - assert "ValueError: boom" in body["detail"] + assert body["detail"] == "An unexpected error occurred. Please try again or contact support." + assert "ValueError" not in body["detail"] + assert body["metadata"]["request_id"] + + +@pytest.mark.asyncio +async def test_generic_exception_handler_logs_full_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + # Given: a request that triggers an unhandled exception + caplog.set_level(logging.ERROR, logger="agent_control_server.errors") + request = Request({"type": "http", "method": "GET", "path": "/boom", "headers": []}) + + # When: handling the exception + await generic_exception_handler(request, ValueError("boom")) + + # Then: server logs include traceback details + assert "Unhandled exception (error_id=" in caplog.text + assert "Traceback" in caplog.text + assert "ValueError: boom" in caplog.text @pytest.mark.asyncio @@ -57,6 +81,74 @@ async def test_http_exception_handler_uses_dict_detail_message() -> None: assert body["detail"] == "bad input" +@pytest.mark.asyncio +async def test_http_exception_handler_sanitizes_500_details() -> None: + # Given: a 500 HTTPException containing internal details + request = Request({"type": "http", "method": "GET", "path": "/bad", "headers": []}) + exc = HTTPException( + status_code=500, + detail='Traceback (most recent call last): File "/tmp/x.py", line 1 password=abc', + ) + + # When: handling the HTTPException + response = await http_exception_handler(request, exc) + + # Then: the response detail is redacted to a safe generic message + assert response.status_code == 500 + body = json.loads(response.body.decode("utf-8")) + assert body["detail"] == "An unexpected error occurred. Please try again or contact support." + assert "Traceback" not in body["detail"] + assert "password" not in body["detail"] + + +@pytest.mark.asyncio +async def test_validation_exception_handler_redacts_string_values() -> None: + # Given: a validation error containing a string input value + request = Request({"type": "http", "method": "POST", "path": "/bad", "headers": []}) + exc = RequestValidationError( + [ + { + "type": "string_type", + "loc": ("body", "api_key"), + "msg": "Input should be a valid string", + "input": "super-secret-token", + } + ] + ) + + # When: handling the validation error + response = await validation_exception_handler(request, exc) + + # Then: the string value is redacted + assert response.status_code == 422 + body = json.loads(response.body.decode("utf-8")) + assert body["errors"][0]["value"] == "[REDACTED]" + + +@pytest.mark.asyncio +async def test_validation_exception_handler_keeps_numeric_values() -> None: + # Given: a validation error containing a numeric input value + request = Request({"type": "http", "method": "POST", "path": "/bad", "headers": []}) + exc = RequestValidationError( + [ + { + "type": "less_than_equal", + "loc": ("body", "limit"), + "msg": "Input should be less than or equal to 100", + "input": 101, + } + ] + ) + + # When: handling the validation error + response = await validation_exception_handler(request, exc) + + # Then: numeric value is preserved + assert response.status_code == 422 + body = json.loads(response.body.decode("utf-8")) + assert body["errors"][0]["value"] == 101 + + def test_internal_error_sets_default_hint() -> None: # Given: an InternalError without an explicit hint err = InternalError(detail="boom") diff --git a/server/tests/test_evaluation_error_handling.py b/server/tests/test_evaluation_error_handling.py index cc2c4140..1b8e652e 100644 --- a/server/tests/test_evaluation_error_handling.py +++ b/server/tests/test_evaluation_error_handling.py @@ -150,8 +150,12 @@ def mock_get_evaluator_instance(config): assert data["errors"] is not None assert len(data["errors"]) == 1 assert data["errors"][0]["control_name"] == control_name - assert "RuntimeError" in data["errors"][0]["result"]["error"] - assert "Simulated evaluator crash" in data["errors"][0]["result"]["error"] + assert ( + data["errors"][0]["result"]["error"] + == "Evaluation failed due to an internal evaluator error." + ) + assert "RuntimeError" not in data["errors"][0]["result"]["error"] + assert "Simulated evaluator crash" not in data["errors"][0]["result"]["error"] # And: no matches are returned because evaluation failed assert data["matches"] is None or len(data["matches"]) == 0 @@ -188,6 +192,8 @@ async def raise_value_error(*_args, **_kwargs): assert resp.status_code == 422 body = resp.json() assert body["error_code"] == "EVALUATION_FAILED" + assert "bad config" not in body["detail"] + assert body["errors"][0]["message"] == "Invalid evaluation request or control configuration." def test_evaluation_warns_when_observability_drops_events( diff --git a/server/tests/test_evaluator_configs.py b/server/tests/test_evaluator_configs.py index 3c74603f..93dc4a2b 100644 --- a/server/tests/test_evaluator_configs.py +++ b/server/tests/test_evaluator_configs.py @@ -167,6 +167,11 @@ def config_model(**_kwargs): # type: ignore[no-untyped-def] data = resp.json() assert data["error_code"] == "INVALID_CONFIG" assert any(err.get("code") == "invalid_parameters" for err in data.get("errors", [])) + assert any( + err.get("message") == "Invalid config parameters for evaluator." + for err in data.get("errors", []) + ) + assert "unexpected parameter" not in resp.text def test_create_evaluator_config_integrity_error_name_conflict( diff --git a/server/tests/test_init_agent.py b/server/tests/test_init_agent.py index 6324e878..f5ad1187 100644 --- a/server/tests/test_init_agent.py +++ b/server/tests/test_init_agent.py @@ -263,6 +263,10 @@ def test_init_agent_logs_warning_on_bad_existing_data(client: TestClient, caplog assert "corrupted data" in response_data.get("detail", "").lower() # Check hint contains force_replace suggestion assert "force_replace" in response_data.get("hint", "").lower() + assert response_data["errors"][0]["message"] == ( + "Stored agent data is corrupted and cannot be parsed." + ) + assert "ValidationError" not in response_data["errors"][0]["message"] # Then: an error is logged about parse failure messages = [rec.getMessage() for rec in caplog.records] assert any("Failed to parse existing agent data" in m for m in messages) From 1915f564113c920269be38f524718a3d4fa6ec2e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 5 Feb 2026 13:38:40 -0800 Subject: [PATCH 2/4] refactor(logging): use exc_info=true for exception logging --- engine/src/agent_control_engine/core.py | 6 +++--- server/src/agent_control_server/errors.py | 15 ++------------- server/tests/test_errors_handlers.py | 7 +++++-- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/engine/src/agent_control_engine/core.py b/engine/src/agent_control_engine/core.py index 158c7d80..de3d3537 100644 --- a/engine/src/agent_control_engine/core.py +++ b/engine/src/agent_control_engine/core.py @@ -205,13 +205,13 @@ async def evaluate_control(eval_task: _EvalTask) -> None: except asyncio.CancelledError: # Task was cancelled due to another deny - that's OK raise - except TimeoutError as exc: + except TimeoutError: # Evaluator timed out error_msg = f"TimeoutError: Evaluator exceeded {timeout}s timeout" logger.warning( f"Evaluator timeout for control '{eval_task.item.name}' " f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}", - exc_info=(type(exc), exc, exc.__traceback__), + exc_info=True, ) eval_task.result = EvaluatorResult( matched=False, @@ -226,7 +226,7 @@ async def evaluate_control(eval_task: _EvalTask) -> None: logger.error( f"Evaluator error for control '{eval_task.item.name}' " f"(evaluator: {eval_task.item.control.evaluator.name}): {error_msg}", - exc_info=(type(e), e, e.__traceback__), + exc_info=True, ) eval_task.result = EvaluatorResult( matched=False, diff --git a/server/src/agent_control_server/errors.py b/server/src/agent_control_server/errors.py index 083cff13..c3e6e382 100644 --- a/server/src/agent_control_server/errors.py +++ b/server/src/agent_control_server/errors.py @@ -32,7 +32,6 @@ import logging import re -import traceback import uuid from typing import Any @@ -612,22 +611,12 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes # Generate a correlation ID for support to look up the full error in logs # In production, you'd want to use a proper request ID from middleware error_id = str(uuid.uuid4())[:8] - if exc.__traceback__ is not None: - trace_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) - else: - stack_text = "".join(traceback.format_stack()) - trace_text = ( - f"Traceback unavailable for propagated exception: {type(exc).__name__}: {exc}\n" - "Stack (most recent call last):\n" - f"{stack_text}" - ) - _logger.error( - "Unhandled exception (error_id=%s, path=%s, method=%s)\n%s", + "Unhandled exception (error_id=%s, path=%s, method=%s)", error_id, request.url.path, request.method, - trace_text, + exc_info=True, ) problem = ProblemDetail( diff --git a/server/tests/test_errors_handlers.py b/server/tests/test_errors_handlers.py index 1a458768..69d49e25 100644 --- a/server/tests/test_errors_handlers.py +++ b/server/tests/test_errors_handlers.py @@ -57,8 +57,11 @@ async def test_generic_exception_handler_logs_full_traceback( caplog.set_level(logging.ERROR, logger="agent_control_server.errors") request = Request({"type": "http", "method": "GET", "path": "/boom", "headers": []}) - # When: handling the exception - await generic_exception_handler(request, ValueError("boom")) + # When: handling the exception while inside an except block + try: + raise ValueError("boom") + except ValueError as exc: + await generic_exception_handler(request, exc) # Then: server logs include traceback details assert "Unhandled exception (error_id=" in caplog.text From 5830175b212c99c7d67f5473fc489b9158f0a509 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 5 Feb 2026 13:46:04 -0800 Subject: [PATCH 3/4] refactor(server): simplify API error sanitization --- server/src/agent_control_server/errors.py | 89 ++++++++--------------- 1 file changed, 29 insertions(+), 60 deletions(-) diff --git a/server/src/agent_control_server/errors.py b/server/src/agent_control_server/errors.py index c3e6e382..6d136da3 100644 --- a/server/src/agent_control_server/errors.py +++ b/server/src/agent_control_server/errors.py @@ -31,7 +31,6 @@ """ import logging -import re import uuid from typing import Any @@ -51,6 +50,7 @@ _logger = logging.getLogger(__name__) _MAX_PUBLIC_TEXT_LENGTH = 500 +_MAX_LABEL_LENGTH = 120 _REDACTED_VALUE = "[REDACTED]" _GENERIC_INTERNAL_DETAIL = "An unexpected error occurred. Please try again or contact support." _GENERIC_DATABASE_DETAIL = "A database error occurred while processing the request." @@ -62,37 +62,18 @@ _GENERIC_FORBIDDEN_DETAIL = "Permission denied." _GENERIC_NOT_FOUND_DETAIL = "Requested resource was not found." _GENERIC_CONFLICT_DETAIL = "Request conflicts with existing state." - -_EXCEPTION_PREFIX_RE = re.compile(r"^[A-Za-z_][\w.]{0,80}(Error|Exception):\s+") -_TRACEBACK_RE = re.compile(r"traceback \(most recent call last\):", re.IGNORECASE) -_STACK_FRAME_RE = re.compile(r'File ".*?\.py", line \d+', re.IGNORECASE) -_PYDANTIC_INTERNAL_RE = re.compile(r"\bvalidation error for\b", re.IGNORECASE) -_SECRET_KV_RE = re.compile( - r"(?i)\b(password|passwd|secret|token|api[-_]?key|authorization)\b\s*[:=]\s*\S+" -) -_BEARER_TOKEN_RE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-/+=]+") -_CONNECTION_STRING_RE = re.compile(r"(?i)\b(postgresql|postgres|mysql|sqlite)://\S+") -_ABSOLUTE_PATH_RE = re.compile(r"(?i)(/Users/|/home/|/var/|[A-Z]:\\)") -_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_.:\-\[\]/]+$") -_SENSITIVE_PATTERNS = ( - _TRACEBACK_RE, - _STACK_FRAME_RE, - _EXCEPTION_PREFIX_RE, - _PYDANTIC_INTERNAL_RE, - _SECRET_KV_RE, - _BEARER_TOKEN_RE, - _CONNECTION_STRING_RE, - _ABSOLUTE_PATH_RE, -) +_DEFAULT_5XX_DETAIL_BY_CODE: dict[ErrorCode, str] = { + ErrorCode.INTERNAL_ERROR: _GENERIC_INTERNAL_DETAIL, + ErrorCode.DATABASE_ERROR: _GENERIC_DATABASE_DETAIL, + ErrorCode.AUTH_MISCONFIGURED: _GENERIC_AUTH_MISCONFIGURED_DETAIL, +} def _default_safe_detail(status_code: int, error_code: ErrorCode | None) -> str: """Return a fallback safe detail for the given status and error code.""" - if error_code == ErrorCode.DATABASE_ERROR: - return _GENERIC_DATABASE_DETAIL - if error_code == ErrorCode.AUTH_MISCONFIGURED: - return _GENERIC_AUTH_MISCONFIGURED_DETAIL if status_code >= 500: + if error_code is not None and error_code in _DEFAULT_5XX_DETAIL_BY_CODE: + return _DEFAULT_5XX_DETAIL_BY_CODE[error_code] return _GENERIC_INTERNAL_DETAIL if status_code in (400, 422): return _GENERIC_BAD_REQUEST_DETAIL @@ -115,12 +96,17 @@ def _normalize_public_text(text: str) -> str: return normalized -def _contains_internal_or_sensitive_data(text: str) -> bool: - """Detect content that should never be returned to API callers.""" - return any(pattern.search(text) for pattern in _SENSITIVE_PATTERNS) +def _sanitize_label(value: str, *, fallback: str) -> str: + """Normalize a short identifier-like field for response payloads.""" + normalized = _normalize_public_text(value) + if not normalized: + return fallback + if len(normalized) > _MAX_LABEL_LENGTH: + return normalized[:_MAX_LABEL_LENGTH] + return normalized -def sanitize_public_error_text( +def _safe_detail( text: str, *, status_code: int, @@ -128,11 +114,10 @@ def sanitize_public_error_text( error_code: ErrorCode | None = None, ) -> str: """ - Sanitize a string for client-facing error responses. + Return safe client-facing detail text. - This is intentionally strict: - - All 5xx content is replaced with a safe template. - - 4xx content is preserved only if it does not look internal/sensitive. + For 5xx statuses, this always returns a fixed safe template. + For 4xx statuses, this keeps caller-provided text after normalization. """ safe_fallback = ( fallback if fallback is not None else _default_safe_detail(status_code, error_code) @@ -143,33 +128,17 @@ def sanitize_public_error_text( normalized = _normalize_public_text(text) if not normalized: return safe_fallback - if _contains_internal_or_sensitive_data(normalized): - return safe_fallback return normalized -def _sanitize_optional_public_text(text: str | None) -> str | None: - """Sanitize optional advisory text; returns None when unsafe.""" +def _safe_optional_text(text: str | None) -> str | None: + """Normalize optional advisory text; empty values become None.""" if text is None: return None normalized = _normalize_public_text(text) - if not normalized: + if normalized == "": return None - if _contains_internal_or_sensitive_data(normalized): - return None - return normalized - - -def _sanitize_identifier(value: str, *, fallback: str) -> str: - """Sanitize resource/field/code identifiers in validation payloads.""" - normalized = _normalize_public_text(value) - if not normalized: - return fallback - if len(normalized) > 120: - normalized = normalized[:120] - if not _SAFE_IDENTIFIER_RE.fullmatch(normalized): - return fallback return normalized @@ -202,14 +171,14 @@ def _sanitize_validation_errors( sanitized.append( item.model_copy( update={ - "resource": _sanitize_identifier(item.resource, fallback="Request"), + "resource": _sanitize_label(item.resource, fallback="Request"), "field": ( - _sanitize_identifier(item.field, fallback="field") + _sanitize_label(item.field, fallback="field") if item.field is not None else None ), - "code": _sanitize_identifier(item.code, fallback="validation_error"), - "message": sanitize_public_error_text( + "code": _sanitize_label(item.code, fallback="validation_error"), + "message": _safe_detail( item.message, status_code=status_code, fallback="Invalid value.", @@ -223,14 +192,14 @@ def _sanitize_validation_errors( def _sanitize_problem_detail(problem: ProblemDetail) -> ProblemDetail: """Apply public-safe sanitization rules to a ProblemDetail payload.""" - problem.detail = sanitize_public_error_text( + problem.detail = _safe_detail( problem.detail, status_code=problem.status, error_code=problem.error_code, ) if problem.hint is not None: - problem.hint = _sanitize_optional_public_text(problem.hint) + problem.hint = _safe_optional_text(problem.hint) problem.errors = _sanitize_validation_errors(problem.errors, status_code=problem.status) From fe755dd97f29e5bb490ef9fd172be7acc7b405df Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 5 Feb 2026 13:51:19 -0800 Subject: [PATCH 4/4] refactor(server): reduce error sanitizer complexity --- server/src/agent_control_server/errors.py | 123 +++++++--------------- 1 file changed, 38 insertions(+), 85 deletions(-) diff --git a/server/src/agent_control_server/errors.py b/server/src/agent_control_server/errors.py index 6d136da3..32d48547 100644 --- a/server/src/agent_control_server/errors.py +++ b/server/src/agent_control_server/errors.py @@ -50,7 +50,6 @@ _logger = logging.getLogger(__name__) _MAX_PUBLIC_TEXT_LENGTH = 500 -_MAX_LABEL_LENGTH = 120 _REDACTED_VALUE = "[REDACTED]" _GENERIC_INTERNAL_DETAIL = "An unexpected error occurred. Please try again or contact support." _GENERIC_DATABASE_DETAIL = "A database error occurred while processing the request." @@ -67,25 +66,14 @@ ErrorCode.DATABASE_ERROR: _GENERIC_DATABASE_DETAIL, ErrorCode.AUTH_MISCONFIGURED: _GENERIC_AUTH_MISCONFIGURED_DETAIL, } - - -def _default_safe_detail(status_code: int, error_code: ErrorCode | None) -> str: - """Return a fallback safe detail for the given status and error code.""" - if status_code >= 500: - if error_code is not None and error_code in _DEFAULT_5XX_DETAIL_BY_CODE: - return _DEFAULT_5XX_DETAIL_BY_CODE[error_code] - return _GENERIC_INTERNAL_DETAIL - if status_code in (400, 422): - return _GENERIC_BAD_REQUEST_DETAIL - if status_code == 401: - return _GENERIC_UNAUTHORIZED_DETAIL - if status_code == 403: - return _GENERIC_FORBIDDEN_DETAIL - if status_code == 404: - return _GENERIC_NOT_FOUND_DETAIL - if status_code == 409: - return _GENERIC_CONFLICT_DETAIL - return _GENERIC_BAD_REQUEST_DETAIL +_DEFAULT_4XX_DETAIL_BY_STATUS: dict[int, str] = { + 400: _GENERIC_BAD_REQUEST_DETAIL, + 401: _GENERIC_UNAUTHORIZED_DETAIL, + 403: _GENERIC_FORBIDDEN_DETAIL, + 404: _GENERIC_NOT_FOUND_DETAIL, + 409: _GENERIC_CONFLICT_DETAIL, + 422: _GENERIC_BAD_REQUEST_DETAIL, +} def _normalize_public_text(text: str) -> str: @@ -96,53 +84,33 @@ def _normalize_public_text(text: str) -> str: return normalized -def _sanitize_label(value: str, *, fallback: str) -> str: - """Normalize a short identifier-like field for response payloads.""" - normalized = _normalize_public_text(value) - if not normalized: - return fallback - if len(normalized) > _MAX_LABEL_LENGTH: - return normalized[:_MAX_LABEL_LENGTH] - return normalized +def _default_public_detail(status_code: int, error_code: ErrorCode | None) -> str: + """Return the default public-safe detail template for this status/code.""" + if status_code >= 500: + if error_code is not None and error_code in _DEFAULT_5XX_DETAIL_BY_CODE: + return _DEFAULT_5XX_DETAIL_BY_CODE[error_code] + return _GENERIC_INTERNAL_DETAIL + return _DEFAULT_4XX_DETAIL_BY_STATUS.get(status_code, _GENERIC_BAD_REQUEST_DETAIL) -def _safe_detail( - text: str, - *, - status_code: int, - fallback: str | None = None, - error_code: ErrorCode | None = None, -) -> str: +def _public_detail(status_code: int, error_code: ErrorCode | None, detail: str) -> str: """ Return safe client-facing detail text. For 5xx statuses, this always returns a fixed safe template. For 4xx statuses, this keeps caller-provided text after normalization. """ - safe_fallback = ( - fallback if fallback is not None else _default_safe_detail(status_code, error_code) - ) + safe_fallback = _default_public_detail(status_code, error_code) if status_code >= 500: return safe_fallback - normalized = _normalize_public_text(text) + normalized = _normalize_public_text(detail) if not normalized: return safe_fallback return normalized -def _safe_optional_text(text: str | None) -> str | None: - """Normalize optional advisory text; empty values become None.""" - if text is None: - return None - - normalized = _normalize_public_text(text) - if normalized == "": - return None - return normalized - - -def _sanitize_validation_error_value(value: Any) -> Any | None: +def _sanitize_validation_error_value(value: Any) -> bool | int | float | str | None: """ Redact potentially sensitive invalid values before returning them to clients. @@ -159,55 +127,40 @@ def _sanitize_validation_error_value(value: Any) -> Any | None: def _sanitize_validation_errors( items: list[ValidationErrorItem] | None, - *, - status_code: int, ) -> list[ValidationErrorItem] | None: """Sanitize validation error arrays for safe client output.""" if items is None: return None - sanitized: list[ValidationErrorItem] = [] - for item in items: - sanitized.append( - item.model_copy( - update={ - "resource": _sanitize_label(item.resource, fallback="Request"), - "field": ( - _sanitize_label(item.field, fallback="field") - if item.field is not None - else None - ), - "code": _sanitize_label(item.code, fallback="validation_error"), - "message": _safe_detail( - item.message, - status_code=status_code, - fallback="Invalid value.", - ), - "value": _sanitize_validation_error_value(item.value), - } - ) + return [ + item.model_copy( + update={ + "value": _sanitize_validation_error_value(item.value), + } ) - return sanitized + for item in items + ] def _sanitize_problem_detail(problem: ProblemDetail) -> ProblemDetail: """Apply public-safe sanitization rules to a ProblemDetail payload.""" - problem.detail = _safe_detail( - problem.detail, - status_code=problem.status, - error_code=problem.error_code, - ) + problem.detail = _public_detail(problem.status, problem.error_code, problem.detail) + + if problem.status >= 500: + problem.hint = None + problem.errors = None + if problem.details is not None: + problem.details.causes = None + return problem if problem.hint is not None: - problem.hint = _safe_optional_text(problem.hint) + normalized_hint = _normalize_public_text(problem.hint) + problem.hint = normalized_hint if normalized_hint else None - problem.errors = _sanitize_validation_errors(problem.errors, status_code=problem.status) + problem.errors = _sanitize_validation_errors(problem.errors) if problem.details is not None and problem.details.causes is not None: - problem.details.causes = _sanitize_validation_errors( - problem.details.causes, - status_code=problem.status, - ) + problem.details.causes = _sanitize_validation_errors(problem.details.causes) return problem