Repository navigation
Releases: marcinmarzeta/obstat
Release list
0.7.0 — the human's answer is in the chain
Record schema 8. One theme: make the log answer questions the data already held.
The human's decision is in the chain. obstat approve and obstat deny now append a durable approval record as the database row changes — the approval id, the request record it answers, the state, who decided. Until now the chained record of a refusal was written by the agent's next retry, so an agent that never came back left the human's answer in the one store here anyone can UPDATE. That §8 item is gone.
obstat log --dangling lists every allowed call with no outcome record and says what its run says about why: the run ended, so the outcome write was lost; the run has no end marker, so the process died or is still serving; or the record predates schema 7. 0.6.0 put the run markers in the file. Nothing read them until now.
obstat pending warns the approver holding a rubber stamp. A request whose tool named nothing in record_args gets a line saying so — a digest and a resource id are all the approver then has.
A denial keeps its record id through the MCP SDK. A new test calling through the SDK found that since SDK 2.2 any exception that is not its own ToolError is treated as a crash: the traceback is logged and the client is told only Error executing tool <name>. Under the current SDK, obstat's denials were reaching agents with no record id at all. Denied now inherits from the SDK's class when the SDK is importable, and from Exception otherwise. No dependency added; except Denied is unchanged. The built wheel was run through the full suite on every MCP 2.x release, 2.0.0 through 2.2.0.
Also: py.typed ships, so type checkers see the annotations; the §9 table lists the fork-child run test; a subject resolver that raises is recorded (0.6.1) and the spec's §3.1 table has it as step 1c.
65 tests.
0.6.1 — a resolver that raises is a denial, not a crash
Three faults, each found by reading rather than by use. None changes the record schema.
A subject resolver that raised escaped unrecorded. The resource callable has been treated as "whatever it throws is a verdict to record" since 0.5.0. The subject resolver is host code of the same kind — a token check that times out — and got no such treatment: the exception propagated, and the log showed nothing. It now denies with a record, code subject_unresolved, step 1c in §3.1.
obstat approve said yes to a request already past its window. obstat pending was hiding it, and the retry would have refused it as approval_expired, so this was the one place in the protocol that reported success on something already dead. It now refuses. The window still runs from the request rather than the decision — that half stays in §8.
Every approval call leaked its SQLite connection. sqlite3's own context manager commits and does not close, so each call left its connection to the garbage collector. Python 3.13 reports that as a ResourceWarning; older versions do not report it at all. Connections are now closed on the way out.
Three tests added, one per fault. 60 total.
v0.6.0
v0.5.1 — a call missing an argument is refused, not authorised
One fix. No record-format change — schema stays 6.
@guard bound call arguments partially, so a call missing a required
argument passed the gate: it took an allow record and then raised TypeError
from the tool body. The log asserted a call had been authorised when it could
never have executed, which is the one kind of claim this project exists not to
make. §3.1 step 1b already said binding rejects such a call; the code did not.
It binds fully now, and the refusal is a recorded arguments_rejected denial.
Worth knowing why it survived four releases: a host that validates against the
advertised schema refuses these calls before obstat sees them, and the MCP SDK
does exactly that with pydantic. Through a server, nothing reached the gate.
The check still belongs in obstat — the guarantee is obstat's, not the host's —
and a host that does not validate is the one that needed it.
Upgrading: a call missing an argument now raises Denied rather than
TypeError. Fail-closed, and the shape §3.1 always described.
v0.5.0 — approved_by in the record, no unchecked approval ids, fail-closed resolvers
Three review findings, one theme: the record must not repeat, or depend on,
anything a caller or a mutable store asserts. Schema 5 → 6.
- The allow record that spends an approval carries
approved_by, read from
the row inside the transaction that consumes it. "Who said yes" no longer
lives only in the approvals database — the one store here anyone can
UPDATE. - An approval id supplied on a call policy allowed outright is dropped, not
recorded. It was never checked against anything, and recording it would
repeat a caller's claim that an approval existed — §1.2's rule, applied to a
different field. - Anything a resource callable raises becomes a recorded denial. The
callable is where §3.3 sends validation, and aValueErrorthere used to
escape with no record written; an unrecorded refusal is half a control. - §5.3's "a TTL elapsing denies" finally has a test, and §8 records that the
approval window runs from the request rather than the decision.
v0.4.0 — obstat_subject, what a call did, which policy said so
Everything here came out of running obstat in front of three real mailboxes.
Schema 4 → 5.
- The injected caller identity moved from
subjecttoobstat_subject.
Reserving the bare word took it from every tool that had a use for it — an
email tool wantssend_email(to, subject, body), and the failure was silent:
the parameter vanished from the advertised schema and aSubjectobject
landed in theSubject:header.obstat_is now the only reserved prefix,
and a tool still declaring the old spelling is refused at decoration rather
than handedNonefor ever. obstat.note(...)— a tool can record what the call did, not only what
it was allowed to do. A bulk delete records one sender whether it removed one
message or ten thousand; now the body can saydeleted=154, matched=158, on
failure too, since half a bulk delete is the case a reader most needs a
number for.- Every decision that consulted policy carries a
policydigest over the
bytes of the file, sorule 2still means something after the file is edited
and the numbering shifts. erroron outcome records is the exception's__qualname__—IMAP4.error,
noterror.- §8 now leads with the boundary the mailboxes demonstrated: the record
covers the gate, not the resource — and admits that Windows loses records
when two processes share a log (measured at 57 of 60 surviving).
v0.3.1 — an approval is a return value, not a protocol error
- The advertised return annotation is widened to
R | dict[str, Any]. A
server that validates a tool's result against-> strwas turning the
approval payload into a protocol error at the client — the exact outcome
§5.1 returns rather than raises to avoid. Found by calling through the real
MCP SDK instead of past it; there is a test doing that now. - The two claims about uncoordinated writers finally have tests: two real
processes appending to one log interleave records without splitting one
(POSIX), and a forked chain still verifies.
v0.3.0 — a policy you can start, and an approver who can see
obstat initwrites a starter policy — every rule commented out, one live
deny — and refuses to overwrite the file that is the only thing standing
between an agent and every guarded tool.obstat checkanswers what the
policy would decide without a call to make it decide, exits 0 only on allow,
and reports a broken policy instead of leaving it to the next real call.record_args(schema 4): a tool names the parameters whose values go on
the record, andobstat pendingprints them under the row — because an
approver deciding aboutsha256:ae32e6…is deciding about nothing. The
digest still covers every argument; values are written beside it, never
instead of it.OBSTAT_APPROVAL_TTLmakes the approval window configurable; a value that
cannot work raises instead of being silently replaced.- §8 (what is weak) and §10 (what is next) written down, including that a
resource id is caller-controlled text matched byte for byte (§3.3).
v0.2.0 — the log says when it has been edited
The record format moved: schema 1 → 3. Logs written by 0.1.1 still read — their
records verify as unverifiable rather than as damaged.
- Every record carries the hash of the one before it, and
obstat verify
recomputes the chain (schema 2). An edited line and a deleted line both show
up; a truncated tail does not — tamper-evidence, not non-repudiation, and the
spec says so in those words. - Every decision carries a stable
codebeside the prosereason(schema 3).
Filter, branch and assert on the code; read the reason. Until now the one
load-bearing branch — pending retry vs hard denial — compared an interpolated
sentence, so renaming a SQLite state would have turned every slow approval
into a failed call. - Three fixes closing the gap between what the record claims and what it does:
a record is one unbufferedwrite()syscall however large it is; the write
that creates the log fsyncs the directory entry too; and asubject
parameter declared before a positional one no longer takes the caller's
argument after theallowrecord is already on disk.
v0.1.1 — a retry rejoins the approval it already asked for
Fixes the one thing in 0.1.0 that bit a real agent on a real call.
The problem
Approval ids were random, so every retry without an id opened a new pending
approval. An agent polling while a human thought about it produced a queue of
identical requests — and the pile-up was worst exactly when someone was already
too busy to answer. Worse, a retry that did carry the id got Denied with "do
not retry" while the notification was still on somebody's phone, which turns a
slow approver into a failed call.
The fix
The approval id is now derived from the call:
sha256(tool | subject | resource | args_digest)[:12]
A retry therefore rejoins the request already waiting instead of opening another
one. A retry against a pending approval returns the phase-1 payload again
rather than denying.
ask x3, same call -> one approval, waiting=False then True, True
retry with the id -> payload again, waiting=True
queue depth -> 1 (was 3)
A row that is terminal or expired is replaced rather than rejoined: doing the
same thing twice on purpose is not a replay, and must be able to get its own
approval.
Behaviour changes
- The
approval_requiredpayload gainedwaiting—truewhen this rejoined
an existing request, which is how an agent tells "asked" from "asked again". - A retry against a pending approval no longer raises
Denied. It returns the
payload.denied,consumedand expired remain hard denials. - In
approval.consume, the binding check now runs before the state checks,
so a caller holding another call's id learns only that it does not match this
call — not what is happening to that approval.
Also
docs/obstat-spec.md— the spec, ported from the Airlock work. §5.1 and §5.3
cover the above; §8 lists what is still weak, including that the approver is
currently whoever can run the CLI.- Copyright line in the README.
Nothing in the enforcement order changed, and the record shape is unchanged apart
from approval_required records now naming their approval id.