Integrations¶
One decorator, N context providers. Each module below lives behind an optional extra and is
imported by nothing in the core; the shared halves — identity resolution, refusal rendering,
tool-manifest filtering — are in base and tools so a FastAPI refusal and a Flask refusal
are the same refusal. See web frameworks and agents.
sentrykit.integrations.base¶
What every framework provider shares: identity resolution, lifecycle, and refusal rendering.
sentrykit has one decorator and N context providers. A provider is small on purpose — it does exactly two things, and both of them are the same on every framework:
Establish ambient context at the boundary, from the framework’s request plus an app-supplied auth → principal-id callback, resolving that id through a
GrantStoreso the principal arrives at the decision path already materialized (roles, parent edges, attributes, argument constraints, expiries already applied).Render a sentrykit refusal as an HTTP response carrying the
_authzenvelope at whatever the effective verbosity allows.
Everything genuinely framework-specific — how a header is read, how a response is built, which hook runs at teardown — is a handful of methods on the subclass. The status mapping and the envelope renderer live here so a FastAPI 403 and a Flask 403 are the same 403.
The per-request lifecycle is establish-then-reset, and the reset is correctness
A provider must release the context it established, in a finally or in the framework’s
teardown hook. This is not tidiness — it is the failure mode: Flask runs a request on a
pooled worker thread and its test client runs one in the caller’s own context, so an
un-reset ContextVar from request N is still ambient when request
N+1 starts. Request N+1 would then be authorized as request
N’s principal — the worst failure this library can have.
So the contract is:
scope = provider.establish(request) # at the boundary
try:
... # the handler, and anything it calls
finally:
provider.release(scope) # ALWAYS, even on the error path
BaseContextProvider.release() is deliberately total: it resets the scope when there is
one, clears the ambient context outright when there is not, and drops the last envelope
either way, so nothing about request N survives into request N+1.
An unauthenticated request establishes no principal and gets no scope. That is the
fail-closed rule doing its job: every protected call inside such a request
raises NoContext, which renders as a 403. The provider still
clears the context on the way in, because “no principal” must mean no principal rather than
“whatever the last request left behind”.
Refusals are rendered from the exception, not from ambient state
Ambient state is not reliably there when a refusal is rendered. A sync FastAPI route runs in
an AnyIO worker thread, which receives a copy of the context — so a ContextVar written
inside it, including the one last_envelope() reads, is discarded
on return and is simply not there when the exception handler runs. FastAPI’s
yield-dependency teardown has the same problem from the other direction: it unwinds before
the exception handler.
Every sentrykit error already carries structured detail — the refused action and its
determining policies, the offending argument name and reason, the allowed set, pydantic’s
field errors — so error_envelope() reads the exception and nothing else. The rendering
is then identical on every framework and on every code path, which is the only way the tests
can mean anything.
- sentrykit.integrations.base.DEFAULT_PRINCIPAL_HEADER: Final = 'x-sentrykit-principal'¶
Header the DSN-built default
identifyreads a principal id from.A provider built from a bare DSN (
fastapi://) has no application auth to call into, so it falls back to a header. That is enough for an example app, a smoke test, or a service behind a gateway that has already authenticated the caller — and nowhere near enough for anything else, which the docs say in as many words. Real deployments passidentify=.
- sentrykit.integrations.base.DEFAULT_REQUEST_ID_HEADER: Final = 'x-request-id'¶
Header a correlation id is taken from when the caller supplies one.
- sentrykit.integrations.base.DEFAULT_STATUS: Final = 500¶
Status for a sentrykit error that predates its own entry in
STATUS_BY_ERROR.
- sentrykit.integrations.base.NARROWED_ARGUMENTS_HEADER: Final = 'x-sentrykit-narrowed-arguments'¶
Response header naming the narrowed arguments, comma-separated, first-seen order.
- sentrykit.integrations.base.NARROWED_HEADER: Final = 'x-sentrykit-narrowed'¶
Response header announcing that a value the caller proposed did not survive.
Emitted only when something was narrowed, which is the same discipline
as_dict()applies to thenarrowedfield: the exceptional outcome is the one worth a line, and two headers on every ordinary response is a cost paid by every caller to say “nothing happened”. See ADR-029.
- sentrykit.integrations.base.STATUS_BY_ERROR: Final[Mapping[type[BaseException], int]] = {<class 'sentrykit.errors.ArgumentRejected'>: 400, <class 'sentrykit.errors.AuditUnavailable'>: 500, <class 'sentrykit.errors.AuthzDenied'>: 403, <class 'sentrykit.errors.ConfigurationError'>: 500, <class 'sentrykit.errors.DefaultUnavailable'>: 400, <class 'sentrykit.errors.NoContext'>: 403, <class 'sentrykit.errors.NoValidArguments'>: 400, <class 'sentrykit.errors.ReservedKey'>: 403, <class 'sentrykit.errors.SentrykitError'>: 500, <class 'sentrykit.errors.ValidationFailed'>: 422}¶
Exception → HTTP status. Looked up along the MRO, so a subclass inherits its base’s status.
- sentrykit.integrations.base.STATUS_PHRASES: Final[Mapping[int, str]] = {400: 'Bad Request', 403: 'Forbidden', 422: 'Unprocessable Entity', 500: 'Internal Server Error'}¶
Generic reason phrases, used as the whole body when verbosity is
off.
- class sentrykit.integrations.base.BaseContextProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]¶
Bases:
Generic[RequestT]Everything a context provider does that is not framework-specific.
A subclass supplies four small things —
narrow(),read_header(),render_error(), and aname— and inherits identity resolution, environment construction, the establish/release lifecycle, and the shared refusal rendering.Omitting identify is logged, once, at construction
A provider built without
identifyresolves identity with its own fallback, and every shipped fallback ends at something the request supplies —principal_headerhere, an_metakey or adepsattribute in the agent providers. So whoever can set that chooses who they are. That is the right wiring for an example app and for a service whose gateway has already authenticated the caller, and the wrong one everywhere else — a distinction only the deploying engineer can draw, and one they cannot draw from a default that says nothing. So construction emits aWARNINGon thesentrykit.integrationslogger naming the provider class and the key it will read. It fires once per provider, not once per request, and not at all whenidentifyis given.- Parameters:
identify – The app’s auth → identity callback. Given the framework request, it returns a principal id, an
EntityRef, a ready-madePrincipal, orNonefor an unauthenticated caller. Defaults to readingprincipal_header, which is enough for an example app and not enough for anything else, and is warned about when it is what you get.grants – Where a principal id is materialized. Defaults to the installed governor’s grant store, resolved lazily at request time so
install()and the provider can be wired in either order.environment – Extra Cedar request context per request, merged over
channel,request_idand the principal type. Policies read these ascontext.*.principal_header – Header the default
identifyreads.request_id_header – Header an incoming correlation id is taken from.
channel – The
channelestablished for every request.httpfor a web provider; the agent providers passagent.
- property name: str¶
Short name for this provider, e.g.
fastapi.- Returns:
The provider’s DSN scheme.
- Raises:
NotImplementedError – Always; every subclass names itself.
- property channel: Literal['http', 'agent', 'internal']¶
The channel this provider establishes —
httpfor a web boundary.
- property principal_header: str¶
Header the default
identifyreads a principal id from.
- property grants: GrantStore | None¶
The grant store principals are materialized from.
Falls back to the installed governor’s store when none was passed, so
install(grant_store=..., context_provider=...)needs no second wiring step and the two can be configured in either order.- Returns:
The store, or
Nonewhen neither the provider nor the governor has one — in which case only anidentifyreturning a wholePrincipalcan produce one.
- narrow(request: object) RequestT[source]¶
Check that
requestis this framework’s request type, and return it as such.The one place the boundary between “some object arrived” and “a Starlette request arrived” is crossed. It is a method rather than a cast so a provider wired to the wrong framework fails with a configuration error naming both, instead of an
AttributeErrorthree frames deeper.- Parameters:
request – The object the framework hook handed over.
- Returns:
The same object, typed.
- Raises:
NotImplementedError – Always; every subclass narrows to its own request type.
- read_header(request: RequestT, name: str) str | None[source]¶
Read one request header, case-insensitively.
- Parameters:
request – The framework request.
name – The header name.
- Returns:
The value, or
Nonewhen the header is absent.- Raises:
NotImplementedError – Always; header access is framework-specific.
- render_error(error: Exception) object[source]¶
Render a sentrykit refusal as this framework’s response.
- Parameters:
error – A
SentrykitError.- Returns:
The framework-native response, carrying the
_authzenvelope at whatever the effective verbosity allows.- Raises:
NotImplementedError – Always; building a response is framework-specific.
- error_response(error: Exception) tuple[int, dict[str, object]][source]¶
Resolve the status and JSON body for a refusal.
The whole of
render_error()that is not framework-specific. Both shipped providers call this and then wrap the result, which is what makes a FastAPI 403 and a Flask 403 byte-identical below the framework’s own serialization.- Parameters:
error – The raised exception.
- Returns:
(status, body).
- identity_of(request: RequestT) str | EntityRef | Principal | None[source]¶
Run the app’s auth callback, or fall back to the principal header.
- Parameters:
request – The framework request.
- Returns:
The identity, or
Nonefor an unauthenticated caller.
- resolve_principal(request: RequestT) Principal | None[source]¶
Turn a request into a materialized principal.
The identity comes from the app; the authority comes from the grant store. Resolving through
get_principal()is what makes roles, group edges, per-argument constraints and expiries part of the decision without the web layer knowing any of them exist.- Parameters:
request – The framework request.
- Returns:
The principal, or
Nonewhen the caller is unauthenticated or the store has never heard of them. Both areNoneon purpose: an unknown id is not an error the boundary should raise, it is a call that fails closed a few frames later with a refusal the caller can be shown.
- environment_for(request: RequestT) dict[str, object][source]¶
Build the Cedar request context for one request.
channelandrequest_idare always present — the first so a policy can say “not over HTTP”, the second so the decision, the operational logs and the audit record share one key. Anything the app’senvironmentfactory returns is merged on top, including over those two, because an app that has a better request id than we do should win.- Parameters:
request – The framework request.
- Returns:
The environment to establish.
- request_id(request: RequestT) str[source]¶
Return the correlation id for one request.
- Parameters:
request – The framework request.
- Returns:
The caller’s
request_id_headerwhen they supplied one, otherwise a fresh UUID4. Trusting the caller’s id is deliberate — it is what makes a trace span a gateway and a service — and it is never used as anything but a label.
- establish(request: object) ContextScope | None[source]¶
Establish ambient context for one request, at the boundary.
- Parameters:
request – The framework’s request object.
- Returns:
A
ContextScopeto hand torelease()at teardown, orNonewhen the caller is unauthenticated.Noneis not a failure: the context has still been cleared, and every protected call inside the request will fail closed withNoContext.- Raises:
ConfigurationError – If the request is not this framework’s, or an identity resolved with no grant store to materialize it from.
- narrowing() tuple[bool, tuple[str, ...]][source]¶
What the boundary should report about narrowing, for this request.
Reads the
EnvelopeRecorderestablish()installed, so it answers even when the decision was made in a sync route’s context copy – wherelast_envelope()returnsNone. The union across every decision in the request is what a response-level report needs: one route may make several protected calls.- Returns:
Whether anything was narrowed, and the argument names, in first-seen order. Both empty when nothing was narrowed, when verbosity is
off, or when no recorder is installed.
- release(scope: ContextScope | None) None[source]¶
Undo
establish(), at teardown. Always safe to call.Total by design: it resets the scope when there is one, clears the context outright when there is not, and drops the last envelope either way. A pooled worker thread that served principal A must start the next request holding nothing of A — not the principal, not the environment, and not the envelope of A’s last decision.
- Parameters:
scope – Whatever
establish()returned, includingNone.
- class sentrykit.integrations.base.ContextProvider(*args, **kwargs)[source]¶
Bases:
ProtocolHow a framework establishes ambient context and renders sentrykit’s refusals.
Declared here, with the other three contracts, so the plugin machinery is uniform across all four entry-point groups. The implementations live under
sentrykit/integrations/, behind optional extras, and the core must not import them to know the contract. Registered under thesentrykit.context_providersentry-point group.- property name: str¶
Short name for this provider, e.g.
fastapi.
- establish(request: object) object[source]¶
Resolve the principal and environment from a framework request and establish them.
- Parameters:
request – The framework’s request object.
- Returns:
A
ContextScopeto reset at the end of the boundary.
- render_error(error: Exception) object[source]¶
Render a sentrykit refusal as the framework’s error response.
- Parameters:
error – A
SentrykitError.- Returns:
The framework-native response, carrying the
_authzenvelope at whatever the effective verbosity allows.
- sentrykit.integrations.base.EnvironmentFactory¶
Extra Cedar request context contributed per request — auth strength, tenant, break-glass.
alias of
Callable[[RequestT],Mapping[str,object]]
- sentrykit.integrations.base.Identify¶
An app’s auth → identity callback.
Nonemeans “unauthenticated”, not “error”.alias of
Callable[[RequestT],str|EntityRef|Principal|None]
- sentrykit.integrations.base.Identity = str | sentrykit.cedar.EntityRef | sentrykit.context.Principal¶
What an app’s auth callback may hand back.
A
stris a principal id and is read asUser::"<id>"; anEntityRefnames the type explicitly. Both are resolved throughget_principal(), which is the point — roles, group edges and per-argument constraints come from the store, not from the request.A ready-made
Principalis accepted as the escape hatch for an app that has no grant store, or that materializes principals its own way. It is used as-is.
- sentrykit.integrations.base.action_of(error: Exception) str | None[source]¶
Return the action an error refers to, when it names one.
AuthzDeniedandNoContextboth carry the action; the argument errors do not. The action doubles as the key for a per-action verbosity override, so resolving it here means an action configuredverbosity="debug"gets a debug-level refusal body too.- Parameters:
error – The raised exception.
- Returns:
The action uid, or
None.
- sentrykit.integrations.base.error_envelope(error: Exception, *, verbosity: Literal['off', 'summary', 'verbose', 'debug']) dict[str, object] | None[source]¶
Build the
_authzbody for a refusal, trimmed toverbosity.Built from the exception alone — see this module’s docstring for why ambient state is not available on every path. The field names match
AuthzEnvelopeso a caller reads one vocabulary whether the call was allowed or refused, and the trimming rules match it too:offNo envelope at all.
summaryThe decision, the action and resource where the error names them, the reason, and the names and reasons of rejected arguments. No values.
verboseAdds the offending values, the allowed set a caller needs in order to retry, and the raw input pydantic rejected.
debugAdds the determining policies and the engine errors behind a refusal.
- Parameters:
error – The raised exception.
verbosity – How much to include.
- Returns:
The envelope body, or
Nonewhenverbosityisoff.
- sentrykit.integrations.base.error_payload(error: Exception, *, settings: Settings | None = None) dict[str, object][source]¶
Build the whole JSON body for a refusal.
Shaped so it reads naturally to both audiences:
detailis what FastAPI and Flask callers already expect to find on an error,erroris the machine-readable class name a client can branch on, and_authzis the same envelope the success path attaches to a mapping result.At
verbosity="off"the body collapses to a generic reason phrase — no action names, no reasons, no envelope — becauseoffmeans the caller is told nothing beyond the status.- Parameters:
error – The raised exception.
settings – Effective settings. Defaults to the process settings resolved for the action the error names, so a per-action
verbosityoverride reaches the refusal body as well as the success envelope.
- Returns:
A JSON-ready mapping.
- sentrykit.integrations.base.narrowing_headers(narrowed: bool, arguments: Sequence[str]) dict[str, str][source]¶
Build the response headers that report narrowing, for any framework.
Lives here rather than in each adapter so the two web providers cannot disagree about the header names or about when to emit them — the kind of drift that makes a documented contract a per-framework surprise.
Deliberately excludes
guidance. It is a sentence of prose, it can exceed 300 characters, and one of its two forms contains an em dash, which is not spellable in an HTTP field value without encoding. A header is for a fact a machine acts on; the prose belongs in the body or inlast_envelope(). ADR-029 records that decision and its cost.- Parameters:
narrowed – Whether anything was narrowed while serving this response.
arguments – The narrowed argument names.
- Returns:
The headers to add, empty when nothing was narrowed. The argument header is omitted — while
NARROWED_HEADERis still emitted — if any name cannot be spelled in a field value, so the caller is told that narrowing happened even when it cannot be told which, and is never handed a list that silently dropped an entry.
- sentrykit.integrations.base.reason_for(error: Exception) str[source]¶
One sentence explaining a refusal, safe to show at
summaryverbosity.Safe means: no argument values, no policy ids, no internal detail. Names of things the caller already knows — the action they invoked, the argument they passed — are fine, and a constraint’s own
reasonis written to be shown to a caller.- Parameters:
error – The raised exception.
- Returns:
A sentence-case explanation.
- sentrykit.integrations.base.status_for(error: Exception) int[source]¶
Map a sentrykit error to its HTTP status.
Walks
type(error).__mro__so a host that subclasses one of the shipped errors gets its base’s status rather than a surprise 500.- Parameters:
error – The raised exception.
- Returns:
The status code, defaulting to
DEFAULT_STATUS.
sentrykit.integrations.tools¶
Tool-manifest filtering: deciding what a principal is even shown.
The agent half of one-decorator-N-providers. A web provider has one job — establish context at the boundary — because an HTTP client already knows which URLs exist. An agent boundary has a second job: it tells the model what it may attempt, and that manifest is per-caller.
This module is the shared half of that, and it is deliberately framework-free. It imports no
mcp and no pydantic_ai; MCP’s tools/list and Pydantic-AI’s prepare are two
spellings of the same question, and they get one answer so they cannot drift apart.
The invariant
A tool is hidden from a principal if and only if invoking it would be refused by the authorization gate.
Both halves matter, and the tests assert both together.
Only if — hiding is never the enforcement. The dispatch path runs the identical decision, so
a client that ignores the manifest and calls a hidden tool by name is refused exactly as if it
had never been hidden. Manifest filtering that were the enforcement would be a hole the
moment a caller skipped tools/list, and “the model did not know the tool existed” is not
an access-control argument.
If — a tool the principal may invoke is never hidden. Hiding is a usability affordance: it stops the model proposing calls that cannot succeed, burning turns and context on refusals it could not have predicted. A filter that hid more than it had to would silently narrow what the agent can do for its user.
Why “the gate” and not “the whole pipeline”
plan() is run in full — this is the mechanism
sentrykit.engine was split for, in as many words — but its outcomes are read in two
groups:
AuthzDeniedandNoContextare about entitlement. The principal may not perform this action at all, whatever they pass. ⇒ hidden.DefaultUnavailable,NoValidArguments,ArgumentRejectedandValidationFailedare about arguments, and the manifest probe supplies none. A tool with a required parameter and no policy default raisesDefaultUnavailablefor everybody; treating that as “denied” would hide every such tool from every principal. ⇒ visible, and enforced on dispatch once the model has proposed something to enforce against.
So the manifest answers the only question that is decidable before the model has proposed
anything, and the argument-level pipeline answers the rest at dispatch. That split is the
honest reading of the invariant above, and ENTITLEMENT_ERRORS is the one place it is
written down.
An unprotected tool is not a governed tool
A callable with no @protect wrapper carries no action, no resource and no argument spec,
so there is nothing to decide about it. It is reported as ToolDecision.governed
False and left visible: sentrykit does not silently hide a tool its author never asked
it to govern, and pretending to have made a decision about one would be worse than saying so.
Hosts that want everything governed assert that themselves — which is a one-line test over
decide_tools(), and the shipped examples have it.
- sentrykit.integrations.tools.ENTITLEMENT_ERRORS: Final[tuple[type[SentrykitError], ...]] = (<class 'sentrykit.errors.AuthzDenied'>, <class 'sentrykit.errors.NoContext'>)¶
Refusals that mean this principal may not do this at all, so the tool is hidden.
Every other
SentrykitErroris about the arguments of a particular call, which a manifest probe has not got — see this module’s docstring. Matched withisinstance, so a host subclassing one of these inherits its meaning.
- class sentrykit.integrations.tools.ToolDecision(name: str, visible: bool, governed: bool, error: SentrykitError | None = None, plan: Plan | None = None)[source]¶
Bases:
objectWhat the shared gate concluded about one tool, for one principal.
Returned rather than a bare
boolbecause the two agent providers need different parts of it: MCP drops the hidden names from atools/listresult, Pydantic-AI returnsNonefrompreparefor the same tools, and both want the refusal for a debug log that explains why a tool the developer expected to see is missing.- Variables:
name (str) – The advertised tool name.
visible (bool) – Whether the tool should appear in this principal’s manifest.
governed (bool) – Whether the underlying callable carries a
@protectwrapper.Falsemeans no decision was made andvisibleisTrueby default — see the module docstring.error (sentrykit.errors.SentrykitError | None) – The refusal that hid it, or the argument-level refusal that did not.
Nonewhen planning succeeded or the tool is ungoverned.plan (sentrykit.engine.Plan | None) – The successful plan, when there was one. Carries the action, the resource and the principal’s argument constraints, which is what an optional schema-narrowing pass reads.
- sentrykit.integrations.tools.decide_tool(name: str, fn: Callable[[...], object], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) ToolDecision[source]¶
Decide whether one tool belongs in
principal’s manifest.Runs
plan()with no proposed arguments — the same call the enforcement path makes, minus the arguments the model has not proposed yet — and reads the outcome per this module’s docstring.- Parameters:
name – The advertised tool name, for the returned decision and the logs.
fn – The registered callable. Unprotected callables are reported ungoverned and visible.
principal – Who the manifest is for.
governor – The governor to decide against. Defaults to the one pinned to the protected callable, and failing that to the process-installed one.
environment – Cedar request context for the probe, merged over the ambient environment. A provider passes the same environment it would establish for a dispatch, so a policy conditioning on
context.channelseesagenthere too.
- Returns:
The decision.
- Raises:
ConfigurationError – If
fnis protected but no governor can be resolved. A manifest that silently showed everything because nothing was installed would be the exact failure the fail-closed rule exists to prevent.
- sentrykit.integrations.tools.decide_tools(tools: Mapping[str, Callable[[...], object]] | Iterable[tuple[str, Callable[[...], object]]], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) tuple[ToolDecision, ...][source]¶
Decide a whole manifest, in the order the tools were given.
- Parameters:
tools – The registered tools, as a mapping or as
(name, callable)pairs.principal – Who the manifest is for.
governor – As for
decide_tool().environment – As for
decide_tool().
- Returns:
One decision per tool, in input order.
- Raises:
ConfigurationError – As for
decide_tool().
- sentrykit.integrations.tools.governed_meta(fn: object) ProtectedMeta | None[source]¶
Return the
@protectmetadata for a tool’s callable, unwrapping one adapter layer.An agent provider rarely registers the protected function itself. MCP registers a
proposable()shim; Pydantic-AI registers actx-taking adapter around that shim, which is two hops. Each layer records what it wraps under__wrapped__, so the whole chain is walked rather than a fixed number of links – a filter that stopped one hop short would read a governed tool as ungoverned and advertise it to everybody — exactly the failure the manifest filter exists to prevent.- Parameters:
fn – The registered tool callable.
- Returns:
The metadata, or
Nonewhen nothing in the chain is protected.
- sentrykit.integrations.tools.permitted_tools(tools: Mapping[str, Callable[[...], object]] | Iterable[tuple[str, Callable[[...], object]]], *, principal: Principal, governor: Governor | None = None, environment: Mapping[str, object] | None = None) tuple[str, ...][source]¶
Return the names
principalmay be shown, in the order the tools were given.The convenience form of
decide_tools()for a caller that needs only the names.- Parameters:
tools – The registered tools, as a mapping or as
(name, callable)pairs.principal – Who the manifest is for.
governor – As for
decide_tool().environment – As for
decide_tool().
- Returns:
The permitted names.
- Raises:
ConfigurationError – As for
decide_tool().
- sentrykit.integrations.tools.proposable(fn: Callable[[...], Any], *, optional: Sequence[str] | None = None) Callable[[...], Any][source]¶
Re-declare a protected callable so a caller may omit the arguments policy supplies.
Both agent frameworks validate a proposal against the tool’s advertised JSON schema before dispatch, so an argument the schema marks
requiredcan never be left out and can therefore never be filled in from the principal’s defaults – and that holds on each of the two frameworks independently. Without this shim, default injection is unreachable from an agent at all, which would leave one of the three things sentrykit does unusable at the very boundary the agent integrations exist to govern.This is the same move the FastAPI example already makes one layer out: its route declares
region: str | None = Noneand forwards only what the caller actually supplied. An absent argument has to be expressible at the boundary, and on an agent boundary the boundary is the tool signature.The returned shim satisfies two introspections at once, which is the whole trick:
inspect.signature()reports the shim’s parameters, because a callable carrying its own__signature__stops the unwrapping walk – so the framework advertises the optional schema; whileinspect.unwrap()still reaches the protected callable through__wrapped__, sogoverned_meta()finds the@protectmetadata and the manifest filter governs the tool instead of waving it through as unprotected.
Every synthesized parameter is made keyword-only. Agent frameworks propose arguments by name, so nothing is lost, and it removes the ordering trap where making one parameter optional would leave a non-default parameter standing after a default one.
- !!! warning
A parameter listed in
optionalcannot carry a meaningfulNone: the shim readsNoneas not proposed and drops it, exactly as the FastAPI example’sif region is not Nonedoes. Do not list a genuinely nullable parameter.
- Parameters:
fn – The protected callable to register as a tool. Sync or async; the shim matches.
optional – Which parameters become omittable. Defaults to every parameter with no Python-level default – precisely the set whose value could only have come from policy, and so precisely the set worth making omittable.
- Returns:
The shim, to hand to
@server.tool()or totool_adapter().- Raises:
ConfigurationError – If
fn’s signature cannot be introspected, or ifoptionalnames a parameter it does not declare or declares positional-only – which could not be proposed by name and so could not be omitted either.
sentrykit.integrations.fastapi¶
The FastAPI / Starlette context provider.
Wire it once and every @protect-ed callable reached from a request — the route itself, a
service function three frames deeper, a helper in another module — is governed by the same
ambient principal:
from fastapi import FastAPI
from sentrykit.integrations.fastapi import protect_app
app = FastAPI()
protect_app(app, provider=FastAPIProvider(identify=lambda request: request.headers.get("x-user")))
protect_app adds a pure-ASGI middleware and registers the exception handlers. Both are
available separately for apps that want to place them by hand.
Why a pure-ASGI middleware, and never a sync dependency
There are four places a provider could establish context, and only three of them work:
Established in |
reaches an |
reaches a |
|---|---|---|
pure-ASGI middleware |
yes |
yes |
|
yes |
yes |
|
yes |
yes |
`def` (sync) dependency |
no |
no |
A sync dependency runs in an AnyIO worker thread with a copy of the context, so its
establishment is discarded the moment it returns and every protected call downstream then
fails closed. This module ships no sync-dependency form, so there is nothing to reach for by
mistake. A sync route is fine — anyio.to_thread.run_sync copies the context into the
worker, so the principal established upstream travels with it.
The middleware is pure ASGI rather than BaseHTTPMiddleware for two reasons: it needs no task
group and no response buffering, and — because Starlette builds the stack as
ServerErrorMiddleware → user middleware → ExceptionMiddleware → router — the context it
establishes is still ambient when an exception handler runs. A yield-dependency’s is not: the
AsyncExitStack unwinds below ExceptionMiddleware, so its teardown happens first.
- class sentrykit.integrations.fastapi.FastAPIProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]¶
Bases:
BaseContextProvider[Request]Establishes ambient context from a Starlette request and renders refusals as JSON.
Construction is inherited from
BaseContextProvider; see there foridentify,grants,environmentand the header options. This class supplies only the four framework seams.- classmethod from_dsn(dsn: DSN) FastAPIProvider[source]¶
Build from
fastapi://— the registry’s door, used byinstall().A DSN-built provider has no application auth to call into, so it identifies callers by the header named in
?header=(defaultDEFAULT_PRINCIPAL_HEADER). That suits an example app or a service behind a gateway that has already authenticated the caller. Anything else should construct the provider directly and passidentify=.- Parameters:
dsn – The parsed address. Options:
header(principal id header),request_id_header.- Returns:
The provider.
- property name: str¶
fastapi.
- narrow(request: object) Request[source]¶
Check that
requestis a Starlette request.- Parameters:
request – The object the boundary handed over.
- Returns:
The same object, typed.
- Raises:
ConfigurationError – If it is not a Starlette request — which means this provider was wired to the wrong framework.
- read_header(request: Request, name: str) str | None[source]¶
Read one header from a Starlette request. Starlette’s headers are case-insensitive.
- render_error(error: Exception) JSONResponse[source]¶
Render a sentrykit refusal as a JSON response carrying the
_authzenvelope.- Parameters:
error – A
SentrykitError.- Returns:
The response, at the status
status_for()maps the error to.
- class sentrykit.integrations.fastapi.SentrykitMiddleware(app: Callable[[MutableMapping[str, Any], Callable[[], Awaitable[MutableMapping[str, Any]]], Callable[[MutableMapping[str, Any]], Awaitable[None]]], Awaitable[None]], provider: FastAPIProvider | None = None)[source]¶
Bases:
objectPure-ASGI middleware that establishes context for the request and releases it after.
The release is in a
finallyand is not optional: a reused ASGI task that kept the previous request’s principal would authorize the next caller as somebody else. Seesentrykit.integrations.basefor the measurement behind that.Non-HTTP scopes (
lifespan,websocket) pass straight through untouched.- Parameters:
app – The next ASGI application in the stack.
provider – The provider to establish with. Defaults to the one wired into the installed governor by
install(context_provider=...), and failing that to a header-identifyingFastAPIProvider.
- sentrykit.integrations.fastapi.context_dependency(provider: FastAPIProvider | None = None) Callable[[Request], AsyncIterator[None]][source]¶
Build an
asyncyield-dependency that establishes context for one route.The route-scoped alternative to
SentrykitMiddleware, for an app that governs some of its routes and not others:@app.get("/orders", dependencies=[Depends(context_dependency())]) def list_orders() -> dict[str, object]: ...
It must be
async: see this module’s docstring for why a sync dependency cannot work. One difference from the middleware is worth knowing — the dependency’s teardown runs before an exception handler, so ambient context is already released by the time a refusal is rendered. That costs nothing, because refusals are rendered from the exception.- Parameters:
provider – The provider to establish with. Defaults as for
SentrykitMiddleware.- Returns:
The dependency callable, ready to hand to
Depends.
- sentrykit.integrations.fastapi.install_error_handlers(app: FastAPI, provider: FastAPIProvider | None = None) None[source]¶
Register a handler for every sentrykit error on
app.One handler per entry in
STATUS_BY_ERROR, plusSentrykitErroritself as the catch-all. Starlette resolves a handler by walking the exception’s MRO, so a host that subclasses a shipped error is covered without registering anything.- Parameters:
app – The application.
provider – The provider whose
FastAPIProvider.render_error()builds the response.
- sentrykit.integrations.fastapi.protect_app(app: FastAPI, *, provider: FastAPIProvider | None = None, middleware: bool = True, handlers: bool = True) FastAPIProvider[source]¶
Wire sentrykit into
appin one call.- Parameters:
app – The application.
provider – The provider to use. Defaults as for
SentrykitMiddleware.middleware – Whether to add
SentrykitMiddleware. Turn it off when context is established per route withcontext_dependency()instead.handlers – Whether to register the exception handlers.
- Returns:
The provider that was wired, so a caller can hold on to it.
sentrykit.integrations.flask¶
The Flask context provider.
The WSGI half of one-decorator-N-providers. Same decorator, same envelope, same statuses — a different pair of hooks:
from flask import Flask
from sentrykit.integrations.flask import FlaskProvider, protect_app
app = Flask(__name__)
protect_app(app, provider=FlaskProvider(identify=lambda request: request.headers.get("x-user")))
Establish in `before_request`, release in `teardown_request`
Flask’s hook ordering is what makes this work. For a request whose view raises, Flask runs:
before_request -> view raises -> errorhandler renders -> teardown_request
so the handler still sees the ambient principal, and teardown_request is the last hook — the
right place for the release. Three details follow from the same observation and each one is a
bug if ignored:
teardown_requestruns even when `before_request` raised, so the release has to cope with there being no scope to release.before_requestandteardown_requestfire even for a 404, so establishment cannot assume a view was matched.Everything happens on one thread, so the
ContextVartoken captured on the way in is always resettable on the way out.
That release is the whole reason this module is careful. Flask serves a request on a pooled
worker thread, and its test client serves one in the caller’s own context: an un-reset
ContextVar from request N is still ambient when request N+1 starts, and request N+1
would be authorized as request N’s principal. §5 of the same notes measured it.
Flask async def views need the asgiref extra and are not exercised here; sync views are
the shape this provider is built for.
- sentrykit.integrations.flask.SCOPE_KEY: str = '_sentrykit_scope'¶
Attribute on
flask.gthe request’s context scope is parked under.grather than a module global because it is request-scoped and thread-local already, which is exactly the lifetime the scope has. The name is public so a host debugging a leak knows what to look at.
- class sentrykit.integrations.flask.FlaskProvider(*, identify: Callable[[RequestT], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RequestT], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'http')[source]¶
Bases:
BaseContextProvider[Request]Establishes ambient context from a Flask request and renders refusals as JSON.
Construction is inherited from
BaseContextProvider; see there foridentify,grants,environmentand the header options. This class supplies only the four framework seams plus the two Flask hooks.- classmethod from_dsn(dsn: DSN) FlaskProvider[source]¶
Build from
flask://— the registry’s door, used byinstall().A DSN-built provider has no application auth to call into, so it identifies callers by the header named in
?header=(defaultDEFAULT_PRINCIPAL_HEADER). Real deployments construct the provider directly and passidentify=.- Parameters:
dsn – The parsed address. Options:
header(principal id header),request_id_header.- Returns:
The provider.
- property name: str¶
flask.
- narrow(request: object) Request[source]¶
Check that
requestis a Flask request, unwrapping the request-local proxy.Inside a request,
flask.requestis awerkzeug.local.LocalProxy, not aflask.Request. The proxy forwards__class__, soisinstancealone would accept it and quietly hand the proxy to the application’sidentifycallback — an object whose attribute lookups fail the moment the request context is gone. It is unwrapped here instead, soidentifyandenvironmentalways receive the real request and may keep it.- Parameters:
request – The object the boundary handed over — a request or a proxy to one.
- Returns:
The underlying request, typed.
- Raises:
ConfigurationError – If it is not a Flask request — which means this provider was wired to the wrong framework.
- read_header(request: Request, name: str) str | None[source]¶
Read one header from a Flask request. Werkzeug’s headers are case-insensitive.
- render_error(error: Exception) tuple[Response, int][source]¶
Render a sentrykit refusal as a JSON response carrying the
_authzenvelope.- Parameters:
error – A
SentrykitError.- Returns:
(response, status)— the pair Flask accepts from an error handler, at the statusstatus_for()maps the error to.
- before_request() None[source]¶
Establish ambient context for the current request, parking the scope on
g.Registered as a
before_requesthook. Takes no arguments and returnsNone, which is Flask’s contract for “carry on with the request” — the provider never short-circuits a request itself, because refusing is the decorator’s job and an unauthenticated caller may well be hitting a route that is not protected at all.
- after_request(response: Response) Response[source]¶
Add the narrowing headers to the outgoing response.
Registered as an
after_requesthook, which is the last place the headers are still mutable and runs beforeteardown_requestdrops the recorder. Flask serves a request on one thread in one context, so unlike the ASGI side there is no copied-context problem here — the recorder is used anyway, because it is the channel that answers the response-level question (a view may make several protected calls) and because one answer shared by both frameworks is worth more than two shaped by their own plumbing.- Parameters:
response – The response Flask is about to send.
- Returns:
The same response, with the headers added when anything was narrowed. An unprotected route, or one that narrowed nothing, is returned untouched.
- teardown_request(error: BaseException | None = None) None[source]¶
Release the context established for the current request.
Registered as a
teardown_requesthook, and total: a request whosebefore_requestnever ran, or ran and raised, has no scope ong, and that is a no-op rather than aKeyError. It tolerates being called with no application context at all, so a host driving the hooks by hand cannot turn a missing context into a teardown exception that masks the real one.- Parameters:
error – The exception that ended the request, or
None. Accepted because Flask passes it; the release is the same either way, sincerelease()is what makes the next request safe and that is owed regardless of how this one ended.
- sentrykit.integrations.flask.install_error_handlers(target: Flask | Blueprint, provider: FlaskProvider | None = None) None[source]¶
Register a handler for every sentrykit error on
target.One handler per entry in
STATUS_BY_ERROR, plusSentrykitErroritself as the catch-all. Flask resolves a handler by walking the exception’s MRO, so a host that subclasses a shipped error is covered without registering anything.- Parameters:
target – The application or blueprint to register on.
provider – The provider whose
FlaskProvider.render_error()builds the response.
- sentrykit.integrations.flask.protect_app(app: Flask, *, provider: FlaskProvider | None = None) FlaskProvider[source]¶
Wire sentrykit into
appin one call.Registers the
before_requestestablishment, theafter_requestnarrowing headers, theteardown_requestrelease, and the error handlers, application-wide.- Parameters:
app – The application.
provider – The provider to use. Defaults to the one wired into the installed governor by
install(context_provider=...), and failing that to a header-identifyingFlaskProvider. Callinstall()first if you want it picked up — see_effective_provider()for why the default is resolved here and not per request.
- Returns:
The provider that was wired, so a caller can hold on to it.
- sentrykit.integrations.flask.protect_blueprint(blueprint: Blueprint, *, provider: FlaskProvider | None = None) FlaskProvider[source]¶
Wire sentrykit into one blueprint rather than the whole application.
For an app where only part of the surface is governed — an
/apiblueprint in front of an unprotected marketing site, or two blueprints authenticating callers differently. The hooks are blueprint-scoped (Blueprint.before_request/Blueprint.after_request/Blueprint.teardown_request), so they run only for this blueprint’s routes, and its error handlers likewise.- Parameters:
blueprint – The blueprint.
provider – The provider to use. Defaults as for
protect_app().
- Returns:
The provider that was wired.
sentrykit.integrations.mcp¶
The MCP tool-server context provider — the model proposes, the policy decides.
Wire it once and every @protect-ed tool on the server is governed by the principal the
call arrived for:
from mcp.server.mcpserver import MCPServer
from sentrykit.integrations.mcp import governed_tool, protect_server
server = MCPServer("orders")
@governed_tool(server, action="order.read", resource='Order::"o-1"')
def read_order(order_id: str, limit: int = 10) -> dict[str, object]: ...
protect_server(server)
protect_server installs one SentrykitToolMiddleware, which does two things and
nothing else: it establishes ambient context around every tools/call and releases it
after, and it filters every tools/list result down to the tools the caller may actually
invoke.
What this is built against
mcp 2.0 — mcp.server.mcpserver.MCPServer and the ServerMiddleware protocol in
mcp.server.context. That one distribution is the whole dependency: sentrykit[mcp]
installs mcp>=2,<3, the entry point and the mcp:// DSN name the same thing, and nothing
here reaches for any other MCP package.
Two names you may meet elsewhere are not this. mcp 2.0 renamed its own ergonomic server class
mcp.server.fastmcp.FastMCP → MCPServer, so a 1.x example will
import a class that no longer exists. And the separately published fastmcp project is a
different library: it dropped mcp as a dependency, does not import against mcp 2.0, and
carries its own tool-authorization parameter. Neither is needed here, and neither is installed
by this extra.
Register protected tools with `governed_tool`, and nothing else
This is the one sharp edge in the module, so it is stated plainly: a `@protect`-ed callable
handed straight to `@server.tool()` will have its refusals swallowed. mcp’s Tool.run wraps
whatever the body raised in a ToolError and MCPServer._handle_call_tool turns that into a
CallToolResult(isError=True) with no structured content, so the caller gets the string
"Error executing tool …" and the _authz envelope is gone before any middleware can see it.
The gate still ran and the side effect was still refused — but the model is told nothing it can
act on.
governed_tool() is what closes that. It registers the tool behind two thin layers, each
solving one measured problem and neither of them optional:
a
proposable()shim, so an argument the principal’s policy would supply is advertised as omittable and can actually be omitted. mcp validates a proposal against the advertised schema before the body runs, so without this a parameter with no Python default could never be filled in from a policy default; anda translator that catches
SentrykitErrorand re-raises it as anmcp.shared.exceptions.MCPError, which is the one exception type_handle_call_toolre-raises rather than swallowing. That is what carries a refusal out of the tool body intact.
Both layers keep __wrapped__ pointing at the protected callable, so the manifest filter
still finds its @protect metadata, and governed_tool hands the protected callable back
unchanged — registering a tool does not take a function away from the rest of the program.
A refusal never reaches the tool, and the decision is made exactly once
The gate runs inside @protect, before the body — so the side effect is refused before it
happens, which is architecture §8 made concrete. The refusal then travels out as a
_ToolRefusal and the middleware renders it as a
CallToolResult carrying isError and the _authz envelope in its
structured content, so the model gets a structured reason it can act on instead of an opaque
error string.
Deciding it here instead — planning the call in the middleware ahead of call_next —
also works, and is not what this module does, because it decides every allowed call twice:
one allowed dispatch then writes two allowed audit records, and a library positioned
audit-first cannot have its own log double-count. It also forces the caller-facing envelope
to be reconstructed from the pre-flight plan, so defaults_applied describes a decision
the decorator had not made. Both problems disappear when the refusal is
carried out rather than predicted.
What the middleware still owns is the rendering, and that shape is not a style choice. Measured against a live client, the three options differ:
the middleware… |
what the client sees |
|---|---|
lets the error propagate |
|
returns a plain |
a |
returns a |
|
A tools/list result is the other way round: by the time the middleware sees it, it is an
already-serialized mapping that does carry its discriminator, so filtering rewrites it in
place.
The manifest is filtered, and the filter is not the enforcement
See sentrykit.integrations.tools for the invariant and why it is written the way it is.
The short version: hiding a tool and refusing it are the same decision made twice, so a client
that skips tools/list and calls a hidden tool by name gets refused just the same.
Finding the callable behind an advertised name
mcp 2.0 exposes no public accessor for a registered tool’s underlying function, and the
manifest filter needs one in order to read its @protect metadata. registered_tools()
reads the server’s tool manager defensively — any failure degrades to “no tools
discovered” rather than to an exception on a live dispatch — and every entry point here
accepts an explicit tools= mapping for a host that would rather not depend on that at all.
- sentrykit.integrations.mcp.CALL_TOOL_METHOD: str = 'tools/call'¶
The inbound method the middleware establishes context around.
- sentrykit.integrations.mcp.LIST_TOOLS_METHOD: str = 'tools/list'¶
The inbound method whose result the middleware filters.
- sentrykit.integrations.mcp.NARROWED_ARGUMENTS_META_KEY: str = 'sentrykit/narrowedArguments'¶
_metakey naming the narrowed arguments, in first-seen order.
- sentrykit.integrations.mcp.NARROWED_META_KEY: str = 'sentrykit/narrowed'¶
_metakey announcing that a tool call ran with a value the model did not propose.
- class sentrykit.integrations.mcp.MCPProvider(*, identify: Callable[[ServerRequestContext[Any, Any]], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[ServerRequestContext[Any, Any]], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'agent')[source]¶
Bases:
BaseContextProvider[ServerRequestContext[Any, Any]]Establishes ambient context from an MCP request and renders refusals as tool results.
Construction is inherited from
BaseContextProvider; see there foridentify,grants,environmentand the header options. The channel defaults toagentrather thanhttp, so a policy writtenwhen { context.channel == "agent" }applies to every tool call without the server saying anything.Where the principal comes from
read_headerlooks in two places, in order:``ctx.meta`` — the
_metaobject on the request params. It is an open map, bothClient.call_toolandClient.list_toolsacceptmeta=, and it is the only carrier that exists on every transport including stdio and in-memory. This is what makes the example server and the whole test suite drivable without a socket.The transport request’s headers, when the transport has one.
Noneon stdio.
Either is enough for an example, a smoke test, or a server behind a gateway that has already authenticated the caller, and nowhere near enough for anything else. A real deployment passes
identify=, typically reaching formcp.server.mcpserver.authenticated_principal(), which resolves the OAuth(client, issuer, subject)identity from the ambient access token.- classmethod from_dsn(dsn: DSN) MCPProvider[source]¶
Build from
mcp://— the registry’s door, used byinstall().- Parameters:
dsn – The parsed address. Options:
header(the_metakey, or header name, a principal id is read from),request_id_header.- Returns:
The provider.
- property name: str¶
mcp.
- narrow(request: object) ServerRequestContext[Any, Any][source]¶
Check that
requestis an MCP server request context.- Parameters:
request – The object the boundary handed over.
- Returns:
The same object, typed.
- Raises:
ConfigurationError – If it is not a
ServerRequestContext— which means this provider was wired to the wrong framework.
- read_header(request: ServerRequestContext[Any, Any], name: str) str | None[source]¶
Read one value from the request’s
_meta, falling back to transport headers.- Parameters:
request – The MCP request context.
name – The
_metakey, or header name, to read. Matched case-insensitively against headers, exactly against_metakeys —_metais an open map whose keys the client chooses, and quietly matching a near-miss there would be guessing at identity.
- Returns:
The value, or
Nonewhen neither carrier has it.
- render_error(error: Exception) CallToolResult[source]¶
Render a sentrykit refusal as a failed tool result carrying the
_authzenvelope.The body is
error_payload()’s — byte-identical to what a FastAPI or Flask caller would receive for the same refusal, because a refusal should read the same whether the caller was a browser or a model.- Parameters:
error – A
SentrykitError.- Returns:
A
CallToolResultwithis_errorset, the caller-facing reason as text content, and the whole payload as structured content.
- class sentrykit.integrations.mcp.SentrykitToolMiddleware(server: MCPServer | None = None, *, provider: MCPProvider | None = None, tools: Mapping[str, Callable[[...], object]] | None = None, filter_manifest: bool = True)[source]¶
Bases:
objectServer middleware that governs tool dispatch and filters the tool manifest.
Implements
mcp.server.context.ServerMiddleware. Registered outermost-first onMCPServer.middleware, it wraps every inbound message; everything that is neithertools/callnortools/listpasses straight through untouched.For a
tools/callthe lifecycle is the web providers’ exactly: establish on the way in, release in afinally. The release is not tidiness — a dispatch that kept the previous caller’s principal would authorize the next model call as somebody else, which is the worst failure this library can have.- Parameters:
server – The server whose tools are being governed, used to resolve an advertised tool name back to its callable for the manifest filter. Optional when
toolsis given.provider – The provider to establish with. Defaults to the one wired into the installed governor by
install(context_provider=...), and failing that to a_meta-identifyingMCPProvider.tools – An explicit tool name → callable mapping, for a host that would rather not rely on
registered_tools(). Takes precedence overserverwhen both are given.filter_manifest – Whether to filter
tools/list. Turning it off leaves enforcement completely intact — seesentrykit.integrations.tools— and is for a server that would rather advertise a stable manifest to every caller.
- async dispatch(ctx: ServerRequestContext[Any, Any], call_next: Callable[[ServerRequestContext[Any, Any]], Awaitable[BaseModel | dict[str, Any] | None]]) BaseModel | dict[str, Any] | None[source]¶
Establish context for one
tools/call, run it, and release.A
SentrykitErrorraised by a protected tool is caught and rendered as a failed tool result rather than allowed to propagate, because a propagating error loses the_authzenvelope (see the module docstring). Anything else the tool raises is the tool’s business and passes through untouched.A tool registered through
governed_tool()delivers its refusal wrapped in a_ToolRefusal, since that is the only shape mcp lets out of a tool body. A bareSentrykitErroris caught too — that is what a failure in the middleware’s own establishment path raises, and it deserves the same rendering.The decision itself is made exactly once, inside the decorator, so one dispatch writes one audit record. Nothing here re-decides.
- Parameters:
ctx – The request context.
call_next – The rest of the middleware chain.
- Returns:
The tool’s result, or the refusal.
- async manifest(ctx: ServerRequestContext[Any, Any], call_next: Callable[[ServerRequestContext[Any, Any]], Awaitable[BaseModel | dict[str, Any] | None]]) BaseModel | dict[str, Any] | None[source]¶
Filter a
tools/listresult down to what this caller may invoke.The result arrives already serialized and carrying its own
resultType, so thetoolsarray is rewritten in place and the server’s_metais left exactly as it built it. The freshness hints are not: see_keep_private().An unauthenticated caller is shown nothing: with no principal there is no entitlement to reason about, and every one of those tools would refuse on dispatch. That is the same fail-closed rule as everywhere else, applied to the manifest.
- Parameters:
ctx – The request context.
call_next – The rest of the middleware chain.
- Returns:
The result, with
toolsnarrowed.
- sentrykit.integrations.mcp.governed_tool(server: MCPServer, *, action: str | None = None, resource: str | None = None, name: str | None = None, description: str | None = None, optional: Sequence[str] | None = None) Callable[[Callable[[...], Any]], Callable[[...], Any]][source]¶
Register a protected callable on
serveras a governed tool.The MCP spelling of
sentrykit.integrations.pydantic_ai.governed_tool(), deliberately the same name and the same shape, because the two boundaries should read identically to somebody who has only ever wired the other one:@governed_tool(server, action='Action::"orders.search"', resource='Tool::"orders"') def search_orders(region: str, limit: int) -> dict[str, object]: ...
It does the three things a governed MCP tool needs, so a host does not have to remember them separately: applies
@protectwhen the callable does not already carry it, makes the policy-suppliable arguments omittable withproposable(), and registers the result behind the translator that lets a refusal out of the tool body. The module docstring says why none of those three is optional.The decorated function is returned unchanged – protected, and still directly callable by ordinary application code under an established context. Registering a tool should not take a function away from the rest of the program.
- Parameters:
server – The server to register on.
action – The Cedar action, as for
protect(). Ignored when the callable is already protected, since re-protecting it would stack two gates and silently discard whichever pair the author wrote first.resource – The Cedar resource, as for
protect(). Ignored when the callable is already protected.name – Tool name advertised to the model. Defaults to the function’s name.
description – Tool description. Defaults to the function’s docstring.
optional – Which parameters the model may omit, as for
proposable(). Defaults to every parameter with no Python default – precisely the set whose value could only have come from policy.
- Returns:
The decorator, which returns the protected callable.
- Raises:
ConfigurationError – If
actionorresourcecannot be parsed, or the callable’s signature cannot be introspected.
- sentrykit.integrations.mcp.protect_server(server: MCPServer, *, provider: MCPProvider | None = None, tools: Mapping[str, Callable[[...], object]] | None = None, filter_manifest: bool = True) MCPProvider[source]¶
Wire sentrykit into
serverin one call.Appends one
SentrykitToolMiddlewaretoserver.middleware. Calling it twice on the same server is a no-op the second time, so a module that wires at import and a test that wires in a fixture cannot double-govern a dispatch.- Parameters:
server – The MCP server.
provider – The provider to use. Defaults as for
SentrykitToolMiddleware.tools – An explicit tool name → callable mapping.
filter_manifest – Whether to filter
tools/list.
- Returns:
The provider that was wired, so a caller can hold on to it.
- sentrykit.integrations.mcp.registered_tools(server: MCPServer) dict[str, Callable[[...], object]][source]¶
Map every tool registered on
serverto the callable behind it.mcp 2.0 has no public accessor for this —
MCPServer._tool_manageris private, though thelist_tools()it exposes is an ordinary method and eachToolcarries.nameand.fn. So this reads it defensively and never raises: if the internal shape ever changes, the result is an empty mapping, every tool reads as ungoverned-and-visible, and enforcement on dispatch is completely unaffected. Degrading the manifest is survivable; raising from inside a livetools/listis not.- Parameters:
server – The MCP server.
- Returns:
Tool name → registered callable. Empty when the tools could not be read, with a warning logged.
sentrykit.integrations.pydantic_ai¶
The Pydantic-AI context provider — an agent acts for a human, and policy knows it.
Wire it per tool and every call the model proposes is authorized as the person the agent is running on behalf of:
from dataclasses import dataclass
from pydantic_ai import Agent
from sentrykit.decorator import protect
from sentrykit.integrations.pydantic_ai import governed_tool
@dataclass
class Caller:
principal: str # the human, not the agent
agent = Agent("...", deps_type=Caller)
@governed_tool(agent, action="order.read", resource='Order::"o-1"')
@protect(action="order.read", resource='Order::"o-1"')
def read_order(order_id: str, limit: int = 10) -> dict[str, object]:
"""Read an order."""
...
result = await agent.run("what is on order o-1?", deps=Caller(principal="alice"))
governed_tool registers the tool, bridges RunContext.deps to ambient context for the
duration of each dispatch, and installs the per-run manifest filter. @protect is the same
decorator a Flask view uses; nothing about the tool’s body knows it is being called by a
model.
On-behalf-of is the whole point, and it is not a special mechanism
An agent acting for a human is that human, as far as the policy is concerned. The
principal established here is the one named in deps — never a service identity for the
agent process — so a delegation chain, a team edge or an expiry granted to alice is what
governs the call the model proposed on alice’s behalf. Cedar resolves the chain; the model
never sees it, and could not influence it if it tried, because deps is supplied by the
application at run() time and the model has no way to write to it.
Why the tool is a plain function and the adapter is synthesized
A @agent.tool function takes ctx: RunContext[…] as its first parameter. Wrapping that
in @protect would make ctx a proposed argument: it would go into the Cedar request
context, through the per-principal argument filter, and into the pydantic validation model —
wrong three times over, and the last one raises.
So the tool you write is a plain, ctx-free, @protect-ed function, and
tool_adapter() builds the ctx-taking callable Pydantic-AI actually registers. The
adapter carries a synthesized __signature__ that prepends ctx, which produces exactly
the right tool definition: ctx absent from the JSON schema, the docstring’s argument
descriptions intact. The author still writes one decorator, which is the whole promise.
…and why the adapter re-declares the arguments as optional
The same synthesis solves a second problem, and it is the one that decides whether default
injection exists on this boundary at all. Pydantic-AI validates the model’s proposal against
the advertised JSON schema before dispatch, so a parameter the schema marks required
can never be omitted — and an argument that can never be omitted can never be filled in from
the principal’s policy default. The whole “which defaults to inject when they pass none” half
of what sentrykit does would be unreachable from an agent.
proposable() is therefore applied inside
tool_adapter(), between the protected callable and the ctx-prepending layer. It
re-declares every parameter that has no Python-level default as keyword-only and optional, and
forwards only the arguments the model genuinely proposed. The result is that a model may leave
region out, the omission reaches the decorator as a real omission, and policy supplies the
value — which is exactly what the FastAPI example’s route does one layer out when it writes
if region is not None. Name the set yourself with optional= when the default is wrong
for your tool.
Establish per dispatch, not per run
release happens in a finally around each individual tool call, not around the whole
agent.run(...). A run is a loop: the model proposes, a tool runs, the model proposes again.
Holding one establishment across all of that would mean a principal outliving the dispatch it
was established for — and the no-leak test asserts exactly that it does not, by driving two
dispatches inside one contextvars context and checking the principal is gone in between.
Refusals come back as data, not as exceptions
A refused tool returns the _authz payload as its result — the same body a FastAPI caller
would get for the same refusal — with is_error implied by decision: "deny". The side
effect never happened, and the model is told why in a form it can act on: pick a different
tool, ask the user for authorization, or explain the refusal. Letting the error propagate
would abort the run and tell the user’s agent nothing useful. Hosts that would rather fail the
run outright pass on_refusal="raise".
- sentrykit.integrations.pydantic_ai.DEFAULT_DEPS_ATTRIBUTE: str = 'principal'¶
Attribute (or mapping key) on
RunContext.depsthe principal id is read from.
- sentrykit.integrations.pydantic_ai.OnRefusal¶
What a governed tool does with a refusal.
returnHand the
_authzpayload back as the tool’s result. The run continues and the model can adapt. The default, and the reason is in this module’s docstring.raiseLet the
SentrykitErrorpropagate and end the run. For a host that treats any refusal as a bug in its own prompt or wiring.
alias of
Literal[‘return’, ‘raise’]
- class sentrykit.integrations.pydantic_ai.PydanticAIProvider(*, deps_attribute: str = DEFAULT_DEPS_ATTRIBUTE, identify: Callable[[RunContext[Any]], str | EntityRef | Principal | None] | None = None, grants: GrantStore | None = None, environment: Callable[[RunContext[Any]], Mapping[str, object]] | None = None, principal_header: str = DEFAULT_PRINCIPAL_HEADER, request_id_header: str = DEFAULT_REQUEST_ID_HEADER, channel: Literal['http', 'agent', 'internal'] = 'agent')[source]¶
Bases:
BaseContextProvider[RunContext[Any]]Establishes ambient context from
RunContext.depsand renders refusals as results.Construction is inherited from
BaseContextProviderwith one addition,deps_attribute; see there foridentify,grantsandenvironment. The channel defaults toagent.- Parameters:
deps_attribute – Where the principal id lives on
deps— a mapping key whendepsis a mapping, an attribute otherwise. Ignored whenidentifyis given, which is what an application with a richerdepstype should pass.identify – As for the base class, but receiving the
RunContext.grants – As for the base class.
environment – As for the base class.
principal_header – Fallback key read from
depswhendeps_attributeyields nothing, so a plaindictdeps shaped like a request also works.request_id_header – Fallback key for a correlation id. Rarely needed — see
request_id().channel – The channel established for every dispatch.
- classmethod from_dsn(dsn: DSN) PydanticAIProvider[source]¶
Build from
pydantic-ai://— the registry’s door, used byinstall().- Parameters:
dsn – The parsed address. Options:
deps_attribute,header,request_id_header.- Returns:
The provider.
- property name: str¶
pydantic-ai.
- property deps_attribute: str¶
Where the principal id is read from on
deps.
- narrow(request: object) RunContext[Any][source]¶
Check that
requestis a Pydantic-AI run context.- Parameters:
request – The object the boundary handed over.
- Returns:
The same object, typed.
- Raises:
ConfigurationError – If it is not a
RunContext— which means this provider was wired to the wrong framework.
- identity_of(request: RunContext[Any]) str | EntityRef | Principal | None[source]¶
Resolve who this dispatch acts for, from
deps.Overrides the base class’s header-reading default because
depsis a typed object an application designed, not a bag of strings. In order: the app’sidentifycallback when one was given; thendepsitself when it is already aPrincipalor a bare id; thendeps_attributeon it; thenprincipal_header.deps=Nonemeans unauthenticated, which is a legitimate state — an agent run with no caller — and every governed tool in it will fail closed.- Parameters:
request – The run context.
- Returns:
The identity, or
None.
- read_header(request: RunContext[Any], name: str) str | None[source]¶
Read one named value from
deps.- Parameters:
request – The run context.
name – A mapping key when
depsis a mapping; otherwise an attribute name, with-read as_so a header-shaped name still resolves.
- Returns:
The value as a string, or
None.
- request_id(request: RunContext[Any]) str[source]¶
Return the correlation id for one dispatch.
Pydantic-AI already has one:
RunContext.run_idis stable across every tool call in a run, which is exactly the correlation an audit trail wants — one id spanning the model’s whole chain of proposals rather than a fresh UUID per tool. The tool call’s own id goes into the environment instead, so a record can still be traced to a single dispatch.- Parameters:
request – The run context.
- Returns:
The run id, falling back to the base class’s behaviour when there is none.
- environment_for(request: RunContext[Any]) dict[str, object][source]¶
Build the Cedar request context for one dispatch.
Adds
tool(the tool the model proposed) andtool_call_idto what the base class contributes, so a policy can condition on which tool is being invoked without the action id having to encode it, and an audit record names the individual proposal.- Parameters:
request – The run context.
- Returns:
The environment to establish.
- render_error(error: Exception) dict[str, object][source]¶
Render a sentrykit refusal as the mapping a governed tool returns.
The body is
error_payload()’s — the same one the web providers put in an HTTP response and the MCP provider puts in a tool result — so_authzreads identically wherever a refusal surfaces.- Parameters:
error – A
SentrykitError.- Returns:
The refusal payload, carrying
detail,errorand_authz.
- sentrykit.integrations.pydantic_ai.governed_tool(agent: Agent[Any, Any], *, action: str | None = None, resource: str | None = None, provider: PydanticAIProvider | None = None, name: str | None = None, description: str | None = None, on_refusal: Literal['return', 'raise'] = 'return', filter_manifest: bool = True, optional: Sequence[str] | None = None) Callable[[Callable[[...], Any]], Callable[[...], Any]][source]¶
Register a protected callable on
agentas a governed tool.Does the three things a governed tool needs, so a host does not have to remember them separately: applies
@protectwhen the callable does not already carry it, registerstool_adapter()’s adapter viaagent.tool, and attachestool_prepare()so the tool is hidden from any run whose principal could not invoke it.The decorated function is returned unchanged — protected, and still directly callable by ordinary application code under an established context. Registering a tool should not take a function away from the rest of the program.
- Parameters:
agent – The agent to register on.
action – The Cedar action, as for
protect(). Ignored when the callable is already protected.resource – The Cedar resource, as for
protect(). Ignored when the callable is already protected.provider – Defaults as for
tool_adapter().name – Tool name advertised to the model. Defaults to the function’s name.
description – Tool description. Defaults to the function’s docstring.
on_refusal – What to do with a refusal.
filter_manifest – Whether to hide the tool from runs that could not invoke it. Turning it off leaves enforcement completely intact.
optional – Which parameters the model may omit so policy can supply them, as for
tool_adapter(). Defaults to every parameter with no Python-level default.
- Returns:
The decorator, which returns the protected callable.
- sentrykit.integrations.pydantic_ai.tool_adapter(fn: Callable[[...], object], *, provider: PydanticAIProvider | None = None, on_refusal: Literal['return', 'raise'] = 'return', optional: Sequence[str] | None = None) Callable[[...], Any][source]¶
Wrap a plain protected callable as the
ctx-taking function Pydantic-AI registers.The returned adapter establishes ambient context from
ctx, callsfnwith the arguments the model proposed, and releases in afinally.Its signature is built in two layers, and both are load-bearing. Innermost,
proposable()re-declaresfn’s parameters so the ones policy could supply are omittable — without which the model must invent a value for every parameter and default injection never happens, per this module’s docstring. Outermost, a synthesized__signature__prependsctx: RunContext[Any]. What the model is shown is thereforefn’s tool definition withctxexcluded, the docstring’s argument descriptions intact, andrequiredempty for the omittable parameters.Both layers record what they wrap under
__wrapped__, andgoverned_meta()walks the whole chain, so the@protectmetadata is still reachable from the registered adapter.- Parameters:
fn – The tool body — a plain,
ctx-free callable, normally@protect-ed. Sync and async are both supported; the adapter is always async, since Pydantic-AI awaits it either way.provider – The provider to establish with. Defaults to the one wired into the installed governor, and failing that to a
deps-readingPydanticAIProvider.on_refusal – What to do with a
SentrykitError.optional – Which parameters the model may omit, as for
proposable(). Defaults to every parameter with no Python-level default. Name the set explicitly when a parameter takes a meaningfulNone, which the shim would otherwise read as not proposed.
- Returns:
The adapter.
- Raises:
ConfigurationError – If
fn’s signature cannot be introspected, if it already declares a parameter namedctx, or ifoptionalnames a parameter it does not declare.
- sentrykit.integrations.pydantic_ai.tool_filter(tools: Mapping[str, Callable[[...], object]], *, provider: PydanticAIProvider | None = None) Callable[[RunContext[Any], list[ToolDefinition]], Any][source]¶
Build a whole-agent
prepare_toolshook over an explicit tool mapping.The one-hook alternative to a
prepareper tool, for an agent assembled from tools it did not register throughgoverned_tool():agent = Agent(model, deps_type=Caller, prepare_tools=tool_filter({"read_order": read_order}))
A definition whose name is absent from
toolsis left alone rather than dropped — this hook governs what it was told about and does not quietly remove an agent’s other tools.- Parameters:
tools – Tool name → the callable behind it.
provider – Defaults as for
tool_adapter().
- Returns:
A
ToolsPrepareFunc, ready to pass toAgent(...).
- sentrykit.integrations.pydantic_ai.tool_prepare(fn: Callable[[...], object], *, provider: PydanticAIProvider | None = None) Callable[[RunContext[Any], ToolDefinition], Any][source]¶
Build the per-tool
preparehook that hidesfnwhen its principal may not run it.This is Pydantic-AI’s
list_tools: returningNonefrompreparedrops the tool from the definitions offered to the model for that run. The decision isdecide_tool()’s — the same gate the dispatch path runs — so a hidden tool is a denied tool and vice versa. Seesentrykit.integrations.toolsfor why that equivalence is stated as an invariant and why hiding is never the enforcement.- Parameters:
fn – The tool body, or the adapter around it. Either resolves to the same metadata.
provider – The provider whose environment the probe runs under. Defaults as for
tool_adapter().
- Returns:
A
ToolPrepareFunc, ready to pass toagent.tool.