fix(guardrail): hide-secrets playground redaction and guardrail telemetry (#39398)

* Fix hide-secrets guardrail: playground redaction, UI dropdown entry, spend-log telemetry

The hide-secrets guardrail never implemented apply_guardrail, so the UI test
playground echoed secrets verbatim; it was missing from the Add Guardrail
dropdown; and it recorded no guardrail_information, so Spend Logs could not
distinguish a redacted request from a clean one.

- implement apply_guardrail (unified interface) with use_native_lifecycle_hooks
  so proxied traffic stays on async_pre_call_hook (per-key opt-out and
  data["prompt"] handling live only there)
- record standard_logging_guardrail_information (allow/mask + masked_entity_count)
  via _process_response/_process_error; opted-out keys and legacy nameless
  callback instances record nothing
- advertise hide-secrets in /guardrails/ui/add_guardrail_settings (pre_call only)
  and /guardrails/ui/provider_specific_params with a config model

Resolves LIT-3548

* Fix hide-secrets passthrough telemetry and JSON config input

* fix(guardrails): validate hide-secrets object config before submit

- apply_guardrail treats empty-string-only texts as no input, so no
  false allow is recorded
- the UI object field keeps raw text while editing and blocks submission
  until it parses to a JSON object, instead of posting a string to an
  object-only API
- supported_modes_by_provider keeps its dict[str, list[str]] value type

* fix(guardrails): record no hide-secrets telemetry when nothing was inspected

walk_user_text and the prompt redaction now report how many non-empty
strings they visited; when neither inspected anything (image-only
content, empty strings), the run records no guardrail entry instead of
an 'allow' row that counts a check which never saw any text.
This commit is contained in:
yucheng-berri 2026-09-03 00:01:03 -07:00 committed by GitHub
parent 3cac5e5cd4
commit ecabfbd5af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 659 additions and 50 deletions

View file

@ -11,17 +11,35 @@ import sys
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import functools
import tempfile
from typing import Optional
from contextvars import ContextVar
from typing import TYPE_CHECKING, ClassVar, Literal, Optional
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails._content_utils import walk_user_text
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
GUARDRAIL_NAME = "hide_secrets"
GUARDRAIL_PROVIDER = "hide-secrets"
# Per-invocation tally of redacted secrets by detect-secrets plugin type; None
# means the guardrail did not run, so _process_response records nothing.
_masked_entity_count: ContextVar[Optional[dict]] = ContextVar(
"hide_secrets_masked_entity_count", default=None
)
_custom_plugins_path = "file://" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "secrets_plugins"
)
@ -422,6 +440,10 @@ _default_detect_secrets_config = {
class _ENTERPRISE_SecretDetection(CustomGuardrail):
# Keeps proxied traffic on async_pre_call_hook (the unified apply_guardrail
# path skips should_run_check and never sees data["prompt"]).
use_native_lifecycle_hooks: ClassVar[bool] = True
def __init__(self, detect_secrets_config: Optional[dict] = None, **kwargs):
self.user_defined_detect_secrets_config = detect_secrets_config
super().__init__(**kwargs)
@ -455,6 +477,26 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
return detected_secrets
def redact_text(self, text: str, source: str = "message") -> str:
"""Replace every detected secret in ``text`` with ``[REDACTED]`` and
tally the detected types into the per-invocation masked-entity count."""
detected_secrets = self.scan_message_for_secrets(text)
if not detected_secrets:
return text
counts = _masked_entity_count.get()
if counts is not None:
for secret in detected_secrets:
counts[secret["type"]] = counts.get(secret["type"], 0) + 1
secret_types = [secret["type"] for secret in detected_secrets]
verbose_proxy_logger.warning(
f"Detected and redacted secrets in {source}: {secret_types}"
)
return functools.reduce(
lambda redacted, secret: redacted.replace(secret["value"], "[REDACTED]"),
detected_secrets,
text,
)
async def should_run_check(self, user_api_key_dict: UserAPIKeyAuth) -> bool:
if user_api_key_dict.permissions is not None:
if GUARDRAIL_NAME in user_api_key_dict.permissions:
@ -463,7 +505,45 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
return True
@log_guardrail_information
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
"""Unified-interface entrypoint, used by /guardrails/apply_guardrail
(the UI test playground). Proxied traffic keeps using
``async_pre_call_hook``, see ``use_native_lifecycle_hooks``."""
texts = inputs.get("texts")
if not texts or not any(texts):
return inputs
_masked_entity_count.set({})
return {**inputs, "texts": [self.redact_text(text) for text in texts]}
def _redact_prompt(self, data: dict) -> int:
"""Redact ``data["prompt"]`` (the text-completion shape, which
``walk_user_text`` does not cover) and return how many non-empty
strings were inspected."""
prompt = data.get("prompt")
if isinstance(prompt, str):
if not prompt:
return 0
data["prompt"] = self.redact_text(prompt, source="prompt")
return 1
if isinstance(prompt, list):
data["prompt"] = [ # mutable-ok: data["prompt"] is a list on the wire
self.redact_text(item, source="prompt")
if isinstance(item, str) and item
else item
for item in prompt
]
return sum(1 for item in prompt if isinstance(item, str) and item)
return 0
#### CALL HOOKS - proxy only ####
@log_guardrail_information
async def async_pre_call_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
@ -471,53 +551,84 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
data: dict,
call_type: str, # "completion", "embeddings", "image_generation", "moderation"
):
_masked_entity_count.set(None)
if await self.should_run_check(user_api_key_dict) is False:
return
_masked_entity_count.set({})
# Covers multimodal list content + Responses-API input.
def _redact_message_text(text: str) -> str:
detected_secrets = self.scan_message_for_secrets(text)
for secret in detected_secrets:
text = text.replace(secret["value"], "[REDACTED]")
if detected_secrets:
secret_types = [secret["type"] for secret in detected_secrets]
verbose_proxy_logger.warning(
f"Detected and redacted secrets in message: {secret_types}"
)
return text
inspected = walk_user_text(data, self.redact_text) + self._redact_prompt(data)
walk_user_text(data, _redact_message_text)
if inspected == 0:
# Image-only, empty-text, and unsupported payloads inspected
# nothing, so recording "allow" would count a run that never
# looked at any content.
_masked_entity_count.set(None)
if "prompt" in data:
if isinstance(data["prompt"], str):
detected_secrets = self.scan_message_for_secrets(data["prompt"])
for secret in detected_secrets:
data["prompt"] = data["prompt"].replace(
secret["value"], "[REDACTED]"
)
if len(detected_secrets) > 0:
secret_types = [secret["type"] for secret in detected_secrets]
verbose_proxy_logger.warning(
f"Detected and redacted secrets in prompt: {secret_types}"
)
elif isinstance(data["prompt"], list):
# Index back into the list — assigning to ``item`` would only
# rebind the loop variable and leave ``data["prompt"]``
# carrying the unredacted secret.
for idx, item in enumerate(data["prompt"]):
if isinstance(item, str):
detected_secrets = self.scan_message_for_secrets(item)
for secret in detected_secrets:
item = item.replace(secret["value"], "[REDACTED]")
data["prompt"][idx] = item
if len(detected_secrets) > 0:
secret_types = [
secret["type"] for secret in detected_secrets
]
verbose_proxy_logger.warning(
f"Detected and redacted secrets in prompt: {secret_types}"
)
# ``data["input"]`` (Responses API and embeddings/moderation) is
# already covered by ``walk_user_text`` above.
return
def _process_response(
self,
response: Optional[dict],
request_data: dict,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
duration: Optional[float] = None,
event_type: Optional[GuardrailEventHooks] = None,
original_inputs: Optional[dict] = None,
):
"""Record allow/mask plus the masked-entity tally for a completed run.
Records nothing when the guardrail inspected nothing (opted-out key,
empty inputs) or when the instance has no guardrail_name (legacy
``litellm_settings.callbacks`` deployments, which predate guardrail
telemetry and stay without it).
"""
counts = _masked_entity_count.get()
_masked_entity_count.set(None)
if counts is None or self.guardrail_name is None:
return response
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response="mask" if counts else "allow",
request_data=request_data,
guardrail_status="success",
duration=duration,
start_time=start_time,
end_time=end_time,
event_type=event_type,
guardrail_provider=GUARDRAIL_PROVIDER,
masked_entity_count=counts,
)
return response
def _process_error(
self,
e: Exception,
request_data: dict,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
duration: Optional[float] = None,
event_type: Optional[GuardrailEventHooks] = None,
):
"""Label the failed run with this guardrail's provider so error rows
group with the successful ones in the monitor. Nameless legacy
instances record nothing, matching ``_process_response``."""
_masked_entity_count.set(None)
if self.guardrail_name is None:
raise e
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response=e,
request_data=request_data,
guardrail_status=(
"guardrail_intervened"
if self._is_guardrail_intervention(e)
else "guardrail_failed_to_respond"
),
duration=duration,
start_time=start_time,
end_time=end_time,
event_type=event_type,
guardrail_provider=GUARDRAIL_PROVIDER,
)
raise e

