167 lines
6.4 KiB
Go
167 lines
6.4 KiB
Go
// Package guardrail provides input/output validation and safety interception
|
|
// for the Cyrene agent runtime.
|
|
//
|
|
// It implements the Guardrail design in DevDocs/design/04-运行时.md §5: one or
|
|
// more checks run in parallel with agent execution and fail fast on the first
|
|
// denial. A guardrail evaluates a model.Request (before it reaches the LLM) or
|
|
// a model.Response (before it is returned to the user) and returns a Verdict
|
|
// that either allows the payload or blocks it with a human-readable Reason.
|
|
//
|
|
// The package ships three built-in, configurable checks:
|
|
//
|
|
// - KeywordGuard: content-policy keyword/category matching.
|
|
// - SensitiveGuard: regex detection of API keys, tokens, passwords and other
|
|
// secrets, in either block or mark mode. It never echoes the matched secret
|
|
// into a Reason, log or return value.
|
|
// - LengthGuard: input/output character-length limits.
|
|
//
|
|
// Checks are composed into a Pipeline (see New). All errors surfaced by a
|
|
// Pipeline wrap the sentinel ErrGuardrail, so callers can test with errors.Is.
|
|
//
|
|
// Guardrail is a leaf package: it depends only on the standard library and
|
|
// internal/runtime/model types, and it never imports the agent loop or any other
|
|
// runtime package, so it can sit alongside the loop without an import cycle.
|
|
package guardrail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// Verdict is the outcome of a single guardrail evaluation.
|
|
//
|
|
// When Allow is true the payload is accepted; when it is false the payload is
|
|
// blocked and Reason explains why. Reason is also set on an allow verdict to
|
|
// flag context (for example a sensitive-information mark) without carrying the
|
|
// offending secret.
|
|
type Verdict struct {
|
|
Allow bool
|
|
Reason string
|
|
}
|
|
|
|
// Guardrail evaluates a request or a response and returns a Verdict.
|
|
//
|
|
// Implementations must be safe for concurrent use and honor ctx cancellation:
|
|
// a canceled context returns immediately with a non-nil error. A nil Verdict
|
|
// on a nil error is treated as allow by the Pipeline.
|
|
type Guardrail interface {
|
|
// CheckInput evaluates a completion request before it reaches the LLM.
|
|
CheckInput(ctx context.Context, input *model.Request) (*Verdict, error)
|
|
// CheckOutput evaluates a completion response before it is returned.
|
|
CheckOutput(ctx context.Context, output *model.Response) (*Verdict, error)
|
|
}
|
|
|
|
// ErrGuardrail is the sentinel wrapped around any error returned by a
|
|
// Pipeline's CheckInput or CheckOutput. Use errors.Is to detect it.
|
|
var ErrGuardrail = errors.New("guardrail: evaluation failed")
|
|
|
|
// wrapErr attaches the ErrGuardrail sentinel to a guard's internal error so the
|
|
// caller can tell a guardrail failure apart from any other runtime error.
|
|
func wrapErr(err error) error {
|
|
return fmt.Errorf("%w: %w", ErrGuardrail, err)
|
|
}
|
|
|
|
// Pipeline runs a sequence of Guardrails in the order they were registered and
|
|
// fails fast: the first deny verdict short-circuits the rest and is returned
|
|
// as-is. Because the sentinel is non-fatal, allow verdicts let the remaining
|
|
// guards run, and the reason of the last flagged verdict is preserved.
|
|
type Pipeline struct {
|
|
guards []Guardrail
|
|
}
|
|
|
|
// New builds a Pipeline from the given options. Options are applied in order,
|
|
// so the guards run in the same order they are passed. With no options the
|
|
// pipeline allows every payload.
|
|
func New(opts ...Option) *Pipeline {
|
|
p := &Pipeline{}
|
|
for _, o := range opts {
|
|
o(p)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Option configures a Pipeline. Options are applied in the order passed to New.
|
|
type Option func(*Pipeline)
|
|
|
|
// WithGuard appends a custom Guardrail to the pipeline.
|
|
func WithGuard(g Guardrail) Option {
|
|
return func(p *Pipeline) { p.guards = append(p.guards, g) }
|
|
}
|
|
|
|
// WithKeywords appends a KeywordGuard that blocks any payload containing one of
|
|
// the given keywords (case-insensitive substring match).
|
|
func WithKeywords(keywords ...string) Option {
|
|
return func(p *Pipeline) { p.guards = append(p.guards, NewKeywordGuard(keywords...)) }
|
|
}
|
|
|
|
// WithSensitiveInfo appends a SensitiveGuard using the built-in default patterns
|
|
// in the given mode. Use WithSensitivePatterns to supply your own patterns.
|
|
func WithSensitiveInfo(mode SensitiveMode) Option {
|
|
return WithSensitivePatterns(mode, DefaultSensitivePatterns()...)
|
|
}
|
|
|
|
// WithSensitivePatterns appends a SensitiveGuard that matches the given patterns
|
|
// in the given mode.
|
|
func WithSensitivePatterns(mode SensitiveMode, patterns ...SensitivePattern) Option {
|
|
return func(p *Pipeline) { p.guards = append(p.guards, NewSensitiveGuard(patterns, mode)) }
|
|
}
|
|
|
|
// WithLengthLimits appends a LengthGuard capping input and output in characters.
|
|
// A zero limit disables that side.
|
|
func WithLengthLimits(maxInputChars, maxOutputChars int) Option {
|
|
return func(p *Pipeline) { p.guards = append(p.guards, NewLengthGuard(maxInputChars, maxOutputChars)) }
|
|
}
|
|
|
|
// CheckInput runs every registered guard against the request in order and
|
|
// returns the first deny verdict. An internal guard error is wrapped with
|
|
// ErrGuardrail.
|
|
func (p *Pipeline) CheckInput(ctx context.Context, input *model.Request) (*Verdict, error) {
|
|
return p.run(ctx, input)
|
|
}
|
|
|
|
// CheckOutput runs every registered guard against the response in order and
|
|
// returns the first deny verdict. An internal guard error is wrapped with
|
|
// ErrGuardrail.
|
|
func (p *Pipeline) CheckOutput(ctx context.Context, output *model.Response) (*Verdict, error) {
|
|
return p.run(ctx, output)
|
|
}
|
|
|
|
// run is the shared fail-fast loop. eval dispatches the payload to the guard
|
|
// and returns a verdict, an error, or both (an error wins). The first deny
|
|
// verdict stops the loop, while a flagged-allow verdict is remembered as the
|
|
// final reason.
|
|
func (p *Pipeline) run(ctx context.Context, payload any) (*Verdict, error) {
|
|
var reason string
|
|
for _, g := range p.guards {
|
|
v, err := p.eval(ctx, g, payload)
|
|
if err != nil {
|
|
return nil, wrapErr(err)
|
|
}
|
|
if v == nil {
|
|
continue
|
|
}
|
|
if !v.Allow {
|
|
return v, nil // fail-fast: first denial
|
|
}
|
|
if v.Reason != "" {
|
|
reason = v.Reason
|
|
}
|
|
}
|
|
return &Verdict{Allow: true, Reason: reason}, nil
|
|
}
|
|
|
|
// eval dispatches a payload to the matching guard method.
|
|
func (p *Pipeline) eval(ctx context.Context, g Guardrail, payload any) (*Verdict, error) {
|
|
switch t := payload.(type) {
|
|
case *model.Request:
|
|
return g.CheckInput(ctx, t)
|
|
case *model.Response:
|
|
return g.CheckOutput(ctx, t)
|
|
default:
|
|
return nil, fmt.Errorf("guardrail: unsupported payload %T", payload)
|
|
}
|
|
}
|