Skip to main content

sentrykit

CI Release PyPI Python Docs License: Apache 2.0

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 _authz envelope; 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 the sentrykit command line. Not yet shipped: the durable WORM audit backends (S3 Object Lock, Iceberg) — those are a Protocol away 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.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sentrykit 0.1.1
File Size Uploaded Provenance Actions
sentrykit-0.1.1.tar.gz 864.6 kB With provenance Details for sentrykit-0.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for sentrykit 0.1.1
File Interpreter ABI Platform Actions
sentrykit-0.1.1-py3-none-any.whl Python 3 none any Details for sentrykit-0.1.1-py3-none-any.whl

Total release size: 1.1 MB

Release files / sentrykit-0.1.1.tar.gz

Download URL sentrykit-0.1.1.tar.gz
Size 864.6 kB
Tags Source
SHA-256 checksum
How to use checksums
62394da7e826b924d5fcf1c770d0065e067d43cb4fc195bf0d7588dcf85a0853
BLAKE2b-256 checksum
How to use checksums
29c8a1830e9952ee026405d59ac2a00cfe0e841952eaa75798ad6c7a97b68584
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 log

Release files / sentrykit-0.1.1-py3-none-any.whl

Download URL sentrykit-0.1.1-py3-none-any.whl
Size 249.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c5130d381c595d517c4563ce6500461f89daf64adda747bf669fa0af26b7bfe3
BLAKE2b-256 checksum
How to use checksums
19d31fdd235fda888fc0defb5768cdf805e66276b3ac5666a56204f6946c1df9
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 log

Security for sentrykit 0.1.1

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.

Select from 2 files

Attestation for sentrykit-0.1.1.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 log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page