Start free

Policy files

Policy lives in heso.toml as an ordered list of [[rule]] blocks. The engine checks them top to bottom and stops at the first match, so order is what you control.

A policy is a single TOML file. Every gated action — a payment, a delete, a tool call — is checked against the rules in order until one fits. That rule’s decision is what the engine enforces and stamps onto the receipt. There is no scoring and no priority math: the first rule that matches wins, full stop.

The file

HESO looks for heso.toml by walking upward from your project root until it finds one — so a single file at the top of a repo governs everything beneath it. Running heso init writes a starter file you can edit in place.

heso.toml
# heso.toml — discovered upward from your project root.# heso init writes this starter; the local data dir is gitignored. [[rule]]id = "starter-allow"order = 0enabled = truesubject = { kind = "any" }verb = "any"scope = "*"conditions = []decision = "allow"

The starter is one open rule so a fresh project runs end to end. You tighten it by adding more specific rules above the broad allow. The local data dir HESO writes alongside it is gitignored, so commit the policy, not the receipts and keys.

A rule

A rule is one [[rule]] block. It pins who the action belongs to (subject), what it does (verb), where it goes (scope), and any extra conditionson the action’s fields. When all of those match, the decision applies.

heso.toml
[[rule]]id = "pay-cap"order = 10enabled = truesubject = { kind = "workflow", value = "vendor-payouts" }verb = "payment"scope = "*"conditions = [  { field = "amount_usd", op = "gt", value = 5000, display = "amount over $5,000" },]decision = "require_approval"approvers = ["finance-lead"]sla_minutes = 60

This rule routes a payment over $5,000 on the vendor-payouts workflow to a human: decision = "require_approval" sends it to the listed approvers with a 60-minute sla_minutes. The conditions array holds one inline table per check — see Conditions & operators for the full set.

Rule fields

Every field a rule can carry, with its type and meaning.

idstringrequired
A stable identifier for the rule. It is stamped onto the receipt as rule_id and named in any [FLOOR_BYPASS] error, so keep it human-readable and unique.
ordernumberrequired
Where the rule sits in the order. Lower runs first. Since the first match wins, order is how you decide which rule a given action falls into.
enabledboolrequired
Whether the rule participates in matching. A disabled rule is skipped entirely, as if it were not in the file.
subject{ kind, value? }required
Who the action belongs to. kind is "any", "workflow", or "account"; value names the specific workflow or account (omitted for any).
verbstringrequired
"any" or exactly one of the seven action verbs: llm_call, tool_call, http_request, payment, data_export, account_change, delete. See Actions & verbs.
scopestringrequired
A host glob to match against the action’s target_host, or "*" to match any host. Useful for narrowing a rule to one API or domain.
conditionsarrayrequired
Zero or more checks on the action’s fields, each an inline table of { field, op, value, display }. All must hold for the rule to match. Use [] for an unconditional rule.
decisionstringrequired
What the engine does when the rule matches: allow, block, redact, or require_approval (route to a human).
approversstring[]
Label strings naming who may approve, used with decision = "require_approval". See Human approval.
sla_minutesnumber
How long an approval may sit before it breaches its service-level window. Recorded on the approval so reviewers see the clock.

Ordering

Because the first match wins, the order of your rules decides everything. Put the specific and dangerous lanes first, and let a broad allow sit at the bottom as the catch-all. The pattern:

  • Most specific, most dangerous rules first — blocks and approvals you never want skipped.
  • Narrowing rules next — redactions and host-scoped checks.
  • The broad allow last, as the default lane for everything benign.

If an action reaches the end without matching anything, it does not fall through to allow. A dangerous lane that no rule covers defaults to require_approval, so a gap in your policy routes to a human instead of letting the action through.

You cannot allow a dangerous lane away

Payments, deletes, account changes, and large data exports carry a built-in floor the engine enforces when it loads the policy. A policy may tighten a floor, but it can never allow one of these lanes without approval. If it tries, the policy is rejected at load with a [FLOOR_BYPASS] error naming the offending rule id and verb. See Pinned floors.

A worked policy

Here is a realistic file: block deletes on the production account, route large payments to two approvers, redact any API key that shows up on a tool call, and allow everything else.

heso.toml
# Rules are tried top to bottom. The first one whose subject + verb +# scope + conditions all match decides. Specific and dangerous lanes# go first; the broad allow goes last. [[rule]]id = "block-prod-deletes"order = 10enabled = truesubject = { kind = "account", value = "prod" }verb = "delete"scope = "*"conditions = []decision = "block" [[rule]]id = "approve-large-payments"order = 20enabled = truesubject = { kind = "any" }verb = "payment"scope = "*"conditions = [  { field = "amount_usd", op = "gt", value = 5000, display = "amount over $5,000" },]decision = "require_approval"approvers = ["finance-lead", "cfo"]sla_minutes = 120 [[rule]]id = "redact-tool-keys"order = 30enabled = truesubject = { kind = "any" }verb = "tool_call"scope = "*"conditions = [  { field = "api_key", op = "exists", value = true, display = "an api_key is present" },]decision = "redact" [[rule]]id = "allow-rest"order = 99enabled = truesubject = { kind = "any" }verb = "any"scope = "*"conditions = []decision = "allow"

Read it top to bottom. A delete on prod hits block-prod-deletes and stops. A $9,000 payment skips the delete rule (wrong verb) and lands on approve-large-payments. A tool call carrying an api_key field is redacted before signing. Everything else falls through to allow-rest.

The natural-language sentence

Every rule renders to one plain English sentence, the rule_display. When a rule decides an action, that sentence is stamped onto the receipt’s policy.rule_display alongside the matched conditions, so anyone verifying the receipt later reads plain text, not just a rule id.

The same renderer, ruleToSentence, runs in the browser policy editor as you type, so the sentence you see while authoring is exactly the one that lands on the receipt. The displaystring you write on each condition feeds that sentence, which is why it’s worth phrasing in human terms — “amount over $5,000,” not a bare expression.

What the sentence is — and isn't

rule_display records which rule authorized the action and why, in words. It does not prove what happened in the real world. A receipt attests intent and authorization under a known policy, not the actual outcome. See Action receipts for the boundary.