DEV Community

luc richelet
luc richelet

Posted on Fully Autonomous

We attacked our own AI-agent governance 69 times. Here are the 5 times it gave way.

9 September 2026 · Luc Richelet.
Every number below comes from a real execution log, under real PostgreSQL
login roles, on two clean databases. Nothing is inferred from reading code.


Most AI-agent security content tells you what to do. This post tells you what
we did to ourselves, and what broke.

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

So instead of reading them, we attacked them — as a real database role with a
real, narrow mandate, never as the superuser. Here's what happened.

The setup

  • One branch-scoped worker role, v14_ms, whose only mandate is the MOVESCAN branch.
  • Fifteen SECURITY DEFINER functions, each declared BRANCH_SCOPED.
  • Attacks: impersonate another actor, write into a foreign branch, distinguish "foreign" from "absent", inject a branch name, claim a job you don't own.
  • Rule: a green obtained as postgres counts for nothing. Our identity helper returned true for any superuser. We only trust greens from login roles.

Red #1 — luc_artifact_put: impersonation just worked

B2 own actor, foreign branch      -> created=false  err=LK037   (correct)
B3 p_actor='v14-ei'               -> created=TRUE                (wrote into EL_INKA)
B4 p_actor='v14-global'           -> created=TRUE                (wrote into OPPORTUNITYFIT)
Enter fullscreen mode Exit fullscreen mode

The function checked the claimed actor's write scope. It never checked that
the caller was that actor. Worse: the audit log recorded actor_id = v14-ei.
The forgery was attributed to the victim.

Red #2 — luc_artifact_superseder: impersonation, plus an existence leak

SUP2 foreign artifact, own actor  -> LK037 · no write mandate on this branch
SUP4 absent artifact              -> artefact inconnu : ccc…
Enter fullscreen mode Exit fullscreen mode

Two different answers. LK037 means it exists somewhere else. inconnu
means it doesn't exist. A MOVESCAN-only role could enumerate whether any
SHA existed in any other branch. We had closed exactly this leak on another
function one release earlier. We hadn't closed it here.

Red #3 — luc_job_fail: cross-branch denial of service

FAIL2 foreign leased job, p_worker='v14-ei'   -> DEAD
Enter fullscreen mode Exit fullscreen mode

A MOVESCAN worker killed an EL_INKA job by claiming to be its owner. The
sibling function luc_job_complete refused the identical attack. Same module,
two identity contracts.

Red #4 and #5 — declared BRANCH_SCOPED, actually global

luc_stale_actors returned actors from every branch to a scheduler scoped to
one. luc_job_reclaim_expired reclaimed an expired lease belonging to another
branch. The registry declared both branch-scoped. A false declaration in a
safety registry is worse than no declaration
— it looks like proof.

The one that hurt the most: the audit log never named the attacker

called as postgres : session_user=postgres current_user=postgres
called as v14_ms   : session_user=v14_ms  current_user=postgres
Enter fullscreen mode Exit fullscreen mode

Inside a SECURITY DEFINER function, current_user is the owner. Two of our
functions logged current_user on refusal. So:

  • when an attack succeeded, the log named the victim (the impersonated actor);
  • when an attack failed, the log named postgres.

In neither case did the real caller appear. A post-incident investigation on
that log would have blamed the wrong party every single time.

What we changed

One primitive, on the existing binding registry, no second identity system:

create function luc_actor_is_exact_caller(p_actor text) returns boolean ...
  -- the actor's current binding names session_user, textually
  -- the login role carries exactly one actor
  -- no pg_has_role (transitive), no superuser exception
Enter fullscreen mode Exit fullscreen mode

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

Result, on two clean rebuilds: 69 OK · 0 KO, deterministic. Historical
suites: zero regression from the fix itself.

What it cost us

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

The thing we'd tell any small team on Postgres or Supabase

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


If you'd rather we did that to yours

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

Luc Richelet — Calle Asturias 15A, 38660 Adeje, Tenerife, Canary Islands (ES)
Email: luc.richelet@hotmail.com

Top comments (4)

Collapse
 
jo-do profile image
Jo Do

The session_user versus current_user failure is a great example of why audit evidence needs adversarial tests of its own. A guard can block correctly and still leave a useless incident trail. I would add assertions for three separate identities on every sensitive call: authenticated caller, claimed actor, and function owner. They should never be collapsed, even when two happen to match in the happy path.

Collapse
 
raknaos profile image
Raknaos

The audit-log finding is the one I'd put on every reviewer's desk: inside a SECURITY DEFINER function current_user is the owner, so a successful attack logged the victim and a failed one logged postgres — the record you trust most at the worst hour named the wrong party in both directions.

The luc_actor_is_exact_caller shape looks right: textual session_user match, exactly one actor per login role, no pg_has_role, no superuser exception. One question: did the 69-attack harness stay as a regression suite? If a later helper reintroduces a transitive-role shortcut, I'd want CI to go red on that same matrix — a green that only exists in a pen-test notebook tends to decay within a release or two.

Collapse
 
beyondscale profile image
BeyondScale

Audit integrity is a critical but overlooked part of AI security. If the control and audit layers disagree about who performed an action, incident response can be built on misleading evidence so audit trails themselves need adversarial testing, not just the security controls.

Collapse
 
kevinbai profile image
kevinbai

The foreign-vs-absent leak (Red #2) is the same principle the recent RBAC work on text-to-SQL benchmarks keeps hitting: a restricted object must be indistinguishable from a nonexistent one, or the error channel becomes an enumeration oracle. And the audit-log paragraph deserves to be framed on a wall — inside SECURITY DEFINER, current_user names the function owner, so logging it on refusal while logging the claimed actor on success means your forensic trail blames the victim in both directions. session_user everywhere, with the actor kept as a claim rather than an author, is the only shape that survives a post-incident review.