Add an example agent that asks before it writes - #44
Open
AntoniTok wants to merge 12 commits into
Open
Conversation
AntoniTok
marked this pull request as ready for review
August 3, 2026 17:17
commit: |
added 12 commits
August 4, 2026 15:51
The approval example needs somewhere for a command to run, and it needs more than one of them. A withheld write capability is enforced in three different places depending on the backend: the two Dynamic Worker backends reach the workspace over RPC, so a refused write fails inside the command as EROFS before anything lands, while the container writes to its own copy of the tree and only meets the refusal when those changes are pulled back, where it surfaces as skipped entries rather than as a failed command. An example that wired up one backend could show the policy deciding but not that difference, which is the part a reader is most likely to get wrong. So the Durable Object registers all three against one Workspace and the exec route takes a backend name, leaving the same command runnable in each. just-bash is registered first and is therefore the default, on the grounds that the backend which refuses outright is the one to reach for without thinking. Nothing here decides anything yet; the policy and the agent arrive in later commits. The loader cast in workspaceOptions is load-bearing and explained where it sits: three backends resolved in one function overrun tsc's instantiation depth limit, because the generated Env holds a namespace of the very class being declared.
The matcher reads a shell line and says whether a human should see it before it runs. It is a heuristic, and the point of putting it behind a capability is that it is allowed to be one: the workspace decides what a command can do, this file only decides how often somebody gets interrupted. That split is what makes the failure modes survivable. A read misjudged as a write costs a question nobody needed to answer. A write misjudged as a read runs without write access and fails, which is a broken command rather than a lost file. Only just-bash gets the matcher. The container runs real coreutils over a public network, and it is also the backend where a refused write is caught late, on write-back rather than in the command, so it is the worst place to be guessing; the JavaScript backend evaluates a module whose effects are not a function of any verb. Both are gated outright, which is the honest answer for a dialect this file cannot read. Two test files, and they check different things. One pins what the matcher says. The other runs every command it would allow through just-bash itself against a recording filesystem and fails if any of them wrote — 630 commands generated, 475 allowed unattended, none of them writing. The assertions in the first file cannot find the bug the second one exists for: both real defects here were found by running the agent and noticing, not by listing cases, because whoever writes the matcher writes its examples from the same blind spot. The corpus derives its verbs from the allowlist rather than from a copy, so a verb added later comes under test without anybody remembering to.
The example comes down to one line, and it reads backwards until you
notice when it runs:
writable: (input) => decideApproval(input, policy).needsApproval
The AI SDK does not call a tool's execute until approval has been
granted, so a command that needed approval and got as far as execute
is a command somebody said yes to. Driving write access off the same
predicate as the approval therefore states the property the whole
example is for: a command runs with write access if and only if a
human approved it. Two decisions that could drift are one decision
instead.
Approval is configured on streamText rather than on the tool, where
the AI SDK moved it in v7. Because the conversation lives on the
client, an approval arrives here as a claim the client makes about
something the user supposedly did, so the requests are HMAC-signed at
issue and checked on replay; the key is generated per object and kept
in its own storage, which leaves nothing to configure and nothing to
leak through a binding. Without that signature the capability and the
gate still hold, but the human in the loop is decorative.
The gate is installed on the Workspace rather than around the agent,
and it is not a second copy of the approval decision. It cannot ask
anybody anything, so what it does is check the invariant above from
the other side: a command holding write access should be one the
matcher would have raised a question about, since that is the only
route to write access through the tool layer. A recognized read that
turns up wanting write access did not come that way, and it is
narrowed back. That covers the HTTP routes and anything added later,
which is the whole reason the seam is on the workspace.
The model gets read and ls and nothing else that changes a file, so
every mutation goes through the one door the policy watches. An
example that asks carefully about `find -delete` while an unguarded
write tool sits next to it would be telling the reader something
false about where the boundary is.
The client is a URL and a title. The AI SDK's terminal UI already knows how to render a pending approval and post the answer back, and the worker returns the UI message stream DefaultChatTransport expects, so there is nothing to write in between. That is the point of showing it: the approval flow is the SDK's, not something this example invented, and the part worth reading is the twenty lines in agent.ts that decide what the answer buys. Ask it to read a file and it answers. Ask it to delete one and the turn stops. The prompt shows the tool name and the command; the matcher's own reason for asking is not in it, because an approval request has nowhere to carry per-call text, so the reason goes to the audit trail instead and /audit is where you read it back. The conversation lives in this process, which is what makes the worker's signature on each approval request load-bearing rather than ornamental.
The README leads with the property rather than the wiring, because the property is the part that is easy to get wrong when reading the code: write access and human attention are one decision, so the matcher is free to be a heuristic. It also spells out the asymmetry that makes that true, since a reader who thinks the matcher is the boundary will draw the wrong lesson from every line of it. The docs chapter now points here from the section on asking a human, which is where a reader arrives wanting to see it done, and the CI example matrix picks the workspace up so the tests and the typecheck actually run. The matrix comment about examples not shipping tests is no longer true and says so.
`wrangler types` no longer emits just the bindings. It also inlines the entire workerd type library, which put 14,749 lines of vendored declarations in the diff for an example whose own content is closer to 2,400. Reviewing the one is not helped by carrying the other. The runtime types are already available: tsconfig.json names @cloudflare/workers-types next to this file, and everything the bindings need resolves from there, the container surface included. So this keeps the three bindings and drops the rest, matching examples/worker-shell and examples/assets rather than the examples that check the generated file in. Worth knowing when changing it: CI regenerates this file before it typechecks, so a mistake here will not fail there. Typecheck without running `wrangler types` first to actually exercise it.
`wrangler types` refuses to overwrite a worker-configuration.d.ts it did not write, so the generate step fails outright on an example that checks in the bindings by hand. The examples that already do this are not in this matrix, which is why nobody had hit it. Skipping the step where the file is hand-written also improves the signal. Every other example has its committed types replaced before the typecheck runs, so the file the repository ships is never the file CI checks. Here it is.
The README opens by putting a file at /workspace/hello.txt, which on a fresh object answered 500: parent directory missing. Nothing had created /workspace. The other examples get that directory as a side effect of mounting a bucket underneath it, and this one mounts nothing, so the first write to a new object had nowhere to land. Create the parent recursively before the write. Both calls go through Workspace.fs, so both pass the gate and both appear in the audit trail, which is where the mkdir now shows up ahead of the write.
The worker signs every approval it asks for. It has to: the conversation lives in the terminal, so an answer arrives as a claim the client makes about something the operator supposedly did, and on that claim rests the write access the command is about to be given. The AI SDK checks the signature before it will run the tool call, which is what stops an approval that was never issued. The terminal UI throws the signature away. Recording an answer replaces part.approval rather than adding to it, so what goes back is unsigned and the turn dies with AI_InvalidToolApprovalSignatureError: missing signature. Every published @ai-sdk/tui through 1.0.52 does this, so every approval in this example failed, which is to say the flow the example exists to show did not work. Wrap the transport instead of forking the UI. It notes the signature on each approval request passing through, and restores it on the answer that comes back. This is a repair, not a loophole: a signature is not a secret but a MAC only the worker can produce or check, the cache can only supply one the worker itself issued for that exact approval id, and carrying it across a turn it was always meant to survive gives the client nothing it did not already have. A forged approval still has nothing to present.
wrangler dev builds the container image before it will start, so a machine that cannot build one gets none of the example — not even the two backends that never touch a container. That is a poor trade for anyone who wants to watch an approval happen, which is most of the reason the example exists. Add a second config without the container, reachable as `dev:local`, and say so in the README next to the instruction it qualifies. It duplicates the bindings rather than deriving them, which is a small drift risk in exchange for leaving the documented path untouched; the comment at the top asks for the two to be kept in step.
The container backend does not stop a command that has no write access. The command writes to the container's own copy of the files and exits zero, and the changes are dropped when they are pulled back, so the exit code says one thing and the workspace says another. The exec tool reports that as discardedWrites, and this teaches the model to read it. The instruction is blunt about which signal wins, because the model sees the exit code first and the temptation is to trust it. A command whose writes were discarded did not do the work, however well it appears to have gone.
Write access is withheld preventively on the two Dynamic Worker backends and only after the fact on the container, where the write lands in the container's own copy and is refused when the change is pulled back. That leaves the two copies disagreeing, which is why the documentation suggests discarding the container rather than carrying on with it. A read-only bind mount inside a private mount namespace would close the gap. The kernel refuses the write before it reaches the filesystem, so nothing lands and there is nothing left to refuse. It is the same shape as the two filesystem handles over one store, with the kernel holding the capability instead of a JavaScript object, and it keeps the per-command guarantee: the view belongs to one process, so a read-only command cannot disarm a writable one running beside it. Whether a container may create a mount namespace at all is a property of the runtime rather than of this repository, so this asks. It tries the unprivileged user namespace route first, falls back to the one needing CAP_SYS_ADMIN, and checks that the read-only view does not leak to the calling shell. Run it inside a deployed container: local Docker is more permissive and reports a pass that does not hold in production.
AntoniTok
force-pushed
the
agent-approvals
branch
from
August 4, 2026 14:53
7fff180 to
f60e2c2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #43, which added a per-command write flag plus a gate and an audit hook. This is a worked example of all three, because those seams are hard to judge in the abstract.
Three things it shows.
First, write access and human approval are one decision rather than two. A command runs with write access if and only if a human approved it. That holds by construction rather than by two pieces of code agreeing with each other.
Second, one workspace over all three backends. A withheld flag is enforced in a different place in each, and the difference changes how much you can trust it.
Third, a gate installed on the workspace rather than around the agent, checking that rule from the other side. The tool layer only covers the model's path, and the workspace has other callers.
There are two ways to keep a model from wrecking a workspace, and only one of them is a real boundary. You can read the command and decide, which is a guess. Or you can withhold write access, so an unapproved command runs against a filesystem handle that cannot write and its writes fail whatever anybody believed. Before #43 only the first was available, which is why its holes mattered. An early version of the matcher here waved through
find /workspace -mindepth 1 -delete, because the verb was on the allowlist and its flags were not.Putting the flag underneath shrinks the matcher's job from stopping damage to asking fewer questions. It also makes the two ways of being wrong very different. Call a read a write, and someone answers a needless question. Call a write a read, and the command runs read-only and fails in the open. Neither loses a file, which is the only reason a verb allowlist and a few regular expressions belong anywhere near the decision.
So both decisions come from one function.
That reads backwards until you notice when it runs. The AI SDK does not call a tool's
executeuntil approval has been granted. Asking for write access exactly when approval was required is what ties the two together. Only two of the four combinations can happen.writable: truewritable: falseThe empty cells are the point. Write access without a question would mean the matcher handed it out, which is what this example exists to avoid. A question followed by no write access would mean somebody was interrupted for nothing. Neither can happen, because there is one decision rather than two that could drift apart.
flowchart TD M["model emits exec('rm -rf /workspace')"] D{"decideApproval(command, backend)<br/>backend rule first, then the matcher"} PAUSE["turn pauses, the human is asked"] H{"human answers"} YES["runs with write access"] NO["command never runs,<br/>the model is told why"] AUTO["runs read-only,<br/>nobody was asked"] GATE{"gate checks the rule again"} NARROW["narrowed to read-only"] AUDIT["audit records what happened,<br/>including the access actually held"] M --> D D -->|needs approval| PAUSE --> H H -->|approved| YES --> GATE H -->|denied| NO D -->|a known read| AUTO --> GATE GATE -->|access the matcher backs| AUDIT GATE -->|"a read that arrived asking to write"| NARROW --> AUDITThe three backends differ in where a refused write is caught, which is why they do not get the same rule.
worker-shellworker-javascriptcontainer-shellcomputerdOnly
worker-shellgets the matcher. A bash line is the one language here whose effect you can read off its text with any confidence. The container runs real binaries with public network access, and it is also where a refusal lands late, so it is the worst place to be guessing. The JavaScript backend runs a module whose effects follow from what it imports rather than from any verb.The gate is not a second copy of the approval decision, and it cannot be. It runs once the action exists, and there is nowhere to pause a running command that does not risk a half-finished result. What it does is check the rule from the other side.
shell.execwithout write accessshell.execwith write accessshell.execwith write accessThe third row cannot happen through the tool layer, since that route goes through the matcher. A command holding write access the matcher would not have asked about came from somewhere else, either a new caller or an edited resolver. Narrowing costs it nothing the matcher says it needed. Filesystem actions are allowed because nothing the model can call reaches them. The routes that do are how the workspace gets seeded.
To try it, run this from the repo root.
wrangler devbuilds the container image before it starts, so a machine that cannot build it gets none of the example. There is a second configuration without the container. It behaves the same except that asking for the container backend fails.Then, in another terminal, give it something to look at and start talking.
curl -X PUT --data-binary 'hello world' \ localhost:8787/c/default/file/workspace/hello.txt npm run chat --workspace @example/computer-agentAsk it to read that file and it answers without asking. Ask it to delete everything under
/workspaceand the turn stops so you can approve or refuse. That only happens once the model actually issues the command, and a small model may look around first or decline to run it at all. The tests pin the decision itself, so it does not depend on which model you get. Either wayGET /c/default/auditshows what the workspace recorded, including the write access each command actually held.Four test files, run with
npm test --workspace @example/computer-agent. One pins what the matcher says, one pins the rule above and the gate's narrowing, and one pins the signature repair. The third is the one that earns its place. It runs every command the policy would allow throughjust-bashitself, against a filesystem that records every change, and fails if any of them wrote. That is 630 commands generated, 475 allowed on their own, none writing. Tests for a matcher are written from the same blind spot as the matcher, so they find the cases somebody already thought of. Both real defects in this policy were found by running the agent by hand instead. The corpus builds its verbs from the allowlist rather than from a copy, so a verb added later comes under test without anyone remembering to.Running it against
wrangler devturned up two defects, both fixed here. Nothing created/workspaceon a fresh object when no bucket was mounted under it, and every approval failed on a dropped signature. The container backend is the one path still untested locally, because building its image needs network this machine does not have.This supersedes #21, which reached the same conclusion from the other end. It built the matcher, found it wanting, and said the boundary belonged at the capability layer. That is now built, so the matcher can be what it should always have been, a way to ask fewer questions. The example has its own README, the approval chapter points at it from the section on asking a human, and the continuous integration matrix picks the workspace up.
Why the approval has to be signed, and why the client repairs it (not core to the example)
The conversation lives in the terminal, not in the durable object. So an answer to an approval is just a claim the client makes. On that claim rests the write access the command is about to get. The worker therefore signs every approval it asks for, and checks the signature before the tool call runs. The key is made per object and kept in that object's own storage. An approval that was never issued has nothing to present, and there is nothing to configure and nothing to leak through a binding.
Recording an answer in the terminal interface replaces the approval rather than adding to it. That throws the signature away and kills the turn. Every published
@ai-sdk/tuithrough 1.0.52 does this, so the client wraps its transport to note signatures on the way in and put them back on the way out. That wrapper is the difference between the approval flow working and every approval failing. The repair belongs in the transport because a signature is not a secret. Only the worker can make one or check one, so carrying it across a turn gives the client nothing it did not already have.The container's weaker enforcement, and a probe for fixing it later (not core to the example)
script/container-mount-probe.shis not a fix. It is the question the container's late refusal keeps raising. A read-only mount inside a private mount view would stop the write up front there too. The kernel refuses before the write reaches a file, so nothing lands. The view belongs to one process, so a read-only command cannot disarm a writable one beside it. That is the same shape as two filesystem handles over one store, with the kernel holding the flag.Whether a container can make a mount view depends on the runtime rather than on this repository, so the script asks. It works under an unprivileged user namespace with no added rights, wherever the syscall filter allows the call. Worth knowing before anyone argues the container's rule should be anything other than always asking.