How a decision is made¶
Every call an agent makes goes through one function. It is 200 lines of pure Python, it imports nothing outside the standard library, and you can read the whole thing in one sitting. That is deliberate: a line in your audit log should be reproducible by someone who does not trust us.
The order¶
1. Is the request well-formed? → malformed_request
2. Does the tool name match the service? → namespace_mismatch
3. Is this principal known? → unknown_principal
4. Does this key hold this service? → service_not_granted
5. Does the profile explicitly deny it? → tool_explicitly_denied
6. Is it absent from the allowlist? → tool_not_in_allowlist ← deny by default
7. Do the arguments satisfy the rules? → argument_constraint_violated
→ allowed_by_profile
Step 6 is the one that matters. A tool nobody wrote a rule for is refused
because it is not there, never because someone remembered to forbid it. A
vendor shipping delete_everything tomorrow is refused tomorrow, by a policy
written today.
Two independent paths¶
visible_tools() shapes what an agent sees. decide() judges what an agent
calls. They are separate implementations, and a test asserts they do not share
code.
That is not neatness. CVE-2026-46519
was a gateway where a filter served both: a tool hidden from tools/list was
still callable by its raw name. Hiding is not refusing, and a single
implementation is how the two get confused.
So: call a denied tool by raw name and it is refused. Call one that was never
advertised and it is refused. prodpeek prove does exactly this, hourly, against
your own instance.
What is checked before the engine¶
Two gates run first, and both can only ever refuse:
- Licence. With enforcement on and no licence,
/mcpreturns-32005. The console, the API, monitoring andprovekeep working. - Availability. If the signed feed has revoked the profile governing a
service, calls to it return
-32004with the reason and a link to the advisory.
Neither can turn a deny into an allow. There is no branch anywhere in the runtime that does.
The error codes¶
| Code | Means | What to do |
|---|---|---|
-32003 |
The policy denied it | Read data.reason. Adapt, or tell a human what you needed |
-32004 |
The service is unavailable — profile revoked, stale or drifted | Stop. This needs a human; retrying will not help |
-32005 |
The instance is not licensed | Stop and say so |
-32002 |
Wrong door | Use the right key for the door |
-32001 |
Unauthorized, or the key expired | Get a new key |
-32010 |
The upstream itself failed | The policy allowed it; the system did not answer |
Every decision is audited¶
One append-only, hash-chained line per call — allowed and denied alike, including refusals that never touched the network.
{"ts":"2026-09-24T09:14:22Z","allowed":false,"reason":"tool_explicitly_denied",
"principal":"anders","service":"ejd-coolify","tool":"ejd-coolify__deploy_application",
"profile":"coolify/read-only","profile_revision":1,"tier":2,
"rule":"tools[deploy_application].verdict=deny","forwarded":false,
"hash":"9f3a...","prev":"7c1e..."}
No response bodies, no secrets, no exceptions to the rule that a call that
happened leaves a record. 
Audit → Denials groups every refusal by tool and reason — a pile on one tool is a profile's to-do list rather than a mystery.

Verify the chain yourself at any time:
The console's word for it is not the proof. That command is.
Read it yourself¶
The whole decision surface is
src/prodpeek/policy.py,
and the golden decision table it must satisfy is
tests/decisions.yaml
— one row per call, one verdict, executed by pytest. That is the spec that cannot
drift.