Skip to content

examples/codemode: put a human in front of the agent's writes - #21

Draft
AntoniTok wants to merge 13 commits into
cloudflare:codemode-backendfrom
AntoniTok:codemode-hitl-approvals
Draft

examples/codemode: put a human in front of the agent's writes#21
AntoniTok wants to merge 13 commits into
cloudflare:codemode-backendfrom
AntoniTok:codemode-hitl-approvals

Conversation

@AntoniTok

@AntoniTok AntoniTok commented Jul 29, 2026

Copy link
Copy Markdown

The codemode example lets a model pick which of three backends runs each command, and until now it could write to the workspace on its own say-so. An example built to show a model driving real backends ought to show how you stop it, so this puts a person in front of anything that is not recognizably a read.

The check hangs off the exec tool rather than the workspace, which keeps the workspace unaware that a model exists. The tool declares needsApproval, and that consults a table keyed by backend. The two sandboxed backends let recognized reads run unattended. The container backend gets a full Linux userland and public network access, so it asks about everything. Anything the matcher cannot classify asks as well: failing closed turns an unfamiliar command into a question, while failing open turns it into an unreviewed write.

A held-back command does not run at all — no shell, no dynamic worker, no container boot. The turn stops, and the Worker files the conversation so far into a second Durable Object, AgentSession, keyed by the same workspace name. That object holds no workspace handle on purpose, so the thing remembering "nobody approved this yet" cannot itself run anything. Nothing is held open across the pause, so the Worker can be redeployed or crash and the turn survives as a row.

sequenceDiagram
    participant H as human
    participant W as Worker
    participant M as Workers AI
    participant S as AgentSession
    participant D as workspace
    H->>W: POST /agent
    W->>M: run the turn
    M->>W: exec { command, backend }
    W->>W: policy says a person must see this
    W->>S: store the paused turn
    W->>H: awaiting-approval (nothing ran)
    H->>W: POST /approvals/id
    W->>S: read the turn back
    W->>M: replay with the answer
    W->>D: now run the command
    W->>H: completed
Loading

Answering the last outstanding approval resumes the turn in that same request. Answering one of several returns 202 until the rest arrive, and answering one twice returns 404 rather than running the command again.

To try it, run npx wrangler dev --enable-containers=false, then ./script/agent "Create /workspace/greeting.txt containing exactly: hello world" from the example directory. It prompts with the command, the backend, and why it stopped. Ask for a read instead, such as counting the files under /workspace, and it goes straight through. APPROVALS=1 ./script/run covers the same ground without a person.

Three test suites are ordinary: the policy decisions, the bookkeeping for a paused turn, and the pause-and-resume loop against a scripted model, which asserts a held-back command never reaches the workspace.

The fourth is the interesting one. The matcher guesses whether a command writes by reading its text, and the same hand writes the guess and the examples it gets checked against, so both miss the same cases. That is not hypothetical: find /workspace -mindepth 1 -delete ran unattended, because the verb was on the allowed list and its flags were not. So approval-policy.effects.test.ts checks the guess against the world. It crosses every allowed verb with argument shapes that include the flags known to turn a read into a write, keeps the commands the policy would run unattended, and executes each under real just-bash against a filesystem that records every change — 630 commands, 475 of them allowed, in under a second. Put the find hole back and it fails naming each file that would have been deleted. The verbs come from the allowed list itself rather than a copy, so widening the policy later brings the new verb under test without anyone remembering to.

What that failure looks like
AssertionError: the policy allowed 4 command(s) that wrote
+ "find /workspace -mindepth 1 -delete" → rm(/workspace/sub/c.txt),
    rm(/workspace/a.txt), rm(/workspace/b.txt), rm(/workspace/sub)

The example README covers the approval flow, and its architecture page gains diagrams for the call sequence and for every branch of the approval route. That page is a standalone file with no build step and does not render on GitHub, so it is worth opening locally rather than reading in the diff.

The pause is not homegrown. It is the AI SDK's needsApproval, one field on the exec tool, and the same field Think gates its own tools with; Think Actions parks a turn on a human and resumes it later much as this does. Think, the Agents SDK, Workflows, fibers and the codemode runtime were each weighed and each costs more here than it saves, and the README says why for every one of them. What none of them answer is which shell commands are safe to run unattended, and that is the part of this change that is new.

