This release is a pre-release and may not be stable for production use.
sentrykit
Framework-agnostic authorization for Python, built on
AWS Cedar. Clip a @protect decorator onto any callable — a
web route, a plain function, an agent tool — and sentrykit decides, per call, whether the
principal may invoke it, which arguments they may pass, and which defaults to inject
when they pass none; then narrows an over-broad request rather than refusing it — the value
the principal may not have is rejected and their own default fills the gap — and writes a
masked, hash-chained record of what it decided.
The caller proposes; the policy decides — and what it decides includes the arguments, not only the call.
It targets two worlds with one decorator:
- Web frameworks — FastAPI, Flask (more to come).
- Agent tooling — MCP tool servers, Pydantic-AI (more to come).
What that gives you, in the order it matters:
- Per-(principal, action) argument constraints. Allow-sets, deny-sets, patterns and numeric bounds, resolved for the principal and the action, one argument at a time.
- Narrow-and-continue instead of refuse. A value above what the principal may have is rejected and their own default fills the gap, so the call proceeds instead of being turned away.
- Policy-supplied default injection. Arguments the caller omitted — and arguments that were just dropped — are filled in from what policy says this principal may have.
- Grant-time constraint validation. A constraint no value could satisfy, or whose own default it would reject, is refused when it is written rather than found by whoever calls next.
- A masked, hash-chained audit trail. One record per decision and per admin mutation, masked before the append-only write and pinned to a hash of the exact policy text.
Across the first four sits one more: argument ceilings declared in reviewed policy text. A
.cedar file says what anybody may pass for an action, the grant store says what this
principal has, and the two intersect — so the reviewed file states the maximum and can only
narrow.
sentrykit sits at the action layer: it refuses the side effect before it happens, rather than inspecting what a model has already produced. It is self-hosted, Cedar-based, and audit-first.
General-purpose policy engines — Cedar, including Amazon Verified Permissions, as well as OPA and Casbin — and relationship-based systems such as OpenFGA answer a boolean question at the gate: may this principal perform this action on this resource. sentrykit is built on one of them, and adds the layer above that decision: which arguments the call may carry, which defaults to supply when the caller omits them, and a record of both. If you already run a policy engine, that argument layer is the part you would otherwise assemble yourself.
Status: 0.1.0, the first release — everything below works.
@protect, ambient context, the Cedar gate, argument filtering, policy-declared argument ceilings, default injection, constraint validation at the write, signature validation and the_authzenvelope; the three pluggable stores with entry-point discovery; masking and the hash-chained audit log; the FastAPI, Flask, MCP and Pydantic-AI providers with one runnable example app each, plus an attack/defence suite that runs nine named attacks twice — once past the policy and once around it; the shipped policy pack; and thesentrykitcommand line. Not yet shipped: the durable WORM audit backends (S3 Object Lock, Iceberg) — those are aProtocolaway rather than built.
Install
pip install sentrykit # core
pip install "sentrykit[fastapi]" # + FastAPI provider
pip install "sentrykit[flask]" # + Flask provider
pip install "sentrykit[mcp]" # + MCP tool-server provider
pip install "sentrykit[pydantic-ai]" # + Pydantic-AI provider
pip install "sentrykit[all]" # everything
The only pinned core dependency worth knowing about is cedarpy >= 4.8, < 5: the engine
parses a policy set, an entity graph and a schema once at wiring time and reuses the handles,
and releases below 4.8 do not expose those. See
which cedarpy.
The idea in 20 seconds
from sentrykit import Entity, EntityRef, Principal, establish, install, protect
install(
"""
@id("support-may-search-orders")
permit (
principal in Role::"support",
action == Action::"tool.search_orders",
resource == Tool::"orders"
);
""",
entities=[Entity(EntityRef("Role", "support")), Entity(EntityRef("Tool", "orders"))],
)
@protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
def search_orders(customer_id: str, region: str = "eu", limit: int = 100) -> dict[str, object]:
...
with establish(Principal(id="alice", roles=("support",)), channel="agent"):
search_orders(customer_id="c-1", region="us", limit=500)
A web dependency or an agent tool-runner establishes who is acting; the decorator supplies
what and with which arguments; Cedar decides; sentrykit filters the arguments, injects
per-principal defaults, validates, invokes, and returns the result with an _authz report of
what was processed, rejected, and defaulted.
If alice is only granted region="eu" and a page size up to 50, the call above still runs.
That is narrow-and-continue: the parts of the proposal she is not entitled to are dropped
and refilled from her own defaults, the call proceeds, and the envelope says so.
{
"customer_id": "c-1", "region": "eu", "limit": 10,
"_authz": {
"decision": "allow",
"rejected": [
{"name": "region", "rule": "allow-set", "reason": "Value is not one of the permitted values"},
{"name": "limit", "rule": "maximum", "reason": "Value is above the maximum of 50"}
],
"defaults_applied": {"region": null, "limit": null},
"narrowed": true,
"narrowed_arguments": ["region", "limit"],
"guidance": "This result is valid and was served. The authorization policy adjusted region, limit before the call ran, because the proposed value is not one this principal may use. Report the result as delivered and say which parameters were adjusted; do not retry with the original values."
}
}
rejected says what the policy would not accept. narrowed, narrowed_arguments and
guidance say what the outcome was — and that pairing is deliberate. A narrowed call
succeeded: real data was returned on values the principal is entitled to. An envelope that
reports only the exceptions describes that success in the vocabulary of a failure, and a reader
acting on it — most sharply a language model — concludes the call failed and withholds the answer
it is already holding. Measured across language models, that happened on roughly half of narrowed
calls. The three outcome fields carry no argument values and no policy ids, so they are present
at every verbosity above off: a caller who may not be told what was narrowed is still told
that the result is good.
Narrowing refines a proposal; it does not invent one. If every argument the caller
supplied is rejected, the call is refused with NoValidArguments rather than completed from
defaults — completing it would answer a question nobody asked, handing back alice's own orders
when she asked about a customer's.
With no established principal, the call is refused rather than run unguarded. Forgetting to wire a provider fails closed.
The reviewed file states the ceiling; the store states the grant
Constraints reach a decision from two places, and they intersect. A grant in the store
says what this principal may pass — mutable, per-principal, and the only layer that carries a
default. A ceiling declared on a permit in a reviewed .cedar file says what anybody
may pass for that action:
@id("support-may-search")
@constraints("{\"limit\": {\"maximum\": 100}, \"region\": {\"allow\": [\"eu\", \"us\"]}}")
permit (
principal in Role::"support",
action == Action::"tool.search",
resource == Tool::"orders"
);
Intersection can only narrow — allow-sets intersect, deny-sets union, bounds move inward — so a grant capped at 10 stays capped at 10 under a ceiling of 100. The file under review states the maximum anyone may have; the store stays the one place a per-principal value lives.
A ceiling is also the only declaration a policy can make that reaches narrow-and-continue
rather than the gate. Cedar answers allow or deny, so "you asked for 500, you may not have
that — here is what you may have" has nowhere else to live: the value above the bound is
rejected, the principal's own default fills the hole, and the call is served rather than
refused. Every part of a declaration is checked at load — the JSON, the rule names, the value
types, the pattern, and that the annotation sits on a permit — because Cedar does not
type-check annotation values, and a bound nobody enforces is worse than no bound. What load
cannot check is a misspelled argument name: nothing here imports your application, so read a
ceiling against the callable's signature. A failed evaluation reports no determining policies
and therefore no ceilings, so a policy set that declares any ceiling refuses an engine failure
under fail_mode="open" too, rather than proceeding unbounded. See
docs/policies.md.
A constraint nothing can satisfy is refused when it is written
Constraints are data, so one can be written that nothing satisfies. minimum=100, maximum=10
is the obvious case; an allow-set member the pattern rejects, or a default outside the
allow-set, are the quieter ones. Left to be discovered at call time, each is a refusal of
everything, found by whoever calls next:
store.grant(GrantSpec(principal=..., kind="constraint", subject="limit",
constraint=ArgumentConstraint(minimum=100, maximum=10)))
# ConfigurationError: the constraint on argument 'limit' cannot be satisfied: ...
The check runs at the write rather than on the model itself, because a store also reads constraints: refusing at construction would make one bad row already on disk fail the load of every principal in the file. The write is where the fault is introduced and where someone is present to fix it.
The same reasoning covers defaults at the call. A default is checked against its own constraint
and withheld if it fails, so omitting an argument is not a way around the rule that governs it.
The rules, how they resolve per (principal, action), and their sharp edges are written up in
docs/concepts.md.
Guard an HTTP route — same decorator, one extra line
A context provider is all a framework contributes. It establishes the principal at the request boundary and renders a refusal as an HTTP response; nothing else changes.
from fastapi import FastAPI
from sentrykit import install
from sentrykit.integrations.fastapi import FastAPIProvider, protect_app
provider = FastAPIProvider(identify=lambda request: request.headers.get("x-user"), grants=store)
install(POLICY_TEXT, grant_store=store, context_provider=provider)
app = FastAPI()
protect_app(app, provider=provider) # <- the one extra line
@app.get("/orders/summary") # this route is NOT protected
def summary() -> dict[str, object]:
return summarize_orders() # this call is
Flask is the same shape (from sentrykit.integrations.flask import protect_app), and
protect_blueprint scopes it to one blueprint. identify answers who is this; the grant
store answers what may they do. A denial comes back as a 403 carrying the same _authz
envelope a success carries, trimmed by verbosity:
{
"detail": "The policy denied action Action::\"orders.search\".",
"error": "AuthzDenied",
"_authz": {"decision": "deny", "action": "Action::\"orders.search\"", "resource": "Tool::\"orders\""}
}
Two runnable apps — one orders-and-refunds desk built twice, with the same seven protected
callables, the same reviewed .cedar directory and the same grant data on both frameworks —
live in examples/fastapi_app/ and examples/flask_app/. Each drives 22 requests through its
own test client and prints what policy did to every one before it starts serving. See
docs/web-frameworks.md.
Guard an agent's tools — same decorator, and the model is told what it may attempt
An agent boundary needs one thing a web boundary does not: the tool manifest is per-caller. sentrykit filters it with the same decision that enforces the call.
from mcp.server.mcpserver import MCPServer
from sentrykit import install
from sentrykit.integrations.mcp import MCPProvider, governed_tool, protect_server
provider = MCPProvider(grants=store)
install(POLICY_TEXT, grant_store=store, context_provider=provider)
server = MCPServer("orders")
governed_tool(server)(search_orders) # register the protected callable
protect_server(server) # <- the one extra line
Pydantic-AI is the same shape, and its principal is the human the agent acts for — never a service identity for the agent process, and never anything the model can write:
from sentrykit.integrations.pydantic_ai import PydanticAIProvider, governed_tool
agent = Agent(model, deps_type=Caller)
governed_tool(agent)(search_orders)
await agent.run("what is on order ord-0003?", deps=Caller(principal="alice"))
The filtering guarantee is stated as an invariant and tested as one:
A tool is hidden from a principal if and only if invoking it would be refused.
Hiding is a courtesy to the model, never the control. A client that skips the manifest and
calls a hidden tool by name is refused just the same — both halves are asserted in the same
test, because a filter that were the control would be a hole the moment someone skipped
tools/list.
An MCP tool server and an on-behalf-of agent govern that same desk in examples/mcp_server/
and examples/pydantic_ai_agent/, and print a 22-flow table of their own. Neither needs an API
key: each uses a local Ollama model when one is reachable and a
deterministic stand-in otherwise, and every authorization outcome is identical either way — a
decision is made from the principal and the proposed arguments, never from which model proposed
them.
Setting SENTRYKIT_OLLAMA_URL to an empty value turns the search off without opening a socket.
See docs/agents.md.
Relationships are Cedar's job, and this composes with them
Underneath the argument layer, nested relationships — user → team → project → resource,
delegation, on-behalf-of, expiry, break-the-glass — are resolved deterministically by Cedar's
entity hierarchy, rather than by a ladder of application if statements or by a model. A
policy therefore names only the far end of a chain:
@id("project-members-may-read-project-orders")
permit (
principal in Project::"orders-eu",
action == Action::"order.read",
resource in Project::"orders-eu"
);
Given alice → Team::"eu-support" → Division::"emea" → Project::"orders-eu", alice may read
an order owned by that project. The policy mentions neither the team nor the division; sever
one edge and the access is gone, because the relationship is the authorization. That is
Cedar's strength and sentrykit inherits it — it is the foundation the argument layer stands on,
and a ceiling can be declared on exactly that permit.
What that is worth, measured rather than asserted
Nine named attacks, each run twice against the same seeded database and the same function body — once past the policy, once around it:
pixi run run-scenarios
| proposed | ungoverned | governed | |
|---|---|---|---|
exfiltration by limit |
limit=100000 |
81 orders | 10 orders |
| an instruction planted in a ticket thread | another tenant, limit=5000 |
25 orders, another tenant's | 5 orders, the caller's own tenant |
| a destructive call with no arguments | cancel_open_orders() |
81 orders cancelled | refused: the signature's own default is denied |
| a state handle presented by somebody else | an export id from a narrowed call | 81 orders | 81 orders |
The comparison is kept fair mechanically: one module holds the plain functions, another applies
@protect to those same function objects, so neither arm has a body of its own and there is
nothing to drift. Three of the nine the gate does not win, and they are in the suite on
purpose — an authorization layer that ships only the cases it wins is describing a feature
rather than a boundary. See docs/scenarios.md.
Every decision is auditable
Point sentrykit at a sink and each decision — allow and refusal — becomes one hash-chained
record: the principal's effective attributes snapshotted at decision time, the arguments
proposed, processed, rejected and defaulted, the policies that decided and where they came
from, and the policy_set_hash that lets the decision be replayed.
install(
policy_source="file://./policies",
grant_store="memory://",
audit_sink="jsonl://./var/audit.jsonl",
)
Sensitive values are masked before the write, never after — anything in an append-only store is permanent, so masking at report time is too late. A policy says what to mask:
@id("support-may-look-up")
@obligations("hmac:account, notify:secops")
permit (principal in Role::"support", action == Action::"tool.lookup", resource == Tool::"accounts");
The callable receives ACCT-4417-9930; the audit log receives
hmac:9aceb229b6349cc0…. Keyed HMAC is the default because it is the one
strategy that lets an auditor correlate records without the store ever holding the value.
Obligations that are not masking instructions — notify:secops — ride along on the record and
the envelope for your application to discharge.
Tampering is detectable, not preventable: each record's hash covers its predecessor's, so
verify_chain catches both an edited record and a deleted one. Preventing the rewrite is the
storage layer's job.
A policy pack ships with it
The shapes every project ends up writing — a role the resource requires, ownership, a group hierarchy, a delegation, an expiry, a break-the-glass — ship as data, reachable through the same union as your own directory:
install(policy_source=["pack://", "file://./policies"])
Eight patterns: rbac, groups, ownership, delegation, expiry, break-glass,
separation-of-duties, jit-elevation. None of them names a role, a resource type or an
action — a shipped pack cannot know your vocabulary, so each one reads the shape of the
request instead. The resource declares what it requires, the grant store supplies the
principal's edges, the context supplies now, break_glass, on_behalf_of and
justification. Adopting a pattern is putting an attribute on an entity.
Half the pack forbids, and that half is the part that contains rather than permits:
@id("delegation-outside-scope-forbidden")
forbid (principal, action, resource)
when { principal has delegated_actions && !(action in principal.delegated_actions) };
Without it a delegated agent could reach the plain RBAC permit and ignore the scope it was
delegated. Same story for break-the-glass: the forbid is what makes an emergency-only
resource emergency-only, and the permit that reopens it demands an emergency role, the flag
and a justification — because a permit conditioned on a caller-supplied boolean alone
authorizes anyone at all. It carries notify:secops and record:justification as mandatory
obligations, so a call that proceeds that way cannot proceed quietly.
with establish(ana, channel="web", break_glass=True, justification="incident 4471"):
open_vault(item="master-key")
See docs/policies.md.
A command line for the parts an API is the wrong shape for
sentrykit policy validate ./policies
sentrykit grant 'User::"ana"' --role oncall --expires 2099-01-01 \
--store jsonfile://./var/grants.json --policy file://./policies \
--as 'User::"root"'
sentrykit audit-report --from 2026-01-01 --to 2026-12-31 --out audit.md
policy validate type-checks a directory against its schema before it deploys, groups errors
by the policy's @id, and exits non-zero — so it belongs in CI, where it catches the policy
set that cannot possibly permit anything and would otherwise deploy cleanly and deny every
request for the life of the process.
grant and revoke are not back doors into the store. Each is a @protect-ed callable
decided by the same governor the application uses, so a policy governs who may administer
grants and which grants they may make — the spec is built from the policy-processed
arguments, not from argv. An operator entitled only to oncall who asks for --role admin
stores oncall, and the audit log says so. --policy is required on both, with no default:
the shipped pack ships no permit for the admin actions, so defaulting to it would turn "you
named no policy source" into "you are not entitled". An allowed mutation leaves two records,
the decision and the mutation; a refused one leaves the refusal and an untouched store.
audit-report makes audit-first literal: the yearly report is a query. It aggregates by
outcome rather than allow/deny, because allow/deny folds six materially different refusals
into one bucket and both admin mutations into the other.
See docs/audit.md.
Everything on the outside is a plugin
Policy sources, grant stores, audit sinks and framework providers all
use one mechanism: a Protocol, a DSN, a register_* decorator, and an entry point. The
built-ins take exactly the path a third-party package takes.
# pyproject.toml of sentrykit-splunk
[project.entry-points."sentrykit.audit_sinks"]
splunk = "sentrykit_splunk.sink:SplunkAuditSink"
After pip install sentrykit-splunk, audit_sink="splunk://..." works with no change to
sentrykit. Pass a list instead of one DSN and you get a composite that satisfies the same
Protocol — policy sources union, grant stores merge or route by namespace, audit sinks fan
out — so the decision path never learns how many were configured.
Develop
This repo uses pixi for environments and helper tasks.
git init # required: `pixi run setup` installs git hooks
pixi install
pixi run setup # install git hooks (incl. the commit-message gate)
pixi run -e all check # format-check, lint, typecheck, tests+coverage, docs
pixi run setup installs pre-commit hooks into .git/hooks, so the repository has to be a
git repository first — on a fresh clone it already is.
The user docs cover policies and grants, audit and masking, and writing a plugin.
pixi run docs builds them into HTML, a PDF, and llms.txt / llms-full.txt — the four
documentation artifacts every release attaches. There is no hosted site: the documentation
travels with the release it describes, so a reader always has the version they installed.
Security
SECURITY.md has the disclosure contact and, more usefully, an explicit account
of what sentrykit defends and what it does not. The short version: it authorizes the
action before the side effect and records a hash-chained trail of every decision. It does
not sandbox a tool body — once a call is authorized, the function runs with your process's
privileges — and without a durable write-once sink behind the AuditSink protocol it
detects tampering rather than preventing it. It also does not authenticate: Principal is
whatever your context provider establishes, and sentrykit trusts it.
License
Apache-2.0. See LICENSE.
Metadata
Release files for sentrykit 0.1.0rc1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| sentrykit-0.1.0rc1.tar.gz | 860.0 kB | With provenance | Details for sentrykit-0.1.0rc1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| sentrykit-0.1.0rc1-py3-none-any.whl | Python 3 | none | any | Details for sentrykit-0.1.0rc1-py3-none-any.whl |
Total release size: 1.1 MB
Release files / sentrykit-0.1.0rc1.tar.gz
| Download URL | sentrykit-0.1.0rc1.tar.gz |
|---|---|
| Size | 860.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9f17f1a0d712ffae4528023eaf31a78a84a0e957832597457f0e078bb25cdce9
|
|
BLAKE2b-256 checksum How to use checksums |
04f6aae02eed205a47147c81828cbc84fbb80bb394bba086ec70d98c40d965d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / sentrykit-0.1.0rc1-py3-none-any.whl
| Download URL | sentrykit-0.1.0rc1-py3-none-any.whl |
|---|---|
| Size | 248.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0036077cf431b27dd9311a83e727799c09a25b6f79bac91737f134ccd906b0f6
|
|
BLAKE2b-256 checksum How to use checksums |
2aca7723a341ffd3bd344d34099acae013bc51f20c3d747ef17ad7fc6ed457b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logSecurity for sentrykit 0.1.0rc1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for sentrykit-0.1.0rc1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.