Get started

Engineering NotesSecurity and audit

Answer-only checks leak via Excel: one check for every exit path

In a conversation that analyzes customer inquiries, someone without permission to view originals sees the phone number in the answer as [PHONE-1] (“Scrubbing answers is too late for phone numbers. We mask tool results”). But if the Excel report the agent built in the same conversation carries the real number, or the agent posts that number in an issue comment, the check that protected the answer stopped nothing. A rule that inspects only chat answers opens exactly this gap once an agent starts producing files and writing to external services. The gap raises no error, so it stays hidden until someone opens that file or comment.

This post defines the three paths through which data leaves (chat answers, generated files and tools that write to external services) and describes the design that sends all three through the same checking code. That code masks personal information according to the permissions of whoever receives it, has a person review and approve what goes out to external services, and records what it did in the audit log. The decisions are rules, not LLM calls, so the same content always gets the same result.

The checking code all three paths pass through, and what it does
Figure 1. The checking code all three paths pass through, and what it does

Data leaves through three paths

A path here means a place where data reaches a person or system outside OwlAI. There are three.

Path What goes out
Chat answer The answer the user reads on screen, with its tables and charts
Generated file Excel, Word, PowerPoint and Hangul documents, CSV exports and diagrams the model creates for the user to download or pass on
Tools that write to external services Sending email, creating issues and comments, posting to a messenger, tools from connectors (external services such as MCP servers that a company connects to OwlAI), and requests that upload files to an outside address

Playbooks (automated jobs that run predefined queries and reports on a schedule) have no path of their own. A playbook's results go to the person who ran it, through the run history and in-app notifications. Sending results to someone else takes a mail connector, so that email leaves through the third path.

A separate check per path misses the next path

The common approach is to attach a check to each path. A mail security product inspects the body and attachments of outgoing mail, and the document feature inspects its own files. It is easy to add, and each path can have rules tailored to it.

In an agent this approach falls behind quickly. Every new tool is a new path. Connect one connector and every tool that server exposes becomes a path at once. A new tool that skips the check still runs normally and raises no error. The rules also drift apart from path to path, which is how a number masked in the answer goes out unmasked in the CSV.

So we tied the place that defines paths to the place that applies the check. Each built-in tool's declaration carries a "sends data out" marker, and the arguments and outputs of every tool with that marker pass through the same checking code before the tool runs. A test confirms that the list of tools that send data out and the list of tools that pass the check are exactly equal. A test that only checks whether one list is contained in the other misses a new tool that was added to the list but not wired into the check.

Connector tools depend on which servers each customer connects, so product code cannot know in advance what they do. So every argument passed to a connector tool goes through the checking code, and the admin decides, tool by tool, which of them require approval (“Permissions are enforced in code, judgment is left to the model”).

Personal information is decided at the moment it leaves, by the receiver's permissions

The model writes from tokens like [PHONE-1] instead of originals from the start (“Scrubbing answers is too late for phone numbers. We mask tool results”). So the job at the path is deciding whether to turn a token back into the original or leave it. The deciding factor is the permission to view originals held by the user on that path.

  • A chat answer follows the permissions of the person viewing it. The answer is stored with tokens in it, and only people with permission can expand the originals on screen. Someone who received the conversation as a share sees it with their own permissions.
  • A generated file follows the permissions of the user who asked for it. With permission, the file is filled with originals; without it, the file holds tokens.
  • A tool that writes to an external service follows the permissions of the user who called it. Email a playbook sends through a mail connector is decided by the permissions of the user who ran the playbook.

This decision belongs at the path because the same conversation calls for different results depending on who receives it and how. A person with permission reading on screen should see the original; someone the conversation was shared with, without permission, should see the token. Which one applies is known only when the content passes through the path.

The checking code also looks again through the model's text for values with a fixed format, such as phone numbers, email addresses and account numbers. If a value that masking at the input missed has slipped into the model's text, it is masked here when it goes to a receiver without permission.

Personal information is the only thing changed on a path. Names, codes, numbers, dates and status values that were queried go into answers and files as they are. Who can see which values was already settled by permissions at query time (“Auditing an AI agent: log every query it runs, not the conversation”).

What goes to external services is reviewed and approved by a person

Email, issue comments and messenger posts cannot be pulled back once sent. So the third path includes a step where a person looks at the content and decides. A tool that uploads files to an outside address asks for approval every time, and a connector tool asks only if the admin has put approval on it.

The approval prompt shows the arguments after the personal-information check. The email body the user sees in the approval prompt is the email body that goes out. A call where the model passed an argument as a file path (“Tool calls a script makes still get permission and approval checks”) is shown with the file's contents expanded, so the person approving never clicks through on a bare path string.

Table names are not rewritten; they are handled by instruction and logs

Table and column names are also information we would rather keep out of answers. A business user should read "monthly sales by branch," not TB_SALES_MON.BR_CD. But the checking code does not rewrite these names, for two reasons.

First, the only names that can appear in an answer belong to databases the user is allowed to query. They come from that user's schema lookups and the analysis catalog. Unlike personal information, they do not leak beyond the user's permissions.

Second, a check that changes content must never be wrong on any customer's schema. A column named with a plain English word such as order, result or version cannot be told apart from the same word in a sentence, and a wrong call changes a perfectly good sentence.

So the rule against using these names is given by the system prompt, in the same words for all three paths. It covers not only answers but file bodies, file names, table titles and headers, and messages sent outside. The checking code compares the text passing through a path against the names that schema lookups returned in this conversation and the names in the analysis catalog, and logs the names that match exactly. Names the user typed themselves are left out of the comparison. As the logs build up, they show on which path the instruction is followed least, and we fix the prompt wording there.

What the check did is recorded in the audit log

What the checking code does is recorded in the audit log. Each entry records four things.

  • The path where the check happened
  • The user who triggered that run
  • What was done (personal information masked, originals restored, approval requested) and how many times
  • For approvals, who decided and when

The original content that went out and personal information values are not stored in the audit log. Admins use these records to see what gets caught most on which path, and adjust column classifications and tool policies.

Design choices

We chose one check in one place over a separate check per path. We gave up the advantage of rules tailored to each path. In exchange, the checking code has to read content in many shapes: documents, CSV, tool arguments. It reads documents at every spot that holds text, and tool arguments after expanding them. What we get back is one place to change the rules and one place to test them, and the list comparison test fails when a new tool skips the check.

We chose rules over having an LLM rewrite outgoing content. An LLM can read context and rewrite even values with no fixed format. But passing a document dozens of pages long, or a CSV of thousands of rows, back through model output cuts off whatever exceeds the output limit, leaves no way to notice when one number in a rewritten table changes, and adds a wait after the answer is already complete. Rules give the same result for the same content every time and record exactly what was masked.

We chose to check every argument to a connector tool over trusting the "read-only" marker a connector server attaches to its tool declarations. That marker is optional, so servers may or may not set it, and how far it can be trusted varies by server. Checking the arguments of a tool that only reads costs one string comparison.

We handle table names with instruction and logs instead of rewriting them. The cost is that a name the model writes against the system prompt goes out in that turn. When the logs point to that spot, we fix the prompt wording.

If your agent creates files or writes to external services, put in a test that confirms the list of tools that send data out matches the list of tools that pass the check, before refining the check path by path. A path that skips the check raises no error.