Two gaps remain. The matcher guesses a command's effect from its text, so it is the policy's second tier rather than its boundary — the backend table is the boundary, and the effects test is what holds the matcher to failing in one direction only. Separately, POST /exec has no authorization check. Both close the same way: give the backend a read-only view of the workspace and let the filesystem refuse the write, which covers every caller instead of only the model's path and leaves the matcher as a way to ask fewer questions.

Antoni T added 12 commits July 28, 2026 16:37
The agent could write to the workspace on its own say-so. Now a
command the approval policy holds back does not run: the turn stops,
the pending command goes on a queue, and it resumes only once a human
answers.

The gate is the AI SDK's own needsApproval on the exec tool, which
excludes a call from execution and reports it as an approval request
instead, so there is no provisional result and nothing to undo. A
paused turn resumes by replaying the stored message history with a
tool-approval-response attached. A rejection comes back to the model
as execution-denied, which it can read and react to rather than an
error that ends the turn.

Approval is a table keyed by backend, because the backends differ in
what they can reach. The two sandboxed backends run recognized reads
unattended, while the container backend, with a full userland and a
public network, is gated outright. Anything the matcher cannot
classify needs a human, so an unknown verb or a state call reached
through a computed access fails closed. Matching command strings is a
heuristic suited to an example, and enforcement belongs at the
capability layer, which is precisely why denying by default matters
here.

The state a pause needs outlives the request that created it, so it
lives in AgentSession, a second durable object. The object that owns
the filesystem stays a filesystem, holding no model state, and the
object that records approval decisions holds no workspace stub and so
cannot run a command.

Two details follow from resuming by replay. The step budget now spans
a whole turn rather than a single pass, so waiting for a human does
not hand the model a fresh allowance. The transcript is collected from
the tool rather than from the model's steps, because a command a human
approved executes before the resumed pass makes its first model call
and therefore belongs to no step.
Describe the pause and resume path, the per-backend policy table and
the deny-by-default matcher behind it, and where a paused turn lives
while it waits. Record why the example reaches for the AI SDK's
approval hook rather than the approvals that ship with
@cloudflare/codemode: the codemode backend here and the codemode
runtime share a name without sharing a mechanism, and adopting the
latter would mean giving up the backend choice this example exists to
show.

The smoke script gains an opt-in APPROVALS=1 pass that asks the agent
for a write, checks the file is still absent while the approval waits,
approves it, and checks that the write then landed. The absence is the
part worth asserting.
The approval matcher allowlisted verbs but only blocklisted flags, so
`find /workspace -mindepth 1 -delete` was classified as a read and ran
unattended. The verb was on the read list, the line held no
redirection or composition, and nothing looked further.

Flags on a verb that can write are now allowlisted as well, and an
unrecognized one gates. That is the shape the state.* check already
used: name what is allowed and treat the rest as a question. A
blocklist of writing flags would have to keep pace with every flag
that happens to write, which is the same losing game the shell
metacharacter list plays and wins only because that list is closed.

Verbs whose writes cannot be read off their arguments are dropped from
the read set rather than papered over. sed writes through -i and
through a `w` command inside its script, uniq and tree take an output
file as a positional argument, and date -s sets the clock. Listing
them buys false confidence, not fewer approvals.
The HTTP surface answers an approval with a second request, which suits
a UI but makes the feature hard to see: a turn pauses by returning
JSON, and approving means copying an id into another curl. Nothing
about that reads like a human being asked a question.

script/agent closes the loop. It starts a turn and, each time the turn
comes back waiting, prints the command with the reason it was held and
asks y/n on the terminal, then posts the answer and picks the turn up
where it left off. It defaults to a fresh workspace so the first write
always has to be approved, takes AUTO_APPROVE=1 for a hands-off run,
and accepts piped answers for scripted ones.

It reads stdin a line at a time rather than through node:readline,
because a piped answer arrives and ends the stream while the first
model call is still in flight, and readline then rejects the question
that follows.
The read-only rule gated every composed command, so listing files with
`ls -1 /workspace | wc -l` stopped for a human. Both stages are reads
and a pipe touches no files, which makes that a question with no
useful answer, and asking it teaches whoever works the queue to stop
reading the prompt.

