Core

The thin core: no web framework, no agent SDK, no optional import anywhere in this list. These twelve modules are what pip install sentrykit gives you before any extra.

sentrykit.decorator

@protect — one decorator, and N context providers behind it.

Everything a framework contributes is a context provider. Everything a developer writes is this decorator. A Flask view, a background reconciliation job and an MCP tool are all guarded by the same three lines of code, because the decorator only supplies the action and the proposed arguments; who is acting comes from the ambient context.

@protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
async def search_orders(customer_id: str, limit: int = 10) -> list[Order]: ...

The wrapper is signature-preserving, so type checkers, IDEs, and — importantly — FastAPI’s and Pydantic-AI’s signature introspection see the original function.

sentrykit.decorator.AS_KEYWORD: str = '_as'

Call keyword that overrides the ambient principal for a single invocation.

class sentrykit.decorator.ProtectedMeta(action: EntityRef, resource: EntityRef | None, spec: ArgumentSpec, governor: Governor | None)[source]

Bases: object

What @protect records on the callable it wraps.

Attached as __sentrykit__ so that tooling can introspect a protected callable without calling it — which is exactly what the agent-side list_tools filtering does to decide whether a principal should be shown a tool at all.

Variables:
sentrykit.decorator.is_protected(fn: object) → bool[source]

Whether fn carries a @protect wrapper.

sentrykit.decorator.protect(fn: Callable[[P], T], /) → Callable[[P], T][source]
sentrykit.decorator.protect(*, action: str | EntityRef | None = None, resource: str | EntityRef | None = None, governor: Governor | None = None) → Callable[[Callable[[P], T]], Callable[[P], T]]

Guard a callable with pre-action authorization.

On every call the wrapper resolves the principal, binds the proposed arguments to parameter names, and hands both to the governor, which gates, filters, defaults, validates and only then invokes. Sync and async callables are both supported; the wrapper matches whichever it wraps.

Usable bare (@protect) or with arguments (@protect(action=...)).

The principal comes from the ambient context established by a provider. It can be overridden for one call by passing _as=<Principal>, which the wrapper strips before binding — useful for a scheduled job that acts for a specific user, and for tests. Because the wrapper preserves the wrapped signature, a strictly type-checked caller passing _as= needs a type: ignore[call-arg]; prefer establish() where that matters.

No ambient principal and no ``_as=`` means the call is denied, not run unguarded. That is the fail-closed rule, and it is why forgetting to wire a provider surfaces immediately rather than as a silent hole.

Parameters:
  • fn – The callable, when used bare as @protect.

  • action – The Cedar action. Accepts a full uid (Action::"tool.search") or a bare id, which is read as an Action. Defaults to Action::"<module>.<qualname>".

  • resource – The Cedar resource. Accepts a full uid or a bare id read as a Resource. Defaults to a resource derived from the action, so that a callable with no natural resource still fits Cedar’s principal/action/resource model.

  • governor – Pin a governor to this callable. Defaults to the process-installed one, resolved at call time so wiring order does not matter.

Returns:

The guarded callable, with the original signature and an attached ProtectedMeta.

Raises:

ConfigurationError – If action or resource cannot be parsed, or the callable’s signature cannot be introspected.

sentrykit.decorator.protected_meta(fn: object) → ProtectedMeta | None[source]

Return the metadata @protect attached to fn, if it is protected.

Parameters:

fn – Any object, typically a callable.

Returns:

The metadata, or None when fn is not protected.

sentrykit.context

Ambient authorization context: who is acting, and under what circumstances.

A protected callable almost never receives the principal as an argument — a Flask view takes a request, an agent tool takes the arguments the model proposed, a background job takes none. Threading a principal through every signature between the boundary and the call would be the tax that stops teams from protecting the inner functions at all.

So sentrykit makes the principal ambient instead: a context provider calls establish() once at the boundary, and @protect reads the ambient principal wherever the callable happens to live. Three rules follow from that, and they are load-bearing:

  • An explicit _as= on the call beats the ambient principal.

  • No principal means deny. Not “run unauthenticated”, not “skip the check”.

  • ContextVar values propagate across await and across asyncio.to_thread, but not across a bare ThreadPoolExecutor.submit. Offloading through an executor without bind_context() would silently lose the principal — and, because of the previous rule, fail closed rather than fail open.

sentrykit.context.Channel

Where the invocation came from. Policies can condition on it via context.channel.

alias of Literal[‘http’, ‘agent’, ‘internal’]

class sentrykit.context.ContextScope(principal_token: Token, environment_token: Token)[source]

Bases: object

The handle establish() returns: undo the establishment when the boundary ends.

Usable two ways, because the two boundaries need different shapes. A framework hook that has no enclosing block — Flask’s before_request, an MCP dispatch callback — keeps the handle and calls reset(). Everything else uses it as a context manager, which resets even when the body raises.

Example

with establish(principal, channel="agent"):
    await run_tool()
reset() → None[source]

Restore the principal and environment that were ambient before establishment.

Idempotent: resetting an already-reset scope does nothing.

class sentrykit.context.Invocation(*, id: str, principal: Principal, action: EntityRef, resource: EntityRef, proposed: dict[str, object]=<factory>, environment: dict[str, object]=<factory>, callable_name: str = '')[source]

Bases: BaseModel

One normalized call, as the core sees it — independent of how it arrived.

This is the object architecture §1 describes: the decorator supplies the action and the proposed arguments, a context provider supplies the principal and the environment, and nothing downstream can tell whether it came from an HTTP route, an MCP tool dispatch, or a plain function call.

Variables:
  • id (str) – Correlation id, shared by the Cedar result, the operational logs and the audit record.

  • principal (sentrykit.context.Principal) – Who is acting.

  • action (sentrykit.cedar.EntityRef) – What they propose to do.

  • resource (sentrykit.cedar.EntityRef) – What they propose to do it to.

  • proposed (dict[str, object]) – The arguments as supplied — untrusted input, before any filtering.

  • environment (dict[str, object]) – Cedar request context: now, channel, request id, auth strength, delegation chain, break-the-glass flag.

  • callable_name (str) – Qualified name of the protected callable, for diagnostics.

property channel: str

The channel from environment, defaulting to internal.

property now: datetime | None

The decision timestamp from environment, if one was established.

class sentrykit.context.Principal(*, id: str, type: str = 'User', roles: tuple[str, ...]=(), parents: tuple[~sentrykit.cedar.EntityRef, ...]=(), attributes: dict[str, object]=<factory>, constraints: dict[str, ~sentrykit.constraints.ConstraintSet]=<factory>)[source]

Bases: BaseModel

Who is acting, as sentrykit sees them.

A principal is a Cedar entity plus the argument constraints that have been granted to them. Roles, teams and delegation edges are all just parent edges: Cedar computes the transitive closure, which is how a multi-hop relationship — user in team, team on project, project owns resource — gets resolved by policy rather than by a chain of if statements in the application.

Variables:
  • id (str) – The entity id.

  • type (str) – The entity type. User covers the common case; an agent acting for a human is still modelled as that human, with the delegation recorded in attributes.

  • roles (tuple[str, ...]) – Convenience for the most common parent edge. Each entry r becomes a parent Role::"r".

  • parents (tuple[sentrykit.cedar.EntityRef, ...]) – Any other direct parent edges — teams, projects, delegation targets.

  • attributes (dict[str, object]) – Cedar attributes for this principal, snapshotted at decision time. Values are coerced for Cedar, so None entries are dropped.

  • constraints (dict[str, sentrykit.constraints.ConstraintSet]) – Argument constraints keyed by action. The key may be a full uid (Action::"tool.search"), a bare action id (tool.search), or "*" for constraints that apply to every action. In a wired application these are materialized from the grant store rather than constructed by hand.

property uid: EntityRef

This principal as a Cedar entity reference.

all_parents() → tuple[EntityRef, ...][source]

Every parent edge — roles expanded to Role::"..." plus parents.

constraints_for(action: EntityRef) → ConstraintSet[source]

Resolve the argument constraints that apply to action.

Lookup runs from least to most specific: the "*" wildcard set first, then the bare action id, then the full uid — each layered over the previous, so a wildcard baseline can be refined per action.

Parameters:

action – The action being authorized.

Returns:

The merged constraint set, empty when nothing applies.

sentrykit.context.bind_context(fn: ~collections.abc.Callable[[~P], ~sentrykit.context.T], /, *args: ~typing.~P, **kwargs: ~typing.~P) → Callable[[], T][source]

Bind a call to a copy of the current context, for offload to a thread pool.

ContextVar values follow await and asyncio.to_thread on their own, so most code needs nothing. A bare ThreadPoolExecutor.submit or loop.run_in_executor is the exception: the worker thread starts from an empty context, the ambient principal is gone, and every protected call inside it denies. Wrapping the call here carries the context across.

Example

executor.submit(bind_context(reconcile_orders, batch_id))
Parameters:
  • fn – The callable to run in the other thread.

  • *args – Positional arguments for fn.

  • **kwargs – Keyword arguments for fn.

Returns:

A zero-argument thunk that runs fn inside a copy of the calling context.

sentrykit.context.clear_context() → None[source]

Drop the ambient principal, the environment, and the last decision envelope.

For tests and for long-lived workers that reuse a context between units of work. The envelope goes too: a worker that has finished one unit of work should not be able to report the previous one’s decision as if it were its own.

sentrykit.context.current_environment() → Mapping[str, object][source]

Return the ambient request environment, empty when nothing was established.

sentrykit.context.current_principal() → Principal | None[source]

Return the ambient principal, or None if none has been established.

sentrykit.context.establish(principal: Principal, **environment: object) → ContextScope[source]

Make principal the ambient principal for this context.

Called by a context provider at the framework boundary. The environment keys become the Cedar request context, so policies can condition on them directly.

Parameters:
  • principal – Who the subsequent protected calls are acting as.

  • **environment – Request environment — channel, request_id, auth_strength, break_glass, a delegation chain, and anything else the policy pack expects. now is filled in by the engine at decision time unless supplied here.

Returns:

A scope handle. Use it as a context manager, or call ContextScope.reset() when the boundary has no enclosing block.

sentrykit.context.principal_context(principal: Principal, **environment: object) → Generator[None, None, None][source]

Establish principal for the duration of a with block.

The generator-based twin of establish(), for callers who prefer an explicit context manager over a handle that happens to be one.

Parameters:
  • principal – Who the block is acting as.

  • **environment – Request environment, as for establish().

Yields:

None, with the context established.

sentrykit.context.require_principal(action: str) → Principal[source]

Return the ambient principal, or refuse the call.

Parameters:

action – The action being attempted, for the error message.

Returns:

The ambient principal.

Raises:

NoContext – If no principal is ambient. This is the fail-closed rule, and it holds regardless of fail_mode: an engine that cannot tell who is calling has nothing to authorize.

sentrykit.engine

The Governor — the decision pipeline every protected call runs through.

One pass, in a fixed order, described in architecture §2:

gate  ->  filter args  ->  inject defaults  ->  validate  ->  invoke  ->  envelope

The order is not arbitrary. The coarse Cedar gate runs first because there is no point reasoning about arguments for a call that is not permitted at all. Filtering precedes defaults so that a dropped argument can be replaced by the principal’s default rather than leaving a hole. Validation runs last, on the final argument set, so the callable never sees a value that policy did not sanction — and so a validation failure is genuinely about types, not about authorization.

The split between Governor.plan() and Governor.invoke() exists so the decision can be made without performing the side effect. Agent-side list_tools filtering plans a call it never invokes, to decide whether to show the tool to a model at all.

class sentrykit.engine.ArgumentSpec(name: str, signature: Signature, parameters: tuple[str, ...], positional_only: tuple[str, ...], required: frozenset[str], model: type[BaseModel], is_async: bool, accepts_var_keyword: bool)[source]

Bases: object

Everything the engine needs to know about a protected callable’s arguments.

Derived once per callable and cached on the decorator’s wrapper, because building a pydantic model from a signature is far more expensive than the authorization decision it supports.

Variables:
  • name (str) – Qualified name, used as the derived action id and in diagnostics.

  • signature (inspect.Signature) – The callable’s signature, used to bind proposed positional arguments to parameter names.

  • parameters (tuple[str, ...]) – Every parameter name the callable accepts, in declaration order.

  • positional_only (tuple[str, ...]) – The subset that must be passed positionally.

  • required (frozenset[str]) – Parameters with no Python-level default, which the call cannot omit.

  • model (type[pydantic.main.BaseModel]) – A pydantic model over the parameters, for validating the final argument set.

  • is_async (bool) – Whether the callable is a coroutine function.

  • accepts_var_keyword (bool) – Whether the callable declares **kwargs, in which case a policy default for an unlisted name is still deliverable.

classmethod from_callable(fn: Callable[[...], object]) → ArgumentSpec[source]

Derive a spec by introspecting fn.

Unannotated parameters are typed as object so that an untyped callable is still protectable — sentrykit’s job is authorization, not enforcing that its users annotate their code. *args and **kwargs are not modelled as fields.

Parameters:

fn – The callable being protected.

Returns:

The derived spec.

Raises:

ConfigurationError – If the signature cannot be introspected or its annotations cannot be resolved into a model.

bind(args: Sequence[object], kwargs: Mapping[str, object]) → dict[str, object][source]

Map a call’s positional and keyword arguments onto parameter names.

Uses bind_partial, so a call that omits arguments the policy will default is not an error here.

Parameters:
  • args – Positional arguments as passed.

  • kwargs – Keyword arguments as passed.

Returns:

Parameter name to proposed value.

Raises:

ConfigurationError – If the arguments cannot be bound to the signature at all.

split(arguments: Mapping[str, object]) → tuple[list[object], dict[str, object]][source]

Split a final argument mapping back into positional and keyword form.

Necessary because positional-only parameters cannot be passed by name; calling fn(**arguments) would fail on them.

Parameters:

arguments – The processed arguments.

Returns:

Positional arguments in declaration order, and the keyword remainder.

validate(arguments: Mapping[str, object]) → dict[str, object][source]

Validate the final argument set against the callable’s model.

Parameters:

arguments – The processed arguments.

Returns:

The validated arguments, coerced by pydantic where the annotations call for it.

Raises:

ValidationFailed – Carrying pydantic’s structured field errors.

class sentrykit.engine.Governor(policy: PolicyEngine, settings: Settings | None = None, *, audit: AuditTrail | None = None, bundle: PolicyBundle | None = None, grants: GrantStore | None = None, context_provider: ContextProvider | None = None)[source]

Bases: object

Runs the authorization pipeline against one policy engine.

