How approval decisions bind a call
For: people running and reviewing agents.
What happens
- The agent calls
acme_github.merge. Before the call runs, the job asks for a decision and waits. It shows up on the console's Approvals page, on the job's page, inoz0 approval list, and in the job's live stream. - An operator, admin or owner of the tenant approves or denies it, with a comment if they like.
- Approved: the call runs and the agent goes on. Denied: the call is not made, and the agent is told who said no and why, for example
The call to acme_github.merge was not made: user:kevin denied it: not before the release., so it can explain or try something else. - No answer by the deadline counts as a no:
nobody answered. The deadline is 24 hours unless the tool, the agent or the tenant sets another (see Deadlines), and before it the escalation ladder tells more people. A deadline never approves.
A person's yes is one check among several. The call still meets the ones after it: the edge's checks, and on the node the approval's binding, the kill switches and the secret scan, any of which can refuse an approved call. How a tool call is decided lists every check, in the order the code runs them.
One turn waits as a whole
A model often asks for several calls in one turn. When one of them waits for a person, the turn's other calls that do not only read wait with it: none of them runs before the decision, and none runs at all when the person says no. The model then reads, for each of them, The call to acme_github.comment was not made: it came in the same turn as a call to acme_github.merge, which was not made (user:kevin denied it: not before the release). Approved, they run.
Read-only calls of the turn run as they come.
A call waits for a person when its function is in requires_approval, when the tenant's tool policy sends it to one, when a pre_tool_call hook matches it, since a hook may escalate, or when it would complete a loop and the agent's on_loop is wait (with stop, the turn's other calls are not made either, since the job ends).
A delegation (oz.delegate) or a quarantine job (oz.quarantine) of the turn waits in the same way: its child job starts only once the turn's decisions are made.
The same holds for every kind of agent: Pydantic AI and the OpenAI Agents SDK, a remote agent (the calls of our tools its provider's turn waits for run together, in the same way), a harness session (while one of its calls is being decided, the session's other calls that do not only read wait, from the moment each arrives, and are refused after a deny, or when that call is refused for a secret value in its input), and an ACP session, whose MCP tool calls wait the same way.
A decision is bound to the call
A person approves the call they saw. The decision is bound to that call.
Bind the arguments
Each approval carries args_sha256, the SHA-256 of the call's arguments as canonical JSON. oz0 approval approve and the console send it with the decision, and the job refuses a decision for other arguments (approval a-... is not for the arguments sha256:... you decided on: look at the call again). From your own app, pass args_sha256 in DecideApproval.
The digest is the call's content too (a guess at small arguments can be checked against it), so callers without the content permission, and those a label policy's wall closes the job to, do not see it and decide without it: a decision from them that carries one is refused.
Control access to the details
An approval or a question in a turn of someone's private chat shows its details only to the tenant's operators who hold the content permission, who decide it; a reader with the content permission sees it without them.
A wall that closes the job to a person closes its approvals' details to them too, whether or not they decide it: they may approve or deny it, but not edit the call, type its confirmation or answer the question, and approving offers no "always allow" rule built from the call's values.
Read the full arguments
The preview is cut at 2 KB, and a cut one says so ([cut; approval args shows all] in the CLI, Show all arguments on the card). oz0 approval args JOB APPROVAL prints the whole arguments and their digest; it needs the content permission.
Check for changes before execution
When the arguments change while the person decides, or a later hook changes what an escalation approved, the person is asked again about the call as it is now.
The approval also records the function's snapshot and the tenant's tool policy, as the job's node has them when the person is asked.
When either changes while the person decides (an admin changes what a rule of the tool policy decides, or the plugin is upgraded), the person is asked again; rewording a rule's reason or rationale does not ask again (the policy's version covers what the rules decide).
They are read again right after the decision, and the node checks them once more before it makes the call.
That last stretch is short for a call alone, but an approved call whose turn still waits for another call's decision waits with it, which can take as long as that decision.
When either changed by then, the node does not make the call, and the agent reads that it may call again, which asks again.
A job without a home node, such as a harness's own tools, binds to the policy it started with (which policy applies).
An approval a person gives when a pre_tool_call hook escalates is bound the same way, and asked again the same way when the snapshot or the policy changed while the person decided.
Bind ACP input, kind and paths
An ACP agent's call is decided as it asked: its input, its kind (edit, read, execute...) and the paths it names in ACP's locations, which the person sees as locations beside the input (an allowed call lets the session's file system serve those paths without asking again). An agent that sends another input, another kind or another path while the decision is pending has the call refused (the agent changed the call while it waited for a decision); one that does so after the job allowed the call is stopped (HarnessChangedAfterApproval).
Approvals for changesets
A changes approval (kind changes) is the same kind of approval for a file-writing agent's changeset: the call it decides is the one that applies the patch, such as oz0_github.push_changes, and its digest covers the patch's own digest, so a yes counts for exactly the diff shown. The dialog shows the changeset's files and patch above the buttons; oz0 changes approve <job> decides it from the CLI.
A changeset that touches a plugin's tests or rules then waits for a second approval of kind protected_paths, of the same call and patch, whose card says this change touches the plugin's tests or its rules with those files first: only another person signed in with governance.write, who neither started the job nor approved its changes, approves it, never a key, an app, an MCP client, a chat or a policy (protected paths).
Widenings of what the tenant's sandboxes may reach are not approvals of a job but change requests, on the Changes tab of the same page, for the tenant's admins. See Reviewed changes.
The wait is part of the job's durable state in Temporal. It costs nothing while it lasts, and it survives restarts of servers and nodes: the job goes on wherever its agent can run once someone decides.
Checked again after a long wait
An approval or a question that waited longer than the tenant's threshold (an hour by default) is checked again when it resolves, before its call runs:
Checks on the edge
When the person decides, the edge checks that the job's plugin and the call's tool are still installed. It also checks that the job's starter still holds what let them start it:
- A person is still a member with at least the operator role, and their account is not disabled or removed.
- A person who started the job through an app still has authority through that app as it lets them act now. Removing the app or taking
jobs.startfrom it refuses the yes. - A person who started it from an MCP client still has authority through the client's grant as it lets them act now, with their membership in the tenant and never their cluster role. Revoking the client, the grant expiring, the tenant's client policy no longer allowing it or the person leaving the tenant refuses the yes.
- An API key is not revoked or expired.
- An operator is still active.
- A routine is not paused or deleted.
Checks on the job's node
Before the call, the node reads the job's grant to call another plugin's function again (granted uses), checks that the function is still installed there, and checks that the tenant's tool policy as it is now, conditions included, still lets the call through.
When a re-check fails
A failed check turns the yes into a denial the model reads, with why: The call to payments.refund was not made: the platform refused it: the re-check after a long wait refused it: the job's starter user:ada no longer holds the operator role in tenant default. The person who approved is told the same, and it is audited as run.recheck.failed. An agent can fail instead:
on_recheck: fail # refuse (the default) or fail
Set the threshold per tenant, from one minute to 30 days:
oz0 tenant tool-policy set --recheck-after 30m
(A policy changed while a person decides asks the person again whatever the wait, as above; the re-check decides the call under the policy the job's home node has now, and only refuses: which policy applies.)
While the edge cannot read who started the job, the yes is not sent unchecked: it is refused as unavailable, and the person decides again shortly. While the edge does not hold the tenant's tool policy, it checks again every approval that waited longer than a minute, the shortest threshold a tenant may set.
What the card shows
The card is built from the call itself, never from what the agent says about it:
The card shows the function, its snapshot hash, the arguments and their digest. It describes what the call does from the tool's annotations: Read, Destructive or Unknown (it says nothing about what it does, or changes something without saying whether it deletes or overwrites, as the catalog shows it), and Reaches outside the tenant for a function that does not say openWorldHint: false.
A write that destroys nothing shows no word of its own: the card does not say Write or Sends out, the policy's other two types, and a Sends out function shows as reaching outside the tenant.
A chat thread's post says the same in a line of its own (reads only, DESTRUCTIVE, reaches outside the tenant). Approvals asked before this release show a write silent on destructiveHint as Destructive, as MCP reads it.
Argument roles and origin checks
The card shows the arguments the tool's input schema marks as a recipient, an amount, a host, a path, a payee or a repository ("x-oz0-role": "recipient", or format: email, hostname, uri), each with where its value came from: the request, the starter's identity, a tenant record, something the job read, or nowhere it read. A value whose origin is below a tenant record is out of scope (the agent took it from somewhere else, such as a page it read): the card warns about it.
Values are compared exactly, as units (an address, a host, an IBAN and so on), so a value that is only part of a longer one is out of scope too.
Jobs that started before this check came compare against the job's input text instead.
Those started on this release compare whole values: an address, a host or a path counts only where nothing continues it on either side, a letter, digit or mark of any script included ([email protected] is not in [email protected], kå[email protected] or o'[email protected], nor bank.example in bigbank.example, bänk.example or api.bank.example, nor report.txt in /home/kevin/report.txt or C:\Users\kevin\report.txt), and an amount only as the same number (1500, 1,500 or 1500.00, never part of 21500).
A job already running when the server was upgraded keeps the comparison it started with, a match of any part of the text, until it ends.
Bounds on argument checks
Every value of a marked argument is compared, not only those the card shows: the card shows twenty rows, and says how many values of an argument it leaves out (open the full arguments to see them); past the first 200 values of one argument the rest are not compared, and the argument counts as out of scope and new to the job; so does an argument past the first 1,000 values of the whole card.
The job's input is read once per card, so the check takes about the same time however many values it holds; the rare value that its reading cannot place (such as one that holds a space) is looked for in the input itself, at most 32 of them per card and no more than eight readings of the longest input a job takes in all, and one past those counts as not in the input, so a card's check takes a bounded time whatever the input holds.
An array whose items carry the role (items: {format: email}) is marked as the array is.
Jobs that started before this release look at the first twenty values, as they did.
Other details on the card
The card also shows:
- what the agent wrote before the call, in a labelled The agent says box apart;
- the level the agent ran at when it asked (
L2for every agent until autonomy levels come); - the ladder's step and when the next one comes;
- a warning when the tool did not keep to what it declares: a function that says it stays inside the tenant (
openWorldHint: false) whose sandbox was refused a connection earlier in the job. It is also in the audit trail (tool.effects_mismatch) and inoz0 doctor. - who asks: the origin check (
asked_by: origin), when an argument's value is below the floor its schema or a tenant rule sets (where arguments came from), or a sequence rule (asked_by: sequence), when the call comes after one a tenant rule names; the card then says which argument, its role, its floor and its origin, or which rule and the call before, never a value. An agent's changes policy (review: policy) does not stand in for these: a changeset whose apply call the origin check or a sequence rule sends to a person waits for one; - who asks: the tool's own
requires_approval, the tenant's policy, or the taint (asked_by: taint): the job read untrusted input, and this function can send data out (the taint-default rule). The card then lists why the job is tainted, each reason a source, a function and a call, such asopen_world web.fetch (call 12), never what the job read. The trace gives the gated step the same reason.
What lies outside the request
Three more facts flag what the job's request did not ask for, by name and class only, never a value:
- Not traced to the request. Each marked argument whose value the origin check found below the request (at the starter's identity, a tenant record, something the job read, or nowhere), with the floor a rule needs.
- New to this job. A recipient or host argument whose value is neither in the job's input nor in any call the job made before. The job's record of values starts again when a long job continues as a new run.
- A class above the job's so far. The call's action class (read, write, egress, destructive, or a
host.one) when it ranks above every class the job's calls used before, such as the first destructive call in a job that so far only read. What a request "needs" is not written down anywhere; the job's own calls are the closest record there is, and the card says it that way.
They show in the console's card, the chat drawer, the job's page, the chat threads' posts and the email notices. A flag never blocks a call; the tool policy does.
Decision controls
Deny is as prominent as Approve, and the focus starts on Deny for a destructive call, one with an argument out of scope, one new to the job or one above the job's class so far.
A tool or a tool-policy rule may ask for a typed confirmation of one argument for high-risk calls (approval: {confirm: amount}): the person types its value before Approve works (oz0 approval approve ... --confirm 250), and a chat thread offers only Deny. A text is typed as it is; a number, a list or an object as compact JSON with its keys sorted (250, ["a","b"], {"currency":"EUR","sum":250}), with letters outside ASCII as themselves ({"name":"Åsa"}) or as JSON's \u escapes ({"name":"\u00c5sa"}), the same on the edge and in the job.
Declared effects
The platform compares a tool's declared effects with the hosts its sandbox was refused. It does not compare those declarations with the tool's file writes. Review the actual changes before applying them.
The recorded autonomy level is L2; other autonomy levels are unavailable. An approval still applies only to the operation and scope shown on its card.