The reason the rule gated all composition was that classifying a
composed line means classifying every element of it. That is worth
doing rather than avoiding. Redirection, substitution, subshells and
backgrounding still disqualify a line outright, since they either
write or run something the matcher would have to parse to see. What
is left is a pipeline, which now splits on its separators and passes
only when every stage passes on its own.

Nothing is given up by this. `ls; rm -rf /workspace` stops on its
second stage, and `find /workspace -type f | xargs rm` stops on
`xargs` even though that `find` would have passed alone.

Add echo and printf to the read set while here. They were held back
on the grounds that they write through their own syntax, which is
true of awk and not of them: both write to stdout only, and aiming
that at a file takes a redirect, which gates the line whatever the
verb is.
The architecture page showed what the pieces are but described the
calls between them as two prose lists, which is the wrong shape for
explaining a flow that pauses in the middle. A reader following the
approval path had to hold five participants and an ordering in their
head at once.

Add a sequence diagram per path, sharing lanes and opening rows so
the two can be read against each other. The unattended turn and the
gated turn are identical up to the policy check and diverge there,
which puts the whole feature in one visual difference: one path
reaches the Durable Object that owns the files, and the other stops
short of it and parks the turn.

Mark the pause explicitly, since that band is what the design is for
and it is the part a prose list cannot show — the Worker holds no
state across it and can die there without losing the turn. Note also
that the policy check appears twice on the gated path, which is the
reason it has to be a pure function.
The sequence diagrams walk one path each and so leave out everything
that makes the route awkward to hold in the head: where the two entry
points meet, which branch returns which status, and the fact that the
route feeds back into itself.

Add a control-flow diagram for it. The shape worth seeing is that
POST /agent and POST /approvals/<id> converge on the same model loop,
so a resumed turn can stop on a different command and hand back
another awaiting-approval rather than a completion. Draw the loop as a
loop.

Put the gate inside the Worker box, upstream of the Durable Object
that owns the files, since that placement is the reason a paused turn
is safe to leave sitting: the command was never sent, so there is
nothing in flight to keep alive.

Note alongside it why an already-answered approval is a 404 and a
partially-answered turn is a 202. Both look like failures next to the
200 that resumes a turn, and the difference is that answering twice
would run the command again.

Give the diagram its own arrow marker and label backing rather than
borrowing the ones defined in the sequence diagram above it. Sharing
them works, because an SVG style block applies document-wide, but it
leaves the arrowheads silently dependent on a sibling figure staying
where it is.
The page has grown four diagrams and is the quickest way to see how a
turn moves through the Worker, the two Durable Objects and the model,
but nothing referenced it, so finding it depended on browsing the
directory. Point at it from the README, near the paragraph that
introduces the approval flow it illustrates.

Give the reader the open command as well. The file renders nowhere on
GitHub, so a bare link invites reading seven hundred lines of hand
written SVG instead of the picture it draws.
The gate sits in the exec tool, which puts it on the model's path and
not on this route's. That is deliberate — approval exists to put a
human in front of a command a model chose, and a caller posting here
is already that human — but the consequence is easy to miss while
reading the approval code, because nothing near the gate mentions that
a second caller reaches the same method unchecked.

Write it down at the route, including that this one will run
`rm -rf /workspace` on request. Note also that it follows from where
enforcement currently sits rather than from a decision to privilege
this route, since that placement is the part of the design most likely
to move.
The existing policy tests assert what the matcher says, which cannot
find the defect that matters. The matcher guesses whether a command
writes by reading its text, and the same hand writes the guess and the
examples it is checked against, so both miss the same cases. That is
not a hypothetical: `find -mindepth 1 -delete` ran unattended and a
pipeline of two reads asked for approval, and neither turned up in a
suite of hand-picked assertions. Both were found by running the agent
and watching.

Check the claim against the world instead. Generate a corpus by
crossing every allowlisted verb with argument shapes that include the
flags known to turn a read into a write, keep the commands the policy
would run unattended, and execute each one under real just-bash
against a filesystem that records every mutating call. The property
runs one way only: a command allowed unattended must write nothing.
A gated read is a nuisance and needs no test.

Take the verbs from READ_ONLY_COMMANDS rather than a copy of it, so
widening the policy later brings the new verb under test without
anybody remembering to come here. Nonsense combinations are kept
deliberately — a command the matcher allows must not write whether or
not it makes sense.

