Documentation
Table of Contents

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 IIntentInvoker of your own: its CheckToolAsync decides only which tools are listed, and its RunAsync must 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.