
Asking a person before a risky tool call
How to put a person in front of an agent’s risky calls. A gate in tool middleware that the model can’t route around, raw evidence shown safely, a yes tied to a hash of exactly what was shown, denials that hold for the whole run, runs that pause and resume instead of blocking, and an audit log that reveals edits.
This page works on one stage of the loop
- 1Build the request
- 2Call the model
- 3Run the tools (this page)
- 4Decide
Some calls an agent makes deserve a second pair of eyes: an issue that people will read, an edit to authorization code, a refund.
The idea is simple and the details decide whether it protects anything.
Where does the check sit, what does the person look at, how far does one yes reach, and what happens at 3 a.m. when nobody replies?
Chapter 14 of the book answers those questions for the crash-triage agent, chapter 15 makes a waiting run survive a restart, and chapter 17 moves the decisions into a review queue.
The code is in ch14/safety and ch14/agent.
Put the gate in the harness
In the companion code, approval is a wrapper around a tool, the same kind of middleware chapter 12 uses for retries and timeouts. It sits outside those wrappers, so time spent waiting for a person never counts against a tool’s time limit and is never retried; only the check on outgoing text sits further out.
The alternative, a tool such as ask_permission that the model calls before acting, puts the choice to ask in the model’s hands.
A model can skip the call by mistake, and an injected instruction can tell it to skip the call on purpose.
A tool for asking a person a question is still worth having, for questions.
Permission has to come from code the model can’t route around.
Decide which calls need a person
A policy marks the calls that wait. The book’s default covers two kinds: anything that puts text in front of people (filing an issue or adding a comment, whether through the agent’s built-in tools or the MCP server in front of the tracker), and any write to a protected path such as the authorization package or an existing test. Each tool reports what a call would change, its targets, because only the tool knows which of its arguments names a file.
Read-only tools stay ungated. A call that has to wait for a decision runs in a stage by itself, so gating reads would give up chapter 9’s parallel reads and buy nothing.
Show the evidence, made safe to read
// An ApprovalRequest is what a person is asked to decide: the call, why
// it needs a decision, the evidence to judge it by, and the hash that a
// decision has to name to count. The hash covers everything the person
// is shown, so a decision applies to what they saw.
type ApprovalRequest struct {
Run string // the agent's name, from WithName
Call ToolCall
Why string
Evidence string // raw: the diff, the report excerpt
Targets []string // what the call would change
Hash string
}
The model’s own reason for a call is in the request, but it was written by the party the person is checking. So the request also carries raw evidence: the first crash report of the group, for a new issue, and the exact old and new text, for a patch. That evidence may be an attacker’s text as well, and a report can plead for its own approval, so the person’s job is to judge the change and ignore its arguments.
Before any of it reaches a screen, characters that could deceive the reader are shown by their code points.
An escape sequence in a report could redraw earlier lines on a terminal, and a right-to-left override could disguise a file name; each appears as something like <U+202E>, so the person sees that it was there.
Paths get a stricter treatment that also escapes anything outside printable ASCII, so a look-alike letter can’t pose as a plain one.
Tie each yes to what was shown
A decision counts only if it names the request’s hash, a SHA-256 over the agent’s name, the call ID, the tool, its arguments with their object keys in sorted order, the reason, the evidence, and the current bytes of every target file.
Two parts of that list do most of the work.
Without the call ID, approving one issue_file call would also approve an identical second call later in the run, and an approver that stores decisions by hash would let the model file that issue again and again.
Without the file contents, a yes given for a patch would still apply after someone changed the file underneath it.
The gate computes the hash again after the decision arrives, and if anything moved in the meantime, the call doesn’t run and the person is asked again.
Lean every default toward no
- A request that gets no answer before its deadline is denied, with “no decision in time” as the reason.
- A denial lasts for the rest of the run. The gate keeps it by hash and by each target, keyed in a way that ignores letter case and path spelling, so an identical change can’t return through another tool, with reworded text, or as
AUTH/auth.go. The issue tools give each filing a target of its own, its crash group, so a refused issue can’t come back under a new title. - The model receives a denial as a tool error,
ErrDenied, carrying the reason, and the run continues so the model can try something else. - Cancelling a run is never recorded as a denial, since nobody refused anything.
The second rule earned its place in a recorded run with no attacker in it:
3 other spellings tried
After its patch to auth/auth.go was denied, the model asked for the same file as auth/../auth/auth.go, auth//auth.go, and ./auth/auth.go. The path check refused the first; the gate’s memory of the denial refused the other two without asking anyone.
- Task
- the injection eval’s
i3-split-fieldstask, with every chapter 14 defense on and a scripted approver that denies changes to protected files - Models
openai/gpt-5.6-luna- Runs
- one recorded run, with a second showing the same pattern
- Date
- 16 October 2026
Chapter 14The finding
Pause the run instead of blocking
People answer in minutes, hours, or the next morning, and a run can’t keep a model call open while it waits.
So an approver with nothing to report returns ErrNoDecision, and the run stops with a typed error:
// ApprovalPending is the error a run stops with when a call is waiting
// for a person. Result.Pending holds that call and the ones after it;
// running the conversation again, with Limits.Approver set to where the
// decisions come from, resumes the run with those calls.
type ApprovalPending struct {
Requests []ApprovalRequest
}
func (p *ApprovalPending) Error() string {
names := make([]string, len(p.Requests))
for i, r := range p.Requests {
names[i] = r.Call.Name
}
return "approval pending for " + strings.Join(names, ", ")
}
The calls that came before the waiting one in the same reply have already run.
The waiting call and everything after it go into Result.Pending with no answers yet, since the later calls may depend on it.
Resuming takes two things: the conversation from that Result, with Limits.Before set to the Result itself, and Limits.Approver set to something that returns the decisions.
The loop runs the pending calls through the gate first, before it makes any model call, and the turns and money already spent count toward the resumed run’s limits, so pausing can’t be used to reset a budget.
A caller that slips an extra user message in after the waiting calls gets ErrBadResume, because the API expects the tool results to come first.
Make the pause survive a restart
Chapter 15 saves the run in a checkpoint, and three details connect it to approval.
The marker that says “this call went out” is written after the yes, not before the question, so a process that dies while a person is deciding leaves nothing to investigate.
The gate’s denials, which chapter 14 keeps only in memory, are rebuilt from the audit log when the command runs with -audit; without it, a refusal doesn’t outlive the process.
And when a crash leaves a call whose outcome nobody knows, the person is asked through the same approver, with a different question: did this take effect?
The crash sweep includes tools behind the gate, with a crash point while the person decides, and with idempotency keys its 1,063 scenarios made no duplicates and asked nobody about an outcome.
Keep a log that shows tampering
Every outcome at the gate goes into an append-only audit log: approvals, denials, calls refused because of an earlier denial, pauses, stale decisions, and whether an approved call then ran or failed. Each entry reaches the disk before the call proceeds.
A plain file can be edited by anyone with write access, and a plain hash chain can be recomputed by the same person. So each line carries an HMAC over itself and the previous line, keyed with a secret stored where neither the agent, its tools, nor test code can read it. The tests flip a denial to an approval, delete a line, and rebuild the whole chain under another key, and verification catches all three. Two limits remain: lines cut from the end of the log leave the chain intact, and an HMAC can’t prove that a person, rather than a script, made the decision. For a record other teams must rely on, the book suggests signing each line with a key only the harness holds and shipping it to a store that accepts additions but no rewrites.
What it costs in decisions
Chapter 14 replayed the book’s 30 recorded runs offline under the default policy.
It would have held 104 calls for a decision, roughly 3.5 per run: 78 were filings and comments, and 26 were protected writes.
Of the 15 writes into auth/, 14 were the agent fixing an already-filed bug outside its task, exactly what a person should catch.
One task’s run would have asked as many as seven times, and the recorded 99-report batch 22 times.
That volume is why chapter 17’s service puts decisions in a review queue, where a paused run waits without holding up any other group, approvals carry a deadline (a day by default), and a question about an unknown outcome has no deadline at all, since neither guess is safe.
The OpenAI Agents SDK has the same overall shape: a tool can declare that it needs approval, a run returns with pending interruptions, and the run’s state can be serialized and resumed later. What the book adds is binding each yes to a hash of the person’s view, and keeping each denial per target file.
Steps for your own agent
- Write the gate as tool middleware, outside retries and timeouts, and never as a tool the model chooses to call.
- Have each tool report its targets, and gate outgoing text and protected writes by default.
- Show the person the raw evidence with control and direction characters made visible.
- Hash the call ID, arguments, evidence, and target contents, and recheck the hash after the decision.
- Treat silence as a denial, remember denials by target for the whole run, and send them to the model as errors.
- Stop the run with a typed error when no decision exists yet, and resume from the saved conversation with the decisions.
- Log every outcome with a keyed chain, with the key out of the agent’s reach.
- Count how many decisions your policy asks for on recorded runs before you ship it.
When to use it
Reach for it
- The agent sends text to people or changes something you protect. Over the book’s 30 recorded runs, the default policy would have held 78 filings and comments and 26 protected writes for a person (chapter 14).
- A model may look for another way after a refusal. One model asked for a denied file under three other spellings, and a gate that remembers denials by file turned each one down (chapter 14).
Leave it out
- Read-only tools. A call waiting for a decision runs alone, so gating reads gives up parallel reads and protects nothing (chapters 9 and 14).
- Trusted input in volume, with a person at a terminal. The recorded batch run would have asked 22 times, which is why approval can be switched off for trusted input and chapter 17 adds a review queue (chapters 14 and 17).
Try it
From the code directory of a clone of the companion repository:
$ go test ./ch14/safety/ -run 'Gate|Audit|PauseResume|DenialByTarget' -v
The tests cover the hash, decisions that went stale, denials remembered by target, pausing and resuming a run, and tampering with the audit log. No key is needed, and nothing calls a model.