<?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: luc richelet</title>
    <description>The latest articles on DEV Community by luc richelet (@luc_richelet_b690bcb7645d).</description>
    <link>https://dev.to/luc_richelet_b690bcb7645d</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%2F4125127%2F676956ba-df71-4177-983f-b6ba4ffeb86b.png</url>
      <title>DEV Community: luc richelet</title>
      <link>https://dev.to/luc_richelet_b690bcb7645d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luc_richelet_b690bcb7645d"/>
    <language>en</language>
    <item>
      <title>We attacked our own AI-agent governance 69 times. Here are the 5 times it gave way.</title>
      <dc:creator>luc richelet</dc:creator>
      <pubDate>Mon, 14 Sep 2026 21:50:37 +0000</pubDate>
      <link>https://dev.to/luc_richelet_b690bcb7645d/we-attacked-our-own-ai-agent-governance-69-times-here-are-the-5-times-it-gave-way-1ca4</link>
      <guid>https://dev.to/luc_richelet_b690bcb7645d/we-attacked-our-own-ai-agent-governance-69-times-here-are-the-5-times-it-gave-way-1ca4</guid>
      <description>&lt;p&gt;&lt;em&gt;9 September 2026 · Luc Richelet.&lt;br&gt;
Every number below comes from a real execution log, under real PostgreSQL&lt;br&gt;
login roles, on two clean databases. Nothing is inferred from reading code.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;Most AI-agent security content tells you what to do. This post tells you what&lt;br&gt;
we did to ourselves, and what broke.&lt;/p&gt;

&lt;p&gt;We run a small platform kernel on PostgreSQL. AI agents work inside it: they&lt;br&gt;
claim jobs, write artifacts, record health checks, emit signals. Every one of&lt;br&gt;
those actions goes through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function that is supposed to&lt;br&gt;
check &lt;em&gt;who&lt;/em&gt; is calling and &lt;em&gt;what&lt;/em&gt; they're allowed to touch. We had fifteen of&lt;br&gt;
those functions. The registry said they were declared but unproven.&lt;/p&gt;

&lt;p&gt;So instead of reading them, we attacked them — as a real database role with a&lt;br&gt;
real, narrow mandate, never as the superuser. Here's what happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One branch-scoped worker role, &lt;code&gt;v14_ms&lt;/code&gt;, whose only mandate is the &lt;code&gt;MOVESCAN&lt;/code&gt;
branch.&lt;/li&gt;
&lt;li&gt;Fifteen &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions, each declared &lt;code&gt;BRANCH_SCOPED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attacks: impersonate another actor, write into a foreign branch, distinguish
"foreign" from "absent", inject a branch name, claim a job you don't own.&lt;/li&gt;
&lt;li&gt;Rule: a green obtained as &lt;code&gt;postgres&lt;/code&gt; counts for nothing. Our identity helper
returned &lt;code&gt;true&lt;/code&gt; for any superuser. We only trust greens from login roles.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Red #1 — &lt;code&gt;luc_artifact_put&lt;/code&gt;: impersonation just worked
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B2 own actor, foreign branch      -&amp;gt; created=false  err=LK037   (correct)
B3 p_actor='v14-ei'               -&amp;gt; created=TRUE                (wrote into EL_INKA)
B4 p_actor='v14-global'           -&amp;gt; created=TRUE                (wrote into OPPORTUNITYFIT)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The function checked the &lt;em&gt;claimed&lt;/em&gt; actor's write scope. It never checked that&lt;br&gt;
the caller &lt;em&gt;was&lt;/em&gt; that actor. Worse: the audit log recorded &lt;code&gt;actor_id = v14-ei&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;The forgery was attributed to the victim.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Red #2 — &lt;code&gt;luc_artifact_superseder&lt;/code&gt;: impersonation, plus an existence leak
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUP2 foreign artifact, own actor  -&amp;gt; LK037 · no write mandate on this branch
SUP4 absent artifact              -&amp;gt; artefact inconnu : ccc…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two different answers. &lt;code&gt;LK037&lt;/code&gt; means &lt;em&gt;it exists somewhere else&lt;/em&gt;. &lt;code&gt;inconnu&lt;/code&gt;&lt;br&gt;
means &lt;em&gt;it doesn't exist&lt;/em&gt;. A &lt;code&gt;MOVESCAN&lt;/code&gt;-only role could enumerate whether any&lt;br&gt;
SHA existed in any other branch. We had closed exactly this leak on another&lt;br&gt;
function one release earlier. We hadn't closed it here.&lt;/p&gt;
&lt;h2&gt;
  
  
  Red #3 — &lt;code&gt;luc_job_fail&lt;/code&gt;: cross-branch denial of service
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL2 foreign leased job, p_worker='v14-ei'   -&amp;gt; DEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A &lt;code&gt;MOVESCAN&lt;/code&gt; worker killed an &lt;code&gt;EL_INKA&lt;/code&gt; job by claiming to be its owner. The&lt;br&gt;
sibling function &lt;code&gt;luc_job_complete&lt;/code&gt; refused the identical attack. Same module,&lt;br&gt;
two identity contracts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Red #4 and #5 — declared &lt;code&gt;BRANCH_SCOPED&lt;/code&gt;, actually global
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;luc_stale_actors&lt;/code&gt; returned actors from every branch to a scheduler scoped to&lt;br&gt;
one. &lt;code&gt;luc_job_reclaim_expired&lt;/code&gt; reclaimed an expired lease belonging to another&lt;br&gt;
branch. The registry declared both branch-scoped. &lt;strong&gt;A false declaration in a&lt;br&gt;
safety registry is worse than no declaration&lt;/strong&gt; — it looks like proof.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one that hurt the most: the audit log never named the attacker
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;called as postgres : session_user=postgres current_user=postgres
called as v14_ms   : session_user=v14_ms  current_user=postgres
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Inside a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function, &lt;code&gt;current_user&lt;/code&gt; is the &lt;em&gt;owner&lt;/em&gt;. Two of our&lt;br&gt;
functions logged &lt;code&gt;current_user&lt;/code&gt; on refusal. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when an attack &lt;strong&gt;succeeded&lt;/strong&gt;, the log named the &lt;strong&gt;victim&lt;/strong&gt; (the impersonated actor);&lt;/li&gt;
&lt;li&gt;when an attack &lt;strong&gt;failed&lt;/strong&gt;, the log named &lt;strong&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In neither case did the real caller appear. A post-incident investigation on&lt;br&gt;
that log would have blamed the wrong party every single time.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;One primitive, on the existing binding registry, no second identity system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;luc_actor_is_exact_caller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_actor&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="c1"&gt;-- the actor's current binding names session_user, textually&lt;/span&gt;
  &lt;span class="c1"&gt;-- the login role carries exactly one actor&lt;/span&gt;
  &lt;span class="c1"&gt;-- no pg_has_role (transitive), no superuser exception&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tested against direct, transitive, collective, stale, current, nonexistent and&lt;br&gt;