Prove the harness before trusting it. Four cases assert the recorder
sees a write, sees a delete reached through find, stays quiet on a
read, and fails when handed a policy that waves a write through.
Without those, a recorder wired up wrong would make every command look
like a read. Reintroducing the find hole fails the suite naming each
file that would have been deleted.

The shell is real but the storage is not, which is sound here: whether
find reaches for a delete is a fact about just-bash and holds wherever
its files live. Two gaps are written down rather than left to be
discovered — the container backend runs GNU coreutils and can differ,
which is another argument for gating it outright, and the codemode
dialect is not exercised because reaching a live state namespace would
drag workerd-only imports into this runner.
The page had the shape of a gated turn in two diagrams and a five-line
prose summary, which is the right amount for deciding whether the
design is sound and not enough for working on it. Questions the
diagrams cannot answer: where the gate is evaluated, what the SDK
leaves behind when it declines to run something, why the policy is
consulted a second time, and what the resume has to put back.

Add a section that walks it at the level of what each line does, split
into the two sides of the pause. Splitting it is the point — a paused
turn is two requests with an indefinite gap between them, and the
things that are easy to get wrong sit either side of that gap: the
first side has to leave enough behind to resume from, and the second
has to reconstruct it without trusting the client.

Call out the parts that read as surprises. The pause is discovered by
scanning the last step's content after generateText returns normally
rather than by catching anything. The gate runs again on resume and can
downgrade an approval that has gone stale. An approved command executes
in the pre-loop and belongs to no step, which is why the transcript
comes from a closure instead of result.steps.

Link it from the README next to the diagram it expands on.
set-versions.mjs rewrites the pinned wsd-linux-x64 image tag in the
example Dockerfiles so a clone at any release tag pulls the matching
image. The codemode example was never added to that list, though its
Dockerfile carries the same pin and the same comment claiming the tag
moves in lockstep.

Nothing is broken today because the pin happens to match the current
version. It would have gone stale at the next release, leaving that one
example pulling an older wsd than everything around it, and the comment
in the file would have argued it could not happen.
@pkg-pr-new

pkg-pr-new Bot commented Jul 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 996df05

The policy documentation treated the backend table and the read
detection as one mechanism and then disowned both, which left the
example arguing against its own centerpiece. They are not the same
kind of thing. The backend table decides on what a backend can reach
and needs no parsing to apply, so it is the boundary. The read
detection classifies a command by reading it, fails closed, and exists
to ask fewer questions. Say so in the README and in
approval-policy.ts, and name approval-policy.effects.test.ts as what
holds the second tier to its one-directional guarantee.

Record why the mechanism is what it is. The pause is the AI SDK's
needsApproval, the same field Think gates its own tools with. Think
Actions, the Agents SDK, Workflows, fibers and the codemode runtime
each cost more here for a different reason, and a reader a year from
now will want that written down rather than rediscovered. Deciding
which shell commands are safe has no equivalent in any of them, which
is the part of this example that is actually new.

Note that experimental_toolApprovalSecret stays off. Approval requests
in the replayed message history carry no signature, so the setting
throws. Nothing depends on it while the history stays on the server,
and GET /agent/<turnId> strips it before answering.

Cut the comment above handleExec to the distinction it was reaching
for. Approval asks whether a person has seen a command the model
chose. That route has no authorization check, which is a separate
question with a separate fix.
@AntoniTok

Copy link
Copy Markdown
Author

Notes on the approval design, for anyone reviewing the choice rather than the diff. The README now carries the durable version of this; the detail below is here so it does not have to live in the tree.

The mechanism is the common one. The pause is the AI SDK's needsApproval, one field on the exec tool. Think reaches for the same field for the same job, and its Actions API has this architecture under another name: kind: "durable-pause" parks a turn on a human and resumes it later with no connection held open, exposed as pendingApprovals(), approveExecution() and rejectExecution(). Those map one-to-one onto GET /approvals, POST /approvals/<id> and the rejection path, down to a repeat answer being a no-op — which is what the 404 here implements.

