I've spent enough years guarding production databases to flinch whenever someone says "just give the agent read access to the customer table." That sentence is where most data leaks start. So when I started playing with Pi, the minimal coding agent from pi.dev, the first thing I wanted to test wasn't whether it could write code. It was whether I could stop it from handing real credit card numbers to another agent that had no business seeing them. This started as a thought experiment. It's now a running proof of concept, and the numbers at the end of this piece come from actually grepping the wire.
Pi is a good place to try this precisely because it's small. Four tools (read, write, edit, bash) under a thousand tokens of instructions. No MCP, no sub-agents, no permission popups baked in. The pitch is "adapt Pi to your workflows, not the other way around," and you customize it with skills, extensions, prompt templates, and a SYSTEM.md. That minimalism matters for what follows. When the harness isn't doing much, the boundary you build is the boundary that exists. There's no hidden middleware quietly logging the raw payload somewhere you forgot about.
The setup
Two agents, three databases, one Postgres container. fintechP is the production database: a relational bank dataset (customers, addresses, accounts, cards, transactions) seeded with Faker so every row carries realistic PII. Real-format SSNs, Luhn-valid 16-digit card numbers, 9-digit account and routing numbers, about 80 customers by default. fintechT is the test database: identical schema, deliberately empty. And a third database, bus, holds a single mq.messages table the two agents use as a mailbox.
The prod agent sits on fintechP and answers data requests. The test agent owns fintechT and needs rows to seed it: the classic internal data request. The lazy version is to let the test side query production directly, or worse, hand it the connection string. Now you have two systems holding the same live PII and twice the blast radius.
The version I built: the test agent asks over the mailbox, the prod agent answers, and the real card numbers and SSNs never leave the prod side's process. The transport is deliberately boring: each agent runs the same mailbox extension, one mailbox_send and one mailbox_wait that long-polls the bus table with FOR UPDATE SKIP LOCKED, so a message gets consumed exactly once. No shared filesystem, no shared database between the agents, just typed request bodies like { entity: "customers", limit: 5 } going one way and masked rows coming back.
One detail I like more than I expected to: both agents run the same extension code. Roles are config, not forks. The bank-db extension reads PI_DB_ROLE: as producer it registers a single tool called query_masked; as consumer it registers insert_rows, query_local, and a watermark tool. One file to audit, two behaviors.
Mask at the producer, never trust the consumer
Here's the part people get wrong. The instinct is to put the rule on the consumer: "don't store the raw values," "redact before you log." That's trusting the side you don't control. Maybe the test agent is fine today. Maybe next quarter someone forks its prompt and the redaction step quietly falls off. You'll never know until it shows up in a log aggregator.
The rule belongs on the producer, because the producer is the only side that ever touches the real data. It masks before the bytes cross the wire. By the time anything reaches the mailbox, the sensitive values are already gone. The consumer's behavior (careful, sloppy, compromised) stops mattering, because there's nothing dangerous left for it to mishandle.
This is just data minimization at the boundary, the same principle a DBA uses when deciding which columns a reporting role is even allowed to select. You don't hand out the full row and ask people to be polite about it.
Don't prompt for safety. Enforce it.
The tempting shortcut with any LLM agent is to write "never reveal full credit card numbers" into the system prompt and call it done. I don't trust that and neither should you. A prompt is a suggestion to a non-deterministic system. It holds until the model gets distracted by a clever request, or the context fills up and the instruction slides out of the window. Treating a prompt as an access control is how you end up explaining an incident.
So in this build the enforcement is two layers of code, and neither one consults the model's judgment.
The first layer is a hard tool block. Pi extensions can intercept every tool call, and the producer's does exactly that. Anything not on a three-item allow-list gets refused before it runs:
const PRODUCER_TOOLS = ["mailbox_wait", "mailbox_send", "query_masked"];
pi.on("tool_call", async (event) => {
if (!PRODUCER_TOOLS.includes(event.toolName)) {
return { block: true, reason: `Blocked: the production agent may only use ${PRODUCER_TOOLS.join(", ")}.` };
}
});That kills bash, read, write, and edit on the prod side. The model physically cannot open a psql session, read the .env, or hand-roll its own query. Its only route to data is query_masked, and that route always masks.
The second layer is the mask itself: one pure TypeScript module, no DB, no network, no model. query_masked runs every row through it before anything returns:
const { rows } = await dbPool().query(sql, args);
// THE BOUNDARY: mask every row here, before it is returned to the model.
const masked = rows.map((r) => maskRow(params.entity as Entity, r));The rules are per-entity and dull on purpose. last_name keeps its first letter: Johnson becomes J***. An SSN of 224-30-8280 becomes ***-**-8280. A card number keeps its first and last four: 4111 **** **** 1234. Emails become d***@yahoo.com. Dates of birth keep the year and zero the month and day, still a valid DATE, so the age math on the test side keeps working. Street addresses get redacted but city and state pass through. And transactions pass through untouched, because amounts and timestamps carry no direct identifiers, and the test side needs realistically-shaped data to be worth anything.
That last point is the design constraint that makes the whole thing usable: every masked value stays insert-compatible with the original column type. The test agent takes what it receives and inserts it straight into fintechT in foreign-key order: customers, then addresses and accounts, then cards and transactions. Same schema, same shapes, a card number that still passes a length check. Good enough to build and test against, useless to anyone who steals the test database.
There's also a proper incremental-sync path, because "reseed everything" isn't how real environments work. The consumer reads its local watermark (the max id it already holds per table) and asks production only for rows past it. New data lands in prod, the delta crosses the mailbox masked, and nothing already synced moves twice. It's change data capture with a security boundary in the middle, and the producer caps any single response at 200 rows so a confused agent can't ask for the world.
Verify it, don't vibe it
A security control you haven't tested is a hope. The nice thing about routing all traffic through one mq.messages table is that the wire itself is queryable after the fact. So the check is one line of SQL: scan every message body that ever crossed the bus for anything shaped like an SSN or a bare 16-digit card number.
SELECT count(*) FROM mq.messages
WHERE body::text ~ '[0-9]{3}-[0-9]{2}-[0-9]{4}'
OR body::text ~ '[0-9]{16}';Expected result: 0. Actual result, after seeding production with Luhn-valid cards and real-format SSNs and letting the agents sync customers, accounts, and cards across: 0. Meanwhile fintechP still shows the raw values and fintechT shows J***, ***-**-8280, 4111 **** **** 1234. The masking module is also a pure function, so it unit-tests in one line of Node with no database at all, and the repo ships model-free sync scripts that reuse the same mask.ts, so you can verify the boundary without spending a single token.
Why the minimal harness helps
This pattern works on any agent framework in principle. It's easier to trust on Pi because there's so little between the tool and the wire. The agent loop is small, the tool surface is four primitives, and the customization is files you can read in one sitting. When a security reviewer asks "where exactly does the SSN get masked, and can anything bypass it," I can point at one module (mask.ts is the entire security boundary) plus a ten-line tool block, and actually answer. The prod agent even prints its full trace every cycle, every tool call and result, so you can watch what it does before any data goes back. Auditing a fat framework with plugins, hidden middleware, and a dozen injection points is a much worse afternoon.
That's the part I keep coming back to. The masking is trivial. The discipline is putting it at the producer, in code, on a harness thin enough that the boundary is the whole story. Give an agent the real customer table and a polite instruction, and you've built a leak with extra steps. Give it a tool that can only ever return masked rows, block everything else, and grep the wire afterward to prove it, and the question of whether the other agent behaves never comes up.
