obstat
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 init
wrote obstat.toml — everything is denied until you uncomment a rule
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. Four 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.
The record says when it has been edited. Every record carries the hash of the
one before it, and obstat verify recomputes the chain:
$ obstat verify
line 3: record cd53f9db… follows a record that is no longer in the log
An edited line and a deleted line both show up. A truncated tail does not, and anyone who can write the file can recompute the whole chain — this is tamper-evidence, not non-repudiation, and §8 says so in those words.
And the record covers the gate, not the resource. Anything reaching the same
system by another path — its own client, a shell, the credential this process is
holding — leaves nothing behind, and the log does not read as incomplete. It
reads as quiet. Absence is evidence only over the calls that came through
@guard, which is a boundary to design your deployment around rather than a bug
to wait on: it is the first entry in §8.
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.
They are matched case-sensitively, against an id built from arguments the
caller sent. If your namespace is not case-sensitive — Jira keys, most
filesystems — a rule written for jira_issue:SEC-* will not cover sec-1, and
nothing will tell you. Normalise the id where you build it:
@guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
That is what the callable form is for; §3.3 has the whole story, including why obstat does not fold case for you.
The order
1 reject a caller-supplied obstat_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 obstat_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. obstat_ is the only prefix reserved —
every other parameter name, subject included, stays the tool's own.
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 init # a starter policy; refuses to overwrite one
obstat check <tool> [res] # what the policy would decide, without a call
obstat pending # approvals waiting on a human
obstat approve <id> [--by] # decide
obstat deny <id> [--by]
obstat log -n 50 # the decision record
obstat verify # recompute the chain; exit 1 if anything was edited
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.
obstat check exits 0 for an allow and 1 for anything else, so a policy can be
tested in CI — and a policy that does not parse is reported there rather than by
the next real call.
Arguments
Fingerprinted by default, never stored: tool arguments carry credentials and personal data, and a governance log that leaks them is a liability rather than a control. Name the ones a human needs and those values are recorded too —
@guard(resource="tool:send_email", record_args=("to",))
def send_email(to: str, body: str) -> str: ...
$ obstat pending
d41b88f29a43 send_email human:ana tool:send_email 871s left
to = 'board@example.com'
— because an approver deciding about sha256:ae32e6… is deciding about nothing.
The digest still covers every argument; body is in it and nowhere else. Name
identifiers, not payloads.
Arguments say what a call was allowed to do. Where they name a set rather than one thing, only the body knows what it then touched — so the body says:
@guard(record_args=("sender",))
def delete_by_sender(sender: str) -> str:
...
obstat.note(deleted=len(gone), matched=len(found))
{"phase": "outcome", "ok": true, "noted": {"deleted": 154, "matched": 158}}
Written on failure too, since half a bulk delete is the case a reader most needs
a number for. Outside a guarded call note() does nothing, so a tool body stays
callable without obstat.
What is not here yet
Deliberately absent: 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 |
OBSTAT_APPROVAL_TTL |
900 (seconds) |
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file obstat-0.4.0.tar.gz.
File metadata
- Download URL: obstat-0.4.0.tar.gz
- Upload date:
- Size: 101.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4ccd8d3d8a86d104faf439f5fa2d2ed797328e92ef3f3703211ebaa721acadf9
|
|
| MD5 |
29c23ee09d0577e21b236f3bf38a9de1
|
|
| BLAKE2b-256 |
47587814f7039a3ba1181b21c3d7855b277a564825c6b58d2c444abde445e6b6
|
Provenance
The following attestation bundles were made for obstat-0.4.0.tar.gz:
Publisher:
publish.yml on marcinmarzeta/obstat
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obstat-0.4.0.tar.gz -
Subject digest:
4ccd8d3d8a86d104faf439f5fa2d2ed797328e92ef3f3703211ebaa721acadf9 - Sigstore transparency entry: 2315839584
- Sigstore integration time:
-
Permalink:
marcinmarzeta/obstat@33729798ce9f01f2c4a27189646ec45306845f28 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/marcinmarzeta
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33729798ce9f01f2c4a27189646ec45306845f28 -
Trigger Event:
push
-
Statement type:
File details
Details for the file obstat-0.4.0-py3-none-any.whl.
File metadata
- Download URL: obstat-0.4.0-py3-none-any.whl
- Upload date:
- Size: 30.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd48d6cd899324ba1b85b526309e387bc149991da70d22f49dcf35d2a689d6e8
|
|
| MD5 |
8795d6e79f0eb2f24fb774e6411288c5
|
|
| BLAKE2b-256 |
1c26bbe7a9f3faef326eea016d7d6a3bed33f0802546462ece502523ae30a2c6
|
Provenance
The following attestation bundles were made for obstat-0.4.0-py3-none-any.whl:
Publisher:
publish.yml on marcinmarzeta/obstat
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obstat-0.4.0-py3-none-any.whl -
Subject digest:
bd48d6cd899324ba1b85b526309e387bc149991da70d22f49dcf35d2a689d6e8 - Sigstore transparency entry: 2315839673
- Sigstore integration time:
-
Permalink:
marcinmarzeta/obstat@33729798ce9f01f2c4a27189646ec45306845f28 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/marcinmarzeta
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@33729798ce9f01f2c4a27189646ec45306845f28 -
Trigger Event:
push
-
Statement type: