Monday, October 19, 2026

Build the Fence Before Letting in the AI: My sp_Blitz App, Part 1

Last month, Grant Fritchey's AI coding agent went looking through his laptop for a running PostgreSQL instance. It found one that was shut down, and started it up on its own, without being asked. The agent was trying to validate its changes before deploying them. Good instinct. Unauthorized action. You can read the whole story in part six of his Simple Talk series.

Grant's takeaway? Guardrails in the prompt weren't enough. You need a real barrier between the AI and anything that matters.

I have never used an AI coding agent. Yes, I know. WTH am I waiting for...

This is it. I'm building an app around Brent Ozar's sp_Blitz, and I'm documenting the process in a series. But before I let an AI agent anywhere near my SQL Server, I want to establish what it can and cannot do. First the fence. Then the AI.

The App

Brent's sp_Blitz is the health check many of us already use. Run it once, and it tells you what's wrong today. Save the output run after run, and you have a history of every finding on the server(s) you run it against.

My app turns that history into a comparison -- what's new since last run, what's been fixed, and what keeps coming back. The data collection is already built into sp_Blitz. Pass it @OutputDatabaseName, @OutputSchemaName and @OutputTableName, and it creates the table, if needed, and writes the results to it. That table is my app's data source.

Credit where it's due: when Brent added table output in sp_Blitz v22, he included the server name and check date so we could compare runs (sp_Blitz v22). And in March, he wrote about using Claude Code with SQL Server, including the importance of thinking about where you point it and what login you give it (Using Claude Code with SQL Server).

The comparison builds on Brent's work. The fence, and what the agent does inside it, are mine.

Each post in this series adds something new. In Part 1, I keep it simple: run sp_Blitz multiple times, save the findings, and report what's new, what's changed, and what's stayed the same.

The setup:

Piece Choice
SQL Server 2025 CU7, local dev instance
Health check sp_Blitz, First Responder Kit
AI agent Claude Code, desktop app

Why Not Just Point It at SQL Server?

Because it would connect as me. If the agent uses Windows authentication, it gets my login, and my login is sysadmin.

Claude Code has its own permission rules, and it reads a rules file called CLAUDE.md at the start of every session. But per Anthropic's own documentation, CLAUDE.md only shapes what Claude tries to do. It doesn't change what Claude Code allows (Claude Code permissions). Guidance isn't a barrier.

So I have three layers, and only one of them is mine to enforce:

Layer Enforced by
CLAUDE.md The agent interprets it; SQL Server doesn't enforce it.
Claude Code
permissions
Claude Code.
SQL Server
permissions
SQL Server. The fence.

I'll use all three -- but trust the last one the most.

Step 1: Install the First Responder Kit

Brent recommends master, but any database is supported, so I put it in my DBA database. Create that first:

CREATE DATABASE DBA;
GO

Then grab the latest release and run Install-All-Scripts.sql in DBA. It installs sp_ineachdb with sp_Blitz, which sp_Blitz needs in the same database (README).

Step 2: Run sp_Blitz Into a Table

Run sp_Blitz with the output parameters. @CheckUserDatabaseObjects = 0 skips the checks inside user databases, which is the fastest way to run it:

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';

Confirm it landed:

SELECT COUNT(*) AS Findings
FROM DBA.dbo.BlitzResults;

Step 3: Build the Fence

The agent gets its own login. It can read one table. That's it.

USE master;
GO
CREATE LOGIN BlitzReader
    WITH PASSWORD = '[USE A STRONG PASSWORD]',
    CHECK_POLICY = ON;
GO

USE DBA;
GO
CREATE USER BlitzReader FOR LOGIN BlitzReader;
GRANT SELECT ON dbo.BlitzResults TO BlitzReader;
GO

That's the fence. BlitzReader can read one table, BlitzResults, and it was granted nothing else. It can't run sp_Blitz, and it can't touch anything else in DBA. When the agent connects, it connects as BlitzReader. Whatever it attempts to do, SQL Server decides what to permit.

