248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
package guardrail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// allow returns a passing verdict.
|
|
func allow() *Verdict { return &Verdict{Allow: true} }
|
|
|
|
// deny returns a blocking verdict with the given reason.
|
|
func deny(reason string) *Verdict { return &Verdict{Allow: false, Reason: reason} }
|
|
|
|
// requestText returns the concatenation of every text-bearing content part in a
|
|
// request's messages. Image parts are ignored.
|
|
func requestText(input *model.Request) string {
|
|
var b strings.Builder
|
|
for _, m := range input.Messages {
|
|
b.WriteString(messageText(m))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// responseText returns the text-bearing output of a response. It prefers the
|
|
// convenience Content field and falls back to the normalized Message text parts
|
|
// when Content is empty, so a response that duplicates its content in both is
|
|
// measured once rather than twice.
|
|
func responseText(output *model.Response) string {
|
|
if output.Content != "" {
|
|
return output.Content
|
|
}
|
|
var b strings.Builder
|
|
for _, c := range output.Message.Content {
|
|
if c.Type == model.ContentTypeText {
|
|
b.WriteString(c.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// messageText returns the text-bearing content parts of a single message.
|
|
func messageText(msg model.Message) string {
|
|
var b strings.Builder
|
|
for _, c := range msg.Content {
|
|
if c.Type == model.ContentTypeText {
|
|
b.WriteString(c.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// KeywordGuard is a content-policy guardrail that blocks any input or output
|
|
// containing one of its configured keywords. Matching is a case-insensitive
|
|
// substring search, so each keyword is matched as a plain fragment rather than
|
|
// a regexp.
|
|
//
|
|
// NewKeywordGuard builds a KeywordGuard from a word list. With no keywords the
|
|
// guard allows everything.
|
|
type KeywordGuard struct {
|
|
keywords []string // lowercased
|
|
}
|
|
|
|
// NewKeywordGuard builds a KeywordGuard that blocks payloads containing any of
|
|
// the given keywords.
|
|
func NewKeywordGuard(keywords ...string) *KeywordGuard {
|
|
normalized := make([]string, 0, len(keywords))
|
|
for _, k := range keywords {
|
|
if k = strings.ToLower(strings.TrimSpace(k)); k != "" {
|
|
normalized = append(normalized, k)
|
|
}
|
|
}
|
|
return &KeywordGuard{keywords: normalized}
|
|
}
|
|
|
|
// CheckInput blocks the request when any keyword is present in its text.
|
|
func (g *KeywordGuard) CheckInput(ctx context.Context, input *model.Request) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if input == nil {
|
|
return nil, errors.New("guardrail: nil request")
|
|
}
|
|
return g.check(requestText(input))
|
|
}
|
|
|
|
// CheckOutput blocks the response when any keyword is present in its text.
|
|
func (g *KeywordGuard) CheckOutput(ctx context.Context, output *model.Response) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if output == nil {
|
|
return nil, errors.New("guardrail: nil response")
|
|
}
|
|
return g.check(responseText(output))
|
|
}
|
|
|
|
func (g *KeywordGuard) check(text string) (*Verdict, error) {
|
|
if len(g.keywords) == 0 {
|
|
return allow(), nil
|
|
}
|
|
lower := strings.ToLower(text)
|
|
for _, kw := range g.keywords {
|
|
if strings.Contains(lower, kw) {
|
|
return deny("content_policy_blocked: " + kw), nil
|
|
}
|
|
}
|
|
return allow(), nil
|
|
}
|
|
|
|
// SensitiveMode selects how a SensitiveGuard reacts to a match.
|
|
type SensitiveMode int
|
|
|
|
const (
|
|
// SensitiveBlock denies any payload that matches a pattern.
|
|
SensitiveBlock SensitiveMode = iota
|
|
// SensitiveMark allows the payload but flags it in Verdict.Reason.
|
|
SensitiveMark
|
|
)
|
|
|
|
// SensitivePattern pairs a compiled regular expression with a human label. The
|
|
// label is what appears in Verdict.Reason; the pattern's captured secret is
|
|
// never echoed anywhere.
|
|
type SensitivePattern struct {
|
|
Label string
|
|
Regexp *regexp.Regexp
|
|
}
|
|
|
|
// DefaultSensitivePatterns returns the built-in set of patterns used to detect
|
|
// common API keys, bearer tokens, cloud credentials, passwords and private-key
|
|
// material. Callers may extend or replace these with their own patterns.
|
|
func DefaultSensitivePatterns() []SensitivePattern {
|
|
return []SensitivePattern{
|
|
{Label: "api_key", Regexp: regexp.MustCompile(`(?i)\bapi[_-]?key\s*[:=]\s*\S+`)},
|
|
{Label: "openai_key", Regexp: regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}\b`)},
|
|
{Label: "aws_access_key", Regexp: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
|
|
{Label: "bearer_token", Regexp: regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~-]+`)},
|
|
{Label: "password", Regexp: regexp.MustCompile(`(?i)\b(?:bind_?)?password\s*[:=]\s*\S+`)},
|
|
{Label: "private_key", Regexp: regexp.MustCompile(`-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----`)},
|
|
}
|
|
}
|
|
|
|
// SensitiveGuard is a guardrail that detects sensitive information such as API
|
|
// keys, tokens and passwords using regular expressions. Matching is
|
|
// case-insensitive where the pattern says so; it never writes the matched secret
|
|
// into a Reason, log or return value.
|
|
//
|
|
// NewSensitiveGuard builds a SensitiveGuard from patterns and a mode.
|
|
type SensitiveGuard struct {
|
|
patterns []SensitivePattern
|
|
mode SensitiveMode
|
|
}
|
|
|
|
// NewSensitiveGuard builds a SensitiveGuard. In SensitiveBlock mode a match
|
|
// denies the payload; in SensitiveMark mode a match allows it but records the
|
|
// flagged label in Verdict.Reason.
|
|
func NewSensitiveGuard(patterns []SensitivePattern, mode SensitiveMode) *SensitiveGuard {
|
|
return &SensitiveGuard{patterns: patterns, mode: mode}
|
|
}
|
|
|
|
// CheckInput evaluates the request text against every pattern.
|
|
func (g *SensitiveGuard) CheckInput(ctx context.Context, input *model.Request) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if input == nil {
|
|
return nil, errors.New("guardrail: nil request")
|
|
}
|
|
return g.check(requestText(input))
|
|
}
|
|
|
|
// CheckOutput evaluates the response text against every pattern.
|
|
func (g *SensitiveGuard) CheckOutput(ctx context.Context, output *model.Response) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if output == nil {
|
|
return nil, errors.New("guardrail: nil response")
|
|
}
|
|
return g.check(responseText(output))
|
|
}
|
|
|
|
func (g *SensitiveGuard) check(text string) (*Verdict, error) {
|
|
for _, p := range g.patterns {
|
|
if p.Regexp == nil || !p.Regexp.MatchString(text) {
|
|
continue
|
|
}
|
|
if g.mode == SensitiveBlock {
|
|
return deny("sensitive_information_blocked: " + p.Label), nil
|
|
}
|
|
return &Verdict{Allow: true, Reason: "sensitive_information_detected: " + p.Label}, nil
|
|
}
|
|
return allow(), nil
|
|
}
|
|
|
|
// LengthGuard is a guardrail that blocks input or output exceeding a configured
|
|
// character limit. A limit of zero disables that side.
|
|
//
|
|
// NewLengthGuard builds a LengthGuard with per-side limits.
|
|
type LengthGuard struct {
|
|
maxInputChars int
|
|
maxOutputChars int
|
|
}
|
|
|
|
// NewLengthGuard builds a LengthGuard. Limits are measured in Unicode code
|
|
// points; a limit of zero means unlimited.
|
|
func NewLengthGuard(maxInputChars, maxOutputChars int) *LengthGuard {
|
|
return &LengthGuard{maxInputChars: maxInputChars, maxOutputChars: maxOutputChars}
|
|
}
|
|
|
|
// CheckInput blocks the request when its text exceeds the input character limit.
|
|
func (g *LengthGuard) CheckInput(ctx context.Context, input *model.Request) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if input == nil {
|
|
return nil, errors.New("guardrail: nil request")
|
|
}
|
|
if g.maxInputChars <= 0 {
|
|
return allow(), nil
|
|
}
|
|
if n := utf8.RuneCountInString(requestText(input)); n > g.maxInputChars {
|
|
return deny("input_length_exceeded"), nil
|
|
}
|
|
return allow(), nil
|
|
}
|
|
|
|
// CheckOutput blocks the response when its text exceeds the output limit.
|
|
func (g *LengthGuard) CheckOutput(ctx context.Context, output *model.Response) (*Verdict, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if output == nil {
|
|
return nil, errors.New("guardrail: nil response")
|
|
}
|
|
if g.maxOutputChars <= 0 {
|
|
return allow(), nil
|
|
}
|
|
if n := utf8.RuneCountInString(responseText(output)); n > g.maxOutputChars {
|
|
return deny("output_length_exceeded"), nil
|
|
}
|
|
return allow(), nil
|
|
}
|