From 4fd3532ff4a796add3669b8e19d0cb8c0670a380 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Tue, 28 Jul 2026 16:37:03 +0100
Subject: [PATCH 01/13] examples/codemode: gate agent commands behind human
approval
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.
---
examples/codemode/package.json | 2 +
examples/codemode/src/agent.test.ts | 495 ++++++++++++++++++
examples/codemode/src/agent.ts | 175 ++++++-
examples/codemode/src/approval-policy.test.ts | 234 +++++++++
examples/codemode/src/approval-policy.ts | 327 ++++++++++++
examples/codemode/src/index.ts | 240 ++++++++-
examples/codemode/src/session.ts | 92 ++++
examples/codemode/src/tools/exec.ts | 43 +-
examples/codemode/src/turn-store.test.ts | 273 ++++++++++
examples/codemode/src/turn-store.ts | 221 ++++++++
examples/codemode/worker-configuration.d.ts | 5 +-
examples/codemode/wrangler.jsonc | 12 +
package-lock.json | 1 +
13 files changed, 2070 insertions(+), 50 deletions(-)
create mode 100644 examples/codemode/src/agent.test.ts
create mode 100644 examples/codemode/src/approval-policy.test.ts
create mode 100644 examples/codemode/src/approval-policy.ts
create mode 100644 examples/codemode/src/session.ts
create mode 100644 examples/codemode/src/turn-store.test.ts
create mode 100644 examples/codemode/src/turn-store.ts
diff --git a/examples/codemode/package.json b/examples/codemode/package.json
index 91072df3..d4196cd4 100644
--- a/examples/codemode/package.json
+++ b/examples/codemode/package.json
@@ -7,6 +7,7 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
+ "test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -18,6 +19,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
"typescript": "^6.0.3",
+ "vitest": "^4.1.7",
"wrangler": "^4.96.0"
}
}
diff --git a/examples/codemode/src/agent.test.ts b/examples/codemode/src/agent.test.ts
new file mode 100644
index 00000000..96303a92
--- /dev/null
+++ b/examples/codemode/src/agent.test.ts
@@ -0,0 +1,495 @@
+import type { LanguageModelV3Content } from "@ai-sdk/provider";
+import { MockLanguageModelV3 } from "ai/test";
+import { describe, expect, it } from "vitest";
+
+import { MAX_STEPS, runAgentTurn } from "./agent.js";
+import type { ApprovalPolicy } from "./approval-policy.js";
+import { decideApproval } from "./approval-policy.js";
+import type { ExecWorkspaceLike } from "./tools/exec.js";
+
+// ---------------------------------------------------------------
+// Test doubles
+// ---------------------------------------------------------------
+
+const READ = "cat /workspace/hello.txt";
+const WRITE = "rm -rf /workspace";
+
+function usage() {
+ return {
+ inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
+ outputTokens: { total: 1, text: 1, reasoning: 0 },
+ };
+}
+
+function execCall(
+ toolCallId: string,
+ input: { command: string; backend?: string; cwd?: string },
+): LanguageModelV3Content {
+ return { type: "tool-call", toolCallId, toolName: "exec", input: JSON.stringify(input) };
+}
+
+function say(text: string): LanguageModelV3Content {
+ return { type: "text", text };
+}
+
+/**
+ * A model that replays scripted responses, one per `generateText`
+ * call. The same instance is reused across a pause and its resume, so
+ * the script reads as the whole turn.
+ */
+function scriptedModel(script: LanguageModelV3Content[][]): MockLanguageModelV3 {
+ let call = 0;
+ return new MockLanguageModelV3({
+ doGenerate: async () => {
+ const content = script[Math.min(call, script.length - 1)];
+ call += 1;
+ const callsTool = content.some((part) => part.type === "tool-call");
+ return {
+ content,
+ finishReason: { unified: callsTool ? "tool-calls" : "stop", raw: undefined },
+ usage: usage(),
+ warnings: [],
+ };
+ },
+ });
+}
+
+interface RecordedExec {
+ command: string;
+ backend: string | undefined;
+ cwd: string | undefined;
+}
+
+function fakeWorkspace(): { workspace: ExecWorkspaceLike; calls: RecordedExec[] } {
+ const calls: RecordedExec[] = [];
+ const workspace: ExecWorkspaceLike = {
+ shell: {
+ exec: async (command, options) => {
+ calls.push({ command, backend: options.backend, cwd: options.cwd });
+ return {
+ result: async () => ({ exitCode: 0, stdout: `ran: ${command}`, stderr: "" }),
+ };
+ },
+ },
+ };
+ return { workspace, calls };
+}
+
+const env = {} as Env;
+
+// ---------------------------------------------------------------
+
+describe("runAgentTurn", () => {
+ describe("commands that need no approval", () => {
+ it("runs a read and finishes the turn", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: READ, backend: "shell" })],
+ [say("done")],
+ ]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "read the file" });
+
+ expect(transcript.status).toBe("completed");
+ expect(transcript.pendingApprovals).toEqual([]);
+ expect(calls).toEqual([{ command: READ, backend: "shell", cwd: undefined }]);
+ expect(transcript.toolCalls).toHaveLength(1);
+ expect(transcript.toolCalls[0]).toMatchObject({
+ command: READ,
+ backend: "shell",
+ exitCode: 0,
+ stdout: `ran: ${READ}`,
+ });
+ expect(transcript.text).toBe("done");
+ });
+
+ it("honours a policy that trusts a backend outright", async () => {
+ // Same mutating command, different policy: the gate is
+ // configuration, not a hardcoded list.
+ const trusting: ApprovalPolicy = { rules: { shell: "never" }, fallback: "always" };
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("removed")],
+ ]);
+
+ const transcript = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ prompt: "delete it",
+ policy: trusting,
+ });
+
+ expect(transcript.status).toBe("completed");
+ expect(calls).toHaveLength(1);
+ });
+ });
+
+ describe("commands that need approval", () => {
+ it("pauses without running the command", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([[execCall("c1", { command: WRITE, backend: "shell" })]]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+
+ expect(transcript.status).toBe("awaiting-approval");
+ // The whole point: nothing ran.
+ expect(calls).toEqual([]);
+ expect(transcript.toolCalls).toEqual([]);
+ expect(transcript.pendingApprovals).toHaveLength(1);
+ expect(transcript.pendingApprovals[0]).toMatchObject({
+ toolCallId: "c1",
+ command: WRITE,
+ backend: "shell",
+ cwd: null,
+ });
+ expect(transcript.pendingApprovals[0].approvalId.length).toBeGreaterThan(0);
+ });
+
+ it("explains the pause with the same reason the policy gave", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([[execCall("c1", { command: WRITE, backend: "shell" })]]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+
+ expect(transcript.pendingApprovals[0].reason).toBe(
+ decideApproval({ command: WRITE, backend: "shell" }).reason,
+ );
+ });
+
+ it("reports the backend the model chose, not the default", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: "uname -a", backend: "container" })],
+ ]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "which kernel?" });
+
+ expect(transcript.pendingApprovals[0].backend).toBe("container");
+ });
+
+ it("falls back to the default backend when the model omits one", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([[execCall("c1", { command: WRITE })]]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+
+ expect(transcript.pendingApprovals[0].backend).toBe("shell");
+ });
+
+ it("keeps the work it already did before pausing", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: READ, backend: "shell" })],
+ [execCall("c2", { command: WRITE, backend: "shell" })],
+ ]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "read then delete" });
+
+ expect(transcript.status).toBe("awaiting-approval");
+ expect(calls.map((call) => call.command)).toEqual([READ]);
+ expect(transcript.toolCalls.map((call) => call.command)).toEqual([READ]);
+ });
+ });
+
+ describe("the resume spine", () => {
+ it("survives a round trip through JSON", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("removed")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+ // This is the assumption the durable object depends on: the
+ // messages are plain data, storable and retrievable verbatim.
+ const rehydrated = JSON.parse(JSON.stringify(paused.messages));
+ expect(rehydrated).toEqual(paused.messages);
+
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: rehydrated,
+ approvals: [{ approvalId: paused.pendingApprovals[0].approvalId, approved: true }],
+ },
+ });
+
+ expect(resumed.status).toBe("completed");
+ });
+
+ it("grows with each pass so a second pause can be resumed too", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [execCall("c2", { command: "mkdir /workspace/d", backend: "shell" })],
+ [say("done")],
+ ]);
+
+ const first = await runAgentTurn({ env, workspace, model, prompt: "delete then make" });
+ const second = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: first.messages,
+ approvals: [{ approvalId: first.pendingApprovals[0].approvalId, approved: true }],
+ },
+ });
+
+ expect(second.status).toBe("awaiting-approval");
+ expect(second.messages.length).toBeGreaterThan(first.messages.length);
+
+ const third = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: second.messages,
+ approvals: [{ approvalId: second.pendingApprovals[0].approvalId, approved: true }],
+ },
+ });
+
+ expect(third.status).toBe("completed");
+ });
+ });
+
+ describe("resuming with an approval", () => {
+ it("runs the command that was held back", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("removed")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: [{ approvalId: paused.pendingApprovals[0].approvalId, approved: true }],
+ },
+ });
+
+ expect(resumed.status).toBe("completed");
+ expect(calls).toEqual([{ command: WRITE, backend: "shell", cwd: undefined }]);
+ expect(resumed.text).toBe("removed");
+ });
+
+ it("reports the approved command in the transcript", async () => {
+ // An approved call executes before the first model call of the
+ // resumed pass, so it lands in no step. Reading the transcript
+ // off the steps would lose it entirely.
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("removed")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: [{ approvalId: paused.pendingApprovals[0].approvalId, approved: true }],
+ },
+ });
+
+ expect(resumed.toolCalls).toHaveLength(1);
+ expect(resumed.toolCalls[0]).toMatchObject({
+ command: WRITE,
+ backend: "shell",
+ exitCode: 0,
+ stdout: `ran: ${WRITE}`,
+ });
+ });
+ });
+
+ describe("resuming with a rejection", () => {
+ it("does not run the command", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("understood, leaving it alone")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: [
+ {
+ approvalId: paused.pendingApprovals[0].approvalId,
+ approved: false,
+ reason: "not now",
+ },
+ ],
+ },
+ });
+
+ expect(calls).toEqual([]);
+ expect(resumed.toolCalls).toEqual([]);
+ expect(resumed.status).toBe("completed");
+ expect(resumed.text).toBe("understood, leaving it alone");
+ });
+
+ it("tells the model the command was denied, and why", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: WRITE, backend: "shell" })],
+ [say("understood")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete it" });
+ await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: [
+ {
+ approvalId: paused.pendingApprovals[0].approvalId,
+ approved: false,
+ reason: "not now",
+ },
+ ],
+ },
+ });
+
+ const prompt = JSON.stringify(model.doGenerateCalls.at(-1)?.prompt);
+ expect(prompt).toContain("execution-denied");
+ expect(prompt).toContain("not now");
+ });
+ });
+
+ describe("several approvals in one step", () => {
+ it("pauses on each of them", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [
+ execCall("c1", { command: WRITE, backend: "shell" }),
+ execCall("c2", { command: "mkdir /workspace/d", backend: "shell" }),
+ ],
+ ]);
+
+ const transcript = await runAgentTurn({ env, workspace, model, prompt: "delete and make" });
+
+ expect(transcript.status).toBe("awaiting-approval");
+ expect(transcript.pendingApprovals).toHaveLength(2);
+ expect(transcript.pendingApprovals.map((entry) => entry.command)).toEqual([
+ WRITE,
+ "mkdir /workspace/d",
+ ]);
+ expect(calls).toEqual([]);
+ });
+
+ it("runs both once both are answered", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [
+ execCall("c1", { command: WRITE, backend: "shell" }),
+ execCall("c2", { command: "mkdir /workspace/d", backend: "shell" }),
+ ],
+ [say("both done")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete and make" });
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: paused.pendingApprovals.map((entry) => ({
+ approvalId: entry.approvalId,
+ approved: true,
+ })),
+ },
+ });
+
+ expect(resumed.status).toBe("completed");
+ expect(calls.map((call) => call.command)).toEqual([WRITE, "mkdir /workspace/d"]);
+ expect(resumed.toolCalls).toHaveLength(2);
+ });
+
+ it("can approve one and deny the other", async () => {
+ const { workspace, calls } = fakeWorkspace();
+ const model = scriptedModel([
+ [
+ execCall("c1", { command: WRITE, backend: "shell" }),
+ execCall("c2", { command: "mkdir /workspace/d", backend: "shell" }),
+ ],
+ [say("partly done")],
+ ]);
+
+ const paused = await runAgentTurn({ env, workspace, model, prompt: "delete and make" });
+ const resumed = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ resume: {
+ messages: paused.messages,
+ approvals: [
+ { approvalId: paused.pendingApprovals[0].approvalId, approved: false, reason: "no" },
+ { approvalId: paused.pendingApprovals[1].approvalId, approved: true },
+ ],
+ },
+ });
+
+ expect(calls.map((call) => call.command)).toEqual(["mkdir /workspace/d"]);
+ expect(resumed.toolCalls).toHaveLength(1);
+ });
+ });
+
+ describe("the step budget", () => {
+ it("accumulates steps across passes", async () => {
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: READ, backend: "shell" })],
+ [say("done")],
+ ]);
+
+ const transcript = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ prompt: "read it",
+ stepsUsed: 3,
+ });
+
+ expect(transcript.steps).toBe(2);
+ expect(transcript.stepsUsed).toBe(5);
+ });
+
+ it("shrinks the allowance a resumed pass gets", async () => {
+ // Without this, every approval would hand the loop a fresh
+ // budget and an approval cycle could run without bound.
+ const { workspace } = fakeWorkspace();
+ const model = scriptedModel([
+ [execCall("c1", { command: READ, backend: "shell" })],
+ [say("done")],
+ ]);
+
+ const transcript = await runAgentTurn({
+ env,
+ workspace,
+ model,
+ prompt: "read it",
+ stepsUsed: MAX_STEPS,
+ });
+
+ expect(transcript.steps).toBe(1);
+ expect(transcript.stepsUsed).toBe(MAX_STEPS + 1);
+ });
+ });
+});
diff --git a/examples/codemode/src/agent.ts b/examples/codemode/src/agent.ts
index b28d0188..6caa571f 100644
--- a/examples/codemode/src/agent.ts
+++ b/examples/codemode/src/agent.ts
@@ -12,11 +12,21 @@
* the agent can run anywhere: here it runs inside the Worker fetch
* handler and reaches the workspace through its stub, so the
* workspace durable object never has to know an agent exists.
+ *
+ * A turn can end in one of two ways. Either the model finishes, or it
+ * asks to run a command the approval policy holds back — in which case
+ * the turn returns `awaiting-approval` along with the message history
+ * needed to pick it up again. Nothing about that history lives here:
+ * `runAgentTurn` is one `generateText` call inside a fetch handler and
+ * is over when it returns. Whoever calls it is responsible for storing
+ * a paused turn and handing it back on resume, which in this example
+ * is the `AgentSession` durable object.
*/
-import { generateText, stepCountIs } from "ai";
+import { generateText, type LanguageModel, type ModelMessage, stepCountIs } from "ai";
import { createWorkersAI } from "workers-ai-provider";
+import { type ApprovalPolicy, DEFAULT_APPROVAL_POLICY, decideApproval } from "./approval-policy.js";
import { createExecTool, type ExecWorkspaceLike } from "./tools/exec.js";
// The Workers AI model that drives the loop. Kimi K2.6 handles tool
@@ -24,13 +34,53 @@ import { createExecTool, type ExecWorkspaceLike } from "./tools/exec.js";
const MODEL_ID = "@cf/moonshotai/kimi-k2.6";
// Plenty of budget for a write-then-read loop, with a ceiling so a
-// confused model can't spin forever.
-const MAX_STEPS = 12;
+// confused model can't spin forever. Spent across every pass of a
+// turn, not per pass, so waiting for a human doesn't buy the model a
+// fresh allowance.
+export const MAX_STEPS = 12;
+
+/** A human's answer to one approval request. */
+export interface ApprovalResponse {
+ approvalId: string;
+ approved: boolean;
+ /** Shown to the model when a command is denied. */
+ reason?: string;
+}
+
+/** A command the model wants to run, held back for a human. */
+export interface PendingApproval {
+ /** Identifies this request when the answer arrives. */
+ approvalId: string;
+ toolCallId: string;
+ backend: string;
+ command: string;
+ cwd: string | null;
+ /** Why the policy stopped it, in one line. */
+ reason: string;
+}
export interface AgentTurnOptions {
env: Env;
workspace: ExecWorkspaceLike;
- prompt: string;
+ /** The user's request. Omit when resuming a paused turn. */
+ prompt?: string;
+ /** Pick up a turn that paused, given a human's answers. */
+ resume?: {
+ /** The `messages` a previous pass returned. */
+ messages: ModelMessage[];
+ /** One entry per approval the paused pass requested. */
+ approvals: ApprovalResponse[];
+ };
+ /** Which commands need a human. Defaults to the example's policy. */
+ policy?: ApprovalPolicy;
+ /** Steps earlier passes of this turn already spent. */
+ stepsUsed?: number;
+ /**
+ * The model to drive the loop with. Defaults to Workers AI; tests
+ * inject a scripted model so the loop can be exercised without a
+ * model round trip.
+ */
+ model?: LanguageModel;
}
export interface AgentToolCall {
@@ -42,10 +92,26 @@ export interface AgentToolCall {
}
export interface AgentTranscript {
+ /**
+ * `awaiting-approval` means the model asked for a command the policy
+ * holds back, nothing further ran, and the turn is resumable.
+ */
+ status: "completed" | "awaiting-approval";
text: string;
finishReason: string;
+ /** Model steps this pass took. */
steps: number;
+ /** Model steps the whole turn has taken. */
+ stepsUsed: number;
+ /** Commands this pass ran. */
toolCalls: AgentToolCall[];
+ pendingApprovals: PendingApproval[];
+ /**
+ * The turn's history so far, for storing against a resume. Server
+ * side only — it carries the whole conversation and is not something
+ * to hand a client.
+ */
+ messages: ModelMessage[];
}
const SYSTEM_PROMPT = [
@@ -67,17 +133,42 @@ const SYSTEM_PROMPT = [
" boot, so reach for it only when the lighter backends can't run",
" the command.",
"",
+ "Some commands need a human's approval before they run. When one",
+ "does, the turn stops and resumes after a person answers; you do",
+ "not need to do anything differently. If a command comes back as",
+ "denied, do not try to run it again on another backend — say what",
+ "you were not allowed to do and stop.",
+ "",
"When the task is done, reply with a short plain-text summary of",
"what you did.",
].join("\n");
export async function runAgentTurn(opts: AgentTurnOptions): Promise {
- const workersai = createWorkersAI({ binding: opts.env.AI });
- const model = workersai(MODEL_ID);
+ if (opts.prompt == null && opts.resume == null) {
+ throw new Error("runAgentTurn: pass either a prompt or a turn to resume");
+ }
+
+ const model = opts.model ?? createWorkersAI({ binding: opts.env.AI })(MODEL_ID);
+ const policy = opts.policy ?? DEFAULT_APPROVAL_POLICY;
+ const defaultBackend = "shell";
+
+ // Every command this pass runs, collected as it happens. See
+ // `onExec` on the tool for why the steps aren't enough.
+ const toolCalls: AgentToolCall[] = [];
const exec = createExecTool({
workspace: opts.workspace,
maxBytes: 16 * 1024,
+ policy,
+ onExec: (record) => {
+ toolCalls.push({
+ backend: record.backend,
+ command: record.command,
+ exitCode: record.exitCode,
+ stdout: record.stdout,
+ stderr: record.stderr,
+ });
+ },
backends: {
shell: {
description:
@@ -112,41 +203,71 @@ export async function runAgentTurn(opts: AgentTurnOptions): Promise ({
+ type: "tool-approval-response" as const,
+ approvalId: approval.approvalId,
+ approved: approval.approved,
+ ...(approval.reason != null ? { reason: approval.reason } : {}),
+ })),
+ },
+ ]
+ : [{ role: "user", content: opts.prompt as string }];
+
+ const stepsAlreadyUsed = opts.stepsUsed ?? 0;
const result = await generateText({
model,
system: SYSTEM_PROMPT,
- prompt: opts.prompt,
+ messages,
tools: { exec },
- stopWhen: stepCountIs(MAX_STEPS),
+ // At least one step, so a turn that has exhausted its budget still
+ // reports back rather than failing.
+ stopWhen: stepCountIs(Math.max(1, MAX_STEPS - stepsAlreadyUsed)),
});
- const toolCalls: AgentToolCall[] = [];
- for (const step of result.steps) {
- for (const tr of step.toolResults) {
- const output = tr.output as {
- backend?: string;
- command?: string;
- exitCode?: number;
- stdout?: string;
- stderr?: string;
- };
- toolCalls.push({
- backend: output.backend ?? "",
- command: output.command ?? "",
- exitCode: output.exitCode ?? -1,
- stdout: output.stdout ?? "",
- stderr: output.stderr ?? "",
- });
- }
+ const lastStep = result.steps.at(-1);
+
+ // The pass paused if the model asked for anything the policy holds
+ // back. Those calls did not run.
+ const pendingApprovals: PendingApproval[] = [];
+ for (const part of lastStep?.content ?? []) {
+ if (part.type !== "tool-approval-request") continue;
+ const input = part.toolCall.input as { command: string; cwd?: string; backend?: string };
+ const backend = input.backend ?? defaultBackend;
+ pendingApprovals.push({
+ approvalId: part.approvalId,
+ toolCallId: part.toolCall.toolCallId,
+ backend,
+ command: input.command,
+ cwd: input.cwd ?? null,
+ // The AI SDK's gate answers yes or no; ask the policy again for
+ // the wording a human should see. Same inputs, same answer.
+ reason: decideApproval({ command: input.command, backend }, policy).reason,
+ });
}
return {
+ status: pendingApprovals.length > 0 ? "awaiting-approval" : "completed",
text: result.text,
finishReason: result.finishReason,
steps: result.steps.length,
+ stepsUsed: stepsAlreadyUsed + result.steps.length,
toolCalls,
+ pendingApprovals,
+ // The history to store against a resume: what went in, plus what
+ // the model and the tools produced on the way out.
+ messages: [...messages, ...(lastStep?.response.messages ?? [])],
};
}
diff --git a/examples/codemode/src/approval-policy.test.ts b/examples/codemode/src/approval-policy.test.ts
new file mode 100644
index 00000000..bd83d8e9
--- /dev/null
+++ b/examples/codemode/src/approval-policy.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from "vitest";
+
+import { type ApprovalPolicy, DEFAULT_APPROVAL_POLICY, decideApproval } from "./approval-policy.js";
+
+// Shorthand: does this command need a human under the default policy?
+function gates(command: string, backend: string, policy?: ApprovalPolicy): boolean {
+ return decideApproval({ command, backend }, policy).needsApproval;
+}
+
+describe("decideApproval", () => {
+ describe("the 'always' rule", () => {
+ it("gates every command on the container backend", () => {
+ expect(gates("cat /workspace/hello.txt", "container")).toBe(true);
+ expect(gates("uname -a", "container")).toBe(true);
+ });
+
+ it("gates a command that the same rule set waves through on shell", () => {
+ // Identical command, different backend: proves the rule is
+ // per-backend rather than per-command.
+ expect(gates("cat /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("cat /workspace/hello.txt", "container")).toBe(true);
+ });
+ });
+
+ describe("the 'never' rule", () => {
+ const trusting: ApprovalPolicy = { rules: { shell: "never" }, fallback: "always" };
+
+ it("waves through even a destructive command", () => {
+ expect(gates("rm -rf /workspace", "shell", trusting)).toBe(false);
+ });
+ });
+
+ describe("unknown backends", () => {
+ it("falls back to the strictest rule", () => {
+ expect(gates("cat /workspace/hello.txt", "not-a-backend")).toBe(true);
+ });
+
+ it("honours an explicit fallback", () => {
+ const lenient: ApprovalPolicy = { rules: {}, fallback: "never" };
+ expect(gates("rm -rf /", "whatever", lenient)).toBe(false);
+ });
+ });
+
+ describe("the 'read-only' rule on a shell dialect", () => {
+ it("waves through recognized reads", () => {
+ expect(gates("cat /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("ls -la /workspace", "shell")).toBe(false);
+ expect(gates("grep -n needle /workspace/haystack.txt", "shell")).toBe(false);
+ expect(gates("find /workspace -name '*.ts'", "shell")).toBe(false);
+ expect(gates("wc -l /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("head -n 5 /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("stat /workspace/hello.txt", "shell")).toBe(false);
+ });
+
+ it("waves through read-only git plumbing", () => {
+ expect(gates("git status", "shell")).toBe(false);
+ expect(gates("git log --oneline -5", "shell")).toBe(false);
+ expect(gates("git diff HEAD", "shell")).toBe(false);
+ });
+
+ it("gates git subcommands that write", () => {
+ expect(gates("git commit -m wip", "shell")).toBe(true);
+ expect(gates("git push origin main", "shell")).toBe(true);
+ expect(gates("git checkout -b feature", "shell")).toBe(true);
+ expect(gates("git", "shell")).toBe(true);
+ });
+
+ it("gates mutating commands", () => {
+ expect(gates("rm -rf /workspace", "shell")).toBe(true);
+ expect(gates("mv /workspace/a /workspace/b", "shell")).toBe(true);
+ expect(gates("mkdir -p /workspace/deep/dir", "shell")).toBe(true);
+ expect(gates("chmod 777 /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("npm install", "shell")).toBe(true);
+ });
+
+ it("gates redirection, even of a read", () => {
+ expect(gates("cat /workspace/a > /workspace/b", "shell")).toBe(true);
+ expect(gates("cat /workspace/a >> /workspace/b", "shell")).toBe(true);
+ expect(gates("cat < /workspace/a", "shell")).toBe(true);
+ });
+
+ it("gates composition, even of two reads", () => {
+ // A pipeline of reads is harmless, but allowing composition
+ // means auditing every element; the gate stays closed instead.
+ expect(gates("cat /workspace/a | grep needle", "shell")).toBe(true);
+ expect(gates("ls /workspace; rm -rf /workspace", "shell")).toBe(true);
+ expect(gates("ls /workspace && rm -rf /workspace", "shell")).toBe(true);
+ expect(gates("ls /workspace || true", "shell")).toBe(true);
+ expect(gates("ls /workspace &", "shell")).toBe(true);
+ });
+
+ it("gates command substitution", () => {
+ expect(gates("cat $(ls /workspace)", "shell")).toBe(true);
+ expect(gates("cat `ls /workspace`", "shell")).toBe(true);
+ expect(gates("cat /workspace/$(whoami)", "shell")).toBe(true);
+ });
+
+ it("gates a newline that hides a second command", () => {
+ expect(gates("ls /workspace\nrm -rf /workspace", "shell")).toBe(true);
+ });
+
+ it("gates sed -i, which edits in place", () => {
+ expect(gates("sed -i s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("sed -i.bak s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("sed --in-place s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ });
+
+ it("waves through sed as a filter", () => {
+ expect(gates("sed s/a/b/ /workspace/hello.txt", "shell")).toBe(false);
+ });
+
+ it("strips leading environment assignments before reading the verb", () => {
+ expect(gates("LC_ALL=C sort /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("LC_ALL=C rm /workspace/hello.txt", "shell")).toBe(true);
+ });
+
+ it("gates an unrecognized command rather than guessing", () => {
+ expect(gates("frobnicate --hard", "shell")).toBe(true);
+ expect(gates("", "shell")).toBe(true);
+ expect(gates(" ", "shell")).toBe(true);
+ });
+ });
+
+ describe("the 'read-only' rule on the codemode dialect", () => {
+ it("waves through a snippet that only reads", () => {
+ expect(gates('return await state.readFile("/workspace/hello.txt");', "codemode")).toBe(false);
+ expect(gates('const s = await state.stat("/workspace"); return s.size;', "codemode")).toBe(
+ false,
+ );
+ expect(gates('return (await state.readdir("/workspace")).length;', "codemode")).toBe(false);
+ });
+
+ it("waves through a snippet that never touches state", () => {
+ // The sandbox has no network and no other reach into the store,
+ // so a snippet without state.* can only compute.
+ expect(gates("return 2 + 2;", "codemode")).toBe(false);
+ });
+
+ it("gates a snippet that mutates", () => {
+ expect(gates('await state.writeFile("/workspace/x", "hi");', "codemode")).toBe(true);
+ expect(gates('await state.mkdir("/workspace/d", { recursive: true });', "codemode")).toBe(
+ true,
+ );
+ expect(gates('await state.rm("/workspace/x", { force: true });', "codemode")).toBe(true);
+ expect(gates('await state.chmod("/workspace/x", 0o755);', "codemode")).toBe(true);
+ expect(gates('await state.symlink("/workspace/x", "/workspace/y");', "codemode")).toBe(true);
+ });
+
+ it("gates a mutation hidden behind a computed member access", () => {
+ // The point of allowlisting reads instead of blocklisting
+ // writes: a blocklist misses these, an allowlist does not.
+ expect(gates('await state["writeFile"]("/workspace/x", "hi");', "codemode")).toBe(true);
+ expect(
+ gates('const m = "writeFile"; await state[m]("/workspace/x", "hi");', "codemode"),
+ ).toBe(true);
+ expect(gates('await state?.["writeFile"]("/workspace/x", "hi");', "codemode")).toBe(true);
+ });
+
+ it("gates a snippet that aliases state", () => {
+ expect(gates('const { writeFile } = state; await writeFile("/x", "y");', "codemode")).toBe(
+ true,
+ );
+ expect(gates("const s = state; return s;", "codemode")).toBe(true);
+ });
+
+ it("gates an unrecognized state member", () => {
+ expect(gates("await state.frobnicate();", "codemode")).toBe(true);
+ });
+
+ it("waves through a read reached with optional chaining", () => {
+ expect(gates('return await state?.readFile("/workspace/hello.txt");', "codemode")).toBe(
+ false,
+ );
+ });
+
+ it("does not apply shell rules to a JavaScript snippet", () => {
+ // `>` is a comparison here, not a redirect, and `state.ls` is a
+ // read. A shell-dialect check would gate this.
+ expect(gates('return (await state.ls("/workspace")).length > 0;', "codemode")).toBe(false);
+ });
+ });
+
+ describe("the decision itself", () => {
+ it("explains every gate it raises", () => {
+ const commands: Array<[string, string]> = [
+ ["rm -rf /workspace", "shell"],
+ ["cat /workspace/a > /workspace/b", "shell"],
+ ["uname -a", "container"],
+ ['await state.writeFile("/workspace/x", "hi");', "codemode"],
+ ["frobnicate", "not-a-backend"],
+ ];
+ for (const [command, backend] of commands) {
+ const decision = decideApproval({ command, backend });
+ expect(decision.needsApproval).toBe(true);
+ expect(decision.reason.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("explains why it let a command through", () => {
+ const decision = decideApproval({ command: "cat /workspace/hello.txt", backend: "shell" });
+ expect(decision.needsApproval).toBe(false);
+ expect(decision.reason.length).toBeGreaterThan(0);
+ });
+
+ it("names the backend rule in the reason, so the queue is readable", () => {
+ expect(decideApproval({ command: "uname -a", backend: "container" }).reason).toContain(
+ "container",
+ );
+ });
+
+ it("is a pure function of command and backend", () => {
+ // Approval survives a pause: the AI SDK re-runs the policy when
+ // the turn resumes, and an approved call whose policy has since
+ // flipped is converted to a denial. Same input, same answer,
+ // every time.
+ const call = { command: 'await state.writeFile("/workspace/x", "hi");', backend: "codemode" };
+ const first = decideApproval(call);
+ for (let i = 0; i < 5; i++) {
+ expect(decideApproval(call)).toEqual(first);
+ }
+ });
+ });
+
+ describe("DEFAULT_APPROVAL_POLICY", () => {
+ it("gates the container backend outright and reads nothing else", () => {
+ expect(DEFAULT_APPROVAL_POLICY.rules).toEqual({
+ shell: "read-only",
+ codemode: "read-only",
+ container: "always",
+ });
+ expect(DEFAULT_APPROVAL_POLICY.fallback).toBe("always");
+ });
+ });
+});
diff --git a/examples/codemode/src/approval-policy.ts b/examples/codemode/src/approval-policy.ts
new file mode 100644
index 00000000..6182a53e
--- /dev/null
+++ b/examples/codemode/src/approval-policy.ts
@@ -0,0 +1,327 @@
+/**
+ * Which commands a human has to approve before the agent runs them.
+ *
+ * The policy is a table keyed by backend id, because the three
+ * backends differ in what they can reach: `container` is a full Linux
+ * userland with a public network, while `shell` and `codemode` are
+ * sandboxed and see only the workspace filesystem. Expressing the
+ * rules per backend keeps the interesting decision visible and
+ * configurable instead of buried in a list of blocked commands.
+ *
+ * Every rule denies by default. Under `read-only`, a command runs
+ * unattended only when it is *recognizably* a read; anything the
+ * matcher does not understand needs a human. That direction matters:
+ * a matcher that fails closed turns an unparsed command into a
+ * question, while one that fails open turns it into an unreviewed
+ * mutation.
+ *
+ * It is worth being plain about the limit here. Pattern-matching a
+ * command line is a heuristic, appropriate for an example. A
+ * production gate belongs at the capability layer — hand the backend
+ * a read-only view of the workspace and let the filesystem refuse the
+ * write — rather than in a matcher that has to anticipate every way a
+ * shell can be told to write a file. Denying by default is what makes
+ * the heuristic's failure mode tolerable in the meantime.
+ *
+ * `decideApproval` must stay a pure function of the command and the
+ * backend. The AI SDK re-runs it when a paused turn resumes, and an
+ * approved call whose policy has since flipped to "no approval
+ * needed" is converted into a denial. Consulting a clock, or any
+ * mutable state, would make approvals decay on their own.
+ */
+
+/** What a backend's commands cost in human attention. */
+export type BackendRule =
+ /** Every command on this backend needs a human. */
+ | "always"
+ /** Recognized reads run unattended; everything else needs a human. */
+ | "read-only"
+ /** Nothing on this backend needs a human. */
+ | "never";
+
+/**
+ * The language a backend's `command` field is written in. The
+ * `read-only` rule has to parse the command to classify it, and the
+ * codemode backend takes JavaScript where the others take a shell
+ * line.
+ */
+export type CommandDialect = "shell" | "javascript";
+
+export interface ApprovalPolicy {
+ /** Rule per backend id, matching the ids the Workspace registered. */
+ rules: Record;
+ /**
+ * Rule for a backend absent from `rules`. Defaults to `always`, so
+ * registering a new backend cannot quietly widen what runs
+ * unattended.
+ */
+ fallback?: BackendRule;
+ /**
+ * Command language per backend. Defaults to JavaScript for the
+ * codemode backend and a shell line for everything else.
+ */
+ dialects?: Record;
+}
+
+export interface ApprovalDecision {
+ needsApproval: boolean;
+ /**
+ * One line explaining the verdict, shown to whoever works the
+ * approval queue. Populated for allowed commands too, so a
+ * transcript can say why nothing was asked.
+ */
+ reason: string;
+}
+
+/**
+ * The example's policy. The container backend is gated outright: it
+ * runs real binaries with public network access, so "which command is
+ * it" is the wrong question to be asking. The two sandboxed backends
+ * can reach only the workspace filesystem, so reads there are cheap
+ * enough to run unattended.
+ */
+export const DEFAULT_APPROVAL_POLICY: ApprovalPolicy = {
+ rules: { shell: "read-only", codemode: "read-only", container: "always" },
+ fallback: "always",
+};
+
+const DEFAULT_DIALECTS: Record = {
+ shell: "shell",
+ container: "shell",
+ codemode: "javascript",
+};
+
+/**
+ * Shell characters that redirect output or glue a second command onto
+ * the first. Their presence disqualifies a line on its own, before
+ * the verb is even considered: `cat a > b` writes, and
+ * `ls; rm -rf /` is not the `ls` it appears to be. A pipeline of two
+ * reads is harmless in principle, but allowing composition means
+ * classifying every element and every operator, so the gate stays
+ * closed on all of it.
+ */
+const SHELL_METACHARACTERS: Array<[string, string]> = [
+ [">", "redirects output"],
+ ["<", "redirects input"],
+ ["|", "pipes into another command"],
+ [";", "chains another command"],
+ ["&", "chains or backgrounds another command"],
+ ["$", "expands a variable or substitutes a command"],
+ ["`", "substitutes a command"],
+ ["(", "groups or substitutes a command"],
+ [")", "groups or substitutes a command"],
+ ["\n", "hides a second command on another line"],
+ ["\r", "hides a second command on another line"],
+];
+
+/**
+ * Commands that only read. Deliberately conservative: `echo` and
+ * `awk` are absent because both can write through their own syntax
+ * rather than through a shell redirect, and the extra approvals cost
+ * less than the argument about whether the matcher caught every form.
+ */
+const READ_ONLY_COMMANDS = new Set([
+ "basename",
+ "cat",
+ "cmp",
+ "cut",
+ "date",
+ "df",
+ "diff",
+ "dirname",
+ "du",
+ "egrep",
+ "fgrep",
+ "file",
+ "find",
+ "grep",
+ "head",
+ "id",
+ "ls",
+ "pwd",
+ "readlink",
+ "realpath",
+ "sed",
+ "sort",
+ "stat",
+ "tail",
+ "test",
+ "tree",
+ "true",
+ "uname",
+ "uniq",
+ "wc",
+ "which",
+ "whoami",
+]);
+
+/** Git subcommands that only inspect history. */
+const READ_ONLY_GIT_SUBCOMMANDS = new Set([
+ "blame",
+ "cat-file",
+ "describe",
+ "diff",
+ "log",
+ "ls-files",
+ "ls-tree",
+ "rev-parse",
+ "shortlog",
+ "show",
+ "status",
+]);
+
+/** `state.*` calls that only read. The mutations are the complement. */
+const READ_ONLY_STATE_MEMBERS = new Set([
+ "exists",
+ "find",
+ "grep",
+ "lstat",
+ "ls",
+ "readFile",
+ "readFileBytes",
+ "readdir",
+ "readlink",
+ "stat",
+]);
+
+export function decideApproval(
+ call: { command: string; backend: string },
+ policy: ApprovalPolicy = DEFAULT_APPROVAL_POLICY,
+): ApprovalDecision {
+ const rule = policy.rules[call.backend] ?? policy.fallback ?? "always";
+
+ if (rule === "never") {
+ return {
+ needsApproval: false,
+ reason: `the ${call.backend} backend is configured to run without approval`,
+ };
+ }
+
+ if (rule === "always") {
+ return {
+ needsApproval: true,
+ reason: `the ${call.backend} backend requires approval for every command`,
+ };
+ }
+
+ const dialect = policy.dialects?.[call.backend] ?? DEFAULT_DIALECTS[call.backend] ?? "shell";
+ const verdict =
+ dialect === "javascript" ? classifyJavaScript(call.command) : classifyShellLine(call.command);
+
+ return { needsApproval: !verdict.readOnly, reason: verdict.reason };
+}
+
+interface Verdict {
+ readOnly: boolean;
+ reason: string;
+}
+
+/**
+ * Classify a shell line. Composition and redirection disqualify it
+ * outright; otherwise the leading verb has to be a recognized read.
+ */
+function classifyShellLine(command: string): Verdict {
+ for (const [character, effect] of SHELL_METACHARACTERS) {
+ if (command.includes(character)) {
+ const shown = character === "\n" || character === "\r" ? "a newline" : `"${character}"`;
+ return { readOnly: false, reason: `${shown} ${effect}` };
+ }
+ }
+
+ const tokens = command
+ .trim()
+ .split(/\s+/)
+ .filter((token) => token.length > 0);
+
+ // `LC_ALL=C sort file` runs sort, so step over any leading
+ // environment assignments to find the verb.
+ let index = 0;
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index])) {
+ index += 1;
+ }
+ const words = tokens.slice(index);
+ if (words.length === 0) {
+ return { readOnly: false, reason: "the command is empty" };
+ }
+
+ const path = words[0];
+ const verb = path.slice(path.lastIndexOf("/") + 1);
+ const args = words.slice(1);
+
+ if (verb === "git") return classifyGit(args);
+
+ if (!READ_ONLY_COMMANDS.has(verb)) {
+ return { readOnly: false, reason: `"${verb}" is not a recognized read-only command` };
+ }
+
+ // sed is a filter until it is handed -i, at which point it rewrites
+ // its input in place.
+ if (verb === "sed") {
+ const inPlace = args.find(
+ (arg) =>
+ arg === "--in-place" ||
+ arg.startsWith("--in-place=") ||
+ (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("i")),
+ );
+ if (inPlace != null) {
+ return { readOnly: false, reason: `sed ${inPlace} edits the file in place` };
+ }
+ }
+
+ return { readOnly: true, reason: `"${verb}" only reads` };
+}
+
+function classifyGit(args: string[]): Verdict {
+ // The first bare word is the subcommand. A global flag that takes a
+ // value (`git -C dir status`) shifts it, and the resulting mismatch
+ // gates the command, which is the safe direction to be wrong in.
+ const subcommand = args.find((arg) => !arg.startsWith("-"));
+ if (subcommand == null) {
+ return { readOnly: false, reason: "git without a subcommand" };
+ }
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) {
+ return { readOnly: false, reason: `"git ${subcommand}" is not a recognized read-only command` };
+ }
+ return { readOnly: true, reason: `"git ${subcommand}" only reads` };
+}
+
+/**
+ * Classify a codemode snippet by the `state.*` calls it names.
+ *
+ * This allowlists reads rather than blocklisting writes, which is what
+ * lets it catch `state["writeFile"]`: every mention of `state` has to
+ * resolve to a named member, and a computed access or an alias is
+ * unclassifiable and therefore gated. A blocklist would wave the same
+ * snippet through.
+ */
+function classifyJavaScript(command: string): Verdict {
+ const mentions = [...command.matchAll(/\bstate\b/g)];
+ if (mentions.length === 0) {
+ // The codemode sandbox has no network and no other route into the
+ // store, so a snippet that never names `state` can only compute.
+ return { readOnly: true, reason: "the snippet does not reach the workspace" };
+ }
+
+ const members = new Set();
+ for (const mention of mentions) {
+ const rest = command.slice((mention.index ?? 0) + mention[0].length);
+ const access = /^\s*(?:\.|\?\.)\s*([A-Za-z_$][\w$]*)/.exec(rest);
+ if (access == null) {
+ return {
+ readOnly: false,
+ reason:
+ "the snippet reaches state through a computed access or an alias, so its effect cannot be classified",
+ };
+ }
+ members.add(access[1]);
+ }
+
+ for (const member of members) {
+ if (!READ_ONLY_STATE_MEMBERS.has(member)) {
+ return { readOnly: false, reason: `state.${member} is not a recognized read-only call` };
+ }
+ }
+
+ const named = [...members].map((member) => `state.${member}`).join(", ");
+ return { readOnly: true, reason: `the snippet only reads (${named})` };
+}
diff --git a/examples/codemode/src/index.ts b/examples/codemode/src/index.ts
index 255f609b..6e1944d7 100644
--- a/examples/codemode/src/index.ts
+++ b/examples/codemode/src/index.ts
@@ -21,16 +21,25 @@
// workspace through its stub, so agency is opt-in per request
// and the workspace stays a plain workspace.
//
+// The agent asks a human before it runs anything the approval policy
+// holds back. A gated command stops the turn, and the turn resumes in
+// a later request once someone answers, so a paused turn has to
+// outlive the request that started it. That state lives in a second
+// durable object, AgentSession, rather than in the workspace: the
+// filesystem object stays a filesystem object.
+//
// Wire shape:
//
-// client ─► Worker ─┬─ /file, /exec deterministic, no model
-// └─ /agent model loop + exec tool
+// client ─► Worker ─┬─ /file, /exec deterministic, no model
+// ├─ /agent model loop + exec tool
+// └─ /approvals the human's side of the loop
// │
-// ▼ (stub RPC)
-// CodemodeExample DO (fs + 3 backends)
-// ├─ shell ─► Dynamic Worker (just-bash)
-// ├─ codemode ─► Dynamic Worker (JS sandbox)
-// └─ container ─► Cloudflare Container (wsd)
+// ┌─────────────┴──────────────┐
+// ▼ ▼ (stub RPC)
+// AgentSession DO CodemodeExample DO (fs + 3 backends)
+// paused turns, ├─ shell ─► Dynamic Worker (just-bash)
+// pending approvals ├─ codemode ─► Dynamic Worker (JS sandbox)
+// (no fs, no backends) └─ container ─► Cloudflare Container (wsd)
import { DurableObject } from "cloudflare:workers";
@@ -49,8 +58,10 @@ import {
} from "@cloudflare/workspace/backends/container";
import { WorkerBackend } from "@cloudflare/workspace/backends/worker";
-import { runAgentTurn } from "./agent.js";
+import { type AgentTranscript, runAgentTurn } from "./agent.js";
+import { AgentSession, type AgentSessionLike } from "./session.js";
import type { ExecWorkspaceLike } from "./tools/exec.js";
+import type { PausedTurn } from "./turn-store.js";
// Re-export so the runtime can build the loopback bindings the
// durable object reaches through ctx.exports:
@@ -59,7 +70,8 @@ import type { ExecWorkspaceLike } from "./tools/exec.js";
// - WorkspaceServiceProxy is the Fetcher the worker backend hands
// into its Dynamic Worker so the in-isolate shell can reach
// back to getWorkspace().
-export { WorkspaceProxy, WorkspaceServiceProxy };
+// The durable object that remembers agent turns paused for approval.
+export { AgentSession, WorkspaceProxy, WorkspaceServiceProxy };
// ---------------------------------------------------------------
// Durable Object: owns one Workspace with three backends.
@@ -165,6 +177,11 @@ interface AgentRequest {
prompt?: string;
}
+interface ApprovalRequest {
+ approved?: boolean;
+ reason?: string;
+}
+
const MOUNT_ROOT = "/workspace";
function resolveMountPath(rest: string): string | null {
@@ -195,6 +212,15 @@ export default {
const agentMatch = url.pathname.match(/^\/c\/([^/]+)\/agent\/?$/);
if (agentMatch) return handleAgent(request, env, agentMatch[1]);
+ const turnMatch = url.pathname.match(/^\/c\/([^/]+)\/agent\/([^/]+)\/?$/);
+ if (turnMatch) return handleTurn(request, env, turnMatch[1], turnMatch[2]);
+
+ const approvalsMatch = url.pathname.match(/^\/c\/([^/]+)\/approvals\/?$/);
+ if (approvalsMatch) return handleApprovals(request, env, approvalsMatch[1]);
+
+ const approvalMatch = url.pathname.match(/^\/c\/([^/]+)\/approvals\/([^/]+)\/?$/);
+ if (approvalMatch) return handleApproval(request, env, approvalMatch[1], approvalMatch[2]);
+
if (url.pathname === "/" || url.pathname === "") {
return new Response(
[
@@ -207,6 +233,11 @@ export default {
" backend: shell | codemode | container",
" POST /c//agent run an agent turn (JSON transcript)",
" body: { prompt }",
+ " GET /c//agent/ one turn's record",
+ " GET /c//approvals commands waiting on a human",
+ " POST /c//approvals/ answer one of them; resumes the",
+ " turn once its last one is answered",
+ " body: { approved, reason? }",
"",
].join("\n"),
{ headers: { "content-type": "text/plain" } },
@@ -300,28 +331,197 @@ async function handleAgent(request: Request, env: Env, name: string): Promise {
+ if (request.method !== "GET") {
+ return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
+ }
+ const turn = await sessionFor(env, name).getTurn(turnId);
+ if (turn == null) return errorJSON(new Error(`no turn ${turnId}`), 404);
+
+ // The message history is the model's working state, not something a
+ // client needs; everything else is the audit trail.
+ const { messages: _messages, awaiting: _awaiting, ...record } = turn;
+ return jsonResponse(record, 200);
+}
+
+// GET the approval queue for this workspace.
+async function handleApprovals(request: Request, env: Env, name: string): Promise {
+ if (request.method !== "GET") {
+ return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
+ }
+ const pending = await sessionFor(env, name).pendingApprovals();
+ return jsonResponse({ pending }, 200);
+}
+
+// POST one human decision. Resolving the last outstanding approval on
+// a turn resumes it in this same request, so the response is the
+// transcript of the resumed pass — which may itself pause again.
+async function handleApproval(
+ request: Request,
+ env: Env,
+ name: string,
+ approvalId: string,
+): Promise {
+ if (request.method !== "POST") {
+ return new Response("method not allowed", { status: 405, headers: { allow: "POST" } });
+ }
+ let body: ApprovalRequest;
+ try {
+ body = (await request.json()) as ApprovalRequest;
+ } catch {
+ return errorJSON(new Error("invalid JSON body"), 400);
+ }
+ if (typeof body.approved !== "boolean") {
+ return errorJSON(new Error("must provide approved as a boolean"), 400);
+ }
+
+ const session = sessionFor(env, name);
+ const outcome = await session.resolveApproval(approvalId, body.approved, body.reason);
+ // Either the id was never issued, or somebody already answered it.
+ // Both mean this request changed nothing, and treating a repeat as a
+ // fresh approval would run the command twice.
+ if (outcome == null) {
+ return errorJSON(new Error(`no approval ${approvalId} is waiting for an answer`), 404);
+ }
+
+ const { turn, ready, answers } = outcome;
+
+ // One model step can ask about several commands, and the resume has
+ // to carry every answer at once, so the turn waits for the rest.
+ if (!ready) {
+ return jsonResponse(
+ {
+ status: "awaiting-approval",
+ turnId: turn.turnId,
+ text: "",
+ finishReason: "",
+ steps: 0,
+ stepsUsed: turn.stepsUsed,
+ toolCalls: turn.toolCalls,
+ pendingApprovals: turn.pending,
+ },
+ 202,
+ );
+ }
+
+ try {
+ const transcript = await runAgentTurn({
+ env,
+ workspace: await workspaceFor(env, name),
+ resume: {
+ messages: turn.messages,
+ approvals: answers.map((answer) => ({
+ approvalId: answer.approvalId,
+ approved: answer.approved,
+ reason: answer.reason,
+ })),
+ },
+ stepsUsed: turn.stepsUsed,
+ });
+
+ // The turn's own history carries forward: commands from earlier
+ // passes belong to the same turn as the ones this pass ran.
+ const resumed = await recordTurn(env, name, turn.turnId, transcript, turn);
+ return transcriptJSON(resumed, transcript, 200);
+ } catch (error) {
+ return errorJSON(error, 500);
+ }
+}
+
+// See AgentSessionLike for why the stub is reached through a named
+// interface rather than its own inferred type.
+function sessionFor(env: Env, name: string): AgentSessionLike {
+ const stub = env.AgentSession.get(env.AgentSession.idFromName(name));
+ return stub as unknown as AgentSessionLike;
+}
+
+// The workspace stub drives the exec tool. Its exec/result methods
+// behave exactly like a local Workspace at runtime, but the capnweb
+// stub wraps them in promise-pipelined types that don't structurally
+// match the plain ExecWorkspaceLike the tool declares. Cast at this
+// one boundary.
+async function workspaceFor(env: Env, name: string): Promise {
+ const stub = env.CodemodeExample.get(env.CodemodeExample.idFromName(name));
+ return (await stub.getWorkspace()) as unknown as ExecWorkspaceLike;
+}
+
+// Persist where a turn got to. A completed turn is kept too, so its
+// record stays readable after the fact.
+async function recordTurn(
+ env: Env,
+ name: string,
+ turnId: string,
+ transcript: AgentTranscript,
+ previous: Pick,
+): Promise {
+ const now = Date.now();
+ const turn: PausedTurn = {
+ turnId,
+ status: transcript.status,
+ messages: transcript.messages,
+ pending: transcript.pendingApprovals,
+ awaiting: transcript.pendingApprovals.map((approval) => approval.approvalId),
+ resolved: previous.resolved,
+ toolCalls: [...previous.toolCalls, ...transcript.toolCalls],
+ stepsUsed: transcript.stepsUsed,
+ createdAt: previous.createdAt,
+ updatedAt: now,
+ };
+ await sessionFor(env, name).saveTurn(turn);
+ return turn;
+}
+
+// The client-facing shape of a transcript. `messages` is deliberately
+// left out: it is the model's working state, and the resume path reads
+// it from storage rather than from the client.
+function transcriptJSON(turn: PausedTurn, transcript: AgentTranscript, status: number): Response {
+ return jsonResponse(
+ {
+ status: turn.status,
+ turnId: turn.turnId,
+ text: transcript.text,
+ finishReason: transcript.finishReason,
+ steps: transcript.steps,
+ stepsUsed: turn.stepsUsed,
+ toolCalls: turn.toolCalls,
+ pendingApprovals: turn.pending,
+ },
+ status,
+ );
+}
+
+function jsonResponse(body: unknown, status: number): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
function errorJSON(error: unknown, status: number): Response {
const message = error instanceof Error ? error.message : String(error);
const code = (error as { code?: string }).code;
diff --git a/examples/codemode/src/session.ts b/examples/codemode/src/session.ts
new file mode 100644
index 00000000..4f5fb348
--- /dev/null
+++ b/examples/codemode/src/session.ts
@@ -0,0 +1,92 @@
+/**
+ * `AgentSession` — the durable home for agent turns that are waiting on
+ * a human.
+ *
+ * This is a second durable object, deliberately separate from the one
+ * that owns the workspace. The workspace durable object is a
+ * filesystem with backends attached; it does not know an agent exists,
+ * and adding a queue of half-finished model turns to it would change
+ * that. So approval state gets its own object, addressed by the same
+ * name, and the workspace stays a workspace.
+ *
+ * It is worth noticing what this object cannot do: it holds no
+ * workspace stub and registers no backends, so it cannot run a
+ * command. The component that records approval decisions and the
+ * component that executes work are separate, which is a property worth
+ * having in the part of the system whose whole job is saying no.
+ *
+ * The model loop itself still runs in the Worker, as it did before.
+ * This object only remembers where a turn got to.
+ */
+
+import { DurableObject } from "cloudflare:workers";
+
+import {
+ type PausedTurn,
+ type PendingApprovalView,
+ type ResolvedApproval,
+ type TurnStorageLike,
+ TurnStore,
+} from "./turn-store.js";
+
+/**
+ * The session's RPC surface, as callers see it.
+ *
+ * Callers reach the durable object through this interface rather than
+ * through the stub's own inferred type. A `PausedTurn` carries
+ * `ModelMessage[]`, and asking tsc to wrap that union in the stub's
+ * recursive promise-pipelined types walks it past the
+ * instantiation-depth limit (TS2589). Naming the surface up front
+ * sidesteps that walk; the runtime shape is identical.
+ */
+export interface AgentSessionLike {
+ saveTurn(turn: PausedTurn): Promise;
+ getTurn(turnId: string): Promise;
+ pendingApprovals(): Promise;
+ resolveApproval(
+ approvalId: string,
+ approved: boolean,
+ reason?: string,
+ ): Promise<{ turn: PausedTurn; ready: boolean; answers: ResolvedApproval[] } | null>;
+}
+
+export class AgentSession extends DurableObject {
+ readonly #turns: TurnStore;
+
+ constructor(ctx: DurableObjectState, env: Env) {
+ super(ctx, env);
+ // The durable object's storage declares wider overloads than the
+ // four methods the store needs; the runtime shape matches.
+ this.#turns = new TurnStore(ctx.storage as unknown as TurnStorageLike);
+ }
+
+ /**
+ * Record where a turn got to. Pruning runs on the way out so an
+ * approval nobody ever answers cannot accumulate forever.
+ */
+ async saveTurn(turn: PausedTurn): Promise {
+ await this.#turns.save(turn);
+ await this.#turns.prune();
+ }
+
+ async getTurn(turnId: string): Promise {
+ return this.#turns.load(turnId);
+ }
+
+ /** Everything waiting on a human, for whoever works the queue. */
+ async pendingApprovals(): Promise {
+ return this.#turns.pending();
+ }
+
+ /**
+ * Record one decision. Null means the approval was not outstanding —
+ * an unknown id, or one that someone already answered.
+ */
+ async resolveApproval(
+ approvalId: string,
+ approved: boolean,
+ reason?: string,
+ ): Promise<{ turn: PausedTurn; ready: boolean; answers: ResolvedApproval[] } | null> {
+ return this.#turns.resolve(approvalId, approved, reason);
+ }
+}
diff --git a/examples/codemode/src/tools/exec.ts b/examples/codemode/src/tools/exec.ts
index 4de528ba..08d1d5a2 100644
--- a/examples/codemode/src/tools/exec.ts
+++ b/examples/codemode/src/tools/exec.ts
@@ -22,6 +22,8 @@
import { tool } from "ai";
import { z } from "zod";
+import { type ApprovalPolicy, decideApproval } from "../approval-policy.js";
+
/**
* Minimal subset of `@cloudflare/workspace` we depend on: a shell
* facade with `exec(command, { cwd, encoding, backend })` whose
@@ -68,6 +70,36 @@ export interface ExecToolOptions {
defaultBackend: string;
/** Truncate captured stdout/stderr above this many bytes. */
maxBytes?: number;
+ /**
+ * Which commands a human has to approve first. Omit to run
+ * everything the model asks for.
+ *
+ * A gated call does not execute: the AI SDK reports it as an
+ * approval request and ends the turn, so there is no provisional
+ * result and nothing to undo.
+ */
+ policy?: ApprovalPolicy;
+ /**
+ * Called after each execution. The caller uses it to build a
+ * transcript.
+ *
+ * Reading executions from `generateText`'s steps would miss the
+ * interesting one: a command approved by a human runs before the
+ * resumed pass makes its first model call, so it belongs to no step.
+ * Recording here catches every execution regardless of which pass it
+ * happened on.
+ */
+ onExec?: (call: ExecRecord) => void;
+}
+
+/** One command the tool actually ran. */
+export interface ExecRecord {
+ command: string;
+ cwd: string | null;
+ backend: string;
+ exitCode: number;
+ stdout: string;
+ stderr: string;
}
const DEFAULT_MAX_BYTES = 64 * 1024; // 64 KiB per stream
@@ -134,6 +166,13 @@ export function createExecTool(opts: ExecToolOptions) {
return tool({
description,
inputSchema,
+ // Must stay a pure function of the input. The AI SDK re-runs it
+ // when a paused turn resumes and downgrades an approved call to a
+ // denial if the answer has changed since the pause.
+ needsApproval: ({ command, backend }) =>
+ opts.policy != null &&
+ decideApproval({ command, backend: backend ?? opts.defaultBackend }, opts.policy)
+ .needsApproval,
execute: async ({ command, cwd, backend }) => {
const handle = await opts.workspace.shell.exec(command, {
cwd,
@@ -141,7 +180,7 @@ export function createExecTool(opts: ExecToolOptions) {
backend,
});
const result = await handle.result();
- return {
+ const record: ExecRecord = {
command,
cwd: cwd ?? null,
backend: backend ?? opts.defaultBackend,
@@ -149,6 +188,8 @@ export function createExecTool(opts: ExecToolOptions) {
stdout: truncate(result.stdout, maxBytes),
stderr: truncate(result.stderr, maxBytes),
};
+ opts.onExec?.(record);
+ return record;
},
});
}
diff --git a/examples/codemode/src/turn-store.test.ts b/examples/codemode/src/turn-store.test.ts
new file mode 100644
index 00000000..956ca539
--- /dev/null
+++ b/examples/codemode/src/turn-store.test.ts
@@ -0,0 +1,273 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import type { PendingApproval } from "./agent.js";
+import { type PausedTurn, type TurnStorageLike, TurnStore } from "./turn-store.js";
+
+/** The durable object's storage, reduced to a Map. */
+class MemoryStorage implements TurnStorageLike {
+ readonly entries = new Map();
+
+ async get(key: string): Promise {
+ return structuredClone(this.entries.get(key)) as T | undefined;
+ }
+
+ async put(key: string, value: T): Promise {
+ this.entries.set(key, structuredClone(value));
+ }
+
+ async delete(key: string): Promise {
+ return this.entries.delete(key);
+ }
+
+ async list(options?: { prefix?: string }): Promise
+
+
layer 3 Approval (human in the loop)
+
+ Commands the policy holds back — every write, and everything on
+ container — do not run. The turn stops with a pending approval and
+ resumes on POST /approvals/<id>. That wait outlives the request, so the
+ paused turn lives in a second Durable Object, AgentSession, which holds no
+ workspace stub and cannot itself run anything.
+
+
The three backends
@@ -320,6 +331,19 @@
Agentic — POST /agent
On stop, Worker returns the transcript with toolCalls[].backend.
+
+
Gated — POST /approvals/<id>
+
+
The policy holds a command back, so the tool never executes it.
+
The turn returns awaiting-approval; Worker stores its history in
+ AgentSession.
+
A human reads GET /approvals and answers one entry.
+
On the last answer, Worker replays the stored history with a
+ tool-approval-response attached.
+
The approved command runs for real; a denied one comes back to the model as
+ execution-denied.
+
+
Key components
@@ -327,8 +351,11 @@
Key components
File
Role
src/index.ts
Worker routes + CodemodeExample DO that owns the Workspace and registers all three backends.
-
src/agent.ts
The opt-in model loop; builds the exec tool over the stub and runs one turn.
-
src/tools/exec.ts
The exec tool: a backend enum plus per-backend descriptions the model reads.
+
src/agent.ts
The opt-in model loop; builds the exec tool over the stub and runs one turn, pausing when the policy holds a command back.
+
src/tools/exec.ts
The exec tool: a backend enum plus per-backend descriptions the model reads, and the approval gate.
+
src/approval-policy.ts
Which commands need a human: a per-backend rule table, denying by default.
+
src/session.ts
The AgentSession DO — where a turn waiting on a human lives.
+
src/turn-store.ts
Paused-turn bookkeeping over a storage handle, so it tests against a plain Map.
backends/codemode/codemode-backend.ts
The backend: builds the ShellRPC, runs each snippet in a fresh executor.
backends/codemode/state-provider.ts
The state.* namespace over the live WorkspaceFilesystem.
backends/codemode/exec-events.ts
Pure mapping from a run's result/logs/error to the stdout/stderr/exit event stream.
diff --git a/examples/codemode/script/run b/examples/codemode/script/run
index 8fda8432..656a1062 100755
--- a/examples/codemode/script/run
+++ b/examples/codemode/script/run
@@ -11,14 +11,16 @@
# surface, closing the loop.
#
# Steps 1 through 4 need no model and no container, so they run by
-# default. Two further backends are opt-in because they cost more to
-# stand up:
+# default. The rest are opt-in because they cost more to stand up:
#
# CONTAINERS=1 also read the tree from inside the Cloudflare
# Container (boots wsd on first use; requires
# `wrangler dev --enable-containers`).
# AGENT=1 also run one agent turn, which needs the AI
# binding and a model round-trip.
+# APPROVALS=1 also drive the human-in-the-loop approval flow: ask
+# the agent for a write, check that nothing ran while
+# it waits, approve, and check that it then did.
#
# Run `npm run dev` in another terminal first, then:
# ./script/run # against http://127.0.0.1:8787
@@ -91,4 +93,59 @@ if [[ "${AGENT:-}" == "1" ]]; then
printf '%s\n' "$agent_out"
fi
+if [[ "${APPROVALS:-}" == "1" ]]; then
+ # A fresh instance, so "the file is not there yet" actually means the
+ # approval held the write back rather than that an earlier run had
+ # not written it.
+ HITL_NAME="${NAME}-hitl-$$"
+ HITL_BASE="${BASE_URL}/c/${HITL_NAME}"
+ HITL_PATH="approved-write.txt"
+
+ step "7. approval: ask for a write on /c/${HITL_NAME}/agent"
+ hitl_out=$(curl -fsS -X POST "${HITL_BASE}/agent" \
+ -H 'content-type: application/json' \
+ -d '{"prompt":"Create the file /workspace/'"${HITL_PATH}"' containing exactly: approved"}')
+ printf '%s\n' "$hitl_out"
+
+ hitl_status=$(JSON="$hitl_out" node -e 'process.stdout.write(JSON.parse(process.env.JSON).status)')
+ [[ "$hitl_status" == "awaiting-approval" ]] \
+ || fail "expected the write to wait for approval, got status '$hitl_status'"
+
+ approval_id=$(JSON="$hitl_out" node -e \
+ 'const d=JSON.parse(process.env.JSON); process.stdout.write(d.pendingApprovals[0].approvalId)')
+
+ step "8. approval: nothing ran while it waits"
+ code=$(curl -sS -o /dev/null -w '%{http_code}' "${HITL_BASE}/file/workspace/${HITL_PATH}")
+ [[ "$code" == "404" ]] \
+ || fail "the command ran before it was approved (GET returned HTTP $code, expected 404)"
+ printf 'GET /file/workspace/%s -> HTTP 404, as it should be\n' "$HITL_PATH"
+
+ step "9. approval: the queue lists it"
+ queue=$(curl -fsS "${HITL_BASE}/approvals")
+ printf '%s\n' "$queue"
+ echo "$queue" | grep -q "$approval_id" \
+ || fail "the pending approval is missing from GET /approvals"
+
+ step "10. approval: approve it, and the turn resumes"
+ resumed=$(curl -fsS -X POST "${HITL_BASE}/approvals/${approval_id}" \
+ -H 'content-type: application/json' -d '{"approved":true}')
+ printf '%s\n' "$resumed"
+ resumed_status=$(JSON="$resumed" node -e 'process.stdout.write(JSON.parse(process.env.JSON).status)')
+ [[ "$resumed_status" == "completed" ]] \
+ || fail "expected the resumed turn to complete, got status '$resumed_status'"
+
+ step "11. approval: now the file exists"
+ written=$(curl -fsS "${HITL_BASE}/file/workspace/${HITL_PATH}")
+ printf '%s\n' "$written"
+ [[ -n "$written" ]] || fail "the approved command did not write the file"
+
+ step "12. approval: answering twice is refused"
+ code=$(curl -sS -o /dev/null -w '%{http_code}' \
+ -X POST "${HITL_BASE}/approvals/${approval_id}" \
+ -H 'content-type: application/json' -d '{"approved":true}')
+ [[ "$code" == "404" ]] \
+ || fail "a second answer to the same approval was accepted (HTTP $code, expected 404)"
+ printf 'second answer -> HTTP 404, so the command cannot run twice\n'
+fi
+
printf '\nOK — one filesystem, reached through every backend.\n'
From ac9998d26b9c3223500a32210ffd04d64388f5f0 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 10:47:55 +0100
Subject: [PATCH 03/13] examples/codemode: gate read verbs whose flags write
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.
---
examples/codemode/src/approval-policy.test.ts | 40 +++++-
examples/codemode/src/approval-policy.ts | 131 +++++++++++++++---
2 files changed, 146 insertions(+), 25 deletions(-)
diff --git a/examples/codemode/src/approval-policy.test.ts b/examples/codemode/src/approval-policy.test.ts
index bd83d8e9..d60305fa 100644
--- a/examples/codemode/src/approval-policy.test.ts
+++ b/examples/codemode/src/approval-policy.test.ts
@@ -99,14 +99,42 @@ describe("decideApproval", () => {
expect(gates("ls /workspace\nrm -rf /workspace", "shell")).toBe(true);
});
- it("gates sed -i, which edits in place", () => {
- expect(gates("sed -i s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
- expect(gates("sed -i.bak s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
- expect(gates("sed --in-place s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ it("gates a read verb handed a flag that writes", () => {
+ // A verb allowlist is not enough on its own: several read
+ // commands write when given the right flag, so an unrecognized
+ // flag on a read verb has to gate too.
+ expect(gates("find /workspace -mindepth 1 -delete", "shell")).toBe(true);
+ expect(gates("find /workspace -name x -exec rm {} +", "shell")).toBe(true);
+ expect(gates("find /workspace -execdir rm {} +", "shell")).toBe(true);
+ expect(gates("find /workspace -fprint /workspace/out", "shell")).toBe(true);
+ expect(gates("sort -o /workspace/out /workspace/in", "shell")).toBe(true);
+ expect(gates("sort --output=/workspace/out /workspace/in", "shell")).toBe(true);
+ });
+
+ it("still waves through the read flags those verbs are used with", () => {
+ expect(gates("find /workspace -name '*.ts'", "shell")).toBe(false);
+ expect(gates("find /workspace -type f -maxdepth 2", "shell")).toBe(false);
+ expect(gates("find /workspace -mtime -1", "shell")).toBe(false);
+ expect(gates("sort -n /workspace/hello.txt", "shell")).toBe(false);
+ expect(gates("sort -u -r /workspace/hello.txt", "shell")).toBe(false);
});
- it("waves through sed as a filter", () => {
- expect(gates("sed s/a/b/ /workspace/hello.txt", "shell")).toBe(false);
+ it("gates an unrecognized flag on a checked verb", () => {
+ expect(gates("find /workspace -frobnicate", "shell")).toBe(true);
+ });
+
+ it("gates verbs whose writing cannot be told from their arguments", () => {
+ // sed writes through -i and through a `w` command inside the
+ // script, which a matcher cannot reliably find. uniq and tree
+ // take an output file as a positional argument, and date -s sets
+ // the clock. None of them are worth the false confidence, so
+ // none of them are recognized reads.
+ expect(gates("sed s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("sed -i s/a/b/ /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("sed 'w /workspace/out' /workspace/hello.txt", "shell")).toBe(true);
+ expect(gates("uniq /workspace/in /workspace/out", "shell")).toBe(true);
+ expect(gates("tree -o /workspace/out", "shell")).toBe(true);
+ expect(gates("date -s 12:00", "shell")).toBe(true);
});
it("strips leading environment assignments before reading the verb", () => {
diff --git a/examples/codemode/src/approval-policy.ts b/examples/codemode/src/approval-policy.ts
index 6182a53e..b80de559 100644
--- a/examples/codemode/src/approval-policy.ts
+++ b/examples/codemode/src/approval-policy.ts
@@ -115,17 +115,23 @@ const SHELL_METACHARACTERS: Array<[string, string]> = [
];
/**
- * Commands that only read. Deliberately conservative: `echo` and
- * `awk` are absent because both can write through their own syntax
- * rather than through a shell redirect, and the extra approvals cost
- * less than the argument about whether the matcher caught every form.
+ * Commands that only read.
+ *
+ * Deliberately conservative, and the omissions are the interesting
+ * part. `echo` and `awk` can write through their own syntax rather
+ * than through a shell redirect. `sed` writes through `-i` and also
+ * through a `w` command buried in its script, which no matcher is
+ * going to find reliably. `uniq` and `tree` take an output file as a
+ * positional argument, so their writes do not look like flags at all.
+ * `date -s` sets the system clock. A verb whose writes cannot be read
+ * off its arguments does not belong here, because listing it would
+ * buy false confidence rather than fewer approvals.
*/
const READ_ONLY_COMMANDS = new Set([
"basename",
"cat",
"cmp",
"cut",
- "date",
"df",
"diff",
"dirname",
@@ -141,20 +147,104 @@ const READ_ONLY_COMMANDS = new Set([
"pwd",
"readlink",
"realpath",
- "sed",
"sort",
"stat",
"tail",
"test",
- "tree",
"true",
"uname",
- "uniq",
"wc",
"which",
"whoami",
]);
+/**
+ * Flags a read verb is allowed to carry, for the verbs that write when
+ * given the wrong one.
+ *
+ * A verb allowlist alone is not enough: `find` deletes with `-delete`
+ * and runs arbitrary commands with `-exec`, and `sort` writes to a
+ * file with `-o`. So for these verbs the flags are allowlisted too,
+ * and an unrecognized flag gates. That is the same shape as the
+ * `state.*` check — name what is allowed, treat everything else as a
+ * question — rather than a blocklist that has to keep up with every
+ * flag that happens to write.
+ *
+ * A verb absent from this table takes flags freely, which is only safe
+ * because the verbs in READ_ONLY_COMMANDS that are absent here have no
+ * flag that writes at all.
+ */
+const READ_ONLY_FLAGS: Record> = {
+ find: new Set([
+ "-a",
+ "-and",
+ "-atime",
+ "-depth",
+ "-empty",
+ "-follow",
+ "-group",
+ "-ilname",
+ "-iname",
+ "-inum",
+ "-ipath",
+ "-iregex",
+ "-links",
+ "-lname",
+ "-ls",
+ "-maxdepth",
+ "-mindepth",
+ "-mmin",
+ "-mtime",
+ "-name",
+ "-newer",
+ "-not",
+ "-o",
+ "-or",
+ "-path",
+ "-perm",
+ "-print",
+ "-print0",
+ "-printf",
+ "-prune",
+ "-readable",
+ "-regex",
+ "-samefile",
+ "-size",
+ "-type",
+ "-user",
+ "-xdev",
+ "-H",
+ "-L",
+ "-P",
+ ]),
+ sort: new Set([
+ "-b",
+ "-c",
+ "-d",
+ "-f",
+ "-g",
+ "-h",
+ "-i",
+ "-k",
+ "-M",
+ "-n",
+ "-r",
+ "-s",
+ "-t",
+ "-u",
+ "-V",
+ "-z",
+ "--check",
+ "--ignore-case",
+ "--key",
+ "--numeric-sort",
+ "--reverse",
+ "--sort",
+ "--unique",
+ "--version-sort",
+ ]),
+};
+
/** Git subcommands that only inspect history. */
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
"blame",
@@ -254,17 +344,20 @@ function classifyShellLine(command: string): Verdict {
return { readOnly: false, reason: `"${verb}" is not a recognized read-only command` };
}
- // sed is a filter until it is handed -i, at which point it rewrites
- // its input in place.
- if (verb === "sed") {
- const inPlace = args.find(
- (arg) =>
- arg === "--in-place" ||
- arg.startsWith("--in-place=") ||
- (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("i")),
- );
- if (inPlace != null) {
- return { readOnly: false, reason: `sed ${inPlace} edits the file in place` };
+ const allowedFlags = READ_ONLY_FLAGS[verb];
+ if (allowedFlags != null) {
+ for (const arg of args) {
+ if (!arg.startsWith("-")) continue;
+ // A negative number is a value, not a flag: `find -mtime -1`.
+ if (/^-\d/.test(arg)) continue;
+ // Compare on the name so `--output=path` is caught as --output.
+ const flag = arg.split("=")[0];
+ if (!allowedFlags.has(flag)) {
+ return {
+ readOnly: false,
+ reason: `"${flag}" is not a recognized read-only flag for "${verb}"`,
+ };
+ }
}
}
From f04d7cc05bb6443653565792b6f9ef6801b54fc2 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 10:48:07 +0100
Subject: [PATCH 04/13] examples/codemode: add an interactive approval driver
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.
---
examples/codemode/README.md | 69 ++++++++++++--
examples/codemode/script/agent | 167 +++++++++++++++++++++++++++++++++
2 files changed, 226 insertions(+), 10 deletions(-)
create mode 100755 examples/codemode/script/agent
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index ee8ee6f2..8638b66f 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -161,14 +161,26 @@ the only direction worth failing in.
The matcher speaks two dialects, because the backends do. For `shell`
and `container` it rejects redirection and composition (`>`, `|`, `;`,
-`&&`, `$(...)`) outright and then requires the leading verb to be a
-known read (`cat`, `ls`, `grep`, `find`, `git log`, …). For `codemode`
-it reads the JavaScript instead: every mention of `state` has to
-resolve to a named member, and every member has to be a read
-(`readFile`, `stat`, `readdir`, …). That is why
-`state["writeFile"](...)` is gated — it allowlists reads rather than
-blocklisting writes, so an unclassifiable access is a question, not a
-free pass.
+`&&`, `$(...)`) outright, then requires the leading verb to be a known
+read (`cat`, `ls`, `grep`, `find`, `git log`, …), and then — for verbs
+that write when handed the wrong flag — requires the flags to be known
+reads too. `find /workspace -name '*.ts'` runs;
+`find /workspace -delete` and `sort -o out in` ask. For `codemode` it
+reads the JavaScript instead: every mention of `state` has to resolve
+to a named member, and every member has to be a read (`readFile`,
+`stat`, `readdir`, …). That is why `state["writeFile"](...)` is gated.
+
+Both dialects allowlist rather than blocklist, which is the only
+version that holds up. A blocklist of writing flags has to keep pace
+with every flag that happens to write; an allowlist turns the ones
+nobody thought of into questions.
+
+Some commands are left out of the read set entirely because their
+writes cannot be read off their arguments: `sed` writes through `-i`
+and through a `w` command buried in its script, `uniq` and `tree` take
+an output file as a positional argument, `awk` and `echo` write through
+their own syntax, and `date -s` sets the clock. Listing them would buy
+false confidence rather than fewer approvals.
The rules are configuration, not a hardcoded list. `runAgentTurn`
takes a `policy`, and `never` turns the gate off for a backend
@@ -351,10 +363,40 @@ curl -X POST $B/agent -H 'content-type: application/json' -d '{
The `/agent` response includes `toolCalls[].backend`, showing which
backend the model chose for each command.
+### Watching it ask
+
+The quickest way to see an approval happen is the interactive driver.
+It starts a turn and prompts on the terminal for every command the
+policy holds back, which is what a real approval UI would do with the
+same two routes:
+
+```sh
+./script/agent "Create /workspace/greeting.txt containing exactly: hello world"
+```
+
+```
+APPROVAL NEEDED (codemode)
+ await state.writeFile("/workspace/greeting.txt", "hello world");
+ why: state.writeFile is not a recognized read-only call
+ approve? [y/N] y
+ approved
+ ran [codemode] await state.writeFile("/workspace/greeting.txt", "hello world");
+
+status completed
+steps 2
+agent Created /workspace/greeting.txt containing exactly `hello world`.
+```
+
+Answer `n` and the command never runs; the model is told it was denied
+and reports back. It defaults to a fresh workspace each run, so the
+first write always has to be approved. `AUTO_APPROVE=1` says yes to
+everything, and piping answers (`printf 'y\nn\n' | ./script/agent …`)
+scripts them.
+
### The approval flow by hand
-Use a fresh workspace name so the "nothing ran yet" step proves
-something:
+The same thing with two curls, which is what the driver is doing. Use a
+fresh workspace name so the "nothing ran yet" step proves something:
```sh
B=http://127.0.0.1:8787/c/hitl-demo
@@ -472,6 +514,7 @@ examples/codemode/
wrangler.jsonc Worker + 2 DOs + worker_loaders + containers + AI
Dockerfile wsd + Debian userland for the container backend
script/run smoke test: file surface, 3 backends, approvals
+ script/agent one agent turn, prompting y/n for each approval
src/index.ts Worker handler + DO (CodemodeExample, 3 backends)
src/agent.ts the optional Workers AI model loop
src/tools/exec.ts the exec tool advertised to the model
@@ -517,3 +560,9 @@ examples/codemode/
strings, so it is conservative by construction and will ask about
commands that are in fact harmless. Real enforcement belongs at the
capability layer, as the [policy section](#the-policy) spells out.
+- **Telling the model not to reroute is advice, not a guarantee.** The
+ system prompt asks it not to retry a denied command on another
+ backend, and models do not always listen. That costs nothing: every
+ attempt is classified afresh, so a reroute either produces a command
+ the policy allows or asks again. The gate does not depend on the
+ model cooperating.
diff --git a/examples/codemode/script/agent b/examples/codemode/script/agent
new file mode 100755
index 00000000..d465033c
--- /dev/null
+++ b/examples/codemode/script/agent
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+// Drive one agent turn against a running `wrangler dev`, stopping to ask
+// on the terminal for every command the approval policy holds back.
+//
+// The HTTP surface answers approvals with a second request, which is
+// the right shape for a UI but an awkward way to see the feature work.
+// This script closes that loop: it starts a turn, and each time the
+// turn comes back waiting on a human it prints the command, asks y/n,
+// posts the answer, and picks the turn up where it left off. What you
+// see is what a real approval UI would drive.
+//
+// ./script/agent "Create /workspace/notes.txt saying hello"
+// NAME=foo ./script/agent "..." # workspace instance
+// BASE_URL=http://127.0.0.1:8799 ./script/agent "..."
+// ./script/agent # uses a default prompt
+// AUTO_APPROVE=1 ./script/agent "..." # say yes to everything
+// printf 'y\nn\n' | ./script/agent "..." # scripted answers
+
+const BASE_URL = (process.env.BASE_URL ?? "http://127.0.0.1:8787").replace(/\/$/, "");
+// A fresh instance by default, so a demo starts from an empty tree and
+// "the file is not there yet" means what it looks like.
+const NAME = process.env.NAME ?? `agent-${Date.now().toString(36)}`;
+const PROMPT =
+ process.argv.slice(2).join(" ") ||
+ "Create /workspace/greeting.txt containing exactly: hello world";
+
+const base = `${BASE_URL}/c/${NAME}`;
+
+const bold = (s) => `\u001b[1m${s}\u001b[0m`;
+const dim = (s) => `\u001b[2m${s}\u001b[0m`;
+const green = (s) => `\u001b[32m${s}\u001b[0m`;
+const red = (s) => `\u001b[31m${s}\u001b[0m`;
+const yellow = (s) => `\u001b[33m${s}\u001b[0m`;
+
+async function post(url, body) {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const text = await response.text();
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 200)}`);
+ }
+ if (parsed.error != null) {
+ throw new Error(`${url} returned HTTP ${response.status}: ${parsed.error}`);
+ }
+ return parsed;
+}
+
+function reportRan(toolCalls, alreadyReported) {
+ for (const call of toolCalls.slice(alreadyReported)) {
+ const status = call.exitCode === 0 ? green("ran") : red(`exit ${call.exitCode}`);
+ console.log(` ${status} ${dim(`[${call.backend}]`)} ${call.command.replace(/\n/g, " ")}`);
+ const out = (call.stdout || call.stderr).trim();
+ if (out.length > 0) console.log(dim(` ${out.slice(0, 200).replace(/\n/g, "\n ")}`));
+ }
+ return toolCalls.length;
+}
+
+/**
+ * Read answers a line at a time from stdin.
+ *
+ * Not `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 with "readline was closed".
+ * Buffering from the start works for a terminal and a pipe alike.
+ * Returns null once stdin has no more to give.
+ */
+function lineReader() {
+ const ready = [];
+ const waiting = [];
+ let buffer = "";
+ let ended = false;
+
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (chunk) => {
+ buffer += chunk;
+ let index = buffer.indexOf("\n");
+ while (index >= 0) {
+ const line = buffer.slice(0, index);
+ buffer = buffer.slice(index + 1);
+ const waiter = waiting.shift();
+ if (waiter) waiter(line);
+ else ready.push(line);
+ index = buffer.indexOf("\n");
+ }
+ });
+ process.stdin.on("end", () => {
+ ended = true;
+ if (buffer.length > 0) {
+ const line = buffer;
+ buffer = "";
+ const waiter = waiting.shift();
+ if (waiter) waiter(line);
+ else ready.push(line);
+ }
+ while (waiting.length > 0) waiting.shift()(null);
+ });
+
+ return {
+ next() {
+ if (ready.length > 0) return Promise.resolve(ready.shift());
+ if (ended) return Promise.resolve(null);
+ return new Promise((resolve) => waiting.push(resolve));
+ },
+ };
+}
+
+const reader = lineReader();
+const autoApprove = process.env.AUTO_APPROVE === "1";
+
+/** Ask the human. No answer left on stdin means no. */
+async function askApproval() {
+ if (autoApprove) {
+ console.log(` approve? ${dim("[y/N]")} y ${dim("(AUTO_APPROVE)")}`);
+ return true;
+ }
+ process.stdout.write(` approve? ${dim("[y/N]")} `);
+ const answer = await reader.next();
+ if (answer === null) {
+ console.log(dim("(no answer on stdin, treating as no)"));
+ return false;
+ }
+ if (!process.stdin.isTTY) console.log(answer.trim());
+ return /^y(es)?$/i.test(answer.trim());
+}
+
+try {
+ console.log(`${bold("workspace")} ${NAME} ${dim(base)}`);
+ console.log(`${bold("prompt")} ${PROMPT}\n`);
+
+ let turn = await post(`${base}/agent`, { prompt: PROMPT });
+ let reported = reportRan(turn.toolCalls, 0);
+
+ while (turn.status === "awaiting-approval") {
+ for (const approval of turn.pendingApprovals) {
+ console.log(`\n${yellow("APPROVAL NEEDED")} ${dim(`(${approval.backend})`)}`);
+ console.log(` ${bold(approval.command.replace(/\n/g, "\n "))}`);
+ console.log(` ${dim(`why: ${approval.reason}`)}`);
+
+ const approved = await askApproval();
+
+ // Nothing has run at this point. Approving is what executes it.
+ const outcome = await post(`${base}/approvals/${approval.approvalId}`, {
+ approved,
+ ...(approved ? {} : { reason: "denied at the prompt" }),
+ });
+ console.log(approved ? green(" approved") : red(" denied"));
+ turn = outcome;
+ reported = reportRan(turn.toolCalls, reported);
+ }
+ }
+
+ console.log(`\n${bold("status")} ${turn.status}`);
+ console.log(`${bold("steps")} ${turn.stepsUsed}`);
+ if (turn.text) console.log(`${bold("agent")} ${turn.text}`);
+ console.log(dim(`\nturn record: curl -s ${base}/agent/${turn.turnId}`));
+} catch (error) {
+ console.error(red(`\nFAIL: ${error.message}`));
+ process.exitCode = 1;
+} finally {
+ process.stdin.pause();
+}
From 39bec35d2c8902823a6d063ede4063de821de5d8 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 11:02:03 +0100
Subject: [PATCH 05/13] examples/codemode: judge a pipeline one stage at a time
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.
---
examples/codemode/README.md | 32 +++++---
examples/codemode/src/approval-policy.test.ts | 49 ++++++++++--
examples/codemode/src/approval-policy.ts | 78 +++++++++++++++----
3 files changed, 131 insertions(+), 28 deletions(-)
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index 8638b66f..8fd32dc8 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -160,11 +160,21 @@ reached through a computed access. The gate fails **closed**, which is
the only direction worth failing in.
The matcher speaks two dialects, because the backends do. For `shell`
-and `container` it rejects redirection and composition (`>`, `|`, `;`,
-`&&`, `$(...)`) outright, then requires the leading verb to be a known
-read (`cat`, `ls`, `grep`, `find`, `git log`, …), and then — for verbs
-that write when handed the wrong flag — requires the flags to be known
-reads too. `find /workspace -name '*.ts'` runs;
+and `container` it rejects redirection, substitution and backgrounding
+(`>`, `<`, `$(...)`, `` ` ``, `&`) outright, since those either write or
+run something it would have to parse to see. What is left is a
+pipeline, so it splits on `|`, `&&`, `||` and `;` and judges each stage
+on its own: the verb has to be a known read (`cat`, `ls`, `grep`,
+`find`, `git log`, …), and for verbs that write when handed the wrong
+flag, the flags have to be known reads too.
+
+Judging stages separately rather than gating all composition is what
+makes the rule usable — `ls -1 /workspace | wc -l` is two reads and a
+pipe, which touches no files, so it runs unattended. It gives nothing
+up: `ls; rm -rf /workspace` still stops on its second stage, and
+`find /workspace -type f | xargs rm` still stops on `xargs`, even
+though that `find` would have passed alone. Single commands behave the
+same as before: `find /workspace -name '*.ts'` runs, while
`find /workspace -delete` and `sort -o out in` ask. For `codemode` it
reads the JavaScript instead: every mention of `state` has to resolve
to a named member, and every member has to be a read (`readFile`,
@@ -178,9 +188,12 @@ nobody thought of into questions.
Some commands are left out of the read set entirely because their
writes cannot be read off their arguments: `sed` writes through `-i`
and through a `w` command buried in its script, `uniq` and `tree` take
-an output file as a positional argument, `awk` and `echo` write through
-their own syntax, and `date -s` sets the clock. Listing them would buy
-false confidence rather than fewer approvals.
+an output file as a positional argument, `awk` writes through its own
+syntax, and `date -s` sets the clock. Listing them would buy false
+confidence rather than fewer approvals. `echo` and `printf` are in the
+read set, on the other hand, because they only ever write to stdout —
+aiming that at a file needs a redirect, which gates the line whatever
+the verb.
The rules are configuration, not a hardcoded list. `runAgentTurn`
takes a `policy`, and `never` turns the gate off for a backend
@@ -476,7 +489,8 @@ npm test --workspace @example/workspace-codemode
```
Three suites. `approval-policy.test.ts` pins the policy: which commands
-are recognized reads, that redirection and composition are gated, that
+are recognized reads, that redirection is gated and that a pipeline is
+judged one stage at a time, that
`state["writeFile"]` does not slip past the allowlist, and that the
decision is a pure function of its inputs. `turn-store.test.ts` drives
the paused-turn bookkeeping against an in-memory map — a turn waits for
diff --git a/examples/codemode/src/approval-policy.test.ts b/examples/codemode/src/approval-policy.test.ts
index d60305fa..c2392c99 100644
--- a/examples/codemode/src/approval-policy.test.ts
+++ b/examples/codemode/src/approval-policy.test.ts
@@ -79,14 +79,53 @@ describe("decideApproval", () => {
expect(gates("cat < /workspace/a", "shell")).toBe(true);
});
- it("gates composition, even of two reads", () => {
- // A pipeline of reads is harmless, but allowing composition
- // means auditing every element; the gate stays closed instead.
- expect(gates("cat /workspace/a | grep needle", "shell")).toBe(true);
+ it("waves through a pipeline whose every stage is a read", () => {
+ // A pipe moves bytes between processes and touches no files, so
+ // a pipeline of reads is a read. Each stage is classified on its
+ // own rather than the composition being waved through.
+ expect(gates("ls -1 /workspace | wc -l", "shell")).toBe(false);
+ expect(gates("cat /workspace/a | grep needle", "shell")).toBe(false);
+ expect(gates("cat /workspace/a | grep needle | wc -l", "shell")).toBe(false);
+ expect(gates("ls /workspace || true", "shell")).toBe(false);
+ expect(gates("ls /workspace && cat /workspace/a", "shell")).toBe(false);
+ expect(gates("cd /workspace; ls", "shell")).toBe(true);
+ });
+
+ it("gates a pipeline with a stage that is not a read", () => {
expect(gates("ls /workspace; rm -rf /workspace", "shell")).toBe(true);
expect(gates("ls /workspace && rm -rf /workspace", "shell")).toBe(true);
- expect(gates("ls /workspace || true", "shell")).toBe(true);
+ expect(gates("cat /workspace/a | tee /workspace/b", "shell")).toBe(true);
+ expect(gates("find /workspace -type f | xargs rm", "shell")).toBe(true);
+ expect(gates("ls /workspace | sed -i s/a/b/", "shell")).toBe(true);
+ });
+
+ it("names the offending stage when it gates a pipeline", () => {
+ expect(
+ decideApproval({ command: "ls /workspace | tee /workspace/b", backend: "shell" }).reason,
+ ).toContain("tee");
+ });
+
+ it("gates an empty or dangling stage", () => {
+ expect(gates("ls /workspace |", "shell")).toBe(true);
+ expect(gates("| wc -l", "shell")).toBe(true);
+ expect(gates("ls &&", "shell")).toBe(true);
+ });
+
+ it("still gates backgrounding, which leaves something running", () => {
expect(gates("ls /workspace &", "shell")).toBe(true);
+ expect(gates("cat /workspace/a & cat /workspace/b", "shell")).toBe(true);
+ });
+
+ it("still gates redirection inside a pipeline", () => {
+ expect(gates("ls /workspace | wc -l > /workspace/count", "shell")).toBe(true);
+ });
+
+ it("waves through echo and printf, which cannot write without a redirect", () => {
+ // Both write to stdout only. Sending that to a file needs `>`,
+ // which gates the whole line regardless of the verb.
+ expect(gates("echo hello", "shell")).toBe(false);
+ expect(gates("ls /workspace && echo done", "shell")).toBe(false);
+ expect(gates("echo hello > /workspace/x", "shell")).toBe(true);
});
it("gates command substitution", () => {
diff --git a/examples/codemode/src/approval-policy.ts b/examples/codemode/src/approval-policy.ts
index b80de559..42a6ff3b 100644
--- a/examples/codemode/src/approval-policy.ts
+++ b/examples/codemode/src/approval-policy.ts
@@ -92,20 +92,20 @@ const DEFAULT_DIALECTS: Record = {
};
/**
- * Shell characters that redirect output or glue a second command onto
- * the first. Their presence disqualifies a line on its own, before
- * the verb is even considered: `cat a > b` writes, and
- * `ls; rm -rf /` is not the `ls` it appears to be. A pipeline of two
- * reads is harmless in principle, but allowing composition means
- * classifying every element and every operator, so the gate stays
- * closed on all of it.
+ * Shell characters that disqualify a line outright, whatever it runs.
+ *
+ * Redirection writes files, and substitution and subshells run
+ * commands this matcher would have to parse to see. None of them are
+ * decomposable the way a pipeline is, so their presence ends the
+ * question before the verb is considered.
+ *
+ * Backgrounding is here for a different reason: `ls &` leaves a
+ * process alive past the command, which is not something to wave
+ * through on the strength of the verb.
*/
const SHELL_METACHARACTERS: Array<[string, string]> = [
[">", "redirects output"],
["<", "redirects input"],
- ["|", "pipes into another command"],
- [";", "chains another command"],
- ["&", "chains or backgrounds another command"],
["$", "expands a variable or substitutes a command"],
["`", "substitutes a command"],
["(", "groups or substitutes a command"],
@@ -114,12 +114,22 @@ const SHELL_METACHARACTERS: Array<[string, string]> = [
["\r", "hides a second command on another line"],
];
+/**
+ * Operators that join commands without touching the filesystem
+ * themselves. A line built from these is split on them and every stage
+ * classified on its own, so `ls | wc -l` reads and
+ * `ls; rm -rf /` does not.
+ *
+ * Longest first, so `&&` and `||` are found before a bare `&` or `|`.
+ */
+const COMMAND_SEPARATORS = ["&&", "||", ";", "|"];
+
/**
* Commands that only read.
*
* Deliberately conservative, and the omissions are the interesting
- * part. `echo` and `awk` can write through their own syntax rather
- * than through a shell redirect. `sed` writes through `-i` and also
+ * part. `awk` can write through its own syntax rather than through a
+ * shell redirect. `sed` writes through `-i` and also
* through a `w` command buried in its script, which no matcher is
* going to find reliably. `uniq` and `tree` take an output file as a
* positional argument, so their writes do not look like flags at all.
@@ -136,6 +146,10 @@ const READ_ONLY_COMMANDS = new Set([
"diff",
"dirname",
"du",
+ // echo and printf write to stdout and nowhere else. Sending that at
+ // a file takes a redirect, which gates the line whatever the verb.
+ "echo",
+ "printf",
"egrep",
"fgrep",
"file",
@@ -307,8 +321,14 @@ interface Verdict {
}
/**
- * Classify a shell line. Composition and redirection disqualify it
- * outright; otherwise the leading verb has to be a recognized read.
+ * Classify a shell line.
+ *
+ * Redirection and substitution disqualify the line outright. What is
+ * left is a pipeline, which is split on its separators and judged one
+ * stage at a time: the line reads only if every stage does. Piping and
+ * sequencing touch no files themselves, so `ls | wc -l` is as much a
+ * read as `ls` is, while the stage-by-stage check is what still catches
+ * the `rm -rf` in `ls; rm -rf /`.
*/
function classifyShellLine(command: string): Verdict {
for (const [character, effect] of SHELL_METACHARACTERS) {
@@ -318,6 +338,36 @@ function classifyShellLine(command: string): Verdict {
}
}
+ // A lone `&` backgrounds; a doubled one is the and-then separator
+ // handled below.
+ if (/(? token.replace(/[|]/g, "\\$&")).join("|")})\\s*`,
+ );
+ const stages = command.trim().split(separator);
+
+ const verdicts: Verdict[] = [];
+ for (const stage of stages) {
+ if (stage.trim().length === 0) {
+ return { readOnly: false, reason: "a stage of the pipeline is empty" };
+ }
+ const verdict = classifyShellCommand(stage);
+ if (!verdict.readOnly) return verdict;
+ verdicts.push(verdict);
+ }
+
+ if (verdicts.length === 1) return verdicts[0];
+ return {
+ readOnly: true,
+ reason: `every stage only reads (${verdicts.map((verdict) => verdict.reason).join("; ")})`,
+ };
+}
+
+/** Classify a single command, with no separators left in it. */
+function classifyShellCommand(command: string): Verdict {
const tokens = command
.trim()
.split(/\s+/)
From d5adf64c47386ba95c16d0820f088b9ac9f5b9ce Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 12:27:54 +0100
Subject: [PATCH 06/13] examples/codemode: diagram the calls a turn makes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
examples/codemode/architecture.html | 204 ++++++++++++++++++++++++++++
1 file changed, 204 insertions(+)
diff --git a/examples/codemode/architecture.html b/examples/codemode/architecture.html
index 9e57552c..2f578e52 100644
--- a/examples/codemode/architecture.html
+++ b/examples/codemode/architecture.html
@@ -309,6 +309,210 @@
How codemode reaches the filesystem
+
Call sequence
+
+ Five participants, and only one of them can touch a file. Read these two diagrams as a pair:
+ the difference between them is the entire feature.
+
+
+
An unattended turn — the policy recognizes a read
+
+
+
+ One HTTP request in, one out. The green self-call is the whole decision; because it
+ answered "read", nobody was asked anything.
+
+
+
+
A gated turn — the policy holds the command back
+
+
+
+ Two HTTP requests, one pause. The orange self-call is where the write was stopped —
+ before the Example DO was ever asked to run it, which is why the pause band is
+ safe to sit in indefinitely.
+
+
+
+
+
+ The one subtlety worth internalising: decideApproval() runs
+ twice — once to gate, once on replay. The AI SDK re-checks the policy when
+ a turn resumes and converts an approved call into a denial if the answer changed. That is
+ why the policy is a pure function of the command and the backend, with no clock and no
+ mutable state: anything else would make approvals decay on their own.
+
+
+
Request flows
From a5b3360e2270ce4309929a2256ad4ae2980247b6 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 14:02:12 +0100
Subject: [PATCH 07/13] examples/codemode: diagram the approval route's
branches
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/ 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.
---
examples/codemode/architecture.html | 141 ++++++++++++++++++++++++++++
1 file changed, 141 insertions(+)
diff --git a/examples/codemode/architecture.html b/examples/codemode/architecture.html
index 2f578e52..4b535bf9 100644
--- a/examples/codemode/architecture.html
+++ b/examples/codemode/architecture.html
@@ -513,6 +513,147 @@
A gated turn — the policy holds the command back
+
The approval route
+
+ Where every branch goes, and what the client gets back. The two entry points funnel into one
+ model loop, and the loop can come back around: answering an approval resumes the turn, and the
+ resumed pass may stop on a different command.
+
+
+ the command runs
+ the command is held back
+ control flow
+
+
+
+
+ The gate is the orange box, and it sits inside the Worker — upstream of the Durable Object
+ that owns the files. Only the green path ever reaches it.
+
+
+
+
+
Why 404 and 202 are different answers
+
+ 404 means the id is not waiting for an answer — either it was never issued, or
+ somebody already answered it. Both are the same fact, and both have to reject: treating a
+ repeat as a fresh approval would run the command a second time.
+ 202 means the answer was recorded but the turn is still waiting on others,
+ because one model step can propose several commands and the resume has to carry every
+ answer at once. Only the last answer resumes the turn, which is why the reply to
+ POST /approvals/<id> is sometimes a transcript and sometimes a receipt.
+
+
+
Request flows
From 8c966e2446de54298401fda4b689cd6b808ac8b5 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 14:16:58 +0100
Subject: [PATCH 08/13] examples/codemode: link the architecture page from the
README
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.
---
examples/codemode/README.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index 8fd32dc8..84498f1c 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -26,6 +26,17 @@ back — anything that writes, and everything on `container` — pause the
turn until a human answers, which is what the
[approval flow](#human-in-the-loop-approval) below is about.
+> [!TIP]
+> [`architecture.html`](architecture.html) draws all of this: the
+> system diagram, the call sequence for an unattended turn beside a
+> gated one, and every branch of the approval route. It is a
+> standalone page with no build step, and it is the fastest way in if
+> you would rather see the shape than read it.
+>
+> ```sh
+> open examples/codemode/architecture.html
+> ```
+
## What makes codemode different
The `shell` and `container` backends take a **shell command line**.
From 295630050bb88b07d5f70908642cf83c8918cac8 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 14:28:50 +0100
Subject: [PATCH 09/13] examples/codemode: say why /exec runs without approval
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
examples/codemode/src/index.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/examples/codemode/src/index.ts b/examples/codemode/src/index.ts
index 6e1944d7..e40611ce 100644
--- a/examples/codemode/src/index.ts
+++ b/examples/codemode/src/index.ts
@@ -284,6 +284,24 @@ async function handleFile(
return new Response("method not allowed", { status: 405, headers: { allow: "GET, PUT" } });
}
+// Run one command with no approval check.
+//
+// The gate lives in the exec *tool*, so it sits on the model's path
+// and not on this one. The reasoning is that approval exists to put a
+// human in front of a command a model chose, and a caller posting here
+// is already that human: asking them to confirm what they just typed
+// buys nothing.
+//
+// The consequence is worth stating plainly rather than leaving to be
+// discovered. Two callers reach ws.shell.exec() and only one of them
+// is checked, so this route will run `rm -rf /workspace` on request.
+// That is a property of where enforcement currently sits, not a
+// decision that this route should be privileged, and it is the part of
+// the design most likely to change: moving enforcement below
+// ws.shell.exec() — handing the backend a read-only view of the
+// workspace and letting the filesystem refuse the write — would cover
+// both callers and leave the matcher as an optimisation rather than
+// the thing standing between a model and the files.
async function handleExec(request: Request, env: Env, name: string): Promise {
if (request.method !== "POST") {
return new Response("method not allowed", { status: 405, headers: { allow: "POST" } });
From 448d7ba55f1055eaced37a90b6269ae81900c07b Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 14:43:24 +0100
Subject: [PATCH 10/13] examples/codemode: test the policy against real command
behaviour
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
examples/codemode/README.md | 28 +-
examples/codemode/package.json | 1 +
.../src/approval-policy.effects.test.ts | 250 ++++++++++++++++++
examples/codemode/src/approval-policy.ts | 9 +-
package-lock.json | 1 +
5 files changed, 286 insertions(+), 3 deletions(-)
create mode 100644 examples/codemode/src/approval-policy.effects.test.ts
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index 84498f1c..58cf8287 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -499,7 +499,7 @@ a model nor a container:
npm test --workspace @example/workspace-codemode
```
-Three suites. `approval-policy.test.ts` pins the policy: which commands
+Four suites. `approval-policy.test.ts` pins the policy: which commands
are recognized reads, that redirection is gated and that a pipeline is
judged one stage at a time, that
`state["writeFile"]` does not slip past the allowlist, and that the
@@ -512,6 +512,32 @@ workspace that a gated command **never reaches `shell.exec`**, that the
message history survives a JSON round trip, and that approving runs the
held-back command while rejecting does not.
+`approval-policy.effects.test.ts` is the one that earns its keep
+differently. The other three assert what the code was meant to do,
+which cannot find the mistake that matters here: the matcher and its
+tests are written by the same hand, so they miss the same cases. Both
+real defects in this policy were found by running the agent and
+noticing, not by a test.
+
+So that suite checks the claim against the world. It generates a corpus
+— every allowlisted verb crossed with argument shapes including the
+flags that turn a read into a write — keeps the commands the policy
+would run **unattended**, and executes each one under real `just-bash`
+against a recording filesystem. The property is one-directional:
+
+> a command the policy allows unattended must write nothing
+
+Currently 630 commands generated, 475 allowed, and the whole suite
+runs in well under a second. Reintroduce the `find -delete` hole and it
+fails naming every file that would have been deleted. The verbs come
+from `READ_ONLY_COMMANDS` itself rather than a copy, so widening the
+policy later puts the new verb under test without anybody remembering
+to.
+
+It does not cover the `container` backend, which runs GNU coreutils
+rather than just-bash and can behave differently — one more reason that
+backend stays gated outright.
+
The backend beneath it has two further test tiers, both under
`packages/workspace`:
diff --git a/examples/codemode/package.json b/examples/codemode/package.json
index d4196cd4..bcbf3447 100644
--- a/examples/codemode/package.json
+++ b/examples/codemode/package.json
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
+ "just-bash": "^3.0.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7",
"wrangler": "^4.96.0"
diff --git a/examples/codemode/src/approval-policy.effects.test.ts b/examples/codemode/src/approval-policy.effects.test.ts
new file mode 100644
index 00000000..aec4e797
--- /dev/null
+++ b/examples/codemode/src/approval-policy.effects.test.ts
@@ -0,0 +1,250 @@
+/**
+ * Does a command the policy waves through actually only read?
+ *
+ * `approval-policy.test.ts` pins what the matcher *says*: given this
+ * command, does it ask for a human. Those assertions are worth having,
+ * but they cannot find the failure that matters here, because the same
+ * person writes the matcher and its tests and so shares a blind spot
+ * with it. Both real defects in this policy were found by running the
+ * agent by hand and noticing — `find /workspace -mindepth 1 -delete`
+ * ran unattended because the verb was allowlisted and its flags were
+ * not, and a pipeline of two reads asked for approval it did not need.
+ * Neither was going to fall out of a list of examples somebody thought
+ * to write down.
+ *
+ * So this file checks the claim against the world instead. It runs the
+ * command for real and watches the filesystem:
+ *
+ * the policy allows a command unattended ⇒ running it writes nothing
+ *
+ * One direction only. A command the policy *gates* needs no check here,
+ * because being asked about a read is a nuisance and not a breach; the
+ * gated direction is already covered by the assertions next door.
+ *
+ * The corpus is generated rather than curated — every allowlisted verb
+ * crossed with argument shapes that include the flags known to turn a
+ * read into a write. Most combinations are nonsense (`pwd -delete`),
+ * and that is fine: a nonsense command the matcher allows must still
+ * not write. Deriving the verbs from READ_ONLY_COMMANDS rather than
+ * from a copy means a verb added to the policy later comes under test
+ * without anybody remembering to add it here.
+ *
+ * ## What this covers, and what it does not
+ *
+ * The shell is real: `just-bash`, the same implementation the `shell`
+ * backend runs inside its Dynamic Worker, driven against just-bash's
+ * own in-memory filesystem wrapped in a recorder. Whether `find
+ * -delete` reaches for a delete is a fact about just-bash and holds
+ * wherever its files happen to live, so the storage underneath does
+ * not need to be the real Durable Object for the answer to be right.
+ *
+ * Two gaps, neither of them quiet:
+ *
+ * The `container` backend runs GNU coreutils rather than just-bash, so
+ * the same command can behave differently there. The default policy
+ * gates that backend outright, which is why the difference does not
+ * bite — and is a reason to keep gating it.
+ *
+ * The `codemode` dialect is not exercised. Its claim is about a closed
+ * list of `state.*` method names, which is a much smaller surface to
+ * get wrong than the verb-and-flag combinatorics here, and reaching a
+ * live `state` namespace would mean pulling the workspace package and
+ * its workerd-only imports into this runner.
+ */
+
+import { Bash, InMemoryFs } from "just-bash";
+import { describe, expect, it } from "vitest";
+
+import { decideApproval, READ_ONLY_COMMANDS } from "./approval-policy.js";
+
+/**
+ * Every method on just-bash's filesystem interface that changes
+ * something. Named explicitly rather than inferred, so a new mutating
+ * method in a future just-bash shows up as an unrecorded write here
+ * instead of being silently classified as a read.
+ */
+const MUTATORS = new Set([
+ "appendFile",
+ "chmod",
+ "cp",
+ "link",
+ "mkdir",
+ "mv",
+ "rm",
+ "symlink",
+ "utimes",
+ "writeFile",
+]);
+
+interface Run {
+ writes: string[];
+ exitCode: number;
+ stderr: string;
+}
+
+/** Run one command against a fresh tree, recording every mutation. */
+async function run(command: string): Promise {
+ const inner = new InMemoryFs({
+ "/workspace/a.txt": "beta\nalpha\n",
+ "/workspace/b.txt": "gamma\n",
+ "/workspace/sub/c.txt": "nested\n",
+ });
+ const writes: string[] = [];
+ const recorder = new Proxy(inner, {
+ get(target, key, receiver) {
+ const value = Reflect.get(target, key, receiver);
+ if (typeof value !== "function" || typeof key !== "string") return value;
+ if (!MUTATORS.has(key)) return value.bind(target);
+ return (...args: unknown[]) => {
+ const shown = args.filter((arg) => typeof arg === "string").join(", ");
+ writes.push(`${key}(${shown})`);
+ return value.apply(target, args);
+ };
+ },
+ });
+
+ const bash = new Bash({ fs: recorder as never, cwd: "/workspace" });
+ const result = await bash.exec(command);
+ return { writes, exitCode: result.exitCode, stderr: result.stderr };
+}
+
+/**
+ * Argument shapes to cross with every verb. The first few are ordinary
+ * usage, present so the corpus contains commands that actually run;
+ * the rest are the ways a read verb turns into a write — the flag that
+ * deletes, the flag that names an output file, the flag that edits in
+ * place, the operator that hands the output to something else.
+ */
+const ARGUMENT_SHAPES = [
+ "",
+ "/workspace",
+ "/workspace/a.txt",
+ "-1 /workspace",
+ "-l /workspace",
+ "/workspace/a.txt /workspace/b.txt",
+ "-delete /workspace",
+ "/workspace -delete",
+ "/workspace -mindepth 1 -delete",
+ "/workspace -type f -delete",
+ "-i s/alpha/beta/ /workspace/a.txt",
+ "--in-place /workspace/a.txt",
+ "-o /workspace/out /workspace/a.txt",
+ "--output=/workspace/out /workspace/a.txt",
+ "-w /workspace/out /workspace/a.txt",
+ "-s /workspace/a.txt",
+ "/workspace/a.txt > /workspace/out",
+ "/workspace/a.txt >> /workspace/a.txt",
+ "/workspace | tee /workspace/out",
+ "/workspace/a.txt | sed -i s/a/b/ /workspace/b.txt",
+];
+
+/** Whole-line shapes, to cover composition rather than one verb. */
+const COMPOSED = [
+ "ls -1 /workspace | wc -l",
+ "cat /workspace/a.txt | grep alpha",
+ "cat /workspace/a.txt | sort | head -1",
+ "ls /workspace && cat /workspace/a.txt",
+ "ls /workspace || true",
+ "ls /workspace; rm -rf /workspace",
+ "find /workspace -type f | xargs rm",
+ "find /workspace -type f | tee /workspace/out",
+ "cat /workspace/a.txt > /workspace/out",
+ "sort /workspace/a.txt -o /workspace/a.txt",
+ "grep -r alpha /workspace | cut -d: -f1",
+ "test -f /workspace/a.txt && echo yes",
+ "echo hello",
+ "printf '%s\\n' hello",
+ "pwd",
+ "ls -la /workspace/sub",
+];
+
+function corpus(): string[] {
+ const commands = new Set(COMPOSED);
+ for (const verb of READ_ONLY_COMMANDS) {
+ for (const shape of ARGUMENT_SHAPES) {
+ commands.add(shape.length === 0 ? verb : `${verb} ${shape}`);
+ }
+ }
+ // git is handled by its own branch in the matcher, on a subcommand
+ // rather than a flag, so it needs its own shapes.
+ for (const sub of [
+ "log",
+ "status",
+ "diff",
+ "show",
+ "add -A",
+ "commit -m x",
+ "checkout .",
+ "clean -fd",
+ ]) {
+ commands.add(`git ${sub}`);
+ commands.add(`git ${sub} > /workspace/out`);
+ }
+ return [...commands];
+}
+
+describe("the detector itself", () => {
+ // If these fail, every other assertion in this file is worthless:
+ // a recorder that sees nothing makes any command look like a read.
+ it("sees a write", async () => {
+ const { writes } = await run("printf hi > /workspace/new.txt");
+ expect(writes).toContain("writeFile(/workspace/new.txt, hi, utf8)");
+ });
+
+ it("sees a delete, including one reached through find", async () => {
+ const { writes } = await run("find /workspace -mindepth 1 -delete");
+ expect(writes.length).toBeGreaterThan(0);
+ expect(writes.every((write) => write.startsWith("rm("))).toBe(true);
+ });
+
+ it("stays quiet on a read", async () => {
+ expect((await run("cat /workspace/a.txt")).writes).toEqual([]);
+ expect((await run("ls -1 /workspace | wc -l")).writes).toEqual([]);
+ });
+
+ it("would catch a policy that waved a write through", async () => {
+ // The policy is the thing under test, so prove the harness fails
+ // when the policy is wrong. `never` is the rule that trusts a
+ // backend completely; under it, a destructive command is
+ // "allowed", and the property below must not hold.
+ const policy = { rules: { shell: "never" as const } };
+ const command = "rm -rf /workspace/sub";
+ expect(decideApproval({ command, backend: "shell" }, policy).needsApproval).toBe(false);
+ expect((await run(command)).writes.length).toBeGreaterThan(0);
+ });
+});
+
+describe("every command the policy allows unattended", () => {
+ it("writes nothing", async () => {
+ const violations: string[] = [];
+ let allowed = 0;
+ let ran = 0;
+
+ for (const command of corpus()) {
+ if (decideApproval({ command, backend: "shell" }).needsApproval) continue;
+ allowed += 1;
+ const result = await run(command);
+ if (result.exitCode === 0) ran += 1;
+ if (result.writes.length > 0) {
+ violations.push(`${JSON.stringify(command)} → ${result.writes.join(", ")}`);
+ }
+ }
+
+ // Reported together rather than one at a time: the useful output
+ // is the whole set of holes, not whichever one sorts first.
+ expect(violations, `the policy allowed ${violations.length} command(s) that wrote`).toEqual([]);
+
+ // A corpus that gates everything, or a shell that cannot run
+ // anything, would satisfy the assertion above while checking
+ // nothing at all.
+ //
+ // These floors exist to catch a harness that died, not a policy
+ // that tightened, so they sit well under what passes today: 630
+ // commands generated, 475 allowed, 163 of those exiting 0. A
+ // policy that legitimately narrows should not have to come here
+ // and edit numbers; a just-bash that stopped running commands, or
+ // a corpus that stopped generating them, lands near zero.
+ expect(allowed, "commands the policy allowed unattended").toBeGreaterThan(100);
+ expect(ran, "allowed commands that also exited 0").toBeGreaterThan(40);
+ });
+});
diff --git a/examples/codemode/src/approval-policy.ts b/examples/codemode/src/approval-policy.ts
index 42a6ff3b..a3aceb8e 100644
--- a/examples/codemode/src/approval-policy.ts
+++ b/examples/codemode/src/approval-policy.ts
@@ -137,7 +137,12 @@ const COMMAND_SEPARATORS = ["&&", "||", ";", "|"];
* off its arguments does not belong here, because listing it would
* buy false confidence rather than fewer approvals.
*/
-const READ_ONLY_COMMANDS = new Set([
+// Exported so approval-policy.effects.test.ts can build its corpus
+// from the claim itself rather than from a copy of it. A verb added
+// here then comes under test automatically, which is the point: the
+// gap that let `find -delete` through was a case nobody had thought
+// to write down.
+export const READ_ONLY_COMMANDS = new Set([
"basename",
"cat",
"cmp",
@@ -188,7 +193,7 @@ const READ_ONLY_COMMANDS = new Set([
* because the verbs in READ_ONLY_COMMANDS that are absent here have no
* flag that writes at all.
*/
-const READ_ONLY_FLAGS: Record> = {
+export const READ_ONLY_FLAGS: Record> = {
find: new Set([
"-a",
"-and",
diff --git a/package-lock.json b/package-lock.json
index b0105046..524f5f4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
+ "just-bash": "^3.0.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7",
"wrangler": "^4.96.0"
From 225f5eb6da2ed0382ae8329a85bd8e78c747b731 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 15:15:23 +0100
Subject: [PATCH 11/13] examples/codemode: write out the approval chain step by
step
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
examples/codemode/README.md | 7 ++
examples/codemode/architecture.html | 141 ++++++++++++++++++++++++++++
2 files changed, 148 insertions(+)
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index 58cf8287..ef2118ba 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -153,6 +153,13 @@ POST /agent {prompt}
may pause again
```
+For the same flow at the level of what each line of code does — where
+the gate is evaluated, what the SDK leaves behind when it stops, and
+what the resume has to put back — see
+[the approval chain, step by step](architecture.html#approval-chain).
+It splits the two sides of the pause, since a paused turn is two
+requests with an indefinite gap in the middle.
+
### The policy
Approval is a table keyed by backend, because the three backends
diff --git a/examples/codemode/architecture.html b/examples/codemode/architecture.html
index 4b535bf9..6b03469e 100644
--- a/examples/codemode/architecture.html
+++ b/examples/codemode/architecture.html
@@ -691,6 +691,147 @@
Gated — POST /approvals/<id>
+
The approval chain, step by step
+
+ The same gated turn as above, at the level of what each line of code does. Two sides, because
+ a paused turn is two requests with an indefinite gap in the middle: the first discovers the
+ pause and files it, the second answers it and finishes the work.
+
+
+
+
+ side one The pause — POST /agent
+
+
+
+ The Worker parses { prompt }, mints a turnId, and gets a
+ workspace stub. Nothing is stored yet.
+
+
+ createExecTool closes over the stub, the policy and a fresh
+ onExec array — none of which appear in the model's schema.
+
+
+ generateText runs with the budget
+ MAX_STEPS - stepsUsed, so a turn cannot buy itself more steps by pausing.
+
+
The model proposes exec { command, backend }.
+
+ Before execute, the SDK evaluates needsApproval. Pure, local,
+ inside the Worker — no Durable Object, no network.
+
+
+ It returns true, so the call is excluded from that step's executeTools.
+ Nothing runs: no shell.exec, no connect(), no
+ dynamic worker, no container boot.
+
+
+ The SDK appends a tool-approval-request part to the step's content — an
+ approvalId plus the original toolCall. A tool-call
+ with no matching tool-result.
+
+
+ The loop's continue condition fails and generateText returns normally.
+ Nothing threw.
+
+
+ runAgentTurn scans result.steps.at(-1).content for those parts.
+ That scan is how the pause is discovered — after the fact, from data.
+
+
+ For each one it asks decideApproval again, purely for the sentence a human
+ should read; the SDK's gate only answered yes or no. Same inputs, same answer.
+
+
+ status becomes awaiting-approval, and messages
+ becomes what went in plus lastStep.response.messages — carrying the
+ serialized mark.
+
+
+ recordTurn writes the row into AgentSession: history,
+ pending, awaiting, toolCalls,
+ stepsUsed. That object holds no workspace stub and cannot run anything.
+
+
+ transcriptJSON replies 200 awaiting-approval with
+ pendingApprovals, omitting messages — the model's working state
+ is read from storage on resume, never trusted from a client.
+
+
+ The request closes. No timer, no polling, no held connection. The turn exists only as a
+ row, and the Worker is free to die.
+
+
+
+
+
+
+ side two The resume —
+ POST /approvals/<id>
+
+
+
+ A human answers. script/agent prints the y/N; the Worker only
+ ever sees a boolean.
+
+
resolveApproval(id, approved) marks it answered and returns an outcome.
+
+ Null means the id was never issued or was already answered. Both mean this
+ request changed nothing, so 404 — treating a repeat as fresh would run
+ the command twice.
+
+
+ Not ready means other approvals on the turn are outstanding, so 202 and
+ back to idle. One model step can propose several commands, and the resume must carry
+ every answer at once.
+
+
Ready means this was the last one, so the stored messages come back out.
+
+ runAgentTurn is called again with resume, and
+ stepsUsed carried forward.
+
+
+ The history is replayed with one appended tool message holding every
+ tool-approval-response together — the SDK reads approvals from the last
+ message only.
+
+
+ The SDK matches each approvalId to the mark in the history and
+ re-runs needsApproval. If the answer changed since the
+ pause, the approval is downgraded to a denial. This is why the policy must be pure.
+
+
+ Approved calls execute in the SDK's pre-loop executeTools, before the step
+ loop starts.
+
+
+ ws.shell.exec() is finally called on the Durable Object — the first time
+ this command touches anything. From here it is the ordinary path: lazy
+ connect(), the dispatchers, evaluate(), the envelope,
+ execEventStream.
+
+
A denied call comes back to the model as execution-denied instead.
+
+ onExec records the run. The steps will not: an approved call belongs to the
+ pre-loop and to no step, which is why the transcript is collected through the closure
+ rather than read off result.steps.
+
+
The loop continues and may propose more commands.
+
+ recordTurn writes again, accumulating toolCalls across passes so
+ the turn's history stays whole.
+
+
+ Completed gives 200 completed. Paused again on a different command gives
+ 200 awaiting-approval, and back to idle.
+
+
+
+ The whole resume runs inline in the human's POST, so their request is the one that waits on
+ the model.
+
+
+
Key components
File
Role
From 4bfe4774f45db626b2600bd88602c334101b6027 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Wed, 29 Jul 2026 15:22:44 +0100
Subject: [PATCH 12/13] ci: bump the codemode example's wsd image tag with the
rest
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.
---
script/set-versions.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/script/set-versions.mjs b/script/set-versions.mjs
index 7d1a9027..53722ab8 100644
--- a/script/set-versions.mjs
+++ b/script/set-versions.mjs
@@ -27,6 +27,7 @@ const PACKAGES = [
// `git clone && wrangler dev` against any release tag pulls the
// matching wsd image.
const DOCKERFILES = [
+ "examples/codemode/Dockerfile",
"examples/container/Dockerfile",
"examples/think/Dockerfile",
"examples/think-compare-runtimes/Dockerfile.workspace",
From 996df05670ff41b1930aa12a46400b3b545e1d88 Mon Sep 17 00:00:00 2001
From: Antoni T
Date: Fri, 31 Jul 2026 10:56:11 +0100
Subject: [PATCH 13/13] examples/codemode: separate the approval boundary from
the matcher
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/ 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.
---
examples/codemode/README.md | 121 +++++++++++++++++++----
examples/codemode/src/approval-policy.ts | 45 +++++----
examples/codemode/src/index.ts | 23 ++---
3 files changed, 133 insertions(+), 56 deletions(-)
diff --git a/examples/codemode/README.md b/examples/codemode/README.md
index ef2118ba..c65551ec 100644
--- a/examples/codemode/README.md
+++ b/examples/codemode/README.md
@@ -162,8 +162,11 @@ requests with an indefinite gap in the middle.
### The policy
-Approval is a table keyed by backend, because the three backends
-differ in what they can reach:
+Approval works in two tiers, and they are worth telling apart because
+only one of them is a guess.
+
+The first tier is a table keyed by backend. It decides on what a
+backend can reach, not on what a command says:
| Backend | Rule | Why |
|---|---|---|
@@ -171,11 +174,24 @@ differ in what they can reach:
| `codemode` | `read-only` | Same, and it has no network at all. |
| `container` | `always` | Full Linux userland with public network. "Which command is it" is the wrong question. |
-Under `read-only`, a command runs unattended only when it is
-*recognizably* a read. Anything the matcher does not understand needs
-a human — an unknown verb, a shell metacharacter, a `state.*` call
-reached through a computed access. The gate fails **closed**, which is
-the only direction worth failing in.
+That table is the boundary, and it needs no parsing to enforce.
+`container` gets a full userland and a public network, so everything
+on it asks, whatever the command turns out to be.
+
+The second tier applies only under `read-only`, and it is an
+optimization: it exists to ask fewer questions, not to be the thing
+standing between the model and the files. A command runs unattended
+only when it is *recognizably* a read. Anything the matcher does not
+understand needs a human — an unknown verb, a shell metacharacter, a
+`state.*` call reached through a computed access.
+
+Because the gate fails closed, the matcher is allowed to be wrong in
+one direction only. Asking about a command that turns out to be
+harmless costs a person ten seconds. Waving through a command that
+writes is the failure that matters, and
+`approval-policy.effects.test.ts` is what rules it out — it runs every
+command the policy would allow and fails if any of them touched a
+file.
The matcher speaks two dialects, because the backends do. For `shell`
and `container` it rejects redirection, substitution and backgrounding
@@ -229,13 +245,28 @@ const transcript = await runAgentTurn({
});
```
-Be clear-eyed about what this is: pattern-matching a command line is a
-heuristic, appropriate for an example. A production gate belongs at
-the capability layer — hand the backend a read-only view of the
-workspace and let the filesystem refuse the write — rather than in a
-matcher that has to anticipate every way a shell can be told to write
-a file. Denying by default is what makes the heuristic's failure mode
-tolerable in the meantime.
+The second tier is the part with no equivalent elsewhere. Pausing a
+turn on a human is well-trodden ground, as the
+[section below](#why-not-an-off-the-shelf-approvals-system) covers.
+Deciding which shell commands are safe enough to skip the pause is
+not: Think's built-in Bash tool runs on the same `just-bash` library
+as the `shell` backend here, and its only controls are on, off, and
+resource limits. This example took the problem on because nothing off
+the shelf solves it.
+
+That is a reason to be careful about how much weight the matcher
+carries. Reading a command line to guess its effect is a heuristic,
+and a heuristic is the wrong place for a boundary. The right place is
+the capability layer: hand the backend a read-only view of the
+workspace and let the filesystem refuse the write. Think already works
+that way, protecting files it did not mount during write-back so a
+script cannot delete what it was never given. Doing the same here
+would cover every caller instead of only the model's path, and would
+leave the matcher as what it should be — a way to ask fewer questions.
+
+Until then, lean on the backend table, treat the matcher as a
+convenience, and read `approval-policy.effects.test.ts` as the thing
+that keeps the convenience honest.
### Where a paused turn lives
@@ -271,7 +302,44 @@ Two consequences worth knowing:
every pass, not per pass, so waiting for a human does not buy the
model a fresh allowance.
-### Why not codemode's built-in approvals
+### Why not an off-the-shelf approvals system
+
+Several parts of the Cloudflare stack already do approvals, and this
+example uses one of them: the pause is the AI SDK's `needsApproval`,
+one field on the `exec` tool that was there anyway. That is the same
+field Think gates its own tools with, so the mechanism here is the
+common one rather than a local invention. The rest were considered and
+are a worse fit, for reasons worth writing down.
+
+Think itself is the closest. Its Actions API can park a turn on a
+human and resume it later with no connection held open, which is this
+design under another name, down to `pendingApprovals`,
+`approveExecution` and `rejectExecution` matching the three routes
+below and a repeat answer being a no-op. But Think is a whole chat
+agent framework — memory, streaming, messengers, scheduled turns — and
+adopting it to get the approval plumbing would replace what this
+example is for.
+
+The Agents SDK is the smaller version of that argument. Its `Agent`
+class supplies SQL storage and routing, but message persistence
+belongs to `AIChatAgent`, so `turn-store.ts` would move from key
+prefixes to SQL rather than disappear, and the check that stops an
+approval being answered twice has no equivalent to inherit. That is
+about 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 can hold an approval for months and retry each step. This
+pause is minutes long and holds nothing open, so a stored row is the
+smaller mechanism, and `waitForApproval()` is an `Agent` method that
+brings the same dependency with it. What that costs is a real timeout
+and escalation; the prune below is the crude substitute.
+
+Fibers solve the neighboring problem, not this one. `runFiber()` makes
+work survive eviction by checkpointing what is in flight. Nothing is
+in flight here on purpose — the command was never sent, no connection
+is open, no container booted — so there is no progress to save.
`@cloudflare/codemode` ships its own approvals system —
`requiresApproval` on connector tools, `createCodemodeRuntime`, and
@@ -285,10 +353,9 @@ tool. Adopting them would mean replacing the `exec` tool with a
connector-based path, wrapping all three backends as a connector, and
taking on replay's determinism rules — which would delete the thing
this example exists to show, that the model picks one of three
-backends per command. The AI SDK's `needsApproval` gives the same
-pause in one field on the tool that is already there. If you are
-building on codemode connectors rather than workspace backends, the
-runtime's approvals are the better fit; here they are not.
+backends per command. If you are building on codemode connectors
+rather than workspace backends, the runtime's approvals are the better
+fit; here they are not.
## HTTP surface
@@ -299,6 +366,8 @@ GET /c//file/workspace/ octet-stream of /workspace/
POST /c//exec { command, cwd?, backend? }
backend: shell | codemode | container
(omit to use the default, shell)
+ runs unapproved: you are the caller,
+ so there is nobody to ask
→ JSON { exitCode, stdout, stderr }
POST /c//agent { prompt }
→ JSON transcript (see below)
@@ -616,8 +685,18 @@ examples/codemode/
background and poll the turn record.
- **The approval matcher is a heuristic.** It classifies command
strings, so it is conservative by construction and will ask about
- commands that are in fact harmless. Real enforcement belongs at the
- capability layer, as the [policy section](#the-policy) spells out.
+ commands that are in fact harmless. What it guarantees is only the
+ one direction the effects test checks: a command it allows does not
+ write. It is the second tier of the policy, not the boundary, and
+ real enforcement belongs at the capability layer, as the
+ [policy section](#the-policy) spells out.
+- **Approval requests in the stored history are unsigned.** The AI
+ SDK can sign them with `experimental_toolApprovalSecret`, but that
+ setting expects approval requests to carry a signature and throws
+ on the plain message history this example replays, so it stays off.
+ Nothing here depends on it: the history never leaves the server, and
+ `GET /agent/` strips `messages` before it answers. Move that
+ history through a client and the signature starts mattering.
- **Telling the model not to reroute is advice, not a guarantee.** The
system prompt asks it not to retry a denied command on another
backend, and models do not always listen. That costs nothing: every
diff --git a/examples/codemode/src/approval-policy.ts b/examples/codemode/src/approval-policy.ts
index a3aceb8e..4590ded6 100644
--- a/examples/codemode/src/approval-policy.ts
+++ b/examples/codemode/src/approval-policy.ts
@@ -1,27 +1,34 @@
/**
* Which commands a human has to approve before the agent runs them.
*
- * The policy is a table keyed by backend id, because the three
- * backends differ in what they can reach: `container` is a full Linux
- * userland with a public network, while `shell` and `codemode` are
- * sandboxed and see only the workspace filesystem. Expressing the
- * rules per backend keeps the interesting decision visible and
- * configurable instead of buried in a list of blocked commands.
+ * There are two tiers here and they are not equally trustworthy.
*
- * Every rule denies by default. Under `read-only`, a command runs
- * unattended only when it is *recognizably* a read; anything the
- * matcher does not understand needs a human. That direction matters:
- * a matcher that fails closed turns an unparsed command into a
- * question, while one that fails open turns it into an unreviewed
- * mutation.
+ * The first is a table keyed by backend id, and it decides on what a
+ * backend can reach rather than on what a command says: `container`
+ * is a full Linux userland with a public network, while `shell` and
+ * `codemode` are sandboxed and see only the workspace filesystem.
+ * Nothing is parsed to apply it, so nothing about it can be fooled by
+ * a command it did not anticipate. This is the boundary.
*
- * It is worth being plain about the limit here. Pattern-matching a
- * command line is a heuristic, appropriate for an example. A
- * production gate belongs at the capability layer — hand the backend
- * a read-only view of the workspace and let the filesystem refuse the
- * write — rather than in a matcher that has to anticipate every way a
- * shell can be told to write a file. Denying by default is what makes
- * the heuristic's failure mode tolerable in the meantime.
+ * The second is `read-only`, which classifies a command by reading
+ * it. A command runs unattended only when it is *recognizably* a
+ * read; anything the matcher does not understand needs a human. That
+ * direction matters: a matcher that fails closed turns an unparsed
+ * command into a question, while one that fails open turns it into an
+ * unreviewed mutation. Because it fails closed it is free to be
+ * wrong in one direction, and `approval-policy.effects.test.ts` is
+ * what holds it to that — it runs every command this file would allow
+ * and fails if any of them wrote.
+ *
+ * Treat the second tier as a way to ask fewer questions, not as the
+ * thing standing between the model and the files. Reading a command
+ * line to guess its effect is a heuristic, and a heuristic is the
+ * wrong place for a boundary. The boundary belongs at the capability
+ * layer: hand the backend a read-only view of the workspace and let
+ * the filesystem refuse the write. Think's built-in Bash tool already
+ * works that way, protecting files it did not mount during write-back
+ * so a script cannot delete what it was never given. Doing the same
+ * here would cover every caller rather than only the model's path.
*
* `decideApproval` must stay a pure function of the command and the
* backend. The AI SDK re-runs it when a paused turn resumes, and an
diff --git a/examples/codemode/src/index.ts b/examples/codemode/src/index.ts
index e40611ce..15ef61c2 100644
--- a/examples/codemode/src/index.ts
+++ b/examples/codemode/src/index.ts
@@ -286,22 +286,13 @@ async function handleFile(
// Run one command with no approval check.
//
-// The gate lives in the exec *tool*, so it sits on the model's path
-// and not on this one. The reasoning is that approval exists to put a
-// human in front of a command a model chose, and a caller posting here
-// is already that human: asking them to confirm what they just typed
-// buys nothing.
-//
-// The consequence is worth stating plainly rather than leaving to be
-// discovered. Two callers reach ws.shell.exec() and only one of them
-// is checked, so this route will run `rm -rf /workspace` on request.
-// That is a property of where enforcement currently sits, not a
-// decision that this route should be privileged, and it is the part of
-// the design most likely to change: moving enforcement below
-// ws.shell.exec() — handing the backend a read-only view of the
-// workspace and letting the filesystem refuse the write — would cover
-// both callers and leave the matcher as an optimisation rather than
-// the thing standing between a model and the files.
+// Approval and authorization are different questions. The gate lives
+// in the exec tool because approval asks whether a person has seen a
+// command the *model* chose; whoever posts here is that person, and
+// asking them to confirm what they just typed answers nothing. What
+// this route does not have is an authorization check, so it will run
+// anything the caller sends. See approval-policy.ts for why the
+// boundary belongs below ws.shell.exec() rather than on either path.
async function handleExec(request: Request, env: Env, name: string): Promise {
if (request.method !== "POST") {
return new Response("method not allowed", { status: 405, headers: { allow: "POST" } });