One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
|
|
Runtime enforcement for the global chat guardrails.
|
|
|
|
Two checks around every LLM call on visitor text:
|
|
- check_inbound: strong-signal injection detection BEFORE inference. Blocking
|
|
is governed by GUARDRAIL_INBOUND_ACTION ("off" / "template_only" / "strict");
|
|
non-blocking hits are still counted, which is the data that gates ever
|
|
turning "strict" on.
|
|
- check_output: scans the reply for leaked policy text (replace + record) and
|
|
for the model's own scope refusal (record only) — the only place scope
|
|
declines become measurable, since chat messages are encrypted at rest.
|
|
|
|
Every entry point here FAILS OPEN: a guardrail bug must degrade to
|
|
"no guardrail", never to "no chat".
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional, Tuple
|
|
|
|
from app.agents.guardrail_policy import CANARY_STRINGS, looks_like_scope_refusal
|
|
from app.core.config import settings
|
|
from app.core.logger import get_logger
|
|
from app.repositories.guardrail_event import record_guardrail_events
|
|
from app.utils.guardrails import (
|
|
RULE_FRAME_TOKENS,
|
|
RULE_OFFTOPIC_EXERCISE,
|
|
detect_injection,
|
|
detect_offtopic_exercise,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# What the visitor sees when a message is blocked pre-inference.
|
|
BLOCK_REPLY = (
|
|
"I can't process that message. If you have a question I can help with, "
|
|
"just ask it in plain words."
|
|
)
|
|
|
|
# What replaces a reply that leaked policy text.
|
|
LEAK_REPLY = (
|
|
"I can't share details about how I'm set up. Is there something else I "
|
|
"can help you with?"
|
|
)
|
|
|
|
_EXCERPT_MAX = 300
|
|
|
|
# Output-side rule ids (the inbound ones live with their patterns in guardrails).
|
|
RULE_PROMPT_LEAK = "injection.prompt_leak"
|
|
RULE_MODEL_REFUSED = "offtopic.model_refused"
|
|
|
|
|
|
class Surface:
|
|
"""Where a guardrail event came from — the values stored in
|
|
guardrail_events.surface."""
|
|
|
|
WIDGET = "widget"
|
|
CHANNEL = "channel"
|
|
WORKFLOW = "workflow"
|
|
HELP_CENTER = "help_center"
|
|
|
|
|
|
def offtopic_reply(org_name: Optional[str]) -> str:
|
|
"""The scope decline, used when an exercise brief is stopped in code. Says
|
|
the same thing the policy asks the model to say, so a visitor cannot tell
|
|
which layer answered."""
|
|
who = (org_name or "").strip() or "this business"
|
|
return (
|
|
f"I can only help with questions about {who}. "
|
|
f"What can I help you with?"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InboundVerdict:
|
|
rule_ids: Tuple[str, ...] = ()
|
|
block: bool = False
|
|
matched: Tuple[str, ...] = ()
|
|
reply: str = BLOCK_REPLY
|
|
|
|
@property
|
|
def triggered(self) -> bool:
|
|
return bool(self.rule_ids)
|
|
|
|
def as_attributes(self) -> dict:
|
|
"""chat_history.attributes payload — rule ids only, the column is
|
|
unencrypted plain JSON."""
|
|
if not self.triggered:
|
|
return {}
|
|
return {
|
|
"guardrail": {
|
|
"rules": list(self.rule_ids),
|
|
"action": "blocked" if self.block else "counted",
|
|
}
|
|
}
|
|
|
|
|
|
def _record(
|
|
*,
|
|
surface: str,
|
|
layer: str,
|
|
rules: Tuple[str, ...],
|
|
action: str,
|
|
ctx=None,
|
|
session_id: Optional[str] = None,
|
|
text: Optional[str] = None,
|
|
) -> None:
|
|
"""Log line first (always), then one best-effort write for all rules.
|
|
|
|
`matched` carries the rule ids only. The matched substring would be the
|
|
visitor's own words, and this column is plain JSON while `excerpt` is
|
|
encrypted — reviewable text belongs there, not here.
|
|
"""
|
|
org_id = getattr(ctx, "org_id", None)
|
|
agent_id = getattr(ctx, "agent_id", None)
|
|
for rule in rules:
|
|
logger.warning(
|
|
f"GUARDRAIL_EVENT rule={rule} layer={layer} action={action} "
|
|
f"surface={surface} org={org_id} agent={agent_id} "
|
|
f"session={session_id} len={len(text) if text else 0}"
|
|
)
|
|
if not getattr(settings, "GUARDRAIL_EVENTS_ENABLED", True):
|
|
return
|
|
excerpt = None
|
|
if text and getattr(settings, "GUARDRAIL_STORE_EXCERPT", True):
|
|
excerpt = text[:_EXCERPT_MAX]
|
|
# One session for the whole verdict: an attack tripping several rules used
|
|
# to open a connection per rule.
|
|
record_guardrail_events(
|
|
rules=rules,
|
|
surface=surface,
|
|
layer=layer,
|
|
action=action,
|
|
org_id=org_id,
|
|
agent_id=agent_id,
|
|
session_id=session_id,
|
|
char_len=len(text) if text else None,
|
|
excerpt=excerpt,
|
|
)
|
|
|
|
|
|
def _business_terms(ctx) -> Tuple[str, ...]:
|
|
"""Org name and the domain's own label — a message naming either is about
|
|
the business, whatever else it contains."""
|
|
terms = []
|
|
for attr in ("org_name", "domain"):
|
|
value = getattr(ctx, attr, None)
|
|
if isinstance(value, str) and value.strip():
|
|
terms.append(value.strip())
|
|
if attr == "domain" and "." in value:
|
|
terms.append(value.split(".", 1)[0])
|
|
return tuple(terms)
|
|
|
|
|
|
def _default_scope_in_force(ctx) -> bool:
|
|
"""True only when the agent runs the shipped scope rule unmodified."""
|
|
try:
|
|
if not getattr(ctx, "guardrail_enabled", True):
|
|
return False
|
|
custom = getattr(ctx, "guardrail_prompt", None)
|
|
return not (isinstance(custom, str) and custom.strip())
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def _should_block(rule_ids: Tuple[str, ...]) -> bool:
|
|
mode = (getattr(settings, "GUARDRAIL_INBOUND_ACTION", "template_only") or "off").lower()
|
|
if mode == "strict":
|
|
return bool(rule_ids)
|
|
if mode == "template_only":
|
|
return RULE_FRAME_TOKENS in rule_ids
|
|
return False
|
|
|
|
|
|
def check_inbound(
|
|
text: Optional[str],
|
|
ctx=None,
|
|
surface: str = Surface.WIDGET,
|
|
session_id: Optional[str] = None,
|
|
allow_block: bool = True,
|
|
) -> InboundVerdict:
|
|
"""Pre-LLM injection check on a visitor message.
|
|
|
|
`allow_block=False` forces count-only regardless of mode — used on paths
|
|
that must never short-circuit (the workflow LLM node owns its own control
|
|
flow and message storage).
|
|
"""
|
|
try:
|
|
result = detect_injection(text or "")
|
|
rules = list(result.rule_ids)
|
|
block = allow_block and _should_block(result.rule_ids)
|
|
reply = BLOCK_REPLY
|
|
|
|
# Off-topic exercise briefs: prompt text could not hold these, so they
|
|
# are stopped here — which also avoids paying for the inference the
|
|
# whole change exists to prevent.
|
|
#
|
|
# Only when the tenant is running the DEFAULT scope rule. The detector
|
|
# encodes our default's assumptions (an algorithms brief is off-topic),
|
|
# which is wrong for a coding bootcamp or a maths tutor — and blocking
|
|
# pre-inference gives the model no chance to disagree. If they switched
|
|
# the rule off or wrote their own, this must not fire, or the dashboard
|
|
# would say one thing and the code do another.
|
|
if _default_scope_in_force(ctx) and detect_offtopic_exercise(
|
|
text or "", _business_terms(ctx)
|
|
):
|
|
rules.append(RULE_OFFTOPIC_EXERCISE)
|
|
mode = (getattr(settings, "GUARDRAIL_OFFTOPIC_ACTION", "block") or "off").lower()
|
|
if allow_block and mode == "block":
|
|
block = True
|
|
reply = offtopic_reply(getattr(ctx, "org_name", None))
|
|
|
|
if not rules:
|
|
return InboundVerdict()
|
|
_record(
|
|
surface=surface,
|
|
layer="inbound",
|
|
rules=tuple(rules),
|
|
action="blocked" if block else "counted",
|
|
ctx=ctx,
|
|
session_id=session_id,
|
|
text=text,
|
|
)
|
|
return InboundVerdict(
|
|
rule_ids=tuple(rules), block=block, matched=result.matched, reply=reply
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Inbound guardrail check failed open: {e}")
|
|
return InboundVerdict()
|
|
|
|
|
|
def check_output(
|
|
message: Optional[str],
|
|
ctx=None,
|
|
surface: str = Surface.WIDGET,
|
|
session_id: Optional[str] = None,
|
|
) -> Tuple[Optional[str], List[str]]:
|
|
"""Post-LLM output check.
|
|
|
|
Returns (possibly replaced message, rule ids recorded). A canary hit —
|
|
policy text leaking into the reply — replaces the message; the model's
|
|
own scope refusal is only counted, never altered.
|
|
"""
|
|
try:
|
|
if not message or not getattr(settings, "GUARDRAIL_OUTPUT_CHECK_ENABLED", True):
|
|
return message, []
|
|
if any(canary in message for canary in CANARY_STRINGS):
|
|
_record(
|
|
surface=surface,
|
|
layer="output",
|
|
rules=(RULE_PROMPT_LEAK,),
|
|
action="replaced",
|
|
ctx=ctx,
|
|
session_id=session_id,
|
|
text=message,
|
|
)
|
|
return LEAK_REPLY, [RULE_PROMPT_LEAK]
|
|
if looks_like_scope_refusal(message):
|
|
_record(
|
|
surface=surface,
|
|
layer="output",
|
|
rules=(RULE_MODEL_REFUSED,),
|
|
action="counted",
|
|
ctx=ctx,
|
|
session_id=session_id,
|
|
text=message,
|
|
)
|
|
return message, [RULE_MODEL_REFUSED]
|
|
return message, []
|
|
except Exception as e:
|
|
logger.error(f"Output guardrail check failed open: {e}")
|
|
return message, []
|