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