Policy Rules
Define governance rules in YAML with enforcement levels, severity, scopes, and time windows.
Rule Format
Rules are defined in YAML (or JSON) and loaded from .usertrust/policies/default.yml by default.
The file is read when the controller is built (trust() / createGovernor()). Its path is the policies key of usertrust.config.json (default ./policies/default.yml, relative to the .usertrust/ vault); a .yml or .yaml extension parses as YAML, anything else as JSON. The document is either a bare list of rules or an object with a single rules key. No file means built-in defaults only. A file that is present but unreadable, unparseable, or contains any invalid rule throws PolicyLoadError and the controller does not start; no partial rule set is ever loaded. Check a file ahead of time with usertrust policy validate [path] [--json].
# Requires operator scope on the controller/config, e.g. scope: ["production/api"]
- name: block-expensive-models
effect: deny
enforcement: hard
severity: high
priority: 50
conditions:
- field: model
operator: in
value:
- claude-opus-4-6
- gpt-5.4
scopePatterns:
- "production/*"
timeWindows:
- daysOfWeek: [1, 2, 3, 4, 5]
startHour: 9
endHour: 17Rule Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| id | string | no | — | Unique rule identifier; when set, the denial reason is labelled [id] instead of [name] |
| name | string | yes | — | Rule name (non-empty) |
| description | string | no | — | Human-readable rationale, appended to the denial reason |
| effect | "deny" or "warn" | yes | — | What happens when matched |
| enforcement | "hard" or "soft" | yes | — | Hard = block, Soft = allow with warnings |
| severity | "critical" | "high" | "medium" | "low" | "info" | no | — | Severity level |
| priority | number | no | 100 | Lower = higher priority |
| enabled | boolean | no | true | Toggle rule on/off |
| conditions | FieldCondition[] | yes | — | Conditions to match (ALL must match; on a hard rule an unevaluable condition counts as matched, see Enforcement) |
| scopePatterns | string[] | no | — | Minimatch glob patterns |
| timeWindows | TimeWindow[] | no | — | Time-based restrictions |
A rule may carry only the keys above, and a condition only field, operator, and value. An unknown key (for example scopePattern, singular) is a load error rather than something the loader drops, and one invalid rule fails the whole file.
Enforcement
- Hard — Returns
decision: "deny". The call (LLM or governed action) is blocked: the SDK throwsPolicyDeniedErrorwhosereasonis the matched rules' reasons joined with"; ". - Soft — Returns
decision: "allow"withhasWarnings: trueand the rule listed insoftViolations. The call proceeds. Nothing in the SDK readssoftViolations,hasWarnings, or the[WARN]reasons after evaluation: a soft match is not logged, not written to the audit chain, and not carried on the receipt. A soft rule is observational only; use a hard rule when the match must have an effect.
Hard rules fail closed. A condition whose field is absent from the context, or is the wrong type for gt, gte, lt, lte, contains or regex (eq, neq, in and not_in compare strictly, so a value of another type is a determinate comparison: false for eq and in, true for neq and not_in), cannot be evaluated; on a hard rule it counts as satisfied and the rule fires, on a soft rule it counts as unmet. governAction() calls carry no model field, so the hard model in [...] rule above denies every governed action inside its scope; lead such a rule with { field: model, operator: exists } when it must apply only to LLM calls. Every hard denial throws PolicyDeniedError.
Scope Matching
Uses minimatch glob patterns against the operator-declared scope on the controller or usertrust.config.json — not request content, and not params.params.scope.
scopePatterns:
- "production/**" # matches production/api, production/chat/v2
- "staging/*" # matches staging/test but not staging/a/bIf the operator sets no scope, a rule with scopePatterns never matches. It does not "narrow a live rule". An absent scopePatterns still matches all calls.
Time Windows
Restrict when a rule is active:
timeWindows:
- daysOfWeek: [1, 2, 3, 4, 5] # Mon-Fri (0=Sun, 6=Sat)
startHour: 9 # 9 AM
endHour: 17 # 5 PMHours are read from the process's local clock, not UTC. startHour is inclusive and endHour exclusive, so 9 to 17 covers 09:00 through 16:59; either bound may be omitted. A window whose startHour is greater than its endHour (for example 22 to 6) matches on both sides of midnight, with daysOfWeek checked against the timestamp's own day. Valid values: daysOfWeek integers 0–6, startHour 0–23, endHour 0–24. A rule with several windows is active when any one matches.
Default Rules
Three rules ship by default:
- block-budget-overshoot — Deny when
budget_remaining_afterlt0 (hard) - block-budget-exhausted — Deny when
budget_remaininglte0 (hard) - warn-high-cost — Warn when
estimated_costgt1000 (soft)
These three are built into the SDK and are always enforced: rules loaded from the policy file are appended after them, never merged over them. A file rule that reuses a default's id, or sets enabled: false on it, adds a second rule and leaves the built-in active. usertrust init also writes the same three rules into policies/default.yml; deleting them there removes the copies, not the built-ins.
PolicyResult
interface PolicyResult {
decision: "allow" | "deny";
hasWarnings: boolean;
matched: RuleMatch[];
hardViolations: RuleMatch[];
softViolations: RuleMatch[];
reasons: string[];
evaluatedAt: string;
}RuleMatch
interface RuleMatch {
id?: string; // omitted when the rule has no id
name: string;
effect: "deny" | "warn";
enforcement: "hard" | "soft";
severity: "critical" | "high" | "medium" | "low" | "info" | undefined;
fields: string[]; // field names of the matched rule's conditions
}evaluatedAt is the context timestamp when one was supplied, otherwise the evaluation time as an ISO string. Neither PolicyResult nor RuleMatch is exported from the usertrust package entry points; they describe the result the controller evaluates internally.