How to add human approval to agent code execution
The first version of this that everyone builds asks a human before every tool call. It survives about four days. By day two the reviewer is clicking approve without reading, because the agent's ninth call in a row was a directory listing and the tenth was reading a requirements file. By day four someone adds an allowlist to stop the notifications. The allowlist grows every time it fires. Within a month the gate waves through everything that matters and blocks nothing.
The other failure is quieter. No gate at all, the agent has a shell and whatever credentials the process it started in happened to be holding, and one afternoon it decides the fastest route to a green test run is to drop the table and recreate it. Nobody notices until the next morning.
Both come from the same mistake: treating approval as a property of tool calls when it is a property of effects. A tool call is a request. An effect is what survives after the agent is gone. Almost all of an agent's tool calls have no surviving effect at all, and the design that works is built around that fact rather than in spite of it. I build PandaStack, so the sandbox in the code below is mine, but the shape works with any provider that gives you an isolated execution target.
Approving everything is the same as approving nothing
Human attention is a fixed budget and a small one. If you spend it on a listing of a temp directory, you do not have it when the agent proposes a force push. This is not a discipline problem you can train out of a reviewer. It is what happens to anyone asked to make the same decision forty times an hour when thirty-nine of them are obviously fine.
So the interesting number is not how many calls you gate. It is the ratio. If more than a few percent of an agent's calls stop for a human, the gate will be defeated, and it will be defeated by the people who installed it. Design for a reviewer who sees three or four prompts a day and reads all of them, not for one who sees eighty and reads none.
That means the work is not in building the approval UI. It is in getting the auto-approved set as large as you honestly can.
Classify the effect, not the call
Three questions sort nearly everything, and they are about consequences rather than about commands.
- Can I undo this by throwing away the machine it ran on? If yes, it is reversible and it does not need a human.
- Does it touch anything outside that machine — a network write, a credential, a shared file store, a real database? If yes, it needs at least a record and probably a gate.
- Will anyone other than the agent see the result? Opening a pull request, sending an email, publishing a package, posting to a webhook. These are irreversible in the social sense even when they are technically revertible, and they are the ones people actually regret.
The first question is where isolation earns its keep. In a disposable microVM, an agent running a recursive delete of the root filesystem is not an incident. It is Tuesday. The blast radius is one VM that was going to be destroyed in ten minutes anyway, and the recovery is to create another one, which on a snapshot-restore path costs about as long as an HTTP round trip. The exact same command on a developer laptop with the company monorepo mounted is a career event.
The second and third questions carve out a much smaller set. In practice it is a short list you can write down: pushes to a remote, calls to your own production API, anything holding a live credential, package publishes, infrastructure CLIs, and direct connections to a database that is not the throwaway one you created for this run. Everything else is the agent flailing around inside a box, which is what you hired it to do.
Where the gate lives
Three options, and the middle one is usually right.
In the agent loop is the easiest to reach for, because most frameworks have an interrupt or a before-tool hook. It also means your policy is written in your framework's idioms, so moving from one framework to another means rewriting it, and a second agent in the same codebase gets a second copy. Fine for a prototype.
In the tool wrapper is where it belongs for most teams. The gate becomes an ordinary function that sits between the model's request and the execution call, it is testable without running an agent at all, and every framework you bolt onto it inherits the same policy. It has one real weakness: it only sees calls that go through it. An agent that gets a raw shell inside the sandbox and then writes its own HTTP client has routed around you.
At the platform is strongest and rarest. If the network namespace itself refuses to route to anything but an allowlist, then no amount of creativity inside the guest gets out. If you have that, use it, and treat the wrapper as ergonomics rather than as the security boundary.
A wrapper you can actually read
The classifier first. Keep it boring, keep it a list, and keep it in a file a human can review in a code review. Clever classification is how you get a gate whose behaviour nobody can predict.
# pip install pandastack
import re
from dataclasses import dataclass
# Anything whose effect can survive the sandbox being destroyed.
GATED = [
(re.compile(r"\bgit\s+push\b"), "pushes commits to a remote"),
(re.compile(r"\bgh\s+(pr|release|repo)\b"), "creates something on GitHub"),
(re.compile(r"\b(aws|gcloud|az|kubectl|terraform)\s"), "touches real infrastructure"),
(re.compile(r"\b(npm|pip|poetry|cargo)\s+publish\b"), "publishes a package"),
(re.compile(r"\bpsql\b|\bmysql\b"), "connects to a database directly"),
(re.compile(r"-X\s*(POST|PUT|PATCH|DELETE)"), "sends a write request off-box"),
]
@dataclass(frozen=True)
class Verdict:
gated: bool
reason: str
def classify(cmd: str) -> Verdict:
for pattern, why in GATED:
if pattern.search(cmd):
return Verdict(True, why)
# Everything else is confined to a VM we are about to throw away.
return Verdict(False, "confined to the sandbox")Then the wrapper. Note what it returns when a human says no: a normal tool result with a readable explanation, not an exception. An agent that gets a clear denial usually proposes something narrower on the next turn. An agent that gets a stack trace tries the same thing three more ways.
import json, time, uuid
from pandastack import Sandbox
class GatedSandbox:
def __init__(self, approve, run_id: str):
self.sbx = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
self.approve = approve # your callback: returns True / False / None
self.run_id = run_id
self.audit = []
def _record(self, cmd, verdict, outcome, ms):
self.audit.append({
"run": self.run_id, "at": time.time(), "cmd": cmd,
"gated": verdict.gated, "reason": verdict.reason,
"outcome": outcome, "ms": ms,
})
def exec(self, cmd: str, timeout_seconds: int = 60) -> str:
v = classify(cmd)
if v.gated:
req_id = str(uuid.uuid4())
decision = self.approve(req_id=req_id, cmd=cmd, reason=v.reason)
if decision is not True:
# None means nobody answered in time. Irreversible fails closed.
outcome = "denied" if decision is False else "timed_out"
self._record(cmd, v, outcome, 0)
return json.dumps({
"status": outcome,
"message": f"A human did not approve this command ({v.reason}). "
"Propose an approach that stays inside the sandbox, "
"or explain why this step is necessary.",
})
t0 = time.time()
r = self.sbx.exec(cmd, timeout_seconds=timeout_seconds)
self._record(cmd, v, f"ran:{r.exit_code}", int((time.time() - t0) * 1000))
return json.dumps({
"status": "ran", "exit_code": r.exit_code,
"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:],
})
def close(self):
self.sbx.kill()The audit list is doing more work than it looks like. Every entry, including the auto-approved ones, is the thing you will want at three in the morning when someone asks what the agent did. Ship it to whatever you already use for structured logs and keep it longer than you think you need to.
What a human needs to see in five seconds
Show the literal artefact. The exact command, the exact diff, the exact request body and destination host. Do not show a model-written summary of what the agent intends to do, because the summary is generated by the same system you are checking, and a summary is exactly where a prompt-injected agent would put the reassuring version.
A prompt that works has four things in it: what is about to run, where it will run, why it stopped for a human, and what happens if nobody answers. That last one is missing from almost every implementation I have seen and it is the one that makes the decision fast, because it tells the reviewer whether they can safely ignore it.
On timeouts, split the default by tier rather than picking one. Irreversible actions fail closed: no answer means no. Sandboxed actions that you gated out of caution rather than necessity can fail open after a short window, because the worst case is a wasted VM. Pick the window from how the reviewer actually lives — an hour is reasonable for a working day, and anything under about five minutes will produce denials that are really just people being in a meeting.
Make the approval asynchronous. A gate implemented as a blocking input call inside the agent process means a Python process pinned open for forty minutes waiting for someone to come back from lunch, and a sandbox billing the whole time. Post the request to Slack or a webhook, persist it, and let the agent run resume when the answer arrives. If your sandbox can hibernate or snapshot, take one while you wait: the state is preserved, you stop paying for idle compute, and a restore puts you back where you were in well under a second.
Gate what leaves, not what runs
The pattern that scales best puts the boundary somewhere else entirely. The agent gets a sandbox with no credentials and no route to anything that matters, and inside it, it can do absolutely anything it likes without asking. The gate sits on the exit.
Concretely: the agent writes its intended external effects as files into an outbox directory. Your host process drains that outbox between turns, classifies each request, gets approval for the ones that need it, and performs the effect itself using credentials the agent never sees. The agent proposes; your code disposes.
import json
OUTBOX = "/workspace/outbox"
# The agent is told: to affect anything outside this machine, write a JSON
# file into /workspace/outbox. It has no tokens and no route to prod.
def drain_outbox(gs: GatedSandbox, approve, effects: dict) -> list[dict]:
"""Apply the agent's proposed external effects, with approval."""
results = []
for entry in gs.sbx.filesystem.listdir(OUTBOX):
if entry.is_dir:
continue
req = json.loads(gs.sbx.filesystem.read(entry.path))
kind = req.get("kind")
handler = effects.get(kind)
if handler is None:
results.append({"file": entry.name, "status": "unknown_effect"})
elif approve(kind=kind, payload=req) is True:
# Credentials live here, in the host process, not in the guest.
results.append({"file": entry.name, "status": "applied",
"detail": handler(req)})
else:
results.append({"file": entry.name, "status": "denied"})
gs.sbx.exec(f"rm -f {entry.path}")
# Feed the outcomes back so the agent knows what actually happened.
gs.sbx.filesystem.write(
"/workspace/outbox-results.json", json.dumps(results, indent=2))
return results
effects = {
"open_pull_request": open_pr, # your function, your GitHub token
"send_email": send_email,
"post_webhook": post_webhook,
}Three things get better at once. The number of approval prompts collapses, because only genuine external effects reach a human instead of every command that looked scary to a regex. The credentials stop being reachable by generated code, which removes the entire class of prompt-injection-to-credential-theft. And the approval payload becomes reviewable, because a structured effect with a title and a diff is a much better five-second decision than a shell command whose consequences you have to infer.
The cost is that the agent needs to be told about the outbox, and it will occasionally try to do the thing directly instead. That is a prompting problem, and it is a much better problem to have than a credential-holding agent with a regex in front of it.
When the honest answer is no gate at all
A lot of internal use cases do not need any of this. If the agent runs in a disposable VM, holds no credentials that reach anything real, cannot route to your production network, and everything it does is logged, then a human approval step adds latency and a false sense of control without removing any risk that isolation has not already removed.
Say what the gate is for before you build it. If the answer is that a stakeholder wants to feel comfortable, a good audit log and a weekly review of what the agent actually ran will do that better than a prompt nobody reads. If the answer is that the agent can spend money, or write to a system of record, or say something in your company's name, then you have a real gate to build and it should cover exactly those things and nothing else.
The shortest version of the whole design:
- Put the agent somewhere its worst command is boring. Isolation is what makes the auto-approved set large enough to be worth having.
- Classify effects, not calls, using a list a human can read in a code review.
- Gate the small irreversible set. Auto-approve the rest and log all of it.
- Show the literal command or diff, never a model-written summary of it.
- Fail closed on irreversible, fail open on sandboxed, expire every request.
- Move the boundary to the exit when you can — no credentials in the guest, effects applied by your own code.
The gate you want is one that almost never fires and gets read carefully when it does. Everything above is in service of that ratio.
Frequently asked questions
Should I ask for approval before every agent tool call?
No, and every team that starts there abandons it within about a week. Agents are chatty, most calls are directory listings and file reads, and a reviewer asked to make the same trivial decision dozens of times an hour stops reading. What you end up with is a gate that gets clicked through, which is worse than no gate because it creates a false record of review. Gate the small set of actions with effects that survive the sandbox, auto-approve the rest, and log everything so the auto-approved calls are still auditable after the fact.
How do I decide which commands need human approval?
Ask whether destroying the machine undoes it. If deleting the VM removes every trace of the action, it is reversible and no human needs to see it. If it writes over the network, uses a live credential, changes shared state, or produces something another person will see — a pull request, an email, a published package — it is irreversible in the way that matters and belongs behind a gate. Keep the resulting list explicit and short enough to review in a pull request. Classification logic that nobody can predict is its own failure mode.
Where should the approval gate live in my agent architecture?
In the tool wrapper, for most teams. Putting it in the agent loop couples the policy to one framework and duplicates it for every agent you add. Putting it at the platform layer, where the network namespace itself refuses to route anywhere unapproved, is genuinely stronger, because generated code cannot route around a boundary it cannot see. A wrapper only inspects calls that pass through it, so treat it as ergonomics rather than a security boundary if the agent has a raw shell and can write its own HTTP client.
What should happen if nobody responds to an approval request?
Split the default by risk. Irreversible actions fail closed: no answer means the action does not happen, and the agent is told so in a way it can respond to. Actions you gated out of caution, where the worst case is a wasted sandbox, can fail open after a short window. Also expire requests and bind them to a run identifier, so an approval that arrives ninety minutes later cannot execute against a run that has since taken a different path. That stale-approval bug appears in almost every first implementation.
Does a sandbox remove the need for human approval entirely?
For a lot of internal work, yes. If the agent runs in a disposable microVM, holds no credentials that reach production, cannot route to your real network, and every command is logged, then an approval prompt adds latency and the feeling of control without reducing risk that isolation already handled. What isolation cannot cover is effects that deliberately leave the box: spending money, writing to a system of record, or communicating in your organisation's name. Gate exactly those, and let the agent work freely everywhere else.
Keep reading
- Sandboxes for AI agents — isolation that makes auto-approval safe
- How PandaStack sandboxes work
- From prompt injection to RCE in an agent tool chain
- What is an AI agent sandbox?
- What tool calling actually does
49ms p50 cold start. Fork, snapshot, and scale to zero.