Tool labels and approvals
For: plugin authors.
Carry labels and argument origins through tool policy checks.
Labels
A result may say what it holds, in its _meta under oz0/labels (taint and labels): trust (untrusted for text from outside, such as a mail or a ticket's body), level (a name from the tenant's ordered list, such as confidential) and compartments (up to 16 names, ^[a-z0-9][a-z0-9_.-]{0,63}$, such as a matter's ID), at most 2 KiB:
return CallToolResult(
content=[TextContent(type="text", text=body)],
_meta={"oz0/labels": {"trust": "untrusted", "level": "confidential", "compartments": ["matter-17"]}},
)
The job that read the result joins these into its own labels. A level the tenant's list does not hold, an unknown key, a malformed compartment or too many withholds the result: the model reads an error, never the content. trust: "trusted" never lowers anything: an open-world function's results are untrusted unless the tenant vouches for the function.
The manifest says what a tool's results are when they say nothing:
tools:
- id: mail
mcp: [python, -m, mail_server]
labels:
source: imap # its results come from a labelled system: an unlabelled one gets the tenant's default level
untrusted: true # its results hold outside text, though it stays inside the tenant
A plugin that maps a source's own labels (a document store's sensitivity labels, say) to the tenant's list declares a source and sets level on each result. The platform takes your plugin's word for what it declares; a tool that under-labels lowers what its tenant's rules can protect.
Where arguments came from
Mark the arguments a person should not have to take on trust, and say where their values must come from. In a function's input schema, x-oz0-role names what an argument is (recipient, amount, host, path, payee or repository; format: email counts as a recipient, hostname, uri and url as a host), and x-oz0-origin the floor its value's origin must reach: request (what the person asked), identity (the starter themselves), record (a tenant record) or any (anything the job read, but never a value the model made up). With FastMCP and Pydantic:
from typing import Annotated
from pydantic import Field
TO = Field(json_schema_extra={"x-oz0-role": "recipient", "x-oz0-origin": "record"})
@server.tool(annotations=ToolAnnotations(destructive_hint=False, open_world_hint=True))
def send(to: Annotated[str, TO], body: str) -> str:
return mail.send(to, body)
Before a call of send, the platform checks to against where the job's values came from, with no model in the decision: an address the person typed, their own, or one a vouched-for record returned passes; one only a web page named asks a person, and the card says why (Tool policy). A list's items are checked one by one. A function that returns tenant records, such as a CRM lookup, says so at its input schema's root:
{"type": "object", "x-oz0-origin-source": "record", "properties": {"name": {"type": "string"}}}
Its results count as records only once a tenant admin vouches for it (oz0 tenant tool-policy set --origin-record crm.lookup): a plugin cannot raise its own results' rank. plugin lint checks the keywords in your measured listing: a floor's value, a floor without a role, and the record source's one value. For a list of values (type: array), the role and the floor may sit on the property or on its items, as in a list of recipients with x-oz0-origin on the list and format: email on its items; every value of the list is checked.
They are read through the schema an optional or a union argument takes too: the branches of anyOf, oneOf and allOf (a branch that is only null names nothing, as in Pydantic's Optional[list[str]]) and a local $ref such as #/$defs/Address, at most eight schemas deep. The property's own role wins; branches that name different roles, and a $ref that cannot be followed or goes round, mark the argument as one checked as an exact string, never as one left unchecked, and plugin lint says why.
Approvals
requires_approval lists functions that pause the job until a person approves:
- id: acme_github
mcp: ["python", "-m", "acme_github.mcp"]
requires_approval: [acme_github.merge]
Every call to them waits for an admin to approve or deny it in the console or with oz0 approval; a denied call is not made, and the model is told why. See Approvals.