Approval Desk: modes and policies
The baseline matrix explains defaults. It does not explain every decision. Nia now compares two plausible rules: “user policy always wins” and “yolo allows everything.” Both are too broad.
The actual resolver gives special treatment to denial, then distinguishes explicit tool policy from an override-only prompt and from the mode fallback.
Follow the implementation’s precedence
First, the tool’s declaration is evaluated against the arguments and normalized. A declaration can be a bare tier, an object carrying a tier and additional fields, or a function returning either form.
The resolver then identifies the effective user policy. Its key is the declaration’s policyKey, if present, otherwise the invoking tool’s name. If a distinct keyed policy is absent or invalid, the invoking tool’s policy is the fallback.
After that lookup, the following order applies:
| Order | Condition | Result |
|---|---|---|
| 1 | Tool decision says deny | Deny from tool policy. |
| 2 | Effective user policy says deny | Deny from user policy. |
| 3 | Mode is yolo | An explicit tool policy wins; otherwise use the effective user policy or allow. Override is reported false. |
| 4 | Non-yolo decision has override: true | Explicit tool allow remains allow; otherwise prompt. Source is tool and override remains true. |
| 5 | Tool decision explicitly says allow or prompt | Use that tool policy. |
| 6 | A valid effective user policy remains | Use that user policy. |
| 7 | No earlier branch decided | Compare the tier with the mode matrix. |
This table describes resolveApproval(). The wrapper can add provider safety requirements and has a specific forwarded-dispatch prompt predicate, examined later.
An override-only prompt is a decision such as { "tier": "exec", "override": true }, with no explicit policy. It prompts in a non-yolo mode even if a user allow would otherwise admit the call. In yolo, the override-only prompt is ignored. By contrast, { "tier": "exec", "policy": "prompt" } remains a prompt in the resolver’s yolo branch.
Even override: true is not literally “always force a prompt”: a decision that also explicitly says allow returns allow, after the deny checks. Read the fields together.
Work through four policy conflicts
Prediction: for each case below, name the winning source as well as allow, prompt, or deny.
| Case | Important inputs | Recorded resolution |
|---|---|---|
approval-tool-deny-wins | Tool write-tier deny; user allow; yolo | deny, source tool, reason Fictional desk is closed |
approval-user-deny-wins | Tool read-tier allow; user deny; yolo | deny, source user, policy key seed_note |
approval-explicit-tool-prompt | Explicit tool prompt; user allow; yolo | prompt, source tool, override false |
approval-tool-allow-before-user-prompt | Explicit tool allow; user prompt; always-ask | allow, source tool |
The last row is easy to misread. A valid user prompt is not a universal veto over an explicit tool allow. A valid effective user deny is checked earlier and does stop it. Consequently, the short settings description cannot replace the resolver’s actual order.
These records also show why denial provenance matters. denyError() distinguishes a tool-owned refusal from a user-policy refusal. A tool-policy error can include the tool’s reason. A user-policy error names the effective user key. The stock error’s repair hint is not a workbook instruction to remove the policy.
A policy key is a lookup identity, not another cumulative grant
Compare these complete fictional resolver inputs. They are teaching data, not configuration to install.
{
"tool": "write",
"approval": { "tier": "exec", "policyKey": "seed_slot" },
"mode": "yolo",
"userPolicies": {
"seed_slot": "not-a-policy",
"write": "deny"
}
}
This is the input shape of approval-policy-key-fallback. The keyed value is invalid, so the valid invoking-tool policy supplies the denial.
Recorded answer: deny, source user, policy key write.
Now inspect approval-policy-key-specific:
{
"tool": "write",
"approval": { "tier": "exec", "policyKey": "seed_slot" },
"mode": "always-ask",
"userPolicies": {
"seed_slot": " ALLOW ",
"write": "deny"
}
}
Recorded answer: allow, source user, policy key seed_slot. The valid device policy replaces the invoking write fallback, including that fallback’s deny. The resolver is not performing an intersection of every policy entry in the record.
That does not contradict deny precedence. The write entry is no longer the effective user policy for this keyed decision. A tool-owned deny or a deny at a later applicable inner gate is a different matter.
Keep normalization claims local
normalizePolicy() accepts strings, trims them, lowercases them, and recognizes only allow, deny, and prompt. Other values do not become policies at that resolver layer. A missing or invalid tier in a returned decision defaults to exec. A function-valued declaration can still throw; the general resolver does not promise to catch every throwing declaration and convert it into a safe result.
These facts are not a claim that every raw settings object is validated everywhere. The wrapper separately uses raw own-property presence when deciding whether an inner prompt is explicit. An invalid entry can therefore be ignored as a policy by the resolver while still mattering to that later predicate. Nor does resolveApproval() normalize arbitrary invalid mode names into one of the three supported modes.
Use the documented record shape and exact mode values. Do not intentionally depend on malformed settings as a safety mechanism.
Read the result as an explanation
A resolved result contains policy, tier, and override, with provenance fields where the selected branch supplies them. policyKey is useful when user policy won. reason is optional, not a complete audit record. For example, ignoring an override-only critical prompt in yolo can also omit its reason from the resolved result.
Paper checkpoint: does write: deny necessarily deny a call whose declaration has policyKey: seed_slot? Worked answer: no. A valid keyed policy is selected first; the invoking write policy is consulted only when the keyed value supplies no valid policy. Then the ordinary precedence applies.
Failure boundary: an allow result is permission at this resolver, not a successful call, a persisted human answer, a domain grant, or proof that another gate agrees.
Source anchors: packages/coding-agent/src/tools/approval.ts — normalizePolicy, normalizeDecision, resolveApproval, requiresApproval, denyError; the fixture references identify the resolver section as lines 104–233. Recorded cases are in proof/report.json, under the Approval Desk IDs above. Existing contract coverage is in packages/coding-agent/test/tools/approval.test.ts.
Tool permissions and approvals · Source chapter: permissions/approval-desk-modes-and-policies. Original evidence remains scoped to its recorded snapshot.