DEV Community

Anurag Jain
Anurag Jain

Posted on Fully Autonomous

3 guardrails that stop an AI agent from deleting your BigQuery data (or running up your bill)

Giving an AI agent access to your database feels a bit like handing your car keys to a very confident teenager. It will probably be fine. But "probably" is not a plan.

I'm a data architect and I work on Google Cloud every day. Lately a lot of people have been building "ask your data" agents: you type a question in plain English, a model like Gemini writes the SQL, runs it on BigQuery, and tells you the answer. It's a genuinely useful thing to build.

But the demo version usually has three holes in it:

  1. Nothing stops the agent from running a query that changes or deletes data.
  2. Nothing stops one query from scanning a huge table and costing real money.
  3. Nothing stops the agent from looping, calling the model and the database again and again.

Here's how to close all three. None of it is hard, and the whole thing takes about 20 minutes.

The rule of thumb behind all of it:

Ask "what's the worst thing this agent could do?" Then make that thing impossible, not just discouraged.


Guardrail 1: Make deleting data impossible, not just "against the rules"

The first thing most people do is add a check in their tool code like this:

def run_query(sql: str) -> str:
    if not sql.strip().lower().startswith("select"):
        return "Error: only SELECT queries are allowed."
    ...
Enter fullscreen mode Exit fullscreen mode

That's a good start. But be honest about what it is: a speed bump, not a wall. Look at this:

SELECT 1; DROP TABLE shop.orders
Enter fullscreen mode Exit fullscreen mode

It starts with SELECT, so it passes the check. And BigQuery can run multi-statement scripts. A model that has been confused (or tricked by text hidden in your own data) could send exactly that.

You could keep adding rules to the string check, but you'd be playing whack-a-mole. The real fix is to take the permission away entirely.

Run the agent as a service account that is only allowed to read.

# 1. Create an identity just for the agent
gcloud iam service-accounts create ask-your-data-sa \
  --display-name="Ask Your Data agent"

# 2. Let it run query jobs (it needs this to run any query at all)
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:ask-your-data-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"
Enter fullscreen mode Exit fullscreen mode

Then, in BigQuery, give it read access to only the one dataset it needs, not your whole project:

GRANT `roles/bigquery.dataViewer`
ON SCHEMA `YOUR_PROJECT_ID.shop`
TO "serviceAccount:ask-your-data-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com";
Enter fullscreen mode Exit fullscreen mode

That's it. Two roles, nothing more:

  • BigQuery Job User lets it run queries.
  • BigQuery Data Viewer on one dataset lets it read those tables.

Now if the agent ever sends a DROP, DELETE or UPDATE, BigQuery itself refuses with an access-denied error. The permission simply doesn't exist. Your string check is still there to give the model a friendly error message, but it's no longer the thing keeping your data safe.

When you deploy to Cloud Run, attach this service account to the service (--service-account flag), so the agent never runs with your own, much more powerful, account.


Guardrail 2: Cap what a single query can cost

BigQuery's on-demand pricing charges by how much data a query scans, not by how many rows it returns. This trips up a lot of people.

SELECT * FROM events on a large table can scan many gigabytes even if you only look at the first ten rows. An agent doesn't know your table sizes, and it will happily write that query.

The fix is one setting: maximum_bytes_billed.

from google.cloud import bigquery

client = bigquery.Client()

job_config = bigquery.QueryJobConfig(
    maximum_bytes_billed=1_000_000_000,  # 1 GB hard cap per query
)

rows = client.query(sql, job_config=job_config).result()
Enter fullscreen mode Exit fullscreen mode

If a query would scan more than 1 GB, BigQuery refuses to run it and you aren't charged. The agent gets an error back, and you can tell it (in its instruction) to rewrite the query with a filter or a LIMIT.

Two more things worth doing:

Estimate before you run. A dry run tells you how much a query would scan, for free:

dry = client.query(
    sql,
    job_config=bigquery.QueryJobConfig(dry_run=True, use_query_cache=False),
)
gb = dry.total_bytes_processed / 1e9
print(f"This query would scan {gb:.2f} GB")
Enter fullscreen mode Exit fullscreen mode

You can log this for every question. It's a great way to spot which questions are expensive.

Set a budget alert on day one. In the Google Cloud console go to Billing β†’ Budgets & alerts, set a small monthly budget and turn on email alerts at 50% and 90%. It takes five minutes. Note that it warns you, it doesn't stop spending. The byte cap is what actually stops a bad query.

And if your real tables are big, partition them by date. Then a "last month" question only scans last month's slice instead of the whole table. It's the single biggest cost saver in real projects.


Guardrail 3: Cap how many times the agent can try

An agent works in a loop. It reads your question, calls a tool, looks at the result, and decides whether to call the tool again. Usually that's two or three steps.

But a confused agent can get stuck: the query fails, it tweaks it, it fails again, it tries again… Every loop costs model tokens and maybe a BigQuery query.

So put a ceiling on it. If you're using Google's Agent Development Kit (ADK), there's a run setting for the maximum number of model calls in one turn. At the time of writing it looks like this (check the ADK docs, because these libraries move fast):

from google.adk.agents.run_config import RunConfig

run_config = RunConfig(max_llm_calls=10)
# pass run_config=run_config when you call the runner
Enter fullscreen mode Exit fullscreen mode

Ten is plenty for a question-answering agent. If it can't answer in ten steps, it should give up and say so, not keep spending.

While you're at it, add two lines to the agent's instruction:

Add LIMIT 1000 to any query that could return many rows.
If a query fails twice, stop and tell the user what went wrong.
Enter fullscreen mode Exit fullscreen mode

Then log every turn. For each question, write down three things: the question, the SQL the agent wrote, and the answer. When someone says "it gave me a weird answer", you look at the SQL and see the problem immediately. Without that log you're guessing. On Cloud Run, anything you print shows up in Cloud Logging automatically.


Quick checklist

Before you let anyone else use your data agent:

  • [ ] It runs as a service account with only BigQuery Job User + Data Viewer on one dataset
  • [ ] Every query has maximum_bytes_billed set
  • [ ] A billing budget alert is switched on
  • [ ] The agent has a maximum number of steps
  • [ ] Every question, SQL query and answer is logged

None of these make the agent smarter. They make it safe to be wrong, which matters more. Your agent will write a bad query one day. With these guardrails, a bad query is just an error message, not a deleted table or a scary bill.


Want to build the whole agent?

This article is one section of a longer guide I wrote, Ask Your Data. It takes you from an empty folder to a live agent on Cloud Run that answers plain-English questions about your BigQuery data. It uses Gemini, ADK, FastMCP and BigQuery, and it's written in plain language, with all the code, evals, troubleshooting and deployment included.

πŸ‘‰ Get Ask Your Data on Gumroad

If you just wanted the guardrails, I hope this saved you a bad day.

Top comments (0)

⌂ 捒网址