🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)
🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)

🔧 Programmierung 🕛 kürzlich 9 Min Lesezeit
0

Your agent's audit log is a story, not evidence

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

Almost every tool-governance layer I have looked at writes its log after the call

returns. Some write it in a finally. Some batch it. Some hand it to a logging

framework that flushes on its own schedule.



That ordering quietly decides what your log can be used for.



If the record is written after the body runs, then a record that is missing has

two possible explanations, and nothing in the file distinguishes them:




  1. The call was never authorised, so it never ran.

  2. The call was authorised, ran, did its work, and the process died before the
    log line reached disk.



Those are not close together. One is the control working. The other is an

unlogged deletion. When someone asks you six weeks later what your agent was

permitted to do at 03:14, "there is no line for it" answers nothing.



So I wrote a small library that inverts the order.





obstat



obstat is an auditable decision

record for agent tool calls. Nihil obstat — nothing stands in the way — was the

formal clearance a censor granted in writing, before publication. That is the

whole idea.




CODE
from obstat import guard


@guard(resource="doc:{doc_id}")
def delete_document(doc_id: str) -> str: ...






An agent asks to do something, a rule decides, and the decision goes to disk —

written and fsynced — before the tool body executes. If the process dies

mid-call, the record still says what was authorised, for whom, against which

resource, and why.



record.decision() returns only after the fsync returns. Not flushed after,

not deferred, not batched. Everything else in the library is convenience; this is

the part an examiner relies on.





The claim has a test, not a paragraph



An architectural promise nobody can falsify is marketing. This one is checked by

reading the log from inside the tool body — the one place where anything

buffered, deferred, or written afterwards is invisible:




CODE
def test_record_is_durable_before_the_body_runs(workspace):
workspace(ALLOW_ALL)
seen: dict[str, list] = {}

@guard()
def read_thing(what: str) -> str:
# Read the log off disk from inside the body. Anything buffered, deferred
# or written afterwards is invisible here, which is the point.
seen["records"] = record.read()
return f"read {what}"

assert read_thing("a-file") == "read a-file"

decisions = [r for r in seen["records"] if r["phase"] == "decision"]
assert len(decisions) == 1
assert decisions[0]["effect"] == "allow"






Move the write one line later and the test fails. That is the property, stated in

a form that breaks when it stops being true.



The outcome record — did it succeed, did it raise — is written afterwards and is

deliberately not durable. If the process dies between the two, the log reads

"authorised, outcome unknown", which is the honest state. Paying for a second

fsync to say something merely informative is the wrong trade.





What follows from "the record is the product"



Authorisation is per resource, not per tier. READ / WRITE / DESTRUCTIVE

cannot express "may edit their own ticket, not yours". obstat resolves a resource

id from the call arguments and matches rules against that:




