<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Purav Kanda</title>
    <description>The latest articles on DEV Community by Purav Kanda (@purav_kanda_d5ee42d6e6ca5).</description>
    <link>https://dev.to/purav_kanda_d5ee42d6e6ca5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066405%2Fe56bdbc2-c93b-4228-a279-08f2b3a92d96.png</url>
      <title>DEV Community: Purav Kanda</title>
      <link>https://dev.to/purav_kanda_d5ee42d6e6ca5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/purav_kanda_d5ee42d6e6ca5"/>
    <language>en</language>
    <item>
      <title>Blocking an AI agent's tool call before it runs: runtime verification with LTL3</title>
      <dc:creator>Purav Kanda</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:05:19 +0000</pubDate>
      <link>https://dev.to/purav_kanda_d5ee42d6e6ca5/blocking-an-ai-agents-tool-call-before-it-runs-runtime-verification-with-ltl3-mak</link>
      <guid>https://dev.to/purav_kanda_d5ee42d6e6ca5/blocking-an-ai-agents-tool-call-before-it-runs-runtime-verification-with-ltl3-mak</guid>
      <description>

&lt;p&gt;AI agents that call real tools — deleting records, sending payments,&lt;br&gt;
editing files — don't usually fail by misunderstanding instructions in&lt;br&gt;
bulk. They fail by issuing one bad call in an otherwise-correct session.&lt;br&gt;
An eval score of 98% is no comfort if you're the run in the 2%, and no eval&lt;br&gt;
can tell you, in the moment, whether the call on the stack right now is&lt;br&gt;
that one.&lt;/p&gt;

&lt;p&gt;That's the specific problem I built &lt;a href="https://github.com/Purav-Kanda/Acel" rel="noopener noreferrer"&gt;ACEL&lt;/a&gt;&lt;br&gt;
to solve: a runtime verification layer that sits between an agent and its&lt;br&gt;
tools, checks each tool call against declared rules as it happens, and&lt;br&gt;
blocks the call &lt;em&gt;before it executes&lt;/em&gt; if it would violate one.&lt;/p&gt;
&lt;h2&gt;
  
  
  The core idea: monitor the trace, don't predict the average
&lt;/h2&gt;

&lt;p&gt;Statistical evals answer "how good is this agent on average, across many&lt;br&gt;
runs." That's a different question from "did this specific, currently-running&lt;br&gt;
execution just violate a rule I can't undo." ACEL only answers the second&lt;br&gt;
one — it's runtime verification, not formal verification: it doesn't prove&lt;br&gt;
the agent correct for all inputs, it checks whether the one finite trace&lt;br&gt;
actually happening is still consistent with the spec, extended by one event&lt;br&gt;
per call.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why LTL3, not just "if statements"
&lt;/h2&gt;

&lt;p&gt;Ordering rules over a call stream are naturally LTL, but classical LTL&lt;br&gt;
assumes an infinite trace — an agent session is finite and still running.&lt;br&gt;
ACEL's temporal layer is grounded in LTL3 (Bauer, Leucker &amp;amp; Schallhart,&lt;br&gt;
2011): three-valued semantics for monitoring LTL over finite prefixes.&lt;br&gt;
Every rule reports SATISFIED, VIOLATED, or UNKNOWN at each step, with&lt;br&gt;
VIOLATED being sticky/monotone once a safety property breaks. Seven named&lt;br&gt;
templates cover the common ordering and cumulative-limit patterns&lt;br&gt;
(&lt;code&gt;must_precede&lt;/code&gt;, &lt;code&gt;at_most_n_times&lt;/code&gt;, &lt;code&gt;at_most_total&lt;/code&gt;, &lt;code&gt;never_after&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;required_before_session_end&lt;/code&gt;, &lt;code&gt;cannot_follow_without&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;mutually_exclusive&lt;/code&gt;), each a small deterministic automaton advanced in&lt;br&gt;
O(1) per event — no LTL parser, no formula DSL to maintain, and cost scales&lt;br&gt;
with the number of &lt;em&gt;active&lt;/em&gt; contracts, not session length.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;acel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;must_precede&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at_most_n_times&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authenticated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;must_precede&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;validate_record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete_record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;at_most_n_times&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_payment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete_record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r_42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;# raises: validate never happened
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trusted state, not claimed state
&lt;/h2&gt;

&lt;p&gt;Ordering isn't the whole problem — some rules need facts, like "only allow&lt;br&gt;
this read if authentication actually succeeded." A precondition that reads&lt;br&gt;
the &lt;em&gt;current call's own arguments&lt;/em&gt; for that fact is trivially bypassable —&lt;br&gt;
an agent (or a hallucinating one) can just claim &lt;code&gt;authenticated: true&lt;/code&gt;.&lt;br&gt;
ACEL's &lt;code&gt;StateStore&lt;/code&gt; only updates through a &lt;code&gt;commit&lt;/code&gt; callback wired to a&lt;br&gt;
&lt;em&gt;prior&lt;/em&gt; tool's real result, never through the current call's arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authenticate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authenticated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_user_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;precondition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authenticated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Live enforcement via the MCP proxy
&lt;/h2&gt;

