Policy & decisions
A policy is an ordered list of rules. Evaluating an action returns one of four decisions: allow, block, redact, or require_approval.
Every action your agent takes is captured as an action— a verb, a tool name, a target host, and a set of fields. Before that action runs, HESO checks it against your policy, takes the first rule that matches, and uses that rule’s decision. The matched rule and its decision are recorded on the receipt, so anyone verifying it later can see which rule routed the action.
First match wins
A policy is an ordered list of rules. Each rule has an order (an integer). HESO sorts rules by ascending order, walks them top-down, and stops at the first rule that matches the action. That rule’s decision is the result. No later rule is checked. Rules never merge or vote.
Because order decides the winner, write rules from most specific to most general:
- Put tight, high-stakes rules (one workflow, a narrow host, a hard cap) at low order numbers.
- Put broad catch-alls (subject “any,” scope “*”) at high order numbers.
- A rule with
enabled = falseis skipped, as if it were not in the file.
The heso.toml format — the [[rule]] block, every field, and how order drives evaluation — is covered in Writing policy.
The four decisions
Every matched rule resolves to exactly one of four decisions. This decision is recorded on the receipt as the decision_path.
| Decision | What happens |
|---|---|
allow | The action proceeds. It is still captured, signed, and chained. |
block | The action is refused and never runs. The attempt is still recorded. |
redact | Named fields are replaced with a hash before signing, then the action proceeds. See Redaction. |
require_approval | The action pauses and routes to a human approver. When they co-sign with their own key, the receipt becomes L1. |
What a rule matches on
A rule matches an action only when all of its facets agree with the captured action.
- subject — who is acting.
kindisany,workflow, oraccount, with an optionalvalueto pin a specific workflow or account. - verb — what kind of action:
"any"or one of the seven verbs. - scope— where it points. A host glob matched against the action’s target host, or
"*"for any host. - conditions — typed checks on the action’s fields. Operators are
gt,lt,eq,contains, andregex. All conditions on a rule are AND’d — every one must pass. For OR, write two rules. See Conditions & operators.
Here is one rule from a heso.toml. It matches a payment over $5,000 in the vendor-payouts workflow and routes it to a human:
[[rule]]
id = "pay-cap"
order = 10
enabled = true
subject = { 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 = 60When this rule wins, HESO records the decision on the receipt as a PolicyOutcome inside the signed content, so a verifier sees the exact verdict without needing your policy file:
{
"rule_id": "pay-cap",
"rule_display": "Require approval to pay over $5,000",
"matched_conditions": [
{ "field": "amount_usd", "op": "gt", "value": "5000" }
],
"decision_path": "require_approval"
}Dangerous lanes and pinned floors
Four verbs are dangerous lanes: payment, delete, account_change, and data_export. Each carries a built-in floor the engine enforces. A policy can tighten a floored lane — add conditions, lower a cap, narrow a scope — but it can never bypass the floor. You cannot, for example, blanket-allow a deletewithout the floor’s approval.
Floor bypass is caught when the policy loads. A policy that tries to allow a dangerous lane past its floor is rejected with a [FLOOR_BYPASS] error naming the offending rule id and verb; a malformed policy is rejected with [PARSE]. This check runs in the browser too, so a bad policy is caught before it ships. The floors themselves are detailed in Pinned floors.
The default: permissive
When no rule matches, the decision is allow. The default is permissive: an action you did not write a rule for is permitted (and still captured and signed).
The dangerous lanes are the exception. Their floors apply whether or not a rule matches, so an unmatched payment, delete, account_change, or data_export still enforces its floor instead of falling through to allow.
One engine everywhere
A single Rust core evaluates policy and is compiled to Python, Node, and browser WASM, so a decision is the same wherever it runs. The package layout and how to gate from each runtime are in the SDK overview.
A receipt proves you authorized an action under your policy, and at L1 that a person approved it with a device-held key. It does not prove the action succeeded downstream.