One note: BlitzReader is a SQL login, so the instance needs to be in SQL Server and Windows Authentication mode before anything can connect with it.

Step 4: Give It Something to Find

To test the comparison, I needed something to change. I made a throwaway database and gave sp_Blitz two things to flag, one at a time: Auto-Shrink and Page Verification.

CREATE DATABASE BlitzDemo;
GO
ALTER DATABASE BlitzDemo SET AUTO_SHRINK ON;
GO

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';
GO

ALTER DATABASE BlitzDemo SET AUTO_SHRINK OFF;
ALTER DATABASE BlitzDemo SET PAGE_VERIFY NONE;
GO

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';
GO

Between the last two runs, Auto-Shrink got fixed and Page Verification is new. Everything else should be the same. Three runs, each with its own CheckDate:

SELECT CheckDate, COUNT(*) AS Findings
FROM DBA.dbo.BlitzResults
GROUP BY CheckDate
ORDER BY CheckDate;

Step 5: Now We Install Claude Code

I'm using the Claude desktop app. It includes Claude Code, so there's no separate CLI install, but it does require a paid subscription (Claude Code overview). Brent recommends starting with the terminal version instead, because its documentation is much better (Using Claude Code with SQL Server). I wanted to keep the first part simple, so I went with the app.

Download, install, sign in. There's no password to remember -- you sign in with the email of your Claude.ai account and you'll receive an emailed login link. Claude Code is the </> icon at the top left, next to the chat icon.

You need to give Claude a project folder. I created C:\BlitzApp. Everything the agent writes goes into this directory.

Step 6: The Ground Rules

Now the rules file for Claude. This is my CLAUDE.md, saved in C:\BlitzApp. Remember, it's guidance, not enforcement. The last rule comes straight from Grant's lessons: he said he'd now make the AI keep a log of what worked and what didn't.

# Ground rules

- Work only in this project folder.
- Do not connect to any database. I run every query.
- All SQL you write is SELECT-only, against DBA.dbo.BlitzResults.
- Every query has a TOP clause.
- Ask before running any command.
- Log every attempt in attempts.md: what you tried, what worked, what didn't.

Notice the second rule. In this part of the series, the agent writes and I run. It doesn't touch SQL Server at all.

Step 7: The First Prompt

In the Claude Code tab, I set the folder to C:\BlitzApp and the permission mode to Manual -- 'Always ask before making changes'. Then I gave it this, verbatim:

Read CLAUDE.md first.

The table DBA.dbo.BlitzResults holds sp_Blitz output from several runs. Every row from one run shares the same CheckDate. Columns: ID int, ServerName nvarchar, CheckDate datetimeoffset, Priority tinyint, FindingsGroup varchar, Finding varchar, DatabaseName nvarchar, URL varchar, Details nvarchar, QueryPlan xml, QueryPlanFiltered nvarchar, CheckID int.

Write a query that compares the two most recent runs and labels each finding:
- New: in the latest run only
- Fixed: in the previous run only
- Changed: in both runs, but Details differ
- Same: in both runs, Details unchanged

Match findings on CheckID and DatabaseName. DatabaseName can be NULL.

Save the query as report.sql. Do not run it. I will run it myself.

After about 45 seconds, it asked permission to write the file:

Note that orange arrow. 'Always allow' is the default, but I used the drop down and picked 'Allow once' instead. I want to approve every action myself -- at least for now.

Then I received this as Claude Code's response:

I saved the query as report.sql and logged this attempt in attempts.md. I haven't run it or connected to anything, so it's untested until you run it.
How it works:

* Picking the two runs: it takes the two most recent distinct `CheckDate` values.
* Matching: findings match on `CheckID` + `DatabaseName`, and two NULL DatabaseNames count as a match.
* Repeated findings: sp_Blitz can return several rows for the same CheckID and database. A plain join would pair every one of those rows with every other, so the query matches in two passes:
   1. Rows with the same key and identical Details are labelled Same.
   2. Remaining rows with the same key are paired in ID order and labelled Changed. Anything still unpaired is New (latest run only) or Fixed (previous run only).