View file

@ -1485,7 +1485,7 @@ def log_guardrail_information(func):
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
original_inputs = kwargs.get("inputs")
logging_obj: Final = kwargs.get("logging_obj")
logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj")
self_recorded_token: Final = _guardrail_self_recorded.set(False)
try:
response: Final = await func(*args, **kwargs)
@ -1527,7 +1527,7 @@ def log_guardrail_information(func):
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
original_inputs = kwargs.get("inputs")
logging_obj: Final = kwargs.get("logging_obj")
logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj")
self_recorded_token: Final = _guardrail_self_recorded.set(False)
try:
response: Final = func(*args, **kwargs)

View file

@ -8,7 +8,7 @@ import json
import os
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime, timezone
from types import UnionType
from types import MappingProxyType, UnionType
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union, cast, get_args, get_origin
from urllib.parse import urlparse
@ -51,6 +51,9 @@ from litellm.types.guardrails import (
SupportedGuardrailIntegrations,
ToolPermissionGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.hide_secrets import (
HideSecretsGuardrailConfigModel,
)
if TYPE_CHECKING:
from types import CodeType
@ -1401,7 +1404,11 @@ async def get_guardrail_ui_settings():
provider: [hook.value for hook in hooks]
for provider, guardrail_class in guardrail_class_registry.items()
if (hooks := guardrail_class.get_supported_event_hooks()) is not None
}
} | MappingProxyType(
# hide-secrets lives in the enterprise package, not in the registry
# above; it only runs on pre_call.
{SupportedGuardrailIntegrations.HIDE_SECRETS.value: [GuardrailEventHooks.pre_call.value]}
)
return GuardrailUIAddGuardrailSettings(
supported_entities=[entity.value for entity in PiiEntityType],
@ -1953,12 +1960,18 @@ async def get_provider_specific_params():
tool_permission_fields["ui_friendly_name"] = ToolPermissionGuardrailConfigModel.ui_friendly_name()
# hide-secrets lives in the enterprise package, not in the registry loop below.
hide_secrets_fields: Final = _get_fields_from_model(HideSecretsGuardrailConfigModel)
hide_secrets_fields["ui_friendly_name"] = HideSecretsGuardrailConfigModel.ui_friendly_name()
# Return the provider-specific parameters
provider_params: Final = {
SupportedGuardrailIntegrations.BEDROCK.value: bedrock_fields,
SupportedGuardrailIntegrations.PRESIDIO.value: presidio_fields,
SupportedGuardrailIntegrations.LAKERA_V2.value: lakera_v2_fields,
SupportedGuardrailIntegrations.TOOL_PERMISSION.value: tool_permission_fields,
SupportedGuardrailIntegrations.HIDE_SECRETS.value: hide_secrets_fields,
}
### get the config model for the guardrail - go through the registry and get the config model for the guardrail

View file

@ -0,0 +1,20 @@
"""Types for the Hide Secrets guardrail."""
from pydantic import Field
from .base import GuardrailConfigModel
class HideSecretsGuardrailConfigModel(GuardrailConfigModel):
"""Configuration for the Hide Secrets guardrail. Detection runs in-process
on the detect-secrets library; ``detect_secrets_config`` overrides the
bundled plugin set."""
detect_secrets_config: dict | None = Field( # mutable-ok: UI type derivation maps dict to "object"
default=None,
description="Optional detect-secrets configuration (plugins_used, filters_used) overriding the bundled plugin set",
)
@staticmethod
def ui_friendly_name() -> str:
return "Hide Secrets"

View file

@ -0,0 +1,274 @@
"""Tests for the hide-secrets guardrail (LIT-3548).
Covers the three defects from the ticket:
- ``apply_guardrail`` (the UI test playground path) must redact, not echo.
- Guardrail runs must record ``standard_logging_guardrail_information`` so
Spend Logs / the guardrails monitor show activity, with hits ("mask" +
masked_entity_count) distinguishable from clean requests ("allow").
- Defining ``apply_guardrail`` must NOT reroute proxied traffic off the
native ``async_pre_call_hook`` (per-key opt-out and ``data["prompt"]``
handling live only on the native path).
"""
import pytest
from litellm_enterprise.enterprise_callbacks.secret_detection import (
_ENTERPRISE_SecretDetection,
)
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
def _guardrail() -> _ENTERPRISE_SecretDetection:
return _ENTERPRISE_SecretDetection(
guardrail_name="hide-secrets", event_hook="pre_call", default_on=True
)
def _recorded(request_data: dict) -> dict:
entries = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(entries) == 1
return entries[0]
@pytest.mark.asyncio
async def test_apply_guardrail_redacts_secrets():
"""Playground path: the returned texts must carry [REDACTED], not the secret."""
guardrail = _guardrail()
request_data: dict = {"metadata": {}}
result = await guardrail.apply_guardrail(
inputs={"texts": [f"my key is {AWS_KEY}, keep it safe"]},
request_data=request_data,
input_type="request",
)
assert result["texts"] == ["my key is [REDACTED], keep it safe"]
recorded = _recorded(request_data)
assert recorded["guardrail_status"] == "success"
assert recorded["guardrail_response"] == "mask"
assert recorded["guardrail_provider"] == "hide-secrets"
assert recorded["masked_entity_count"] == {"AWS Access Key": 1}
@pytest.mark.asyncio
async def test_apply_guardrail_clean_text_records_allow():
guardrail = _guardrail()
request_data: dict = {"metadata": {}}
result = await guardrail.apply_guardrail(
inputs={"texts": ["nothing sensitive here"]},
request_data=request_data,
input_type="request",
)
assert result["texts"] == ["nothing sensitive here"]
recorded = _recorded(request_data)
assert recorded["guardrail_status"] == "success"
assert recorded["guardrail_response"] == "allow"
assert recorded["masked_entity_count"] == {}
@pytest.mark.asyncio
async def test_pre_call_hook_records_mask_with_entity_count():
"""Live-traffic path: a redaction must be visible in spend-log telemetry."""
guardrail = _guardrail()
data = {
"messages": [{"role": "user", "content": f"use {AWS_KEY} for auth"}],
"metadata": {},
}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["messages"][0]["content"] == "use [REDACTED] for auth"
recorded = _recorded(data)
assert recorded["guardrail_status"] == "success"
assert recorded["guardrail_response"] == "mask"
assert recorded["guardrail_provider"] == "hide-secrets"
assert recorded["masked_entity_count"] == {"AWS Access Key": 1}
@pytest.mark.asyncio
async def test_pre_call_hook_clean_request_records_allow():
"""A request with no secrets must be distinguishable from a redacted one."""
guardrail = _guardrail()
data = {
"messages": [{"role": "user", "content": "what's the weather"}],
"metadata": {},
}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
recorded = _recorded(data)
assert recorded["guardrail_status"] == "success"
assert recorded["guardrail_response"] == "allow"
assert recorded["masked_entity_count"] == {}
@pytest.mark.asyncio
async def test_pre_call_hook_opt_out_records_nothing():
"""A key with permissions={"hide_secrets": False} skips redaction, so no
telemetry is recorded: every reader of a recorded entry (guardrail usage
tracking, compliance checks, the spend-log viewer) counts it as a run."""
guardrail = _guardrail()
content = f"my key is {AWS_KEY}"
data = {"messages": [{"role": "user", "content": content}], "metadata": {}}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(permissions={"hide_secrets": False}),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["messages"][0]["content"] == content # untouched
assert "standard_logging_guardrail_information" not in data["metadata"]
@pytest.mark.asyncio
async def test_pre_call_hook_still_redacts_text_completion_prompt():
"""data["prompt"] (str and list) is a native-hook-only surface; it must
keep redacting now that the class also implements apply_guardrail."""
guardrail = _guardrail()
data = {"prompt": f"key {AWS_KEY} end", "metadata": {}}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["prompt"] == "key [REDACTED] end"
guardrail = _guardrail()
data = {"prompt": [f"key {AWS_KEY}", "clean"], "metadata": {}}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["prompt"] == ["key [REDACTED]", "clean"]
def test_proxied_traffic_stays_on_native_hooks():
"""Implementing apply_guardrail must not reroute proxied requests onto the
unified path: that path skips ``should_run_check`` (per-key opt-out) and
never sees ``data["prompt"]``."""
guardrail = _guardrail()
assert guardrail.uses_apply_guardrail_interface() is True
assert guardrail._deployment_pre_call_target() is guardrail
@pytest.mark.asyncio
async def test_apply_guardrail_without_texts_records_nothing():
"""No inputs means nothing was inspected, so no "allow" row is recorded.
Empty strings count as no input: there is no content to inspect."""
guardrail = _guardrail()
empty_variants: list[list[str]] = [[], ["", ""]]
for texts in empty_variants:
request_data: dict = {"metadata": {}}
result = await guardrail.apply_guardrail(
inputs={"texts": texts}, request_data=request_data, input_type="request"
)
assert result == {"texts": texts}
assert "standard_logging_guardrail_information" not in request_data["metadata"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"data",
[
pytest.param(
{
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://x/y.png"}}
],
}
],
"metadata": {},
},
id="image_only",
),
pytest.param(
{"messages": [{"role": "user", "content": ""}], "metadata": {}},
id="empty_message",
),
pytest.param({"prompt": "", "metadata": {}}, id="empty_prompt"),
pytest.param({"prompt": ["", ""], "metadata": {}}, id="empty_prompt_list"),
],
)
async def test_pre_call_hook_without_inspectable_text_records_nothing(data: dict):
"""A payload the guardrail could not inspect (image-only content, empty
strings) must not record an "allow" run: monitoring would count a check
that never looked at any text."""
guardrail = _guardrail()
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert "standard_logging_guardrail_information" not in data["metadata"]
@pytest.mark.asyncio
async def test_pre_call_hook_mixed_prompt_list_still_redacts_and_records():
"""A prompt list mixing empty and real strings is inspected, so the run is
recorded and the non-empty entry is still redacted."""
guardrail = _guardrail()
data = {"prompt": ["", f"key {AWS_KEY}"], "metadata": {}}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["prompt"] == ["", "key [REDACTED]"]
recorded = _recorded(data)
assert recorded["guardrail_response"] == "mask"
assert recorded["masked_entity_count"] == {"AWS Access Key": 1}
@pytest.mark.asyncio
async def test_legacy_nameless_instance_records_nothing():
"""``litellm_settings.callbacks: ["hide_secrets"]`` builds an arg-less
instance with no guardrail_name. It still redacts, but recording a nameless
entry would flip every spend row's guardrail status with nothing to join on."""
guardrail = _ENTERPRISE_SecretDetection()
data = {
"messages": [{"role": "user", "content": f"use {AWS_KEY} for auth"}],
"metadata": {},
}
await guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data=data,
call_type="completion",
)
assert data["messages"][0]["content"] == "use [REDACTED] for auth"
assert "standard_logging_guardrail_information" not in data["metadata"]

