Get started

Engineering NotesSecurity and audit

Scrubbing answers is too late for phone numbers. We mask tool results

"Find the customers who asked for a refund more than three times last week and summarize what they said" is not a question a single number can answer. The model has to read rows from the support history table, and those rows hold customer names, phone numbers, and email addresses. The support notes column sometimes holds the bank account number a customer read out for the refund. The moment these values reach the model as a query result, they go to the model provider, and they ride along in the conversation context back to the model on the next turn. Scrubbing them from the answer cannot bring back what has already gone out.

So we mask personal data just before a tool result becomes model input. Column classification from schema analysis and the format of the value together decide what gets masked, and the check is a set of rules with no LLM involved, so the same result has the same values masked every time. Each masked value becomes a token that stays the same for the whole conversation. The model counts the same customer using tokens alone, without the original, and when it queries that customer again the server puts the original back in. This post explains where the masking happens, what it masks, and why tokens are scoped to a conversation.

Tool results are masked before the model sees them; originals stay in a server-side mapping
Figure 1. Tool results are masked before the model sees them; originals stay in a server-side mapping

Prompt instructions cannot stop a question that needs rows

The system prompt tells the model not to query personal identifier columns without reason, and to return them masked or aggregated when the question really needs them. It also tells the model to aggregate in the database instead of pulling rows and counting them itself. For a question that ends in a number, such as "how many customers cancelled last month," no row values come back as long as the model follows these instructions.

The question in the first paragraph is different. Summarizing the inquiries means reading rows, and picking out the people who asked more than three times takes some value that tells customers apart. Even if the model leaves the phone number column out of its query, the account number inside the support notes comes along. On top of that, the model follows system prompt instructions most of the time, not always (“Permissions are enforced in code, judgment is left to the model”). We use the instructions to cut unnecessary queries, and code takes on the job of keeping personal data away from the model.

Three things a fixed answer cannot undo

Finding and removing personal data after the answer is written is simple, and it does protect the answer on screen. Once the model has read an original, though, fixing the answer does not undo the following three things.

  • The original has gone to the model provider. In an installation that uses external models, there is no way to delete what was sent. Keeping sensitive databases out of external-model conversations altogether is covered in “External models never see internal-only data, not even its name”.
  • The original stays in the conversation context. Tool results from earlier turns are sent again as model input on later turns, so the original goes out again on every turn while the conversation continues.
  • The answer is not the only way out. The model can write originals into an Excel file, put them in an email body, or post them as an issue comment. The design that checks these exits together is covered in “Answer-only checks leak via Excel: one check for every exit path”.

Masking on input solves all three in one place. The model never receives the original, so it cannot put the original in front of the provider, into the conversation context, or into files and email.

Column classification and value format decide what gets masked

Two criteria pick the values to mask.

The first is column classification. Schema analysis decides for each column whether it holds personal data, and an administrator can correct that decision on the analysis results screen. Values in a column classified as personal data are masked whatever they look like. Values with no fixed format, such as names and addresses, can only be found this way.

The second is value format. Values with a fixed format, such as resident registration numbers, phone numbers, email addresses, account numbers, and card numbers, are masked whichever column they came from. This criterion also covers data with no columns: document search results, results from connectors (external services a company connects to OwlAI), and web page text. A phone number someone typed into an unclassified remarks column is masked here too.

Each criterion fills the other's gaps. Column classification exists only for database query results, and value format cannot recognize personal data without a format. Values without a format are masked by the classification schema analysis assigned from what the column means, and formatted values are masked by format wherever the data came from.

Masking happens just before a tool result becomes model input

When a tool runs, its result goes through a series of cleanup steps before it reaches the model. Binary file data is replaced with a size marker, and transfer URLs carrying embedded access tokens are replaced with short references (“Keeping presigned URLs out of the model with 19-character references”). Then personal data is masked, and last, large results are set aside as sandbox files. These spill files are copies the model reads in pieces or through scripts when a result is too large, so they must hold the masked values. That is why setting the file aside comes after masking. In the other order, originals would be left on disk.

There is only one such point for every feature. Chat, playbooks, deep research researchers, and meetings all run the same agent loop, and every tool result passes through the same executor (“One agent loop for every feature, with the differences in hooks”). SQL results, document search results, and connector results are masked by the same rules, and when a new tool is added, its results pass through this point too.

The same value always gets the same token within a conversation

Replacing values with *** would make the question in the first paragraph unanswerable. The model would have no way to tell whether four rows belong to the same customer, or whether people in three results are the same person. So each masked value becomes a token with a kind and a number. The first phone number in a conversation becomes [PHONE-1], a different number becomes [PHONE-2], and the same number keeps the same token until the conversation ends. The model sees [PHONE-3] on four refund requests and finds that customer without ever seeing the original.

The mapping between tokens and originals is kept outside the model, on the server, separately for each conversation. Deleting a conversation deletes its mapping too. The same number gets a different token in another conversation, so collecting tokens from several conversations to follow one person does not work.

For follow-up queries, the server restores originals just before execution

Ask a model that sees only tokens "Show me that customer's earlier orders too," and it writes SQL with [PHONE-3] in the condition. Just before sending the SQL to the database, the read-only execution tool turns the token back into the original using this conversation's mapping. The database runs the query with the real number, and the returned rows are masked again by the same rules and reach the model with the same token. Originals travel only between the company database and the server, and they are not included in model input.

Restoration applies only to tokens in that conversation's mapping. Whether originals go into tools that write to outside services, such as email or issue comments, is decided by the exit checks covered in “Answer-only checks leak via Excel: one check for every exit path”, based on the original-viewing permission of the user running that tool.

Only people with permission can reveal originals on screen

The model's answer arrives with tokens in it. A user whose role an administrator has granted original-viewing permission can click a token on screen to reveal the original; a user without that permission sees only the token. Someone who opens a shared conversation sees it according to their own permission.

Revealing an original and downloading a file that contains originals are recorded in the audit log. Who opened originals in which conversation, and when, is recorded line by line; the revealed values themselves are not stored in the record. An administrator's change to a column classification is recorded as a settings change.

Design choices

We chose rules over an LLM for the check. A model that detects personal data (named entity recognition) placed in front would also catch values without a format, such as a name inside a support note, and that is a real advantage. But to make that check, the original has to be sent to yet another model, the same result is not guaranteed the same verdict every time, and each tool result waits for one more model call. Column classification handles values without a format, so the check stays with rules that give the same result for the same input.

We chose to mask more rather than less. When an order number that looks like an account number is masked along with real account numbers, business answers get less convenient. So we log which kinds of values were masked in which columns and how often, and administrators use that record to reclassify a column as a business number rather than personal data. The corrected classification applies from the next query. An original that has gone out cannot be taken back, while an over-masked value returns with one setting.

We do not mask values the user typed. When a user types a customer number to ask about it, querying by that number is the point of the question, and the user has already handed that value to the model.

Tokens are numbered fresh for each conversation rather than across the whole installation. Giving the same number the same token across the installation would let you link a customer in a past conversation to one in this conversation. But the token would then become one more identifier that points to the same person everywhere. The cost of numbering per conversation is that comparisons across conversations cannot be made with tokens; a person with original-viewing permission makes them on screen.

If your agent reads company data and passes it to external models, the place to mask personal data is the single point where tool results arrive, not after the answer. Make every feature pass through that point and keep the mapping of originals outside the model, and the model can analyze with tokens while only people with permission see the originals.