Skip to content

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

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

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

Conversation

@AntoniTok

Copy link
Copy Markdown

Builds on #43, which added a per-command write capability plus a gate and an audit hook. This is a worked example of all three, and it exists because those seams are hard to judge in the abstract: the interesting question is not what they do but what they let you stop worrying about.

There are two ways to keep a model from wrecking a workspace, and only one of them is a boundary. You can read the command and decide, which is a heuristic. Or you can withhold the capability, so that a command that was not approved runs against a filesystem handle with no write access and its writes fail whatever anybody believed about them.

Before #43 only the first was available, which is why its holes mattered. This example carries a matcher that was written when it was load-bearing, and an early version of it waved through find /workspace -mindepth 1 -delete, because the verb was on the allowlist and its flags were not.

Putting the capability underneath changes what the matcher is for. Its job shrinks from stopping damage to reducing interruptions, and its failure modes stop being symmetrical. Getting a read wrong costs one question nobody needed to answer. Getting a write wrong means the command runs read-only and fails visibly. Neither loses a file, and that is the only reason a matcher built from a verb allowlist and a few regular expressions belongs anywhere near the decision.

So the two decisions are wired to a single predicate:

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

which reads backwards until you notice when it runs. The AI SDK does not call a tool's execute until approval has been granted, so asking for write access exactly when approval was required states the property the example is built around: a command runs with write access if and only if a human approved it. Two things that could drift apart are one decision instead.

The example runs one workspace over all three backends, because a withheld capability is enforced in a different place in each. The two Dynamic Worker backends reach the workspace over RPC, so a refused write fails inside the command. The container writes to its own copy of the tree and meets the refusal on write-back, where it surfaces as skipped entries rather than as a failed command. Only the shell backend gets the matcher: the container runs real binaries with public network access and is also where a refusal lands late, so it is the worst place to be guessing, and the JavaScript backend evaluates a module whose effects are not a function of any verb.

The gate and the audit hook are installed on the Workspace rather than around the agent, which is the point of them being there — the tool layer only covers the model's path. The gate is not a second copy of the approval decision, and it cannot be: a gate runs once the action exists, and there is nowhere to suspend a running command that does not risk a partial result. What it does is check the invariant 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 to read-only, which costs it nothing the matcher says it needed.

Two smaller decisions worth flagging. The conversation lives on the client, so an approval arrives as a claim the client makes about something the user supposedly did; the approval requests are signed and checked on replay, with a key generated per durable object and kept in its own storage, so there is nothing to configure and nothing to leak through a binding. And the model gets read and ls but no write or edit tool, 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 teach the reader something false about where the boundary is.

To try it:

npm run build
npm run dev --workspace @example/computer-agent     # Docker, for the container backend
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. Either way GET /c/default/audit shows what the workspace recorded, including the write access each command actually held.

Three test files, and the third is the one that earns its place. One pins what the matcher says. One pins the invariant above and the gate's narrowing. The third runs every command the policy would allow through just-bash itself, against a filesystem that records every mutation, and fails if any of them wrote — 630 commands generated, 475 allowed unattended, none of them writing. Assertions about a matcher are written by whoever wrote the matcher, from the same blind spot, so they find the cases somebody thought of; both real defects in this policy were found by running the agent by hand and noticing. 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.

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 instead. 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 chapter on write access and approval now points at it from the section on asking a human, and the continuous integration example matrix picks the workspace up so its tests and typecheck run.

Antoni T added 5 commits August 3, 2026 18:01
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.
@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: 1801b49

Antoni T added 2 commits August 3, 2026 18:29
`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.
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