How enforcement works
Every proxied call already gets a policy decision (layer 3). The enforcement mode says what that decision does: observe records it, enforce acts on it, require_approval pauses the call for a human.
Which mode applies to a call
The mode is resolved per call from the enforcement: section of your policy YAML, using the agent's environment and the tool being called. A per-tool rule always wins over the environment mode:
mode = enforcement.rules[tool] if the tool has a per-tool rule
= enforcement[agent.environment] else, if the environment is listed
= observe otherwise — the default, foreverNo enforcement: section at all means every call observes — layer 3 behaviour. This is what makes rollout graduated: staging: enforce with production unlisted enforces staging only; rules: {delete_repo: enforce} enforces one tool everywhere while both environments observe.
What enforce does
In an enforcing mode, a denied decision blocks the call before the upstream is ever contacted. The block is logged (status blocked), an alert fires, and the agent receives a machine-readable refusal it can handle:
{
"detail": {
"error": "blocked_by_sentnelops",
"reason": "<why policy denied this call>"
}
}Permitted calls stream through byte-identical, exactly as in observe mode — unless the approval gateway triggers.
The approval hold, end to end
A permitted call is held for a human when either trigger fires:
| Trigger | Recorded reason |
|---|---|
The tool's mode is require_approval — an explicit rule you wrote. | rule requires approval for <tool> |
Mode is enforcing and the tool's declared blast radius ≥ blast_radius_threshold (default 0.7). | blast radius <score> ≥ <threshold> |
The hold then runs as a fixed sequence:
| Step | What happens |
|---|---|
| 1. Pending row | A pending row is inserted into mcp_approvals — agent, tool, params, blast radius, reason, window. The row, not process memory, is the source of truth and the tamper-evident audit of who decided what. |
| 2. Alert out | An approval_request alert is enqueued (Slack / email if configured) carrying the approval id and the environment's channel override. |
| 3. Connection held | The agent's request simply waits inside the proxy. Nothing is forwarded yet. |
| 4. Human decides — on any replica | Approve or deny via POST /approvals/<id>/approve|deny. A decision in the same process releases the hold instantly via an in-memory event; the waiter also polls the row every second, so a decision landing on another replica still releases the call. One second of poll latency is noise against a window measured in tens of seconds. |
| 5. Outcome | Approved → the call forwards immediately, logged with reason approved by <approver>. Denied or window expired → the agent gets the same 403 blocked_by_sentnelops shape, with the decision and approver in the reason. |
Timeout is a dead-man's switch, implemented as an atomic claim: when the window expires, the waiter runs UPDATE ... SET status='timeout' WHERE status='pending'. A human racing the deadline — even on another replica — resolves to exactly one outcome; nobody can double-decide. And if the process restarts mid-hold, the held connection dies with it: the agent gets a connection error and the call is never forwarded. Every failure of the hold resolves toward deny, never toward allow.
The alerts pipeline
Blocks, approval requests, approval decisions, and fail-closed events raise alerts. Delivery is asynchronous and off the hot path: the proxy enqueues and returns; a background task drains the queue. An alert failure can never fail a customer call — it logs and moves on. Both channels are config-gated: with no Slack webhook and no SMTP host configured, events are logged locally and nothing leaves your infrastructure — zero egress by default.
Fail modes: choose your failure
Fail mode answers one narrow question: what happens when the policy engine itself errors — unreadable YAML, a bug in evaluation. It is not about process death (a dead gateway fails closed trivially: no proxy, no calls — see Trust & reliability). Each environment chooses explicitly, never silently:
| Mode | On policy-engine error | Choose when |
|---|---|---|
open | The call passes; a warning is logged. The shipped default. | Availability matters more — dev, staging. |
closed | The call gets the standard 403 and a fail_closed alert fires. | Security matters more — production. |
fail_mode: {production: closed} deliberately.