* Output: status, priority, finding, CheckID, DatabaseName, previous and latest Details, and both row IDs. New findings sort first, then Changed, Fixed and Same.

Before you run it:

* TOP clauses: only the final SELECT (`TOP (1000)`) and the step that picks the run dates (`TOP (2)`) have one. I left TOP off the steps in between because a row limit there could silently drop rows and give them the wrong label. If you want TOP on every SELECT, tell me and I'll add it.
* Multiple servers: if the table holds runs from several servers with different CheckDates, "the two most recent runs" spans all servers. The query would then need to compare each server separately.
* Noisy Details: some sp_Blitz Details include dates, counts or sizes, so those findings will show as Changed on every run.

Look at that first 'Before you run it' item. My rules said every query gets a TOP clause. Claude didn't follow that rule everywhere, explained why, and asked before changing anything. Its reasoning was sound -- a TOP on an intermediate step could silently drop rows and mislabel findings.

The rules shaped the agent's work, but did not control every decision. That's guidance, not enforcement.

The query it wrote, verbatim:

-- report.sql
-- Compares the two most recent sp_Blitz runs in DBA.dbo.BlitzResults.
-- A run = all rows sharing one CheckDate. Findings match on CheckID + DatabaseName (NULL-safe).
--
--   New     : in the latest run only
--   Fixed   : in the previous run only
--   Changed : in both runs, Details differ
--   Same    : in both runs, Details unchanged
--
-- sp_Blitz can return more than one row for the same CheckID + DatabaseName.
-- To avoid a many-to-many join, rows are paired in two passes:
--   1. Pair rows whose Details are identical            -> Same
--   2. Pair the leftovers by key, in ID order           -> Changed
--      Anything still unpaired                          -> New / Fixed
-- When the key is unique per run, this is a plain one-to-one match.
--
-- SELECT-only. If only one run exists, every row comes back as New.

WITH Runs AS (
    SELECT TOP (2)
           CheckDate,
           ROW_NUMBER() OVER (ORDER BY CheckDate DESC) AS RunNum
    FROM DBA.dbo.BlitzResults
    GROUP BY CheckDate
    ORDER BY CheckDate DESC
),
Cur AS (   -- latest run
    SELECT b.ID, b.CheckID, b.DatabaseName, b.Priority, b.FindingsGroup, b.Finding, b.Details,
           ROW_NUMBER() OVER (PARTITION BY b.CheckID, b.DatabaseName, b.Details ORDER BY b.ID) AS DupNum
    FROM DBA.dbo.BlitzResults AS b
    JOIN Runs AS r ON r.CheckDate = b.CheckDate AND r.RunNum = 1
),
Prev AS (  -- previous run
    SELECT b.ID, b.CheckID, b.DatabaseName, b.Priority, b.FindingsGroup, b.Finding, b.Details,
           ROW_NUMBER() OVER (PARTITION BY b.CheckID, b.DatabaseName, b.Details ORDER BY b.ID) AS DupNum
    FROM DBA.dbo.BlitzResults AS b
    JOIN Runs AS r ON r.CheckDate = b.CheckDate AND r.RunNum = 2
),
-- Pass 1: same key, same Details
SamePairs AS (
    SELECT c.ID AS LatestID, p.ID AS PreviousID,
           c.CheckID, c.DatabaseName, c.Priority, c.FindingsGroup, c.Finding,
           p.Details AS PreviousDetails, c.Details AS LatestDetails
    FROM Cur AS c
    JOIN Prev AS p
      ON  (c.CheckID      = p.CheckID      OR (c.CheckID      IS NULL AND p.CheckID      IS NULL))
      AND (c.DatabaseName = p.DatabaseName OR (c.DatabaseName IS NULL AND p.DatabaseName IS NULL))
      AND (c.Details      = p.Details      OR (c.Details      IS NULL AND p.Details      IS NULL))
      AND c.DupNum = p.DupNum
),
-- Rows not paired in pass 1, renumbered within the key
CurLeft AS (
    SELECT c.ID, c.CheckID, c.DatabaseName, c.Priority, c.FindingsGroup, c.Finding, c.Details,
           ROW_NUMBER() OVER (PARTITION BY c.CheckID, c.DatabaseName ORDER BY c.ID) AS KeyNum
    FROM Cur AS c
    LEFT JOIN SamePairs AS s ON s.LatestID = c.ID
    WHERE s.LatestID IS NULL
),
PrevLeft AS (
    SELECT p.ID, p.CheckID, p.DatabaseName, p.Priority, p.FindingsGroup, p.Finding, p.Details,
           ROW_NUMBER() OVER (PARTITION BY p.CheckID, p.DatabaseName ORDER BY p.ID) AS KeyNum
    FROM Prev AS p
    LEFT JOIN SamePairs AS s ON s.PreviousID = p.ID
    WHERE s.PreviousID IS NULL
)
SELECT TOP (1000)
       x.Status, x.Priority, x.FindingsGroup, x.Finding, x.CheckID, x.DatabaseName,
       x.PreviousDetails, x.LatestDetails, x.PreviousID, x.LatestID