Parameters:
  • policy – The Cedar policy set and entity graph to decide against.

  • settings – Base settings. Defaults to the process-wide configuration, resolved per action at decision time so a per-action override still applies.

  • audit – Where decisions are recorded. None — the default — means no records are built at all, so a governor wired without a sink costs exactly what it did before the audit trail existed.

  • bundle – The loaded policy bundle, when the governor was built from a PolicySource. Supplies the per-policy annotation index obligations are read from, and the provenance the audit record carries. Without one, obligations stay empty and provenance is unavailable, which is exactly how a governor wired from raw policy text behaves.

  • grants – The grant store principals were materialized from, when there is one. The decision path never consults it — a principal arrives already materialized, by way of establish() — but the admin surface needs to reach the store it is governing, and holding the wiring in one object beats a second process-global.

property policy: PolicyEngine

The policy engine this governor decides against.

property audit: AuditTrail | None

The audit trail this governor records to, if any.

property bundle: PolicyBundle | None

The policy bundle this governor’s policies came from, if any.

property grants: GrantStore | None

The grant store backing this governor’s principals, if one was wired.

property context_provider: ContextProvider | None

The framework provider wired for this process, if one was.

Held here for the same reason grants is: the decision path never touches it – a principal reaches the governor already established – but the boundary adapter has to find the wiring somewhere, and one object beats a second process-global. It is what lets SentrykitMiddleware(app) be spelled with no arguments once install(context_provider=...) has run.

plan(spec: ArgumentSpec, *, principal: Principal, action: EntityRef, resource: EntityRef | None = None, proposed: Mapping[str, object] | None = None, environment: Mapping[str, object] | None = None, invocation_id: str | None = None) → Plan[source]

Decide an invocation without performing it.

Runs the gate, the argument filter, default injection and validation, in that order.

Parameters:
  • spec – The target callable’s argument spec.

  • principal – Who is acting. Resolving the ambient principal — and refusing when there is none — happens in the decorator, before this point.

  • action – What they propose to do.

  • resource – What they propose to do it to. Defaults to Resource::"<action id>" so that a callable which declares no resource is still expressible in Cedar’s three-part model.

  • proposed – The arguments as supplied.

  • environment – Request environment. Merged over the ambient environment, with now and channel filled in when absent.

  • invocation_id – Correlation id. Generated when omitted; pass one to keep an audit trail deterministic in tests.

Returns:

The decided plan.

Raises:
  • AuthzDenied – If the gate denied, or if the engine failed under fail_mode="closed".

  • ArgumentRejected – If an argument violated its constraint under arg_handling="strict".

  • NoValidArguments – If the caller supplied arguments, every one was rejected, and defaults cannot complete the call.

  • DefaultUnavailable – If a required argument has neither a supplied value nor a default.

  • ValidationFailed – If the final argument set does not satisfy the callable’s model.

invoke(fn: Callable[[...], T], plan: Plan) → T[source]

Perform a planned call and attach its envelope.

Parameters:
  • fn – The protected callable.

  • plan – The Plan produced by plan() for this call.

Returns:

Whatever fn returned, with the _authz envelope attached when the result is a mapping and envelope_on_success is set.

async ainvoke(fn: Callable[[...], Awaitable[T]], plan: Plan) → T[source]

Await a planned call and attach its envelope.

The decision itself is synchronous — Cedar evaluation is in-process and cheap — so this differs from invoke() only in awaiting the callable. Hosts that need the decision off the event loop wrap it with bind_context().

Parameters:
  • fn – The protected coroutine function.

  • plan – The Plan produced by plan() for this call.

Returns:

Whatever fn resolved to, with the envelope attached where applicable.

class sentrykit.engine.Plan(invocation: Invocation, decision: CedarDecision, settings: Settings, spec: ArgumentSpec, processed: dict[str, object]=<factory>, rejected: tuple[~sentrykit.constraints.RejectedArgument, ...]=(), defaults_applied: dict[str, object]=<factory>, signature_defaults: tuple[str, ...]=(), obligations: tuple[str, ...]=(), trace: dict[str, object]=<factory>)[source]

Bases: object

A decided-but-not-yet-performed invocation.

Holding the decision separately from the side effect is what lets the same pipeline answer “may this run?” for a tool manifest and “run this” for a real call.

Variables:
  • invocation (sentrykit.context.Invocation) – The normalized invocation the decision was made about.

  • decision (sentrykit.cedar.CedarDecision) – What Cedar said.

  • settings (sentrykit.config.Settings) – The effective settings for this action.

  • spec (sentrykit.engine.ArgumentSpec) – The target callable’s argument spec.

  • processed (dict[str, object]) – The final arguments, filtered, defaulted and validated.

  • rejected (tuple[sentrykit.constraints.RejectedArgument, ...]) – Arguments the policy refused.

  • defaults_applied (dict[str, object]) – Arguments filled in from the principal’s defaults.

  • signature_defaults (tuple[str, ...]) – Governed parameters whose value came from the callable’s own signature, after the constraint that governs them checked it and accepted it. Deliberately not merged into processed: that is the set sentrykit decided and passed, and a signature-sourced value is one it declined to fill, so folding them together would lose the distinction. Names only.

  • obligations (tuple[str, ...]) – Obligations the policy attached.

  • trace (dict[str, object]) – Debug detail, populated only at debug verbosity.

property allowed: bool

Whether the call may proceed.

True for a clean Cedar allow, and for an engine failure that fail_mode="open" waved through. A clean policy deny never reaches here — Governor.plan() raises instead of returning a refused plan.

narrowing() → tuple[tuple[str, ...], tuple[str, ...]][source]

Name the arguments whose proposed value did not survive, and which lost it entirely.

Narrowing is defined against the proposal, not against rejected, because the two differ in both directions. A rejection whose argument kept a surviving value of the caller’s own is not narrowing; a withheld default — a rejection for an argument the caller never mentioned — is not narrowing either; and an argument dropped with no replacement is narrowing even though nothing was injected to mark it.

Returns:

The narrowed parameter names, and the subset of them that reached the callable with no sentrykit-supplied value — so the callable’s own signature default applied, which no constraint ever checked.

envelope() → AuthzEnvelope | None[source]

Build the caller-facing envelope for this plan, honoring its verbosity.

sentrykit.engine.current_governor() → Governor | None[source]

Return the process-default governor, or None if none is installed.

sentrykit.engine.get_governor() → Governor[source]

Return the process-default governor.

Returns:

The installed governor.

Raises:

ConfigurationError – If none is installed. Protecting a callable without wiring a policy is a configuration mistake, and it fails loudly at call time rather than quietly allowing.

sentrykit.engine.install(policies: str | None = None, *, entities: Sequence[Entity] = (), schema: str | None = None, settings: Settings | None = None, policy_source: str | PolicySource | Sequence[str | PolicySource] | None = None, grant_store: str | GrantStore | Sequence[str | GrantStore] | None = None, audit_sink: str | AuditSink | Sequence[str | AuditSink] | None = None, context_provider: str | ContextProvider | None = None, masker: Masker | None = None) → Governor[source]

Wire a governor and make it the process default.

One call covers both ends of the range. Hand it a policy string and it wires an in-memory governor — the shape for tests and for a service whose policy fits in a literal. Hand it DSNs instead and the same call assembles the pluggable substrate: policy sources unioned, grant stores merged, audit sinks fanned out, each resolved through the registry so a third-party backend is reachable by name alone.

Either way the result is one Governor, because multiplicity is a composite that satisfies the same Protocol — the decision path never learns how many sources were configured.

Parameters:
  • policies – Cedar policy text. Mutually exclusive with policy_source.

  • entities – Extra stable entities — roles, teams, resources — merged on top of whatever the sources and the grant store contribute.

  • schema – Optional Cedar schema text, for inline policies.

  • settings – Base settings, defaulting to the process configuration.

  • policy_source – One DSN ("file://./policies"), several, an already-built source, or a sequence mixing the two — ["pack://", LinkedPolicySource(links)] unions the shipped pack with something the host constructed itself.

  • grant_store – One DSN ("memory://"), several, or an already-built store. Its stable entities join the graph; its principals do not, since those are per-request.

  • audit_sink – One DSN ("jsonl://./audit.jsonl"), several, or an already-built sink. Defaults to SENTRYKIT_AUDIT_SINK. Nothing configured means no audit trail — a library must not begin writing files merely because it was imported.

  • context_provider – One DSN ("fastapi://") or an already-built provider, wired so a framework adapter can find it with no second configuration step. Exactly one, and never a sequence: a process serves one boundary, and there is no composite that could guess which framework an incoming request belongs to.

  • masker – How sensitive values are masked before an audit write. Defaults to one built from the settings.

Returns:

The installed governor.

Raises:

ConfigurationError – If neither or both of policies/policy_source were given, if a DSN names an unknown backend, or if the policies, entities or schema fail to parse.

sentrykit.engine.set_governor(governor: Governor | None) → None[source]

Install governor as the process default, or clear it with None.

sentrykit.engine.use_governor(governor: Governor) → Generator[Governor, None, None][source]

Install governor for the duration of a block, then restore the previous one.

Parameters:

governor – The governor to use.

Yields:

The governor, for convenience.

sentrykit.cedar

Thin wrapper over cedarpy — the only module in sentrykit that imports Cedar.

Everything else in the core talks to CedarDecision, a normalized value object, so the decision path stays unit-testable without a policy set and so a future engine binding — partial evaluation, when Cedar exposes it — can be swapped in behind this seam.

Three things this module owns that callers should not re-implement:

  1. Pre-parsed handles. cedarpy exposes reusable PolicySet / Entities / Schema handles. PolicyEngine parses once at construction — so a bad policy raises ConfigurationError at wiring time rather than silently denying every request at runtime — and reuses the handles per call.

  2. Determining-policy naming. Cedar reports parser-generated ids (policy0) in its diagnostics; the human-meaningful name is the policy’s @id("...") annotation. CedarDecision.determining_policies carries the annotation when one exists and the generated id otherwise, so denial messages and audit records stay readable.

  3. Value coercion, and the boundary it defends. Cedar’s type system has no floating-point and no null. A stray None in the request context turns the whole decision into NoDecision, and a None in an entity attribute makes the entity set fail to parse outright. Every value handed to Cedar passes through coerce_value() first. The same function owns the one boundary that matters for forgery: a supplied mapping can never spell Cedar’s reserved __ escape keys, so it cannot promote itself into a typed entity reference. An EntityRef object still becomes one — which is what that type is for, and what makes it the application’s decision rather than the library’s. See coerce_value() for the consequence when an application annotates a caller-facing parameter with it.

sentrykit.cedar.RESERVED_KEY_PREFIX: Final = '__'

Prefix Cedar reserves for its own escape forms in request and entity JSON.

__entity and __extn are the two Cedar defines today, and the prefix as a whole is reserved — so the check is on the prefix, not on the two spellings, and stays correct if Cedar adds a third.

class sentrykit.cedar.CedarDecision(allowed: bool, decision: Literal['allow', 'deny', 'no-decision'], determining_policies: tuple[str, ...] = (), errors: tuple[str, ...] = (), correlation_id: str | None = None, policy_set_hash: str = '')[source]

Bases: object

A Cedar authorization result, normalized.

Variables:
  • allowed (bool) – Whether Cedar returned Allow. Deny and NoDecision are both False; read decision to tell them apart.

  • decision (Literal['allow', 'deny', 'no-decision']) – The raw verdict, lower-cased.

  • determining_policies (tuple[str, ...]) – The policies that decided it, named by their @id annotation where they declare one. Empty on a default deny — which is itself diagnostic: it means no policy matched at all.

  • errors (tuple[str, ...]) – Cedar parse or evaluation errors. Cedar folds these into the result instead of raising; a non-empty list is an engine failure, distinct from a policy denial, and is what fail_mode arbitrates.

  • correlation_id (str | None) – Echoed from the request.

  • policy_set_hash (str) – Digest of the policy text the decision was evaluated against, so a decision can be replayed later against the same logic.

property failed: bool

Whether the engine failed, as opposed to reaching a clean allow or deny.

as_trace() → dict[str, object][source]

Render the decision for the envelope’s debug trace and the audit record.

class sentrykit.cedar.CedarRequest(principal: EntityRef, action: EntityRef, resource: EntityRef, context: Mapping[str, object]=<factory>, entities: tuple[~sentrykit.cedar.Entity, ...]=(), correlation_id: str | None = None)[source]

Bases: object

One authorization question, in sentrykit’s own terms.

Parameters:
  • principal – Who is acting.

  • action – What they propose to do.

  • resource – What they propose to do it to.

  • context – Cedar request context — now, channel, the proposed arguments, and whatever else the provider established. Coerced before evaluation.

  • entities – Per-request entities merged on top of the engine’s base graph, typically the principal snapshot. A delta entity whose uid duplicates a different base entity is an error, so the base graph should hold only stable, shared nodes.

  • correlation_id – Invocation id, echoed back on the decision so operational logs, the Cedar result and the audit record share one key.

class sentrykit.cedar.Entity(uid: EntityRef, attributes: Mapping[str, object]=<factory>, parents: tuple[~sentrykit.cedar.EntityRef, ...]=())[source]

Bases: object

One node of the Cedar entity graph.

Parameters:
  • uid – This entity’s reference.

  • attributes – Cedar attributes. Values are coerced by coerce_value(), so None entries are dropped rather than making the entity set unparseable.

  • parents – Direct parent edges. Cedar computes the transitive closure, so a role, a team and a delegation edge are all just parents — this is the mechanism that lets multi-hop relationships be resolved by policy instead of by application code.

as_json() → dict[str, object][source]

Return the Cedar entity-JSON form: {"uid", "attrs", "parents"}.

class sentrykit.cedar.EntityRef(type: str, id: str)[source]

Bases: NamedTuple

A Cedar entity reference — an entity type plus an id.

Rendered as Type::"id" and sent to Cedar in its structured {"type", "id"} form, which sidesteps the surface-syntax normalization rules that would otherwise reject ids containing awkward characters. That matters because resource ids reach us from callers and from language models.

type: str

Alias for field number 0

id: str

Alias for field number 1

classmethod parse(value: str | EntityRef, *, default_type: str | None = None) → EntityRef[source]

Parse Type::"id", or a bare id when default_type is supplied.

Parameters:
  • value – A Cedar uid string, a bare id, or an already-parsed reference.

  • default_type – Entity type to assume when value carries no Type:: prefix.

Returns:

The parsed reference.

Raises:

ConfigurationError – If value has no type prefix and no default_type was given, or if it is malformed.

as_json() → dict[str, str][source]

Return the structured {"type": ..., "id": ...} form cedarpy accepts.

class sentrykit.cedar.PolicyAnnotation(cedar_id: str, effect: ~typing.Literal['permit', 'forbid'], annotations: ~collections.abc.Mapping[str, str | None] = <factory>)[source]

Bases: object

One policy’s identity and annotations, read out of the policy text.

Cedar’s PolicySet handle is opaque — it cannot be walked — but cedarpy.policies_to_json_str renders the parsed set as JSON carrying every annotation, not just @id. That is the only way to see an @obligations(...) declaration, which is why policy_annotations() exists and why obligations are declared as annotations at all.

Variables:
  • cedar_id (str) – Cedar’s generated id (policy0). Positional in the policy text, and the key that diagnostics.reasons reports.

  • effect (Literal['permit', 'forbid']) – permit or forbid.

  • annotations (collections.abc.Mapping[str, str | None]) – Every annotation on the policy. A valueless annotation (@obligations with no parentheses) maps to None.

property declared_id: str

The @id annotation when the policy declares a non-empty one, else cedar_id.

Matches how CedarDecision.determining_policies names a policy, so the two can be joined.

annotation(key: str) → str | None[source]

Return one annotation’s value, or None when it is absent or valueless.

class sentrykit.cedar.PolicyEngine(policies: str, entities: Sequence[Entity] = (), *, schema: str | None = None)[source]

Bases: object

A parsed, reusable Cedar policy set plus a base entity graph.

The in-memory policy source: hand it policy text and the stable entities, and it answers questions. A PolicySource or a GrantStore changes where the text comes from without changing this class.

Parameters:
  • policies – Cedar policy text. Parsed eagerly.

  • entities – The stable part of the entity graph — roles, teams, resources. Per-request entities such as the principal snapshot go on CedarRequest instead.

  • schema – Optional Cedar schema text, used to validate entities and requests.

Raises:

ConfigurationError – If the policies, entities or schema fail to parse.

property policy_set_hash: str

Stable digest of the policy text and schema this engine evaluates against.

property policies: str

The Cedar policy text this engine was built from.

with_entities(entities: Iterable[Entity]) → PolicyEngine[source]

Return a new engine with entities added to the base graph.

The receiver is unchanged and its parsed handles are reused, so this is cheap enough to call when a tenant’s entities are loaded alongside the shared ones.

evaluate(request: CedarRequest) → CedarDecision[source]

Ask Cedar whether request is authorized.

Never raises on a denial or on a Cedar evaluation error: both come back on the returned decision, because deciding what an engine failure means is the Governor’s job, governed by fail_mode.

Parameters:

request – The question to answer.

Returns:

The normalized decision.

Raises:

ConfigurationError – If the request’s per-request entities cannot be merged onto the base graph — for example a delta uid that duplicates a different base entity. That is a wiring fault, not a decision.

class sentrykit.cedar.PolicyValidation(passed: bool, errors: tuple[PolicyValidationError, ...] = ())[source]

Bases: object

The outcome of validating a policy set against a schema.

Variables:
grouped() → dict[str, list[str]][source]

Group the messages by the policy they belong to.

One mistake routinely produces several errors — an unrecognized action yields both “unrecognized action” and “unable to find an applicable action” — so error count is not policy count, and a flat list reads as worse than the problem is.

describe() → str[source]

Render every complaint as one line, policies in sorted order.

class sentrykit.cedar.PolicyValidationError(cedar_id: str, policy_id: str, message: str)[source]

Bases: object

One complaint Cedar’s validator made about a policy set.

Variables:
  • cedar_id (str) – The generated id of the offending policy, or empty when the failure was a parse error and no policy could be identified.

  • policy_id (str) – That policy’s @id annotation where it declares one, else cedar_id, else "<parse>" — so a report names policies the way the author does.

  • message (str) – Cedar’s own wording.

sentrykit.cedar.coerce_value(value: object) → object | None[source]

Coerce a Python value into something Cedar’s type system accepts.

Cedar has integers, booleans, strings, sets, records and entity references — and no floating-point type and no null. Feeding it either produces a NoDecision with the unhelpful message failed to parse schema from request, or, in an entity attribute, makes the whole entity set fail to parse. Coercing at this boundary is what keeps a caller-supplied None from quietly denying every request.

The mapping: None becomes None and is dropped by the caller; bool, int and str pass through; datetime and date become epoch seconds so policies can compare them against context.now; float and Decimal are rendered as strings, since silently truncating them to integers would change what a policy decides; EntityRef becomes Cedar’s {"__entity": ...} form; mappings and sequences recurse, dropping any element or entry that coerces to None. Anything else is stringified.

A supplied mapping is never an escape form. A record key beginning with __ is reserved by Cedar — __entity promotes a record into a typed entity reference, __extn into an extension value — so a mapping that spells one would be indistinguishable from a reference the application built itself. Any such key raises ReservedKey rather than being coerced, dropped or escaped: dropping it would silently turn off an argument-aware forbid that tests context.args has ..., and escaping it would leave a value that only fails downstream as a Cedar type error, which fail_mode="open" would tolerate. Refusing here refuses under either fail mode.

An :class:`EntityRef` object, by contrast, is always promoted, and that is a decision about types rather than about provenance. isinstance cannot tell where an object came from, and every web and agent integration validates a caller’s JSON against the protected callable’s annotations before the decorator runs — so a parameter annotated EntityRef is a caller-chosen entity, type included. Cedar’s in is reflexive, which makes !(context.args.region in Group::"approved") satisfiable by passing the approved group itself. A policy reading an entity-valued argument must pin its type (context.args.region is Region && ...), bound it with a constraint or a declared ceiling, or sit behind a Cedar schema that types context.args. Annotate a parameter EntityRef only where the caller really is meant to name the entity.

Naive datetime values are read as UTC rather than as local time, so a decision does not depend on the timezone of the host that happened to evaluate it.

Parameters:

value – The value to coerce.

Returns:

A Cedar-safe value, or None when the value has no Cedar representation and should be omitted.

Raises:

ReservedKey – If a mapping anywhere inside value carries a key beginning with __.

sentrykit.cedar.format_policies(policies: str, *, line_width: int = 80, indent_width: int = 2) → str[source]

Render policies the way Cedar’s own formatter would write them.

The lint half of sentrykit policy validate. A policy file that does not equal its own formatting is reported as a warning and never rewritten, because reviewed policy text is not something a validation command edits — so the only thing a caller needs is the formatting Cedar would produce, to compare against.

It lives here for the reason the module docstring gives: cedarpy is imported by exactly one module, and a second import in the CLI would turn that claim into a comment.

Parameters:
  • policies – Cedar policy text.

  • line_width – Column the formatter wraps at.

  • indent_width – Spaces per indent level.

Returns:

The formatted text, ending in a newline.

Raises:

ConfigurationError – If the text does not parse. Cedar’s own message here is terse (cannot parse input policies), so a caller holding a filename should name the file it came from.

sentrykit.cedar.policy_annotations(policies: str) → tuple[PolicyAnnotation, ...][source]

Read the effect and annotations of every static policy in policies.

The generated ids this returns are positional in policies, so the result is only joinable with a decision if it was produced from exactly the text the PolicyEngine was built from. Callers that concatenate policy fragments must concatenate once, deterministically, and derive both the engine and this index from that one string.

Templates and template links are ignored: only static policies can be determining.

Parameters:

policies – Cedar policy text.

Returns:

One entry per static policy, in the order Cedar numbered them.

Raises:

ConfigurationError – If the text does not parse.

sentrykit.cedar.validate_policies(policies: str, schema: str) → PolicyValidation[source]

Type-check policies against schema, normalizing Cedar’s result.

Cedar’s validator never raises: an unparseable policy, an unparseable schema and a genuine type error all come back as entries in errors with validation_passed false. Branching on the flag is the only way to notice, and getting that wrong means a policy set that cannot possibly permit anything sails through wiring and denies every request at runtime instead.

Parameters:
  • policies – Cedar policy text.

  • schema – Cedar schema text. The JSON schema form is not accepted — Schema.from_str rejects it — so this is the .cedarschema surface syntax.

Returns:

The normalized outcome. Callers decide what a failure means; this function does not raise for one, because “the policy set is invalid” is a finding to report for sentrykit policy validate and a ConfigurationError for a policy source.

sentrykit.constraints

Per-(principal, action) argument constraints: the layer that filters what a caller may pass.

Cedar’s gate is boolean — it answers “may this principal perform this action on this resource”, not “with which arguments”. So argument-level enforcement stays in Python, driven by attribute data resolved from the grant store, with a Cedar forbid backstop as defense in depth. This module is that enforcement, and nothing in it knows about Cedar, the engine, or the decorator: it is a pure function from constraints plus proposed arguments to kept, rejected and defaulted arguments.

That purity is the point. The caller — a web client or a language model — proposes arguments; the constraint set decides which of them survive. A rejection is data, not an exception, so the engine can drop it (arg_handling="lenient") or raise on it ("strict") without this module knowing which mode is in force.

A constraint is data, which means one can be written that no value satisfies, or whose own default it would reject. Both are faults of the moment a constraint is written, not of the call that happens to run into them, so ArgumentConstraint.inconsistencies() states them as sentences an operator can act on and a grant store runs it at the write. The decision path stays fail-closed regardless: ConstraintSet.inject_defaults() re-checks every default against its own constraint and withholds one that fails, because an argument nobody proposed must not be a way around the rule that governs it.

sentrykit.constraints.CEILING_RULES: Final = ('allow', 'deny', 'pattern', 'minimum', 'maximum', 'required')

The rules a policy-declared ceiling may set.

default is absent on purpose. ArgumentConstraint.describe() already withholds a default value from callers because a per-principal default can itself be sensitive – it is someone’s tenant id, not a rule – so it is grant data, and reviewed policy text is where rules live. policy is absent because sentrykit fills it in with the id of the policy that carried the declaration; a self-reported provenance would be unverifiable.

class sentrykit.constraints.ArgumentConstraint(*, allow: tuple[object, ...] | None = None, deny: tuple[object, ...] | None = None, pattern: str | None = None, minimum: int | float | None = None, maximum: int | float | None = None, default: object | None = None, required: bool = False, policy: str | None = None)[source]

Bases: BaseModel

The rules that govern one argument for one principal and action.

Every rule is optional and they compose: a value must satisfy all of the ones that are present. An unconstrained argument is one with no entry in the ConstraintSet at all — an empty constraint here permits everything, which is deliberately different from forbidding everything, since the coarse Cedar gate has already decided the call itself is permitted.

Variables:
  • allow (tuple[object, ...] | None) – If set, the value must be one of these. The value is compared as a whole, so a container is rejected outright rather than read element-wise: admitting a list because each of its elements is permitted would widen a rule written about one value.

  • deny (tuple[object, ...] | None) – If set, neither the value nor anything inside it may be one of these. This is the one rule read element-wise, because denial is the fail-closed direction — a scalar never equals a container, so comparing whole values only would let a caller defeat the rule by wrapping the forbidden value in a one-element list.

  • pattern (str | None) – If set, a regular expression the value must match in full. Non-string values are rejected as not comparable rather than coerced.

  • minimum (int | float | None) – If set, the lower bound (inclusive) for a numeric value. Written as an int or a float; a Decimal or any other real number is compared against it at its own precision.

  • maximum (int | float | None) – If set, the upper bound (inclusive) for a numeric value, on the same terms as minimum.

  • default (object | None) – Value to inject when the caller supplies none, or when theirs was dropped. Distinguished from “no default configured” by has_default, so a default of None is expressible.

  • required (bool) – Whether the call cannot proceed without this argument. A required argument with no supplied value and no default raises DefaultUnavailable.

  • policy (str | None) – Id of the policy or grant this constraint came from, recorded on rejections for provenance.

property has_default: bool

Whether a default was configured, including an explicit default of None.

check(name: str, value: object) → RejectedArgument | None[source]

Test one value against every rule in this constraint.

Rules are evaluated allow-set, deny-set, pattern, minimum, maximum, and the first violation is returned: reporting one concrete reason is more actionable than a list, and the order runs from the coarsest rule to the finest.

The vocabulary is scalar-only but for one asymmetry: deny is read element-wise and nothing else is. It walks a proposed container — a mapping’s values and its keys, any other collection that is not a str or bytes, and on through nesting as far as _DENY_DEPTH_LIMIT — and rejects the value if anything inside it is denied, or if the container was too deep to finish reading. Every other rule compares the value as a whole and so rejects a container outright, allow as allow-set and the pattern and the bounds as not-comparable. That is deliberate and not an oversight to tidy up: reading denial element-wise only ever narrows what a principal may pass, while reading permission the same way would widen it.

Parameters:
  • name – The parameter name, used to label a rejection.

  • value – The proposed value.

Returns:

None if the value is acceptable, otherwise the rejection describing the first rule it violated. Never raises, whatever the proposal holds.

intersected_with(other: ArgumentConstraint) → ArgumentConstraint[source]

Return the constraint satisfying both this one and other.

The counterpart to ConstraintSet.merged_with(), which overrides. Layering a ceiling declared in reviewed policy text over a principal’s grant must never widen what they may pass, so every rule composes in the tightening direction: allow-sets intersect, deny-sets union, bounds move inward, required is sticky, and two patterns must both match in full.

default and policy come from the receiver where it has them. A ceiling declares no default – a per-principal default is grant data, not reviewed logic – and the receiver’s policy names the specific, per-principal layer, while the layer being intersected in is already named by the decision’s determining policies, which the envelope and the audit record carry.

Parameters:

other – The constraint to narrow by.

Returns:

A constraint no value satisfies unless it satisfies both. It may well be unsatisfiable – two disjoint allow-sets intersect to none, and nothing then passes, which is the fail-closed direction.

inconsistencies() → tuple[str, ...][source]

Every way this constraint contradicts itself, as sentences an operator can act on.

A constraint is data, so nothing stops one being written that no value can satisfy – minimum=100, maximum=10 constructs without complaint – or one whose own default it would reject. Left alone, both are discovered at call time as a refusal of everything, by whoever happens to call next. This is the check that moves that discovery to the moment the grant is written, and grant() runs it there.

Four things are checked:

  1. The pattern compiles.

  2. minimum is not above maximum.

  3. Every allow member satisfies this constraint’s other rules. A permitted value the pattern or the bounds reject is dead configuration: a reviewer reading the allow-set believes it is reachable.

  4. A configured default satisfies this constraint. Without this the way to defeat a constraint is to stop proposing a value, since an omitted argument is filled from the default rather than filtered.

The last two delegate to check(), so what a grant promises at the write and what the filter enforces at the call cannot drift apart.

Returns:

One sentence per problem, or an empty tuple when the constraint is coherent. An uncompilable pattern or inverted bounds short-circuit the value-level checks: check() rejects everything under either, so probing the allow-set and the default would restate the same fault once per value.

describe() → dict[str, object][source]

Summarize the rules, for the “here is what you may pass” half of an error.

Only the rules that were actually configured appear. The default value never does — a caller is told that one exists, not what it is, since a per-principal default can itself be sensitive (someone else’s tenant id, say).

class sentrykit.constraints.ConstraintSet(*, arguments: dict[str, ~sentrykit.constraints.ArgumentConstraint]=<factory>)[source]

Bases: BaseModel

Every argument constraint that applies to one principal for one action.

Variables:

arguments (dict[str, sentrykit.constraints.ArgumentConstraint]) – Parameter name to its constraint. Parameters absent from this mapping are unconstrained.

merged_with(other: ConstraintSet) → ConstraintSet[source]

Return this set with other layered on top, per argument.

Used to let a wildcard ("*") constraint set supply the baseline that an action-specific set refines. Overriding is whole-constraint, not field-by-field: an action-specific entry for an argument replaces the wildcard entry for it outright, which keeps “what applies here” answerable by reading one object.

intersected_with(other: ConstraintSet) → ConstraintSet[source]

Return the set satisfying both, argument by argument.

Where both sets constrain an argument, the two constraints intersect; where only one does, that one applies unchanged. Unlike merged_with() this can only ever narrow, which is what makes it the safe direction for layering a ceiling declared in reviewed policy text over a principal’s grants.

Parameters:

other – The set to narrow by.

Returns:

The intersected set.

filter(proposed: Mapping[str, object]) → FilterResult[source]

Split proposed arguments into those the policy accepts and those it does not.

Parameters:

proposed – What the caller asked for.

Returns:

The kept arguments and the rejections. Never raises — turning a rejection into an error is arg_handling’s decision, made by the engine.

inject_defaults(kept: Mapping[str, object], parameters: Collection[str], *, accept_unlisted: bool = False) → DefaultsResult[source]

Fill in per-principal defaults for arguments the caller did not supply.

A default never overrides a value that survived filtering — the caller’s own valid input wins. Defaults are injected for dropped arguments too, which is what makes lenient handling useful: an over-broad request is narrowed rather than refused. Nothing is trimmed to a bound — the value the principal may not have was already rejected by filter(), and what lands here is the gap it left.

Every default is checked against its own constraint before it is injected, and one that fails is withheld rather than substituted. Without that check the way to defeat a constraint would be to stop proposing a value: a supplied value is filtered, an omitted one is filled in, and only the first path ran a check. The write-time check in ArgumentConstraint.inconsistencies() is what a grant store enforces so this situation cannot be stored at all; this is the backstop for a constraint set that reached the decision path some other way, and like filter() it reports rather than raises.

Parameters:
  • kept – Arguments that survived filter().

  • parameters – Parameter names the target callable actually accepts. Defaults for anything else are skipped, so a stale grant cannot make a call fail with an unexpected keyword argument.

  • accept_unlisted – Whether a default may be injected for a name parameters does not list. A callable declaring **kwargs accepts names its signature never spells, so a default for one of them is deliverable and withholding it would turn every constraint on a kwargs-only argument into a rule that rejects and refills nothing. It defaults to False because the caller is the only side that knows: for an ordinary signature the name filter above is what keeps a stale grant from making a call fail with an unexpected keyword argument, and that protection has to stay on unless it is explicitly stood down.

Returns:

The completed argument mapping, the subset that was injected, and the defaults that were withheld.

missing_required(present: Collection[str]) → tuple[str, ...][source]

Name the required arguments that are still absent after defaults were injected.

Parameters:

present – Argument names that now have a value.

Returns:

The names of required arguments with neither a supplied value nor a default — each one a DefaultUnavailable.

inconsistencies() → tuple[str, ...][source]

Every self-contradiction across the whole set, each labelled with its argument.

The set-level entry point for ArgumentConstraint.inconsistencies(), used where a whole set is written or loaded at once rather than one constraint at a time.

Returns:

One sentence per problem, or an empty tuple when every constraint is coherent.

describe() → dict[str, object][source]

Summarize every constraint, for the allowed-set half of an error payload.

class sentrykit.constraints.DefaultsResult(arguments: dict[str, object], applied: dict[str, object], withheld: tuple[RejectedArgument, ...] = ())[source]

Bases: NamedTuple

What ConstraintSet.inject_defaults() produced.

Variables:
  • arguments (dict[str, object]) – The kept arguments plus every injected default.

  • applied (dict[str, object]) – Only the defaults that were injected, for the envelope’s defaults_applied.

  • withheld (tuple[sentrykit.constraints.RejectedArgument, ...]) – One entry per default that was not injected because it does not satisfy its own constraint. A grant store refuses such a constraint at the write, so reaching this is an operator fault in a constraint set built some other way – and the fault is reported rather than resolved in the caller’s favour.

arguments: dict[str, object]

Alias for field number 0

applied: dict[str, object]

Alias for field number 1

withheld: tuple[RejectedArgument, ...]

Alias for field number 2

class sentrykit.constraints.FilterResult(kept: dict[str, object], rejected: tuple[RejectedArgument, ...])[source]

Bases: NamedTuple

What ConstraintSet.filter() decided.

Variables:
kept: dict[str, object]

Alias for field number 0

rejected: tuple[RejectedArgument, ...]

Alias for field number 1

class sentrykit.constraints.RejectedArgument(*, name: str, rule: Literal['allow-set', 'deny-set', 'pattern', 'minimum', 'maximum', 'not-comparable'], reason: str, policy: str | None = None, value: object | None = None)[source]

Bases: BaseModel

One argument the policy would not accept, and why.

Carried on the _authz envelope, on NoValidArguments, and into the audit record, so the reason a value was dropped is reportable rather than guessable.

Variables:
  • name (str) – The argument’s parameter name.

  • rule (Literal['allow-set', 'deny-set', 'pattern', 'minimum', 'maximum', 'not-comparable']) – Which constraint it violated.

  • reason (str) – A sentence-case explanation suitable for showing to the caller.

  • policy (str | None) – Id of the policy or grant that carried the constraint, when known.

  • value (object | None) – The offending value. Emitted only at verbose verbosity or above, and masked before any audit write — never assume this is safe to log.

as_detail() → dict[str, object][source]

Render without the value, for summary verbosity and for error payloads.

sentrykit.constraints.Rule

Which kind of constraint a value violated.

alias of Literal[‘allow-set’, ‘deny-set’, ‘pattern’, ‘minimum’, ‘maximum’, ‘not-comparable’]

sentrykit.constraints.parse_ceiling(declaration: str | None, *, policy: str) → ConstraintSet[source]

Parse an argument ceiling declared in reviewed policy text.

A ceiling is a compact JSON object mapping an argument name to the rules that bound it:

{"limit": {"maximum": 100}, "region": {"allow": ["eu", "us"]}}

It reaches sentrykit as a Cedar annotation value, and Cedar type-checks annotation values not at all – a policy carrying a nonsense one validates clean. Every check is therefore ours, and every one of them happens here, at load, so a malformed declaration fails at wiring rather than silently enforcing nothing at decision time.

Parameters:
  • declaration – The annotation’s value. A valueless annotation arrives as None.

  • policy – Id of the policy that carried it, recorded on every constraint so a rejection names the reviewed file it came from.

Returns:

The declared ceiling.

Raises:

ConfigurationError – If the declaration is absent, is not a JSON object of objects, names a rule a ceiling may not set, carries a value of the wrong type, declares a pattern that will not compile or that carries a backreference, or describes a constraint no value could satisfy.

sentrykit.envelope

The _authz envelope — what sentrykit tells the caller about its own decision.

An authorization layer that silently rewrites a caller’s arguments is worse than one that refuses them: the caller gets a plausible answer to a question they did not ask. The envelope is the fix. It reports what was processed, what was rejected and why, and what was defaulted, so a narrowed call is visibly narrowed — to a human reading an HTTP response, and to a language model deciding what to do next.

Reporting the rejection is not enough on its own, and that is a measured result rather than a design opinion. A narrowed call succeeded: the policy decided, the arguments were refilled from the principal’s own entitlement, and the callable ran and returned real data. But an envelope whose only narrowing fields are rejected and defaults_applied 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 holding. So the envelope states the outcome as well as the exceptions: AuthzEnvelope.narrowed says a proposed value did not survive, AuthzEnvelope.narrowed_arguments names which, and AuthzEnvelope.guidance says in one sentence that the result is valid and what to do with it. Those three carry no argument values and no policy ids, so unlike processed they are available at every verbosity above off — a caller who may not be told what was narrowed can still be told that the result is good.

Verbosity trades that transparency against leaking policy structure and argument values to callers who should not see either:

off

No envelope.

summary

The decision, the action and resource, and the names and reasons of rejected and defaulted arguments — enough to explain the outcome without disclosing values.

verbose

Adds the processed argument values and the offending values on rejections.

debug

Adds the trace: determining policies, the policy-set hash, timings, and the effective settings. For development and incident response.

The envelope is a report, not the audit record. The audit record is written append-only, masked, and hash-chained regardless of what verbosity shows the caller.

class sentrykit.envelope.AuthzEnvelope(*, decision: ~typing.Literal['allow', 'deny'], action: str, resource: str | None = None, principal: str | None = None, processed: dict[str, object] = <factory>, rejected: tuple[~sentrykit.constraints.RejectedArgument, ...] = (), defaults_applied: dict[str, object] = <factory>, signature_defaults: tuple[str, ...] = (), narrowed: bool = False, narrowed_arguments: tuple[str, ...] = (), guidance: str | None = None, obligations: tuple[str, ...] = (), reason: str | None = None, determining_policies: tuple[str, ...] = (), trace: dict[str, object] | None = None)[source]

Bases: BaseModel

A caller-facing report of one authorization decision.

Variables:
  • decision (Literal['allow', 'deny']) – allow or deny.

  • action (str) – The action that was authorized.

  • resource (str | None) – The resource it was authorized against.

  • principal (str | None) – The acting principal’s uid. Omitted below verbose.

  • processed (dict[str, object]) – The argument set the policy decided and sentrykit passed. A parameter left to the callable’s own default is absent here and named in signature_defaults instead. Omitted below verbose.

  • rejected (tuple[sentrykit.constraints.RejectedArgument, ...]) – Arguments the policy refused, with reasons. Values are included only at verbose and above.

  • defaults_applied (dict[str, object]) – Arguments filled in from the principal’s defaults. Below verbose the keys are still the argument names but every value is None, so the field keeps one shape at every verbosity and a caller can always read defaults_applied.keys().

  • signature_defaults (tuple[str, ...]) – Governed parameters whose value came from the callable’s own signature — the caller proposed none that survived, policy supplied none, and the constraint was checked against the application’s own default and accepted it. Names only, never values. Present at every verbosity, because the point of the field is that this case used to be invisible: from the argument layer’s point of view nothing happened, so a clean allow was reported over a value no policy chose. A call where that default was refused does not appear here; it is a refusal.

  • narrowed (bool) – Whether the call ran with a value the caller did not propose — the reject-and-refill outcome. Distinct from rejected being non-empty: a rejection that left the argument with the caller’s own surviving value is not narrowing, and a default injected for an argument the caller never mentioned is not either. Omitted from as_dict() when false, so a clean allow stays terse.

  • narrowed_arguments (tuple[str, ...]) – The parameter names whose proposed value did not survive. Present at every verbosity, because rejected already carries these names.

  • guidance (str | None) – One sentence on what the outcome means and what to do with it, set only when narrowed is true. Carries no argument values and no policy ids, so it is safe at every verbosity. It exists because a reader given only rejected reads a successful narrowed call as a failed one.

  • obligations (tuple[str, ...]) – Obligations the policy attached to the decision — break-the-glass justification, review requirements. Carried through from the determining policies; the shipped policy pack is what attaches them.

  • reason (str | None) – Why a denial happened, in one sentence.

  • determining_policies (tuple[str, ...]) – The policies that decided it. debug only, since policy ids describe the shape of the rule set.

  • trace (dict[str, object] | None) – Decision trace — policy-set hash, engine errors, timings, effective settings. debug only.

as_dict() → dict[str, object][source]

Render as a JSON-ready mapping, dropping empty sections.

Argument values are whatever the protected callable was called with, so they are not guaranteed to be JSON-serializable. Anything pydantic cannot render is replaced by its repr rather than raising: a report about a decision must not be able to fail the call the decision permitted.

Returns:

The envelope with unset and empty fields omitted, so a clean allow stays terse. narrowed is dropped when false for the same reason: it is the exceptional outcome, and emitting "narrowed": false on every ordinary response would train a reader to skip the key.

class sentrykit.envelope.EnvelopeRecorder[source]

Bases: object

A boundary’s running account of the decisions made while serving one request.

This exists because last_envelope() cannot answer the boundary’s question. A ContextVar write propagates downward into a copied context and never back up: a sync framework route runs in a worker thread holding a copy, so nothing the engine sets while that route executes is visible to the middleware that has to write the response headers. Measured, not assumed — see ADR-029.

A recorder closes that gap by being a mutable object reached through the context variable rather than a value stored in it. The copy a worker thread receives points at the same instance, so record() mutates what the boundary is still holding.

It is also the right shape for the question a boundary actually asks. A request may make several protected calls, and “was anything narrowed while serving this response” is not answerable from the most recent envelope alone, so narrowed_arguments accumulates the union across every decision in the scope, in first-seen order.

A boundary installs one with record_decisions() and drops it with close(); BaseContextProvider does both, so every shipped framework adapter has one without asking.

Variables:
  • decisions – How many decisions have been recorded.

  • envelope – The most recent one, matching what last_envelope() would return had the decision been made in this context.

  • narrowed_arguments – Every argument narrowed anywhere in the scope, first-seen order.

  • last_was_in_band – Whether the most recent decision’s envelope was written into the result itself under _authz. Per-decision rather than cumulative on purpose: it answers “does this result already say so?”, which is a question about one return value, and a boundary asks it immediately after the call it describes. A non-mapping return leaves it False, and that is the signal the agent adapters act on.

property narrowed: bool

Whether any decision in this scope ran with a value its caller did not propose.

record(envelope: AuthzEnvelope) → None[source]

Fold one decision into the account.

Parameters:

envelope – The decision’s envelope. A None envelope — verbosity off — is filtered out by set_last_envelope() before it reaches here, so a host that turned reporting off gets no headers rather than empty ones.

close() → None[source]

Uninstall this recorder. Idempotent, and safe to call on an already-closed one.

Shaped like reset() on purpose: a boundary tears down what it set up, in a finally, and a pooled worker must not start the next request holding the previous caller’s account.

sentrykit.envelope.attach_envelope(result: T, envelope: AuthzEnvelope | None) → T[source]

Attach envelope to a result and record it as the context’s last envelope.

A mapping result gets the envelope under _authz in place — which is what an agent tool result or a JSON handler return usually is, and the reason the README can promise the report travels with the answer. Any other result is returned untouched: rewrapping it would change the protected callable’s return type and break both static typing and every caller. Those callers read last_envelope() instead, which is how the web framework providers attach it to an HTTP response.

Parameters:
  • result – Whatever the protected callable returned.

  • envelope – The envelope, or None to attach nothing.

Returns:

result — the same object, mutated in place when it is a mapping.

sentrykit.envelope.build_envelope(*, decision: Literal['allow', 'deny'], action: str, resource: str | None, principal: str | None, processed: Mapping[str, object], rejected: tuple[RejectedArgument, ...], defaults_applied: Mapping[str, object], signature_defaults: Sequence[str] = (), narrowed_arguments: Sequence[str] = (), narrowed_without_replacement: Sequence[str] = (), obligations: tuple[str, ...] = (), reason: str | None = None, determining_policies: tuple[str, ...] = (), trace: Mapping[str, object] | None = None, verbosity: Literal['off', 'summary', 'verbose', 'debug']) → AuthzEnvelope | None[source]

Build the envelope for one decision, trimmed to verbosity.

Trimming happens here rather than at render time so that a value excluded by verbosity is never carried on the object at all — a report cannot leak a field it does not hold.

Below verbose the trimming is: no principal, no processed, no determining_policies and no trace; each rejection is rebuilt without its offending value; and defaults_applied keeps only the names, each mapped to None. Mapping to None rather than dropping the section keeps one shape for the field across every verbosity, so a caller can always read envelope.defaults_applied.keys() to learn which arguments policy filled in.

Parameters:
  • decision – The outcome.

  • action – Action uid.

  • resource – Resource uid, if any.

  • principal – Principal uid.

  • processed – Final arguments the call ran with.

  • rejected – Arguments the policy refused.

  • defaults_applied – Arguments filled in from policy defaults.

  • signature_defaults – Governed parameters the callable’s own signature default filled, after the constraint checked and accepted it. Names only, and not trimmed by verbosity for the same reason narrowed_arguments is not.

  • narrowed_arguments – Parameter names whose proposed value did not survive. Sets AuthzEnvelope.narrowed and drives the guidance sentence. Not trimmed by verbosity: these names already appear on rejected at every verbosity.

  • narrowed_without_replacement – The subset of narrowed_arguments that sentrykit supplied no value for, so the callable’s signature default applied.

  • obligations – Obligations attached by the policy.

  • reason – One-sentence explanation, for denials.

  • determining_policies – Policies that decided the outcome.

  • trace – Debug trace.

  • verbosity – How much to include.

Returns:

The envelope, or None when verbosity is off.

sentrykit.envelope.current_recorder() → EnvelopeRecorder | None[source]

Return the recorder installed for this context, if any.

Returns:

The recorder, or None outside a boundary that installed one — which is the normal case for a plain with establish(...) block in application code.

sentrykit.envelope.last_envelope() → AuthzEnvelope | None[source]

Return the envelope from the most recent decision in this context.

Scoped to the current ContextVar context, so concurrent tasks and threads each see their own.

Returns:

The most recent envelope, or None if there has been no decision or verbosity is off.

sentrykit.envelope.narrowing_guidance(narrowed_arguments: Sequence[str], *, unsupplied: Sequence[str] = ()) → str[source]

Compose the sentence AuthzEnvelope.guidance carries.

The wording has one job, and it is not decoration: a reader handed only rejected treats a narrowed call as a failed one and withholds the result. Measured across language models, that happened in roughly half of narrowed calls. So the sentence leads with the outcome rather than the exceptions, and it closes by saying not to retry the original values, because a reader that reads “rejected” as “try again” will loop.

The lead is conditional, and that is the point of the unsupplied argument. When every narrowed argument was refilled from the principal’s own entitlement, the call ran on values policy approves and the result can be vouched for. When one was not, the callable’s own signature default landed in the slot, no constraint ever saw it, and claiming the result is “valid” would be precisely the overclaim this envelope exists to prevent — so that case says “served” and marks the affected part unverified instead.

Only names appear, never values and never policy ids, which is what makes the sentence safe to emit at summary verbosity alongside the trimmed fields.

Parameters:
  • narrowed_arguments – Parameter names whose proposed value did not survive.

  • unsupplied – The subset of those for which sentrykit supplied no replacement, so the callable’s own signature default applied. Called out separately because sentrykit never saw that value and must not imply it is policy-approved.

Returns:

One sentence, suitable for a human reading an HTTP response or a model reading a tool result.

sentrykit.envelope.record_decisions() → EnvelopeRecorder[source]

Install an EnvelopeRecorder for the current context and return it.

Call at a request boundary, before anything protected runs, and EnvelopeRecorder.close() it at teardown. The shipped providers do this inside establish() and release(); a hand-written provider that wants narrowing to reach its response needs the same pair.

Returns:

The recorder. Also reachable from the same context with current_recorder(), which is what lets a boundary read it without threading it through the call stack.

sentrykit.envelope.set_last_envelope(envelope: AuthzEnvelope | None) → None[source]

Record envelope as this context’s most recent decision.

Called by the engine on both allow and deny, so a provider rendering an error response has the same detail available as one rendering a success. It also feeds the boundary’s EnvelopeRecorder when one is installed, which is the only path that survives a decision made inside a copied context.

sentrykit.errors

Exception hierarchy for sentrykit.

Every error carries structured, machine-readable detail so context providers can render a useful response (and so the audit record is precise). Never raise a bare Exception.

exception sentrykit.errors.SentrykitError[source]

Bases: Exception

Base class for all sentrykit errors.

When audit_failure is set, both halves of it matter. The refusal is correct: it is the one the decision produced, it stands, and it is not softened because nobody could write it down — deciding and recording are separate guarantees, and losing the second does not forfeit the first. And the refusal is unrecorded: there is no audit record that this boundary was tested, so nothing in the trail will later show a caller probing which actions exist and which argument values are accepted.

It lives on the base class because any refusal can arrive this way, and it travels with the exception so that whoever renders the refusal — a provider’s response, a host’s own error handler, a report — can say so at the point a reader is looking. The alternative is to leave the fact in an operational log, and the operational channel is deliberately not the audit trail: a fact that only ever lands there is a fact nobody can prove afterwards.

Variables:

audit_failure (sentrykit.errors.AuditUnavailable | None) – The audit sink failure that stopped this refusal from being recorded, or None — which is what it is on every refusal a reachable sink accepted, so on all but a vanishing fraction of them.

exception sentrykit.errors.ConfigurationError[source]

Bases: SentrykitError

Raised when sentrykit is misconfigured (bad settings, missing backend, etc.).

exception sentrykit.errors.NoContext(action: str)[source]

Bases: SentrykitError

Raised when a protected callable is invoked with no resolvable principal.

This is a fail-closed condition: no ambient context and no explicit override means the call is denied.

exception sentrykit.errors.AuthzDenied(action: str, resource: str | None, determining_policies: list[str], engine_errors: list[str] | None = None)[source]

Bases: SentrykitError

Raised when the coarse Cedar gate denies the invocation.

engine_errors separates the two ways a call can fail to be authorized. A plain denial has none: a policy said no, or no policy said yes. A denial carrying engine errors means Cedar could not evaluate cleanly – an unparseable policy, a missing attribute – and fail_mode="closed" turned that failure into a refusal. Operators need to tell those apart, because the second is a bug in the policy set, not a correctly enforced boundary.

exception sentrykit.errors.ReservedKey(key: str, path: str = '')[source]

Bases: SentrykitError

Raised when caller-supplied data spells one of Cedar’s reserved __ keys.

Cedar’s request JSON reserves record keys beginning with __ for its own escape forms: __entity promotes a plain record into a typed entity reference, and __extn into an extension value. A caller who can put either key inside an argument can therefore hand the evaluator an entity reference of their own choosing, and an argument-aware forbid will read it as a genuine one – so forbid ... when { !(context.args.region in Group::"approved") } is satisfied by passing the approved group itself as the region, because Cedar’s in is reflexive.

sentrykit refuses the request instead of deciding it, and the refusal is deliberately not arbitrated by fail_mode: a forged request is not a broken policy set, and fail_mode="open" exists to tolerate operator error, never caller input.

This closes the spelling, not the general question of who chooses an entity’s type. A parameter a callable annotates as EntityRef is a caller-chosen entity by construction, and the same reflexive in applies to it – see coerce_value() for how a policy pins that down.

Variables:
  • key – The reserved key that was supplied.

  • path – Where it appeared inside the request context, e.g. args.region or args.regions[2].id. Empty when the value was coerced on its own.

exception sentrykit.errors.ArgumentRejected(name: str, reason: str, policy: str | None = None)[source]

Bases: SentrykitError

Raised (in strict mode) when a supplied argument violates its policy constraint.

exception sentrykit.errors.SignatureDefaultRejected(name: str, reason: str, policy: str | None = None, *, declared: bool = False)[source]

Bases: SentrykitError

Raised when the only value left for a governed argument is one the policy refuses.

The argument has a constraint, nothing the caller supplied survived it, no policy default applies — and the callable’s own signature default does not satisfy the constraint either. Python would otherwise fill the slot from that default, which is how a bound could be exceeded by a value no rule ever saw.

This is the same rule a policy default has always been held to: a default is checked against the constraint that governs it, so omitting an argument is not a way around that rule. A signature default is a default from a different source, and it answers to the same check.

declared says whose mistake it is. When it is true the refusing rule came from an argument ceiling — reviewed, version-controlled policy text — so the operator’s policy file and their application code disagree about the same parameter, and every caller of the action is affected equally. When it is false the rule came from this principal’s own grant data, so the refusal is specific to them and is ordinary authorization rather than a misconfiguration.

Not a ConfigurationError even in the declared case, for two reasons: it refuses one call rather than failing the wiring, and there is no moment at which it could fail the wiring instead, since nothing enumerates protected callables for a policy load to check against. Classifying it with configuration faults would also make it unqueryable in the audit trail, where it has its own outcome.

exception sentrykit.errors.NoValidArguments(allowed: dict[str, Any], rejected: list[dict[str, Any]])[source]

Bases: SentrykitError

Raised when the caller supplied arguments and the policy rejected every one of them.

Deliberately raised even when defaults could complete the call. Filling in defaults after discarding everything the caller asked for would answer a question they did not ask – the caller wanted orders for one customer and would silently get their own. Partial rejection is different: some of the request stood, so narrowing the rest to what the principal is entitled to is a refinement rather than a substitution.

allowed describes what the principal may pass, so the caller can retry correctly.

exception sentrykit.errors.DefaultUnavailable(name: str)[source]

Bases: SentrykitError

Raised when a required argument is missing and no policy default is configured.

exception sentrykit.errors.ValidationFailed(errors: list[dict[str, Any]])[source]

Bases: SentrykitError

Raised when the filtered/defaulted arguments fail the callable’s pydantic model.

errors mirrors pydantic’s structured ValidationError.errors() output.

exception sentrykit.errors.AuditUnavailable(sink: str, reason: str, failures: list[dict[str, Any]] | None = None)[source]

Bases: SentrykitError

Raised when an audit record could not be written to its sink.

An operational failure, not an authorization one — but under fail_mode="closed" it still refuses the governed call, because an action nobody can prove happened is an action that should not have happened (“can’t prove it, don’t do it”, per the logging standards).

sink names the backend that failed; failures carries one entry per sink when a fan-out composite raised, so an operator can see whether one replica or all of them are down.

sentrykit.audit

The audit record, the hash chain, and the one path from a decision to a sink.

An audit log has to survive scrutiny, which means four properties this module owns:

  • Reproducible. The record pins policy_set_hash and snapshots the principal’s effective attributes at decision time, so a replay is possible even after the policy set and the grants have both moved on.

  • Attributable. Per-policy provenance names which source and which artifact produced each determining policy — not just its id.

  • Tamper-evident. Each record carries the previous record’s hash, so altering any record invalidates every record after it. Detection, not prevention; prevention is the sink’s job (S3 Object Lock, WORM).

  • Safe to keep forever. A decision’s sensitive values are masked before the write and never after: its arguments, the principal’s attributes, and the allow-sets, deny-sets and patterns of the constraints that governed it. A grant mutation is the exception, and is written as it stands — nothing on that path declares which of its fields are sensitive, so there is no plan to mask by, and an attribute grant’s value reaches the sink in the clear.

Masking is structural rather than conventional: AuditTrail is the only thing in sentrykit that calls write(), and it masks on the way in. A unit test asserts that statically — the audit-side analogue of the import scan that keeps web frameworks out of the core — so a future caller that reaches around it fails the suite rather than the compliance review.

sentrykit.audit.GENESIS_HASH: Final = '0000000000000000000000000000000000000000000000000000000000000000'

The prev_hash of the first record in a chain. Fixed width, so a chain is scannable.

sentrykit.audit.AuditKind

Whether a record describes an authorization decision or an admin grant mutation.

alias of Literal[‘decision’, ‘mutation’]

sentrykit.audit.AuditOutcome

What actually happened.

Finer-grained than decision on purpose. “Cedar permitted the call but every argument the caller proposed was refused” and “no policy permitted the call” are both denials to the caller and completely different events to an auditor.

alias of Literal[‘allowed’, ‘denied’, ‘engine-error’, ‘arguments-rejected’, ‘no-valid-arguments’, ‘default-unavailable’, ‘signature-default-rejected’, ‘validation-failed’, ‘granted’, ‘revoked’]

class sentrykit.audit.AuditTrail(sink: AuditSink | None = None, *, masker: Masker | None = None, bundle: PolicyBundle | None = None, strict: bool = True)[source]

Bases: object

Masks, chains, and writes — the only path from a decision to an AuditSink.

Funnelling every write through one object is what makes masking-before-the-write enforceable rather than aspirational. Nothing else in sentrykit calls sink.write, so “masking runs before the write” is a property of one function instead of a rule every future caller has to remember.

Parameters:
  • sink – Where records go. None disables auditing entirely — the trail then builds no records at all, which is what keeps an unaudited governor as cheap as it was before this hook existed.

  • masker – How sensitive values are masked. Defaults to a masker built from the process settings.

  • bundle – The loaded policy bundle, for per-policy provenance and obligations. Omitted when the governor was wired from raw policy text rather than from a source.

  • strict – Whether a sink failure refuses the governed call. Defaults to the effective fail_mode — “can’t prove it, don’t do it”.

property sink: AuditSink | None

The configured sink, or None when auditing is off.

property enabled: bool

Whether a sink is configured. False means record() does no work at all.

record(facts: DecisionFacts) → AuthzAuditRecord | None[source]

Build, mask, chain and write one decision record.

Parameters:

facts – What the engine decided.

Returns:

The written record, or None when auditing is disabled.

Raises:

AuditUnavailable – If the sink refused the write and strict is set.

record_mutation(grant: Mapping[str, object], *, actor: Principal, outcome: Literal['allowed', 'denied', 'engine-error', 'arguments-rejected', 'no-valid-arguments', 'default-unavailable', 'signature-default-rejected', 'validation-failed', 'granted', 'revoked'], invocation_id: str | None = None, reason: str | None = None) → AuthzAuditRecord | None[source]

Write a record for an admin grant mutation.

The admin surface is governed by the same engine and logged into the same sink (architecture §5), so a role assignment is as auditable as the call it later permits.

Parameters:
  • grant – The as_record() rendering.

  • actor – Who made the change.

  • outcome – granted or revoked.

  • invocation_id – Correlation id; generated when omitted.

  • reason – The justification.

Returns:

The written record, or None when auditing is disabled.

Raises:

AuditUnavailable – If the sink refused the write and strict is set.

query(window: AuditWindow) → Iterable[AuthzAuditRecord][source]

Read records back from the sink.

Parameters:

window – The filter.

Returns:

The matching records, oldest first. Empty when auditing is disabled.

class sentrykit.audit.AuthzAuditRecord(*, record_id: str, sequence: int = 0, timestamp: ~datetime.datetime, invocation_id: str, kind: ~typing.Literal['decision', 'mutation'] = 'decision', principal: ~sentrykit.audit.PrincipalSnapshot, action: str = '', resource: str = '', callable_name: str = '', channel: str = 'internal', environment: dict[str, object] = <factory>, decision: ~typing.Literal['allow', 'deny'], outcome: ~typing.Literal['allowed', 'denied', 'engine-error', 'arguments-rejected', 'no-valid-arguments', 'default-unavailable', 'signature-default-rejected', 'validation-failed', 'granted', 'revoked'], reason: str | None = None, proposed: dict[str, object] = <factory>, processed: dict[str, object] = <factory>, rejected: tuple[~sentrykit.constraints.RejectedArgument, ...] = (), defaults_applied: dict[str, object] = <factory>, signature_defaults: tuple[str, ...] = (), obligations: tuple[str, ...] = (), determining_policies: tuple[str, ...] = (), provenance: tuple[dict[str, object], ...] = (), policy_set_hash: str = '', engine_errors: tuple[str, ...] = (), masked: dict[str, ~typing.Literal['redact', 'partial', 'hmac', 'tokenize']] = <factory>, grant: dict[str, object] | None = None, dropped_since_last_write: int = 0, prev_hash: str = '0000000000000000000000000000000000000000000000000000000000000000', record_hash: str = '')[source]

Bases: BaseModel

One decision, or one admin mutation, as it is written to a sink.

Every field is either something an auditor asks for or something a replay needs. The record is frozen: chaining produces a new record rather than mutating this one, so a record that has been written can never be edited in place by construction.

One field is there to contradict the others. The chain advances solely on a successful write, so the records either side of a sink outage link perfectly and verify_chain() calls the run intact — which is true, nothing was altered, and is also exactly what a caller probing the policy boundary while the sink was down would want it to say. So a contiguous chain across a record does not mean nothing happened before it; dropped_since_last_write says how much did. It is inside record_hash whenever it is set, which puts the admission on the channel that counts as evidence rather than in an operational log nobody can prove anything from afterwards.

Variables:
  • record_id (str) – Unique id for this record.

  • sequence (int) – Position in the chain, starting at 0.

  • timestamp (datetime.datetime) – When the decision was made.

  • invocation_id (str) – Correlation id, shared with the Cedar result, the _authz envelope and the operational logs.

  • kind (Literal['decision', 'mutation']) – decision or mutation.

  • principal (sentrykit.audit.PrincipalSnapshot) – The effective-principal snapshot.

  • action (str) – The action uid.

  • resource (str) – The resource uid.

  • callable_name (str) – Qualified name of the protected callable.

  • channel (str) – http, agent or internal.

  • environment (dict[str, object]) – The Cedar request context, masked. The proposed arguments are stripped from it — they are carried in proposed, and duplicating them would mean masking the same values twice, in two shapes, with two chances to miss one.

  • decision (Literal['allow', 'deny']) – allow or deny, as the caller experienced it.

  • outcome (Literal['allowed', 'denied', 'engine-error', 'arguments-rejected', 'no-valid-arguments', 'default-unavailable', 'signature-default-rejected', 'validation-failed', 'granted', 'revoked']) – The finer-grained reason, see AuditOutcome.

  • reason (str | None) – One sentence, for a human reading the log.

  • proposed (dict[str, object]) – What the caller asked for, masked. Untrusted input, recorded as evidence.

  • processed (dict[str, object]) – The final argument set the policy decided and sentrykit passed, masked. A parameter it left to the callable’s own default is absent here and named in signature_defaults instead.

  • rejected (tuple[sentrykit.constraints.RejectedArgument, ...]) – Arguments the policy refused, with reasons and masked values.

  • defaults_applied (dict[str, object]) – Arguments filled in from the principal’s grants, masked.

  • signature_defaults (tuple[str, ...]) – Governed parameters whose value came from the callable’s own signature rather than from the caller or from policy — names only, never values, because the value is the application’s own literal and the name is what an auditor needs. processed does not carry these, by design: it is the set sentrykit decided, and blurring the two would cost the distinction defaults_applied exists to make. A non-empty tuple means the policy checked that value and accepted it; a call where it was refused is a refusal, with its own outcome.

  • obligations (tuple[str, ...]) – Obligations the determining policies attached.

  • determining_policies (tuple[str, ...]) – The policies that decided it.

  • provenance (tuple[dict[str, object], ...]) – Where each determining policy came from.

  • policy_set_hash (str) – Digest of the policy logic evaluated, so the decision replays.

  • engine_errors (tuple[str, ...]) – Cedar errors, when the engine failed rather than decided.

  • masked (dict[str, Literal['redact', 'partial', 'hmac', 'tokenize']]) – Which fields were masked and with which strategy. Without this an auditor cannot tell an hmac:... string from a value that genuinely looked like one.

  • grant (dict[str, object] | None) – The grant record, for kind="mutation".

  • dropped_since_last_write (int) – How many records were built, masked and then refused by the sink between the previous record that landed and this one — the only thing that tells a quiet log from a log with a hole in it. Zero on all but a vanishing number of records, and covered by record_hash whenever it is not.

  • prev_hash (str) – The previous record’s record_hash, or GENESIS_HASH.

  • record_hash (str) – This record’s digest, over everything above.

payload() → dict[str, object][source]

Render everything the hash covers — that is, every field but record_hash.

prev_hash is covered, which is what binds the chain: re-pointing a record at a different predecessor changes its own hash too.

dropped_since_last_write and signature_defaults are covered only when they are set, and that asymmetry is deliberate. A log written before a field existed hashed a payload without the key, so a key that always appeared — even at its default — would change every one of those digests and make an existing log fail re-verification. Omitting an unset one is still tamper-evident in both directions: forging a count to zero drops the key and forging one onto a clean record adds it, and either way the payload, and so the hash, is no longer the one that was written.

Any field added here from now on follows the same rule, and the reason is worth keeping in mind rather than re-deriving: this method is the canonical pre-image, so every key it emits is part of the integrity claim for every record ever written.

compute_hash() → str[source]

Compute this record’s digest from payload().

chained(*, prev_hash: str, sequence: int, dropped_since_last_write: int = 0) → AuthzAuditRecord[source]

Return a copy positioned in a chain, with record_hash computed.

All three arguments are the record’s position in the log rather than anything the decision knew, which is why the loss count arrives here and not at build time: it is only knowable once the preceding writes have been attempted. Carrying it through this method is what keeps it inside the digest — a count applied after the hash was computed could be stripped off without trace, and a count applied by a second model_copy at the call site would put chain state in two places.

Parameters:
  • prev_hash – The predecessor’s hash, or GENESIS_HASH for the first record.

  • sequence – Position in the chain.

  • dropped_since_last_write – Records the sink refused since the last one it accepted.

Returns:

The chained record, ready to write.

verify() → bool[source]

Whether record_hash still matches this record’s content.

class sentrykit.audit.ChainVerdict(ok: bool, broken_at: int | None = None, reason: str | None = None)[source]

Bases: NamedTuple

The result of verifying a chain.

Variables:
  • ok (bool) – Whether the chain is intact.

  • broken_at (int | None) – Sequence number of the first bad record, or None.

  • reason (str | None) – What was wrong — a rewritten record, a broken link, a gap in the sequence.

ok: bool

Alias for field number 0

broken_at: int | None

Alias for field number 1

reason: str | None

Alias for field number 2

class sentrykit.audit.DecisionFacts(invocation: ~sentrykit.context.Invocation, decision: ~sentrykit.cedar.CedarDecision, outcome: ~typing.Literal['allowed', 'denied', 'engine-error', 'arguments-rejected', 'no-valid-arguments', 'default-unavailable', 'signature-default-rejected', 'validation-failed', 'granted', 'revoked'], allowed: bool, reason: str | None = None, processed: ~collections.abc.Mapping[str, object] = <factory>, rejected: tuple[~sentrykit.constraints.RejectedArgument, ...] = (), defaults_applied: ~collections.abc.Mapping[str, object] = <factory>, signature_defaults: tuple[str, ...] = (), obligations: tuple[str, ...] = (), declared_sensitive: ~collections.abc.Mapping[str, ~typing.Literal['redact', 'partial', 'hmac', 'tokenize'] | None] = <factory>, engine_errors: tuple[str, ...] = ())[source]

Bases: object

Everything the engine knows about one decision, handed to the trail.

A value object rather than the Plan itself, so sentrykit.audit does not import sentrykit.engine — the dependency runs one way, and the trail stays testable without a governor.

Variables:
  • invocation (sentrykit.context.Invocation) – The normalized invocation, including the raw proposed arguments.

  • decision (sentrykit.cedar.CedarDecision) – What Cedar returned.

  • outcome (Literal['allowed', 'denied', 'engine-error', 'arguments-rejected', 'no-valid-arguments', 'default-unavailable', 'signature-default-rejected', 'validation-failed', 'granted', 'revoked']) – What happened, finer-grained than allow/deny.

  • allowed (bool) – Whether the call proceeded.

  • reason (str | None) – One-sentence explanation, matching the envelope’s.

  • processed (collections.abc.Mapping[str, object]) – The final argument set, when the call proceeded.

  • rejected (tuple[sentrykit.constraints.RejectedArgument, ...]) – Arguments the policy refused.

  • defaults_applied (collections.abc.Mapping[str, object]) – Arguments filled from the principal’s grants.

  • signature_defaults (tuple[str, ...]) – Governed parameters the callable’s own signature default filled, having been checked against the constraint and accepted. Names only.

  • obligations (tuple[str, ...]) – Obligations from the determining policies.

  • declared_sensitive (collections.abc.Mapping[str, Literal['redact', 'partial', 'hmac', 'tokenize'] | None]) – sensitive_fields() for the protected callable, so a signature-level Sensitive marker is honored.

  • engine_errors (tuple[str, ...]) – Cedar errors, when the engine failed.

class sentrykit.audit.PrincipalSnapshot(*, uid: str, type: str, id: str, roles: tuple[str, ...]=(), parents: tuple[str, ...]=(), attributes: dict[str, object]=<factory>, constraints: dict[str, object]=<factory>)[source]

Bases: BaseModel

The acting principal exactly as the decision saw them.

A snapshot, not a reference. Grants change, roles are revoked, attributes are corrected — and a record that merely pointed at a principal would silently start describing a different one. This is what makes a decision replayable long after the fact.

Variables:
  • uid (str) – The principal’s Cedar uid.

  • type (str) – Entity type.

  • id (str) – Entity id.

  • roles (tuple[str, ...]) – Role names, expanded to Role::"..." edges at decision time.

  • parents (tuple[str, ...]) – Every other parent edge, as uids — the teams, projects and delegation edges a multi-hop relationship was resolved through.

  • attributes (dict[str, object]) – Effective Cedar attributes, masked.

  • constraints (dict[str, object]) – The argument constraints in force, by action, as describe() renders them — which withholds the default value but emits every other rule as written. of() therefore does not mask them; AuditTrail does, masking the rules that carry values — the allow-set, the deny-set and the pattern — under the name of the argument they govern. minimum and maximum reach the sink as written, because a bound is a rule rather than a value.

classmethod of(principal: Principal) → PrincipalSnapshot[source]

Snapshot a principal. Does not mask — AuditTrail does that.

That includes the constraints. They arrive described rather than dumped, which withholds the default value, but an allow-set, a deny-set and a pattern still hold the very values the constraint exists to fence off. AuditTrail masks those three on the way to the sink and leaves the bounds as written — so a caller using this snapshot for anything else is holding raw values and has to treat them as such.

sentrykit.audit.canonical_json(payload: Mapping[str, object]) → bytes[source]

Render a payload to the exact bytes the chain hashes.

Determinism is the whole requirement: sorted keys, no insignificant whitespace, UTF-8, and no dependence on dict insertion order or platform. Anything unrenderable falls back to its repr rather than raising — an audit record must not be able to fail the decision it describes.

Parameters:

payload – The record payload, already JSON-normalized.

Returns:

The canonical encoding.

sentrykit.audit.verify_chain(records: Iterable[AuthzAuditRecord]) → ChainVerdict[source]

Verify a run of records: each hash matches its content, and each link matches.

Checks both, because they catch different attacks. Re-hashing catches an edited record; link checking catches a deleted one, which would otherwise leave every remaining record individually valid.

Parameters:

records – The records, in write order.

Returns:

The verdict, naming the first record that fails. An empty run is intact by definition. The first record’s prev_hash is not required to be GENESIS_HASH, because a run is often a window over a longer log rather than the log itself.

sentrykit.masking

Masking — the step that runs immediately before every audit write.

The reasoning in one line: anything written to an append-only store is permanent, so a raw sensitive value written there cannot be withdrawn, and masking at read or report time is too late by definition. The consequence: masking on the audit path is not configurable. The redaction setting governs the caller-facing envelope and the operational logs; it cannot switch off the mask before an immutable write.

Four strategies, trading recoverability against exposure:

redact

Replace the value outright. Nothing survives — use it for values that have no analytical use at all.

partial

Keep a short suffix (****4321). Enough for a human to recognize a record, not enough to reconstruct the value.

hmac

Keyed digest. The default, because it is the only one that lets an auditor correlate records — “the same account appears in these fourteen decisions” — without the store ever holding the account number. Unkeyed hashing would not do: a low-entropy value like a customer id is trivially recovered from a plain digest by enumeration.

tokenize

Reversible, via a mapping held in a separate vault. The audit store holds only the token; the vault is mutable and can honor an erasure request, which is exactly why the two must not be the same store.

A field selects its strategy three ways, most specific first:

  1. A policy obligation — @obligations("mask:customer_id, redact:ssn") on a determining policy. Policy wins, because an obligation is reviewed logic.

  2. A signature annotation — Annotated[str, Sensitive("hmac")] on the protected callable’s parameter. The developer’s declared default.

  3. The configured default strategy, for anything an obligation named without a verb.

sentrykit.masking.MASK_VERBS: Final[frozenset[str]] = frozenset({'hmac', 'mask', 'partial', 'redact', 'tokenize'})

Obligation verbs that select a masking strategy.

mask means “use the configured default”; the other four name a strategy outright. An obligation whose verb is not in this set — notify:secops, justify — is not a masking instruction and passes through untouched, to be discharged by the host.

class sentrykit.masking.HmacStrategy(key: bytes, *, digest_size: int = 32)[source]

Bases: object

Keyed SHA-256 digest — correlatable, not recoverable. The default.

Parameters:
  • key – The HMAC key, from SENTRYKIT_HMAC_KEY.

  • digest_size – How many hex characters of the digest to keep. 32 is ample for correlation and keeps a record readable.

Raises:

ConfigurationError – If key is empty. An unkeyed digest of a low-entropy value — a customer id, an account number — is recoverable by enumeration, so a missing key is a configuration fault rather than something to silently degrade over.

mask(value: object, *, field: str) → object[source]

Return hmac:<hex>, keyed by the configured key and bound to field.

The field name is hashed alongside the value with a separator that cannot occur in an identifier, so customer_id="c-1" and account="c-1" do not collide and a correlation across records is a correlation of the same field.

class sentrykit.masking.MaskStrategy(*args, **kwargs)[source]

Bases: Protocol

One way of turning a sensitive value into something safe to persist forever.

property name: Literal['redact', 'partial', 'hmac', 'tokenize']

The strategy’s name, recorded on the audit record so a reader knows what was done.

mask(value: object, *, field: str) → object[source]

Mask one value.

Parameters:
  • value – The raw value. May be of any type the callable accepted.

  • field – The argument name, used by keyed strategies so that the same raw value under two different field names does not produce the same digest.

Returns:

The masked value. Always JSON-renderable, because it is bound for a sink.

sentrykit.masking.MaskStrategyName

The four masking strategies a value can be written through.

alias of Literal[‘redact’, ‘partial’, ‘hmac’, ‘tokenize’]

class sentrykit.masking.Masker(strategies: Mapping[Literal['redact', 'partial', 'hmac', 'tokenize'], MaskStrategy] | None = None, *, default: Literal['redact', 'partial', 'hmac', 'tokenize'] = 'hmac', key: bytes | None = None, vault: TokenVault | None = None)[source]

Bases: object

Applies the masking policy for one decision.

Parameters:
  • strategies – Strategy instances by name. Defaults to the built-in four, with tokenize present only when a vault was supplied.

  • default – Which strategy an unqualified mask: obligation or a bare Sensitive() marker resolves to.

  • key – HMAC key, used to build the default HmacStrategy.

  • vault – Token vault, used to build TokenizeStrategy.

Raises:

ConfigurationError – If a strategy the configuration needs cannot be built — an hmac default with no key, a tokenize default with no vault. That check is deferred to the moment a field actually resolves to the strategy, not made at construction: a masker with no key still masks correctly with redact and partial, and a process that declares nothing sensitive must not be stopped from auditing because it never set a key it does not use.

classmethod from_settings(settings: Settings, *, vault: TokenVault | None = None) → Masker[source]

Build a masker from Settings.

Parameters:
  • settings – The effective settings, read for hmac_key and mask_default.

  • vault – Token vault, when tokenization is wanted.

Returns:

The configured masker.

Raises:

ConfigurationError – If the settings ask for a strategy that cannot be built.

plan(*, declared: Mapping[str, Literal['redact', 'partial', 'hmac', 'tokenize'] | None] | None = None, obligations: Sequence[str] = ()) → dict[str, Literal['redact', 'partial', 'hmac', 'tokenize']][source]

Decide which strategy applies to which field, for one decision.

Parameters:
  • declared – sensitive_fields() output for the protected callable.

  • obligations – The obligations the determining policies attached. Masking verbs are consumed; anything else is ignored here and left for the host.

Returns:

Field name to the strategy that will mask it. An obligation beats a declaration.

mask_value(field: str, value: object, strategy: Literal['redact', 'partial', 'hmac', 'tokenize']) → object[source]

Mask one value with a named strategy.

Raises:

ConfigurationError – If the strategy is not available on this masker. Refusing is the only safe answer: the alternative is to fall back to a weaker strategy and write the value into a store that cannot be corrected.

mask_mapping(values: Mapping[str, object], plan: Mapping[str, Literal['redact', 'partial', 'hmac', 'tokenize']]) → dict[str, object][source]

Mask every field in values that plan names, leaving the rest as they are.

Parameters:
  • values – Raw argument values.

  • plan – The output of plan().

Returns:

A new mapping. The input is never mutated — the caller still has to hand the raw values to the protected callable.

mask_rejections(rejected: Iterable[RejectedArgument], plan: Mapping[str, Literal['redact', 'partial', 'hmac', 'tokenize']]) → tuple[RejectedArgument, ...][source]

Mask the value carried on each RejectedArgument.

A rejection is exactly where raw sensitive data hides: the argument was refused, but the value the caller proposed is still attached to explain why.

Parameters:
  • rejected – The rejections from the decision.

  • plan – The output of plan().

Returns:

New rejections with masked values, in the original order.

class sentrykit.masking.PartialStrategy(keep: int = 4, placeholder: str = '*')[source]

Bases: object

Keep a short suffix of the rendered value, mask the rest.

Parameters:
  • keep – How many trailing characters to preserve.

  • placeholder – The character standing in for each masked position.

mask(value: object, *, field: str) → object[source]

Return the value with everything but its last keep characters replaced.

A value at or below keep characters is fully masked rather than partially revealed: the short-value case is exactly where a partial mask would leak the whole thing.

class sentrykit.masking.RedactStrategy[source]

Bases: object

Replace the value with a fixed marker. Nothing survives.

mask(value: object, *, field: str) → object[source]

Return REDACTED.

The marker is fixed rather than shaped like the input — no length hint, no type hint — because anything derived from the value is something an attacker can work with.

class sentrykit.masking.Sensitive(strategy: Literal['redact', 'partial', 'hmac', 'tokenize'] | None = None)[source]

Bases: object

Marks a parameter as sensitive, in the signature of the protected callable.

Written as annotation metadata, so it travels with the type rather than living in a registry someone has to keep in sync:

@protect(action='Action::"tool.lookup"')
def lookup(account: Annotated[str, Sensitive("hmac")], note: str = "") -> dict[str, str]:
    ...
Variables:

strategy (Literal['redact', 'partial', 'hmac', 'tokenize'] | None) – Which strategy to apply. None means “sensitive, use the configured default”, which is the right spelling when a codebase wants to declare what is sensitive without deciding how it is masked.

class sentrykit.masking.TokenVault(*args, **kwargs)[source]

Bases: Protocol

The reversible mapping behind TokenizeStrategy.

Deliberately not an audit store: it is mutable, so a subject-erasure request can drop the mapping and leave the audit chain intact but no longer resolvable. Putting it in the append-only store would make the erasure impossible, which is the whole point of masking upstream of the write.

tokenize(value: str, *, field: str) → str[source]

Return a stable token for value, minting one if this is the first sighting.

resolve(token: str) → str | None[source]

Return the raw value behind token, or None if it is unknown or erased.

class sentrykit.masking.TokenizeStrategy(vault: TokenVault)[source]

Bases: object

Replace the value with a stable token, keeping the mapping in a separate vault.

Parameters:

vault – Where the reversible mapping lives.

mask(value: object, *, field: str) → object[source]

Return token:<id>, recording the mapping in the vault.

sentrykit.masking.sensitive_fields(model: type[BaseModel]) → dict[str, Literal['redact', 'partial', 'hmac', 'tokenize'] | None][source]

Read the Sensitive markers off a protected callable’s argument model.

ArgumentSpec builds that model with get_type_hints(include_extras=True), so Annotated metadata survives onto model_fields[...].metadata and can be read back here without the engine knowing anything about masking.

Parameters:

model – The generated argument model.

Returns:

Field name to its declared strategy, None where the marker named no strategy. Fields with no marker are absent.

sentrykit.config

Runtime settings for sentrykit.

Settings are read from the environment with the SENTRYKIT_ prefix (or set explicitly by the host application through configure()) and resolved per protected action, so a single noisy or strict action can deviate from the process-wide default without a second configuration mechanism.

The knobs and their meaning:

verbosity

How much of the _authz envelope is rendered — off emits none at all, summary names what happened, verbose adds the argument values, debug adds the decision trace.

arg_handling

lenient silently drops arguments the policy rejects; strict raises ArgumentRejected on the first violation.

fail_mode

What to do when the engine fails (unparseable policies, a Cedar evaluation error, an unavailable audit sink) — closed refuses the call, open proceeds and records the failure. A policy denial is not an engine failure: it denies under both modes, as does a missing principal.

redaction

Whether sensitive argument values are masked before they leave the engine. The flag is carried through the envelope; the strategies themselves live in sentrykit.masking.

envelope_on_success

Whether a successful invocation gets an envelope attached at all. Denials always carry their detail on the raised error, regardless.

audit_sink

DSN of the sink decisions are written to — jsonl://./audit.jsonl. Unset means no audit trail is emitted at all, which is the right default for a library that must not start writing files because it was imported.

hmac_key

Key for the hmac masking strategy. Held as a SecretStr so it cannot be printed into a debug trace or a repr by accident.

mask_default

Which strategy an unqualified mask: obligation or a bare Sensitive() marker resolves to. hmac by default, because it is the only strategy that keeps records correlatable without keeping the value.

Note that redaction governs the caller-facing envelope and the operational logs only. It does not reach the audit path: masking before an immutable write is not configurable, because that write is the one that cannot be taken back.

sentrykit.config.ENV_PREFIX: Final = 'SENTRYKIT_'

Environment-variable prefix, e.g. SENTRYKIT_VERBOSITY=debug.

sentrykit.config.ArgHandling

Whether a policy-rejected argument is dropped (lenient) or fatal (strict).

alias of Literal[‘lenient’, ‘strict’]

sentrykit.config.FailMode

Whether an engine failure refuses the call (closed) or lets it through (open).

alias of Literal[‘closed’, ‘open’]

sentrykit.config.MaskDefault

Default masking strategy.

Spelled out here rather than imported from sentrykit.masking so that configuration stays at the bottom of the dependency graph — masking imports config, not the other way round. The two are kept in step by a unit test.

alias of Literal[‘redact’, ‘partial’, ‘hmac’, ‘tokenize’]

class sentrykit.config.Settings(*, verbosity: Verbosity = 'summary', arg_handling: ArgHandling = 'lenient', fail_mode: FailMode = 'closed', redaction: bool = True, envelope_on_success: bool = True, audit_sink: str | None = None, hmac_key: SecretStr | None = None, mask_default: MaskDefault = 'hmac')[source]

Bases: BaseSettings

Process-wide sentrykit configuration.

Frozen: an effective per-action configuration is produced by copying, never by mutating the global, so a per-action override cannot leak into unrelated calls.

class sentrykit.config.SettingsOverrides[source]

Bases: TypedDict

Keyword overrides accepted by configure() and override_action().

Declared as a TypedDict so the variadic keyword arguments of those two functions stay fully typed instead of collapsing to Any.

sentrykit.config.Verbosity

How much of the _authz envelope to render.

alias of Literal[‘off’, ‘summary’, ‘verbose’, ‘debug’]

sentrykit.config.apply_overrides(base: Settings, action: str | None) → Settings[source]

Layer the override registered for action on top of base.

Split out from settings_for() so a governor constructed with explicit settings still honors per-action overrides – otherwise configuring a governor would silently disable a mechanism the host had already relied on.

Parameters:
  • base – The settings to start from.

  • action – The action being authorized, in either spelling, or None.

Returns:

base unchanged when nothing is registered for action, otherwise a copy with the override applied.

sentrykit.config.clear_action_overrides() → None[source]

Remove every per-action override, leaving the global settings in place.

sentrykit.config.configure(**overrides: Unpack[SettingsOverrides]) → Settings[source]

Replace the process-wide settings with environment values plus overrides.

Parameters:

**overrides – Fields to override on top of what the environment supplies.

Returns:

The newly installed global Settings.

Raises:

ConfigurationError – If the resulting combination is invalid.

sentrykit.config.get_settings() → Settings[source]

Return the process-wide settings, loading them from the environment on first use.

Returns:

The cached global Settings.

Raises:

ConfigurationError – If an environment variable holds a value the model rejects.

sentrykit.config.override_action(action: str, **overrides: Unpack[SettingsOverrides]) → None[source]

Register settings that apply only to action.

Registering twice for the same action replaces the earlier override outright, so what applies to an action stays answerable by reading a single call.

Parameters:
  • action – The action this override applies to, either the bare id ("tool.search_orders") or the full Cedar uid (Action::"tool.search_orders"). Both spellings resolve to the same override.

  • **overrides – Fields to override for that action only.

sentrykit.config.reset_settings() → None[source]

Drop the cached global settings and every per-action override.

Intended for tests and for hosts that reload configuration at runtime; the next get_settings() re-reads the environment.

sentrykit.config.settings_for(action: str | None = None) → Settings[source]

Resolve the effective settings for action.

Parameters:

action – The action being authorized, or None for the global settings.

Returns:

The global settings, with any override registered for action applied on top.

sentrykit.registry

One plugin mechanism, used by everything pluggable.

Four pieces, everywhere: a Protocol contract, a name-keyed registry, an in-process register_* decorator, and importlib.metadata entry-point discovery — the same set for policy sources, grant stores, audit sinks and framework context providers. Adding a sink and adding a framework are the same shape of task, and the core imports neither.

Built-ins register through the decorator a third party would use. They are not special- cased anywhere; jsonl reaches the registry by exactly the path sentrykit-splunk would. That is the property the plugin tests assert, because it is the only way to know an external backend is a first-class citizen rather than a documented aspiration.

Backends are addressed by DSN: jsonl://./audit.jsonl?fsync=true, file://./policies, memory://. The scheme selects the backend; everything after it is the backend’s business.

Discovery is lazy. EntryPoint.load() imports the plugin’s module, so resolving every installed backend at import sentrykit time would drag optional dependencies into every process. The registry looks at entry points only when an in-process lookup misses, then caches the result.

Three importlib.metadata details matter here: always call entry_points(group=...) (the bare call returns a different type on 3.11), treat the result as an iterable and nothing more (it is a list subclass on 3.11 and a tuple subclass on 3.12+), and expect an unknown group to be empty rather than to raise.

class sentrykit.registry.DSN(scheme: str, target: str = '', options: Mapping[str, str]=<factory>, raw: str = '')[source]

Bases: object

A parsed backend address.

Parsed by hand rather than with urllib.parse.urlsplit(), because the targets are often filesystem paths and this library ships for win-64: a URL parser reads the D: of file://D:/policies as a scheme-relative host and mangles it. Splitting on the first :// and the first ? keeps a Windows path intact, which matters more here than RFC conformance for addresses only sentrykit ever reads.

Variables:
  • scheme (str) – The registered backend name — jsonl, file, memory.

  • target (str) – Everything between the scheme and the query. Usually a path; may be empty (memory://).

  • options (collections.abc.Mapping[str, str]) – Query parameters, as strings.

  • raw (str) – The DSN exactly as given, for error messages and provenance.

option(name: str, default: str | None = None) → str | None[source]

Return one option, or default when it is absent.

flag(name: str, default: bool = False) → bool[source]

Read an option as a boolean.

true/1/yes/on are true, false/0/no/off are false, in any case.

Raises:

ConfigurationError – If the value is present but is neither. A typo in ?fsync=ture silently meaning “no” is exactly the kind of durability setting that must not fail quietly.

integer(name: str, default: int | None = None) → int | None[source]

Read an option as an integer.

Raises:

ConfigurationError – If the value is present and not an integer.

sentrykit.registry.ENTRY_POINT_GROUPS: Final[Mapping[PluginKind, str]] = {'audit_sink': 'sentrykit.audit_sinks', 'context_provider': 'sentrykit.context_providers', 'grant_store': 'sentrykit.grant_stores', 'policy_source': 'sentrykit.policy_sources'}

Entry-point group per kind. A third-party package publishes into one of these.

sentrykit.registry.Configured: TypeAlias = 'str | object | Sequence[str | object]'

a DSN, a built backend, or a sequence mixing the two.

Deliberately not narrowed to each Protocol. The four build_* functions share one normalization path, and a caller who passes something that satisfies no contract gets a ConfigurationError naming the protocol it failed — which is a better error than a type checker’s, and the only one available to a host wiring itself from a config file at runtime.

Type:

How a backend can be named

sentrykit.registry.PluginKind

The four extension points.

alias of Literal[‘policy_source’, ‘grant_store’, ‘audit_sink’, ‘context_provider’]

class sentrykit.registry.RegisteredPlugin(kind: Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider'], name: str, factory: Callable[[DSN], object], distribution: str | None = None, version: str | None = None, source: Literal['builtin', 'entry-point'] = 'builtin')[source]

Bases: object

One registered backend, however it got here.

Variables:
  • kind (Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider']) – Which extension point it plugs into.

  • name (str) – The DSN scheme it answers to.

  • factory (collections.abc.Callable[[sentrykit.registry.DSN], object]) – Callable taking a DSN and returning an instance. For a class registered directly this is its from_dsn classmethod.

  • distribution (str | None) – The distribution that supplied it (sentrykit, sentrykit-splunk), when discovery knows — EntryPoint.dist may be absent.

  • version (str | None) – That distribution’s version, when known.

  • source (Literal['builtin', 'entry-point']) – builtin for an in-process registration, entry-point for a discovered one. Recorded for diagnostics only: the core treats the two identically, which is the whole point of the registry.

sentrykit.registry.available(kind: Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider']) → tuple[str, ...][source]

List every backend name available for kind, discovering entry points.

Parameters:

kind – Which extension point.

Returns:

The names, sorted.

sentrykit.registry.build_audit_sink(dsn: Configured | None) → AuditSink | None[source]

Build an audit sink from one DSN, or a fan-out composite from several.

Parameters:

dsn – One DSN, several, or None. The error policy for a fan-out is a DSN option on the first sink (?on_error=primary-durable or ?on_error=all-or-nothing).

Returns:

The sink, the composite, or None when none were configured — which leaves auditing off rather than silently discarding records into a null sink.

Raises:

ConfigurationError – If a scheme is unknown or a backend rejects its DSN.

sentrykit.registry.build_context_provider(dsn: Configured | None) → ContextProvider | None[source]

Build a framework context provider. Phases 3 and 4 supply the implementations.

Parameters:

dsn – One DSN, several, or None.

Returns:

The provider, or None.

Raises:

ConfigurationError – If a scheme is unknown.

sentrykit.registry.build_grant_store(dsn: Configured | None) → GrantStore | None[source]

Build a grant store from one DSN, or a composite from several.

Several stores merge by precedence — earlier DSNs win — unless every one of them declares a namespace, in which case the composite routes by namespace instead. See CompositeGrantStore.

Parameters:

dsn – One DSN, several, or None.

Returns:

The store, the composite, or None.

Raises:

ConfigurationError – If a scheme is unknown or a backend rejects its DSN.

sentrykit.registry.build_policy_source(dsn: Configured | None) → PolicySource | None[source]

Build a policy source from one DSN, or a composite from several.

Multiplicity is a composite that satisfies the same Protocol, so the core always holds exactly one source and never learns how many were configured.

Parameters:

dsn – One DSN, several, an already-built source, a mix of the two, or None.

Returns:

The single source when one DSN was given, a CompositePolicySource when several were, and None when none were.

Raises:

ConfigurationError – If a scheme is unknown or a backend rejects its DSN.

sentrykit.registry.clear_registry(kind: Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider'] | None = None) → None[source]

Drop registrations and the discovery cache.

For tests, and for a host that installs a plugin at runtime. Built-ins re-register on their module’s next import, so this is not a way to permanently remove one.

Parameters:

kind – Which extension point to clear, or None for all of them.

sentrykit.registry.parse_dsn(dsn: str) → DSN[source]

Parse a backend address.

Parameters:

dsn – scheme://target?key=value&key2=value2. A bare name (memory) is accepted and read as memory://, since a schemeless backend with no target is common enough that requiring the punctuation is just friction.

Returns:

The parsed address.

Raises:

ConfigurationError – If the string carries no usable scheme.

sentrykit.registry.register_audit_sink(name: str) → Callable[[type[T]], type[T]][source]

Register an AuditSink under a DSN scheme.

See register_policy_source() for the contract.

sentrykit.registry.register_context_provider(name: str) → Callable[[type[T]], type[T]][source]

Register a ContextProvider under a DSN scheme.

See register_policy_source() for the contract.

sentrykit.registry.register_grant_store(name: str) → Callable[[type[T]], type[T]][source]

Register a GrantStore under a DSN scheme.

See register_policy_source() for the contract.

sentrykit.registry.register_policy_source(name: str) → Callable[[type[T]], type[T]][source]

Register a PolicySource under a DSN scheme.

The decorated class must expose from_dsn(cls, dsn: DSN) -> Self.

Parameters:

name – The DSN scheme, e.g. file.

Returns:

The decorator, which returns the class unchanged.

Raises:

ConfigurationError – If name is already registered for this kind by a different class. Re-registering the same class is a no-op, so module reimport is harmless.

sentrykit.registry.registrations(kind: Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider']) → Mapping[str, RegisteredPlugin][source]

Return the in-process registrations for kind, without triggering discovery.

Exposed for resolve() and for tests that need to see the registry as it stands.

sentrykit.registry.remember(plugin: RegisteredPlugin) → None[source]

Cache a discovered registration so the entry-point sweep runs once per kind.

An in-process registration always wins: a host that has explicitly registered a backend has said which one it wants, and an installed package must not quietly replace it.

sentrykit.registry.resolve(kind: Literal['policy_source', 'grant_store', 'audit_sink', 'context_provider'], name: str) → RegisteredPlugin[source]

Find the backend registered as name, discovering entry points if needed.

In-process registrations win over entry points, so a host can override a shipped backend without uninstalling the package that provides it.

Parameters:
  • kind – Which extension point.

  • name – The DSN scheme.

Returns:

The registration.

Raises:

ConfigurationError – If nothing answers to that name. The message lists what is available, because “unknown sink ‘jsonl1’” with no list is a bad afternoon.

sentrykit.cli

sentrykit — the maintenance and audit command line.

Three things an operator needs that a library API is the wrong shape for: checking that a policy directory is sound before it is deployed, moving a grant, and turning a year of decisions into a document somebody can read.

sentrykit policy validate ./policies
sentrykit grant 'User::"ana"' --role oncall --expires 2026-09-01     --store memory:// --as 'User::"root"'
sentrykit revoke 4f2c... --reason "shift ended" --store memory:// --as 'User::"root"'
sentrykit audit-report --from 2026-01-01 --to 2026-12-31     --out audit.md --sink jsonl://./audit.jsonl

Reachable as the sentrykit console script and as python -m sentrykit.cli; both call main().

No CLI framework

argparse from the standard library, and nothing else. Three command groups over a stdlib parser is a few dozen lines of wiring, and a dependency bought with every install of a library whose entire pitch is that it is thin is a bad trade. Nothing here imports a web framework or an agent SDK either: the CLI is part of the core, and the core stays framework-free.

The admin surface governs itself

grant and revoke are not privileged back doors into the store. Each one is a @protect-ed callable, decided by the same Governor the application uses, against Action::"admin.grant" / Action::"admin.revoke" on Resource::"admin.grants" — so a policy can say who may administer grants, and which grants they may make, because the proposed subject and role are ordinary arguments subject to ordinary argument filtering. The mutation is then written to the audit sink as its own record (architecture §5).

That means an allowed grant writes two audit records — the decision that permitted it, and the mutation that happened — and a refused one writes one, the refusal. The scenario tests assert those counts, because a decision that is right and a log that is wrong is still a failure for an audit-first library.

sentrykit.cli.ADMIN_RESOURCE: Final = 'Resource::"admin.grants"'

The resource both admin actions are decided against.

sentrykit.cli.GRANT_ACTION: Final = 'Action::"admin.grant"'

The Cedar action a sentrykit grant asks permission for.

sentrykit.cli.REVOKE_ACTION: Final = 'Action::"admin.revoke"'

The Cedar action a sentrykit revoke asks permission for.

class sentrykit.cli.AuditReport(window: AuditWindow, sink: str = '', generated_at: datetime | None = None, total: int = 0, decisions: int = 0, mutations: int = 0, allowed: int = 0, denied: int = 0, by_outcome: Mapping[str, int]=<factory>, by_action: Mapping[str, int]=<factory>, by_principal: Mapping[str, int]=<factory>, by_policy: Mapping[str, int]=<factory>, by_obligation: Mapping[str, int]=<factory>, refusals: tuple[AuthzAuditRecord, ...]=(), chain: ChainVerdict | None = None, verified: int = 0, records_lost: int = 0)[source]

Bases: object

A period’s decisions, aggregated — the yearly audit report as a query, not as manual log-reading.

Aggregated by outcome rather than by decision, because allow/deny collapses six materially different refusals (and both admin mutations) into two buckets, and an auditor asking “how often did a call fail validation” cannot get that back out again.

Variables:
  • window (AuditWindow) – The query this report answers.

  • sink (str) – The name of the sink that answered it.

  • generated_at (datetime | None) – When the report was built.

  • total (int) – How many records the window matched.

  • decisions (int) – How many of them were authorization decisions.

  • mutations (int) – How many were admin grant mutations.

  • allowed (int) – Decisions that permitted the call.

  • denied (int) – Decisions that refused it.

  • by_outcome (Mapping[str, int]) – Record count per AuditOutcome, descending.

  • by_action (Mapping[str, int]) – Record count per action uid, descending.

  • by_principal (Mapping[str, int]) – Record count per principal uid, descending.

  • by_policy (Mapping[str, int]) – Refusal and allow count per determining policy id, descending — the column that answers “which rule is actually doing the work”.

  • by_obligation (Mapping[str, int]) – How often each obligation was raised.

  • refusals (tuple[AuthzAuditRecord, ...]) – The refusals themselves, newest first, capped by the caller’s --top.

  • chain (ChainVerdict | None) – The tamper-evidence verdict over the matched records, or None when the window filtered by identity and the records are therefore a non-contiguous slice of the chain rather than a run of it.

  • verified (int) – How many records’ own record_hash recomputed correctly. Always meaningful, filtered window or not.

  • records_lost (int) – How many records the window’s own records admit were built, masked and then refused by the sink — the sum of their dropped_since_last_write. An intact chain says nothing about these: the chain advances only on a successful write, so the records that survived an outage link perfectly across it. This is the only number in the report that can say the log is incomplete.

sentrykit.cli.build_parser() → ArgumentParser[source]

Build the argument parser for every command.

Separated from main() so a test can assert the surface — flags, defaults, help — without running anything.

Returns:

The parser. Sub-commands are dispatched through the command destination.

sentrykit.cli.build_report(records: Iterable[AuthzAuditRecord], *, window: AuditWindow, sink: str = '', generated_at: datetime | None = None, top: int = 20) → AuditReport[source]

Aggregate a window’s worth of records.

Pure: it reads an iterable and returns a value, touching no sink and no clock. That is what lets the report be asserted exactly in a test over a seeded log.

Parameters:
  • records – The records, oldest first, as query() returns them.

  • window – The query they answer, carried onto the report.

  • sink – The sink’s name, for the report header.

  • generated_at – The report timestamp. Pass one to make a test deterministic.

  • top – How many refusals to carry through to AuditReport.refusals.

Returns:

The aggregate.

sentrykit.cli.main(argv: Sequence[str] | None = None) → int[source]

Run the CLI.

Parameters:

argv – The arguments, defaulting to sys.argv [1:].

Returns:

EXIT_OK when everything asked for succeeded, EXIT_FAILED when the command ran and reported a failure. A usage error exits 2 through argparse, and a ConfigurationError is reported as a message on stderr rather than as a traceback — an operator mistyping a DSN has made a mistake, not found a bug.

sentrykit.cli.render_markdown(report: AuditReport) → str[source]

Render a report as markdown.

Markdown rather than PDF: it is diffable, it renders in every forge and doc site, and it costs no dependency. Anyone who needs a PDF runs the markdown through the converter they already have.

Parameters:

report – The aggregate.

Returns:

The document, ending in a newline.