Files
Cyrene_v2/internal/runtime/guardrail/guardrail.go
T

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)
}
}