Skip to content

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, /mcp returns -32005. The console, the API, monitoring and prove keep working.
  • Availability. If the signed feed has revoked the profile governing a service, calls to it return -32004 with 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. The audit log in the console, filtered

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.

Denials grouped by tool and reason

Verify the chain yourself at any time:

prodpeek audit-verify
Output
audit chain OK: 1621 record(s) verified

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.