Guardrails
A guardrail is a rule set checked at a checkpoint: before a model call, after a reply, before an intent runs as a tool,
before an embedding call, before a target runs a command, and when a change is found. The
check runs in your process with no model call. A refused operation does not run, and the result names the rule that
refused it. Rule sets come from rule files, which the IntentSharp.Guardrails package reads, or from code, which needs
only the core. A rule set is not the rules of an intent file, which check one intent's answer, nor an
[AIConstraint], which sets one request's limits. Rules written in words, for a model to compile, are on
Compiled rules.
Rule files
A manifest, intentsharp.guardrails.json in a document store, lists the rule files in order:
{
"intentsharp": "guardrails/1",
"documents": ["rules/requests.rules.yaml", "rules/replies.rules.yaml", "rules/orders.rules.yaml"]
}
A rule file, .rules.yaml, .rules.yml or .rules.json, starts with intentsharp: rules/1 and holds one set: its
set name, its checkpoint, its rules and, optionally, a label, a target, settings, intent and compile. A
rule has an id, a subject, one kind with its argument, and optionally a message and enforce. This set checks
every model call:
intentsharp: rules/1
set: requests
checkpoint: request
rules:
- id: known-providers
subject: request.provider
allow: [main, backup]
message: Call only the providers this application names.
- id: input-limit
subject: request.inputChars
range: { max: 20000 }
message: The input is too long for this application.
- id: long-input
subject: request.inputChars
range: { max: 4000 }
enforce: false
message: The input is longer than this application usually sends.
Give the client the store and the sets every call is checked against:
// "policy" holds the provider file, the manifest intentsharp.guardrails.json and the rule files it lists.
using var store = new FolderDocumentStore("policy");
using var client = new IntentClientBuilder()
.UseProviderOptions(store)
.WithDocumentStore(store)
.WithGuardrailFiles(store, "requests") // the sets every call is checked against
.Build();
Under dependency injection:
var store = new FolderDocumentStore("policy");
services.AddDocumentStore(store);
services.AddGuardrails(store, "requests");
services.AddIntentSharp(configuration);
| When | What happens |
|---|---|
| A rule file is saved | The edit applies to the next check. |
| A rule file does not load, or vanishes | Its last good version stays in force, and the problem names the file and the line. |
| There is no manifest | No set loads, and a client that names a set is not built. |
| A check names a set that is not loaded, or one that two listed files define | The check is refused. |
Every decision names the set, its document, the document's version and its hash, so a log line shows which rules decided. One operation never mixes two revisions.
Pin a file with GuardrailSourceOptions.ExpectedHashes: when its running version has another hash, loading throws
GuardrailLoadException, and every check that names a set is refused until the file is reverted or the pin is changed.
Kinds
| Kind | Argument | Passes when |
|---|---|---|
allow |
a list | The value, or each item of a list value, is in the list. "*" allows any value; "@described" allows every entity the target describes. |
deny |
a list | No value or item is in the list. A deny always wins over an allow. |
pattern |
a .NET regular expression | The text, or each item, matches it anywhere. A match that runs past rules.patternTimeout, 250 ms unless a setting says otherwise, is a violation. |
range |
{ min, max } |
The number is between the two, inclusive. |
count |
{ min, max } |
The list holds that many items, or the number is in that range. |
schema |
a JSON Schema | The value matches the schema. This kind comes with the IntentSharp.Guardrails package. |
allow and deny compare text ignoring case.
A rule's message is what the model is told when its answer or its command breaks the rule, and what the result
records. A rule with no message sends its id, so choose an id you would show; the model is never sent a rule's
pattern or values. A kind of your own implements IGuardrailKind under a name with a dot in it, and gives the same
verdict for the same value without calling a model.
Checkpoints and subjects
| Checkpoint | Checked | Subjects |
|---|---|---|
request |
before each model call, the judge's included | request.provider, request.host, request.model, request.purpose, request.intent, request.inputChars, request.maxOutputTokens |
reply |
after a reply passed its checks, before it is returned | reply.text, reply.value, reply.strings |
tool |
before an intent runs as a tool | tool.intent, tool.tags |
embedding |
before an embedding call | embedding.provider |
data |
before a target runs a command | data.entities, data.operations, data.stage, data.results, data.timeout, and the target's own |
environment |
when a change is found | environment.kind, environment.source, environment.pendingRecompile |
| Subject | What to know |
|---|---|
data.entities, data.operations |
Required: a check passes only when an enforced allow rule allows each. |
| Any other subject | Not checked when no rule names it, unless a target declares it required. |
request.provider, request.host, request.model, data.entities, data.operations |
The core's security subjects: once a rule names one, a missing value is refused, and a rule on one cannot be detect-only. |
request.host |
The host a provider sends its calls to (Provider file). |
request.inputChars |
The length of the input, never its content: filter what you send before the call. |
reply.strings |
Every string of the answer, but no property names. |
reply.text |
The reply as the model wrote it, escapes included. Write a content rule on both, and check keys in a validator of your own. |
tool.tags |
An allow or deny passes an intent with no tags. Add count: { min: 1 } to require one. |
Enforced and detect-only
Every rule refuses what it fails, unless it has enforce: false. A detect-only rule refuses nothing: it is logged at
Warning and listed in the decision's Detections, as the rule long-input above is. A detect-only rule on one of
the core's security subjects does not load.
Sets for one call
A call can name sets of its own, beside the client's. This set checks the answers of the calls that name it:
intentsharp: rules/1
set: replies
checkpoint: reply
rules:
- id: plain-text
subject: reply.strings
pattern: '^[^<>]*$'
message: Write plain text, with no markup.
- id: triage-shape
subject: reply.value
schema:
type: object
required: [category, summary]
properties:
category: { enum: [billing, shipping, other] }
summary: { type: string, maxLength: 200 }
message: Answer with a category of billing, shipping or other, and a summary of at most 200 characters.
// The set replies applies to this call only, after the client's own sets.
var options = new AIRequestOptions { GuardrailSets = { "replies" } };
AIResult<Triage> triage = await email.AIAsync<Triage>(client, "Sort this support email and summarize it.", options);
// A reply a rule refuses fails its attempt; the next attempt is told the rule's message.
foreach (var attempt in triage.Attempts)
foreach (var issue in attempt.Issues.Where(i => i.Source == IssueSource.Guardrail))
Console.WriteLine($"Attempt {attempt.Number}: {issue.Message}");
// A detect-only rule refuses nothing; what it found is in the decision.
if (triage.Guardrails is { } decision)
foreach (var detection in decision.Detections)
Console.WriteLine($"Detected: {detection.Set}/{detection.RuleId}");
On scripted replies, a reply with markup, a call to a provider the set requests does not name, and a long input
print:
A reply with markup, under the sets requests and replies:
[Guardrail] attempt 1: Write plain text, with no markup.
Success: true, after 2 attempts
Value: billing: Customer was charged twice for order 1182.
A call to the provider other, under the set requests:
Success: false
[Guardrail] attempt 1: The answer call was refused at the Request checkpoint: Call only the providers this application names (rule known-providers in guardrail set "requests").
An input of 4800 characters, under the set requests:
Success: true
Detected: requests/long-input: The input is longer than this application usually sends.
A reply a rule refuses is not kept: the result, its attempts and the memory hold a marker in its place, and the next
attempt is sent the rule's message without it. Every other reply is kept as the model wrote it, in RawOutput and in
each attempt's RawOutput, so treat both as untrusted text.
Edits that widen
An edit that widens is applied by default and flagged: EditImpact.WidensSecurity is true, and WidenedSubjects
names what it widens. That holds for an edit a person submits through the client, a model's edit and a companion the
compile writes (Compiled rules).
| An edit that | Flagged |
|---|---|
| Allows more than the running version allows, or removes a security limit | Yes |
| Moves a file's rules to another set or target | Yes |
Raises a time or spend limit, such as rules.patternTimeout or client.maxModelCalls, or makes one unlimited |
Yes |
| Only narrows | No |
To review such edits before they run, wrap the writer that applies them. This one stores a flagged edit as a version that does not run:
/// <summary>Holds an edit that widens security for a person to review; every other edit goes to the inner writer.</summary>
public sealed class HoldWideningEdits(IEditWriter inner, IDocumentStore store) : IEditWriter
{
public async ValueTask<EditOutcome> SubmitAsync(ProposedEdit edit, CancellationToken ct = default)
{
if (edit.Kind != EditKind.Document || edit.Document is not { } document || !edit.Impact.WidensSecurity)
return await inner.SubmitAsync(edit, ct);
// Stored, not running: the version before it keeps running until someone activates this one.
var version = await store.SaveAsync(new DocumentSave
{
Id = document,
Text = edit.Content,
Author = edit.Author,
Reason = edit.Reason,
BasedOn = edit.BasedOn,
Activate = false,
}, ct);
return new EditOutcome { Status = EditStatus.SavedNotRunning, Version = version, Impact = edit.Impact, Message = "Held for review." };
}
}
var client = new IntentClientBuilder()
.UseProviderOptions(store)
.WithDocumentStore(store)
.WithGuardrailFiles(store, "orders-data")
.WithEditWriter(inner => new HoldWideningEdits(inner, store))
.Build();
Under dependency injection, wrap it with DecorateEditWriter. IntentSharp ships no review queue: a held edit is a
stored version, and it runs when you activate it. An edit that adds "@described" to orders-data, the rule file
Compiled rules shows, held and then applied by the default writer:
A read of Customers under orders-data:
Allowed: false
- orders-data: data.entities is not allowed by any rule
An edit that adds allow ["@described"] on data.entities, with HoldWideningEdits:
Status: SavedNotRunning
WidensSecurity: true (data.entities)
The same read after the edit was held:
Allowed: false
- orders-data: data.entities is not allowed by any rule
The same edit with the default writer:
Status: Applied
WidensSecurity: true (data.entities)
A read of Customers after it applied:
Allowed: true
A read of PaymentCards after it applied:
Allowed: false
- orders-data/no-cards: Payment cards are not available here.
An edit made to a file in the folder itself is checked, and does not run if it does not load, but it goes to no writer. Whoever can write the folder can change any rule file, so let only the people who may change the rules write it.
Rule sets written in code
InMemoryGuardrailSource, in the core, serves sets you build in code, with no rule file and no store:
var orders = new GuardrailSet
{
Name = "orders-data",
Checkpoint = GuardrailCheckpoint.Data,
Target = "sqlite",
Rules =
[
new GuardrailRule { Id = "customers-only", Subject = new SubjectName("data.entities"), Kind = "allow", Settings = new JsonArray("Customers"), Message = "Use only the Customers table." },
new GuardrailRule { Id = "read-only", Subject = new SubjectName("data.operations"), Kind = "allow", Settings = new JsonArray("read"), Message = "Only read." },
],
};
// builder: an IntentClientBuilder with your providers. No rule file, no store, no IntentSharp.Guardrails package.
var client = builder
.WithGuardrails(new InMemoryGuardrailSource(orders), "orders-data")
.Build();
They are checked as file sets are and fail closed the same way. They never change: to change the rules, build a new
client, or implement IGuardrailSource to serve them from a store of your own.
Check rule files in a test
GuardrailFiles reads rule files from text, the way the client does:
// In a test: every rule file loads, or ParseOrThrow throws GuardrailLoadException with each problem and its line.
var documents = Directory.EnumerateFiles(Path.Combine(folder, "rules"), "*.rules.yaml")
.Select(path => KeyValuePair.Create("rules/" + Path.GetFileName(path), File.ReadAllText(path)))
.ToList();
GuardrailFileSet rules = GuardrailFiles.ParseOrThrow(documents);
Limits
- A check is a guard, not a permission boundary: it checks what an operation may do, and does not make it harmless. Keep the accounts and permissions of your providers and databases as narrow as the application needs.
- The model calls of a judge of your own, and of a provider a memory holds directly, are not checked. Nor are the
calls of an
IIntentInvokerof your own: itsCheckToolAsyncdecides only which tools are listed, and itsRunAsyncmust apply the same decision itself. - The folder store's lock covers one host. Applications on several hosts that share rule files need a store of their own that shares a lock.