sentinel
Human-in-the-loop approval for autonomous agents. Asymmetric timeouts, per-argument risk, and a gate that cannot fail open.
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.
sentineldecides; 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
341b734d0503b983a81a98cbc764d2e43b8ee7c2ab9a38e61ef8e9c7b36b5589
|
|
| MD5 |
f6257fe88afb280e2f2c073d1738e9fb
|
|
| BLAKE2b-256 |
e236a0ff74df3e78af70e49ebea12cb7f0817f98cdf7efa57fb37d26dd2d47f9
|
Provenance
The following attestation bundles were made for sentinel_gate-0.1.1.tar.gz:
Publisher:
release.yml on Raghu23-dev/sentinel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sentinel_gate-0.1.1.tar.gz -
Subject digest:
341b734d0503b983a81a98cbc764d2e43b8ee7c2ab9a38e61ef8e9c7b36b5589 - Sigstore transparency entry: 2494081339
- Sigstore integration time:
-
Permalink:
Raghu23-dev/sentinel@25b9b34e1ae1d3caee4d23f3068d4d75a313c43a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Raghu23-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@25b9b34e1ae1d3caee4d23f3068d4d75a313c43a -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
56cfabf2e14b24d7bb63fd9e8d7c00e62d8acd946aad8ce1a9ab1b338288aa9f
|
|
| MD5 |
bfdc4046e9caddf38bd6133c4162fbaa
|
|
| BLAKE2b-256 |
414071182012d4f08656e92a88166c4e174b243b48dd426b34a80d15ec939bb5
|
Provenance
The following attestation bundles were made for sentinel_gate-0.1.1-py3-none-any.whl:
Publisher:
release.yml on Raghu23-dev/sentinel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sentinel_gate-0.1.1-py3-none-any.whl -
Subject digest:
56cfabf2e14b24d7bb63fd9e8d7c00e62d8acd946aad8ce1a9ab1b338288aa9f - Sigstore transparency entry: 2494081406
- Sigstore integration time:
-
Permalink:
Raghu23-dev/sentinel@25b9b34e1ae1d3caee4d23f3068d4d75a313c43a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Raghu23-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@25b9b34e1ae1d3caee4d23f3068d4d75a313c43a -
Trigger Event:
push
-
Statement type: