Get started

Engineering NotesSecurity and audit

Told to confirm first, the model still sends the email, so approval happens right before execution

You can write "Confirm with the user before sending email" in the system prompt, and the model can still call the email tool without asking (“Permissions are enforced in code, judgment is left to the model”). So in OwlAI, when the model calls a tool that requires approval, the server stops the loop right before that tool runs and waits for a person's answer. Showing an approval prompt is not enough on its own. If the place that receives the answer is created after the prompt goes out, an answer clicked very quickly is lost. If the screen draws a button the server doesn't accept, a turn where someone presses it stalls. If the model calls again after a rejection, prompts keep coming. And if an administrator tightens a rule, an old "Always allow" still lets the call through.

This post covers the details of approval right before execution: what a person sees in the approval prompt, what is held where while the answer is pending, what the model receives after a rejection or an expiry, how far "Always allow" reaches and how it is revoked, and what happens in a scheduled run with no one there to approve. Which tools require approval is covered in “Permissions are enforced in code, judgment is left to the model” and “Customers know their connector tools' risks, so tool policy is per install”.

The check right before execution decides whether to ask

You could leave the confirmation to the model: give it a tool for asking the user a question, and tell it to ask before sending email. The model can read the context and ask only on risky calls, but it still decides whether to ask, so the one time it doesn't is the incident. A confirmation dialog that lives only in the screen has the opposite problem: the server doesn't know about it, and a call that doesn't go through the screen runs unconfirmed.

So the decision is made at the step where the server checks permissions before running a tool. For each call, that step returns allow, block or require approval. On require approval, the approval procedure runs right there, and only an approved call runs. Calls the model makes directly, calls made from inside a script (“Tool calls a script makes still get permission and approval checks”), and the file tools the desktop app runs on the user's PC all go through the same step and the same approval procedure.

If the check itself fails, the call does not run and the model gets the reason policy_unavailable. Passing an unjudged call to the approval procedure would let a single user approval run a tool the administrator blocked. The failure is not remembered, so once the next check succeeds, the tool follows its normal permissions again.

The approval prompt shows every argument the tool will receive

The approval prompt shows the tool name, the connector name, and each argument the model passed to the tool, one line per key. The screen shows every key the model sent. Connector tools name their arguments differently from server to server, so with a fixed list a tool from an unfamiliar server would show no arguments at all. Marking three emails as read and deleting a hundred would then look like the same prompt.

The server shortens values before sending them. A long value carries only its beginning, and the screen adds "(showing part of N characters)" to that line so the approver knows something is hidden. When a whole value is an address, the host and path stay intact and only the tail of the query is shortened. When you approve a tool that uploads a file or opens a page, what matters is where it goes. Tools that delete or move files on the user's PC carry a warning that the action cannot be undone.

The server also decides the buttons. It sends the choices it will accept for that call (approve, "Always allow", reject), and the screen draws exactly those buttons. If the screen always drew all three, pressing "Always allow" on a tool that doesn't accept it would stall the turn.

The place to receive the answer comes first, then the prompt

One approval runs in this order:

  1. The server creates a pending item for the call in Postgres.
  2. It sends an approval request to the screen. The request carries the tool name, the connector name, the argument preview and the choices the server will accept.
  3. The loop running the turn waits in place, reading the pending item's status at short intervals.
  4. When the answer arrives, it is turned into approve or reject and handed back to the execution step. If the wait cap passes first, the request expires.
One approval round trip, from the pre-execution check to running the call or returning a reason
Figure 1. One approval round trip, from the pre-execution check to running the call or returning a reason

The pending item is created before the prompt because of a race. In the reverse order, a user who clicks the moment the prompt appears would find no item to receive the answer. The pending item lives in Postgres rather than in process memory because the web process that receives the answer is not the process running the turn. Whichever process the answer arrives at updates one row, and the waiting loop reads that row. Pending items are deleted once processed.

The wait cap is 600 seconds by default, adjustable with timeouts.userInteractionParkSeconds. While it waits, the loop and the conversation context stay in memory, and one thread is tied up by the wait.

The API that receives answers checks three things: that the requesting account owns the pending item, that the conversation matches, and that the item hasn't already been answered or cancelled. An answer to another account's item is rejected, and an answer to an item already settled comes back as a conflict. Another answer or a cancellation can slip in between those checks and the actual write, so the final write is a single UPDATE with the condition "change it only if it is still pending." When two requests arrive at once, only one wins, even if they land on different processes. When a user stops a turn, only the pending items in that person's conversation are cancelled, and the waiting loop ends the turn.

Rejections and expirations go back to the model as the reason the call didn't run

If the user rejects the call, or no answer comes before the cap, the call does not run and the model receives a reason code as the tool result: approval_denied for a rejection, approval_timeout for an expiry. Each code comes with what to do next: don't call again with the same arguments, tell the user why it didn't run, and continue with whatever can be done without that tool.

