Metadata-Version: 2.5
Name: sentinel-gate
Version: 0.1.1
Summary: Human-in-the-loop approval for autonomous agents. Asymmetric timeouts, per-argument risk, and a gate that cannot fail open.
Project-URL: Homepage, https://github.com/Raghu23-dev/sentinel
Project-URL: Repository, https://github.com/Raghu23-dev/sentinel
Project-URL: Issues, https://github.com/Raghu23-dev/sentinel/issues
Author-email: Raghuram P <raghu2308.dev@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,approval,audit,guardrails,hitl,llm,safety
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: hypothesis>=6.100; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# sentinel

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

[![PyPI](https://img.shields.io/pypi/v/sentinel-gate)](https://pypi.org/project/sentinel-gate/)
[![CI](https://github.com/Raghu23-dev/sentinel/actions/workflows/ci.yml/badge.svg)](https://github.com/Raghu23-dev/sentinel/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Coverage 98%](https://img.shields.io/badge/coverage-98%25-brightgreen)](#development)
[![Types: strict](https://img.shields.io/badge/mypy-strict-blue)](#development)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Raghu23-dev/sentinel/badge)](https://scorecard.dev/viewer/?uri=github.com/Raghu23-dev/sentinel)

---

## 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 |

```bash
pip install sentinel-gate    # the import name is `sentinel`
```

```python
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**:

```console
$ 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

```console
$ 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

```python
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:

```python
from sentinel import Gate, SqliteStore

gate = Gate(store=SqliteStore("approvals.db"), reviewer=ask_on_slack)
```

```console
$ 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":

```python
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.

```python
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

```python
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

```bash
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
