Skip to main content

obstat

PyPI Python CI License

An auditable decision record for agent tool calls. The clearance is written down before the call runs — not reconstructed from logs afterwards.

Nihil obstat: nothing stands in the way. It was the formal clearance a censor granted in writing, before publication. That is the whole idea here. An agent asks to do something, a rule decides, and the decision goes to disk before the tool body executes. If the process dies mid-call, the record still says what was authorised and why.

pip install obstat

No dependencies. Not AWS, not an identity provider, not a policy service — the decorator, tomllib, sqlite3 and a file.

docs/obstat-spec.md is normative, and its §8 lists what is still weak.

60 seconds

obstat.toml:

[[rule]]
tool = "read_*"
effect = "allow"

[[rule]]
tool = "delete_*"
effect = "approve"

Your tool:

from obstat import guard


@guard(resource="doc:{doc_id}")
def delete_document(doc_id: str) -> str: ...  # obstat has already decided this may happen

First call returns instead of running:

>>> delete_document("q3-report")
{'obstat': 'approval_required',
 'approval_id': '4f1c2a9b8e07',
 'expires_in_seconds': 900,
 'retry': "A human must approve this call. Once approved, call the same tool
           again with identical arguments plus obstat_approval_id='4f1c2a9b8e07'."}

A human decides:

$ obstat pending
4f1c2a9b8e07  delete_document  anonymous  doc:q3-report  871s left
$ obstat approve 4f1c2a9b8e07
4f1c2a9b8e07 approved by ana

The agent retries with the id, the call runs, and .obstat/decisions.jsonl holds the whole story.

What it is not

There are several good libraries that gate MCP tool calls. This one is built around a narrower claim: the record is the product. Three things follow from that, and they are the reason to pick this over a permission wrapper.

The decision is durable before the body runs. Not flushed after, not written in a finally, not batched. record.decision() returns only after fsync. A log written after the fact is a story about what happened; a record written before it is evidence of what was authorised. There is a test that runs inside a tool body, reads the log off disk, and fails if its own decision record is not already there.

Authorisation is per resource, not per tool. A tier — READ, WRITE, DESTRUCTIVE — cannot say "may edit their own ticket, not yours". obstat resolves the resource from the call arguments and matches rules against it:

@guard(resource="jira_issue:{issue_key}")
[[rule]]
subject = "human:ana"
resource = "jira_issue:ACME-*"
effect = "allow"

An approval is bound to one call. It carries the tool, the subject, the resource and a digest of the arguments, and it is single-use. Approving "delete q3-report" cannot be spent on deleting something else, and cannot be spent twice. This is enforced in one BEGIN IMMEDIATE transaction, so two concurrent retries cannot both win.

Identity is optional

Most MCP servers today have no token at all: stdio, one local user, or a gateway that already terminated auth. Demanding an identity provider before you can try a governance library is why governance libraries go untried. An anonymous call is a legitimate call here — it is recorded as anonymous, and the policy decides what anonymous may do.

When you do have identity, hand it over:

from obstat import Subject, set_subject_resolver

set_subject_resolver(lambda: Subject(id=current_user(), kind="human", verified=True))

verified=False is the honest flag for identity that came from somewhere a caller could influence — a header, an argument. It is recorded, so a reader can tell the difference between "Ana did this" and "something claimed to be Ana did this".

Policy

First matching rule wins. Nothing matching is a deny — an absent rule is not permission, and a missing policy file is an error rather than an implicit allow.

key matches default
tool the function name, or tool= on the decorator *
subject human:ana, agent:planner, service:etl, anonymous *
resource whatever the resource template produced *
effect allow, deny, approve required

Patterns are globs. The file is re-read when it changes, so editing policy does not need a restart.

The order

1  reject a caller-supplied subject
2  stop file
3  resolve the resource from the arguments
4  policy
5  approval, if policy asked for one
6  write the decision record — durable      <-- before, not after
7  run the body
8  write the outcome — best effort

Step 1 exists because subject is stripped from the tool's advertised signature. A client that sends one anyway is trying to name itself, and that is a denial before anything reads the value.

Step 8 is deliberately not durable. If the process dies between 7 and 8 the record reads "authorised, outcome unknown", which is the honest state; paying for a second fsync to say something merely informative is the wrong trade.

Operator commands

obstat pending              # approvals waiting on a human
obstat approve <id> [--by]  # decide
obstat deny <id> [--by]
obstat log -n 50            # the decision record
obstat stop                 # deny every guarded call
obstat resume

obstat stop is checked before policy, so stopping never depends on the policy file still being parseable.

What is not here yet

Arguments are fingerprinted (sha256:…), never stored — tool arguments carry credentials and personal data, and a governance log that leaks them is a liability rather than a control. A per-key allowlist for recording chosen values is the obvious next step.

Also absent, deliberately: retention and rotation of the log, Slack and webhook approval channels, a policy for where a result may be sent, and anything that talks to a cloud. Those belong at the edges, and the edges should be adapters rather than dependencies.

Configuration

variable default
OBSTAT_POLICY obstat.toml
OBSTAT_LOG .obstat/decisions.jsonl
OBSTAT_DB .obstat/approvals.db
OBSTAT_HALT .obstat/halt

Read at call time, never at import. A library that raises on import is a library you cannot try.

License

Apache-2.0. Copyright 2026 Marcin Marzęta.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

obstat-0.1.1.tar.gz (76.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

obstat-0.1.1-py3-none-any.whl (21.3 kB view details)

Uploaded Python 3

File details

Details for the file obstat-0.1.1.tar.gz.

File metadata

  • Download URL: obstat-0.1.1.tar.gz
  • Upload date:
  • Size: 76.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for obstat-0.1.1.tar.gz
Algorithm Hash digest
SHA256 bdfcc0abd39ed312f89c72ca436a2415484efa5530aab9efa4de10fb4bd6b485
MD5 3e9423702d4ae43a8a233a1a2338b5c4
BLAKE2b-256 407ec5d380764a01afae3541fdb61211b861aa1c84ecef51311728c8ba5b29c1

See more details on using hashes here.

Provenance

The following attestation bundles were made for obstat-0.1.1.tar.gz:

Publisher: publish.yml on marcinmarzeta/obstat

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file obstat-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: obstat-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 21.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for obstat-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 2aded329a7377c1f1e26bb3838d29e4c7eaa996fb0d1cca86153cf19f4732480
MD5 91ceeb98b6c5a1577d57b5b2364081f4
BLAKE2b-256 7f64723d35a6869265495b3fbbab07e59d511c319abf8fedc0a73b3d44dedad8

See more details on using hashes here.

Provenance

The following attestation bundles were made for obstat-0.1.1-py3-none-any.whl:

Publisher: publish.yml on marcinmarzeta/obstat

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page