SentnelOpsSentnelOpsbeta
LAYER 4 · RUNTIME ENFORCEMENT

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 resolution — per-rule override wins
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, forever

No 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:

HTTP 403 — the exact response body
{
  "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:

TriggerRecorded 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:

StepWhat happens
1. Pending rowA 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 outAn approval_request alert is enqueued (Slack / email if configured) carrying the approval id and the environment's channel override.
3. Connection heldThe agent's request simply waits inside the proxy. Nothing is forwarded yet.
4. Human decides — on any replicaApprove 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. OutcomeApproved → 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:

ModeOn policy-engine errorChoose when
openThe call passes; a warning is logged. The shipped default.Availability matters more — dev, staging.
closedThe call gets the standard 403 and a fail_closed alert fires.Security matters more — production.
If you don't choose, you have chosen open. The default exists so that a policy bug can never take down every agent in every environment at once — but that is a default tuned for availability, not security. Set fail_mode: {production: closed} deliberately.