Tool policy

Rules for taint, origins and sequences

Rules for taint, origins and sequences for tenant admins and governance reviewers.

For: tenant admins and governance reviewers.

The taint-default rule

Every policy has a built-in rule, rule-taint-default with preset taint-default: * Ask, when job.tainted && tool.outbound. Once a job read untrusted input (an outside start, an open-world result, an event), each call of a function that can send data out waits for a person, and the card says the taint asked.

It is a floor: it takes no part in the first-match order, and only ever raises what the rules and classes decided, so it never loosens a refusal and an allow rule before it does not get past it.

Narrow its condition (--taint-default-when, below), set it to shadow mode or turn it off (--taint-default off) like any rule, with a reason; Taint and labels says what taints a job and how to recover with a quarantine child.

A tenant that trusts a service with what its jobs read leaves that service's calls out of the rule's condition, and every other outbound call of a tainted job still asks:

Terminal
oz0 tenant tool-policy set --reason "Jev is our triage service: reading tickets is its job" \
  --taint-default-when 'job.tainted && tool.outbound && !(mcp.tool.target == "jev" && mcp.tool.name == "decide")'

Where arguments came from

A function's input schema can mark a sensitive argument with a role (x-oz0-role: recipient, amount, host, path, payee or repository) and a floor (x-oz0-origin: request, identity, record or any): how trusted the place its value came from must be (Tools shows the keywords). Each job keeps an index of the values it may legitimately use, built from what its history recorded, never from what the model says:

OriginWhat it covers
requestthe input of a job a person or an app started (not a webhook's, a chat's or another outside event's)
identitythe starter's own account name and the addresses their identity provider verified
recordresults of functions whose schema says x-oz0-origin-source: record and that the tenant vouches for (--origin-record)
anyanything else the job read: every other tool result, a child's answer, an outside start's input

Origin floors and value comparison

A floor accepts its origin and every more trusted one.

A word the checks do not know is read as strictly as it can be, never as nothing to check: a floor word that is no floor (a misspelt requets, or one a newer version wrote) is request, and an x-oz0-role that is no role still marks its argument, which is then checked as an exact string.

A URL counts by the host a browser would connect to: one whose host part holds a backslash, a space or other control character, or a letter outside ASCII is no host at all, so it is checked as an exact string too.

Values are compared as units of one kind, normalised the same way everywhere: an email address ([email protected] is [email protected]), a host (a URL counts by its host), a repository (owner/name, from a URL too), a path, an IBAN, a phone number, an amount (1,200.50 is 1200.5), or else the exact string.

A value that is merely part of a longer one does not match: [email protected] is not in [email protected].

A child job starts with what its parent could vouch for (its request, identity and records, at most 512 values, the most trusted first), so a recipient a person gave the parent passes in the child.

A result is read for values up to 256 KiB of its text.

After the conditions and the taint-default rule, and before an approval, every role-marked argument with a floor is checked. A value below its floor is origin.unknown: the built-in rule rule-origin-default (preset origin-default) asks a person, and the card says which argument, its role, its floor and where its value came from, never the value.

The trace and the audit trail record the same.

Like the taint-default rule it is a floor: it never loosens a decision.

Turn it off with --origin-default off (a loosening, with a reason), or keep a rule of its preset in shadow mode to see what it would ask; a rule of the preset in shadow mode, with a weaker action or with an end counts as a loosening too.

An agent's changes policy never stands for a person's approval the origin check or a sequence rule asks for.

A tenant can set floors for functions it does not own, and make them refuse instead of ask, with an origin rule:

Terminal
# a payment's payee must be one the person named, or a CRM record the tenant vouches for
oz0 tenant tool-policy set --reason "the CRM's customers are records" --origin-record crm.lookup_customer
oz0 policy refuse 'payments.*' --origin role:payee=record --origin amount=request --reason "payees from the request or the CRM"

--origin names arguments, or every argument of a role as role:<role>. Vouching for a record function (--origin-record) loosens what passes, so it takes a reason too, and a second admin where four eyes are on.

The edge decides a refusing origin rule again, and the origin-default rule when you keep it refusing (with the floors the tool's schema sets): a call it names that carries no record of the check (a node runtime from before origin checks) or whose own check came out below the floor is refused there too.

It reads which arguments a tool marks from the plugin's approved version it already holds, so it decides the same while it cannot reach management.

A call by hand and a channel's post are no model's calls: origin rules do not apply to them.

Sequence rules

A sequence rule decides a call by what came before it in the job, or anywhere in its job tree:

Terminal
oz0 policy ask '*' --after-level confidential --then-outbound yes --within tree \
  --reason "confidential reads, then nothing leaves without a person"

Sequence matching and enforcement

The first call and the later one are each matched by --after-function and --then-function (a pattern), --after-class and --then-class (action classes, repeatable), --after-plugin and --then-plugin, and --after-outbound and --then-outbound (yes or no); the first call can also match the labels of its result (--after-trust, --after-level, a level at least as high, and --after-compartment, any of them).

--within is job (the default) or tree.

oz0 tenant tool-policy set --gate-rule takes the same rule as JSON ({"match": "*", "action": "approve", "sequence": {"after": {...}, "then": {...}, "within": "tree"}}).

A rule fires once the result of a matching call has come back (a result without labels counts as untrusted), and an after that names only labels also fires on the labels the job started with: a job a webhook or an event started is untrusted from its start.

Each job's runtime decides the rules for its own calls, and a child starts with the tree rules its parent fired and hands its own back when it ends.

The edge, which sees every tool call of every job of the tree, decides within: tree rules again for the tree's other jobs.

A job's runtime tells the edge which tree rules fired as soon as the result that fired them comes back, not only with the job's next tool call, so a sibling meets the rule from its next call: when a rule fired in one job and a sibling's later call matches its then, a refusing rule refuses that call, and an asking rule refuses it unless a person approved it (its runtime could not know, so the edge fails it rather than let it pass).

A job's own calls are its runtime's to decide.

What fired counts from the tree's start: a job ID used again starts a new tree.

Sequence rules are floors too, and take a mode, an end and a reason like any rule.

Two shipped presets are off until a tenant adds them:

Terminal
oz0 tenant tool-policy set --keep-rules --reason "after untrusted input, a person decides" --preset untrusted-then-consequential
oz0 tenant tool-policy set --keep-rules --reason "confidential, then egress asks" --preset confidential-then-egress
  • untrusted-then-consequential: after a result that is untrusted, a call of class write, destructive, egress or host.* asks, within the job.
  • confidential-then-egress: after a result labelled at least confidential, a call that can send data out asks, within the tree.
Copyright © 2026