nexusguard

package module
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package nexusguard puts a shield engine behind nexus guards, so the prompts, tool results and model replies that pass through a nexus gateway are scanned by shield.

Register an input guard and an output guard on the gateway's guard service:

gw.Guard().Register(nexusguard.Input(eng, nexusguard.WithProfile(presets.InfraAgentInput), nexusguard.WithApp("loomplane")))
gw.Guard().Register(nexusguard.Output(eng, nexusguard.WithProfile(presets.InfraAgentOutput), nexusguard.WithApp("loomplane")))

Both guards are named "shield". Each text of a scanned message is one shield scan, stored like any other.

The input guard scans the messages of the roles WithRoles lists ("user" and "tool" by default) plus those WithUntrustedRoles lists ("tool" by default). It never scans "system". A message with an empty role is scanned as "user". A message whose role neither option names passes unscanned, so list every role that carries text the model reads, such as the legacy "function" role. A message of an untrusted role is scanned with scan.Input.Untrusted set and Source "<role>_message", and always comes back as shield's quoted text, so the model sees it as data. Other roles change only when the scan redacts. The output guard scans every reply message, whatever role the provider gives it, with Engine.ScanOutput and replaces its text when the scan redacts.

A scan that blocks makes the guard block with shield's fixed reason, which never quotes the content. A flag passes with Action warn. The result's Details["scan_ids"] lists the stored scans that did not allow their text, so a host can look them up. Assistant tool calls are not scanned here: hosts check them with Engine.ScanToolCall.

Content

The guards read string content, []provider.ContentPart and the []any of JSON parts a decoded HTTP body holds. They know these part types:

  • text parts "text", "refusal" and "thinking", whose text sits under the key of the same name or under "content", both scanned, and must be a string;
  • "tool_result", whose "content" is a string or parts, nested at most four deep, and whose "text", if it has one, is scanned too;
  • media parts "image_url", "image", "image_base64", "input_audio", "audio", "file", "redacted_thinking" and "tool_use", which pass unchanged and may hold no "text" or "content" key. Text inside media, such as the words in an image or a PDF, is not scanned.

Any other part type, a text part whose text is not a string, a tool_result whose "text" is not a string, and any other content shape fail the request in a scanned role, so text the guard cannot read never reaches the model unscanned. Each text is scanned on its own and its replacement is written back into the part it came from, so the layout of text and media parts is kept.

History

nexus runs the input guard on the whole conversation of every request. Each guard keeps the verdicts of the texts it scanned (see WithVerdictCache), so only new or changed messages are scanned and stored, and the model judge runs only for them. A block is never cached. Keep your own unguarded history: the guardrail middleware writes the guarded messages back into the request. If you send the guarded copy back anyway, the guard strips its own quoting first and quotes each text once.

Tenants

Every scan needs a shield tenant. By default the guards use the tenant already on the context (shield.WithTenant), which wins over the nexus tenant of the request (CheckInput.TenantID), and fall back to the nexus tenant. WithTenant maps the nexus tenant instead. No tenant from either source is shield.ErrTenantRequired, and the request fails.

nexus versions

The module requires nexus v1.9.0 or later. CheckInput.Metadata holds a copy of the request's metadata in both phases, and the guards copy it onto each stored scan, leaving out the keys shield owns ("source", "untrusted", "secret_count"). The guards never write to it. Output guards run on streamed replies as well as non-streamed ones.

With nexus v1.9.0, output guards buffer every stream by default. Nexus holds the chunks until the reply ends, checks the whole message, then plays it back, so the first token arrives late. That is the price of redaction: text already sent can't be redacted. Passing nexus.WithStreamGuardStrategy(guard.StrategyPassthrough) streams at once and checks at the end. A block still stops tool calls, but a redaction comes too late and is lost.

Model judge

NewJudge and GatewayJudge build a judgment.Judge that asks a model, through nexus, how risky a text is. Register it with engine.WithJudge and turn it on with shield.Config.ModelJudge. The engine replaces every secret and personal data value that awareness or shield's built-in detectors find with a placeholder before the judge sees the text, whatever the profile's awareness settings. The judge's completion carries a context marker that makes both guards pass it unscanned, so judging a scan never starts another. The marker is a private key type: callers cannot set it. WithJudgeProvider pins the judge's calls to one provider instead of the gateway's routing strategy.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GatewayJudge