superuser identities. Then rewired the write gates to it, made "foreign" and&lt;br&gt;
"absent" return byte-identical responses, moved the branch check &lt;em&gt;into&lt;/em&gt; the&lt;br&gt;
function with a returned-and-logged refusal instead of a raw constraint error,&lt;br&gt;
and logged &lt;code&gt;session_user&lt;/code&gt; everywhere — with the claimed actor kept as a&lt;br&gt;
&lt;em&gt;claim&lt;/em&gt;, not as an author.&lt;/p&gt;

&lt;p&gt;Result, on two clean rebuilds: &lt;strong&gt;69 OK · 0 KO&lt;/strong&gt;, deterministic. Historical&lt;br&gt;
suites: zero regression from the fix itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost us
&lt;/h2&gt;

&lt;p&gt;Honesty section. Along the way we introduced four defects of our own — a&lt;br&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; column that rolled back the very refusal trace we were trying to&lt;br&gt;
write, a two-transaction migration the real installer rightly refused, an event&lt;br&gt;
block rewritten with a value a foreign key rejected, and a variable/column name&lt;br&gt;
collision. All four were found by execution, none by rereading. The full&lt;br&gt;
before/after logs ship with the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing we'd tell any small team on Postgres or Supabase
&lt;/h2&gt;

&lt;p&gt;Row Level Security decides &lt;em&gt;which rows&lt;/em&gt;. It does not decide &lt;em&gt;which actions,&lt;br&gt;
under whose authority, with what proof, and who answers for it&lt;/em&gt;. If your agent&lt;br&gt;
can call a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function with an actor name as a parameter, ask&lt;br&gt;
one question: &lt;strong&gt;does the function check the parameter, or the caller?&lt;/strong&gt; Then&lt;br&gt;
don't trust your reading. Log in as the narrowest role you have and try to lie.&lt;/p&gt;




&lt;h3&gt;
  
  
  If you'd rather we did that to yours
&lt;/h3&gt;

&lt;p&gt;We run the same adversarial battery against your agent's Postgres surface —&lt;br&gt;
identity, scope, existence leaks, branch injection, audit attribution — as real&lt;br&gt;
login roles, and hand you the matrix: what your agent managed to do that it&lt;br&gt;
shouldn't have, with the log. Two days, fixed price, and you keep the harness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Luc Richelet&lt;/strong&gt; — Calle Asturias 15A, 38660 Adeje, Tenerife, Canary Islands (ES)&lt;br&gt;
Email: &lt;a href="mailto:luc.richelet@hotmail.com"&gt;luc.richelet@hotmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>security</category>
      <category>ai</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
