Skip to main content

sentinel

Human-in-the-loop approval for autonomous agents. Asymmetric timeouts, per-argument risk, and a gate that cannot fail open.

PyPI CI Python 3.11+ License: MIT Coverage 98% Types: strict OpenSSF Scorecard


The problem with one timeout

Every approval gate needs a timeout, because a reviewer eventually goes home. But a single timeout policy is wrong in one direction or the other:

  • Timeout means deny? Your agent stalls overnight on a file write nobody needed to see.
  • Timeout means allow? Your agent drops a production table at 3am because nobody was awake.

Existing agent frameworks mostly pick one, and several have no timeout at all — a request simply hangs, indefinitely, with no record that it is waiting.

The fix: the timeout direction follows the risk

Risk Ask? On silence Notes
LOW no reads, listings. Nobody is interrupted
MEDIUM yes allow recoverable writes proceed unattended
HIGH yes deny hard to undo, or touches secrets
CRITICAL yes deny plus: approval requires a written reason
pip install sentinel-gate    # the import name is `sentinel`
from sentinel import Gate, ToolCall

gate = Gate()

gate.decide(ToolCall("write_file", {"path": "notes.md"})).permitted   # True  — proceeds
gate.decide(ToolCall("delete_file", {"path": "prod.db"})).permitted   # False — waits, then denies

Risk is a property of the call, not the tool

read_file is harmless. read_file on ~/.aws/credentials is exfiltration. A per-tool risk table cannot tell them apart, so risk is computed from the tool and its arguments:

$ sentinel check read_file -a '{"path": "README.md"}'
risk:  low
result: ALLOWED without asking anyone

$ sentinel check read_file -a '{"path": "~/.aws/credentials"}'
risk:  high  (escalated from low)
rule:  sensitive-path
result: requires approval
        on silence:  deny

Per-tool risk maps are common. Per-argument escalation is not, and it is where the interesting attacks live.

Some calls are not approval questions

$ sentinel check bash -a '{"cmd": "rm -rf /"}'
result: BLOCKED — never put to a human

Asking implies the answer could be yes. A denylist is checked before a reviewer is contacted, and a blocked call is recorded as BLOCKED rather than DENIED — no human time was spent, and the reason is mechanical rather than judgement.


It cannot fail open

This is the part worth reading the source for.

A widely used agent CLI had a reported bug where killing the permission-check process caused the gated tool to be allowed. The failure of the control became the permission. That is the single worst default available in this problem space.

Every path out of Gate.decide is an explicit permit or a refusal:

Failure Result
A rule raises BLOCKED
The store is unreachable BLOCKED
The reviewer raises falls to the timeout policy → deny for HIGH/CRITICAL
No reviewer configured timeout policy → deny for HIGH/CRITICAL
Tool absent from the risk table HIGH, not LOW
Risk absent from the timeout table deny
Approval arrives after the window closed not applied
CRITICAL approved with no written reason not accepted
A reviewer substitutes a denylisted payload BLOCKED
A reviewer's edit raises the risk class DENIED — resubmit as its own request

There is a test for each row, and a property-based test that searches for a permit across generated tool names and nested argument structures.

Reviewer edits are re-assessed

Review.modified_arguments lets a reviewer narrow a request — change {"env": "production"} to {"env": "staging"} — and Decision.arguments returns the edit, so a caller cannot run the originals by forgetting to check.

The edit is then re-assessed against the same policy, because in 0.1.0 it was not, and that was exploitable: risk came from the original call, so substituting a worse payload executed it under an approval granted for something else. A denylisted command refused when submitted directly was permitted when a reviewer substituted it, defeating the one outcome documented as unapprovable; and a CRITICAL payload cleared a HIGH review, skipping the written-reason requirement.

An edit at the same or lower risk is honoured. One that raises the risk class is denied, and one that matches a denylist rule is blocked. Found by running the published wheel against an adversarial reviewer — not by the test suite, which only tested the cooperative case.

Timeout is not denial

Outcome.TIMED_OUT   # nobody answered
Outcome.DENIED      # a human looked at it and said no
Outcome.BLOCKED     # policy refused before anyone was asked

Collapsing these into "not approved" destroys the distinction an operator needs at 3am. "Nobody was there" and "someone refused" call for opposite responses — one is a staffing problem, the other is a correctly working control.

The queue survives a restart

An in-process gate loses its pending requests on restart, which silently converts awaiting approval into never happened every time you deploy. SqliteStore is one file, no server:

from sentinel import Gate, SqliteStore

gate = Gate(store=SqliteStore("approvals.db"), reviewer=ask_on_slack)
$ sentinel pending
#7  deploy         critical    1204s left  on-silence=deny  agent=worker-3
      call targets production

$ sentinel audit
✗ 2026-08-16T17:20:01Z  timed_out critical  deploy         production-target
✓ 2026-08-16T17:19:44Z  approved  high      push           -
✗ 2026-08-16T17:19:02Z  blocked   critical  bash           destructive-shell

expire_stale() resolves requests that outlived the process, by their own timeout policy — otherwise a durable store accumulates rows that are neither pending nor decided.

A human's correction is never discarded

A reviewer can approve a changed version of a call — "yes, but not against production":

Review(approved=True, comment="staging only", modified_arguments={"env": "staging"})

decision.arguments returns the reviewer's version, so a caller cannot execute the originals by forgetting to check. The original is preserved on decision.call, and the substitution is recorded in the audit log.


Wiring in a reviewer

A reviewer is any callable. No subclassing, no framework.

def ask_on_slack(request):
    response = slack.ask(
        f"{request.call.agent} wants to {request.call.tool} "
        f"({request.risk.value} risk): {request.reason}",
        timeout=request.remaining(time.monotonic()),
    )
    if response is None:
        return None                      # no answer → the timeout policy decides
    return Review(approved=response.yes, comment=response.text, reviewer=response.user)

Returning None means unanswered, which is the honest signal — and it means a broken notification channel degrades to the timeout policy rather than crashing the agent.

Tuning the policy

from sentinel import Policy, Risk, pattern_rule

policy = (
    Policy(approve_from=Risk.HIGH)                       # let MEDIUM through unasked
    .with_tool("run_migration", Risk.CRITICAL)
    .with_rule(pattern_rule("customer-data", (r"/customers/",), escalate_to=Risk.CRITICAL))
)

Policies are frozen; with_* returns a copy. A permission policy that the code it governs can mutate at runtime is not a control.

Run sentinel policy to print the effective configuration — a policy nobody can read is a policy nobody can trust.


A bug the tests found

The credential rule originally matched against all argument values joined into one string. That meant {"paths": [".env", "other.txt"]} matched nothing: the pattern \.env(\.|$) saw a space after .env rather than end-of-value, so a batch-read call walked straight past the gate.

Values are now matched individually, with whole_command=True for the shell rules where the words are only dangerous as a phrase (rm -rf / is three harmless tokens). Joining also risked the inverse — two innocent values forming a match across the seam where they were joined.

Both directions are now covered by property tests.

Not in scope

  • Not an executor. sentinel decides; it never invokes. A gate that could run the action it guards is one refactor away from being the thing that bypasses itself.
  • Not authentication. It records who reviewed if you tell it; verifying that identity is your auth system's job.
  • Not a sandbox. It answers "should this run", not "run this safely".

Development

uv venv && uv pip install -e ".[dev]"
uv run pytest              # 90% coverage floor, enforced
uv run mypy src/sentinel   # strict
uv run sentinel policy     # print the effective policy

92 tests, 98% coverage, mypy strict, zero dependencies (SQLite is stdlib), green on Python 3.11–3.13.

The suite is organised by failure mode rather than by method, because the value of this library is entirely in what it refuses to do. TestCannotFailOpen is the one to read first: every case is a way a permission system has actually been observed to grant access by accident.

Licence

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sentinel_gate-0.1.1.tar.gz (39.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sentinel_gate-0.1.1-py3-none-any.whl (26.8 kB view details)

Uploaded Python 3

File details

Details for the file sentinel_gate-0.1.1.tar.gz.

File metadata

  • Download URL: sentinel_gate-0.1.1.tar.gz
  • Upload date:
  • Size: 39.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sentinel_gate-0.1.1.tar.gz
Algorithm Hash digest
SHA256 341b734d0503b983a81a98cbc764d2e43b8ee7c2ab9a38e61ef8e9c7b36b5589
MD5 f6257fe88afb280e2f2c073d1738e9fb
BLAKE2b-256 e236a0ff74df3e78af70e49ebea12cb7f0817f98cdf7efa57fb37d26dd2d47f9

See more details on using hashes here.

Provenance

The following attestation bundles were made for sentinel_gate-0.1.1.tar.gz:

Publisher: release.yml on Raghu23-dev/sentinel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sentinel_gate-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: sentinel_gate-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 26.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sentinel_gate-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 56cfabf2e14b24d7bb63fd9e8d7c00e62d8acd946aad8ce1a9ab1b338288aa9f
MD5 bfdc4046e9caddf38bd6133c4162fbaa
BLAKE2b-256 414071182012d4f08656e92a88166c4e174b243b48dd426b34a80d15ec939bb5

See more details on using hashes here.

Provenance

The following attestation bundles were made for sentinel_gate-0.1.1-py3-none-any.whl:

Publisher: release.yml on Raghu23-dev/sentinel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page