func GatewayJudge(gw func() *nexus.Gateway, model string, opts ...JudgeOption) judgment.Judge

GatewayJudge is NewJudge over the gateway gw returns, resolved on every call, so the judge can be built before the gateway exists. A nil gateway, or one not yet initialized, is an error.

func Input

func Input(eng *engine.Engine, opts ...Option) guard.Guard

Input returns a nexus guard for the input phase. It scans the messages of the configured roles with Engine.ScanInput (see the package doc).

func NewJudge

func NewJudge(complete CompleteFunc, model string, opts ...JudgeOption) judgment.Judge

NewJudge returns a judgment.Judge that asks model, through complete, for a risk score. It sends a fixed system prompt and the text quoted with scan.Quote("judged_content", ...), asks for at most 200 tokens, and treats any reply that is not one {"score": <0..1>, "reason": "..."} object as an error (other fields are ignored). The text is the one the engine hands the judge, with every secret and personal data value the built-in detectors find already replaced by a placeholder.

The context it passes to complete makes both guards of this package pass the completion unscanned, so a judge behind a guarded gateway does not scan its own call.

func Output

func Output(eng *engine.Engine, opts ...Option) guard.Guard

Output returns a nexus guard for the output phase. It scans every reply message with Engine.ScanOutput and replaces its text when the scan redacts.

Types

type CompleteFunc

CompleteFunc sends one completion request, typically through a nexus gateway.

type JudgeOption

type JudgeOption func(*judgeOptions)

JudgeOption configures a judge built by NewJudge or GatewayJudge.

func WithJudgeProvider

func WithJudgeProvider(name string) JudgeOption

WithJudgeProvider sends every judge request to the named nexus provider (CompletionRequest.Provider), so the judge's calls are not routed by the gateway's strategy. Empty, the default, leaves routing to the gateway.

type Option

type Option func(*options)

Option configures a guard built by Input or Output.

func WithApp

func WithApp(appID string) Option

WithApp scans under appID. Empty, the default, keeps the app already on the context (shield.WithApp).

func WithProfile

func WithProfile(name string) Option

WithProfile names the shield safety profile each scan uses. Empty, the default, leaves it to shield.Config.DefaultProfile.

func WithRoles

func WithRoles(roles ...string) Option

WithRoles sets the roles whose messages the input guard scans, replacing the default "user" and "tool". Roles match without regard to case, and "system" is never scanned. Untrusted roles are scanned whatever this lists. A message with an empty role is scanned as "user". A message whose role neither this nor WithUntrustedRoles names passes unscanned, so name every role that carries text the model reads, such as the legacy "function" role for tool results. The output guard ignores it.

func WithTenant

func WithTenant(resolve func(ctx context.Context, nexusTenant string) string) Option

WithTenant maps the nexus tenant of a request (CheckInput.TenantID) to the shield tenant its scans run under. By default the guards use the shield tenant on the context and fall back to the nexus tenant, so a shield tenant on the context wins over a different nexus tenant: never leave a stale one on a long-lived context. A mapper that returns "" fails the check with shield.ErrTenantRequired.

func WithUntrustedRoles

func WithUntrustedRoles(roles ...string) Option

WithUntrustedRoles sets the roles whose messages the input guard scans as untrusted content (scan.Input.Untrusted, Source "<role>_message"), replacing the default "tool". Their messages always come back quoted. None at all leaves every role trusted.

func WithVerdictCache

func WithVerdictCache(n int) Option

WithVerdictCache keeps the verdicts of up to n texts (10,000 by default), so a conversation's history is not scanned again on every turn: a text whose verdict is cached makes no engine call and stores no new scan, and only new or changed messages are scanned. 0 turns the cache off.

A verdict is keyed by tenant, app, profile, role, whether the role is untrusted, the engine's ProfileGeneration and the SHA-256 of the text, and expires after the engine's ProfileCacheTTL (a negative TTL turns the cache off too). A block is never cached, so a profile change that lifts it takes effect on the next request. The cache also holds at most 64 MiB of replacement text. A cached verdict does not record the request metadata of later requests, since no scan is stored for them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL