<?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: Umang Kumar</title>
    <description>The latest articles on DEV Community by Umang Kumar (@umangcirvix).</description>
    <link>https://dev.to/umangcirvix</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%2F4172855%2F1e5bc522-809b-4718-9807-4ed0d70c89f8.jpg</url>
      <title>DEV Community: Umang Kumar</title>
      <link>https://dev.to/umangcirvix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/umangcirvix"/>
    <language>en</language>
    <item>
      <title>Hash-chained audit logs for AI agent actions, in 40 lines</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 15:00:17 +0000</pubDate>
      <link>https://dev.to/umangcirvix/hash-chained-audit-logs-for-ai-agent-actions-in-40-lines-3pfn</link>
      <guid>https://dev.to/umangcirvix/hash-chained-audit-logs-for-ai-agent-actions-in-40-lines-3pfn</guid>
      <description>&lt;p&gt;When an AI agent calls a tool — reads a file, applies a manifest, installs a package — you want an audit log you can trust weeks or months later. Tamper-evident doesn't require a blockchain or a signing service. A SHA-256 hash chain over append-only entries is enough to detect a rewrite.&lt;/p&gt;

&lt;p&gt;Here's a minimal TypeScript example you can drop into an MCP server, a tool proxy, or an agent harness.&lt;/p&gt;

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

&lt;p&gt;Each event captures the who, what, and when:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// unique id, e.g. ulid or uuid&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// epoch ms, set by the log&lt;/span&gt;
  &lt;span class="nl"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// agent / client identity&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// tool name or operation&lt;/span&gt;
  &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// canonicalized, redacted JSON string&lt;/span&gt;
  &lt;span class="nl"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;err&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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="nl"&gt;priorHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// hash of the previous event&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;priorHash&lt;/code&gt; is the chain. Event 0's &lt;code&gt;priorHash&lt;/code&gt; is a fixed genesis constant; every subsequent event's &lt;code&gt;priorHash&lt;/code&gt; is the SHA-256 of the previous event's serialized text.&lt;/p&gt;

&lt;h2&gt;
  
  
  The logger
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&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;GENESIS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0000000000000000000000000000000000000000000000000000000000000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;eventHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChainedLog&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&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="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Omit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuditEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;priorHash&lt;/span&gt;&lt;span class="dl"&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;AuditEvent&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;priorHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&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="nx"&gt;GENESIS&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;eventHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&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;1&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;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="nx"&gt;priorHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&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;entry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="cm"&gt;/** Serialize one event to the canonical line you'd write to a file. */&lt;/span&gt;
  &lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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="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;To make it append-only on disk, each &lt;code&gt;line(entry)&lt;/code&gt; is appended to a file with &lt;code&gt;O_APPEND&lt;/code&gt; (Node's &lt;code&gt;fs/promises&lt;/code&gt; &lt;code&gt;fd.appendFile&lt;/code&gt;), and no write removes or overwrites prior bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify
&lt;/h2&gt;

&lt;p&gt;Verification walks the chain forward and recomputes each hash. If any &lt;code&gt;priorHash&lt;/code&gt; in the log doesn't match the recomputed hash of its predecessor, the log was rewritten between those two entries.&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;function&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;badAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;expectedPrior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GENESIS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;events&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&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;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priorHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedPrior&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;badAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;expectedPrior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;eventHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&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="na"&gt;badAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;Usage is straightforward:&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;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChainedLog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evt-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read_file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"path":"README.md"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evt-2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kubectl_apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"manifest":"deploy.yaml","namespace":"prod"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outcome&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { valid: true, badAt: null }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone replaces &lt;code&gt;evt-1&lt;/code&gt;'s &lt;code&gt;action&lt;/code&gt; with &lt;code&gt;write_file&lt;/code&gt; or deletes &lt;code&gt;evt-2&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt; returns &lt;code&gt;badAt: 1&lt;/code&gt; on the first walk. The chain catches insertions, deletions, and in-place edits without any cryptography heavier than SHA-256.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things the chain doesn't do
&lt;/h2&gt;

&lt;p&gt;A hash chain makes rewriting detectable. It does not, by itself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anchor the chain in time or to a principal.&lt;/strong&gt; Bind the head or a periodic checkpoint to something outside the log — a signed attestation, a write to a remote append store, or a human-reviewed summary — and the chain becomes evidence rather than just a local checksum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevent the writer from appending whatever they like.&lt;/strong&gt; If the agent controls the logger, it can append favorable entries. That's a policy problem, not a hashing problem. The chain still proves that whatever is there wasn't altered afterward, which is the useful half.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to put it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In an MCP server: log every tool invocation and its outcome.&lt;/li&gt;
&lt;li&gt;In a tool proxy: log every forwarded call and the policy decision (allow / deny / hold).&lt;/li&gt;
&lt;li&gt;In an agent harness: log every tool grant and every approval the harness surfaced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The chain is the same everywhere. The identity in &lt;code&gt;caller&lt;/code&gt; is what makes the entries attributable.&lt;/p&gt;

&lt;p&gt;I'm building Cirvix AgentControl, an open-source default-deny policy layer for agent tool calls: &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;https://github.com/CIRVIX/agent-control&lt;/a&gt; (try &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt;).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>devops</category>
    </item>
    <item>
      <title>Securing MCP servers: a practical checklist for 2026</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 14:41:29 +0000</pubDate>
      <link>https://dev.to/umangcirvix/securing-mcp-servers-a-practical-checklist-for-2026-4jci</link>
      <guid>https://dev.to/umangcirvix/securing-mcp-servers-a-practical-checklist-for-2026-4jci</guid>
      <description>&lt;p&gt;MCP servers look harmless because they expose tools, not endpoints. A client calls a tool and gets a result. But the tools often touch a filesystem, a database, a package manager, or a deployment API. That makes the MCP server the authorization boundary, whether you planned it or not.&lt;/p&gt;

&lt;p&gt;Here are the five failure modes I've seen show up in production and a short checklist you can run through before exposing a server to agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool-definition drift
&lt;/h2&gt;

&lt;p&gt;The client decides what a tool does by reading its declaration: name, description, and parameter schema. If the declaration lies, or drifts from what the handler actually does, the client's policy is enforcing the wrong thing.&lt;/p&gt;

&lt;p&gt;Real examples: a tool declared as "read a file at this path" that silently tails a log stream; a "list packages" tool whose handler also accepts a &lt;code&gt;--resolve&lt;/code&gt; flag that performs installs; a parameter added server-side that the client's declaration doesn't mention yet the agent keeps sending.&lt;/p&gt;

&lt;p&gt;Mitigation: the client should pin the tool schema it authorized (hash it) and reject handlers whose live declaration diverges. Server-side, version the tool manifest and log every declaration change. Treat a declaration update the same way you treat a dependency bump — it's an authority surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over-broad filesystem tools
&lt;/h2&gt;

&lt;p&gt;A filesystem tool that accepts any path is a filesystem tool that accepts &lt;code&gt;/etc/shadow&lt;/code&gt;, &lt;code&gt;~/.ssh&lt;/code&gt;, and every mounted secret. I've seen MCP servers ship a single &lt;code&gt;read_file(path)&lt;/code&gt; and a single &lt;code&gt;write_file(path, content)&lt;/code&gt; and then rely on the client to pass safe paths.&lt;/p&gt;

&lt;p&gt;Clients are agents. Agents are given goals. If the goal is "fix this deploy" and the agent has a write_file that accepts any path, the shortest path to the goal sometimes writes to &lt;code&gt;/etc&lt;/code&gt; or to a directory that the CI pipeline reads from.&lt;/p&gt;

&lt;p&gt;Narrow the tools. Ship &lt;code&gt;read_project_file(glob)&lt;/code&gt; scoped to the repo root, &lt;code&gt;write_project_file(glob, content)&lt;/code&gt; scoped the same way, and a separate, explicitly-named tool for anything outside the project. Make the default "no" for the rest of the filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets in the environment
&lt;/h2&gt;

&lt;p&gt;MCP servers run as processes. Processes inherit environment variables. It is common to run an MCP server inside a container or a CI step that also carries &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;, or a signing key.&lt;/p&gt;

&lt;p&gt;An agent with a "run command" tool or a filesystem tool can read &lt;code&gt;/proc/self/environ&lt;/code&gt; or list the directory a shell writes &lt;code&gt;.env&lt;/code&gt; files to. The server didn't ship a "read secrets" tool, but the tools it did ship composed into one.&lt;/p&gt;

&lt;p&gt;Drop sensitive variables at the server boundary, not at the agent boundary. Run the MCP server with a minimal environment and pass only what its tools need. If a tool needs a credential, pass it through the server's own secret store, not through the process environment the agent can observe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Egress allowlists
&lt;/h2&gt;

&lt;p&gt;A tool that fetches a URL, installs a package, or calls a registry can turn an MCP server into a data exfiltration or supply-chain hop. The client authorized the tool; the tool's network destination is what determines the blast radius.&lt;/p&gt;

&lt;p&gt;Treat outbound connections from the MCP server like outbound connections from any service that runs untrusted input: an allowlist of destinations, and nothing else. Package installs go to the pinned registry. API calls go to the declared host. Everything else is dropped.&lt;/p&gt;

&lt;p&gt;You don't need a full egress proxy to get started. Even &lt;code&gt;iptables&lt;/code&gt; rules or a container runtime's network policy on the MCP server process surface is enough to make the difference between "the agent fetched a template" and "the agent phoned home with a config file."&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit logging
&lt;/h2&gt;

&lt;p&gt;You cannot defend what you cannot replay. Every tool invocation the server executes should be logged with: the calling client identity, the tool name, the input parameters (redacted for credentials), the outcome, and a server-side timestamp.&lt;/p&gt;

&lt;p&gt;Logs should be append-only after the fact and shipped out of the host the server runs on. If an agent has a filesystem tool on that host, it has a write tool on the log file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;Run this against every MCP server you expose to an agent before you let it run workloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Pin and hash each tool declaration on the client; reject handlers that drift.&lt;/li&gt;
&lt;li&gt;[ ] Replace blanket filesystem tools with path/glob-scoped tools rooted to the project.&lt;/li&gt;
&lt;li&gt;[ ] Strip secrets from the server's process environment; pass credentials only through the server's own store.&lt;/li&gt;
&lt;li&gt;[ ] Apply an egress allowlist to the server's outbound connections (registry + declared API hosts only).&lt;/li&gt;
&lt;li&gt;[ ] Log every tool invocation with caller identity, tool, redacted inputs, outcome, and server timestamp; ship logs off-host.&lt;/li&gt;
&lt;li&gt;[ ] Review the server's tool manifest after every update the same way you review a dependency bump.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP servers aren't untrusted in the browser sense. They're more like a CI runner that an agent can steer. Secure them like one.&lt;/p&gt;

&lt;p&gt;I'm building Cirvix AgentControl, an open-source default-deny policy layer for agent tool calls: &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;https://github.com/CIRVIX/agent-control&lt;/a&gt; (try &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt;).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why approval prompts don't work as a security boundary for coding agents</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 14:40:53 +0000</pubDate>
      <link>https://dev.to/umangcirvix/why-approval-prompts-dont-work-as-a-security-boundary-for-coding-agents-4h8e</link>
      <guid>https://dev.to/umangcirvix/why-approval-prompts-dont-work-as-a-security-boundary-for-coding-agents-4h8e</guid>
      <description>&lt;p&gt;When a coding agent asks a human to approve a file change, a database call, or a deploy, the approval prompt feels like a security boundary. It's not.&lt;/p&gt;

&lt;p&gt;In practice, approval prompts leak authority for three reasons: approval fatigue, approvals that never expire, and stale pending approvals that outlive the decision they capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval fatigue
&lt;/h2&gt;

&lt;p&gt;An agent that runs an entire PR lifecycle can surface dozens of prompts in a single session. Most of them are low-risk: formatting a config, reading a log, re-running tests. A few are high-risk: writing to a deployment manifest, touching secrets, changing network rules.&lt;/p&gt;

&lt;p&gt;When everything looks the same in the prompt, humans stop reading. They learn to click "Approve" to keep the agent moving and catch up later. That later is when the deploy went to the wrong environment and the rollback took thirty minutes.&lt;/p&gt;

&lt;p&gt;The fix isn't more prompts. It's making every prompt distinguishable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approvals that never expire
&lt;/h2&gt;

&lt;p&gt;Most agent toolkits record an approval as a boolean decision on a category of action: "approve kubectl apply" or "approve writes to /etc". There is no time bound attached.&lt;/p&gt;

&lt;p&gt;An approval granted at 9:00 AM should not authorize the same action at 4:00 PM, after the incident context has changed, the on-call has handed off, and the agent's mission scope has drifted.&lt;/p&gt;

&lt;p&gt;Without an expiry, the approval becomes a standing credential. The human reviewed a point-in-time description and unknowingly minted a persistent grant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stale pending approvals
&lt;/h2&gt;

&lt;p&gt;The flip side: a prompt sits in a queue while the human is on a call or asleep. Three hours later, the agent replays the pending request against a newer snapshot of the environment.&lt;/p&gt;

&lt;p&gt;The description the human approved no longer matches what would execute. The approval was sound when issued; it is unsound when fulfilled.&lt;/p&gt;

&lt;p&gt;This is why "pending approvals" and "authorized actions" are not the same thing. A pending approval captures a decision about a specific action at a specific time. Once that time window closes, the decision must be withdrawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  A better shape: expiring HOLD approvals bound to the exact action hash
&lt;/h2&gt;

&lt;p&gt;A practical fix that keeps humans in the loop without minting standing credentials:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Halt the action, don't queue it.&lt;/strong&gt; The agent emits a HOLD with a deterministic hash of exactly what would execute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind the approval to the hash, not the category.&lt;/strong&gt; The human's "yes" signs off on that hash. If anything about the inputs, target, or arguments changes, the hash changes and the approval doesn't apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach a short, non-extendable expiry.&lt;/strong&gt; Five to fifteen minutes is enough for a focused review; long enough that the human isn't forced to answer while a deployment countdown hits zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed on expiry.&lt;/strong&gt; When the timer expires, the HOLD transitions to DENY. The agent re-emits a fresh request with a fresh hash if it still wants the action.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is how Cirvix AgentControl structures an approval:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;actionHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;HoldApproval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;actionHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// epoch ms&lt;/span&gt;
  &lt;span class="nl"&gt;approvedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;approvedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HoldApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;requestedAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;boolean&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;freshHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;actionHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestedAction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionHash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;freshHash&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;
  &lt;span class="p"&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;call&lt;/span&gt; &lt;span class="o"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kubectl_apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deploy.yaml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod&lt;/span&gt;&lt;span class="dl"&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;hold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HoldApproval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hold-001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actionHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;actionHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;approvedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;human@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;approvedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;drifted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod-backup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;drifted&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three checks — status approved, hash matches the exact action, and current time is still inside the expiry — make the approval a verifiable ticket for one execution, not a standing credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes for the agent
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The agent's loop gains an explicit HOLD/DENY transition instead of silently retrying a pending queue.&lt;/li&gt;
&lt;li&gt;Tool wrappers compute the action hash before execution and refuse to run if the hash doesn't match the approval that cleared them.&lt;/li&gt;
&lt;li&gt;Observability becomes simpler: every approval event carries the hash and the expiry, so you can diff a denied execution against what was approved and see exactly which field drifted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What doesn't change
&lt;/h2&gt;

&lt;p&gt;Humans still review. The difference is that their review is scoped: a short window, a specific action, and a clear failure mode when the scope shifts.&lt;/p&gt;

&lt;p&gt;Prompt-based approvals are still useful as a UX. They just shouldn't be the boundary. The boundary is the hash-bound, time-bound decision that the enforcement layer verifies at execution.&lt;/p&gt;

&lt;p&gt;I'm building Cirvix AgentControl, an open-source default-deny policy layer for agent tool calls: &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;https://github.com/CIRVIX/agent-control&lt;/a&gt; (try &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt;).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>devops</category>
    </item>
    <item>
      <title>5 permission rules every coding agent should ship with</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:22:26 +0000</pubDate>
      <link>https://dev.to/umangcirvix/5-permission-rules-every-coding-agent-should-ship-with-19ih</link>
      <guid>https://dev.to/umangcirvix/5-permission-rules-every-coding-agent-should-ship-with-19ih</guid>
      <description>&lt;p&gt;Coding agents now run shell commands, edit files, and push branches on real repos. Most teams give them either everything or a pile of "are you sure?" prompts that people click through.&lt;/p&gt;

&lt;p&gt;There is a middle ground: a small set of rules that stop the handful of actions that actually cause incidents, and stay out of the way for everything else. Below are five I think every coding agent should ship with, written in the policy format of &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;Cirvix&lt;/a&gt;, an open-source runtime authorization layer that sits in the tool-call path of Claude Code, Cursor, Codex, and MCP clients. Each call gets &lt;strong&gt;ALLOW&lt;/strong&gt;, &lt;strong&gt;HOLD&lt;/strong&gt;, or &lt;strong&gt;DENY&lt;/strong&gt;, with deny as the default.&lt;/p&gt;

&lt;p&gt;Every snippet below validates with &lt;code&gt;cirvix policy check&lt;/code&gt; and is either taken from the repo's &lt;code&gt;policies/&lt;/code&gt; files or written in the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Deny reads of &lt;code&gt;.env&lt;/code&gt; and credential files
&lt;/h2&gt;

&lt;p&gt;Reading &lt;code&gt;.env&lt;/code&gt; is the shortest path from a prompt injection to a live credential. The agent does not need the raw value to do its job.&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;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-dotenv&lt;/span&gt;
  &lt;span class="s"&gt;tool = filesystem.read&lt;/span&gt;
  &lt;span class="s"&gt;path = **/.env&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Reading .env is the shortest path from a prompt injection to a live credential."&lt;/span&gt;
  &lt;span class="s"&gt;remediation = "Request the value as a handle&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secrets.get(\"NAME\")"&lt;/span&gt;

&lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-dotenv-variants&lt;/span&gt;
  &lt;span class="s"&gt;tool = filesystem.read&lt;/span&gt;
  &lt;span class="s"&gt;path = **/.env.*&lt;/span&gt;
  &lt;span class="s"&gt;reason = ".env.production and friends hold the credentials that matter most."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo's &lt;code&gt;secrets.policy&lt;/code&gt; extends this to &lt;code&gt;~/.aws&lt;/code&gt;, &lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;.kube/config&lt;/code&gt;, &lt;code&gt;.npmrc&lt;/code&gt;, &lt;code&gt;.netrc&lt;/code&gt; and Docker config. The &lt;code&gt;remediation&lt;/code&gt; line matters: it tells the agent what to do instead, so it does not just retry. If a secret-shaped read does slip through, a session taint rule then blocks external egress for the rest of that session.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. HOLD destructive shell in production
&lt;/h2&gt;

&lt;p&gt;Not every shell command is dangerous, and blocking all of them makes the agent useless. In production, though, a person should see it first.&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;require_approval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = hold-shell-in-production&lt;/span&gt;
  &lt;span class="s"&gt;tool = shell.exec&lt;/span&gt;
  &lt;span class="s"&gt;env = production&lt;/span&gt;
  &lt;span class="s"&gt;approvers = platform-oncall&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Shell in production is held for a named human."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;require_approval&lt;/code&gt; is a HOLD: the call does not fail, it waits on a named approver. By default it returns "pending" right away with an approval id, so the agent can say what it is waiting for instead of looking like a hung tool call. The default policy also ships &lt;code&gt;approve-high-risk-shell&lt;/code&gt; (&lt;code&gt;risk &amp;gt;= HIGH&lt;/code&gt;) and a flat deny on &lt;code&gt;rm -rf&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deny force-push
&lt;/h2&gt;

&lt;p&gt;Force-push discards commits other people may already have. The repo's default policy denies it outright:&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;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-history-rewrite&lt;/span&gt;
  &lt;span class="s"&gt;tool = shell.exec&lt;/span&gt;
  &lt;span class="s"&gt;command = "git push --force"&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Force-push discards commits other people may already have. Recoverable only if somebody still has the objects."&lt;/span&gt;

&lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-history-rewrite-short&lt;/span&gt;
  &lt;span class="s"&gt;tool = shell.exec&lt;/span&gt;
  &lt;span class="s"&gt;command = "git push -f"&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Same rule, short flag."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;command =&lt;/code&gt; compiles to a &lt;em&gt;contains&lt;/em&gt; match, so &lt;code&gt;git push -f origin main&lt;/code&gt; is caught. It is still a literal string match, so cover the variants you care about (the second rule is my addition) and prove it with a test block in the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"force push short flag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;
  &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git push -f origin main"&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="n"&gt;deny&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deny force-push everywhere rather than only on &lt;code&gt;main&lt;/code&gt;. An agent rewriting history on any shared branch is a bad day, and humans can still force-push themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep writes inside the workspace
&lt;/h2&gt;

&lt;p&gt;An agent scoped to a repo has no reason to write to &lt;code&gt;/etc/hosts&lt;/code&gt; or your shell profile.&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;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-workspace-escape-write&lt;/span&gt;
  &lt;span class="s"&gt;tool = filesystem.write&lt;/span&gt;
  &lt;span class="s"&gt;workspace = &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Writes outside the workspace root are outside what this run was scoped to change."&lt;/span&gt;

&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = allow-workspace-write&lt;/span&gt;
  &lt;span class="s"&gt;tool = filesystem.write&lt;/span&gt;
  &lt;span class="s"&gt;workspace = &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;workspace&lt;/code&gt; is decided &lt;em&gt;after&lt;/em&gt; path canonicalization: traversal like &lt;code&gt;./src/../../etc/hosts&lt;/code&gt; is collapsed and encoding tricks are normalised before the check, so the rule sees where the write actually lands. Deny always wins over allow, so a looser rule in another file cannot reopen it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make approvals expire
&lt;/h2&gt;

&lt;p&gt;A HOLD is only as good as its approval. "Yes, do that" means yes to the situation the approver was looking at, not to the same call next Tuesday.&lt;/p&gt;

&lt;p&gt;In Cirvix this is enforced by the approval queue rather than a policy line. The defaults from &lt;code&gt;src/core/approvals.mjs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An unanswered approval &lt;strong&gt;expires after 15 minutes&lt;/strong&gt; instead of hanging.&lt;/li&gt;
&lt;li&gt;A granted approval is &lt;strong&gt;spendable for 10 minutes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A grant is &lt;strong&gt;single use&lt;/strong&gt;: one yes authorises one execution.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;matched by fingerprint&lt;/strong&gt;, not by tool: approving a &lt;code&gt;database.write&lt;/code&gt; on one table does not release a &lt;code&gt;database.write&lt;/code&gt; on another.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pair it with a hold rule like the repo's:&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;require_approval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = approve-database-migrate&lt;/span&gt;
  &lt;span class="s"&gt;tool = database.migrate&lt;/span&gt;
  &lt;span class="s"&gt;approvers = platform-oncall&lt;/span&gt;
  &lt;span class="s"&gt;reason = "A migration changes the shape of data every other system reads."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and work the queue with &lt;code&gt;cirvix approvals&lt;/code&gt;, &lt;code&gt;cirvix approve&lt;/code&gt;, and &lt;code&gt;cirvix deny&lt;/code&gt;. Every transition goes into the local SHA-256 hash-chained audit log, so "what did that approval authorise?" has an answer afterwards.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @cirvix_ai/agent-control scan
cirvix policy check &lt;span class="nt"&gt;--policy&lt;/span&gt; cirvix.policy
cirvix policy &lt;span class="nb"&gt;test&lt;/span&gt;  &lt;span class="nt"&gt;--policy&lt;/span&gt; cirvix.policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero runtime dependencies, no phone-home, and it only enforces on calls routed through its MCP gateway or SDK, so check your client actually goes through it.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;https://github.com/CIRVIX/agent-control&lt;/a&gt;&lt;br&gt;
Site: &lt;a href="https://cirvix.com" rel="noopener noreferrer"&gt;https://cirvix.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What would be your sixth rule?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop the read-then-exfiltrate chain: session taint for AI agents</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:22:26 +0000</pubDate>
      <link>https://dev.to/umangcirvix/stop-the-read-then-exfiltrate-chain-session-taint-for-ai-agents-134f</link>
      <guid>https://dev.to/umangcirvix/stop-the-read-then-exfiltrate-chain-session-taint-for-ai-agents-134f</guid>
      <description>&lt;p&gt;Most AI agent incidents people worry about are not one bad tool call. They are two ordinary ones in a row.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent reads a file. Maybe it is &lt;code&gt;.env&lt;/code&gt;, maybe &lt;code&gt;config/credentials.yml&lt;/code&gt;, maybe a token pasted into a ticket it was asked to summarise.&lt;/li&gt;
&lt;li&gt;The agent makes an outbound HTTP request. Maybe to "fetch the docs", maybe to a URL that a prompt injection planted in a README.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each call, judged on its own, can look fine. Agents read files all day. Agents fetch URLs all day. A per-call allowlist that says "reads inside the workspace are OK" and "HTTP to public hosts is OK" will happily approve both, and the secret leaves in the second request's query string.&lt;/p&gt;

&lt;p&gt;This is the read-then-exfiltrate chain, and it is the reason a policy layer for agents needs at least a little memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why per-call rules are not enough
&lt;/h2&gt;

&lt;p&gt;Cirvix is an open-source runtime authorization layer that sits in the tool-call path of agents like Claude Code, Cursor, Codex, and anything speaking MCP. Every call gets a verdict: &lt;strong&gt;ALLOW&lt;/strong&gt;, &lt;strong&gt;HOLD&lt;/strong&gt; (wait for a named human), or &lt;strong&gt;DENY&lt;/strong&gt;. The default is deny.&lt;/p&gt;

&lt;p&gt;The obvious first rules are about &lt;em&gt;what&lt;/em&gt; is touched:&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;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-dotenv&lt;/span&gt;
  &lt;span class="s"&gt;tool = filesystem.read&lt;/span&gt;
  &lt;span class="s"&gt;path = **/.env&lt;/span&gt;
  &lt;span class="s"&gt;reason = "Reading .env is the shortest path from a prompt injection to a live credential."&lt;/span&gt;
  &lt;span class="s"&gt;remediation = "Request the value as a handle&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secrets.get(\"NAME\")"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those matter, and they catch the obvious case. But secret-shaped material turns up in places no path rule anticipated: a log file, a test fixture, a tool result. You cannot enumerate every place a credential might live. You &lt;em&gt;can&lt;/em&gt; notice that one was read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session taint: one bit, set once
&lt;/h2&gt;

&lt;p&gt;Cirvix keeps a session flag, &lt;code&gt;session.touchedSecret&lt;/code&gt;. It is set the moment a session successfully reads something matching &lt;code&gt;/secret|credential|token|password|\.env/i&lt;/code&gt;, and it never resets for that session.&lt;/p&gt;

&lt;p&gt;Policies can then condition on it. This is the real rule from the repo's starter set (&lt;code&gt;docs/examples/cirvix.policy.json&lt;/code&gt;):&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;"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;"deny-external-egress-after-secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"forbid"&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="s2"&gt;"http.request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"net.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resources"&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;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"when"&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;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"egress.external"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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="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;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"session.touchedSecret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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="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;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This session read secret material, so outbound requests to external destinations are blocked for the remainder of it."&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;And the same idea in the &lt;code&gt;.policy&lt;/code&gt; DSL, from &lt;code&gt;policies/network.policy&lt;/code&gt;:&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;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;name = deny-egress-after-secret-read&lt;/span&gt;
  &lt;span class="s"&gt;tool = network.request&lt;/span&gt;
  &lt;span class="s"&gt;touched_secret = &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="s"&gt;reason = "This session read secret-shaped material, so outbound requests are blocked for the remainder of it."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: &lt;em&gt;once this session has seen a secret, nothing it does may leave the machine to an external host.&lt;/em&gt; Both conditions must hold. A session that never touched a secret can still fetch docs. A session that did can still read, write, and run tests locally. It just cannot phone out.&lt;/p&gt;

&lt;p&gt;Deny wins over allow in Cirvix, always. So even if a later file adds &lt;code&gt;allow-allowlisted-egress&lt;/code&gt;, the taint rule still blocks the request. Composing policy files can only tighten, never loosen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent sees
&lt;/h2&gt;

&lt;p&gt;A refusal an agent cannot read is a refusal it cannot recover from, so the decision comes back structured: the verdict, the rule name, the reason, and a trace of every rule considered. In practice the agent gets "denied by &lt;code&gt;deny-external-egress-after-secret&lt;/code&gt;: this session read secret material", and it can tell the user why instead of retrying in a loop.&lt;/p&gt;

&lt;p&gt;Every decision is also written to a local audit log where each record carries a SHA-256 hash of the previous one, so you can check the chain is internally consistent with &lt;code&gt;cirvix audit verify&lt;/code&gt;. Nothing is sent anywhere; Cirvix has no phone-home and zero runtime dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secret handles do not taint
&lt;/h2&gt;

&lt;p&gt;The taint rule is blunt on purpose, and blunt rules need an escape hatch. If the agent genuinely needs a credential to do its job, it should not read the raw value at all. Cirvix's secret brokering gives the agent a handle (&lt;code&gt;secrets.get("STRIPE_KEY")&lt;/code&gt;) instead of the material. Spending a handle does &lt;strong&gt;not&lt;/strong&gt; set &lt;code&gt;touchedSecret&lt;/code&gt;, because the agent never held the secret. That is the whole point of a handle.&lt;/p&gt;

&lt;p&gt;So the healthy pattern is: raw reads of secret-shaped files taint and lock egress; brokered handles keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;Session taint is one specific two-step pattern, hard-coded as a boolean the engine tracks. It is not a general sequence language. You cannot yet write "deny step C if steps A and B happened in that order within five calls" or express arbitrary multi-step chains in policy. General multi-step sequence policies are still open work.&lt;/p&gt;

&lt;p&gt;A few more things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Taint is per session and, unless an embedder persists it, process-local. A restart starts a clean session.&lt;/li&gt;
&lt;li&gt;The pattern match on what counts as "secret-shaped" is a heuristic. Pair it with explicit path denies for the files you know about.&lt;/li&gt;
&lt;li&gt;Cirvix enforces on calls that are routed through it (the MCP gateway, the SDK guard, the wrappers). It is not machine-wide interception; an editor's built-in tools or a subprocess that bypasses the gateway are outside that boundary.&lt;/li&gt;
&lt;/ul&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @cirvix_ai/agent-control scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan looks at your local agent configuration and which credential paths are reachable. Then put the taint rule in your policy and test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cirvix policy check &lt;span class="nt"&gt;--policy&lt;/span&gt; cirvix.policy
cirvix policy &lt;span class="nb"&gt;test&lt;/span&gt;  &lt;span class="nt"&gt;--policy&lt;/span&gt; cirvix.policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;https://github.com/CIRVIX/agent-control&lt;/a&gt;&lt;br&gt;
Site: &lt;a href="https://cirvix.com" rel="noopener noreferrer"&gt;https://cirvix.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a real chain you want to express that taint does not cover, open an issue. That is exactly the input the sequence-policy work needs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>mcp</category>
    </item>
    <item>
      <title>MCP Security: Where to Put the Authorization Boundary</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:29:01 +0000</pubDate>
      <link>https://dev.to/umangcirvix/mcp-security-where-to-put-the-authorization-boundary-2j1k</link>
      <guid>https://dev.to/umangcirvix/mcp-security-where-to-put-the-authorization-boundary-2j1k</guid>
      <description>&lt;p&gt;An MCP tool call passes through the model, the client, possibly a proxy, and the server before anything happens. Only one of those places sees every call in structured form and is controlled by you rather than by the agent or a third party: the seam between client and servers. That is where an MCP security gateway belongs, and this page shows how to put one there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One seam&lt;/strong&gt;: client ↔ servers&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;server__tool&lt;/strong&gt;: namespaced per upstream&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default deny&lt;/strong&gt;: unlisted tools refused&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hash-chained&lt;/strong&gt;: audit per decision&lt;/p&gt;

