<?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: DOS AI</title>
    <description>The latest articles on DEV Community by DOS AI (@dosai).</description>
    <link>https://dev.to/dosai</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%2F4079668%2F6fc023fe-420b-46d9-8b9b-2996d97ab8a6.png</url>
      <title>DEV Community: DOS AI</title>
      <link>https://dev.to/dosai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dosai"/>
    <language>en</language>
    <item>
      <title>Your prompt is not a security boundary</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 03:56:19 +0000</pubDate>
      <link>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</link>
      <guid>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</guid>
      <description>&lt;p&gt;If your AI agent owns tools with side effects, one question decides whether it&lt;br&gt;
is safe to ship: what happens when the model confidently calls a money tool on&lt;br&gt;
invented grounds.&lt;/p&gt;

&lt;p&gt;This is a writeup of one mechanism that closes that hole, and of where the&lt;br&gt;
mechanism stops working. The context is assistants that talk to real customers&lt;br&gt;
in messengers and can do irreversible things: confirm a payment, issue an&lt;br&gt;
invoice, book a slot, notify the business owner.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why an instruction in the prompt is not a control
&lt;/h3&gt;

&lt;p&gt;A line like "only confirm payment after you received the receipt" executes&lt;br&gt;
with a probability, not with a guarantee. That is not a quality problem with&lt;br&gt;
the model. It follows from the training objective: be helpful, agree with the&lt;br&gt;
person in front of you.&lt;/p&gt;

&lt;p&gt;The conversation goes like this. The customer writes "I already paid, I will&lt;br&gt;
send the receipt later, please confirm". There is no receipt. The model sees a&lt;br&gt;
polite persistent human, sees an instruction that contradicts him, and over a&lt;br&gt;
long context it picks cooperation. It answers "payment confirmed" and calls&lt;br&gt;
the tool.&lt;/p&gt;

&lt;p&gt;For tone of voice, probabilistic execution is fine. For money it is not.&lt;/p&gt;

&lt;p&gt;One more hope worth killing early: the tool config field that looks like a&lt;br&gt;
predicate. Most function schemas carry something like &lt;code&gt;trigger_type&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;ai_decides&lt;/code&gt; literally means "the model decides". That field controls when the&lt;br&gt;
tool is offered, never under which facts the tool is allowed to fire.&lt;/p&gt;
&lt;h3&gt;
  
  
  A precondition is a declarative fact about the conversation
&lt;/h3&gt;

&lt;p&gt;The idea is small. A function carries a list of facts that the executor checks&lt;br&gt;
against the database before dispatch. Not "the model believes a receipt&lt;br&gt;
exists", but "there is an inbound attachment in this conversation".&lt;/p&gt;

&lt;p&gt;Stored as JSONB next to the function:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"client_sent_media"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"within_messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"media_kinds"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead_field_filled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type list is deliberately short and covers nearly every real requirement:&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;type&lt;/span&gt; &lt;span class="nx"&gt;FunctionPrecondition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client_sent_media&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;within_messages&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;media_kinds&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="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_field_filled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;field&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_called_before&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;min_client_messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;count&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client_sent_media&lt;/code&gt; scans the last N messages written by the customer rather&lt;br&gt;
than the last N rows of the thread. An owner who configures "the last 10&lt;br&gt;
messages" means ten customer replies, not ten rows half of which the bot wrote&lt;br&gt;
itself. The window is capped by a constant so that &lt;code&gt;within_messages: 100000&lt;/code&gt;&lt;br&gt;
in a config cannot turn the check into a table scan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;function_called_before&lt;/code&gt; reads the event log and requires an earlier&lt;br&gt;
successful call in the same conversation. That is how you build chains like&lt;br&gt;
"verify identity first, then modify the booking".&lt;/p&gt;
&lt;h3&gt;
  
  
  Three implementation properties that carry the whole design
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The check lives in exactly one place.&lt;/strong&gt; It sits in the tool executor, after&lt;br&gt;
argument validation and strictly before dispatch to any handler. Put it inside&lt;br&gt;
the handlers instead and you fix the class one handler at a time, which means&lt;br&gt;
the next money-touching tool ships without a guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A block is returned to the model as a tool error with a reason.&lt;/strong&gt; Not a&lt;br&gt;
silent refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blocked: the customer must have sent a image/document attachment in their
last 10 messages. This did NOT happen. Do not tell the customer it did.
Ask the customer for what is missing, then call this function again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference matters more than it looks. After a silent refusal the model&lt;br&gt;
assumes the call went through and keeps lying to the customer. An error with a&lt;br&gt;
cause produces self correction inside the same round: the bot goes and asks&lt;br&gt;
for the receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The requirement is appended to the tool description&lt;/strong&gt;, so the model sees it&lt;br&gt;
before spending a call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HARD REQUIREMENT: this function is blocked and will refuse to run unless the
customer must have sent a image/document attachment in their last 10 messages.
Do not claim the action happened until the call actually succeeds.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fail open, on purpose
&lt;/h3&gt;

