Files

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
}