&lt;p&gt;The question&lt;/p&gt;

&lt;h2&gt;
  
  
  Five places you could enforce. One that works.
&lt;/h2&gt;

&lt;p&gt;Every MCP security control is really a decision about where the authorization boundary sits.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cirvix.com/mcp-security.html" rel="noopener noreferrer"&gt;MCP security overview&lt;/a&gt; covers what goes wrong: broad-scope tools, poisoned tool descriptions, credentials leaking through results. This page answers the follow-up question teams ask once they accept that per-call policy is needed: &lt;em&gt;where&lt;/em&gt; should the decision be made?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the boundary sits&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;th&gt;Who controls it&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In the prompt or system message&lt;/td&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;The model, which can be talked out of it&lt;/td&gt;
&lt;td&gt;Advisory at best&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inside each MCP server&lt;/td&gt;
&lt;td&gt;Its own calls only&lt;/td&gt;
&lt;td&gt;Whoever wrote that server, often a third party&lt;/td&gt;
&lt;td&gt;Inconsistent across servers, no shared context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network or HTTP proxy&lt;/td&gt;
&lt;td&gt;Hosts and bytes; nothing for stdio servers&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;Misses local servers and argument meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gateway at the client–server seam&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Every routed &lt;code&gt;tools/call&lt;/code&gt;: server, tool, arguments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;You, independent of agent and server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The authorization boundary&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-process wrapper (&lt;code&gt;guard.wrap&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Calls to tools you own&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;Use for non-MCP tools alongside the gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prompt is the wrong place because the same channel that carries policy also carries injected instructions. The server is the wrong place because you will run servers you did not write, each with its own idea of safety, and none of them knows what the agent did on another server a moment ago. A network proxy is the wrong place because most local MCP servers speak stdio and never open a socket, and a proxy sees a hostname, not that the tool is &lt;code&gt;drop_table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The client–server seam has none of these problems. Every &lt;code&gt;tools/call&lt;/code&gt; crosses it as structured JSON-RPC with a server, a tool name and arguments. It sits outside the model, so nothing the model reads can rewrite it. And it is one place, so one policy governs every server behind it.&lt;/p&gt;

&lt;p&gt;Why the seam&lt;/p&gt;

&lt;h2&gt;
  
  
  What a gateway at the seam can do that nothing else can.
&lt;/h2&gt;

&lt;p&gt;From the Cirvix gateway's documented behavior and its public source.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluate before forwarding
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;tools/call&lt;/code&gt; is decoded, evaluated against policy, recorded and then forwarded, refused or held. Every governed agent action routed through Cirvix is evaluated before execution; a refused call never reaches the upstream server.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep servers apart
&lt;/h3&gt;

&lt;p&gt;Tool names are namespaced as &lt;code&gt;server__tool&lt;/code&gt;. Two servers that both expose &lt;code&gt;search&lt;/code&gt; stay distinct, so a rule written for one cannot silently govern the other. Rules read the server and tool as &lt;code&gt;mcp.server&lt;/code&gt; and &lt;code&gt;mcp.tool&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin tool definitions
&lt;/h3&gt;

&lt;p&gt;A tool description enters the model's context as instruction text supplied by the server. The gateway fingerprints each definition when it first sees it and withholds a tool whose definition later drifts from that pin.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Return denials the agent can read
&lt;/h3&gt;

&lt;p&gt;A refusal comes back as a tool result with the rule, reason and remediation, not as a transport error. The agent can re-plan instead of treating the server as broken.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  Record one chain for every server
&lt;/h3&gt;

&lt;p&gt;Decisions from every upstream land in the same SHA-256 hash-chained audit log, and results are scanned on the way back.&lt;/p&gt;

&lt;p&gt;Setup&lt;/p&gt;

&lt;h2&gt;
  
  
  The artifact: a gateway in front of two servers.
&lt;/h2&gt;

&lt;p&gt;Three files. Upstreams in one, the client's gateway-only map in another, policy in the third.&lt;/p&gt;

&lt;p&gt;mcp-upstreams.json — what the gateway connects tojson&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "mcpServers": {
    "github":   { "command": "npx", "args": ["-y", "&amp;lt;your-github-mcp-server&amp;gt;"],
                  "env": { "GITHUB_TOKEN": "&amp;lt;token&amp;gt;" } },
    "postgres": { "command": "npx", "args": ["-y", "&amp;lt;your-postgres-mcp-server&amp;gt;", "&amp;lt;connection-string&amp;gt;"] }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;.mcp.json (or your client's MCP config) — the only server the client seesjson&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "mcpServers": {
    "cirvix": {
      "command": "cirvix",
      "args": ["gateway",
               "--servers", "/abs/path/mcp-upstreams.json",
               "--policy",  "/abs/path/cirvix.policy.json",
               "--cwd",     "/abs/path/workspace"]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;cirvix.policy.json — per-server, per-tool rulesjson&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "rules": [
    { "name": "hold-github-writes", "effect": "hold",
      "when": [
        { "path": "mcp.server", "op": "eq", "value": "github" },
        { "path": "mcp.tool",   "op": "in", "value": ["create_pull_request", "merge_pull_request", "push_files"] }
      ],
      "approvers": ["maintainers"],
      "reason": "Writes to GitHub need a maintainer." },

    { "name": "allow-github-reads", "effect": "permit",
      "when": [
        { "path": "mcp.server", "op": "eq", "value": "github" },
        { "path": "mcp.tool",   "op": "in", "value": ["get_file_contents", "search_issues", "list_issues"] }
      ] },

    { "name": "allow-postgres-query", "effect": "permit",
      "when": [
        { "path": "mcp.server", "op": "eq", "value": "postgres" },
        { "path": "mcp.tool",   "op": "eq", "value": "query" }
      ] }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy names tools explicitly with &lt;code&gt;mcp.tool&lt;/code&gt; rather than relying on action classes like &lt;code&gt;fs.write&lt;/code&gt;. Cirvix classifies tool names into actions heuristically, which is useful for generic rules, but for third-party MCP tools an explicit tool list is clearer to review and cannot be surprised by a name the classifier reads differently. After your first real calls, check &lt;code&gt;cirvix logs&lt;/code&gt; to see exactly how each tool was normalized.&lt;/p&gt;

&lt;p&gt;Walkthrough&lt;/p&gt;

&lt;h2&gt;
  
  
  Four calls through the gateway.
&lt;/h2&gt;

&lt;p&gt;Output from &lt;code&gt;@cirvix_ai/agent-control&lt;/code&gt; 0.2.5 with the policy above, against stub upstream servers named &lt;code&gt;postgres&lt;/code&gt; and &lt;code&gt;github&lt;/code&gt;. Responses trimmed.&lt;/p&gt;

&lt;p&gt;JSON-RPC transcripttext&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# client -&amp;gt; gateway: tools/call  postgres__query  {"sql":"select count(*) from orders"}
&amp;lt;- result: "upstream ran query ..."                       # PERMIT  allow-postgres-query

# client -&amp;gt; gateway: tools/call  postgres__drop_table  {"table":"orders"}
&amp;lt;- isError: true
   "Denied by policy: default-deny
    No rule permits this call. The policy set is default-deny: an action must be explicitly allowed."
   _meta: { "cirvix/verdict": "deny", "cirvix/rule": null, "cirvix/decision_id": "dec_..." }

# client -&amp;gt; gateway: tools/call  github__create_pull_request  {"title":"Bump deps"}
&amp;lt;- isError: true
   "Held for human approval: hold-github-writes
    Writes to GitHub need a maintainer.
    Waiting on: maintainers"
   _meta: { "cirvix/verdict": "hold", "cirvix/rule": "hold-github-writes", "cirvix/approval_id": "apr_..." }

# client -&amp;gt; gateway: tools/call  github__get_file_contents  {"path":"README.md"}
&amp;lt;- result: "upstream ran get_file_contents ..."           # PERMIT  allow-github-reads

$ cirvix audit verify      # Hash chain intact · 4 records verified
$ cirvix approvals         # 1 call waiting: create_pull_request, policy hold-github-writes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The read query runs because &lt;code&gt;allow-postgres-query&lt;/code&gt; names it. &lt;code&gt;drop_table&lt;/code&gt; is refused with &lt;code&gt;rule: null&lt;/code&gt;: nobody wrote a rule forbidding it, and nobody had to, because nothing permits it. The pull request is held for &lt;code&gt;maintainers&lt;/code&gt; and shows up in &lt;code&gt;cirvix approvals&lt;/code&gt;; approving records a single-use grant for a retry, and nothing resumes on its own. The file read passes. Four calls, four decisions, one verified chain.&lt;/p&gt;

&lt;p&gt;Limits&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gateway does not cover.
&lt;/h2&gt;

&lt;p&gt;Be exact about this in your threat model.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Calls that don't route through it
&lt;/h3&gt;

&lt;p&gt;Direct upstream entries left in a client config, editor built-in tools, unwrapped callables and arbitrary subprocesses are outside the boundary.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  General network egress
&lt;/h3&gt;

&lt;p&gt;The gateway speaks MCP. Setting &lt;code&gt;HTTP_PROXY&lt;/code&gt; does not route ordinary agent traffic through its policy engine.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;

&lt;p&gt;It does not replace MCP authentication or the server's own credential checks. It adds a per-call decision on top of them.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt injection itself
&lt;/h3&gt;

&lt;p&gt;It does not detect injected instructions. It limits what an injected agent can get the servers to do. The &lt;a href="https://cirvix.com/ai-agent-authorization.html" rel="noopener noreferrer"&gt;AI agent authorization walkthrough&lt;/a&gt; shows the same rules applied to non-MCP calls.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  Live policy reload
&lt;/h3&gt;

&lt;p&gt;Rules are chosen when the gateway starts. Plan a controlled restart after a policy change.&lt;/p&gt;

&lt;p&gt;Deployment checklist&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting an MCP security gateway in front of your servers.
&lt;/h2&gt;

&lt;p&gt;Ten steps. The full command reference is in the &lt;a href="https://cirvix.com/guides/mcp.html" rel="noopener noreferrer"&gt;MCP guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Inventory your MCP servers
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt;. It reads known client configs and flags &lt;code&gt;mcp-broad-scope&lt;/code&gt;, &lt;code&gt;mcp-inline-secrets&lt;/code&gt; and &lt;code&gt;mcp-duplicated&lt;/code&gt;. A server with filesystem scope &lt;code&gt;/&lt;/code&gt; is the first one to put behind policy; see &lt;a href="https://cirvix.com/mcp-security-broad-scope.html" rel="noopener noreferrer"&gt;broad filesystem scope&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Move upstreams into their own file
&lt;/h3&gt;

&lt;p&gt;Copy the server entries into &lt;code&gt;mcp-upstreams.json&lt;/code&gt;. The gateway accepts &lt;code&gt;mcpServers&lt;/code&gt;, &lt;code&gt;servers&lt;/code&gt; or a bare map, with &lt;code&gt;command&lt;/code&gt;/&lt;code&gt;args&lt;/code&gt;/&lt;code&gt;env&lt;/code&gt; for stdio or &lt;code&gt;url&lt;/code&gt;/&lt;code&gt;headers&lt;/code&gt; for HTTP upstreams. Never point the gateway at the client's own gateway-only file.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Install the CLI globally
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npm install -g @cirvix_ai/agent-control&lt;/code&gt;. A project-local install is generally not on your editor's &lt;code&gt;PATH&lt;/code&gt;, so the client could not launch &lt;code&gt;cirvix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the gateway the only server the client launches
&lt;/h3&gt;

&lt;p&gt;Replace the client's server map with the single &lt;code&gt;cirvix&lt;/code&gt; entry and absolute paths. Any direct upstream entry left behind is an ungoverned route.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  Write rules per server and per tool
&lt;/h3&gt;

&lt;p&gt;Scope permits with &lt;code&gt;mcp.server&lt;/code&gt; and &lt;code&gt;mcp.tool&lt;/code&gt;. Unlisted tools on listed servers, and every tool on unlisted servers, fall through to default deny.&lt;/p&gt;

&lt;p&gt;06&lt;/p&gt;

&lt;h3&gt;
  
  
  Hold the writes that matter
&lt;/h3&gt;

&lt;p&gt;Merges, pushes, migrations, payments: use &lt;code&gt;hold&lt;/code&gt; with named &lt;code&gt;approvers&lt;/code&gt; rather than a blanket permit or a blanket deny.&lt;/p&gt;

&lt;p&gt;07&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate and test before restarting
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;cirvix policy check&lt;/code&gt; and &lt;code&gt;cirvix policy test&lt;/code&gt;. Gateway rules are loaded once at startup, so restart the client deliberately after a policy change.&lt;/p&gt;

&lt;p&gt;08&lt;/p&gt;

&lt;h3&gt;
  
  
  Prove routing with real calls
&lt;/h3&gt;

&lt;p&gt;From the client, make one call you expect to pass and one you expect to be refused. Then &lt;code&gt;cirvix logs&lt;/code&gt; and &lt;code&gt;cirvix audit verify&lt;/code&gt;: expect both decision IDs and a non-zero record count. Starting the gateway is not proof that the client uses it.&lt;/p&gt;

&lt;p&gt;09&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep HTTP mode on loopback
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cirvix gateway --http --host 127.0.0.1 --port 8787&lt;/code&gt; serves Streamable HTTP. Do not expose an unauthenticated gateway on a public interface; non-loopback use needs a token and a reviewed TLS and network boundary.&lt;/p&gt;

&lt;p&gt;10&lt;/p&gt;

&lt;h3&gt;
  
  
  Cover what the gateway can't see
&lt;/h3&gt;

&lt;p&gt;Built-in editor tools and subprocesses are outside the boundary. Restrict them with host and editor permissions, and wrap any non-MCP tools you own with &lt;code&gt;guard.wrap&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;FAQ&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP security gateways, asked directly.
&lt;/h2&gt;

&lt;p&gt;Short answers.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an MCP security gateway?
&lt;/h3&gt;

&lt;p&gt;A process that sits between an MCP client and its upstream MCP servers, presents itself to the client as a single server, and evaluates each &lt;code&gt;tools/call&lt;/code&gt; against policy before forwarding it. Cirvix runs one locally with &lt;code&gt;cirvix gateway&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Is an MCP gateway the same as MCP authentication or OAuth?
&lt;/h3&gt;

&lt;p&gt;No. Authentication establishes who is on each side of a connection and which credentials a server accepts. A gateway decides whether each specific call may execute. You want both: authentication on the connection, authorization on every call.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I just use an HTTP proxy for MCP security?
&lt;/h3&gt;

&lt;p&gt;Not for stdio servers, which never touch the network, and not for decisions about tool arguments, which a network proxy sees only as bytes. The Cirvix gateway speaks MCP; it is not an &lt;code&gt;HTTP_PROXY&lt;/code&gt; egress proxy and does not govern ordinary agent HTTP traffic.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the gateway protect editor built-in tools?
&lt;/h3&gt;

&lt;p&gt;No. Every governed agent action routed through Cirvix is evaluated before execution, and built-in editor tools, direct upstream entries and arbitrary subprocesses do not route through the gateway. Use host permissions for those, and &lt;code&gt;guard.wrap&lt;/code&gt; for tools you own.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know my client is actually using the gateway?
&lt;/h3&gt;

&lt;p&gt;Remove every direct upstream entry from the client config, generate a benign call and a call you expect to be denied, then run &lt;code&gt;cirvix logs&lt;/code&gt; and &lt;code&gt;cirvix audit verify&lt;/code&gt;. You should see both decision IDs and a non-zero record count.&lt;/p&gt;

&lt;p&gt;Next step&lt;/p&gt;

&lt;h2&gt;
  
  
  Put one MCP server behind the gateway.
&lt;/h2&gt;

&lt;p&gt;Start with the server that has the broadest scope, watch the first deny land, then expand. The MCP guide walks through install, connect, policy, test and verify.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cirvix.com/guides/mcp.html" rel="noopener noreferrer"&gt;Read the MCP guide →&lt;/a&gt;&lt;a href="https://cirvix.com/runtime-authorization-for-ai-agents.html" rel="noopener noreferrer"&gt;Runtime authorization guide&lt;/a&gt;&lt;a href="https://cirvix.com/mcp-security.html" rel="noopener noreferrer"&gt;MCP security overview&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://cirvix.com" rel="noopener noreferrer"&gt;cirvix.com&lt;/a&gt;. Cirvix is open source: try it locally with &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt; or see &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;github.com/CIRVIX/agent-control&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>AI Agent Authorization: Control What Agents Can Do</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:22:31 +0000</pubDate>
      <link>https://dev.to/umangcirvix/ai-agent-authorization-control-what-agents-can-do-21o</link>
      <guid>https://dev.to/umangcirvix/ai-agent-authorization-control-what-agents-can-do-21o</guid>
      <description>&lt;p&gt;AI agent authorization decides whether a specific agent may take a specific action on a specific resource, in the current context, before the action runs. Credentials say what an agent &lt;em&gt;can&lt;/em&gt; reach. Authorization says what it &lt;em&gt;may&lt;/em&gt; do with that reach, one call at a time. Below is a worked policy and six real requests traced to their verdicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4 inputs&lt;/strong&gt;: agent · action · resource · context&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 verdicts&lt;/strong&gt;: permit / hold / deny&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default deny&lt;/strong&gt;: rule: null&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remediation&lt;/strong&gt;: agents can re-plan&lt;/p&gt;

&lt;p&gt;The gap&lt;/p&gt;

&lt;h2&gt;
  
  
  Credentials grant reach. Nobody is deciding use.
&lt;/h2&gt;

&lt;p&gt;Most agents run with someone else's permissions and no per-call decision at all.&lt;/p&gt;

&lt;p&gt;When you start a coding agent, it inherits your shell, your cloud profile, your SSH keys and your repository access. When you connect it to an MCP server, every tool on that server becomes available to every prompt the agent reads. The question "should this particular call happen?" is never asked. If the call is technically possible, it runs.&lt;/p&gt;

&lt;p&gt;That is fine until the agent reads a poisoned pull request title, misunderstands a task, or chains two harmless steps into a harmful one. At that point the only thing standing between the model's choice and the side effect is whatever you configured weeks ago. AI agent authorization adds the missing step: a deterministic decision on each call, made outside the model, that the model cannot argue with.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Granted to&lt;/th&gt;
&lt;th&gt;Decides per call?&lt;/th&gt;
&lt;th&gt;Sees resource and context?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API key / OAuth scope&lt;/td&gt;
&lt;td&gt;A credential&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RBAC role&lt;/td&gt;
&lt;td&gt;An identity&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System-prompt rules&lt;/td&gt;
&lt;td&gt;The model&lt;/td&gt;
&lt;td&gt;The model decides&lt;/td&gt;
&lt;td&gt;Only as text it can be talked out of&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent authorization policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Each agent, each call&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Identity still matters: a policy can only scope by agent if each agent has a distinct name rather than a shared key. Our page on &lt;a href="https://cirvix.com/agent-iam.html" rel="noopener noreferrer"&gt;agent IAM&lt;/a&gt; covers that half.&lt;/p&gt;

&lt;p&gt;The model&lt;/p&gt;

&lt;h2&gt;
  
  
  Four inputs, three verdicts.
&lt;/h2&gt;

&lt;p&gt;Cirvix rules match on exactly the things an authorization decision needs.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent
&lt;/h3&gt;

&lt;p&gt;Who is asking. Rules use the &lt;code&gt;agents&lt;/code&gt; glob. One permit for &lt;code&gt;release-bot&lt;/code&gt; says nothing about &lt;code&gt;pr-triage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Action
&lt;/h3&gt;

&lt;p&gt;What kind of operation: &lt;code&gt;fs.read&lt;/code&gt;, &lt;code&gt;k8s.apply&lt;/code&gt;, &lt;code&gt;db.migrate&lt;/code&gt;. Tool names are classified into actions heuristically; unknown tools keep a &lt;code&gt;mcp.&amp;lt;server&amp;gt;.&amp;lt;tool&amp;gt;&lt;/code&gt; or &lt;code&gt;tool.&amp;lt;name&amp;gt;&lt;/code&gt; identity, so you can always target one tool by name.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource
&lt;/h3&gt;

&lt;p&gt;What the action touches, canonicalized before matching so traversal, relative paths and case differences resolve to one value.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Context
&lt;/h3&gt;

&lt;p&gt;Conditions in &lt;code&gt;when&lt;/code&gt;: &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;path.insideWorkspace&lt;/code&gt;, &lt;code&gt;egress.external&lt;/code&gt;, &lt;code&gt;egress.allowlisted&lt;/code&gt;, &lt;code&gt;session.touchedSecret&lt;/code&gt;, &lt;code&gt;mcp.server&lt;/code&gt;, &lt;code&gt;mcp.tool&lt;/code&gt;. Conditions are data, never code.&lt;/p&gt;

&lt;p&gt;Every request resolves to one verdict. &lt;strong&gt;Permit&lt;/strong&gt; lets the call run. &lt;strong&gt;Hold&lt;/strong&gt; refuses it until a named approver signs off. &lt;strong&gt;Deny&lt;/strong&gt; refuses it, with a reason and a remediation. Precedence is fixed: forbid beats hold, hold beats permit, and no match is a deny. The full grammar is on the &lt;a href="https://cirvix.com/policy-engine.html" rel="noopener noreferrer"&gt;policy engine&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;Walkthrough&lt;/p&gt;

&lt;h2&gt;
  
  
  Six requests, one policy, every verdict explained.
&lt;/h2&gt;

&lt;p&gt;A release agent that may read the repo, deploy to staging on its own, and deploy to production only with approval.&lt;/p&gt;

&lt;p&gt;cirvix.policy.jsonjson&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "rules": [
    { "name": "deny-dotenv-read", "effect": "forbid",
      "actions": ["fs.*"], "resources": ["**/.env", "**/.env.*"],
      "reason": "Reading .env files is denied outside an approved secrets flow.",
      "remediation": "Request the deploy token as a secret handle." },

    { "name": "deny-egress-after-secret", "effect": "forbid",
      "when": [
        { "path": "session.touchedSecret", "op": "eq", "value": true },
        { "path": "egress.external", "op": "eq", "value": true },
        { "path": "egress.allowlisted", "op": "eq", "value": false }
      ],
      "reason": "This session has read secret material; external egress is closed." },

    { "name": "hold-prod-deploys", "effect": "hold",
      "agents": ["release-bot"], "actions": ["k8s.apply"],
      "when": [{ "path": "environment", "op": "eq", "value": "production" }],
      "approvers": ["release-managers"],
      "reason": "Production deploy. A release manager must approve." },

    { "name": "allow-staging-deploys", "effect": "permit",
      "agents": ["release-bot"], "actions": ["k8s.apply"],
      "when": [{ "path": "environment", "op": "eq", "value": "staging" }] },

    { "name": "allow-workspace-read", "effect": "permit",
      "actions": ["fs.read", "fs.list"],
      "when": [{ "path": "path.insideWorkspace", "op": "eq", "value": true }] }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Deciding rule&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;release-bot&lt;/code&gt; · &lt;code&gt;fs.read&lt;/code&gt; · &lt;code&gt;src/app.ts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PERMIT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allow-workspace-read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The canonical path resolves inside the workspace and no forbid or hold matches.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;release-bot&lt;/code&gt; · &lt;code&gt;fs.read&lt;/code&gt; · &lt;code&gt;.env.production&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DENY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deny-dotenv-read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The file is inside the workspace, so the permit also matches, but forbid always wins. The agent receives the remediation and can ask for a handle instead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;release-bot&lt;/code&gt; · &lt;code&gt;k8s.apply&lt;/code&gt; · &lt;code&gt;production/checkout&lt;/code&gt; · env &lt;code&gt;production&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;HOLD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hold-prod-deploys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Consequential and environment-specific. The call is refused pending approval by &lt;code&gt;release-managers&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;release-bot&lt;/code&gt; · &lt;code&gt;k8s.apply&lt;/code&gt; · &lt;code&gt;staging/checkout&lt;/code&gt; · env &lt;code&gt;staging&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PERMIT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allow-staging-deploys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same action, different context, different answer. This is the decision a static scope cannot make.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pr-triage&lt;/code&gt; · &lt;code&gt;k8s.apply&lt;/code&gt; · &lt;code&gt;staging/checkout&lt;/code&gt; · env &lt;code&gt;staging&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DENY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none (&lt;code&gt;rule: null&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;The permit names &lt;code&gt;release-bot&lt;/code&gt; only. Nothing matches another agent, so default deny applies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;release-bot&lt;/code&gt; · outbound POST to &lt;code&gt;https://paste.example.net&lt;/code&gt;, after reading &lt;code&gt;deploy/token.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DENY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deny-egress-after-secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reading a file whose path looks like a token sets &lt;code&gt;session.touchedSecret&lt;/code&gt;, which never resets for the session. Each call was fine alone; the sequence is not.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three lessons fall out of the table. First, the order you write rules in does not create holes: row 2 shows a broad permit losing to a narrow forbid. Second, context, not the tool, carries the risk: rows 3 and 4 are the same tool call with different answers. Third, default deny is what keeps a policy small: you never had to write "pr-triage may not deploy" for row 5 to be refused.&lt;/p&gt;

&lt;p&gt;Test an action&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce the walkthrough yourself.
&lt;/h2&gt;

&lt;p&gt;No account, no agent, nothing executed. &lt;code&gt;check&lt;/code&gt; evaluates one hypothetical call.&lt;/p&gt;

&lt;p&gt;Terminalbash&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Reproduce rows 1-5 with the policy saved as cirvix.policy.json. Nothing executes.
P="--policy cirvix.policy.json"
npx @cirvix_ai/agent-control check $P --agent release-bot --action fs.read   --resource src/app.ts
npx @cirvix_ai/agent-control check $P --agent release-bot --action fs.read   --resource .env.production
npx @cirvix_ai/agent-control check $P --agent release-bot --action k8s.apply --resource production/checkout --env production
npx @cirvix_ai/agent-control check $P --agent release-bot --action k8s.apply --resource staging/checkout --env staging
npx @cirvix_ai/agent-control check $P --agent pr-triage   --action k8s.apply --resource staging/checkout --env staging
# Add --json to any line for the full decision record, including the "considered" trace.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; exits &lt;code&gt;1&lt;/code&gt; on deny and &lt;code&gt;0&lt;/code&gt; on permit or hold, so do not treat a zero exit as permission. It also builds a deliberately minimal context, with &lt;code&gt;egress.*&lt;/code&gt; and &lt;code&gt;session.touchedSecret&lt;/code&gt; set to false, which is why row 6 belongs in a test that passes context explicitly:&lt;/p&gt;

&lt;p&gt;policy.test.mjsjavascript&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// policy.test.mjs  —  run with: node --test policy.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { evaluate } from "@cirvix_ai/agent-control/testing";

test("production deploys by release-bot are held", async () =&amp;gt; {
  const d = await evaluate({ policyFile: "cirvix.policy.json", agent: "release-bot",
    action: "k8s.apply", resource: "production/checkout",
    context: { environment: "production" } });
  assert.equal(d.verdict, "hold");
  assert.deepEqual(d.approvers, ["release-managers"]);
});

// Row 6 needs session context, which `check` always sets to false.
// The rule matches on context, so the action name here is illustrative.
test("no external egress after a secret was read", async () =&amp;gt; {
  const d = await evaluate({ policyFile: "cirvix.policy.json", agent: "release-bot",
    action: "http.post", resource: "https://paste.example.net/upload",
    context: { session: { touchedSecret: true }, egress: { external: true, allowlisted: false } } });
  assert.equal(d.verdict, "deny");
  assert.equal(d.rule, "deny-egress-after-secret");  // not just default deny
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent experiences.
&lt;/h2&gt;

&lt;p&gt;Enforcement only counts if the agent's calls actually pass through it.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  A deny is a structured error
&lt;/h3&gt;

&lt;p&gt;Through &lt;code&gt;guard.wrap&lt;/code&gt;, a denied call throws &lt;code&gt;CirvixDenied&lt;/code&gt; carrying the rule name and a decision ID, and the tool function is never invoked. The remediation text is what lets the agent re-plan instead of retrying the same call.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  A hold is not a failure
&lt;/h3&gt;

&lt;p&gt;SDKs raise a distinct &lt;code&gt;CirvixHeld&lt;/code&gt;. An approver reviews pending calls with &lt;code&gt;cirvix approvals&lt;/code&gt;; approval records a single-use grant for a later retry. Nothing resumes automatically, and local approval records name a reviewer rather than carrying an authenticated signature.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Every decision can be recorded
&lt;/h3&gt;

&lt;p&gt;With an audit sink configured, decisions land in a SHA-256 hash-chained log. &lt;code&gt;cirvix why &amp;lt;decision-id&amp;gt;&lt;/code&gt; explains one decision, and &lt;code&gt;cirvix audit verify&lt;/code&gt; checks the chain's internal consistency.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Routing is your job
&lt;/h3&gt;

&lt;p&gt;Register the &lt;em&gt;returned&lt;/em&gt; wrapped tools with your framework, and make the &lt;a href="https://cirvix.com/mcp-security-gateway.html" rel="noopener noreferrer"&gt;MCP gateway&lt;/a&gt; the only server your client launches. Every governed agent action routed through Cirvix is evaluated before execution; a tool the agent reaches directly is not.&lt;/p&gt;

&lt;p&gt;Pitfalls&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways agent authorization quietly fails.
&lt;/h2&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrapping tools but registering the originals
&lt;/h3&gt;

&lt;p&gt;The executor keeps calling unwrapped functions and nothing is enforced. Check by triggering a known deny.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaving a direct MCP entry next to the gateway
&lt;/h3&gt;

&lt;p&gt;The agent has two routes and only one is governed. Remove upstream entries from the client config.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing rules against raw strings
&lt;/h3&gt;

&lt;p&gt;Cirvix canonicalizes resources, but your globs still need to match the canonical form. Inspect &lt;code&gt;check --json&lt;/code&gt; output to see the resource the engine compared.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Holds with nobody to approve them
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;validateRules&lt;/code&gt; flags a hold without &lt;code&gt;approvers&lt;/code&gt;. A hold nobody watches becomes a deny that frustrates users into loosening policy.&lt;/p&gt;

&lt;p&gt;FAQ&lt;/p&gt;

&lt;h2&gt;
  
  
  AI agent authorization, asked directly.
&lt;/h2&gt;

&lt;p&gt;Short answers.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  What is AI agent authorization?
&lt;/h3&gt;

&lt;p&gt;Deciding whether a specific AI agent may perform a specific action on a specific resource in the current context, and enforcing that decision before the action runs. It is narrower than authentication, which establishes who is calling, and finer-grained than the scopes on the agent's credentials.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Why aren't OAuth scopes enough to authorize an agent?
&lt;/h3&gt;

&lt;p&gt;Scopes are granted to a credential once and apply to every call made with it. They cannot see the resource of a single call, the environment, or what the agent did earlier in the session. Agent authorization evaluates each call with that context.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  What should happen when an agent is denied?
&lt;/h3&gt;

&lt;p&gt;The agent should get a structured refusal it can act on: the rule that matched, a reason and a remediation that names the legitimate path. Cirvix SDKs raise &lt;code&gt;CirvixDenied&lt;/code&gt; for a deny and a distinct &lt;code&gt;CirvixHeld&lt;/code&gt; for a hold, so an agent does not abandon work a person is about to approve.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test an authorization rule without running the agent?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;npx @cirvix_ai/agent-control check --action &amp;lt;action&amp;gt; --resource &amp;lt;resource&amp;gt;&lt;/code&gt; for a single hypothetical call, and &lt;code&gt;evaluate()&lt;/code&gt; from &lt;code&gt;@cirvix_ai/agent-control/testing&lt;/code&gt; inside &lt;code&gt;node --test&lt;/code&gt; for assertions in CI. Neither executes a tool.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cirvix authorize every action an agent takes?
&lt;/h3&gt;

&lt;p&gt;Every governed agent action routed through Cirvix is evaluated before execution. That means calls made through the returned SDK wrappers, the MCP gateway or the local control socket. Editor built-in tools, unwrapped functions and arbitrary subprocesses are outside the boundary.&lt;/p&gt;

&lt;p&gt;Try it on your own action&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the call you are most nervous about. Test it.
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npx @cirvix_ai/agent-control check&lt;/code&gt; against the action your agent should never take unattended, then put that agent's tools behind policy.&lt;/p&gt;

&lt;p&gt;Test an action →&lt;a href="https://cirvix.com/docs.html" rel="noopener noreferrer"&gt;Read the quickstart&lt;/a&gt;&lt;a href="https://cirvix.com/pricing.html" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://cirvix.com" rel="noopener noreferrer"&gt;cirvix.com&lt;/a&gt;. Cirvix is open source: try it locally with &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt; or see &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;github.com/CIRVIX/agent-control&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>Runtime Authorization for AI Agents: A Practical Guide</title>
      <dc:creator>Umang Kumar</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:21:56 +0000</pubDate>
      <link>https://dev.to/umangcirvix/runtime-authorization-for-ai-agents-a-practical-guide-5dbn</link>
      <guid>https://dev.to/umangcirvix/runtime-authorization-for-ai-agents-a-practical-guide-5dbn</guid>
      <description>&lt;p&gt;Runtime authorization for AI agents is a decision made on every tool call, after the agent proposes it and before it runs: may this agent perform this action on this resource, here, now? This guide covers what that decision needs, where to enforce it, and a checklist for putting your first agent under policy with Cirvix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PERMIT / HOLD / DENY&lt;/strong&gt;: one verdict per call&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default deny&lt;/strong&gt;: no rule, no execution&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forbid wins&lt;/strong&gt;: permits can't override&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apache-2.0&lt;/strong&gt;: runs locally&lt;/p&gt;

&lt;p&gt;Definition&lt;/p&gt;

&lt;h2&gt;
  
  
  What runtime authorization means for an agent.
&lt;/h2&gt;

&lt;p&gt;Capability is not authority. An agent that can call a tool has not been authorized to call it with these arguments.&lt;/p&gt;

&lt;p&gt;An AI agent turns text into consequences: it reads files, queries databases and calls MCP tools and APIs, usually with the credentials of whoever launched it. Most stacks decide what an agent may touch once, at configuration time. After that, every individual call is trusted by default.&lt;/p&gt;

&lt;p&gt;Runtime authorization moves the decision to the moment of action. The agent proposes a structured request (who is asking, which action, which resource, in what context), a deterministic policy engine evaluates it, and the tool runs only if the verdict allows it. With Cirvix, every governed agent action routed through Cirvix is evaluated before execution, and the decision can be recorded in a SHA-256 hash-chained audit log.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;When it decides&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;th&gt;What it can't do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authentication / IAM&lt;/td&gt;
&lt;td&gt;When a credential is issued&lt;/td&gt;
&lt;td&gt;Identity and scopes&lt;/td&gt;
&lt;td&gt;Judge a single call's resource or context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt guardrails&lt;/td&gt;
&lt;td&gt;When text goes in or out of the model&lt;/td&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;Stop a tool call the model has already chosen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post-hoc monitoring&lt;/td&gt;
&lt;td&gt;After execution&lt;/td&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;Undo a side effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime authorization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Before each governed call executes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Agent, action, canonical resource, context&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Evaluate calls that bypass its enforcement point&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These layers compose. Keep IAM and prompt defenses, and add runtime authorization where side effects happen. The category overview is on the &lt;a href="https://cirvix.com/ai-agent-security.html" rel="noopener noreferrer"&gt;AI-agent security&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;Why runtime&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the decision has to happen at runtime.
&lt;/h2&gt;

&lt;p&gt;Three properties of agents break configuration-time permissions.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent inputs are untrusted by design
&lt;/h3&gt;

&lt;p&gt;Pull request titles, web pages, documents and tool results can all carry instructions the model will follow. You cannot vet the inputs, so you have to evaluate the actions they produce.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  The same tool means different things in different contexts
&lt;/h3&gt;

&lt;p&gt;Writing a file in a scratch workspace is routine. Applying a manifest in production is not. A static grant cannot tell the two apart; a rule with a &lt;code&gt;when&lt;/code&gt; condition on &lt;code&gt;environment&lt;/code&gt; can.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Harm comes from sequences
&lt;/h3&gt;

&lt;p&gt;Reading a credential and making an outbound request are each ordinary. Together they are exfiltration. Cirvix tracks &lt;code&gt;session.touchedSecret&lt;/code&gt;, so the starter rule &lt;code&gt;deny-external-egress-after-secret&lt;/code&gt; refuses external egress for the rest of a session that has read secret-shaped material.&lt;/p&gt;

&lt;p&gt;Anatomy&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of one decision.
&lt;/h2&gt;

&lt;p&gt;Request in, canonical resource, ordered rules, exactly one verdict, an explanation out.&lt;/p&gt;

&lt;p&gt;Each request carries an agent name, an action such as &lt;code&gt;fs.read&lt;/code&gt; or &lt;code&gt;k8s.apply&lt;/code&gt;, a resource, and context (environment, whether the path is inside the workspace, whether the session has touched a secret, which MCP server and tool). Before any rule is compared, the resource is canonicalized: &lt;code&gt;./x/../.env&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt; and an absolute path to the same file become one resource.&lt;/p&gt;

&lt;p&gt;Then precedence does the rest:&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Forbid always wins
&lt;/h3&gt;

&lt;p&gt;A matching &lt;code&gt;forbid&lt;/code&gt; short-circuits evaluation. No permit, however specific, overrides it.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Hold outranks permit
&lt;/h3&gt;

&lt;p&gt;If any rule says a human must see this call, another permissive rule cannot skip that person.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Permit allows the call
&lt;/h3&gt;

&lt;p&gt;Only an explicit permit lets the call run.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  No match means deny
&lt;/h3&gt;

&lt;p&gt;A request that matches nothing is denied with &lt;code&gt;rule: null&lt;/code&gt;. An empty rule set denies everything, so a policy that fails to load fails closed.&lt;/p&gt;

&lt;p&gt;The output is a decision record, not a boolean: &lt;code&gt;considered&lt;/code&gt; traces the rules examined, and &lt;code&gt;remediation&lt;/code&gt; gives the agent a legitimate path so it can re-plan instead of retrying.&lt;/p&gt;

&lt;p&gt;Decision returned by evaluate()json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "verdict": "deny",
  "rule": "deny-dotenv-read",
  "reason": "Reading .env files is denied outside an approved secrets flow.",
  "remediation": "Ask for the value as a secret handle instead.",
  "considered": [
    { "rule": "deny-dotenv-read", "effect": "forbid", "matched": true }
  ],
  "resource": "/workspace/.env.production"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enforcement points&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to enforce it.
&lt;/h2&gt;

&lt;p&gt;A decision nobody enforces is a log line. Cirvix enforces at three points, and each has an edge.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Enforcement point&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;guard.wrap&lt;/code&gt; (Node or Python SDK)&lt;/td&gt;
&lt;td&gt;You own the agent's tool functions&lt;/td&gt;
&lt;td&gt;Only the returned wrapped tools. Originals still held by an executor are not governed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cirvix gateway&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The agent uses MCP servers (Claude Code, Cursor, any MCP client)&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;tools/call&lt;/code&gt; requests routed through the gateway. Direct upstream entries and editor built-ins are outside it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cirvix runtime&lt;/code&gt; control socket&lt;/td&gt;
&lt;td&gt;A cooperating client speaks the local protocol&lt;/td&gt;
&lt;td&gt;Only clients that use the socket. Starting it does not instrument an existing agent.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cirvix does not intercept all activity on a machine. Subprocesses, unwrapped callables and host processes stay outside the boundary, so host permissions still matter. The smallest in-process example, where the denied call never reaches the tool function:&lt;/p&gt;

&lt;p&gt;guard.wrap: enforce in-processjavascript&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { readFile } from "node:fs/promises";
import { guard, CirvixDenied, STARTER_RULES } from "@cirvix_ai/agent-control";

// Register the RETURNED tools with your agent's executor, not the originals.
const tools = guard.wrap(
  { read_file: async ({ path }) =&amp;gt; readFile(path, "utf8") },
  { agent: "pr-triage", rules: STARTER_RULES },
);

await tools.read_file({ path: "src/index.mjs" });      // runs
try {
  await tools.read_file({ path: ".env.production" });  // refused before the tool is invoked
} catch (err) {
  if (!(err instanceof CirvixDenied)) throw err;
  console.log(err.policy, err.decisionId);             // "deny-dotenv-read", "dec_..."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Artifact&lt;/p&gt;

&lt;h2&gt;
  
  
  A starter policy for a coding agent.
&lt;/h2&gt;

&lt;p&gt;Two prohibitions, one hold, one scoped permit. Everything else is denied by default.&lt;/p&gt;

&lt;p&gt;cirvix.policy.jsonjson&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "rules": [
    {
      "name": "deny-dotenv-read",
      "effect": "forbid",
      "actions": ["fs.read", "fs.*"],
      "resources": ["**/.env", "**/.env.*"],
      "reason": "Reading .env files is denied outside an approved secrets flow.",
      "remediation": "Ask for the value as a secret handle instead."
    },
    {
      "name": "deny-workspace-escape",
      "effect": "forbid",
      "actions": ["fs.*"],
      "when": [{ "path": "path.insideWorkspace", "op": "eq", "value": false }]
    },
    {
      "name": "hold-prod-deploys",
      "effect": "hold",
      "actions": ["k8s.apply", "db.migrate"],
      "when": [{ "path": "environment", "op": "eq", "value": "production" }],
      "approvers": ["platform-oncall"],
      "reason": "Production change. Held for a named approver."
    },
    {
      "name": "allow-workspace-read-write",
      "effect": "permit",
      "actions": ["fs.read", "fs.list", "fs.write"],
      "when": [{ "path": "path.insideWorkspace", "op": "eq", "value": true }]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom the way the engine does. A read of &lt;code&gt;config/.env.local&lt;/code&gt; matches &lt;code&gt;deny-dotenv-read&lt;/code&gt; and is denied even though &lt;code&gt;allow-workspace-read-write&lt;/code&gt; would also match. A write to a file outside the workspace root is denied by &lt;code&gt;deny-workspace-escape&lt;/code&gt;. A &lt;code&gt;k8s.apply&lt;/code&gt; with &lt;code&gt;--env production&lt;/code&gt; is held for &lt;code&gt;platform-oncall&lt;/code&gt;. An outbound HTTP request, or any action you have not named, matches no rule and is denied with &lt;code&gt;rule: null&lt;/code&gt; until you write a permit for it. The full rule grammar, including all &lt;code&gt;when&lt;/code&gt; operators, is on the &lt;a href="https://cirvix.com/policy-engine.html" rel="noopener noreferrer"&gt;policy engine&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;Deployment checklist&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting your first agent under runtime authorization.
&lt;/h2&gt;

&lt;p&gt;Eight steps, in order. Each one has a check you can run.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  Inventory what you are about to govern
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt;, a local, heuristic inventory of agent runtimes, MCP configs and reachable credential paths (findings such as &lt;code&gt;runtime-ungoverned&lt;/code&gt; and &lt;code&gt;mcp-broad-scope&lt;/code&gt;). Treat it as a map, not proof of what a running agent can do.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick one agent and one enforcement point
&lt;/h3&gt;

&lt;p&gt;Start with a single agent: &lt;code&gt;guard.wrap&lt;/code&gt; if you own its tool code, &lt;code&gt;cirvix gateway&lt;/code&gt; if it uses MCP servers.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Start from the starter rules, then narrow
&lt;/h3&gt;

&lt;p&gt;With no workspace policy, Cirvix falls back to nine starter rules (print them with &lt;code&gt;cirvix policy --json&lt;/code&gt;). Copy what you need into &lt;code&gt;cirvix.policy.json&lt;/code&gt; and add the rules specific to this agent's job. Rule names appear in every decision, so make them meaningful.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate and test the policy like code
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;cirvix policy check --policy cirvix.policy.json&lt;/code&gt;, then write tests with &lt;code&gt;evaluate()&lt;/code&gt; from &lt;code&gt;@cirvix_ai/agent-control/testing&lt;/code&gt; and run them with &lt;code&gt;node --test&lt;/code&gt; in CI. Assert the denials and holds you care about, not only the happy path. For policy pull requests, &lt;code&gt;expectNoLoosening&lt;/code&gt; from the same module compares a before and after rule set.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  Wire enforcement so there is no side door
&lt;/h3&gt;

&lt;p&gt;Hand the framework the &lt;em&gt;returned&lt;/em&gt; wrapped tools. For MCP, keep upstreams in a separate &lt;code&gt;mcp-upstreams.json&lt;/code&gt; and make the gateway the only server the client launches. A direct upstream entry left in the client config is an ungoverned route.&lt;/p&gt;

&lt;p&gt;06&lt;/p&gt;

&lt;h3&gt;
  
  
  Give every hold a named approver
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;hold&lt;/code&gt; for consequential actions such as production deploys, migrations and deletes. &lt;code&gt;validateRules&lt;/code&gt; flags a hold without &lt;code&gt;approvers&lt;/code&gt;. Approvals are local records that authorize a retry; they are not authenticated signatures, and nothing resumes on its own.&lt;/p&gt;

&lt;p&gt;07&lt;/p&gt;

&lt;h3&gt;
  
  
  Turn on a record and verify it
&lt;/h3&gt;

&lt;p&gt;Supply an &lt;code&gt;AuditChain&lt;/code&gt; sink (Node) or an &lt;code&gt;on_decision&lt;/code&gt; callback (Python). Generate a benign allowed call and a denied one, then run &lt;code&gt;cirvix audit verify&lt;/code&gt; and &lt;code&gt;cirvix logs&lt;/code&gt;. Confirm the expected decision IDs and a non-zero record count, because an empty or missing file verifies as an empty chain.&lt;/p&gt;

&lt;p&gt;08&lt;/p&gt;

&lt;h3&gt;
  
  
  Expand one rule at a time
&lt;/h3&gt;

&lt;p&gt;Add the next agent or tool once the first runs cleanly. Because &lt;code&gt;forbid&lt;/code&gt; always wins, a new permit cannot open a hole through an existing prohibition.&lt;/p&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;h2&gt;
  
  
  Test a decision before you install anything.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;check&lt;/code&gt; evaluates one hypothetical call. It reads no file, runs no tool and writes no audit record.&lt;/p&gt;

&lt;p&gt;Terminalbash&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Node 20+. No account. Nothing is read or executed by `check`.
npx --yes @cirvix_ai/agent-control check --action fs.read --resource .env.production
#   DENY  fs.read /workspace/.env.production
#   rule    deny-dotenv-read

npx @cirvix_ai/agent-control check --action fs.read --resource src/index.mjs
#   PERMIT  fs.read /workspace/src/index.mjs
#   rule    allow-workspace-read          (exit 0)

# With the policy above, a production deploy is held, not run:
npx @cirvix_ai/agent-control check --policy cirvix.policy.json \
  --action k8s.apply --resource production/checkout --env production
#   HOLD  k8s.apply /workspace/production/checkout
#   rule    hold-prod-deploys
#   waits   platform-oncall                (exit 0: a hold is not permission)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the exit codes: &lt;code&gt;check&lt;/code&gt; exits &lt;code&gt;1&lt;/code&gt; on deny and &lt;code&gt;0&lt;/code&gt; on both permit and hold, so exit status alone is not authorization to execute. When you are ready to enforce, install the package with &lt;code&gt;npm install @cirvix_ai/agent-control&lt;/code&gt; (or &lt;code&gt;pip install cirvix&lt;/code&gt; for the Python evaluator) and follow the &lt;a href="https://cirvix.com/docs.html" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;. For MCP clients, the &lt;a href="https://cirvix.com/guides/mcp.html" rel="noopener noreferrer"&gt;MCP guide&lt;/a&gt; and our article on &lt;a href="https://cirvix.com/mcp-security-gateway.html" rel="noopener noreferrer"&gt;where to put the MCP authorization boundary&lt;/a&gt; cover the gateway setup.&lt;/p&gt;

&lt;p&gt;Honest limits&lt;/p&gt;

&lt;h2&gt;
  
  
  What runtime authorization does not do.
&lt;/h2&gt;

&lt;p&gt;Stated plainly so you can design around it.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  It does not prevent prompt injection
&lt;/h3&gt;

&lt;p&gt;It limits what a manipulated agent is permitted to do afterward.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  It cannot evaluate what it never sees
&lt;/h3&gt;

&lt;p&gt;Coverage is exactly the set of calls routed through the wrappers, the gateway or the socket. Remove alternate routes.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  It honours the policy you wrote
&lt;/h3&gt;

&lt;p&gt;A permissive rule is applied as written. Test rules in CI.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit chain is tamper-evident, not tamper-proof
&lt;/h3&gt;

&lt;p&gt;SHA-256 chaining detects internal inconsistencies. Detecting tail deletion or a fully recomputed history needs a trusted external checkpoint. A record shows authorization, not that the tool succeeded.&lt;/p&gt;

&lt;p&gt;FAQ&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime authorization, asked directly.
&lt;/h2&gt;

&lt;p&gt;Short answers.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;

&lt;h3&gt;
  
  
  What is runtime authorization for AI agents?
&lt;/h3&gt;

&lt;p&gt;A per-action decision, made on the execution path, about whether a specific agent may perform a specific action on a specific resource in the current context. The decision happens after the agent proposes the call and before the tool runs, so a refused action never produces a side effect.&lt;/p&gt;

&lt;p&gt;02&lt;/p&gt;

&lt;h3&gt;
  
  
  How is runtime authorization different from API scopes or IAM roles?
&lt;/h3&gt;

&lt;p&gt;Scopes and roles are granted once, at configuration time, to a credential. Runtime authorization is evaluated on every call, with the resource and context of that call. The two compose: IAM decides which credentials exist; runtime authorization decides whether this use of them may run now.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h3&gt;
  
  
  Does runtime authorization stop prompt injection?
&lt;/h3&gt;

&lt;p&gt;No. Injection happens in text the model reads, and Cirvix does not detect it. Runtime authorization limits what a manipulated agent can do next: the harmful tool call it attempts is still evaluated against policy and can be denied or held.&lt;/p&gt;

&lt;p&gt;04&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cirvix see everything an agent does?
&lt;/h3&gt;

&lt;p&gt;No. Every governed agent action routed through Cirvix is evaluated before execution. Calls that do not pass through the returned SDK wrappers, the MCP gateway or the local control socket, such as editor built-in tools or arbitrary subprocesses, are outside that boundary.&lt;/p&gt;

&lt;p&gt;05&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it cost to try?
&lt;/h3&gt;

&lt;p&gt;The local engine is Apache-2.0 and the free tier needs no account: one governed agent and 100 policy decisions a day. &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt; and &lt;code&gt;check&lt;/code&gt; run locally. Paid tiers and the hosted control plane are in early access.&lt;/p&gt;

&lt;p&gt;Start where you are&lt;/p&gt;

&lt;h2&gt;
  
  
  Put one agent under policy today.
&lt;/h2&gt;

&lt;p&gt;Run the free local scan with no account, test a decision with &lt;code&gt;check&lt;/code&gt;, then wrap one agent's tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cirvix.com/docs.html" rel="noopener noreferrer"&gt;Read the quickstart →&lt;/a&gt;&lt;a href="https://cirvix.com/ai-agent-authorization.html" rel="noopener noreferrer"&gt;AI agent authorization walkthrough&lt;/a&gt;&lt;a href="https://cirvix.com/pricing.html" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://cirvix.com" rel="noopener noreferrer"&gt;cirvix.com&lt;/a&gt;. Cirvix is open source: try it locally with &lt;code&gt;npx @cirvix_ai/agent-control scan&lt;/code&gt; or see &lt;a href="https://github.com/CIRVIX/agent-control" rel="noopener noreferrer"&gt;github.com/CIRVIX/agent-control&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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