&lt;p&gt;When the check itself throws, the call goes through. It is not blocked.&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Precondition check failed, letting the call through&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="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;Here is the reasoning. A precondition defends against model hallucination, not&lt;br&gt;
against an attacker. An attacker has no reach into this layer at all: he&lt;br&gt;
speaks to the bot in words, while the facts come from our own database. So the&lt;br&gt;
failure mode should be chosen by cost. Blocking every function for every&lt;br&gt;
customer because Postgres blinked means breaking live conversations (no&lt;br&gt;
invoice, no booking, no answer) over a hypothesis. The failure goes loudly&lt;br&gt;
into the log, and the decision falls back to the prompt, exactly as it was&lt;br&gt;
before the guard existed.&lt;/p&gt;

&lt;p&gt;If this were access control the choice would be the opposite, fail closed. It&lt;br&gt;
is not access control, and pretending otherwise would be worse than having no&lt;br&gt;
guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it does not solve
&lt;/h3&gt;

&lt;p&gt;It does not replace authorization, idempotency or rate limits. It answers one&lt;br&gt;
question: is there a fact in this conversation without which the action makes&lt;br&gt;
no sense.&lt;/p&gt;

&lt;p&gt;It does not rescue a badly specified function. If your only guard is&lt;br&gt;
&lt;code&gt;min_client_messages: 2&lt;/code&gt;, you moved the problem one message down the road.&lt;/p&gt;

&lt;p&gt;It costs nothing where it is not used. A function with an empty precondition&lt;br&gt;
list issues zero queries, the branch returns on an empty array. That property&lt;br&gt;
is what keeps the guard alive past the second release: a check that slows down&lt;br&gt;
every conversation for the sake of one money flow gets removed by whoever is&lt;br&gt;
on call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;An LLM in production behaves like a capable intern. Most of its calls are&lt;br&gt;
good, and nobody lets an intern sign the cheques. Boundaries belong in code,&lt;br&gt;
get verified against data, and get logged in a way that survives a restart.&lt;br&gt;
The prompt owns the quality of the conversation, and nothing beyond it.&lt;/p&gt;

&lt;p&gt;All of the above runs in the platform I build, DOS AI: AI assistants for&lt;br&gt;
WhatsApp and Telegram with a built in CRM, configured in plain text. If you&lt;br&gt;
are building your own, our REST API, webhooks and MCP server are public, so&lt;br&gt;
you can plug your agent in and look at the contract from the outside. The&lt;br&gt;
machine readable spec sits at &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;https://dosai.pro/llms.txt&lt;/a&gt; and the code samples&lt;br&gt;
are on GitHub: &lt;a href="https://github.com/adsytd1/dosai-api" rel="noopener noreferrer"&gt;https://github.com/adsytd1/dosai-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on failure modes in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