&lt;p&gt;For tools exposed through a real MCP server, ACEL wires into the official&lt;br&gt;
Python SDK's &lt;code&gt;ServerMiddleware&lt;/code&gt; hook. Every &lt;code&gt;tools/call&lt;/code&gt; request passes&lt;br&gt;
through the gate &lt;em&gt;before&lt;/em&gt; the real handler runs — on a block, the handler&lt;br&gt;
is never called, so a blocked delete has zero side effects, not "a side&lt;br&gt;
effect that gets rolled back." Because it's server-side, it's client&lt;br&gt;
agnostic: any MCP client hitting that server gets the same enforcement.&lt;/p&gt;

&lt;p&gt;Building this surfaced a real bug worth sharing: the MCP SDK only&lt;br&gt;
populates &lt;code&gt;structured_content&lt;/code&gt; on a tool result if that tool opts into&lt;br&gt;
typed structured output — most don't, and get their return value silently&lt;br&gt;
serialized into a JSON text block instead. My first version only checked&lt;br&gt;
&lt;code&gt;structured_content&lt;/code&gt;, found &lt;code&gt;None&lt;/code&gt; for ordinary tools, and silently fed&lt;br&gt;
postconditions an empty dict — the call looked successful, the state store&lt;br&gt;
just never updated. Only a live-SDK test against a real subprocess caught&lt;br&gt;
it; a mocked transport would have handed back exactly the shape my code&lt;br&gt;
expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tamper-evident evidence, not just a log line
&lt;/h2&gt;

&lt;p&gt;Every violation produces a hash-chained record: &lt;code&gt;bundle_hash =&lt;br&gt;
sha256(prev_hash + trace_hash)&lt;/code&gt;, so any retroactive edit to any historical&lt;br&gt;
record breaks every hash after it — checkable independently of ACEL itself,&lt;br&gt;
a minimal Merkle-style chain, no blockchain involved. Two things I fixed&lt;br&gt;
after a security pass on my own design: bundles used to embed full call&lt;br&gt;
arguments unredacted (a real problem if an argument is a password or&lt;br&gt;
token — now &lt;code&gt;redact_fields={"password", ...}&lt;/code&gt; masks matching values with a&lt;br&gt;
non-reversible hash marker before anything is hashed), and the optional&lt;br&gt;
Ed25519 signer used to generate a fresh, unpersisted key every run — it can&lt;br&gt;
now write the key to a file once and reuse it, so signatures actually stay&lt;br&gt;
verifiable across restarts. &lt;code&gt;acel show evidence.json&lt;/code&gt; prints the whole&lt;br&gt;
thing as a readable timeline instead of raw JSON, and &lt;code&gt;acel verify&lt;/code&gt; checks&lt;br&gt;
a saved log for tampering from a completely fresh process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving more than one client safely
&lt;/h2&gt;

&lt;p&gt;The first version of the MCP proxy shared one &lt;code&gt;Session&lt;/code&gt; across every&lt;br&gt;
connected client — fine for a demo, not for anything with more than one&lt;br&gt;
user. &lt;code&gt;ACELMiddleware(session_factory=...)&lt;/code&gt; now builds a fully isolated&lt;br&gt;
&lt;code&gt;Session&lt;/code&gt; per connection instead. The interesting part: I initially keyed&lt;br&gt;
the per-connection cache on the &lt;code&gt;ServerSession&lt;/code&gt; object middleware receives,&lt;br&gt;
assuming it was stable for a connection's lifetime — it isn't. It's rebuilt&lt;br&gt;
fresh on &lt;em&gt;every single request&lt;/em&gt;, confirmed by printing its object identity&lt;br&gt;
across several requests inside one connection and getting a different&lt;br&gt;
object every time. The actual stable identity is the private &lt;code&gt;Connection&lt;/code&gt;&lt;br&gt;
object the SDK's &lt;code&gt;ServerSession&lt;/code&gt; wraps internally. Proven with two real,&lt;br&gt;
simultaneous client connections to the same server: one authenticates and&lt;br&gt;
its own follow-up call succeeds, the other client's identical call stays&lt;br&gt;
blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out without blocking anything on day one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Session(mode="shadow")&lt;/code&gt; runs identical detection — same evidence, same&lt;br&gt;
hash chain — but never blocks. Run it against real traffic, see what it&lt;br&gt;
would have caught, then flip to enforce once you trust the rule set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers, not vibes
&lt;/h2&gt;

&lt;p&gt;59-case labeled correctness suite spanning all seven templates: 100%&lt;br&gt;
precision and recall (expected for a deterministic automaton, not a&lt;br&gt;
statistical classifier — the suite's real value is as a CI regression&lt;br&gt;
gate). Added latency: ~0.0036ms per call at 1 active contract, ~0.035ms at&lt;br&gt;
50, tested live against a real MCP server over a real subprocess. 194&lt;br&gt;
tests passing overall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;acel-core
acel init-config rules.yaml   &lt;span class="c"&gt;# or write contracts directly in Python&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT-licensed, self-hosted, no service to sign up for. Repo and full&lt;br&gt;
technical write-up: &lt;a href="https://github.com/Purav-Kanda/Acel" rel="noopener noreferrer"&gt;https://github.com/Purav-Kanda/Acel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
    </item>
  </channel>
</rss>
