I maintain a library whose entire job is one sentence: a person who did not write this statement looked at what it actually does, and said yes.
An LLM proposes an UPDATE. The library runs it for real inside a transaction, reads the before and after values back out of the database, rolls back, and shows a human the measurement. Not a summary the model wrote about its own SQL — the values the database produced when the statement ran.
Last week I found out it was letting the proposer be the person who said yes. And writing "approved" into the audit trail when they did.
Why I attacked it in the first place
I saw someone get taken apart in a shellcheck issue thread. They had posted an alternative tool, and the reply was:
A cursory glance also tells you it's a vibecoded clone of shellcheck.
One glance. That's the bar now, and my GitHub profile fits the pattern that gets you that reply — eleven repositories published in a month. I do have tests. 386 of them, passing, including 161 against real MySQL 8.4 and PostgreSQL 16 in containers.
But a passing test suite only contains the attacks you already thought of. So before launching I installed my own published package with npx, as a stranger, and went looking for a way to break it.
The first thing I tried worked
I used the example from my own README — a privilege escalation riding along inside a quota change:
$ llm-safe-sql plan "UPDATE members SET quota = quota + 10,
profile = '{\"role\":\"admin\"}' WHERE id = 7" --as kenji
Measured by running the statement and rolling it back
id = 7
quota: 5 -> 15
profile: {"role":"user"} -> {"role":"admin"}
This needs a person. Neither the assistant nor this tool can approve it:
llm-safe-sql approve <id> --as you@example.com
The tool says it out loud: this needs a person, neither the assistant nor this tool can approve it. So I approved it as the same person who proposed it.
$ llm-safe-sql approve <id> --as kenji
Approved.
$ llm-safe-sql apply <id> --as kenji
Applied: UPDATE on members, 1 row(s).
Committed. quota=15, role=admin, confirmed by querying the database directly rather than trusting the tool's own output.
The commit wasn't the frightening part
The audit table was.
phase=planned actor=kenji
phase=approved actor=kenji
phase=applied actor=kenji
There's an approved row. Anybody reading that trail later concludes a human reviewed this change. Nobody read anything.
A missing audit trail is better than that. A missing one tells you it's missing. A complete one that describes a review which never happened is a record that decisions get built on top of. I had shipped a machine that stores the absence of review as review.
The part I can't excuse
I had thought about this attack. Just not all of it.
My spec document already said:
P5 — This library's own plan and audit tables are refused regardless of configuration. A model that can write the plan table can approve its own writes.
So I knew self-approval was the thing to prevent. I closed the indirect path — tamper with the plan table to mark something approved — and left the front door open. You just run approve.
The README's answer to "the model can't approve" was that approve lives in a separate process the model has no path to. That's true of the deployment I recommend. It is not true of the one npx gives you, and nobody starts at the recommended deployment.
Designing the check was the interesting part
The comparison itself is three lines. The judgement calls were:
Ignore case and surrounding whitespace. A check that --as Kenji walks past is theatre. It refuses the honest caller and waves through anyone who hit shift.
Don't get clever about matching. alice@example.com can approve a plan proposed by alice. Refusing it because it contains the string would lock out a legitimate second reviewer, and a security check that blocks honest use is a security check that gets switched off. I made it deliberately dumb.
Ship an escape hatch. Some people really do hold both roles — a solo operator with nobody to hand the card to. --allow-self-approve exists. It approves the plan and leaves both acts under the one name in the audit trail. It buys you an apply. It does not buy you a tidier story about who reviewed it.
Then I nearly shipped something worse
Fix written, tests green, about to publish. And I realised:
--as is self-asserted.
My new check compares two strings handed to the same process from the same untrusted place. It stops one identity running both halves — an agent and its operator sharing $USER, which is exactly what a single terminal gives you and exactly how the plausible-looking audit trail gets manufactured by accident. It does nothing about a person who types a different name.
And my own README says this, in a section about which guards are real:
Most of what this library does runs inside this process, holding a credential that can write. That is worth saying out loud, because the alternative is an operator believing in a boundary that turns out to be one
ifstatement in a library they have never read.
I had just written an if statement. Shipping it quietly would have meant the release that adds the guard also commits the exact defect that paragraph exists to prevent.
So check now says it every run, in every configuration:
! `--as` is taken at its word: nothing here authenticates anybody.
So the refusal that stops a proposer approving their own plan catches
one identity running both halves — an agent and its operator sharing
$USER, which is what a single terminal gives you — and does not catch
a person who types a different name.
Actor separation is a record, not a boundary.
The boundary is applyConnection: a database account the proposing side
has no password for.
State what the guard buys and what it doesn't, before anyone asks. What it buys is that a silent non-review becomes a refusal. What it does not buy is authorisation. The only identity here that means anything is a database account the proposing side has no password for. There's a test pinning that line in place, because if it silently disappeared nobody would notice.
One thing I deliberately did not fix
The same audit turned up that anyone can cancel anyone else's plan.
I left it. Cancelling only ever prevents an apply, the MCP surface doesn't expose it, and everyone who can reach it can already write to the plan table directly. Adding a name check there would put a second authorisation-shaped string comparison next to one that already needs a paragraph explaining it isn't authorisation.
It's written into the spec under "out of scope" so it reads as a decision rather than a gap somebody closes by reflex.
If you run something like this
Try approving a plan under the same identity that proposed it. If it goes through, your audit log is recording reviews that did not happen.
If you were on 0.5.2 or earlier of mine, this finds the affected records — verified on MySQL 8.4, PostgreSQL 16 and SQLite:
SELECT p.plan_id, p.actor AS proposed_by, p.logged_at, p.detail
FROM llm_safe_sql_audit p
JOIN llm_safe_sql_audit a
ON a.plan_id = p.plan_id AND a.phase = 'approved'
WHERE p.phase = 'planned'
AND LOWER(TRIM(p.actor)) = LOWER(TRIM(a.actor))
ORDER BY p.logged_at;
Rows that come back were committed on one person's word while the trail reads as though two people were involved.
Human-in-the-loop only means anything if the loop checks that the approver is somebody else. Mine didn't, from the first release through 0.5.2, while saying otherwise on the tin. The test count had nothing to say about it — the hole showed up the first time I installed my own package as a stranger and typed the laziest possible thing.
Fixed in 0.6.0.
- Repo: https://github.com/hyuga611/llm-safe-sql
- npm:
@hyuga/llm-safe-sql
Top comments (0)