Skip to content

Add an example agent that asks before it writes - #44

Open
AntoniTok wants to merge 12 commits into
exec-writable-gatefrom
agent-approvals
Open

Add an example agent that asks before it writes#44
AntoniTok wants to merge 12 commits into
exec-writable-gatefrom
agent-approvals

Conversation

@AntoniTok

@AntoniTok AntoniTok commented Aug 3, 2026

Copy link
Copy Markdown

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.

writable: (input) => decideApproval(input, policy).needsApproval

That reads backwards until you notice when it runs. The AI SDK does not call a tool's execute until 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.

a human was asked nobody was asked
runs with writable: true the write path, approval was required and granted cannot happen
runs with writable: false cannot happen the read path, a known read that ran on its own

The 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 --> AUDIT
Loading

The three backends differ in where a refused write is caught, which is why they do not get the same rule.

Backend Runs A refused write lands Default rule
worker-shell just-bash in a Dynamic Worker inside the command, as a read-only error matcher decides
worker-javascript a module in a Dynamic Worker inside the module, the same way always ask
container-shell real coreutils under computerd on the way back, as skipped entries always ask

Only worker-shell gets 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.

Gate sees Matcher Gate does
shell.exec without write access never runs allow, nothing to check
shell.exec with write access needs approval allow, the access was earned
shell.exec with write access a known read narrow to read-only
a filesystem action never runs allow, and record it

The 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.

npm run build
npm run dev --workspace @example/computer-agent

wrangler dev builds 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.

npm run dev:local --workspace @example/computer-agent

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-agent

Ask it to read that file and it answers without asking. Ask it to delete everything under /workspace and 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 way GET /c/default/audit shows 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 through just-bash itself, 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 dev turned up two defects, both fixed here. Nothing created /workspace on 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/tui through 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.sh is 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.

@AntoniTok
AntoniTok marked this pull request as ready for review August 3, 2026 17:17
@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@44

commit: f60e2c2

Antoni T 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant