feat(guardrail): 输入/输出护栏(关键词/敏感信息/长度,pipeline fail-fast)
This commit is contained in:
1 parent
e33b005612
commit
02aae2bbb4
3 files changed
+863
No files matched your search
@@ -0,0 +1,166 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user