Function conditions and action classes
For: tenant admins and governance reviewers.
A rule by address
A rule's pattern may name its plugin: <plugin>/<tool>.<function>, with * where a pattern takes it. The rule then decides only that plugin's functions, so github/* asks about every function github provides and nothing else, however other plugins name their tools:
oz0 policy ask 'github/*' --reason "a person sees every GitHub call this quarter"
oz0 policy refuse github/oz0_github.delete_branch --reason "branches stay"
oz0 tenant tool-policy set --keep-rules --rule 'github/oz0_github.merge_*=approve'
rule-5b1e09c2d7aa github/* Ask · a person sees every GitHub call this quarter
- The owner check: when the pattern names a whole tool, such as
github/oz0_github.*, the server checks that the plugin provides it and refuses one another plugin provides, with that plugin's name. A pattern whose tool part holds a*(github/*,github/oz0_*) needs no installed tool, and so does a plugin the tenant has not installed yet: the rule decides once it is. - Where it is kept: the policy stores the plugin apart from the bare pattern (the admin API's
ToolPolicyRule.plugin), and the console,oz0 policy show,oz0 policy explainandoz0 policy exportshow it as the address. A rule without a plugin decides exactly as before, and its policy's version is unchanged. - Where the function's plugin is not known, a rule by address does not apply. Nodes know the plugin of every function they run, and the catalog's POLICY column knows it too, so both show the rule's decision.
Rules by caller
A rule may also say which callers it is for: the plugin whose agent or flow makes the call, by name (--caller plugin:<name>), by publisher (--caller publisher:<namespace>, a hub listing's namespace such as ai.oz0) and by where it was installed from (--caller source:<kind>: git, workspace, bundle, oci, hub, mcp, npm, archive).
--other-plugins limits it to calls of another plugin's functions, which that plugin was granted, and --type to functions of some kinds: read (functions that say readOnlyHint: true, never a remote server's, whose hints only count where they are stricter), not-read (everything else), destructive (functions that say destructiveHint: true), reaches-outside (Reaches outside) and declares-nothing (functions with no annotations at all), which the stored policy names read_only, not_read_only, destructive, open_world and undeclared.
A kind is narrower than the type of the same name in two places: read leaves out a remote server's Read functions, and no kind takes in every Unknown function, since a write that is silent on destructiveHint is Unknown but declares something.
--type unknown is refused for that reason: name such functions, or set the Unknown default (oz0 tenant tool-policy set --undeclared). Hub plugins may call only Read functions of other plugins:
oz0 policy refuse '*' --caller source:hub --other-plugins --type not-read --reason "hub plugins only read others"
The call to github.merge was not made: the tenant's tool policy refuses it: the tenant's rule * for callers by sources hub, on other plugins' functions (not_read_only).
With oz0 tenant tool-policy set, the same rule is --rule '*=refuse if source=hub other-plugins kind=not_read_only' --keep-rules.
A rule by caller never applies to a call without one, such as a hook's, which the platform makes for every job. The admin API takes it as ToolPolicyRule.callers, other_plugins and kinds. The catalog has no caller, so its POLICY column is the decision for calls that no rule by caller matches, and its WHY column adds the rules by caller that decide otherwise for some callers, such as for some callers: Refuse by the tenant's rule * for callers by sources hub, on other plugins' functions.
Conditions on each call
A rule may have a condition, --when: a CEL expression over the call, and the rule then decides only the calls it holds for. Merges into your own organisation's repositories run, and every other merge asks a person:
oz0 policy ask github.merge --reason "merges need a person"
oz0 policy allow github.merge --when "args.repo.startsWith('acme/')" --reason "our own repositories"
The second rule has a condition, so it goes before the first, and wins for the calls it holds for. With oz0 tenant tool-policy set, a condition follows when inside the rule's text: --rule "github.merge=allow when args.repo.startsWith('acme/')".
The names follow agentgateway's for MCP authorisation:
| Name | What it is |
|---|---|
args | the call's arguments, an object (also mcp.tool.arguments) |
mcp.tool.name | the function as its server names it, such as merge |
mcp.tool.target | the tool, by its ID, such as github |
mcp.methodName | tools/call |
caller.plugin, caller.publisher, caller.source | the plugin whose agent or flow makes the call, as rules by caller name it; empty for a call by hand |
caller.agent | the agent or flow that makes the call |
tool.class | the function's action class: read (Read), write (Write), egress (Sends out), destructive (Destructive), or a host tool's host.read, host.write or host.destructive (below) |
tool.outbound | whether the function can send data out: open-world (even read-only), egress or host.*, or bound to a connection or a credential (taint) |
job.tainted, job.taint | whether the job read untrusted input, and why: [{source, tool, call}] (taint) |
job.labels.level, job.labels.level_name, job.labels.compartments | the job's confidentiality: the level's index in the tenant's list (so >= works), its name, and its compartments |
The usual operators and functions work (==, in, &&, size(), startsWith(), endsWith(), contains(), matches() with RE2), and has(args.x) asks whether an argument is there. all() and exists() read a list, one level deep, so that every recipient of a message can be checked:
oz0 policy refuse mail.send --when "!args.to.all(r, r.endsWith('@acme.example'))" --reason "mail stays inside"
A comprehension inside another is refused, and so are map, filter and exists_one, when the policy is saved; a call whose arguments hold a list or an object of more than 1,000 elements makes a condition with all() or exists() fail to evaluate (which never loosens a decision, below). A condition is at most 1024 bytes. Saving checks the syntax too:
rules[0].when: ERROR: <input>:1:11: undeclared reference to 'session' ...
A condition is decided twice: by the runtime in the job's workflow, with the arguments the pre_tool_call hooks left, before the call or its approval; and again at the edge, which reads the call's arguments from the command that schedules it (opening the tenant's payload encryption if it has one). A job whose runtime made a call the policy refuses fails at the edge with ToolPolicyRefused, and the audit log records toolpolicy.denied. The edge checks refusals only; whether a person approved a call is the job's own history.
A condition that cannot be evaluated, because an argument is missing or has another type, never loosens a decision: a rule that allows does not apply, and a rule that asks for approval or refuses does, saying why:
The call to web.fetch was not made: the tenant's tool policy refuses it: the tenant's rule web.fetch when args.url.startsWith('http://') (its condition could not be evaluated: no such key: url).
Write has(args.x) && ... when an argument may be left out. The job's runtime decides a condition with the rules the job started with, and the edge decides its refusals again with the policy as the edge has it now (which policy applies).
Conditions decide the calls of agents, flows and people; a hook's call, which the platform makes for every job, is decided by them at the edge (its arguments are {"event": {...}}, the event the hook is asked about), and a rule by caller never applies to it.
The catalog's WHY column adds a function's conditions, such as per call: Allow when args.repo.startsWith('acme/'), and the console's Policy › Rules edits them next to each rule. oz0 policy explain <id> names a rule's condition rather than deciding it: a condition is decided call by call.
Action classes
Every function has an action class, which conditions read as tool.class, from what its listing's annotations say: read (Read) for a read-only function, destructive (Destructive) for one that is not read-only and does not say destructiveHint: false (MCP's reading, so a function that says nothing is in it too), egress (Sends out) for one that reaches beyond the tenant (open-world) without being read-only, and write (Write) for the rest.
The class is the engine's own reading and differs from a function's type in two places: a function that says nothing is class destructive and type Unknown, and an open-world write a reviewed listing marks "oz0/class": "write" is class egress and type Write.
A function without annotations is destructive; a remote server's read-only hint does not count.
The host.* classes are for tools that act on a node's host: a tool says so with the annotation oz0/class, which counts only as the host form of the class its annotations give (read to host.read, write to host.write, destructive to host.destructive) and never for a remote server's tool.
A declared class never moves a function out of egress or into a milder class, so a tool cannot make itself read or write by what it says about itself.
It can move itself to the host form of its class, though: a condition tool.class == 'destructive' does not hold for a tool that declares host.destructive.
A rule about destructive functions should therefore name both forms: tool.class in ['destructive', 'host.destructive'] (and likewise for read and write), which is what --class writes.
OZ's own functions (oz.delegate, oz.ask_user and the rest) are classed by OZ.
oz0 policy ask '*' --class egress --reason "what leaves the tenant waits for a person"
oz0 policy ask '*' --class destructive --reason "destructive calls, host tools too"
Rule rule-new2 * Ask (when tool.class in ['destructive', 'host.destructive']) goes before rule ...
A rule by kind (--type destructive) covers host tools by itself: a host tool's kinds are its annotations'.