Behaviorally, needsApproval returning true means the call is dropped before the tool execution pass, and the request is recorded in the message history as a tool-approval-request part. That is what makes the pause safe to hold: no connection opens, no dynamic worker starts, no container boots. The policy is consulted again on resume and downgrades an approval that has gone stale, which is why decideApproval has to stay a pure function of the command and the backend.

What has no equivalent is the matcher. Think's built-in Bash tool runs on the same just-bash library as the shell backend, and its only controls are on, off, and resource limits. Nothing in the stack classifies a shell command as read or write. That is the part of this change worth reviewing hardest, and it is also the part deliberately framed as the policy's second tier rather than its boundary.

The capability argument has a precedent. Think protects files it did not mount during write-back, so a script cannot delete what it was never given. That is enforcement below the shell rather than in front of it, and it is the direction both open gaps close in.

Alternatives weighed, and what each would cost

Think — closest fit, and it is a whole chat agent framework: memory, streaming, messengers, scheduled turns. Adopting it for the approval plumbing would replace what this example demonstrates.

Agents SDKAgent gives SQL storage and routing, but message persistence belongs to AIChatAgent. So turn-store.ts moves from key prefixes to SQL rather than disappearing, and the already-answered check has nothing to inherit. Around a hundred lines saved against eleven more dependencies in an example that has four. AIChatAgent would delete the store outright, but it is built around streaming chat messages, so the turn loop, the routes and both scripts would need rewriting.

Workflows — built for waits measured in months with per-step retries. This pause is minutes and holds nothing open, so a stored row is the smaller mechanism, and waitForApproval() is an Agent method that brings the same dependency. The real cost of skipping it is a timeout and escalation; the prune is a crude substitute.

FibersrunFiber() checkpoints work that is in flight so it survives eviction. Nothing is in flight here by design, so there is no progress to save.

codemode runtime — gates connector calls inside model-generated code and resumes by abort-and-replay. Different shape, and the name collides with the codemode backend without being related to it.

The two sides of a gated turn
sequenceDiagram
    participant H as human
    participant W as Worker
    participant M as Workers AI
    participant S as AgentSession
    participant D as workspace

    H->>W: POST /agent { prompt }
    W->>M: generateText
    M-->>W: exec { command, backend }
    Note over W: needsApproval → true<br/>call dropped before execution
    W->>S: saveTurn (messages, pending, stepsUsed)
    W-->>H: awaiting-approval + turnId + approvalId
    Note over W,D: nothing ran: no connection,<br/>no dynamic worker, no container
Loading
sequenceDiagram
    participant H as human
    participant W as Worker
    participant M as Workers AI
    participant S as AgentSession
    participant D as workspace

    H->>W: POST /approvals/{id} { approved }
    W->>S: resolveApproval
    S-->>W: turn + ready + answers
    W->>M: generateText (history + approval response)
    Note over M: policy re-checked;<br/>a stale approval becomes a denial
    M->>D: exec runs
    D-->>M: result
    M-->>W: text
    W->>S: saveTurn (completed)
    W-->>H: transcript
Loading
Every branch of POST /approvals/<approvalId>
flowchart TD
    A["POST /approvals/{id}"] --> B{"id outstanding?"}
    B -- "no: unknown, or already answered" --> C["404 — nothing changed"]
    B -- yes --> D["record the decision"]
    D --> E{"any approvals left<br/>on this turn?"}
    E -- yes --> F["202 — keep answering"]
    E -- no --> G["resume the turn in this request"]
    G --> H{"paused again?"}
    H -- yes --> I["200 awaiting-approval"]
    H -- no --> J["200 completed"]
Loading

404 and 202 are answering different questions, which is easy to misread. 404 means this request changed nothing and no command will run for it — the identifier was never issued, or somebody already answered it, and treating a repeat as fresh would run the command twice. 202 means the decision was recorded but the turn is still short an answer: one model step can request several approvals, and the AI SDK wants every response in a single message, so the turn waits for the last one.

architecture.html draws the same things in more detail and does not render here, so open examples/codemode/architecture.html for the full version.

@aron-cf

aron-cf commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@AntoniTok thanks for opening this PR. It's pretty dense I need to sit with it once we've got the main codemode backend out of the door. In my head the key thing workspace needed to do was to provide a hook within an action with metadata that allowed a gate to be implemented in the tool layer, plus a hook after the action was completed for auditing. Similar to our observability implementation.

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.

2 participants