How the runtime enforces tool policy
For: tenant admins and governance reviewers.
No audit entry, no call
Before the edge passes a job's tool call on to be scheduled, it appends run.tool_intent to the tenant's audit trail: the job, the call, its function and plugin, and the call's verdicts (each sensitive argument's origin and floor, the sequence rules that decided it), never an argument.
When the entry cannot be written, the call is not scheduled: the node's task is offered again until it can be.
On an edge that reaches the database through management, the entry is written by management or kept on the edge's disk before the call goes on, and reaches the trail when management is back.
Digests in the trail are keyed per tenant, so reading the trail does not let anyone test a guessed value.
oz0 evidence verify rebuilds every job's origin index from the exported trail, decides every recorded verdict again offline, and compares the values each call's record checked with the arguments the call was made with (the run receipts of the threat model).
Words the policy does not know
Saving refuses a policy whose words it does not know. A policy that reaches a node or the edge another way (an old export, a store edited by hand) is read strictly: an action that is not allow, approve (Ask) or refuse, in a default, a rule, a floor or an origin or sequence rule, refuses, rather than falling back to the default or being left out. oz0 policy export refuses a stored rule whose origins or sequence do not read, since written without them it would apply again broader than the tenant's.
Calls that carry a secret
The model never gets a secret's value: plugins get their secrets by name (see Secrets).
A model that read one somewhere (a file, a tool's answer) could still write it into a call's arguments, and so send it where no plugin should.
Before each call of an agent, a flow or a person, the runtime looks for the values of all the tenant's secrets in the arguments, each written as it is, in base64 or base64url, in hex, or percent-encoded.
A call that carries one is not made.
The scan runs at a different point for each call type:
- For an MCP function, it runs on the home node when the call is made, after approval and the edge's checks. A person can therefore approve a call that the scan later refuses.
- For a harness's own tools, it runs before the hooks (the call-check order).
- For platform built-ins whose arguments reach another job, a person or the edge's records, it runs on the home node before the built-in acts. The node also refuses the call if it cannot read its fingerprints.
The built-in check covers calls from Pydantic AI, OpenAI Agents SDK, JSON-lines harnesses, ACP agents, Ask and flow steps. It covers board notes, delegation tasks, hand-over notes, questions to people or colleagues, outcomes, update notes, quarantine tasks, failure messages, and an event wait's subject and filter, which the edge stores and lists with job waits:
web.post was not called: it carries the value of the tenant's secret GITHUB_TOKEN, base64-encoded, which a tool call never needs (the platform passes secrets to tools by name); remove it from the arguments
The audit log records secrets.refused with the job, the function and the secret's name.
Nodes never get the values for this: the edge sends them salted SHA-256 fingerprints, which the runtime compares windows of the arguments with.
Every byte of the arguments is scanned, up to 16 MiB of text in a call; a call with more is not made (pass a large content as a file).
Until the fingerprints have reached a node, or while its copy cannot be read, a call whose arguments hold text is not made, since the node cannot tell whether it carries a secret; the reason says so.
Values a guess would find are not looked for: whoever holds a node's fingerprints could test likely values against them. The edge leaves out values shorter than 16 bytes and values a guesser's lists and rules would reach in fewer than about 2^64 tries.
The edge estimates each value's strength the way such a guesser builds candidates: words and made-up words with their capitals and digits for letters, letters of any script beyond ASCII at a bit each, keyboard runs and zigzags, sequences and two patterns woven together, years, dates and times, and repeats. A password or a pass phrase in any language is therefore left out however long it is.
The edge logs which secrets it left out, and setting one says so (oz0 secret set, the console's Secrets tab and the admin API's note).
A random token of 16 bytes or more is looked for; the estimate errs low, so a rare random value may be left out too.
A value split across two arguments, or encrypted, is not found.
A plugin's settings, such as a search backend's address, are not secrets and are not looked for.
A harness session's own tools (Claude Code's Bash or WebFetch, an imported plugin's MCP servers) are scanned when the session asks whether it may run a call, and a call that carries a secret is refused the same way.
Tools the harness runs without asking, because its own settings allow them, are not seen. Hooks' calls are not scanned: their event is what the platform sends them, such as a tool's result for a post_tool_call hook, so a hook that redacts secrets from results still gets them to redact. An event source's normaliser gets the delivery as it came.
The scan runs beside the job, not in its way: a mebibyte of arguments takes about a fifth of a second. The sandbox's egress proxy scans plain-text HTTP requests the same way (see Security).
Where it applies
Nodes get the policy on their control stream and apply it to every job that starts after their next heartbeat, within about ten seconds.
A node never decides by the defaults in place of the tenant's policy: until the policy has reached it (a node that just joined, or one that started again, since a starting node removes its last run's copy), or while its copy cannot be read, no job starts there (the start is tried again, on that node or another) and the checks that read the policy refuse, saying so in the reason.
A job reads it once, when it starts, and keeps what it decided in its history, so a job that runs for days keeps the policy it started with, and a replay on another node decides the same.
What a change does to a running job anyway (an approval asked again when the policy's version changed, the re-check after a long wait, the edge's check of conditions and refusing floors) is said once, in which policy applies; every check a call meets, in order, is in how a tool call is decided.
In a tenant that set the Reaches outside default to Refuse and the Destructive default to Allow (oz0 tenant tool-policy set --open-world refuse --destructive allow; the shipped defaults are Allow and Ask), a refused call looks like this to the agent, and in the job's trace:
The call to team.fetch was not made: the tenant's tool policy refuses it: it is open-world. To fix: add openWorldHint: false to team.fetch if it stays inside the tenant; or let it run: oz0 policy allow team.fetch.
A job that started before this release keeps the older command in the message, oz0 tenant tool-policy set --rule 'team.fetch=allow' --keep-rules, which still works: its agent read it, and its history keeps it.
An agent that loads its tools lazily is decided the same way: its oz.call_tool call is unwrapped before the policy sees it, so every rule, condition and approval applies to the real function, and its oz.find_tools never offers a function the policy refuses.
The message names the rule that decided and how to change it: the annotation the function should declare, or the rule that lets it run. A job whose call was refused, such as a tool called by hand, fails with the same message.
The catalog shows what the policy does with each function, and why (in the same tenant: under the shipped defaults, team.fetch would be Allow and team.wipe Ask):
oz0 catalog
FUNCTION TYPE POLICY WHY
team.fetch Read Refuse it is open-world
team.hello Read Allow the tenant's rule team.hello
team.wipe Destructive Allow it is destructive
Functions called as hooks are calls too, and the policy applies to them. A hook whose function the policy refuses is not asked: it counts as a hook that failed, so its on_timeout decides (deny, unless the hook says otherwise), and the job's worker log says why. A hook whose function needs approval waits for a person before it is asked; no answer, or a denial, also counts as a failed hook. A plugin's own requires_approval does not apply to its own hooks.
No budget refuses a single tool call, and the policy's spend block decides none. At the job tree's soft wall, or once a person denied a forecast's early request, every tool is withdrawn and the agent can only answer; a flow tool's child start meets the tree's budget at the edge; and a tool's own model calls are budgeted at the gateway as the job's (budgets and tool calls).
--harness-extras) are the plugin's tools, and a session's call mcp__<tool>__<function> is decided as <tool>.<function>, by the same rules, floors, origin checks, hooks and approvals.The session spells a function's name with every character outside letters, digits, _ and - as _ (issues.delete is mcp__jira__issues_delete), and the call is decided as the one function that spelling names; two functions of one server that a session would spell alike are offered to no session.A session's MCP name that is no function of a server the session was given is refused, and so is such a call under a clearance or a wall, since the session reads its result before any label could hold it back.A session reads no server's resources, prompts or instructions: Claude's resource tools are refused and the servers offer sessions their approved functions only, so everything a session gets from them passes these rules.A harness's own tools, such as Claude Code's Bash, have no annotations; their calls pass hooks and approvals as before, and its shell commands meet the command rules too (see Harnesses, and harness tools for every check they meet).The policy's version
An approval is bound to the policy's version as the job's node has it when the person is asked: a hash of what the policy decides. When the version changes while the person decides, the person is asked again, and the node does not make an approved call whose version changed after that.
The version covers the types' defaults, every rule that acts (shadow rules decide nothing and are left out), the floors and the origin and sequence rules, and the approval deadline and escalation ladder. Of each rule it covers everything that decides a call: its pattern, action, condition, callers and types, deciders and four eyes, deadline, ladder and typed confirmation, mode, end, preset and ID.
It leaves out what decides nothing: the rule's reason and rationale, who proposed it and who let it act, the setup assistant's and an install's marks on it, and why the escalation ladder is off, and the blocks that decide no call (spend, labels, offload and tasks).
So an admin may reword a rule while approvals wait, and nobody is asked again; the audit trail keeps every version of the words with the change that wrote them.
The version names its scheme: sha256v2: and a SHA-256 for this one; an approval asked on a release before it is bound to sha256:, a hash that covered the words too, and it is compared under that one (see Upgrades).
A rule stored before rules had IDs is shown with an ID derived from what it says, its words included, until the policy is saved once; rewording such a rule changes its ID and the version, that once.
The edge records another version of the policy in each start's authority envelope and in decision records; it hashes another form of the policy, and the two are not compared.
Hooks come before the rules
A call goes through its checks in this order: a refusal of the tenant's policy that needs no arguments (the call is not made and no hook is asked), then the pre_tool_call hooks, then everything else the policy decides: conditions on the arguments, floors, where arguments came from, sequence rules and approvals.
A harness's shell command meets its command rules after its hooks too, so a hook's modify changes the command the rules then read. So a hook's allow skips no rule, and a hook's modify changes the arguments the conditions then decide on.
A person's yes to a hook's escalation is not the approval a rule asks for: the call still waits for that one. A hook that may only escalate, such as the intent check of ai.oz0/guardrails, can therefore send a call to a person but never let through a call the rules would stop.
This is the order of the policy's own steps in the job's workflow. A call meets more checks than these, before them and after them, from the tools a job still has to the node that makes the call: the secret scan and the kill switches, for one, run on the node after the approval. How a tool call is decided lists every one, in the order the code runs them.