Preview, review and lint tool policy changes
For: tenant admins and governance reviewers.
Shadow mode and ends
A rule in shadow mode decides nothing: every call it would have decided runs as the rest of the policy says, and what the rule would have said (Refuse, Ask or Allow) is recorded with what happened (Allowed, Refused, or Approved or Denied by a person), the agent and the function's action class. Turn a rule on once its record says it decides as you want:
oz0 policy refuse github.merge --shadow --reason "watch before refusing"
oz0 tenant tool-policy shadow-report
RULE MATCH ACTION SHADOW CALLS AGREE
rule-5c1e9a0d77b2 github.merge Refuse yes 14 21%
Would have, per agent and action class (last 30 days):
RULE AGENT CLASS WOULD HAVE HAPPENED CALLS
rule-5c1e9a0d77b2 pr-reviewer Destructive Refuse Approved by a person 11
rule-5c1e9a0d77b2 pr-reviewer Destructive Refuse Denied by a person 3
AGREE is how often the rule's decision matched what happened: Allow with a call that ran, Refuse with one that did not, Ask with any answer of a person. The report names agents and action classes, never a person, and each call is in the audit log as toolpolicy.would_have; its JSON keeps the stored names (deny, ask, allow; allow, deny, approved, denied). Policy › Rules shows the same under Would have.
To turn the rule on: oz0 policy edit rule-5c1e9a0d77b2 --enforce, or switch its mode to enforce in the console. Turning a refuse or approve rule on applies at once; turning an enforced refuse into shadow mode is a loosening, which waits for another admin under four eyes.
A rule may end: --expires takes a time (2026-12-31T00:00:00Z) or a duration from now (72h). A week before the end, oz0 doctor warns (tool_rule_expiring) and the tenant's notification rules for the event tool_rule.expiring are told once; after it the rule acts on nothing, on nodes and at the edge alike, and is listed as expired. A shadow rule may end too.
Every rule carries its origin, preset: the name of the autonomy preset that wrote it, or none for a rule a person wrote. Three presets are the platform's and decide what a rule is: taint-default and label-egress make it a floor, and origin-default makes it the tenant's origin-default rule, which stands in for the built-in one for every function.
So a change of a rule's preset that makes it one of these, or stops it being one, is a change of what it decides: the rule is restamped with who proposed and approved it, and the change is a loosening, which waits for another admin under four eyes. The platform holds these rules to what their presets mean, and refuses a policy otherwise, with the reason:
- one rule of the
taint-defaultpreset at most, and one oforigin-default; the IDsrule-taint-defaultandrule-origin-defaultbelong to the rules of their presets (turn the built-in rules off with--taint-default offor--origin-default off); - a rule of the
origin-defaultpreset has the match*, and no plugin, origins or sequence: an origin rule of your own names its functions; - a floor is no origin or sequence rule;
- a sequence step's
labels.level, and a label-egress rule's level, are among the tenant's levels: a policy that names a level the tenant lacks is refused, and so are new levels that drop one a rule names (change the rule first).
The policy you send is the stored form, every rule in rules; floors and gates are the form nodes get, and a policy that carries them is refused.
Nodes get every rule with its ID, a rule saved before IDs existed with the one it is shown with, so a job's records name the rule that decided a call, not only its reason. A plugin's eval can ship rules of its own for its tests, and policy-path coverage then says which of their branches the cases reached; a tenant's own rules are never part of a plugin's coverage.
Approval deadlines and escalation
Every call the policy asks a person about (and every other approval) is denied at its deadline, never approved, and climbs the escalation ladder before it. The tenant sets the default deadline and ladder; a rule sets them for the functions it names, and wins over the tool's and the agent's own:
oz0 tenant tool-policy set --approval-deadline 8h # the tenant's default (24h when unset)
oz0 policy ask bank.pay --deadline 2h --confirm amount --reason "payments"
oz0 policy ask ops.restart --escalation 0.5:owner,0.9:role:admin --reason "restarts"
oz0 tenant tool-policy set --escalation off --escalation-reason "the on-call desk watches the inbox"
A deadline is 1m to 720h. A ladder has at most five steps (<fraction>:<who>, where who is deciders, owner, admins, role:<role> or team:<name>). --confirm <argument> makes a person type that argument's value before approving.
These are an Ask rule's settings: a rule that allows or refuses takes none. Only the tenant turns its ladder off, with a reason; a rule cannot, and a rule with a condition (when) cannot set approval settings.
Nor can an origin, sequence or floor rule, which only raises the decision: an approval reads its settings from the first rule without a condition over the function, whichever rule asked, so put them on such a rule (or set the tenant's) and every approval of the function gets them. A policy saved before this check keeps such settings until its next save, which refuses them, and they never applied.
How often a rule will ask
Before you add a rule that asks a person, see how often it would:
oz0 tenant tool-policy load
oz0 tenant tool-policy load --policy-file draft.json --days 7
RULE MATCH CALLS (30 DAYS) PER DAY
1 bank.* 84 2.8
2 mail.send 41 at most 1.4
It counts the tenant's tool calls of the last days whose function each Ask rule matches and no earlier rule without conditions decides. A rule with a condition gets an upper bound, since the audit trail keeps no call's arguments outside its private part. Policy › Rules shows the same beside each rule, and Count the prompts counts the draft you are editing. The counts are per rule, never per person.
Suggested rules
After a person approves a call this policy asked about, the card and oz0 approval approve suggest the narrowest rule that would have allowed exactly that call:
{"match":"team.note","action":"allow","when":"caller.agent == \"payer\" && size(args) == 1 && args[\"text\"] == \"hi\""}
It pins the function, the agent, the number of arguments and every value, so it never allows more than the call the person saw. Nothing changes until an admin reviews it and adds it: the card copies it as the rule, or as the command that adds it in shadow mode (oz0 policy allow team.note --when '...' --shadow), and an admin may watch it in shadow mode from the card.
Backtest a change
A shadow rule tells you what it would decide from now on. A governance replay tells you what a change would have done to the jobs that already finished: it decides them again under the change from their records, without calling a model or a tool, and gives each job's first decision that would change.
oz0 replay start --promote rule-5c1e9a0d77b2 --since 7d --wait
oz0 replay start --policy stricter.json --agent support
Each shadow rule under Govern › Policy › Rules has a Replay button that does the first. Read the self-check's rate and the coverage line before the figures: a job whose own record the replay cannot reproduce is left out, and so is everything after a job's first changed decision.
The policy lint
The policy lint reads the tenant's rules against its catalog and warns about rules that do nothing, or not what they seem. It warns and never blocks: every save prints its warnings and saves all the same (oz0 policy allow|ask|refuse|edit|remove|apply, oz0 tenant tool-policy set, and oz0 change approve for a change that changed the policy), Policy › Rules shows each warning beside its rule and a summary under the policy, and oz0 policy lint and oz0 doctor list them. Each warning names the rule, says why and gives one command that fixes it; the reason says what else would.
oz0 policy lint
12 rules checked against 48 functions:
rule-7d0e51a2c9f4 Hidden by an earlier rule: github.merge_pull_request
github.merge_pull_request (Refuse) never decides a call: rule rule-3f9c0a2b81d4, github.* (Allow), comes first and decides every call it would; to let it decide instead, move it before rule-3f9c0a2b81d4 (oz0 policy export -o policy.yaml, move the rule, oz0 policy apply -f policy.yaml)
fix: oz0 policy remove rule-7d0e51a2c9f4
rule-a41c07e2b9d0 Reads an argument the function lacks: github.create_issue
the condition of github.create_issue (Allow, when args.repository.startsWith('acme/')) reads args.repository, which github.create_issue does not take (it takes body, owner, repo, title): the condition errors for its calls, and an Allow rule whose condition errors never applies; oz0 catalog github.create_issue shows its input schema
fix: oz0 policy edit rule-a41c07e2b9d0 --when "<the condition, with an argument the function takes>"
| Warning | When |
|---|---|
| Hidden by an earlier rule | Details |
| Matches no function | No function in the catalog fits the rule's pattern, or none of those that do is of its kinds: often a typo. |
| Reads an argument the function lacks | The condition reads args.<name> (or args['<name>'], has(args.<name>), '<name>' in args, mcp.tool.arguments.<name>), and the input schema of a function the rule matches lists its arguments without that one. An Allow rule whose condition errors never applies; an Ask or Refuse rule then decides every call. A has() test over several functions warns only when none of them takes the argument, and so does a read the test guards (has(args.title) && args.title.startsWith('x'), has(args.n) ? args.n > 1 : false, !has(args.n) || args.n > 1), which never errors where the argument is missing. |
| Leaves out host tools | The condition names tool.class 'destructive', 'write' or 'read' without its host form ('host.destructive'), so host tools of that class are not decided by it. The fix writes the condition with both, as --class does; a condition holding a character you cannot see (a control character, a newline, a tab) is never copied into the fix, which then only says what to write. |
| Shadow rule never turned on | A shadow rule that has watched for 30 days or more, from when it was saved or the first call it recorded: turn it on (oz0 policy edit <id> --enforce) or remove it. |
| Unused for 90 days | No call in the last 90 days of a function the rule could decide, and taking the rule away would loosen nothing. A rule whose removal could loosen a decision is never called unused: a Refuse rule that works leaves no call to count, as its functions are never offered, and an Ask rule over functions the classes or the rules after it allow keeps them asked. Nor is a rule with approval settings. |
Hidden by an earlier rule
An earlier rule with no condition, no caller and no end, acting, takes in every function this one does by its plugin, pattern and kinds; or, by the catalog, each function this one matches today is decided by an earlier rule first. First match wins, so this rule never decides a call.
A rule that ends (--expires) hides nothing: the rule after it decides once it ends. A rule whose approval settings still apply is not called hidden either: the deadline, escalation and typed confirmation of the first rule without a condition that names a function, and the deciders and not-the-starter of every Ask rule that matches it, apply to its approvals whichever rule decides.
What it leaves out, and how far to trust it:
- The platform's own rules (they carry the platform's reason), floors such as the taint-default rule, origin and sequence rules, and rules that have ended are not checked. A plugin's suggested rules are.
- The catalog is what nodes listed of each installed plugin's functions, or its measured listing before a node did. A rule whose pattern may reach a tool no node has listed yet, or the platform's own functions (
oz.*), is not called hidden or matching nothing: the lint cannot know. - An input schema that takes arguments it does not list (
additionalProperties: trueor a schema,patternProperties,anyOf,oneOf,allOf,$ref) is not judged; one that lists its properties and says nothing more is read as taking those. - Unused is approximate. It counts the audit trail's tool calls by function, as the prompt counts do: a condition or a caller the trail does not record may narrow a rule further, and an earlier rule that decides a function for every call takes its calls. It reads 90 days, or what the audit trail keeps when the tenant's retention is shorter, and says so.
The server records when each rule took its present content when it saves it (createdAtinoz0 policy show --jsonandoz0 tenant tool-policy show --json; nodes never get it), and a rule saved less than 90 days ago, or saved before this release and not changed since, is never called unused. Its counts are per function, never per person.
The admin API's GetToolPolicyLint answers the same for a tenant, or for a draft policy without saving it; it takes the tool policy's own read permission (governance.read). SetToolPolicy answers with warnings, and ApproveChangeRequest with policy_warnings when the change it applied changed the policy.
Label presets
A label-egress rule refuses, or asks before, every call of a function that can send data out once the job read something at a level or above: "no egress after Confidential". It reads job.labels.level >= N && (tool.outbound || tool.class == 'egress'), with N the level's place in your list when the rule is added.
# refuse once the job read something confidential or above; it starts in shadow mode
oz0 tenant tool-policy set --label-egress confidential --reason "policy 4.2"
# or ask a person instead
oz0 tenant tool-policy set --label-egress internal:approve --reason "internal data leaves only with a yes"
- It is a floor, as the taint-default rule is: it takes no part in the first-match order and only raises a decision, so an allow rule before it does not get past it, and turning the taint-default rule off leaves it acting.
- It starts in shadow mode: it decides nothing, and the shadow report shows what it would have refused or asked. Promote it when the report says what you expect; demoting it again later loosens and needs a reason.
- Moving levels later does not move the rule: it keeps the number it was made with. Make it again after you reorder levels. Levels that leave its number past the last level are refused while the rule stands.
What never passes the tool gate (a harness session's own tools, a job's own sandbox) is narrowed at the egress proxy instead, by the tenant's egress list for the job's level (Taint and labels).
Rules from plain English
The setup assistant drafts a rule from a sentence. Say "never email outside example.com" in the chat drawer, and it writes the condition, checks it against the rule vocabulary, and proposes a plan with two calls:
SetToolPolicy, adding the rule in shadow mode: it records what it would have decided and decides nothing. Your sentence is kept, word for word, as the rule's reason; every other rule and setting of the policy stays as it is, and in its order. A plan never changes a rule that is on.StartReplay, the backtest: the last seven days replayed with the rule turned on.
+ rule-8e8ff424bb0e: shadow refuse mail.send when !args.to.all(r, r.endsWith('@example.com')) ("never email outside example.com")
+ replay the tenant's jobs of 7d with rule-8e8ff424bb0e turned on, and link the report here
Nothing changes until a person approves the plan. A drafted rule is a plain rule: it has no preset and is no origin or sequence rule (a preset can make a rule the tenant's origin default, which acts apart from the order of the rules), so adding it in shadow mode loosens nothing, and the platform refuses a plan its own comparison finds looser.
It applies when the plan is approved; the replay starts then, as the approver (it reads what jobs read and wrote, so the approver needs the content permission), and the plan links its report.
Turning the rule on later is a change of its own. With four eyes on, a loosening there (an allow rule acting) waits for another admin, and the person who proposed the rule may not approve it.
Without four eyes, an admin turns it on alone, as any rule; that may be the person who proposed it.
A sentence the rules cannot say is refused with the reason, never rewritten into something close: a condition reads only args, mcp.*, caller.*, tool.class, tool.outbound, job.tainted, job.taint and job.labels.*; of CEL's macros only has(), all() and exists(), one level deep (no map, filter or exists_one); at most 1,024 bytes. The platform checks the drafted rule again when the plan is filed. A sentence longer than a reason may be (500 characters) is refused too, not cut.
A drafted rule says where it came from: oz0 tenant tool-policy show and Policy › Rules show "drafted from plain English" with its change request. The rule is proposed by the person who asked and approved by the person who approved the plan, as any rule is. Turning it on keeps the link and its proposer, and records who turned it on as its approver. The link goes when someone changes what the rule decides in any other way, and stays when only its reason changes, or when the assistant drafts it again.
A drafted rule is a proposal a person reviewed, not a decision a model made: the model never decides a call. Read the replay before you turn the rule on.