Tool policy
For: tenant admins and governance reviewers.
Set a tool policy rule to allow, ask about or refuse a function call. Start with the rule commands on this page; use the companion references for conditions, origins, sequence checks and the runtime boundaries.
A tenant's tool policy decides what happens to each call of its plugins' functions. MCP tools say what they do with annotations (readOnlyHint, destructiveHint, idempotentHint and openWorldHint); the policy reads them, and the tenant decides. They are hints from the tool, and the policy is yours.
The words
The console, the CLI, approval cards, the shadow report, errors and these pages use the same words.
What the policy does with a call:
| Word | What happens | Stored as |
|---|---|---|
| Allow | the call runs | allow |
| Ask | a person decides each call before it runs, as an approval | approve |
| Refuse | the call is not made, and the agent is told why | refuse |
| Judge | unavailable: a switch set to Judge is refused, and a plugin's suggested judge is written as Ask | none yet |
A person's own answer to an approval stays Approve or Deny: it says what that person did with one call, not what a rule does. The API, the stored policy and the files of older commands keep the stored names, so scripts that write approve keep working, and every command also takes ask.
What a function does, its type, from its annotations (the first that fits):
| Type | Which functions | By default |
|---|---|---|
| Unknown | those that declare no annotations, and those that are not read-only and say nothing about destructiveHint, which MCP reads as destructive | Ask |
| Read | those that say readOnlyHint: true | Allow |
| Destructive | those that say destructiveHint: true | Ask |
| Sends out | those that say destructiveHint: false and reach beyond the tenant (they do not say openWorldHint: false): what they send leaves the tenant, such as mail, webhooks and posts | Allow |
| Write | the rest: they change something inside the tenant, or in a system they manage, and destroy nothing | Allow |
An open-world write whose data stays in the system it manages, such as a repository's issues, says so with "oz0/class": "write" in its annotations or its _meta (Python's MCP SDK keeps only the second: @server.tool(annotations=..., meta={"oz0/class": "write"})), and is then Write rather than Sends out, but only in a reviewed listing: a plugin installed from the official index under its reviewed namespace, whose review checked that the data stays there.
Anyone else's mark changes nothing, and the function stays Sends out, because the type is the group oz0 plugin policy --set and the install screen switch functions by: a mark any author could set would take mail out of the Sends out group. The mark changes only the type; the policy decides by the annotations themselves, below.
Two markers go with a type. Hints only: a remote MCP server's function, whose annotations count only where they make a call stricter, so a remote Read function still asks. Host tool: a function that acts on a node's host (oz0/class: host.read, host.write or host.destructive). Rules by name and by kind cover host tools by themselves; a condition on tool.class covers them only with its host form, which oz0 policy ask --class (and allow and refuse) writes for you.
The policy keeps four defaults, one for each of Read, Destructive, Unknown and Reaches outside (every function that does not say openWorldHint: false, and every remote server's): a function gets the strictest default that applies to it. Write and Sends out functions take the Reaches outside default when they reach beyond the tenant, and Allow otherwise.
Rules name functions, <tool>.<function> with * matching anything, and win over the defaults, in order: the first rule that matches a call decides it. A plugin's own requires_approval still asks a person, unless the policy refuses the call. The platform puts one kind of rule of its own before yours: a chat channel's post function and the function that reads its deliveries are refused to other plugins' agents and flows, which post with the channel's send function instead. A rule of yours that names such a function exactly (oz0 policy ask oz0_slack.post) replaces the platform's.
Size. A pattern is at most 256 bytes, wherever a policy holds one (a rule's match, a sequence rule's functions, the functions the tenant vouches for as records): 256 bytes hold the longest name a pattern spells out, a function by its address, <plugin>/<tool>.<function>.
A policy holds at most 1,000 rules and 2 MiB as JSON. The edge reads the rules on every tool call of the tenant's jobs, so setting a policy over these bounds is refused with the place that is over.
A policy stored before them that holds a longer pattern cannot be read whole: the edge refuses the tenant's tool calls, saying so, and sends its nodes nothing new until an admin sets the policy again; its lint, its approval counts and a replay of it say why instead of running.
Moving a plugin to v1.0
Since v1.0 an Unknown function asks a person at each call (before, it ran). So do its hooks: a hook whose function declares nothing waits for an approval each time it is asked, and while it waits or once it is denied it counts as its on_timeout answer (a guard that fails closed stops the call). Either:
- Declare what each function does, which is the fix:
readOnlyHint: truefor a function that only reads;destructiveHint: falsefor one that writes but destroys nothing, ordestructiveHint: truefor one that does; andopenWorldHint: falsefor one that stays inside the tenant. In Python,@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))(see Tools).oz0 plugin newwrites tools that do.oz0 devlists the functions of your plugin that the policy does not let run freely, each with its fix. - Or, while the authors annotate them, let the tenant run them as before: one rule per function,
oz0 policy allow jev.decide --reason "...", or every Unknown function at once,oz0 tenant tool-policy set --undeclared allow.
Change it one rule at a time
oz0 policy changes the tenant's tool policy one rule at a time, in these words:
oz0 policy show
oz0 policy allow 'web.*' --reason "ticket 812"
oz0 policy ask jev_support.refund --deciders team:payments --not-starter --reason "refunds need a person"
oz0 policy refuse github.merge_pull_request --when "!args.repo.startsWith('acme/')" --reason "only our repositories"
oz0 policy explain rule-3f9c0a2b81d4
oz0 policy edit rule-3f9c0a2b81d4 --action ask --reason "a person decides after all"
oz0 policy remove rule-3f9c0a2b81d4 --reason "the trial is over"
Tool policy of tenant default, set by operator:admin 2m ago:
By default, a function no rule names gets the default for its type:
Read Allow
Destructive Ask
Reaches outside Allow
Unknown Ask
Rules, in order: the first that matches a call decides it.
rule-3f9c0a2b81d4 jev_support.refund Ask (decided by team:payments; never by the job's starter) · refunds need a person
rule-taint-default * Ask (when job.tainted && tool.outbound; preset taint-default) [only raises a decision]
Where a new rule goes. oz0 policy allow, ask and refuse add one rule where a plugin's switch puts its rule: just before the first rule that decides one of its functions for every caller, so it wins over that rule. Rules that decide only some calls (a condition, a caller, another plugin's functions) and narrower rules (github.merge_pull_request under github.*) stay before it and keep winning for theirs, and floors such as the taint-default rule still raise a decision.
A rule with the same pattern that decides for every caller is changed in place, keeping its ID. A rule with --when, --caller or --other-plugins goes before the first rule that decides one of its functions, so it wins for the calls it names. The command says where the rule went; --dry-run says it and saves nothing.
Flags, not text. Every setting of a rule is a flag:
| Flag | What it does |
|---|---|
--when <cel> | decide only the calls a condition holds for |
--caller plugin:<name>, publisher:<namespace>, source:<kind> | only calls by these plugins (rules by caller); repeatable |
--other-plugins | only calls of another plugin's functions |
--type read, destructive, declares-nothing, not-read, reaches-outside | only functions of this kind (a rule by kind); see below for what each takes in |
--class read, write, egress, destructive | only calls of this action class, host tools included: it writes the condition with each class's host form |
--shadow | decide nothing and record what the rule would have decided (shadow mode) |
--expires <time or duration> | when the rule stops, such as 2026-12-31T00:00:00Z or 72h |
--deadline, --escalation, --confirm, --deciders, --not-starter | for an Ask rule: its approvals |
--origin <argument>=<origin> | an origin rule |
--after-..., --then-..., --within | a sequence rule |
--id, --reason, --decision-ref | the new rule's ID, and why |
Write and Sends out are types, not kinds a rule can name: --class write and --class egress decide by the action class, call by call.
One rule at a time. remove <id>, edit <id> and explain <id> work on one rule (oz0 policy show lists the IDs). edit changes only what its flags name, in the rule's place; --action, --match, --enforce (a shadow rule then decides) and --no-when, --no-expires, --no-callers, --no-deciders are its own. explain says what a rule does with today's catalog: the functions it names, which of them it decides, the earlier rules that decide some of them first, the later rules it hides, and what still raises a decision of its functions (floors, origin and sequence rules, a plugin's requires_approval):
Rule rule-gh: github.* Refuse
It names 4 functions in today's catalog: github.get_file, github.create_issue, github.merge_pull_request, github.delete_branch.
It decides github.create_issue, github.merge_pull_request for every caller: Refuse.
Hidden by the earlier rule rule-7a github.* Allow (for Read functions), which decides github.get_file first.
It hides the later rule rule-9c github.merge_pull_request Ask for github.merge_pull_request.
Every change takes a reason (--reason, at most 500 characters; scripts may set OZ0_REASON instead) and may take --decision-ref, the decision or case it follows.
Admins and owners of the tenant may change the policy, everyone in it may read it, and the audit log records each change (tenant.tool_policy) with its reason.
In a tenant with four eyes on, a change that loosens the policy waits for another admin; without it, the change applies at once.
A plugin's configuration that loosens always waits for another admin; a function's switch that loosens waits for another admin only where four eyes are on (two paths).
In the console, Govern › Policy shows the policy under Rules, each rule with its own deadline and deciders, and lets admins edit it; the tenant-wide approval deadline and escalation ladder are under More, Approval defaults.
Build › Tools and skills shows, beside each function, what the policy does with its calls, and links to Policy.
Related tasks and reference
- Export and apply a tool policy
- Rules for plugin and remote functions
- Function conditions and action classes
- Rules for taint, origins and sequences
- How the runtime enforces tool policy
- Preview, review and lint tool policy changes
- Assign duties to tool calls
- Command rules in a tool policy
Previous section links
a plugins suggested policy: Read the section.
a rule by address: Read the section.
action classes: Read the section.
approval deadlines and escalation: Read the section.
backtest a change: Read the section.
calls that carry a secret: Read the section.
command rules: Read the section.
conditions on each call: Read the section.
duties: Read the section.
hooks come before the rules: Read the section.
how often a rule will ask: Read the section.
label presets: Read the section.
no audit entry no call: Read the section.
one plugins functions: Read the section.
remote mcp servers: Read the section.
rules by caller: Read the section.
rules from plain english: Read the section.
sequence rules: Read the section.
shadow mode and ends: Read the section.
suggested rules: Read the section.
the older commands: Read the section.
the policy lint: Read the section.
the policys version: Read the section.
the switch is placed by the server: Read the section.
the taint default rule: Read the section.
the whole policy as a file: Read the section.
two paths for a plugins functions: Read the section.
where arguments came from: Read the section.
where it applies: Read the section.
words the policy does not know: Read the section.