The model can still issue the same call again. So within one turn, once the same tool with the same arguments has been explicitly rejected twice, the next identical request is blocked without asking and returns approval_denied_repeated. The count is set by toolPolicy.approvalDeniedBlockThreshold (default 2). A separate check blocks the same call with the same arguments made several times in a row, but a call that requires approval goes to the approval procedure first and never reaches that check. Without a separate count of rejections, rejection and re-request prompts would go on endlessly. An expiry is not the user saying no, so it doesn't count, and an approval clears the count for that combination.

"Always allow" covers one person and one tool, and asks again when the rule changes

When a user presses "Always allow", the current call is approved and the pairing of that person and that tool is recorded. From then on, whenever that person calls the tool in any conversation, it is approved without a prompt. The record is per tool, so different arguments to the same tool aren't asked about. It does not apply to other users.

These built-in tools require approval, and this is whether each accepts "Always allow":

Tool "Always allow"
Reading files on the user's PC (desktop app) Accepted
Writing, editing, deleting or moving files on the user's PC Accepted
Fetching files from an external address Accepted
Uploading a PC file to the sandbox Not accepted
Uploading a sandbox file to an external address Not accepted

The two tools that don't accept it send files out of where they currently are. For these two, "Always allow" is removed from the choices, and any trust already recorded is ignored so the user is asked every time. Removing the choice alone would leave earlier recorded trust in place to pass calls through unasked.

"Always allow" is consent given under the rules in force when it was recorded. So if the administrator rule that requires approval changed after the trust was recorded, the user is asked again despite the record. When an administrator tightens a tool from allow to require approval, the prompt appears right away even for someone who already pressed "Always allow".

Trust is revoked on the Auto-approve tab of the Connectors screen. Tools for which the user pressed "Always allow" are grouped by connector, built-in tools appear as system tools, and tools from deleted connectors stay in a group of their own. After revocation, the next call asks again. "Always allow" given in chat is not used in playbook runs. Runs with no one present have their own pre-approval, described next.

Runs with no one present use only the tools the owner approved in advance

A scheduled playbook run has no one to answer an approval prompt. Blocking tools that require approval makes the schedule fail silently every cycle; letting them through makes the administrator's approval requirement meaningless.

So the owner approves once before runs. On the first run, or when the playbook is enabled or scheduled, a Tool use approval dialog appears if any connector tool requires approval. Until approval is given, enabling and scheduling are rejected with PLAYBOOK_TOOL_APPROVAL_REQUIRED. Approval is per playbook and per (connector, tool) pair, so a tool used by several steps needs only one approval. When a run starts, only approvals that are still valid are laid out, and only calls to those pairs pass. Any other call that requires approval is rejected without asking, returning approval_unavailable.

Pre-approval has no expiration date. With an expiry, a schedule would one day fail silently. Instead, approval becomes invalid the moment the premises of consent change, and the dialog shows why:

  • The administrator rule that required approval changed ("Permission settings changed — previous approval expired")
  • The playbook definition changed ("Playbook content changed — previous approval expired")
  • The owner changed ("Owner changed — previous approval expired")

Whether the definition changed is judged by a fingerprint of the whole definition. Moving a step on the canvas doesn't change it; changing instructions, settings or step order does. Editing a work type an administrator registered also invalidates approval, because the work type's version is part of the fingerprint. Only the owner can approve. The run executes with the owner's permissions, so the responsibility for approval sits with the same person.

Approval decisions are recorded in the audit log, one line each. Approve, "Always allow," reject, and expiry in the approval prompt; calls approved without asking because of "Always allow"; calls blocked without asking after repeated rejections; revoking "Always allow"; and granting and revoking pre-approval all appear on the administrator's audit event screen. Each line carries only the tool and connector name and the conversation and turn (the playbook, for pre-approval), never the argument contents (“Auditing an AI agent: log every query it runs, not the conversation”).

Deep research researchers also run with no one watching. Researchers never receive tools that require approval in the first place (“Parent-picked subagent tools skip approval, so code decides them”).

Design choices

We chose the tool, not argument content, as the unit of the approval decision. Grading risk by arguments would be finer-grained: the same email tool could skip the prompt for internal recipients, for example. That would require product code to know what the arguments of external servers mean, and those servers differ from customer to customer. So the decision stays simple at the tool level, and the arguments are laid out in the approval prompt for a person to see and decide. The cost is a prompt even for safe calls; for frequently used tools, "Always allow" cuts how often it appears.

The argument preview sends shortened values rather than the originals, so the approval request doesn't carry credentials or personal data verbatim. The cost is that a long email body can't be read to the end in the prompt; the fact that it was shortened, and its original length, are shown on screen.

A turn awaiting approval waits in place instead of being put down and stood back up. Putting it down would free the thread, but standing it back up would require restoring the conversation context and the round in progress intact. Waiting in place keeps that state safe, and the 600-second cap bounds the cost of holding the thread.

The pre-approval fingerprint covers the whole definition rather than each step. A per-step fingerprint would keep approvals for untouched steps alive and mean fewer re-approvals. But "only step 3 changed, so the step 1 email approval can stand" is a judgment a person has to make. So when anything changes, the owner looks again.

If you are attaching tools that write to external services to an agent, start by putting the approval decision, the wait and the choices in one place on the server, right before execution. And attach to any standing consent such as "Always allow" the condition that it asks again when the rule it relied on changes.