CODE
[[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 —

enforced in one BEGIN IMMEDIATE transaction, so two concurrent retries cannot

both win. The record that spends it names who approved, because "who said yes"

should not live only in a mutable SQLite row.



Arguments are fingerprinted, not stored. Tool arguments carry credentials and

personal data; a governance log that leaks them is a liability rather than a

control. You name the ones a human needs to see, and only those values are

recorded — because an approver deciding about sha256:ae32e6… is deciding about

nothing. The digest still covers everything.



Every record carries the hash of the one before it, so an edited or deleted

line shows up in obstat verify.






What three real mailboxes found



Before writing this post I put obstat in front of my own mail: three IMAP/SMTP

MCP servers — a personal mailbox, a gmail, and a public business address that

takes mail from strangers — with every outbound message behind an approval. Use

found things review had not.



An agent walked around the gate on day one. Asked how many unread messages

the mailboxes held, it found no guarded tool that answered, opened a raw IMAP

connection with the credential the server process was holding, and answered

correctly — 2,360 unread across two mailboxes, in no record at all. Nothing

failed. The gate simply was not on the path it took.



That finding is now the first entry in §8, because it is the one a reader is

most likely to misread past:





  • The record covers the gate, not the resource. Absence is evidence only
    over the calls that came through @guard. Everything else reads as quiet, not
    as incomplete.


  • A credential the caller can read is a gate the caller can walk past. The
    separation has to come from the host — a different account, a sandbox, a
    session with no shell. The ordinary MCP deployment, where advertised tools are
    the entire surface, is what obstat is designed for; a coding agent with a
    shell beside it is not.


  • Coverage is the control. A question the tool surface cannot answer becomes
    a hole in the record rather than a refusal. count_unread exists on that
    server now because it did not then.



The library had reserved the one word an email tool needs. obstat injected

the caller's identity into a parameter called subject — and an email tool

wants send_email(to, subject, body). The dangerous failure was not the crash;

it was the quiet variant, where the parameter vanished from the advertised

schema and an identity object landed in the Subject: header. It is

obstat_subject now, and obstat_ is the only prefix the library reserves.



The record said what was authorised, never what happened. A bulk delete

records one sender whether it removed one message or ten thousand, and the

outcome said ok: true either way. Tools can now write obstat.note(deleted=…,

matched=…)
from inside the body onto the outcome record — on failure too, since

half a bulk delete is the case a reader most needs a number for.



A glob matches the whole string, and smtplib delivers to every address in the

header.
A "mail to yourself is free" rule — resource mail:*@example.com

also matched [email protected],[email protected], and send_message would

have delivered to both. A resource id is caller-controlled text: parse it in the

resource callable, don't pattern-match it. Whatever that callable raises becomes

a recorded denial, not an unrecorded crash.



None of these came from review, and two of them are obstat admitting a limit

rather than fixing a bug. That is the trade I want to be explicit about: the

library can make the polite path leave evidence. It cannot make every path

polite.






What it does not do



A truncated tail does not show up. Anyone who can write the file can recompute

the whole chain. This is tamper-evidence, not non-repudiation, and the spec

says so in those words — §8 of docs/obstat-spec.md is a list of what is still

weak, kept deliberately as prominent as the feature list.



One entry there was found by CI rather than by me. The concurrency test — two

real processes appending to one log — went green on Linux and macOS and came back

from the Windows leg at 57 of 60 records. Windows' append mode is a seek and a

write, not one atomic operation, so concurrent writers lose records silently. The

cross-process guarantee is now documented as POSIX-only, the fix is named

(msvcrt.locking(), which is precisely the inter-process lock the design

declines to take), and the test skips on Windows while the CI leg stays. I would

rather ship a documented hole than an undocumented one.



That test was written after two releases in which nothing touched threads or

processes. The lesson generalises: when a normative claim has no test, that is

where the bugs are — not in the code that gets exercised daily.



The same shape caught something else four releases later, and it is the one I

find most instructive. The spec said a call is rejected if its arguments do not

fit the tool. The code bound them partially, so a call missing a required

argument passed the gate, took an allow record, and then died in the body with

a TypeError — the log asserting a call had been authorised when it could never

have run. That is precisely the kind of unearned claim this whole project exists

not to make, and it sat there for four versions.



It survived because the MCP SDK validates arguments against the advertised

schema before the call reaches the decorator. Through a server the bad call

never arrived, so the gap was invisible from the outside; I only saw it by

writing a test that called the guarded function directly. Two things follow. A

guarantee that holds only because something upstream happens to be careful is

not your guarantee. And a test that exercises your code the way your users do

will systematically miss the cases your users' tooling filters out first.






Trying it






CODE
pip install obstat
obstat init # a starter policy; everything denied until you uncomment a rule






No runtime dependencies. Not AWS, not an identity provider, not a policy service —

the decorator, tomllib, sqlite3, and a file. A governance library nobody can

try on a laptop is one nobody adopts.



Identity is optional, too. 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 evaluate a governance library is why governance libraries

go unevaluated. An anonymous call is a legitimate call here — it is recorded as

anonymous, and the policy decides what anonymous may do.



docs/obstat-spec.md is normative: behaviour changes update it in the same

commit, and where the spec and the code disagree, one of them is a bug.



Apache-2.0. I would particularly like to hear from anyone who has had to answer

the "what was your agent allowed to do, and when" question for real, because I

have built this against my own guess at that conversation.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Hackers Just Poisoned the Rust Supply Chain | Threat Wire
1 Quelle
Hackers Found a Way Into Humanoid Robots | Threat Wire
1 Quelle
Bits und so #1021 (Passwort für Laufwerk)