usertrust

Policy Engine

Rule-based governance with 12 field operators, scope globs, and time windows.

The policy engine evaluates every governed LLM call against a set of rules before the call is forwarded to the provider. Rules are defined in YAML or JSON and support field matching, scope globs, time windows, and priority ordering.

Enforcement Levels

Each rule specifies an enforcement level that determines what happens when the rule matches:

EnforcementEffect
hardDeny the request. Throws PolicyDeniedError.
softAllow the request. A soft-only match is invisible to the caller: the receipt carries no rule or reason, the llm_call audit event records nothing about it, and every call site reads the evaluation result only inside its decision === "deny" branch. hasWarnings and softViolations live only on the internal PolicyResult, which is not exported. A soft rule's [WARN] reason surfaces in exactly one place: the PolicyDeniedError message, when a hard rule denies the same call.

Enforcement also decides what happens when a condition cannot be evaluated — the field is absent from the context, or has the wrong type for a numeric or string operator (every operator except exists / not_exists reports an absent field as indeterminate; eq, neq, in and not_in compare strictly, so a value of another type is a determinate comparison, false for eq and in and true for neq and not_in). A hard rule treats an indeterminate condition as satisfied and still fires (fail closed); a soft rule treats it as unmet and stays silent. So a hard rule on model denies a governed action call, which carries no model; lead such a rule with an exists condition on the same field when it must apply only where the field is populated.

PolicyResult

Every policy evaluation returns a structured result. This type and evaluatePolicy() are internal to the package — neither is exported from usertrust or usertrust/headless — so a governed call observes a policy decision in-band only as a PolicyDeniedError, whose reason joins one entry per matched violation with ; : the rule's [id] (or [name]) label followed by its description (or, for a rule that has an id but no description, its name), with soft matches prefixed [WARN]. Only the denial's audit event is filtered to the hard rules:

interface PolicyResult {
  decision: "allow" | "deny";
  hasWarnings: boolean;
  matched: RuleMatch[];
  hardViolations: RuleMatch[];
  softViolations: RuleMatch[];
  reasons: string[];
  evaluatedAt: string;
}

Rule Structure

A policy rule specifies conditions, scope, enforcement, and optional time windows:

# usertrust.config.json — operator-declared host scope (required for the rule below)
# { "budget": 100000, "scope": ["production/api"] }

# .usertrust/policies/default.yml
rules:
  - name: block-expensive-models
    description: Deny requests to opus-class models in production
    effect: deny
    enforcement: hard
    priority: 10
    scopePatterns: ["production/*"]
    conditions:
      - field: model
        operator: contains
        value: "opus"
    timeWindows:
      - daysOfWeek: [1, 2, 3, 4, 5]  # Mon-Fri
        startHour: 9
        endHour: 17

Operators

The policy engine supports 12 field operators for matching conditions. Each operator compares a field value (resolved via dot-notation) against the rule's expected value.

OperatorDescription
existsField is present
not_existsField is absent
eqEqual (strict)
neqNot equal
gtGreater than
gteGreater than or equal
ltLess than
lteLess than or equal
inValue is in array
not_inValue is not in array
containsString contains substring
regexMatches regular expression

See Policy Operators for detailed usage and examples of each operator.

Scope Matching

scopePatterns match the operator-declared scope on the controller or config — never request content. Set it the same way you set budget or parentUserId:

await trust(client, { scope: ["production/api"] });
// or in usertrust.config.json: { "scope": ["production/api"] }

The patterns themselves are minimatch globs:

scopePatterns: ["production/*"]          # all production sub-scopes
scopePatterns: ["development/**"]        # development and all nested scopes
scopePatterns: ["*/billing"]             # billing scope in any environment

If the operator sets no scope, a rule with scopePatterns never matches — adding a pattern does not "narrow a live rule"; it stays inert until the host declares a matching scope. A request body / params.params.scope cannot volunteer one.

If a rule has no scopePatterns field, it matches all calls.

Time Windows

Rules can be restricted to specific days and hours:

timeWindows:
  - daysOfWeek: [1, 2, 3, 4, 5]    # 0=Sunday, 6=Saturday
    startHour: 9                     # 24-hour format
    endHour: 17

A rule with a time window only matches during the specified period. Outside the window, the rule is skipped entirely. Hours are compared in the controller process's local time (getDay / getHours): startHour (0–23) is inclusive and endHour (0–24) is exclusive, and a window whose startHour is greater than its endHour wraps midnight on the same local day. Either bound may be omitted.

Priority

Rules are evaluated in priority order. Lower numbers are evaluated first (higher priority):

PriorityConvention
1-10Critical safety rules
10-50Cost and budget rules
50-100Operational rules
100Default priority

If multiple rules match, all hard violations result in denial. Soft violations accumulate as warnings.

Default Rules

usertrust ships with three built-in rules:

  1. block-budget-overshoot — Deny when budget_remaining_after lt 0 (hard)
  2. block-budget-exhausted — Deny when budget_remaining lte 0 (hard)
  3. warn-high-cost — Warn when estimated_cost gt 1000 (soft)

The built-ins carry priorities 1, 2 and 50 and are always enforced: a custom policy file is concatenated on top of them (mergePolicies), so user rules can add deny or warn rules but cannot remove, disable, or override a built-in — enabled: false or a rule reusing a built-in's id simply appends another rule while the original stays active. The default.yml that usertrust init writes contains a copy of these three rules; deleting them from the file does not turn them off.

Further Reading