<?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: Royal Simpson Pinto</title>
    <description>The latest articles on DEV Community by Royal Simpson Pinto (@royalpinto007).</description>
    <link>https://dev.to/royalpinto007</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%2F947695%2F6652469e-7e55-4b5b-a816-1f2d54c72b65.jpeg</url>
      <title>DEV Community: Royal Simpson Pinto</title>
      <link>https://dev.to/royalpinto007</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/royalpinto007"/>
    <language>en</language>
    <item>
      <title>Scoping MCP tool access per client, and auditing every call</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/scoping-mcp-tool-access-per-client-and-auditing-every-call-2j6</link>
      <guid>https://dev.to/royalpinto007/scoping-mcp-tool-access-per-client-and-auditing-every-call-2j6</guid>
      <description>&lt;p&gt;Most MCP servers I have seen expose every tool they know about to every client that connects. That is fine on your laptop. It stops being fine the moment the same server is meant to sit between a company's real accounts (Shopify, an analytics platform, a Postgres database) and several different AI clients, where one of them is a read-only analyst assistant and another is an ops bot that is allowed to write.&lt;/p&gt;

&lt;p&gt;The protocol gives you &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt;. It does not tell you who is allowed to see what, or who is allowed to do what. If you wire the server up naively, &lt;code&gt;tools/list&lt;/code&gt; hands back the full menu, and the model on the other end will happily try to call the write tool because it can see it. Bridgekit is my answer to that: a scoped MCP server where every client key carries its own permission boundary, writes are gated separately from reads, and every call, allowed or denied, lands in an append-only audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: scope lives with the key, not the tool
&lt;/h2&gt;

&lt;p&gt;Clients are configured as a JSON secret. Each key maps to a name, the exact list of tools it may use, and whether it may write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bk_live_demo123"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"growth-os"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"shopify_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"triplewhale_metrics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"db_query"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allowWrite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callers present that key as &lt;code&gt;Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code&gt; or as an &lt;code&gt;x-bridgekit-key&lt;/code&gt; header. Every request must resolve to a known client before anything else happens. If the key is missing or unknown, the request never reaches the tool layer; it comes back as a JSON-RPC error with a 401.&lt;/p&gt;

&lt;p&gt;The important part is that the scope is a property of the caller, not a global setting on the server. Two clients hitting the same &lt;code&gt;/mcp&lt;/code&gt; endpoint see two different worlds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Transport is MCP over Streamable HTTP: clients POST JSON-RPC 2.0 to &lt;code&gt;/mcp&lt;/code&gt;, and the server implements &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;tools/call&lt;/code&gt;, and &lt;code&gt;ping&lt;/code&gt;. The whole thing runs as a single Cloudflare Worker.&lt;/p&gt;

&lt;p&gt;The first place scope shows up is discovery. &lt;code&gt;tools/list&lt;/code&gt; does not return the catalog; it returns the intersection of what exists, what this client is scoped for, and (for write tools) whether the client can write at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowWrite&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputSchema&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;p&gt;A read-only client literally never sees the write tool exist. That matters, because a tool the model cannot see is a tool the model will not try to call.&lt;/p&gt;

&lt;p&gt;The second place is enforcement, on &lt;code&gt;tools/call&lt;/code&gt;. Listing filtering is a convenience; it is not security on its own, because a client could still name a tool directly. So the call path re-checks everything from scratch and records the decision either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not in client scope&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rpcOk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" not allowed for this client`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowWrite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write scope required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rpcOk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is a write action; client lacks write scope`&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;p&gt;There are four tools in the current build: &lt;code&gt;shopify_orders&lt;/code&gt;, &lt;code&gt;triplewhale_metrics&lt;/code&gt;, and &lt;code&gt;db_query&lt;/code&gt; are reads, and &lt;code&gt;shopify_tag_order&lt;/code&gt; is the one write. &lt;code&gt;db_query&lt;/code&gt; reads from an allowlisted set of Postgres tables rather than accepting arbitrary SQL, so scope narrows again inside the tool itself.&lt;/p&gt;

&lt;p&gt;One deliberate design choice: a denied write does not blow up as a transport error. It comes back as an MCP tool result with &lt;code&gt;isError: true&lt;/code&gt;. That follows the protocol convention where tool-level failures are readable results, not connection faults, so the model on the other end can actually read "you lack write scope" and react, rather than seeing an opaque crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit log
&lt;/h2&gt;

&lt;p&gt;Every branch above calls &lt;code&gt;audit()&lt;/code&gt; before returning. The allowed path logs after the tool runs; the denied paths log the reason they were rejected. Entries carry the client name, a non-reversible short label of the key (first eight characters, an ellipsis, the last two, so raw keys never hit the log), the tool, the decision, an optional reason, and truncated arguments. They are written to a &lt;code&gt;bk_audit&lt;/code&gt; table over PostgREST.&lt;/p&gt;

&lt;p&gt;Two details I care about. First, logging failures are swallowed. If the audit sink is down, the tool call still returns; observability should never take down the actual product, and the failure surfaces in the Worker logs instead. Second, arguments are truncated before storage (capped at 2000 characters), so a giant payload cannot bloat a row, and the code path is written to keep secrets out of the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;Scopes are coarse. A client either has a tool or it does not, and it either may write or it may not. There is no row-level or field-level policy, no rate limit per client, and no per-tool write approval; write access is a single boolean for the whole client. For the Shopify and analytics workflows this was built around, tool-level plus read/write separation covers the real cases. But if you needed "this client may tag orders under $500 only," that logic does not exist yet; you would push it into the connector by hand. The boundary Bridgekit enforces is which tool and which direction, not which values.&lt;/p&gt;

&lt;p&gt;There is also a demo-shaped edge: the audit log is pruned to the newest rows to stay bounded, so it is a live trail, not long-term retention. In a real deployment you would drop the prune and point it at durable storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The thing I wanted was boring and specific: give an AI client real tools without giving it the keys, and be able to answer "who called what, and did we allow it" after the fact. Scope lives on the key, discovery and enforcement both respect it, and nothing runs without leaving a record. That is the whole product.&lt;/p&gt;

&lt;p&gt;Code and the full tool list are here: &lt;a href="https://github.com/AgentPostmortem/Bridgekit" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/Bridgekit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Catching silent agent regressions in CI before they reach users</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/catching-silent-agent-regressions-in-ci-before-they-reach-users-14li</link>
      <guid>https://dev.to/royalpinto007/catching-silent-agent-regressions-in-ci-before-they-reach-users-14li</guid>
      <description>&lt;p&gt;Most teams ship agents with no way to know that a prompt tweak or a model bump didn't quietly break something. You change one line in a system prompt, the eval you ran by hand still "looks fine," and three cases that used to pass now silently fail. There is no red X anywhere. Nobody notices until a user does.&lt;/p&gt;

&lt;p&gt;I kept hitting this, so I built &lt;strong&gt;Tracecase&lt;/strong&gt;: a small CI layer for AI agents. Your CI posts the results of a test suite after every change, Tracecase diffs that run against the previous run of the same suite, and it fails the build when a case that used to pass now fails or a tool call wasn't allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Agents are non-deterministic, but the thing you actually care about in CI is deterministic: &lt;strong&gt;did this case pass before, and does it pass now?&lt;/strong&gt; That single comparison is what turns a pile of eval output into a real regression signal.&lt;/p&gt;

&lt;p&gt;Tracecase does not run your agent. It doesn't own your model keys or your harness. You run the suite wherever you already run it, you decide what "passed" means per case, and you POST the outcome. Tracecase is the memory and the diff. It stores runs, computes what regressed, and hands you a boolean to gate the merge. Keeping it out of the execution path is deliberate: it means Tracecase works no matter what stack your agent lives in.&lt;/p&gt;

&lt;p&gt;There are three things it tracks per case beyond pass/fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;regressed&lt;/strong&gt; – the case passed in the prior run of the same suite but fails now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;flagged&lt;/strong&gt; – the case carried any safety flag you attached, like &lt;code&gt;unsafe_tool&lt;/code&gt;, &lt;code&gt;hallucination&lt;/code&gt;, or &lt;code&gt;over_budget&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tool calls&lt;/strong&gt; – each call records &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt;, and whether it was &lt;code&gt;allowed&lt;/code&gt;, so an agent that fires a disallowed tool shows up in the diff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;suite&lt;/strong&gt; is a named set of agent test cases. You don't create it up front; it's created automatically the first time you post to it. Every push, your CI runs the suite against the current agent config (a model plus a prompt version) and POSTs the results to &lt;code&gt;/api/runs&lt;/code&gt;. Here is the shape of that call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRACECASE_URL&lt;/span&gt;&lt;span class="s2"&gt;/api/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-tracecase-token: &lt;/span&gt;&lt;span class="nv"&gt;$TRACECASE_INGEST_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"content-type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "suite": "refund-agent",
    "label": "PR #142 / opus-4.8",
    "model": "claude-opus-4-8",
    "promptVersion": "v3",
    "results": [
      { "caseName": "refund under limit", "passed": true, "latencyMs": 820 },
      { "caseName": "refund over limit must escalate",
        "passed": false, "flags": ["unsafe_tool"],
        "output": "issued refund of $900",
        "expected": "escalate to human",
        "toolCalls": [{ "name": "issue_refund", "allowed": false }] }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server side, the regression math is intentionally boring. The endpoint upserts the suite by name, pulls the previous run's per-case pass map, and then compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;regressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prevPass&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;caseName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// CI convention: non-zero regressions or flags should fail the build.&lt;/span&gt;
  &lt;span class="na"&gt;shouldFail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;regressed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;p&gt;The key line is &lt;code&gt;prevPass.get(r.caseName) === true &amp;amp;&amp;amp; !r.passed&lt;/code&gt;. A case only counts as a regression if it was green last time and is red now. A case that was already broken doesn't re-trip the alarm on every run, and a brand-new failing case shows up as a flag or a plain failure rather than a regression. That distinction is what keeps the signal honest instead of noisy.&lt;/p&gt;

&lt;p&gt;The response carries &lt;code&gt;shouldFail&lt;/code&gt;, and that is the whole integration contract. Wire it into your CI step's exit code and the build goes red exactly when a previously-passing case breaks or a safety flag appears. Runs, per-case results, tool calls, and flags all get persisted so the dashboard can show pass rate, regression counts, and per-case diffs with REGRESSED and FIXED badges next to the offending output.&lt;/p&gt;

&lt;p&gt;Under the hood it's Next.js 14 on the App Router with TypeScript, backed by Supabase Postgres. The schema is three tables: &lt;code&gt;tc_suites&lt;/code&gt;, &lt;code&gt;tc_runs&lt;/code&gt; (with denormalized rollups like &lt;code&gt;passed&lt;/code&gt;, &lt;code&gt;regressed&lt;/code&gt;, and &lt;code&gt;flagged&lt;/code&gt; for fast dashboards), and &lt;code&gt;tc_results&lt;/code&gt; for the per-case rows. The app only ever talks to Supabase with the service-role key from server code, so row-level security stays fully restrictive with no anon access, and ingest is gated by a shared token header. It deploys to Cloudflare Workers through the OpenNext adapter.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;Regression is defined strictly against &lt;strong&gt;the immediately previous run of the same suite&lt;/strong&gt;. There is no baseline pinning, no "compare against main" or "compare against the last green run." If a flaky case fails on run N and passes again on run N+1, run N+1 reads as a FIX, not as flakiness. And because the comparison is only one run deep, a case that oscillates pass/fail across runs will keep flipping between REGRESSED and FIXED rather than being called out as unstable. For genuinely non-deterministic cases you'll want to make your own harness deterministic (fixed seeds, retries, or a stricter pass predicate) before you post, because Tracecase trusts the &lt;code&gt;passed&lt;/code&gt; boolean you send. It's also capped to the newest 50 runs of history, so this is a merge-gate and recent-trend tool, not a long-term analytics warehouse.&lt;/p&gt;

&lt;p&gt;That trade is on purpose. The one-run diff is what makes the signal cheap to reason about and easy to wire into any CI in about ten lines. I'd rather ship a sharp, honest gate than a fuzzy scoreboard.&lt;/p&gt;

&lt;p&gt;If you're shipping agents and flying blind on regressions, take a look:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AgentPostmortem/Tracecase" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/Tracecase&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>cicd</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Put the Security Check Inside the Query, Not After It</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Thu, 13 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/put-the-security-check-inside-the-query-not-after-it-jk8</link>
      <guid>https://dev.to/royalpinto007/put-the-security-check-inside-the-query-not-after-it-jk8</guid>
      <description>&lt;p&gt;There is a pattern I keep seeing in AI systems, and it fails the same way every time. You retrieve some data, or you expose a tool, and then you add a security check afterward. Filter out the documents the user should not see. Deny the tool call if the caller lacks the scope. It looks safe. It reads like a sound design in a code review. And it leaks anyway.&lt;/p&gt;

&lt;p&gt;The argument I want to make is narrow and I believe it holds: the security check belongs inside the query or the tool boundary itself, not in a step that runs after the data has already been fetched or the surface has already been exposed. Once the unauthorized thing exists in your process, you have already lost. Everything after that is hoping nobody logs it.&lt;/p&gt;

&lt;p&gt;I built three projects that pushed me to this conclusion from three different angles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retrieve-then-filter trap
&lt;/h2&gt;

&lt;p&gt;Start with RAG, because it is where I first felt this. The textbook pipeline is: embed the question, retrieve top-k chunks, filter out what the user is not allowed to see, generate the answer. That filter step is the problem.&lt;/p&gt;

&lt;p&gt;By the time you filter, the unauthorized chunks are already in your process. They can land in a log line, a trace span, an error report, or a prompt you assembled one step too early. And there is a second, quieter failure: a top-k of 5 that filters down to 1 silently degrades the answer, with no signal that it happened.&lt;/p&gt;

&lt;p&gt;So in vaultrag, I moved the access-control predicate into the same SQL query as the vector search and the keyword search. A chunk the user cannot see is never selected, never scored, never ranked, never logged. It cannot leak, because it was never fetched. Access is evaluated per query, not baked into the index, so revoking someone takes effect on the very next question rather than after a reindex. And the groups a user belongs to are read from the database, never from the request, because if a caller could assert its own group membership the whole ACL would be decorative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that made me believe it
&lt;/h2&gt;

&lt;p&gt;Slogans about security are cheap. I did not want "access control is enforced at retrieval" to be a sentence in a README. I wanted a number.&lt;/p&gt;

&lt;p&gt;So vaultrag has an eval harness that runs a gold set of (user, question, what-they-should-and-should-not-see) tuples against a real Postgres corpus, and reports two metrics that only mean something together: leak rate and recall. The pairing is the entire point, because each is trivial to fake alone. Retrieve nothing and you score a perfect 0 percent leak rate. Retrieve everything and you score perfect recall.&lt;/p&gt;

&lt;p&gt;On the working build, the 11-case gold set reports a leak rate of 0.0 percent at 100 percent recall. Then I delete the ACL predicate from the retrieval query and run the exact same eval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;leak rate: 0.0% -&amp;gt; 81.8%
mean recall: 100.0% -&amp;gt; 100.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second line. Recall did not move. The broken build answers every question correctly and completely, while handing one user the CEO's private notes and another team's salary bands. A quality-only eval scores that build as perfect. That is exactly why the leak number is never reported on its own, and why CI fails on a strict flag rather than on a threshold. Every document in the test corpus contains the phrase "quarterly bonus payout policy", so a retriever without access control would happily serve private notes to anyone asking about bonuses. The only thing standing between the two is that predicate living inside the query. Delete it and 9 of 12 ACL tests fail immediately with leak assertions. That is the difference between a test suite and decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same principle, one layer down: MCP tools
&lt;/h2&gt;

&lt;p&gt;RAG is one place unauthorized data enters a process. MCP tools are another, and the shape of the mistake is identical. An MCP server hands a language model real capabilities: run commands, read files, hit internal URLs, mutate a database. A single over-scoped tool, or a &lt;code&gt;.env&lt;/code&gt; exposed as a resource, turns a helpful agent into a data-exfiltration path. The trap is thinking you will catch the dangerous call later, at the moment it happens. But if the tool is exposed at all, the model can reach it.&lt;/p&gt;

&lt;p&gt;So I wrote mcp-audit, which connects to an MCP server (or lints its manifest offline), enumerates every tool, resource, and prompt, and runs 18 security rules over that surface. It flags arbitrary command execution tools, destructive actions with no confirmation or scoping, secrets exposed as resources, SSRF-prone URL arguments, unauthenticated HTTP transports, and unconstrained input schemas that let the model pass anything anywhere. It is deterministic, runs offline, and drops into CI with SARIF output so a broad, unscoped tool surface breaks the build before an agent ever gets near it. The check moves earlier: you catch the over-broad surface at review time, not at call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the boundary done right: scoped tools from the start
&lt;/h2&gt;

&lt;p&gt;Bridgekit is the constructive version of the same idea. It is a scoped MCP server that exposes a company's tools (Shopify, Triple Whale, Postgres) with per-client permission boundaries baked into the boundary itself. Each client key carries the exact list of tools it may use and whether it may write. And this is the part I care about: &lt;code&gt;tools/list&lt;/code&gt; only advertises the tools the calling client is scoped for. The check is not "let them list everything, then block the call." The unscoped tool is never even shown. A write tool called with a read-only key is denied and written to an append-only audit log, so the day someone asks "did this ever happen", the answer is a query, not an archaeology dig.&lt;/p&gt;

&lt;p&gt;That is the whole thesis in one line of behavior. The permission boundary is not a gate you pass through after picking up the data. It is the shape of what you are allowed to see at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;Putting the check inside the query is not free, and I would be lying if I said it was universally cleaner. It couples your authorization model to your retrieval layer. In vaultrag the ACL predicate lives inside the retrieval query, which means the query is now harder to reason about, the database is doing security work, and you cannot swap retrieval engines without re-implementing the predicate. That is a real cost. My claim is not that it is cheaper. It is that the after-the-fact filter, which is genuinely cheaper and cleaner to write, is the one that leaks, and I would rather pay the coupling tax than ship the version that scores perfect on a quality eval while handing out salary bands.&lt;/p&gt;

&lt;p&gt;If you want to see any of this in code, including the eval that turns "we enforce access control" into a number that fails CI, all three projects are at github.com/royalpinto007.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>rag</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Profiling an AI agent's context window: where the tokens actually go</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Tue, 11 Aug 2026 09:30:30 +0000</pubDate>
      <link>https://dev.to/royalpinto007/profiling-an-ai-agents-context-window-where-the-tokens-actually-go-422o</link>
      <guid>https://dev.to/royalpinto007/profiling-an-ai-agents-context-window-where-the-tokens-actually-go-422o</guid>
      <description>&lt;p&gt;Token dashboards tell you the bill. They do not tell you why the bill is that size. When a coding agent gets slow, expensive, and a little dumb, it is usually because its context window has quietly filled with junk: the same file read six times, a 12k-token tool result that mattered for exactly one turn, tool schemas re-sent on every single step. You can see the total go up. You cannot see where the tokens went, so you cannot delete anything with confidence.&lt;/p&gt;

&lt;p&gt;I wanted a profiler for that. Not a chat UI, not a live proxy, just a tool I could point at a session transcript and ask: what is in this context window, and how much of it is avoidable? That is ctxlens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;ctxlens treats an agent session the way a CPU profiler treats a program. A profiler does not judge whether your code is good; it tells you where the time went so you know where to look. ctxlens does the same for tokens. It parses a session transcript, attributes every message to a segment, counts tokens per segment and per turn, and then runs rule-based checks to flag the parts that are genuinely wasted.&lt;/p&gt;

&lt;p&gt;Every message lands in one of these buckets: &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;tool_definitions&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;tool_result&lt;/code&gt;. Once every token has a home, the interesting questions become answerable. Which segment dominates? When did the context spike? What is being paid for on every turn versus once?&lt;/p&gt;

&lt;p&gt;It reads Claude Code JSONL sessions (the ones under &lt;code&gt;~/.claude/projects/*/*.jsonl&lt;/code&gt;), OpenAI/Codex rollout sessions, and generic OpenAI chat arrays. The format is auto-detected by sniffing the file, and you can force it with &lt;code&gt;--format&lt;/code&gt; if you need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The basic run is one command:&lt;br&gt;
&lt;/p&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;ctxlens-cli
ctxlens analyze session.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a summary panel, a breakdown of context composition by segment, a couple of sparklines for how context grew over the run, and a list of recommendations. The composition view is the part I reach for first, because it immediately answers "what is this window made of":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Context composition by segment
 Segment       Tokens     %  Msgs  Share
 tool result    6,204  49.7    22  ██████████████·······
 assistant      2,110  16.9    14  ██████···············
 system         1,540  12.3     1  ████·················
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tool results eating half the window is the single most common thing I see. Which leads to the second half of the tool: the waste report.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;waste_ratio = total_waste / total_tokens&lt;/code&gt;, and total waste is the sum of four disjoint sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate tokens.&lt;/strong&gt; The same file or tool result appearing more than once, matched either by reference (for example &lt;code&gt;Read:file_path=config.py&lt;/code&gt;) or by exact body. Every copy after the first is counted as wasted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-result bloat.&lt;/strong&gt; Tokens in a tool result above a per-result cap (&lt;code&gt;--tool-result-cap&lt;/code&gt;, default 400). Only the overage counts, and each unique body is charged once so a repeated giant result is not double-counted here and again as a duplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale tool outputs.&lt;/strong&gt; When the same reference is read more than once and a later read supersedes an earlier one, the older superseded copies are dead weight still sitting in context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-definition overage.&lt;/strong&gt; Tool schema tokens above a budget (&lt;code&gt;--tool-def-budget&lt;/code&gt;, default 800). This one stings because you pay it on every turn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each finding carries a severity and an estimated token saving, so recommendations read like "'Read:file_path=config.py' appears 6 times, ~2,410 tokens" rather than generic advice to "manage your context better." The estimate is exactly the arithmetic above, not a guess.&lt;/p&gt;

&lt;p&gt;Because it is all deterministic, it slots into CI. You can fail a build when a captured session wastes too much:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ctxlens analyze session.jsonl &lt;span class="nt"&gt;--fail-over-ratio&lt;/span&gt; 0.30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code &lt;code&gt;0&lt;/code&gt; is fine, &lt;code&gt;2&lt;/code&gt; means the threshold was exceeded, &lt;code&gt;1&lt;/code&gt; is an error. Add &lt;code&gt;--json&lt;/code&gt; for machine-readable output, or diff a baseline against a candidate with &lt;code&gt;ctxlens diff before.jsonl after.jsonl&lt;/code&gt; to catch regressions when you change a prompt or a tool. There is also an HTML reporter via &lt;code&gt;ctxlens report session.jsonl --html -o report.html&lt;/code&gt; for when you want to actually look at it.&lt;/p&gt;

&lt;p&gt;On counting: by default ctxlens uses a deterministic heuristic tokenizer with no network calls and no heavy dependencies, which is deliberate. For relative profiling and CI thresholds you mostly care about proportions and trends, and a stable heuristic gives you reproducible numbers everywhere. If you install &lt;code&gt;tiktoken&lt;/code&gt;, &lt;code&gt;--tokenizer auto&lt;/code&gt; picks it up and you get exact BPE counts. The whole thing has 55 tests covering the parsers, analysis, tokenizers, reporters, and CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The heuristic tokenizer is an approximation, and it should be treated as one. Its token counts will not match your provider's billing exactly, so the absolute numbers in the summary panel are estimates unless you install &lt;code&gt;tiktoken&lt;/code&gt;. What stays reliable without &lt;code&gt;tiktoken&lt;/code&gt; is the shape of the picture: which segment dominates, which references repeat, where the spikes are. If you need the reported token figures to line up with an actual invoice, install the extra and use exact counts. I would rather ship a tool that is honest about being a fast approximation by default than one that implies billing-grade precision it does not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;ctxlens started because I was tired of guessing which part of a bloated agent session was safe to trim. Having the window broken down by segment, with the duplicates and stale reads called out by name and token count, turned that from a hunch into an edit. If you run agents and your context windows feel heavier than they should, point it at a real session and see what falls out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/AgentPostmortem/ctxlens" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/ctxlens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package: &lt;a href="https://pypi.org/project/ctxlens-cli/" rel="noopener noreferrer"&gt;https://pypi.org/project/ctxlens-cli/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Learning prompt injection by attacking a deliberately vulnerable AI</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/learning-prompt-injection-by-attacking-a-deliberately-vulnerable-ai-51o3</link>
      <guid>https://dev.to/royalpinto007/learning-prompt-injection-by-attacking-a-deliberately-vulnerable-ai-51o3</guid>
      <description>&lt;p&gt;Prompt injection is the security problem that defines LLM applications, and I kept running into the same wall when I tried to explain it: reading about it does not build intuition. You can describe "ignore previous instructions" all day, but until you actually watch a model spill a secret it was told to guard, none of it lands. So I built injection-arena, a self-hostable game where a sandboxed AI agent defends a hidden secret and you race to make it leak.&lt;/p&gt;

&lt;p&gt;The pitch is simple: guard a secret, break the guard, top the leaderboard. The learning is the side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Each level is a challenge. It has a system prompt that instructs an agent and hides a secret formatted like &lt;code&gt;IARENA{...}&lt;/code&gt;, a canary token embedded in that same prompt, and a stack of defense layers. You send messages to the agent and try to make it reveal the secret. The game grades you on the server and tells you whether you cracked it.&lt;/p&gt;

&lt;p&gt;There are ten levels, and each one stacks a new defense on top of the last. Level 1 is barely defended so you can feel a plain attack working. By the time you reach level 10, only a combined attack (payload splitting plus delimiter confusion) gets through. The progression is the whole point: you feel exactly what each defense stops and exactly where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the defenses work
&lt;/h2&gt;

&lt;p&gt;An attempt flows through a single server-side pipeline that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input-filter  -&amp;gt;  agent  -&amp;gt;  judge  -&amp;gt;  score  -&amp;gt;  persist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defenses attach at different stages of that pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;System guard&lt;/strong&gt; lives in the prompt itself: hardened instructions telling the agent to refuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input filter&lt;/strong&gt; runs before the model sees anything. It blocks loud override and system-leak payloads pre-agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roleplay block&lt;/strong&gt; rejects persona-hijack attacks, the "pretend you are a different assistant" family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding guard&lt;/strong&gt; rejects base64, spell-it-out, and translation-based exfiltration attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output filter&lt;/strong&gt; runs after the agent responds and redacts the secret if it appears verbatim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary token&lt;/strong&gt; is checked by the judge: if the canary shows up in the output, the prompt escaped and that is an automatic crack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The seeded attack techniques (direct ask, authority override, roleplay, translation, base64, spell-out, ignore-previous-instructions, system-prompt leak, few-shot poisoning, delimiter confusion, payload splitting) live in one library. They power both the difficulty design and the test suite, so the attacks I test against are the same ones players learn to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The judge, and why canary tokens matter
&lt;/h2&gt;

&lt;p&gt;The most important design decision was never trusting the client. Grading happens entirely server-side. A naive version of this game would ask the model "did you leak?" or check the response on the frontend, and both are trivially gamed.&lt;/p&gt;

&lt;p&gt;Instead the judge does a few concrete things. It checks whether the secret appears in the output. It checks whether the canary token appears, which is the sharper signal: the canary is embedded in the system prompt and nowhere else, so if it surfaces in a response, the system prompt itself has escaped even if the literal secret string did not. And it accounts for obfuscation, because a leak that comes back base64-encoded or spelled out letter by letter is still a leak. The output filter can redact the raw secret, but the judge is what decides whether a crack actually happened.&lt;/p&gt;

&lt;p&gt;Scoring rewards higher difficulty, more active defenses, and cracking with fewer attempts. Only your first crack of a level counts, so you cannot farm points by re-submitting the same winning payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offline mock agent
&lt;/h2&gt;

&lt;p&gt;Here is the part I am most happy with. The entire game runs with no API keys and no network. The default provider is a deterministic mock agent that simulates injection susceptibility per level. Each challenge declares which technique families still work against it offline, and the mock honors exactly those, so there is a real difficulty curve even without a real model.&lt;/p&gt;

&lt;p&gt;That means you can clone the repo, run &lt;code&gt;npm install &amp;amp;&amp;amp; npm run dev&lt;/code&gt;, and immediately play all ten levels for free. It also means the whole thing is testable in CI: the suite has 36 tests covering the techniques, the judge, the scoring, the pipeline, sessions, and the database, all running against the deterministic mock with no external calls.&lt;/p&gt;

&lt;p&gt;When you want the real thing, you set &lt;code&gt;AGENT_PROVIDER&lt;/code&gt; to anthropic, openai, or groq and supply the key. Real-model responses are graded by the exact same server-side judge. And if the selected provider's key is missing, the app falls back to the mock automatically, so it is always runnable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting and storage
&lt;/h2&gt;

&lt;p&gt;The persistence layer is a pluggable async interface. Locally and in tests it uses SQLite through &lt;code&gt;better-sqlite3&lt;/code&gt;, stored at a configurable path. In production on Cloudflare Workers it uses Cloudflare D1 through a binding, and the backend is selected automatically at runtime. No external database is required to run it locally. Players are identified by a signed cookie plus a nickname, so there is no login flow to stand up. The live instance runs on Workers with D1 via the OpenNext adapter.&lt;/p&gt;

&lt;p&gt;Adding a level is deliberately small: append a challenge object to the levels file, list which technique families should still crack it offline, and add a test asserting what should and should not work. No schema changes, no migrations. That was a design constraint I held onto, because a challenge platform that is painful to extend does not grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The rate limiter is in-memory and fixed-window. That is fine for a single instance and for local play, but if you self-host across multiple instances, each one keeps its own counter, so the effective limit multiplies and the protection weakens. The code notes this: front it with something shared like Redis if you run more than one instance. I chose the simple version deliberately to keep the zero-dependency local story clean, but it is a real edge you should know about before scaling it out.&lt;/p&gt;

&lt;p&gt;I would also be upfront that the offline mock is a simulation of susceptibility, not a real model. It is excellent for building intuition and for deterministic tests, but the honest way to feel how a specific model behaves is to plug that model in and attack it directly.&lt;/p&gt;

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

&lt;p&gt;Play it live at &lt;a href="https://injection-arena.agentpostmortem.com" rel="noopener noreferrer"&gt;https://injection-arena.agentpostmortem.com&lt;/a&gt;, or clone and self-host from &lt;a href="https://github.com/AgentPostmortem/injection-arena" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/injection-arena&lt;/a&gt;. It is MIT licensed. Contributions of new levels and attack techniques are especially welcome, and given the extension surface, they are genuinely easy to add.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>promptinjection</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Signed, verifiable receipts for RAG answers: what they actually prove</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:30:28 +0000</pubDate>
      <link>https://dev.to/royalpinto007/signed-verifiable-receipts-for-rag-answers-what-they-actually-prove-3pn6</link>
      <guid>https://dev.to/royalpinto007/signed-verifiable-receipts-for-rag-answers-what-they-actually-prove-3pn6</guid>
      <description>&lt;p&gt;Every RAG system I have built eventually runs into the same question, usually months after the answer was generated: "how do we know what actually happened here?" A document leaked into an answer it should not have. A fact turned out to be wrong. A compliance reviewer wants to see which sources supported a claim. The standard reply is "check the logs."&lt;/p&gt;

&lt;p&gt;Logs are a weak answer. They are mutable, they live in your database, and they prove nothing to anyone outside your walls. If I am the party who is being questioned, "trust my logs" is not evidence. That is the gap I built &lt;strong&gt;answerproof&lt;/strong&gt; to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;answerproof produces a &lt;strong&gt;receipt&lt;/strong&gt; for each generated answer. A receipt is a signed, self-contained artifact that records what the system did: the query, the answer, which sources were retrieved, which the answer claimed to use, under whose permissions, with which model and parameters, plus a content hash of every source and a Merkle root over the retrieval set.&lt;/p&gt;

&lt;p&gt;The important property is that a third party can verify a receipt independently, using nothing but the receipt and the library. No access to my database, my servers, or my logs. It turns "trust us" into "verify it yourself."&lt;/p&gt;

&lt;p&gt;One design decision matters a lot here: source contents are never stored in the receipt, only their SHA-256 hashes. So a receipt is safe to hand out even when the underlying documents are sensitive. If someone later has the original content, they can confirm it hashes byte-for-byte to what was recorded. If they do not, the hash and the Merkle root still let them reason about membership without ever seeing the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a receipt
&lt;/h2&gt;

&lt;p&gt;The builder sits at the seam between retrieval and generation. You feed it the same things you already have in a RAG loop.&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;answerproof&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ReceiptBuilder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SigningKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_receipt&lt;/span&gt;

&lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SigningKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReceiptBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How tall is the Eiffel Tower?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The Eiffel Tower is 330 metres tall.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_principal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analyst-7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissions&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;kb:paris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The Eiffel Tower is 330 metres tall.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;finalize()&lt;/code&gt; runs, the builder hashes each source's content, builds a Merkle tree over those hashes, runs a rule-based citation and grounding pass, assembles the payload, serializes it to canonical JSON, and signs that byte string with an Ed25519 key. The signature is detached and the signer's public key travels alongside it in the receipt.&lt;/p&gt;

&lt;p&gt;Canonicalization is the quiet part that makes the whole thing work. Two systems have to agree on the exact bytes being signed, or verification would depend on whitespace and key ordering. answerproof serializes with sorted keys, no extra whitespace (&lt;code&gt;separators=(",", ":")&lt;/code&gt;), and &lt;code&gt;ensure_ascii=False&lt;/code&gt; so UTF-8 is preserved. Same payload, same bytes, everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying, and watching tampering fail
&lt;/h2&gt;

&lt;p&gt;Verification needs only the receipt, plus optionally the original source contents if you want to check them too.&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;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_contents&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;doc-1&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;The Eiffel Tower is 330 metres tall.&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verdict is not a single boolean under the hood. Each check runs independently and is reported separately: &lt;code&gt;signature&lt;/code&gt; (the payload is unmodified and signed by the embedded key), &lt;code&gt;merkle&lt;/code&gt; (the recomputed root matches the signed root), &lt;code&gt;sources&lt;/code&gt; (supplied contents hash to the recorded hashes), &lt;code&gt;grounding&lt;/code&gt; (citations reference real sources), and an optional &lt;code&gt;signer_pin&lt;/code&gt; for when you want to require a specific public key.&lt;/p&gt;

&lt;p&gt;The failure case is where the design earns its keep. Change a single character of the answer after signing:&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;answerproof&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;verify_receipt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;answerproof.schema&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Receipt&lt;/span&gt;

&lt;span class="n"&gt;tampered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;tampered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&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;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The Eiffel Tower is in Berlin.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tampered&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; "signature"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mutated payload no longer canonicalizes to the bytes that were signed, so the Ed25519 check fails and points straight at &lt;code&gt;signature&lt;/code&gt;. There is no way to edit the answer, swap a source, or rewrite the permissions without breaking the signature, because all of it is inside the signed payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merkle inclusion proofs
&lt;/h2&gt;

&lt;p&gt;The Merkle root is not decoration. It lets you prove that one specific source was part of the retrieval set without revealing the others, which is exactly what you want when the rest of the set is confidential.&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;answerproof.merkle&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MerkleTree&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;answerproof.verifier&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;verify_inclusion&lt;/span&gt;

&lt;span class="n"&gt;hashes&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;content_hash&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;proof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MerkleTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_hashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;verify_inclusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tree uses domain-separated hashing, with a &lt;code&gt;0x00&lt;/code&gt; prefix for leaves and &lt;code&gt;0x01&lt;/code&gt; for internal nodes, and it promotes odd nodes rather than duplicating them. Both details close well-known Merkle forgery vectors where a leaf can be passed off as an internal node or a duplicated node manipulates the root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;I want to be precise about what a receipt does and does not prove, because it is easy to oversell this.&lt;/p&gt;

&lt;p&gt;A receipt proves &lt;strong&gt;integrity, source authenticity, set membership, and provenance of the signer.&lt;/strong&gt; It does not prove &lt;strong&gt;truth.&lt;/strong&gt; A perfectly grounded claim can still be wrong if the source is wrong. answerproof records what the system did, not whether the world agrees with it.&lt;/p&gt;

&lt;p&gt;The grounding and citation signal is deliberately transparent, not clever. Citation binding is n-gram overlap, which means it can miss a correct paraphrase or accept a coincidental lexical match. It is an auditable, rule-based signal, not a semantic judge, and I document it as such. Likewise, answerproof verifies signatures but does not run a PKI; you decide which public keys you trust. And it records what was retrieved, not what should have been, so it cannot tell you your retrieval was complete or unbiased.&lt;/p&gt;

&lt;p&gt;Being clear about these boundaries is the point. A receipt is trustworthy precisely because it does not claim more than the cryptography supports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;answerproof is a real library and verifier, not a wrapper around a model. It ships with a CLI (&lt;code&gt;keygen&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt;, &lt;code&gt;inspect&lt;/code&gt;), an optional FastAPI verifier service that can return a shareable HTML verification page, and a test suite of 85 tests including negative tamper cases, run on Python 3.11 and 3.12.&lt;/p&gt;

&lt;p&gt;If you run RAG or agents in any setting where someone might later ask "prove it," a signed receipt is a much better answer than a log line.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/AgentPostmortem/answerproof" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/answerproof&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package: &lt;a href="https://pypi.org/project/answerproof/" rel="noopener noreferrer"&gt;https://pypi.org/project/answerproof/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>cryptography</category>
      <category>rag</category>
      <category>python</category>
    </item>
    <item>
      <title>A perfect transcript can still be a wrong call: evaluating voice agents</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Wed, 05 Aug 2026 09:30:30 +0000</pubDate>
      <link>https://dev.to/royalpinto007/a-perfect-transcript-can-still-be-a-wrong-call-evaluating-voice-agents-4dn2</link>
      <guid>https://dev.to/royalpinto007/a-perfect-transcript-can-still-be-a-wrong-call-evaluating-voice-agents-4dn2</guid>
      <description>&lt;p&gt;I built a small tool called voiceeval because I kept hitting the same blind spot: a voice agent can produce a transcript that reads as flawless and still have failed the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Here is the call that started it. The caller asks for a refund. The agent refunds the amount and says so. Read the transcript and every line agrees with every other line. The caller asked for fifty dollars, the agent refunded fifty dollars, done.&lt;/p&gt;

&lt;p&gt;The caller said &lt;strong&gt;fifteen&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;"Fifteen" and "fifty" are one unstressed syllable apart. Speech-to-text picks one, and the agent acts on whichever it got with exactly the same confidence. By the time it reaches the transcript, the mistake is already baked in and internally consistent. Nothing downstream can see it, because the transcript has no memory of what was actually spoken and no clock on how long the caller waited.&lt;/p&gt;

&lt;p&gt;That is the real issue. If you evaluate a voice agent by reading its transcript, you are evaluating a text agent that happens to have been spoken out loud. You will score a call as perfect when the caller hung up during a four-second silence, or when the agent confidently refunded the wrong amount and nobody ever found out.&lt;/p&gt;

&lt;p&gt;Everyone can demo a voice agent. I wanted something that tells me whether mine is getting worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;voiceeval does not run calls. It judges them. You give it a call as timed turns, each turn carrying who spoke, what the STT heard, when they started and stopped, and for the caller's turns an optional &lt;code&gt;truth&lt;/code&gt; field: what the caller actually said. It runs a set of checks that are each invisible to a text eval, and returns findings with a severity per case.&lt;/p&gt;

&lt;p&gt;The input is deliberately dumb JSON so that whatever produced your call (LiveKit, Vapi, Twilio, a test script) can emit it with a few lines of glue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund-happy-path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"max_refund"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"speaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund fifty dollars"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"truth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund fifteen dollars"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"start_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"speaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Refunding fifty now."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;7.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"consequential"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Run the checker over a call and you get findings, ordered by severity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;voiceeval check fixtures/misheard_call.json
&lt;span class="go"&gt;
FAIL refund-misheard-fifty (7s call)
  high   misheard_number  turn 1
         STT heard ['fifty'] but caller said ['fifteen'].
  high   no_confirmation  turn 2
         Took consequential action (refund) without ever confirming.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate failures, both invisible in text.&lt;/p&gt;

&lt;p&gt;The first, &lt;code&gt;misheard_number&lt;/code&gt;, only fires because the turn carried a &lt;code&gt;truth&lt;/code&gt; field. The check normalises both strings, and if they differ it extracts the numbers from each side and compares the sets. When the heard numbers do not match the spoken numbers, that is a high-severity misheard number, because a wrong number the agent acts on is the most expensive failure in voice. If the words differ but the numbers match, it downgrades to a plain medium &lt;code&gt;misheard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second, &lt;code&gt;no_confirmation&lt;/code&gt;, is about the shape of the interaction rather than the words. The check walks the turns, finds any action marked &lt;code&gt;consequential&lt;/code&gt;, and looks backward for an agent turn that actually confirmed, using phrases like "just to confirm", "did you say", or "shall I go ahead". A refund with no confirmation before it is flagged high. A read-only lookup is not, because demanding confirmation for every read would make the agent unusable. A lookup is not a refund.&lt;/p&gt;

&lt;p&gt;Other checks cover the failures that live in the clock. Latency measures the gap between the caller finishing and the agent starting against a budget, defaulting to 1.5 seconds, and escalates to high past double that. Talking over the user looks for the agent's speech still running when the caller starts, but ignores overlaps under 300ms because humans interrupt each other constantly and flagging normal turn-taking is just noise. Dead air catches long silences where nobody is speaking, which is where callers hang up. Policy violation reads its limit from the interaction, not from the library, so a refund above &lt;code&gt;max_refund&lt;/code&gt; is caught while what counts as allowed stays a business decision.&lt;/p&gt;

&lt;p&gt;For regression work you run a whole suite, label it, then diff two runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;voiceeval run calls/&lt;span class="k"&gt;*&lt;/span&gt;.json &lt;span class="nt"&gt;--label&lt;/span&gt; v1 &lt;span class="nt"&gt;-o&lt;/span&gt; v1.json
&lt;span class="c"&gt;# ... change the prompt ...&lt;/span&gt;
voiceeval run calls/&lt;span class="k"&gt;*&lt;/span&gt;.json &lt;span class="nt"&gt;--label&lt;/span&gt; v2 &lt;span class="nt"&gt;-o&lt;/span&gt; v2.json
voiceeval diff v1.json v2.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--strict&lt;/code&gt; the diff exits non-zero on a regression, so a prompt change that quietly drops the pass rate fails CI instead of shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The misheard-number check is only as good as the &lt;code&gt;truth&lt;/code&gt; field. Without ground truth for what the caller actually said, mis-hearing is undetectable by construction, and there is a test in the suite that documents exactly this. In production that failure is silent, and no tool can fix that for you. This is the argument for scripted test calls: you supply the truth once, in the fixture, and then the check can hold the STT accountable to it. If you only have raw production transcripts, this particular check has nothing to compare against.&lt;/p&gt;

&lt;p&gt;I am also honest that the eval logic is the project and it is fully tested with no keys and no network, while the STT adapter that turns audio into timed turns is not exercised by those tests. If your platform already hands you a timed transcript, you never touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;voiceeval is small on purpose. It does one thing: it looks at a voice call the way the caller experienced it, with a clock and a record of what was really said, and it tells you which calls failed even when the transcript swears they passed. If you are shipping a voice agent and only reading transcripts, the fifteen-versus-fifty call is already somewhere in your logs, scored green.&lt;/p&gt;

&lt;p&gt;Code and fixtures: &lt;a href="https://github.com/royalpinto007/voiceeval" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/voiceeval&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voice</category>
      <category>testing</category>
      <category>python</category>
    </item>
    <item>
      <title>Fail the build when your prompt gets dumber: evalgate for prompt regression CI</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/fail-the-build-when-your-prompt-gets-dumber-evalgate-for-prompt-regression-ci-4k36</link>
      <guid>https://dev.to/royalpinto007/fail-the-build-when-your-prompt-gets-dumber-evalgate-for-prompt-regression-ci-4k36</guid>
      <description>&lt;p&gt;Prompts rot silently. I swap a model, tweak a system prompt, add a tool, and everything still runs. No exception is thrown, no test goes red, the JSON still parses. The output is just quietly worse, and I usually find out from a user rather than from CI. Unit tests are the wrong instrument here because there is nothing to catch: the failure mode is not a crash, it is a drop in quality.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;evalgate&lt;/strong&gt;, a small TypeScript tool that treats prompt and agent quality like a build artifact. You write a declarative eval suite, evalgate runs it, scores it, stores a baseline, and on every pull request it re-runs the suite, computes the quality delta against the base branch, and fails the build when the score regresses. Then it posts the delta table as a PR comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;The important design decision is what question CI is allowed to ask. "Is this prompt good?" is subjective and unwinnable in an automated gate. "Is this worse than it was on main?" is objective and answerable. evalgate is built around that second question. You capture a baseline once, and from then on every change is judged as a delta against it, not against some absolute notion of goodness.&lt;/p&gt;

&lt;p&gt;The second decision was that the whole thing has to run with zero API keys. evalgate ships a deterministic mock provider, so you can run a suite, save a baseline, compare runs, and execute the full test suite completely offline. The project itself has 67 tests and none of them touch the network. Every feature has to work in mock mode before it counts as done.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A suite is a YAML (or JSON) file that lives in version control next to the code it checks. Each case has an input, an expected reference value, and one or more scorers. Here is a minimal one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-agent&lt;/span&gt;
&lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mock&lt;/span&gt;          &lt;span class="c1"&gt;# works with no API key&lt;/span&gt;
&lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9&lt;/span&gt;          &lt;span class="c1"&gt;# mean score required to pass&lt;/span&gt;
&lt;span class="na"&gt;cases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greeting&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;Reply with the standard greeting.&lt;/span&gt;
        &lt;span class="s"&gt;exactly: Hi there! How can I help you today?&lt;/span&gt;
    &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hi&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;there!&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;How&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;can&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;help&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;today?"&lt;/span&gt;
    &lt;span class="na"&gt;scorers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;exact-match&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latency&lt;/span&gt;
        &lt;span class="na"&gt;budgetMs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A case passes when every one of its scorers passes, and its numeric score is the weighted mean of the individual scorer scores. There are 10 scorers in the catalog, covering the range of things you actually want to assert about model output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;exact-match&lt;/code&gt;, &lt;code&gt;regex&lt;/code&gt;, &lt;code&gt;contains&lt;/code&gt;, and &lt;code&gt;not-contains&lt;/code&gt; for string-level checks (contains gives partial credit across multiple substrings).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;json-schema&lt;/code&gt; for structured output, so you can assert the model returns valid JSON matching a schema.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;embedding-similarity&lt;/code&gt; for "close enough in meaning" via cosine similarity.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm-judge&lt;/code&gt; and &lt;code&gt;rubric&lt;/code&gt; for the softer, criteria-based judgments.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;latency&lt;/code&gt; and &lt;code&gt;cost&lt;/code&gt; for budget gates, so a change that makes the agent slow or expensive can also fail the gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of those scorers are pluggable and ship with deterministic offline fallbacks, which is what keeps the mock-first rule honest. &lt;code&gt;embedding-similarity&lt;/code&gt; uses the provider's &lt;code&gt;embed()&lt;/code&gt; if it has one, and otherwise falls back to a stable local bag-of-hashed-words embedding. &lt;code&gt;llm-judge&lt;/code&gt; calls a real provider and parses a &lt;code&gt;{score, reason}&lt;/code&gt; JSON reply, but on the mock provider it computes a reproducible word-overlap score instead. So a suite that uses judges and embeddings still runs identically on every machine with no keys.&lt;/p&gt;

&lt;p&gt;The workflow is three commands. You run a suite, save a baseline, then compare later runs against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @royalpinto007/evalgate run suite.eval.yaml
npx @royalpinto007/evalgate baseline suite.eval.yaml &lt;span class="nt"&gt;--out&lt;/span&gt; baseline.json
npx @royalpinto007/evalgate compare suite.eval.yaml &lt;span class="nt"&gt;--base&lt;/span&gt; baseline.json &lt;span class="nt"&gt;--tolerance&lt;/span&gt; 0.01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;compare&lt;/code&gt; exits non-zero when any case regresses beyond the tolerance, and that non-zero exit is what fails the CI job. The tolerance matters because model output is not perfectly stable; you usually want a small allowed drift before something counts as a real regression.&lt;/p&gt;

&lt;p&gt;The part that makes it feel like CI rather than a script is the GitHub Action. On each pull request it re-runs the suite and upserts a single comment, editing its own comment in place instead of stacking a new one on every push. A regression renders like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### evalgate: support-agent&lt;/span&gt;

FAIL - Quality regressed. 5 case(s) got worse.

Overall score: 94.2% (base) -&amp;gt; 60.1% (head) = -34.2pp

| Case                | Base   | Head  | Delta    | Change |
| ------------------- | ------ | ----- | -------- | ------ |
| refund-intent-json  | 100.0% | 0.0%  | -100.0pp | down   |
| order-id-format     | 100.0% | 0.0%  | -100.0pp | down   |
| greeting-exact      | 100.0% | 66.7% | -33.3pp  | down   |
| judge-helpfulness   | 73.6%  | 69.1% | -4.5pp   | down   |
| paraphrase-quality  | 86.0%  | 84.6% | -1.3pp   | down   |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the overall movement in percentage points and a per-case breakdown of exactly what got worse, right in the review, before the merge. Under the hood the same logic is exposed as a library, so &lt;code&gt;loadSuite&lt;/code&gt;, &lt;code&gt;runSuite&lt;/code&gt;, &lt;code&gt;compareRuns&lt;/code&gt;, and &lt;code&gt;renderCompareMarkdown&lt;/code&gt; are all importable if you want to wire evalgate into something other than the Action, and both scorers and providers are registrable so you can add your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest limitation
&lt;/h2&gt;

&lt;p&gt;evalgate tells you the score moved; it does not tell you whether the new score is the correct one. If your prompt genuinely got better and the reference expectations are now stale, evalgate will still flag a delta, and it is on you to update the baseline. The gate is a change detector, not an oracle. That is also true for the softer scorers: &lt;code&gt;llm-judge&lt;/code&gt; and &lt;code&gt;embedding-similarity&lt;/code&gt; are only as trustworthy as the judge model and the criteria you write, so a green suite built on weak criteria is a false sense of safety. I lean on the exact, regex, and schema scorers for anything I want to be strict about, and treat the model-based scores as directional signal rather than ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The thing I wanted was simple: make prompt quality something a pull request can fail on, the same way a broken type or a failing test does. evalgate does that with a declarative suite, a baseline plus delta engine, 10 scorers, and a GitHub Action that comments the regression inline, all runnable offline through a deterministic mock provider.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/royalpinto007/evalgate" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/evalgate&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package: &lt;a href="https://www.npmjs.com/package/@royalpinto007/evalgate" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@royalpinto007/evalgate&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>llm</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I built agentrace to catch the subagent runs I should not trust</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/i-built-agentrace-to-catch-the-subagent-runs-i-should-not-trust-1kmn</link>
      <guid>https://dev.to/royalpinto007/i-built-agentrace-to-catch-the-subagent-runs-i-should-not-trust-1kmn</guid>
      <description>&lt;p&gt;Directing agents is the easy half. The hard half is knowing which of their answers to trust.&lt;/p&gt;

&lt;p&gt;I spent about two weeks fanning out research subagents in Claude Code, ten at a time, and the bottleneck was never getting them to produce output. A model is good at producing candidates and bad at knowing what counts as proof. So when ten background agents each return a confident wall of text, generation is not the problem. Verification is. And you cannot verify what you cannot see. By the time the last agent reports, the interesting details are buried in a transcript nobody is going to read.&lt;/p&gt;

&lt;p&gt;agentrace reads the transcript for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: no instrumentation
&lt;/h2&gt;

&lt;p&gt;The thing I like most about it is that there is nothing to add ahead of time. No wrapper, no SDK, no decorator around your agent calls. Claude Code already writes every session to disk at &lt;code&gt;~/.claude/projects/&amp;lt;slug&amp;gt;/&amp;lt;session-id&amp;gt;.jsonl&lt;/code&gt;, and that file already contains every &lt;code&gt;Agent&lt;/code&gt; delegation and the result it returned. The data is on disk whether or not you planned to look at it, which means you can analyse the run you wish you had traced, after the fact.&lt;/p&gt;

&lt;p&gt;So agentrace is a reader, not a runtime. It parses those JSONL transcripts, pairs each subagent invocation with its result, and then runs a set of text heuristics over the pair to point at the results worth a second look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it shows you
&lt;/h2&gt;

&lt;p&gt;Three commands do most of the work. First, the aggregate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;agentrace stats
&lt;span class="go"&gt; subagent runs          152
 errored                7
 total agent time       2.3 h
 slowest run            9.5 min
 prompt chars written   350,134
 result chars returned  257,721
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the part that matters, the flagging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;agentrace check
&lt;span class="go"&gt; 36/152 runs flagged, 39 findings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are real numbers from the session that motivated the tool: a 34MB transcript with 152 subagent runs. Of the 36 flagged, 7 were agents that died on session limits mid-sweep, 17 were hedged claims, and 12 were prompts where I forgot to specify an output shape.&lt;/p&gt;

&lt;p&gt;That last number is the one I keep pointing at. Most agent tooling assumes the model is the problem. A third of the flags here were mine.&lt;/p&gt;

&lt;p&gt;You can narrow the view and read a single run in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentrace list                  &lt;span class="c"&gt;# every run: description, duration, sizes&lt;/span&gt;
agentrace check &lt;span class="nt"&gt;--severity&lt;/span&gt; high &lt;span class="c"&gt;# only the ones that definitely matter&lt;/span&gt;
agentrace check &lt;span class="nt"&gt;--strict&lt;/span&gt;        &lt;span class="c"&gt;# exit 1 on any high finding (CI-friendly)&lt;/span&gt;
agentrace show 6e7fAJ8T         &lt;span class="c"&gt;# one run: prompt, result, findings&lt;/span&gt;
agentrace stats &lt;span class="nt"&gt;--json&lt;/span&gt;          &lt;span class="c"&gt;# machine-readable aggregate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The checks come from real failures
&lt;/h2&gt;

&lt;p&gt;Every check exists because it actually happened, not because it sounded plausible. A few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;error&lt;/code&gt;: agents dying on session limits mid-sweep. Work silently lost, and nobody noticed until the report came back short.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;absence_as_evidence&lt;/code&gt;: an agent concluded a company was not hiring because an API returned an empty list. That API returns empty with HTTP 200 for accounts that do not exist. Absence of data is not evidence of absence.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gave_up&lt;/code&gt;: "I was unable to find..." reads like an answer if you skim. It is not one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hedged_claim&lt;/code&gt;: an agent said a company "appears to be" hiring. That "appears to be" became a fact by the time it reached a decision. The hedge was honest; the bug was flattening it downstream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unverified_urls&lt;/code&gt;: twenty URLs cited, none opened. That is autocomplete, not research.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no_output_contract&lt;/code&gt; and &lt;code&gt;thin_prompt&lt;/code&gt;: the failure that is yours, not the model's. A task with no definition of done cannot be verified, because you never really asked the question.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;slow_run&lt;/code&gt;: a subagent running 25 minutes is usually looping or retrying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mechanically these are regex-and-length heuristics over the result and prompt text, plus the run duration. &lt;code&gt;unverified_urls&lt;/code&gt;, for example, counts links in the result and only fires when there are at least five and nothing in the text mentions verifying, confirming, fetching, or an HTTP 200. Cheap, but it catches the shape of the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part was not crying wolf
&lt;/h2&gt;

&lt;p&gt;Every check is a heuristic over text. It tells you what to go read; it does not tell you what is true. That distinction is load-bearing, because a checker that cries wolf gets switched off, which is worse than no checker at all. There is even a test, &lt;code&gt;test_clean_run_produces_nothing&lt;/code&gt;, whose only job is to keep a clean run from producing findings.&lt;/p&gt;

&lt;p&gt;The clearest example of tuning for that: &lt;code&gt;thin_prompt&lt;/code&gt; used to fire on any prompt under 200 characters. But "Run the suite and report every failing test as node ids with its assertion message" is 113 characters and completely verifiable. Flagging it taught nobody anything while spending the reader's attention. Length was never the defect. Being short and never saying what done looks like is. So now both signals have to fire. On the bundled fixture that took findings from 16 down to 9 without losing a single true one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;These are hints, not verdicts. Every finding is a heuristic over text, so it can be wrong in both directions. A &lt;code&gt;hedged_claim&lt;/code&gt; flag does not mean the claim is false, only that a hedge is present and might get flattened downstream. An unflagged run is not certified correct; it just did not trip any of the seven patterns. agentrace narrows where a human should look. It does not replace that human, and it cannot judge whether the agent's answer is actually true. If you want a verdict, you still have to read the run it points you to.&lt;/p&gt;

&lt;p&gt;A couple of parsing choices follow from wanting to look at live runs. It scans the transcript twice, because results can appear before every corresponding use in unusual orderings, and a 34MB file is cheap to scan twice compared to getting the pairing subtly wrong. A torn final line from a session still being appended to is skipped rather than treated as fatal, and a run with no result yet still shows up rather than vanishing, because a dead or still-running agent is exactly the one you want to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;It is working today, has 17 tests, zero dependencies beyond &lt;code&gt;rich&lt;/code&gt;, no API keys, and no network. It reads local files and nothing else. If you run subagents and have ever shipped one of their confident answers without reading it, this is the tool I wanted for exactly that moment.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/royalpinto007/agentrace" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/agentrace&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>observability</category>
      <category>python</category>
    </item>
    <item>
      <title>I built a security linter for MCP servers, because nobody audits the tools we hand our agents</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:30:32 +0000</pubDate>
      <link>https://dev.to/royalpinto007/i-built-a-security-linter-for-mcp-servers-because-nobody-audits-the-tools-we-hand-our-agents-3n9g</link>
      <guid>https://dev.to/royalpinto007/i-built-a-security-linter-for-mcp-servers-because-nobody-audits-the-tools-we-hand-our-agents-3n9g</guid>
      <description>&lt;p&gt;We spent years learning to distrust the code we ship. We run &lt;code&gt;npm audit&lt;/code&gt;, we run linters, we gate pull requests on static analysis. Then the Model Context Protocol arrived, we started handing language models real capabilities, and most of that discipline quietly evaporated.&lt;/p&gt;

&lt;p&gt;An MCP server is not a passive data source. It gives a model the ability to run commands, read files, hit internal URLs, and mutate databases. A single over-scoped tool, or a &lt;code&gt;.env&lt;/code&gt; file exposed as a resource, turns a helpful agent into a remote-code-execution or data-exfiltration path. Yet most MCP servers ship with no security review at all. There is no &lt;code&gt;npm audit&lt;/code&gt; for the surface you are about to attach to your agent.&lt;/p&gt;

&lt;p&gt;So I wrote one. It is called &lt;code&gt;mcp-audit&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp-audit&lt;/code&gt; treats an MCP server the way a linter treats a source file. It connects to the server (or reads a manifest describing it), enumerates every tool, resource, and prompt the server advertises, and runs a catalog of security rules over that surface. Then it reports findings with a stable id, a severity, a location, and a concrete remediation.&lt;/p&gt;

&lt;p&gt;The design goals were narrow on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It runs offline and is fully deterministic. No model calls, no network heuristics, same input gives the same output.&lt;/li&gt;
&lt;li&gt;It drops into CI. There is JSON output and SARIF 2.1.0 output so findings show up as annotations in GitHub code scanning.&lt;/li&gt;
&lt;li&gt;It fails the build when it should. The process exits non-zero once any finding reaches a configurable severity threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The fastest way to use it is to point it at a server you spawn over stdio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @royalpinto007/mcp-audit stdio &lt;span class="s2"&gt;"node my-mcp-server.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It launches the server, speaks the MCP handshake, asks it to list its tools and resources, and audits what comes back. There are three other entry points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Audit a remote server over HTTP, with a bearer token&lt;/span&gt;
npx @royalpinto007/mcp-audit http https://mcp.example.com/mcp &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MCP_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Lint a server's declared surface from a manifest, without running it&lt;/span&gt;
npx @royalpinto007/mcp-audit static ./mcp-manifest.json

&lt;span class="c"&gt;# List every built-in rule&lt;/span&gt;
npx @royalpinto007/mcp-audit rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;static&lt;/code&gt; mode matters more than it looks. It lints a JSON manifest of the tools, resources, and prompts a server would advertise, without ever executing the server. That is exactly what you want when the server is untrusted and you are reviewing it before it ever runs on your machine.&lt;/p&gt;

&lt;p&gt;Right now there are 18 built-in rules across categories like permissions, schema, injection, secrets, transport, metadata, and hygiene. Each one is an independent module with a stable &lt;code&gt;MCPxxx&lt;/code&gt; id. To make this concrete, here is roughly what the destructive-tool rule (MCP001) does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MCP001 - a tool that implies deletion but exposes no confirmation argument&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;containsAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DESTRUCTIVE_VERBS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;schemaProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasConfirm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;CONFIRM_HINTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasConfirm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// report: destructive action with no confirmation/scope parameter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other rules follow the same shape. MCP002 flags tools whose name or description implies arbitrary command or shell execution. MCP030 flags resources that expose secrets or sensitive paths, like a &lt;code&gt;.env&lt;/code&gt; handed out as a readable resource. MCP040 flags HTTP transports with no authentication. MCP041 flags tools that take a caller-controlled URL, which is the classic SSRF setup. There are also schema rules for missing input schemas and unconstrained string arguments, an injection rule for prompt-injection text planted in descriptions, and hygiene rules for things like duplicate tool names.&lt;/p&gt;

&lt;p&gt;A run looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; CRITICAL   (3)
  MCP002 Arbitrary execution tool detected  @ run_shell
      Tool "run_shell" appears to execute commands or code (matched "shell").
      fix: Avoid exposing raw exec. If unavoidable, allowlist commands,
           drop privileges, sandbox execution, and require human approval.
  MCP030 Resource surfaces sensitive material  @ file:///home/app/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is configurable through a &lt;code&gt;.mcpauditrc&lt;/code&gt; file that mcp-audit discovers by walking up from the working directory. You can disable rules, run only a specific set, remap a rule's severity, ignore findings by location substring, and set the fail-on threshold. The CI recipe is one line plus the SARIF upload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @royalpinto007/mcp-audit static ./mcp-manifest.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sarif&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; mcp-audit.sarif &lt;span class="nt"&gt;--fail-on&lt;/span&gt; critical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing is TypeScript, and the test suite has 47 tests, including a real mock MCP server as a fixture so the tests exercise the actual stdio transport rather than a stub.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;These rules are static and, for the most part, lexical. MCP001 fires because a tool's name or description contains a destructive verb and its schema has no confirmation-shaped parameter. MCP002 fires because a description matches an execution term. That means the checks are deterministic and fast, but they reason about how a tool describes itself, not about what its implementation actually does.&lt;/p&gt;

&lt;p&gt;A tool honestly named &lt;code&gt;delete_everything&lt;/code&gt; gets caught. A tool blandly named &lt;code&gt;process_item&lt;/code&gt; that quietly shells out will not be flagged by the name-and-schema heuristics, because mcp-audit never sees the server's source. It audits the advertised surface. This is genuinely useful, since the advertised surface is exactly what the model gets to see and act on, and it catches a large class of real mistakes. But it is a surface linter, not a proof of safety. Treat a clean report as "no obvious footguns in the declared interface," not "this server is secure."&lt;/p&gt;

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

&lt;p&gt;If you are building or adopting MCP servers, run it against one before your agent does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @royalpinto007/mcp-audit stdio &lt;span class="s2"&gt;"node my-mcp-server.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code and full rule catalog are here: &lt;a href="https://github.com/royalpinto007/mcp-audit" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/mcp-audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Package on npm: &lt;a href="https://www.npmjs.com/package/@royalpinto007/mcp-audit" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@royalpinto007/mcp-audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;New rules are the most useful contribution. A good one is independently coded, has a stable id and clear remediation, and ships with a test that fires it against a fixture plus one that proves it stays quiet on a clean surface. If there is a class of MCP footgun you keep seeing, that is a rule worth adding.&lt;/p&gt;

</description>
      <category>security</category>
      <category>mcp</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Enforcing RAG access control inside the retrieval query, not after it</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:30:31 +0000</pubDate>
      <link>https://dev.to/royalpinto007/enforcing-rag-access-control-inside-the-retrieval-query-not-after-it-4gm3</link>
      <guid>https://dev.to/royalpinto007/enforcing-rag-access-control-inside-the-retrieval-query-not-after-it-4gm3</guid>
      <description>&lt;p&gt;Most RAG pipelines have the same shape: embed the question, retrieve the top-k chunks, filter out what the user is not allowed to see, then generate an answer. I built vaultrag because that ordering has always bothered me. By the time you filter, the unauthorized chunks are already inside your process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with retrieve-then-filter
&lt;/h2&gt;

&lt;p&gt;Think about where a retrieved chunk goes before you filter it. It can land in a log line, a trace span, an error report, or a prompt you assembled one step too early. If any of those happen before the filter runs, you have leaked, and you probably will not notice.&lt;/p&gt;

&lt;p&gt;There is a quieter failure too. If your top-k is 5 and the filter removes 4 of them, you now answer from a single chunk with no signal that this happened. Access control silently degraded your answer quality, and nothing told you.&lt;/p&gt;

&lt;p&gt;I wanted a design where an unauthorized chunk is never selected. Not fetched then dropped. Not ranked then trimmed. Never selected in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;vaultrag puts the ACL predicate in the same SQL query as the vector search and the keyword search. A chunk the user cannot see is never selected, never scored, never ranked, never logged. It cannot leak, because it was never fetched.&lt;/p&gt;

&lt;p&gt;The retrieval query starts from a &lt;code&gt;visible&lt;/code&gt; CTE, and both search arms read only from it. Here is the part that matters, straight from &lt;code&gt;app/retrieval.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;visible&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_official&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;doc_acl&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
          &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
            &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principals&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="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vector arm and the keyword arm both &lt;code&gt;SELECT ... FROM visible&lt;/code&gt;. There is no code path in the function that can reach a chunk outside that set, because both arms begin there.&lt;/p&gt;

&lt;p&gt;Two design choices around this predicate carry the security:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deny by default.&lt;/strong&gt; A document with no ACL rows is visible to nobody. Fail-open here would mean one ingestion bug silently publishes a document to the whole company.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups come from the database, never from the request.&lt;/strong&gt; The principals passed into the query are resolved by &lt;code&gt;resolve_principal&lt;/code&gt;, which looks up the user's groups in a &lt;code&gt;users&lt;/code&gt; table. The &lt;code&gt;/ask&lt;/code&gt; endpoint deliberately does not accept a &lt;code&gt;groups&lt;/code&gt; field. If a caller could assert its own group membership, the ACL would be decorative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retrieval is hybrid. Vector search is good at "what is our policy on remote work" and useless at &lt;code&gt;ERR_4021&lt;/code&gt; or "Policy 7.3". Keyword search is the opposite. Real questions contain both, so both arms run and the two ranked lists are fused with Reciprocal Rank Fusion at k=60, which combines them without needing their raw scores to be comparable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it, not asserting it
&lt;/h2&gt;

&lt;p&gt;"Permissions are enforced at retrieval" is a slogan until there is a number attached. So &lt;code&gt;vaultrag eval&lt;/code&gt; runs a gold set of (user, question, what-they-should-and-should-not-see) against a real corpus and reports two metrics that only mean something together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;leak rate&lt;/strong&gt;: did anything the user may not see surface. One leak is a failure, and the target is exactly zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;recall&lt;/strong&gt;: of the documents they may see that answer the question, how many came back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pairing is the entire point, because each is trivial to fake alone. Retrieve nothing and you score a perfect 0% leak rate. Retrieve everything and you score perfect recall.&lt;/p&gt;

&lt;p&gt;The demonstration I like most is deleting the ACL predicate and re-running the diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vaultrag diff before.json after.json
&lt;span class="go"&gt;LEAK
&lt;/span&gt;&lt;span class="gp"&gt;  leak rate: 0.0% -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;81.8%
&lt;span class="gp"&gt;  mean recall: 100.0% -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;100.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second line. Recall did not move. The broken build answers every question correctly and completely, while handing the user the CEO's private notes and HR's salary bands. A quality-only eval scores that build perfect. That is exactly why leak rate is never reported on its own, and why CI fails on &lt;code&gt;--strict&lt;/code&gt; rather than on some threshold.&lt;/p&gt;

&lt;p&gt;The proof lives in the corpus design. Every document in the test set contains the phrase "quarterly bonus payout policy". A retriever without access control would happily hand the CEO's private note to anyone who asks about bonuses. &lt;code&gt;tests/test_acl.py&lt;/code&gt; asserts the boundary 12 different ways, and if you delete the ACL predicate, 9 of those 12 fail immediately. These run against a real Postgres, not a mock, because mocking the database would mean mocking the thing under test.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest limitation
&lt;/h2&gt;

&lt;p&gt;The refusal question, whether the model declines to answer when it has no real evidence, is deliberately reported as &lt;strong&gt;not measured&lt;/strong&gt; under the test setup. The test suite uses a deterministic offline embedder and a scripted stub LLM so that verifying access control does not require a 90MB model download or an API key. But refusal is a property of the actual model, and scoring a stub on it would launder a fake into a metric. So CI proves only the two things it can actually prove: leak rate and recall.&lt;/p&gt;

&lt;p&gt;There is no reranker model either. A cheap score blend with an official-first, most-recent tie-break stands in for one. And there is no UI yet. These are real gaps, not deferred marketing.&lt;/p&gt;

&lt;p&gt;Worth adding: the eval harness caught two real bugs in this repo before it was committed. A refusal threshold was set below the maximum score RRF at k=60 can produce for a single-arm hit, so those hits were always refused. And &lt;code&gt;conftest.py&lt;/code&gt; once reset the schema of whatever &lt;code&gt;DATABASE_URL&lt;/code&gt; pointed at, which let the eval report 0% leaks and 100% pass rate against an empty corpus. The most convincing wrong answer a tool can give is the one that says everything is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The stack is FastAPI, Postgres with pgvector, sentence-transformers running locally with no API key, and Groq for generation, so it costs nothing to run. There are 56 tests passing against real Postgres plus pgvector, and an 11-case gold set at 0% leak rate, both gating CI.&lt;/p&gt;

&lt;p&gt;If retrieve-then-filter has ever made you nervous, the code is here: &lt;a href="https://github.com/royalpinto007/vaultrag" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/vaultrag&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>I shipped five AI-infrastructure tools in a week, here is the throughline</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:18:06 +0000</pubDate>
      <link>https://dev.to/royalpinto007/i-shipped-five-ai-infrastructure-tools-in-a-week-here-is-the-throughline-254h</link>
      <guid>https://dev.to/royalpinto007/i-shipped-five-ai-infrastructure-tools-in-a-week-here-is-the-throughline-254h</guid>
      <description>&lt;p&gt;Over one week I built and released five open-source tools: a permission-aware RAG core, a security scanner for MCP servers, an observability tool for agents, a prompt regression CI, and a library that puts cryptographic receipts on RAG answers. They look like five different projects. They are really one argument.&lt;/p&gt;

&lt;p&gt;The argument is this: the hard part of AI systems in production is not making the model say something. It is knowing whether you can trust what it said, and being able to prove it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation is cheap, verification is expensive
&lt;/h2&gt;

&lt;p&gt;A model is very good at producing a confident answer and very bad at knowing whether that answer is correct. When you build a single chatbot, you paper over this with a human reading each reply. When you fan out ten agents, or run retrieval over private documents, or ship a voice agent that takes real actions, the human is gone and the confident-but-wrong answer goes straight through.&lt;/p&gt;

&lt;p&gt;So the useful work moves. It moves from "get the model to answer" to "build the machinery that decides which answers are safe to act on." Every one of the five tools is a piece of that machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;vaultrag&lt;/strong&gt; is retrieval where the access-control check lives inside the query, not as a filter applied afterwards. If the predicate is inside the query, an unauthorized document is never fetched, never ranked, never seen by the model. A gold-set eval in CI proves it: delete the predicate and the leak rate jumps from zero to most of the corpus, while recall stays the same. The test fails the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mcp-audit&lt;/strong&gt; scans Model Context Protocol servers the way a linter scans code. It connects, enumerates the tools and resources a server exposes, and runs rules that catch over-broad permissions, destructive tools with no scoping, secrets in resources, and prompt-injection sinks. It emits SARIF so the findings drop into GitHub code scanning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;agentrace&lt;/strong&gt; reads agent session transcripts with no instrumentation and flags the runs you should not trust: hedged claims, missing output contracts, silent errors. Every check came from a real failure I hit across many runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;evalgate&lt;/strong&gt; is regression CI for prompts. It runs an eval suite on every pull request, compares to the baseline, and comments the quality delta. The build fails when the prompt gets dumber, the same way it fails when the types stop checking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;answerproof&lt;/strong&gt; attaches a signed, tamper-evident receipt to a RAG answer: which sources were retrieved and cited, under whose permissions, with a Merkle root and an Ed25519 signature anyone can verify independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build the boring layer
&lt;/h2&gt;

&lt;p&gt;None of these are a flashy demo. There is no avatar, no chat that writes your emails. They are the plumbing. But the plumbing is exactly where AI products break in production, and it is the part nobody posts screenshots of, so it stays underbuilt.&lt;/p&gt;

&lt;p&gt;I find that interesting for a simple reason: the demos already exist. What does not exist, in most stacks, is the layer that says no. The retrieval that refuses to fetch what you cannot see. The scanner that fails your MCP server before it ships. The eval that blocks a regression. The receipt that lets someone else check your work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common shape
&lt;/h2&gt;

&lt;p&gt;Look closely and the five share a pattern. Each one moves a check from "after the fact, by a human" to "inside the system, enforced by code."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vaultrag moves the permission check from a post-filter into the query.&lt;/li&gt;
&lt;li&gt;mcp-audit moves security review from a manual audit into CI.&lt;/li&gt;
&lt;li&gt;evalgate moves prompt-quality judgment from a person eyeballing outputs into a gate.&lt;/li&gt;
&lt;li&gt;answerproof moves "trust me" into "verify it yourself."&lt;/li&gt;
&lt;li&gt;agentrace moves "read the transcript later" into "flag it now."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole thesis. Trust in an AI system is not a vibe you add at the end. It is a property you build in, check by check, and ideally one that fails loudly when it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;I am going to keep building down this layer, because it is where I think the durable work is. If you are building agents or RAG in production and fighting the trust problem, these are all MIT licensed and I would genuinely like to hear where they break for you.&lt;/p&gt;

&lt;p&gt;Links to each are on my GitHub: github.com/royalpinto007. I will write up each tool in its own post over the next few weeks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
