Skip to content

Releases: marcinmarzeta/obstat

0.7.0 — the human's answer is in the chain

Choose a tag to compare

@github-actions github-actions released this 15 Sep 15:25

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

Choose a tag to compare

@github-actions github-actions released this 15 Sep 01:02

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

Choose a tag to compare

@github-actions github-actions released this 07 Aug 00:01

Full Changelog: v0.5.1...v0.6.0

v0.5.1 — a call missing an argument is refused, not authorised

Choose a tag to compare

@github-actions github-actions released this 02 Aug 15:23

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

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 02 Aug 14:58

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 a ValueError there 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

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 02 Aug 14:58

Everything here came out of running obstat in front of three real mailboxes.
Schema 4 → 5.

  • The injected caller identity moved from subject to obstat_subject.
    Reserving the bare word took it from every tool that had a use for it — an
    email tool wants send_email(to, subject, body), and the failure was silent:
    the parameter vanished from the advertised schema and a Subject object
    landed in the Subject: header. obstat_ is now the only reserved prefix,
    and a tool still declaring the old spelling is refused at decoration rather
    than handed None for 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 say deleted=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 policy digest over the
    bytes of the file, so rule 2 still means something after the file is edited
    and the numbering shifts.
  • error on outcome records is the exception's __qualname__ — IMAP4.error,
    not error.
  • §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

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 02 Aug 14:57
  • The advertised return annotation is widened to R | dict[str, Any]. A
    server that validates a tool's result against -> str was 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

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 02 Aug 14:57
  • obstat init writes 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 check answers 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, and obstat pending prints them under the row — because an
    approver deciding about sha256:ae32e6… is deciding about nothing. The
    digest still covers every argument; values are written beside it, never
    instead of it.
  • OBSTAT_APPROVAL_TTL makes 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

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 02 Aug 14:57

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 code beside the prose reason (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 unbuffered write() syscall however large it is; the write
    that creates the log fsyncs the directory entry too; and a subject
    parameter declared before a positional one no longer takes the caller's
    argument after the allow record is already on disk.

v0.1.1 — a retry rejoins the approval it already asked for

Choose a tag to compare

@marcinmarzeta marcinmarzeta released this 01 Aug 01:53

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_required payload gained waiting — true when 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, consumed and 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.