View file

@ -670,6 +670,37 @@ def test_get_provider_specific_params():
) # Literal type should be select
@pytest.mark.asyncio
async def test_provider_specific_params_includes_hide_secrets():
"""hide-secrets lives in the enterprise package so it is not in
guardrail_class_registry; the endpoint must still advertise it or the
Add Guardrail UI dropdown never offers it (LIT-3548)."""
from litellm.proxy.guardrails.guardrail_endpoints import (
get_provider_specific_params,
)
provider_params = await get_provider_specific_params()
assert "hide-secrets" in provider_params
# populateGuardrailProviders() in the dashboard only lists providers whose
# entry carries a ui_friendly_name.
assert provider_params["hide-secrets"]["ui_friendly_name"] == "Hide Secrets"
assert provider_params["hide-secrets"]["detect_secrets_config"]["required"] is False
@pytest.mark.asyncio
async def test_add_guardrail_settings_restricts_hide_secrets_to_pre_call():
"""hide-secrets only implements async_pre_call_hook, so offering the other
modes in the UI would create configs that boot clean and never run."""
from litellm.proxy.guardrails.guardrail_endpoints import (
get_guardrail_ui_settings,
)
settings = await get_guardrail_ui_settings()
assert settings.supported_modes_by_provider["hide-secrets"] == ["pre_call"]
def test_optional_params_not_returned_when_not_overridden():
"""Test that optional_params is not returned when the config model doesn't override it"""
from typing import Optional

View file

@ -201,6 +201,7 @@ export const guardrailLogoMap = {
XecGuard: xecguardLogo.src,
"LiteLLM Content Filter": litellmLogo.src,
"LiteLLM LLM as a Judge": litellmLogo.src,
"Hide Secrets": litellmLogo.src,
Akto: aktoLogo.src,
"DeepKeep AI Firewall": deepkeepLogo.src,
"Qostodian Nexus": qohashLogo.src,

View file

@ -0,0 +1,99 @@
import React from "react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { useForm } from "react-hook-form";
import { renderWithProviders } from "@/../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailProviderFields from "./guardrail_provider_fields";
import { populateGuardrailProviderMap } from "./guardrail_info_helpers";
import type { GuardrailFormValues } from "./GuardrailFormField";
vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn() } }));
const HIDE_SECRETS_PARAMS = {
"hide-secrets": {
ui_friendly_name: "Hide Secrets",
detect_secrets_config: {
param: "detect_secrets_config",
description: "Optional detect-secrets configuration",
required: false,
type: "object",
},
},
};
const Harness: React.FC<{ onValid: (values: GuardrailFormValues) => void }> = ({ onValid }) => {
const form = useForm<GuardrailFormValues>();
return (
<form onSubmit={form.handleSubmit(onValid)}>
<GuardrailProviderFields
selectedProvider="Hide-secrets"
control={form.control}
providerParams={HIDE_SECRETS_PARAMS}
/>
<button type="submit">save</button>
</form>
);
};
const renderHarness = () => {
populateGuardrailProviderMap(HIDE_SECRETS_PARAMS);
const onValid = vi.fn();
renderWithProviders(<Harness onValid={onValid} />);
const textarea = screen.getByLabelText(/detect_secrets_config/) as HTMLTextAreaElement;
return { onValid, textarea };
};
describe("GuardrailProviderFields object field", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("commits a valid JSON object as a parsed dict", async () => {
const { onValid, textarea } = renderHarness();
fireEvent.change(textarea, { target: { value: '{"plugins_used": [{"name": "AWSKeyDetector"}]}' } });
fireEvent.blur(textarea);
fireEvent.click(screen.getByRole("button", { name: "save" }));
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
expect(onValid.mock.calls[0][0].detect_secrets_config).toEqual({
plugins_used: [{ name: "AWSKeyDetector" }],
});
});
it("blocks submission while the field holds malformed JSON", async () => {
const { onValid, textarea } = renderHarness();
fireEvent.change(textarea, { target: { value: "{not json" } });
fireEvent.blur(textarea);
fireEvent.click(screen.getByRole("button", { name: "save" }));
await screen.findByText("detect_secrets_config must be a valid JSON object");
expect(onValid).not.toHaveBeenCalled();
expect(textarea.value).toBe("{not json");
});
it.each(['["array"]', '"scalar"', "null", "42"])("blocks non-object JSON %s", async (raw) => {
const { onValid, textarea } = renderHarness();
fireEvent.change(textarea, { target: { value: raw } });
fireEvent.blur(textarea);
fireEvent.click(screen.getByRole("button", { name: "save" }));
await screen.findByText("detect_secrets_config must be a valid JSON object");
expect(onValid).not.toHaveBeenCalled();
});
it("treats a cleared field as unset and submits", async () => {
const { onValid, textarea } = renderHarness();
fireEvent.change(textarea, { target: { value: '{"a": 1}' } });
fireEvent.blur(textarea);
fireEvent.change(textarea, { target: { value: "" } });
fireEvent.blur(textarea);
fireEvent.click(screen.getByRole("button", { name: "save" }));
await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1));
expect(onValid.mock.calls[0][0].detect_secrets_config).toBeUndefined();
});
});

