Taint and labels

Set rules on taint and labels

Set rules on taint and labels for tenant admins and governance reviewers.

For: tenant admins and governance reviewers.

The taint-default rule

Every tenant's tool policy has a built-in rule:

{"id": "rule-taint-default", "preset": "taint-default", "match": "*", "when": "job.tainted && tool.outbound", "action": "approve"}

After a job is tainted, a call of a function that can send data out waits for a person. tool.outbound is true for:

  • an open-world function, even a read-only one (a URL or a query carries data out);
  • a function of class egress or any host.* class (so host writes and shells of a tainted job ask on every call);
  • a function bound to a connection's token or a credential binding;
  • the routine output tool oz0.output.

The rule gates calls the job's workflow decides. It does not gate a harness session's own tools (its shell, its web fetch, a remote session's provider tools): they run inside the session and ask only when a pre_tool_call hook or the agent's harness.requires_approval names them, or, for a shell command, when a command rule asks, which reads the command and not the job's taint.

A tainted session's WebFetch or curl runs without a yes unless one of those does; its MCP tools (the servers an imported plugin's harness extras give it included) and the job's later calls are gated as any call is.

OZ's own tools (oz.delegate, oz.ask_colleague, the board, oz.hand_over, oz.quarantine) are not outbound: their labels travel with them. A chat channel's post functions are refused to agents anyway.

The rule is a floor: it is not part of the first-match order of the rules, and after the rules and the classes decide, it makes the decision at least approve. It never loosens a decision: a refusal stays a refusal, and an allow rule before it does not let a tainted call through. The approval card says that the taint asked, and why (the reasons, without content); the trace gives the step the same reason.

The rule is shown in the tool policy like any rule. You may narrow its pattern or condition (oz0 tenant tool-policy set --taint-default-when CONDITION, such as a service you trust left out, see the tool policy), or set it to shadow mode to see what it would ask before it acts; each loosens oversight and needs a reason. To turn it off:

Terminal
oz0 tenant tool-policy set --taint-default off --reason "agents run in a closed lab"

Rules on labels

Conditions read the job's labels at the call, in the runtime and again at the edge:

NameWhat it is
job.taintedwhether the job read untrusted input
job.taintwhy: a list of {source, tool, call}
job.labels.levelthe level's index in the tenant's list, so >= works
job.labels.level_namethe level's name, "" while the job read nothing labelled
job.labels.compartmentsthe compartments, sorted
tool.outboundwhether the function can send data out (above)
Terminal
# nothing confidential leaves through outbound tools, whatever the job's trust
oz0 policy refuse '*' --when "job.labels.level >= 2 && tool.outbound" --reason "policy 4.2"
# mail from a job that read a web page waits for a person
oz0 policy ask mail.send --when "job.taint.exists(r, r.source == 'open_world')" --reason "phishing"

The edge can only take the runtime's word for what a job read, so every tool call's audit entry records the labels its decision read and the labels of its result, and oz0 evidence verify checks them offline: a call whose recorded labels are lower than an earlier call's, or than an earlier result's, is reported.

A call whose input names no labels claims none: its entry records labels: null, and it is checked as a call that read nothing, so leaving the labels out never takes a call out of the check.

It also bounds each call by the job's start (its run.start entry's labels, and an outside start's untrust: a webhook's or an event's job, and an agent's job a chat message started; a flow a chat message started is not untrusted from its start), by every join the runtime recorded that is not a tool call's own result (run.labels.joined: a child's, a colleague's, the board's, a file's, memory's, an event's, a harness session's start; the first 50 of each run of the job), and by a finished child's labels at its end (its run.output entry).

A quarantine child bounds its parent only through the join the parent recorded, since the parent joins nothing of an answer its contract refused.

A join bounds only the calls scheduled more than two seconds after it, since a join and a call of the same workflow step carry nearly the same time.

A runtime that drops a join before it records it still passes; one that records it and forgets it later does not.

Copyright © 2026