FROM (
    SELECT 'Same' AS Status,
           s.Priority, s.FindingsGroup, s.Finding, s.CheckID, s.DatabaseName,
           s.PreviousDetails, s.LatestDetails, s.PreviousID, s.LatestID
    FROM SamePairs AS s

    UNION ALL

    -- Pass 2: leftovers with the same key are Changed; unpaired rows are New or Fixed
    SELECT CASE WHEN c.ID IS NOT NULL AND p.ID IS NOT NULL THEN 'Changed'
                WHEN c.ID IS NOT NULL                      THEN 'New'
                ELSE                                            'Fixed'
           END,
           COALESCE(c.Priority,      p.Priority),
           COALESCE(c.FindingsGroup, p.FindingsGroup),
           COALESCE(c.Finding,       p.Finding),
           COALESCE(c.CheckID,       p.CheckID),
           COALESCE(c.DatabaseName,  p.DatabaseName),
           p.Details, c.Details, p.ID, c.ID
    FROM CurLeft AS c
    FULL OUTER JOIN PrevLeft AS p
      ON  c.KeyNum = p.KeyNum
      AND (c.CheckID      = p.CheckID      OR (c.CheckID      IS NULL AND p.CheckID      IS NULL))
      AND (c.DatabaseName = p.DatabaseName OR (c.DatabaseName IS NULL AND p.DatabaseName IS NULL))
) AS x
ORDER BY CASE x.Status WHEN 'New' THEN 1 WHEN 'Changed' THEN 2 WHEN 'Fixed' THEN 3 ELSE 4 END,
         x.Priority, x.CheckID, x.DatabaseName, x.LatestID, x.PreviousID;

I ran it as BlitzReader, the fenced login:

USE DBA;
GO
EXECUTE AS LOGIN = 'BlitzReader';

-- report.sql

REVERT;

The results:

Check the Status column. Row 1 is New: Page Verification. Row 2 is Fixed: Auto-Shrink. Everything else is the Same -- exactly the two changes I made in Step 4.

What's Next

I'm leaving DBA, BlitzResults and BlitzReader in place for Part 2, where I'll schedule sp_Blitz and give Claude Code its own connection as BlitzReader, so it can query the results directly.

In Part 3, I'll compare what the agent says it did with SQL Server's own record of what Claude actually ran. Because I want to know what it really did, not just what it tells me it did.

More to Read

Grant Fritchey: How I built an app just by talking to an AI LLM (the series)
SQL Server First Responder Kit on GitHub
Brent Ozar: sp_Blitz
Brent Ozar: Using Claude Code with SQL Server and Azure SQL DB
Claude Code: Configure permissions

No comments:

Post a Comment