View file

@ -13,6 +13,8 @@ import { FieldGroup } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/lib/toast";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
asStringArray,
@ -22,6 +24,7 @@ import {
readRecord,
requiredRule,
type GuardrailFieldControlProps,
type GuardrailFieldRules,
type GuardrailFormControl,
} from "./GuardrailFormField";
@ -60,6 +63,44 @@ const BOOLEAN_ITEMS = [
const isSecretKey = (fieldKey: string): boolean =>
fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key");
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
// Object fields hold the raw text while the user types, so submission must be
// blocked until the value parses to a plain JSON object (or is cleared).
const jsonObjectRule = (fieldKey: string): GuardrailFieldRules => ({
validate: (value: unknown) =>
value === undefined || isPlainObject(value) ? true : `${fieldKey} must be a valid JSON object`,
});
// Commits a parsed object (or undefined for a cleared field) to the form on
// blur; anything else stays as raw text so jsonObjectRule blocks submission.
const commitObjectField = (raw: string, onChange: (value: unknown) => void): void => {
const next = raw.trim();
if (next === "") {
onChange(undefined);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(next);
} catch {
parsed = next;
}
if (isPlainObject(parsed)) {
onChange(parsed);
} else {
toast.error("Enter a valid JSON object for this configuration");
}
};
const fieldRules = (field: ProviderParam, fieldKey: string): GuardrailFieldRules | undefined => {
if (field.type === "object") {
return jsonObjectRule(fieldKey);
}
return field.required ? requiredRule(`${fieldKey} is required`) : undefined;
};
interface ProviderFieldInputProps {
descriptor: ProviderParam;
fieldKey: string;
@ -141,6 +182,25 @@ const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fie
);
}
if (descriptor.type === "object") {
const objectValue = typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : asText(value);
return (
<Textarea
id={id}
name={name}
ref={ref}
placeholder={descriptor.description}
value={objectValue}
onChange={(event) => onChange(event.target.value)}
onBlur={(event) => {
commitObjectField(event.target.value, onChange);
onBlur();
}}
{...aria}
/>
);
}
if (descriptor.type === "number") {
return (
<NumericalInput
@ -316,7 +376,7 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
control={control}
name={fullFieldKey}
label={labelWithHint(fieldKey, field.description)}
rules={field.required ? requiredRule(`${fieldKey} is required`) : undefined}
rules={fieldRules(field, fieldKey)}
defaultValue={resolvedInitialValue}
>
{(fieldControl) => <ProviderFieldInput descriptor={field} fieldKey={fieldKey} control={fieldControl} />}