← All projects AI security · data protection

Keeping personal data out of an AI triage agent

Python · MCP tool servers · Claude API · Slack · Elastic · Okta

What this demonstrates: Handling sensitive data in an AI integration. I designed stand-ins that fail safe for addresses and places, kept real values inside the tool process, and turned two leaks into end-to-end regression tests.

Homelab project: a fake company, real systems, my own test traffic.

In short: my triage agent sends log data to a hosted model and posts verdicts to Slack. My first design hid only the values I'd listed, and it leaked twice. I replaced it with stand-ins for every address, place and outside email. I wrote each leak up as a failing test before fixing it.

Problem

When a detection fires, the agent looks up the user and their sign-in history in the SIEM and in Okta. It sends what it found to a hosted language model, then posts a short verdict to Slack. So log data leaves the lab twice.

The company is fake, but the logs aren't all fake. Every sign-in comes from one of my own devices, so the logs hold my real IP addresses and the places they map to. And anyone can type anything into a sign-in page, including a real person's email address.

At a real company the stakes are higher. An AI tool in the SOC shouldn't send employee or customer identifiers to a third party just because they were in a log line.

Design

Stand-ins applied inside the tool processThe SIEM and Okta return real values to the tool servers. The tool servers replace identifiers with stand-ins and keep the mapping in memory. Only stand-ins go on to the model, the Slack message and the audit log. A final gate checks the Slack message again. SIEM and Okta real values Tool servers: stand-ins address, place, outside email → [ip-1], [city-1], [email-1] the mapping stays in memory here Model stand-ins only Audit log stand-ins only Slack gate no outside email Slack
The highlighted boxes are the controls. Stand-ins are applied before anything leaves the tool servers, and the Slack message is checked again on its way out.

Before and after

The same tool result, before and after stand-ins. All values are made up.

Before: what a deny list let throughsynthetic example
get_ip_activity
  source_ip:  2001:db8:7::21      # not on the deny list
  country:    United States
  city:       Springfield
  usernames tried from this address:
    user-a@example-company           failure x5
    someone@outside.example          failure x1   # typed on the sign-in page
After: what the model and Slack seesynthetic example
get_ip_activity
  source_ip:  [ip-1]
  country:    [country-1]
  city:       [city-1]
  usernames tried from this address:
    user-a@example-company           failure x5   # fake employee: kept
    [email-1]                        failure x1

The verdict can still say "the same address also tried one other account". It can't say which account, or where the address is.

What I got wrong

First, a deny list

My first design masked a list of my known identifiers, like my home IP address and an old personal email. On the agent's first live run, one of my real IP addresses went to the model with its country and city. It was printed in the verdict and written into two audit records.

The masking code wasn't broken. The address came from another network my devices used, and it wasn't on the list. A deny list hides only what it holds.

What bothers me most is that I had a leak test, and it passed. It checked that listed values were masked. It never asked what happens to a value that isn't on any list. Since every sign-in in the lab comes from my own devices, "hide the ones I know about" was the wrong default for all of the data.

The same evening I switched addresses and places to stand-ins, kept the deny list as a second layer, and the next live run was clean.

Then the same mistake, for email

Emails were still masked only by the deny list. Someone had typed an outside email address on the sign-in page. It reached the model and the Slack verdict through a tool that lists every username tried from an address. It was the same lesson in a different field. I applied stand-ins to emails too, added the fixed admin label, and added the check before Slack.

What the model provider received can't be recalled. I didn't edit the affected audit records, because altering logs to hide a mistake is worse than the mistake. They age out under the normal retention.

How I tested it

Limits

What I learned

Ask "what about a value that's on no list?" before the first live run, not after. Listing what's allowed to leave fails safe. Listing what isn't allowed fails the first time the data has something you didn't think of.